marver 0.0.29

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! A shell of the user's own, in a task's workspace.
//!
//! A task has one session and an agent is sitting in it, so every key marver
//! forwards there is addressed to the agent. Running the tests, reading a file,
//! or fixing one line by hand meant asking Claude to do it — or leaving marver
//! for another terminal and finding the worktree again by hand.
//!
//! This opens a second tmux session in the same directory, running the user's
//! shell. A session and not a second window in the agent's, because
//! [`crate::agent::pane`] takes the first pane `list-panes` reports and
//! [`crate::agent::owns_session`] reads the first pane's directory: a window
//! whose pane sorted first would take both, so rejecting a diff would type its
//! instructions at a shell prompt, and one `cd` out of the workspace would make
//! the task's own session stop looking like the task's.
//!
//! The session outlives the screen showing it, so leaving and coming back finds
//! the same scrollback and the same half-typed command. What ends it is the
//! workspace going: [`crate::launcher::Launcher::shut_down`] and
//! [`crate::archive::archive`] both close it, and they are the two doors every
//! path to a removed workspace goes through.

use std::ffi::OsString;
use std::path::PathBuf;

use crate::domain::Task;
use crate::tmux::{self, Tmux};

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("tmux: {0}")]
    Tmux(#[from] tmux::Error),
    #[error("task {task} has nothing at {} to open a shell in", .workspace.display())]
    NoWorkspace { task: i64, workspace: PathBuf },
    #[error("session {session} is not working in task {task}'s workspace")]
    NotOurs { task: i64, session: String },
}

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

/// The shell to run.
///
/// `$SHELL`, because tmux's own `default-shell` is no help: it is a server-wide
/// option taken from the environment of whoever started the server, and marver
/// starts it from a daemon that may have been launched by launchd or systemd
/// with no `SHELL` at all. `/bin/sh` is the fallback because it is the one path
/// a unix is entitled to assume.
pub fn program() -> OsString {
    match std::env::var_os("SHELL") {
        Some(shell) if !shell.is_empty() => shell,
        _ => OsString::from("/bin/sh"),
    }
}

/// Open a task's shell, or find the one it already has.
pub fn open(tmux: &Tmux, task: &Task, prefix: Option<&str>, size: (u16, u16)) -> Result<String> {
    let session = tmux::shell_session_name(prefix, task.id);
    if tmux.session_exists(&session)? {
        // Reattached rather than replaced. The scrollback and the half-typed
        // command are most of the reason to come back to it.
        if !is_ours(tmux, task, &session) {
            return Err(Error::NotOurs {
                task: task.id,
                session,
            });
        }
        return Ok(session);
    }

    // Asked of the disk rather than of the task's state. A queued task has no
    // workspace yet and a reclaimed one no longer has, and tmux answers both by
    // refusing to start — leaving nothing to attach to and nothing on screen to
    // say why.
    if !task.workspace_dir.is_dir() {
        return Err(Error::NoWorkspace {
            task: task.id,
            workspace: task.workspace_dir.clone(),
        });
    }
    tmux.new_session_running(&session, &task.workspace_dir, size, &[program()])?;
    Ok(session)
}

/// Close a task's shell, if it has one and it is really the task's.
///
/// Called before a workspace is taken away, never after: ownership is decided
/// by comparing directories, and once the workspace is gone the answer is
/// permanently no — which is how a session outlives everything that could have
/// killed it.
pub fn close(tmux: &Tmux, task: &Task, prefix: Option<&str>) -> Result<()> {
    let session = tmux::shell_session_name(prefix, task.id);
    // Asked, not assumed. A tmux that cannot be reached must not read as a
    // shell already gone: the caller is about to remove the worktrees that
    // shell may be sitting in.
    if !tmux.session_exists(&session)? {
        return Ok(());
    }
    if !is_ours(tmux, task, &session) {
        return Ok(());
    }
    match tmux.kill_session(&session) {
        // A shell somebody had already typed `exit` at is the outcome this
        // wanted.
        Ok(()) | Err(tmux::Error::NoSuchSession(_)) => Ok(()),
        Err(err) => Err(err.into()),
    }
}

