marver 0.0.20

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! Which agent runs a task, and how it tells marver what it is doing.
//!
//! marver was built around Claude Code and its shape is still the one that fits
//! best, but nothing about worktrees, a tmux session, a queue, or a diff to
//! review is specific to it. What *is* specific is the reporting channel, and
//! that is the whole of this module.
//!
//! # The two halves of running an agent
//!
//! **Starting it** is easy and much the same everywhere: a program, some
//! arguments, and the 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, over a unix socket | started, finished, blocked, and why |
//! | `codex` | the `notify` program, one JSON argument | finished |
//! | anything else | nothing | nothing |
//!
//! Each row is a real capability, not a tier: codex has exactly one event
//! (`agent-turn-complete`), so a codex task never enters `blocked` and nobody
//! should be surprised that it does not. Rather than pretend, marver says so —
//! see [`Report::describe`] — and `f` on the task list is how a person supplies
//! what the harness cannot.
//!
//! # Neither of them has its config edited
//!
//! Claude Code takes `--settings`, and codex takes `--config notify=[…]`, so
//! each task's reporting is wired **on its own command line**. marver never
//! writes to `~/.claude` or `~/.codex`: a tool that edits your global config to
//! run one task has to be trusted to undo it, and a crash in between leaves you
//! with a config that notifies 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: claude, codex")]
    Unknown(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,
}

impl Harness {
    pub fn claude() -> Self {
        Self {
            name: "claude".into(),
            program: "claude".into(),
            args: Vec::new(),
            report: Report::ClaudeHooks,
        }
    }

    pub fn codex() -> Self {
        Self {
            name: "codex".into(),
            program: "codex".into(),
            args: Vec::new(),
            report: Report::CodexNotify,
        }
    }

    /// Any other program, started and then watched rather than heard from.
    ///
    /// The escape hatch for a harness marver has never been told about —
    /// opencode, aider, a shell script. Everything except the reporting works:
    /// the queue, the worktrees, the session, the diff, the todos.
    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.
    ///
    /// Round-trips through [`Self::parse`], which is what lets an auto-started
    /// daemon inherit the harness the interface was given.
    pub fn spec(&self) -> String {
        match self.report {
            Report::Silent => {
                let mut spec = format!("{}:{}", self.name, self.program);
                for arg in &self.args {
                    spec.push(' ');
                    spec.push_str(arg);
                }
                spec
            }
            _ => self.name.clone(),
        }
    }

    /// Look a harness up by the name a user typed.
    ///
    /// `name`, or `name:program arg arg` for one marver does not know: the
    /// second form is what makes an unknown agent usable without a release.
    pub fn parse(spec: &str) -> Result<Self> {
        if let Some((name, command)) = spec.split_once(':') {
            let mut words = command.split_whitespace().map(str::to_string);
            let Some(program) = words.next() else {
                return Err(Error::Unknown(spec.into()));
            };
            return Ok(Self::silent(name, &program, words.collect()));
        }
        match spec {
            "claude" => Ok(Self::claude()),
            "codex" => Ok(Self::codex()),
            other => Err(Error::Unknown(other.into())),
        }
    }

    /// Everything needed to start this agent for a task.
    ///
    /// Writes whatever file the harness needs into `dir` — the task's workspace,
    /// which is also the session's working directory — and returns the argv.
    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. The payload arrives as a trailing argument
                // rather than on stdin, which is why the receiving end is told
                // where to look with `--from codex`.
                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`.
///
/// Hand-written because this is one value of one shape and a TOML serialiser
/// would be a dependency to produce it. Quotes and backslashes are escaped, so
/// a data directory with a quote in its name cannot end the array early and
/// turn the rest of the path into TOML nobody meant.
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 the_two_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);

        let err = Harness::parse("aider").unwrap_err();
        assert!(err.to_string().contains("claude, codex"), "{err}");
    }

    #[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());
    }
}