marver 0.0.27

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Which agent runs a task, and how it tells marver what it is doing.
//!
//! Starting an agent is much the same everywhere: a program, some arguments, a
//! prompt. Being told what it did is where harnesses differ, and it is what the
//! state machine runs on — without it a task sits in `running` behind an agent
//! that finished an hour ago, holding a slot.
//!
//! | Harness | How it reports | What marver learns |
//! |---|---|---|
//! | `claude` | a settings file of hooks | started, finished, blocked, and why |
//! | `codex` | the `notify` program, one JSON argument | finished |
//! | anything else | nothing | nothing |
//!
//! Capabilities, not tiers: codex has one event, so a codex task never enters
//! `blocked` and marver says so rather than pretending. `f` on the task list is
//! how a person supplies what a harness cannot.
//!
//! Neither has its config edited — Claude Code takes `--settings`, codex takes
//! `--config notify=[…]` — so reporting is wired on each task's own command
//! line. Editing a global config means being trusted to undo it, and a crash in
//! between leaves it notifying a daemon about tasks that no longer exist.

use std::ffi::OsString;
use std::path::{Path, PathBuf};

use crate::hook;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("could not write the agent's settings: {0}")]
    Settings(#[from] hook::Error),
    #[error("no harness called {0}; known: {known}", known = known_names())]
    Unknown(String),
    #[error("{0} is not a command marver can read: {1}")]
    BadSpec(String, String),
}

pub type Result<T> = std::result::Result<T, Error>;

/// How a harness tells marver what its agent is doing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Report {
    /// Claude Code's hooks: a generated settings file passed with
    /// `--settings`, each event delivered to `marver hook` on stdin and
    /// forwarded over a unix socket. The only one that reports being blocked,
    /// because it is the only one that has an event for it.
    ClaudeHooks,
    /// Codex's `notify`: an argv set with `--config`, run once per finished
    /// turn with a single JSON argument. Fire-and-forget by design — codex
    /// does not wait for it — which suits a hook that must never slow an agent
    /// down.
    CodexNotify,
    /// Nothing at all. The agent runs in its session and marver watches the
    /// screen like anyone else would.
    Silent,
}

impl Report {
    /// What this harness can and cannot say, for the interface to pass on.
    pub fn describe(self) -> &'static str {
        match self {
            Self::ClaudeHooks => "reports finishing, blocking, and why",
            Self::CodexNotify => "reports finishing a turn; never blocks",
            Self::Silent => "reports nothing — f marks a task finished",
        }
    }

    /// Whether a task under this harness can reach `awaiting-review` on its
    /// own.
    pub fn finishes_by_itself(self) -> bool {
        !matches!(self, Self::Silent)
    }
}

/// An agent marver knows how to start.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Harness {
    pub name: String,
    pub program: String,
    /// Arguments placed before anything marver adds, and before the prompt.
    pub args: Vec<String>,
    pub report: Report,
}

/// A harness marver ships knowing about.
pub struct Known {
    /// What the user types after `--harness`.
    pub name: &'static str,
    /// What to run.
    pub program: &'static str,
    pub report: Report,
}

/// Every harness marver knows by name. Adding one is a row here.
pub const KNOWN: &[Known] = &[
    Known {
        name: "claude",
        program: "claude",
        report: Report::ClaudeHooks,
    },
    Known {
        name: "codex",
        program: "codex",
        report: Report::CodexNotify,
    },
];

/// The names in [`KNOWN`], for error messages that cannot go stale.
fn known_names() -> String {
    KNOWN
        .iter()
        .map(|known| known.name)
        .collect::<Vec<_>>()
        .join(", ")
}

impl Harness {
    /// The harness named in [`KNOWN`], which is the only way these are built.
    fn known(name: &str) -> Option<Self> {
        KNOWN
            .iter()
            .find(|known| known.name == name)
            .map(|known| Self {
                name: known.name.into(),
                program: known.program.into(),
                args: Vec::new(),
                report: known.report,
            })
    }

    pub fn claude() -> Self {
        Self::known("claude").expect("claude is in KNOWN")
    }

    pub fn codex() -> Self {
        Self::known("codex").expect("codex is in KNOWN")
    }

    /// Any other program, started and then watched rather than heard from.
    pub fn silent(name: &str, program: &str, args: Vec<String>) -> Self {
        Self {
            name: name.into(),
            program: program.into(),
            args,
            report: Report::Silent,
        }
    }

