omh 0.3.1

Launch any coding harness, in a sandbox, with your setup already there.
//! Session persistence.
//!
//! A long-lived sandbox is not a long-lived *session*. `exec`ing a harness ties
//! its lifetime to your terminal: close the lid and the agent is hung up on,
//! mid-task, while the container keeps running around the corpse.
//!
//! `dtach` fixes that in about a thousand lines. It is deliberately not tmux:
//! omh needs detach/reattach, not multiplexing — SSH already provides the
//! second — and tmux's prefix key, nesting behaviour, and extra translation
//! layer all land squarely on the harness TUIs we care most about not breaking.
//!
//! Some harnesses ship their own resume. Relying on that would be exactly the
//! per-harness behaviour omh exists to abstract away.

use anyhow::Result;
use std::path::PathBuf;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    /// Reattach if a session is already running, otherwise start one.
    Dtach,
    /// Bind the harness to this terminal's lifetime.
    None,
}

impl Mode {
    pub const NAMES: [&'static str; 2] = ["dtach", "none"];
}

impl std::str::FromStr for Mode {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self> {
        match s {
            "dtach" => Ok(Self::Dtach),
            "none" => Ok(Self::None),
            other => anyhow::bail!(
                "unknown persistence mode `{other}` — expected one of: {}",
                Mode::NAMES.join(", ")
            ),
        }
    }
}

impl std::fmt::Display for Mode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::Dtach => "dtach",
            Self::None => "none",
        })
    }
}

/// Socket path *inside* the sandbox. Kept under `/omh` so it never collides
/// with the workspace, and a pure function of its inputs so a second
/// `omh <harness>` reattaches instead of starting a second agent.
pub fn socket(session: &str, harness: &str) -> PathBuf {
    PathBuf::from(format!("{SOCKET_DIR}/{session}-{harness}"))
}

/// Where the sockets live inside the sandbox. Named once, because the launcher
/// also reads this directory to find out what is still running before it
/// replaces a container.
pub const SOCKET_DIR: &str = "/omh/sock";

/// Wrap the harness invocation so it survives losing the terminal.
pub fn wrap(mode: Mode, session: &str, harness: &str, argv: Vec<String>) -> Vec<String> {
    match mode {
        Mode::None => argv,
        Mode::Dtach => {
            let mut out = vec![
                "dtach".to_string(),
                "-A".to_string(),
                socket(session, harness).to_string_lossy().into_owned(),
            ];
            out.extend(argv);
            out
        }
    }
}

/// Which harnesses are still live in a session, read from a listing of the
/// socket directory inside the container.
///
/// A socket exists only while its `dtach` master does — verified by hand: dtach
/// removes it when the wrapped program exits.
///
/// `pgrep` looks like the more direct check, and was unusable when this was
/// written: PID 1 was `sleep infinity`, which reaps nothing, so an exited dtach
/// lingered as `[dtach] <defunct>` and the check could never answer no. The
/// session runs under a real init now and that trap is gone, but the socket is
/// still the better signal — it *is* the harness name, where the process table
/// needs one parsed out of a command line, and a session started by an older omh
/// still carries the zombies.
pub fn live(session: &str, listing: &str) -> Vec<String> {
    let prefix = format!("{session}-");
    let mut out: Vec<String> = listing
        .lines()
        .map(str::trim)
        .filter_map(|name| name.strip_prefix(&prefix))
        .filter(|harness| !harness.is_empty())
        .map(str::to_string)
        .collect();
    out.sort();
    out.dedup();
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::str::FromStr;

    fn argv() -> Vec<String> {
        vec!["claude".into()]
    }

    #[test]
    fn dtach_attaches_or_creates_rather_than_always_starting_fresh() {
        let out = wrap(Mode::Dtach, "s01", "claude", argv());
        assert_eq!(out[0], "dtach");
        assert!(
            out.contains(&"-A".to_string()),
            "-A is what makes a second `omh claude` reattach instead of starting a second agent: {out:?}"
        );
        assert_eq!(out.last().unwrap(), "claude");
    }

    #[test]
    fn none_leaves_the_command_untouched() {
        assert_eq!(wrap(Mode::None, "s01", "claude", argv()), argv());
    }

    #[test]
    fn harness_arguments_survive_wrapping() {
        let out = wrap(
            Mode::Dtach,
            "s01",
            "claude",
            vec!["claude".into(), "--resume".into(), "abc".into()],
        );
        let tail: Vec<_> = out.iter().rev().take(3).rev().cloned().collect();
        assert_eq!(tail, ["claude", "--resume", "abc"]);
    }

    /// Reattaching is only possible if the socket is a pure function of the
    /// session and harness — a path with anything variable in it would silently
    /// start a second agent every time.
    #[test]
    fn the_socket_is_deterministic() {
        assert_eq!(socket("s01", "claude"), socket("s01", "claude"));
    }

    #[test]
    fn sockets_are_per_session_and_per_harness() {
        assert_ne!(socket("s01", "claude"), socket("s02", "claude"));
        assert_ne!(socket("s01", "claude"), socket("s01", "opencode"));
    }

    #[test]
    fn the_socket_lives_inside_the_sandbox() {
        let s = socket("s01", "claude");
        assert!(s.is_absolute(), "guest path: {s:?}");
        assert!(
            s.starts_with("/omh"),
            "must not collide with the workspace: {s:?}"
        );
    }

    #[test]
    fn modes_round_trip_through_their_names() {
        for mode in [Mode::Dtach, Mode::None] {
            assert_eq!(Mode::from_str(&mode.to_string()).unwrap(), mode);
        }
    }

    #[test]
    fn an_unknown_mode_lists_the_real_ones() {
        let err = Mode::from_str("tmux").unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("dtach") && msg.contains("none"), "got: {msg}");
    }

    /// Restarting a session kills whatever is running in it, so the check that
    /// decides whether to restart has to be able to say "something is".
    #[test]
    fn a_live_socket_names_the_harness_holding_the_session() {
        assert_eq!(live("s01", "s01-claude\n"), vec!["claude"]);
    }

    #[test]
    fn an_empty_socket_directory_means_nothing_is_running() {
        assert!(live("s01", "").is_empty());
        assert!(live("s01", "\n  \n").is_empty());
    }

    /// The directory is per-container and therefore per-session already, but
    /// the prefix is what the name means — matching on it rather than taking
    /// every entry keeps this the exact inverse of `socket`.
    #[test]
    fn another_sessions_socket_is_not_this_sessions_business() {
        assert!(live("s01", "s02-claude\n").is_empty());
        assert_eq!(live("s01", "s02-claude\ns01-opencode\n"), vec!["opencode"]);
    }

    /// `s1` is a prefix of `s10`, and a check that answered "s10 is busy"
    /// because `s1` is running would refuse a restart nobody needs to refuse.
    #[test]
    fn a_session_id_that_prefixes_another_is_not_confused_with_it() {
        assert!(live("s1", "s10-claude\n").is_empty());
    }
}