marver 0.0.24

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.
//!
//! 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,
}

/// Every harness marver knows by name: what to run, and how it reports.
/// Adding one is a row here.
pub const KNOWN: &[(&str, &str, Report)] = &[
    ("claude", "claude", Report::ClaudeHooks),
    ("codex", "codex", Report::CodexNotify),
];

/// The names in [`KNOWN`], for error messages that cannot go stale.
fn known_names() -> String {
    KNOWN
        .iter()
        .map(|(name, _, _)| *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)
            .map(|(name, program, report)| Self {
                name: (*name).into(),
                program: (*program).into(),
                args: Vec::new(),
                report: *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, because an auto-started daemon is handed
    /// this string and parses it back — an argument with a space in it has to
    /// survive the trip as one argument.
    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 (name, _, _) in KNOWN {
            assert!(err.to_string().contains(name), "{err} should name {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());
    }
}