    /// The `--harness` value that would produce this one again, quoted the way
    /// a shell would so that [`Self::parse`] gets the same arguments back.
    pub fn spec(&self) -> String {
        match self.report {
            Report::Silent => {
                let mut words = vec![self.program.clone()];
                words.extend(self.args.iter().cloned());
                format!("{}:{}", self.name, shell_words::join(words))
            }
            _ => self.name.clone(),
        }
    }

    /// Look a harness up by the name a user typed.
    pub fn parse(spec: &str) -> Result<Self> {
        if let Some((name, command)) = spec.split_once(':') {
            let words = shell_words::split(command)
                .map_err(|err| Error::BadSpec(spec.into(), err.to_string()))?;
            let Some((program, args)) = words.split_first() else {
                return Err(Error::Unknown(spec.into()));
            };
            return Ok(Self::silent(name, program, args.to_vec()));
        }
        Self::known(spec).ok_or_else(|| Error::Unknown(spec.into()))
    }

    /// Everything needed to start this agent for a task.
    pub fn prepare(
        &self,
        dir: &Path,
        task_id: i64,
        marver_bin: &Path,
        socket: &Path,
        prompt: &str,
    ) -> Result<Start> {
        let mut argv: Vec<OsString> = vec![self.program.clone().into()];
        argv.extend(self.args.iter().map(OsString::from));
        let mut settings = None;

        match self.report {
            Report::ClaudeHooks => {
                let path = hook::write_settings(dir, task_id, marver_bin, socket)?;
                argv.push("--settings".into());
                argv.push(path.as_os_str().to_owned());
                settings = Some(path);
            }
            Report::CodexNotify => {
                // A TOML value on the command line, so the user's own config
                // is never touched.
                argv.push("--config".into());
                argv.push(
                    format!(
                        "notify={}",
                        toml_argv(&[
                            marver_bin.to_string_lossy().as_ref(),
                            "hook",
                            "--task",
                            &task_id.to_string(),
                            "--socket",
                            socket.to_string_lossy().as_ref(),
                            "--from",
                            "codex",
                        ])
                    )
                    .into(),
                );
            }
            Report::Silent => {}
        }

        // Last, always: a prompt is the one argument that must not be read as
        // a flag, and every harness here takes it as the final positional.
        argv.push(prompt.into());
        Ok(Start { argv, settings })
    }
}

/// What a launch needs, once the harness has been asked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Start {
    pub argv: Vec<OsString>,
    /// The settings file written for this task, for harnesses that use one.
    pub settings: Option<PathBuf>,
}