/// Whether `session` is a shell marver opened for this task.
///
/// The same question [`crate::agent::owns_session`] asks of an agent — a tmux
/// server is machine-wide, so a name is not proof of ownership — but answered
/// from where the session was opened rather than where its pane is now. A shell
/// is there to be `cd`-ed around in, and one that had wandered out of the
/// workspace would stop being provably the task's, so nothing would ever kill
/// it again.
fn is_ours(tmux: &Tmux, task: &Task, session: &str) -> bool {
    let Ok(Some(opened)) = tmux.session_start_path(session) else {
        return false;
    };
    let (Ok(opened), Ok(workspace)) = (
        std::fs::canonicalize(&opened),
        std::fs::canonicalize(&task.workspace_dir),
    ) else {
        return false;
    };
    opened.starts_with(workspace)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::store::Store;
    use crate::tmux::testing::TestServer;
    use chrono::Utc;
    use std::path::Path;
    use tempfile::TempDir;

    /// A task whose workspace exists on disk.
    fn task_in(dir: &Path, store: &mut Store) -> Task {
        let task = store
            .create_task("a task", "do it", &dir.join("tasks"), &[], Utc::now())
            .unwrap();
        std::fs::create_dir_all(&task.workspace_dir).unwrap();
        task
    }

    /// Poll a pane until it shows `needle`, so tests do not race the shell.
    fn wait_for_pane(server: &TestServer, session: &str, needle: &str) -> String {
        let mut seen = String::new();
        for _ in 0..80 {
            if let Ok(panes) = server.tmux.list_panes(session)
                && let Some(pane) = panes.first()
                && let Ok(text) = server.tmux.capture_pane(pane)
            {
                seen = text;
                if seen.contains(needle) {
                    return seen;
                }
            }
            std::thread::sleep(std::time::Duration::from_millis(50));
        }
        seen
    }

    #[test]
    fn a_shell_starts_in_the_tasks_workspace_and_can_be_typed_at() {
        let dir = TempDir::new().unwrap();
        let server = TestServer::new();
        let mut store = Store::open_in_memory().unwrap();
        let task = task_in(dir.path(), &mut store);

        let session = open(&server.tmux, &task, None, (80, 24)).unwrap();

        assert!(server.tmux.has_session(&session));
        assert_eq!(
            std::fs::canonicalize(server.tmux.session_cwd(&session).unwrap()).unwrap(),
            std::fs::canonicalize(&task.workspace_dir).unwrap()
        );

        let pane = server.tmux.list_panes(&session).unwrap().remove(0);
        server.tmux.send_keys(&pane, "printf MARVERSHELL").unwrap();
        server.tmux.send_key(&pane, "Enter").unwrap();
        let seen = wait_for_pane(&server, &session, "MARVERSHELL");
        assert!(seen.contains("MARVERSHELL"), "captured: {seen:?}");
    }

    #[test]
    fn opening_a_shell_twice_comes_back_to_the_one_already_there() {
        // The scrollback and whatever is half-typed in it are the reason to
        // press the key again at all.
        let dir = TempDir::new().unwrap();
        let server = TestServer::new();
        let mut store = Store::open_in_memory().unwrap();
        let task = task_in(dir.path(), &mut store);

        let first = open(&server.tmux, &task, None, (80, 24)).unwrap();
        let pane = server.tmux.list_panes(&first).unwrap().remove(0);
        server.tmux.send_keys(&pane, "printf MARVERAGAIN").unwrap();
        server.tmux.send_key(&pane, "Enter").unwrap();
        wait_for_pane(&server, &first, "MARVERAGAIN");

        let again = open(&server.tmux, &task, None, (80, 24)).unwrap();

        assert_eq!(first, again);
        assert!(
            server
                .tmux
                .capture_pane(&pane)
                .unwrap()
                .contains("MARVERAGAIN"),
            "the same session, with what was in it"
        );
    }

    #[test]
    fn a_task_with_no_workspace_yet_is_told_so_rather_than_left_with_nothing() {
        // A queued task. tmux would refuse to start in a directory that is not
        // there, leaving a screen attached to nothing and no reason on it.
        let dir = TempDir::new().unwrap();
        let server = TestServer::new();
        let mut store = Store::open_in_memory().unwrap();
        let task = store
            .create_task(
                "waiting",
                "do it",
                &dir.path().join("tasks"),
                &[],
                Utc::now(),
            )
            .unwrap();

        assert!(matches!(
            open(&server.tmux, &task, None, (80, 24)),
            Err(Error::NoWorkspace { .. })
        ));
        assert!(
            !server
                .tmux
                .has_session(&tmux::shell_session_name(None, task.id))
        );
    }

    #[test]
    fn a_workspace_that_has_been_reclaimed_is_the_same_answer() {
        let dir = TempDir::new().unwrap();
        let server = TestServer::new();
        let mut store = Store::open_in_memory().unwrap();
        let task = task_in(dir.path(), &mut store);
        std::fs::remove_dir_all(&task.workspace_dir).unwrap();

        assert!(matches!(
            open(&server.tmux, &task, None, (80, 24)),
            Err(Error::NoWorkspace { .. })
        ));
    }

    #[test]
    fn a_namesake_shell_is_refused_rather_than_adopted() {
        // One tmux server serves the machine, so nothing stops a person opening
        // a session under this name by hand.
        let dir = TempDir::new().unwrap();
        let server = TestServer::new();
        let mut store = Store::open_in_memory().unwrap();
        let task = task_in(dir.path(), &mut store);

        let elsewhere = dir.path().join("not-the-workspace");
        std::fs::create_dir_all(&elsewhere).unwrap();
        let name = tmux::shell_session_name(None, task.id);
        server
            .tmux
            .new_session(&name, &elsewhere, (80, 24))
            .unwrap();

        assert!(matches!(
            open(&server.tmux, &task, None, (80, 24)),
            Err(Error::NotOurs { .. })
        ));
        close(&server.tmux, &task, None).unwrap();
        assert!(
            server.tmux.has_session(&name),
            "and a session marver did not open must survive being closed over"
        );
    }

    #[test]
    fn a_shell_that_has_been_cd_ed_out_of_the_workspace_is_still_closed() {
        // `owns_session` reads the pane's current directory, which is the one
        // thing a person in a shell is certain to change. Deciding ownership
        // that way left the session running with nothing left that could prove
        // it was marver's, and so nothing that would ever kill it.
        let dir = TempDir::new().unwrap();
        let server = TestServer::new();
        let mut store = Store::open_in_memory().unwrap();
        let task = task_in(dir.path(), &mut store);

        let session = open(&server.tmux, &task, None, (80, 24)).unwrap();
        let pane = server.tmux.list_panes(&session).unwrap().remove(0);
        // Retried rather than sent once: a pane whose shell has not finished
        // starting reports no directory at all and swallows what is typed at
        // it, and `cd /` costs nothing to repeat.
        let mut cwd = String::new();
        for _ in 0..80 {
            server.tmux.send_keys(&pane, "cd /").unwrap();
            server.tmux.send_key(&pane, "Enter").unwrap();
            std::thread::sleep(std::time::Duration::from_millis(50));
            if let Ok(seen) = server.tmux.session_cwd(&session) {
                cwd = seen;
                if cwd == "/" {
                    break;
                }
            }
        }
        assert_eq!(cwd, "/", "the shell must really have moved");
        assert!(
            !crate::agent::owns_session(&server.tmux, &task, &session),
            "the agent's test would already have given up on it"
        );

        close(&server.tmux, &task, None).unwrap();

        assert!(!server.tmux.has_session(&session));
    }

    #[test]
    fn closing_a_task_that_never_opened_a_shell_is_harmless() {
        let dir = TempDir::new().unwrap();
        let server = TestServer::new();
        let mut store = Store::open_in_memory().unwrap();
        let task = task_in(dir.path(), &mut store);

        close(&server.tmux, &task, None).unwrap();
    }

    #[test]
    fn the_shell_is_the_users_own_with_a_fallback_every_unix_has() {
        // SAFETY: single-threaded test, and the variable is put back.
        let was = std::env::var_os("SHELL");

        unsafe { std::env::set_var("SHELL", "/bin/zsh") };
        assert_eq!(program(), OsString::from("/bin/zsh"));

        // Set but empty is not an answer, and neither is unset — a daemon
        // started by a service manager has neither.
        unsafe { std::env::set_var("SHELL", "") };
        assert_eq!(program(), OsString::from("/bin/sh"));
        unsafe { std::env::remove_var("SHELL") };
        assert_eq!(program(), OsString::from("/bin/sh"));

        match was {
            Some(value) => unsafe { std::env::set_var("SHELL", value) },
            None => unsafe { std::env::remove_var("SHELL") },
        }
    }
}