/// A TOML array of strings, for `--config key=value`.
fn toml_argv(words: &[&str]) -> String {
    let escaped: Vec<String> = words
        .iter()
        .map(|word| format!("\"{}\"", word.replace('\\', "\\\\").replace('"', "\\\"")))
        .collect();
    format!("[{}]", escaped.join(","))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn paths() -> (PathBuf, PathBuf) {
        ("/usr/local/bin/marver".into(), "/tmp/marverd.sock".into())
    }

    fn strings(argv: &[OsString]) -> Vec<String> {
        argv.iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect()
    }

    #[test]
    fn claude_is_given_a_settings_file_of_hooks() {
        let tmp = TempDir::new().unwrap();
        let (bin, socket) = paths();

        let start = Harness::claude()
            .prepare(tmp.path(), 7, &bin, &socket, "fix it")
            .unwrap();

        let argv = strings(&start.argv);
        assert_eq!(argv[0], "claude");
        assert_eq!(argv[1], "--settings");
        assert_eq!(argv.last().unwrap(), "fix it", "the prompt goes last");
        let written = std::fs::read_to_string(start.settings.unwrap()).unwrap();
        assert!(written.contains("\"Stop\""), "{written}");
        assert!(written.contains("--task"), "{written}");
    }

    #[test]
    fn codex_is_given_a_notify_override_rather_than_a_config_file() {
        // Editing ~/.codex/config.toml would mean being trusted to undo it,
        // and a crash in between leaves a config notifying a dead daemon.
        let tmp = TempDir::new().unwrap();
        let (bin, socket) = paths();

        let start = Harness::codex()
            .prepare(tmp.path(), 7, &bin, &socket, "fix it")
            .unwrap();

        let argv = strings(&start.argv);
        assert_eq!(argv[0], "codex");
        assert_eq!(argv[1], "--config");
        assert_eq!(
            argv[2],
            "notify=[\"/usr/local/bin/marver\",\"hook\",\"--task\",\"7\",\"--socket\",\"/tmp/marverd.sock\",\"--from\",\"codex\"]"
        );
        assert_eq!(argv.last().unwrap(), "fix it");
        assert_eq!(start.settings, None, "codex needs no file");
        assert!(
            std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
            "and none was written"
        );
    }

    #[test]
    fn a_quote_in_a_path_cannot_end_the_toml_array_early() {
        let tmp = TempDir::new().unwrap();
        let start = Harness::codex()
            .prepare(
                tmp.path(),
                7,
                Path::new("/tmp/od\"d/marver"),
                Path::new("/tmp/s.sock"),
                "go",
            )
            .unwrap();

        let argv = strings(&start.argv);
        assert!(argv[2].contains(r#"\"d/marver"#), "{}", argv[2]);
        assert!(argv[2].ends_with("\"codex\"]"), "{}", argv[2]);
    }

    #[test]
    fn a_silent_harness_is_started_and_then_only_watched() {
        let tmp = TempDir::new().unwrap();
        let (bin, socket) = paths();
        let harness = Harness::silent("opencode", "opencode", vec!["run".into()]);

        let start = harness
            .prepare(tmp.path(), 7, &bin, &socket, "fix it")
            .unwrap();

        assert_eq!(strings(&start.argv), ["opencode", "run", "fix it"]);
        assert_eq!(start.settings, None);
        assert!(!harness.report.finishes_by_itself());
        assert!(harness.report.describe().contains("f marks"));
    }

    #[test]
    fn an_unknown_harness_can_be_described_on_the_spot() {
        let harness = Harness::parse("opencode:opencode --agent build").unwrap();

        assert_eq!(harness.name, "opencode");
        assert_eq!(harness.program, "opencode");
        assert_eq!(harness.args, ["--agent", "build"]);
        assert_eq!(harness.report, Report::Silent);
    }

    #[test]
    fn a_harness_round_trips_through_the_flag_that_named_it() {
        // An interface that was given --harness starts a daemon with it, so a
        // spec that did not survive the trip would launch the wrong agent.
        for spec in ["claude", "codex", "opencode:opencode --agent build"] {
            let harness = Harness::parse(spec).unwrap();
            assert_eq!(harness.spec(), spec);
            assert_eq!(Harness::parse(&harness.spec()).unwrap(), harness);
        }
    }

    #[test]
    fn an_argument_with_a_space_in_it_stays_one_argument() {
        // The daemon is started with `--harness <spec>` and parses it back, so
        // a spec that lost its quoting would hand the agent three arguments
        // where the user wrote two.
        let harness = Harness::silent(
            "oc",
            "opencode",
            vec!["--agent".into(), "build fast".into(), "it's odd".into()],
        );

        let back = Harness::parse(&harness.spec()).unwrap();

        assert_eq!(back, harness, "spec was {:?}", harness.spec());
    }

    #[test]
    fn a_spec_with_an_unbalanced_quote_is_refused_rather_than_guessed_at() {
        let err = Harness::parse("oc:opencode --agent \"build").unwrap_err();
        assert!(matches!(err, Error::BadSpec(..)), "{err}");
    }

    #[test]
    fn the_ones_it_knows_are_named_and_the_rest_are_refused() {
        assert_eq!(
            Harness::parse("claude").unwrap().report,
            Report::ClaudeHooks
        );
        assert_eq!(Harness::parse("codex").unwrap().report, Report::CodexNotify);

        // The list in the message comes from KNOWN, so adding a harness cannot
        // leave the error naming the old set.
        let err = Harness::parse("aider").unwrap_err();
        for known in KNOWN {
            assert!(
                err.to_string().contains(known.name),
                "{err} should name {}",
                known.name
            );
        }
    }

    #[test]
    fn only_the_one_with_an_event_for_it_claims_to_report_blocking() {
        assert!(Report::ClaudeHooks.describe().contains("blocking"));
        assert!(Report::CodexNotify.describe().contains("never blocks"));
        assert!(Report::CodexNotify.finishes_by_itself());
    }
}