marver 0.0.19

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! Talking to a task's live agent.
//!
//! Three screens now put text in front of an agent — rejecting a diff, resuming
//! a paused task, sending it a todo — and each of them has to get the same two
//! details right:
//!
//! - **Target a pane, not a session.** `send-keys` takes a pane, and the `=`
//!   exact-match prefix that a session target needs is rejected there. Without
//!   the prefix, `marver-1` also matches `marver-12`.
//! - **Text and submission are separate sends.** `send-keys -l` is what stops
//!   tmux reading the text as key names, and it would swallow an appended
//!   `Enter` as five characters.
//!
//! Getting either wrong types something into the wrong agent, or types nothing
//! and reports success. One implementation, used by all three.

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}: no live session named {session}")]
    NoSession { task: i64, session: String },
    #[error("session {session} is not working in task {task}'s workspace")]
    NotOurs { task: i64, session: String },
    #[error("text with control characters cannot be sent to an agent")]
    ControlCharacter,
}

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

/// The session a task's agent runs in, whether or not it exists.
pub fn session_of(task: &Task) -> String {
    task.session_name
        .clone()
        .unwrap_or_else(|| tmux::session_name(task.id))
}

/// Whether `session` is this task's, rather than a namesake.
///
/// Sessions are named `marver-<id>` and one tmux server serves the whole
/// machine, so a second data directory holding its own task 1 names the same
/// session as this one. Sending by name alone therefore types into a stranger's
/// agent — a worse outcome than any error, because it is invisible and the text
/// is already in someone else's prompt.
///
/// The working directory tells them apart: a task's session is opened in its own
/// workspace. Canonicalised on both sides because macOS answers with
/// `/private/var` where the path says `/var`. Anything unreadable counts as not
/// ours, since a check that cannot tell must not be the thing that decides.
pub fn owns_session(tmux: &Tmux, task: &Task, session: &str) -> bool {
    let Ok(cwd) = tmux.session_cwd(session) else {
        return false;
    };
    let (Ok(cwd), Ok(workspace)) = (
        std::fs::canonicalize(&cwd),
        std::fs::canonicalize(&task.workspace_dir),
    ) else {
        return false;
    };
    cwd.starts_with(workspace)
}

/// The first pane of a task's live session.
///
/// `session_exists` rather than `has_session`: the latter reads a tmux that
/// cannot be run at all as "no such session", which would report a live agent
/// as gone and let a caller act as though the work had stopped.
pub fn pane(tmux: &Tmux, task: &Task) -> Result<String> {
    let session = session_of(task);
    if !tmux.session_exists(&session)? {
        return Err(Error::NoSession {
            task: task.id,
            session,
        });
    }
    // Checked here rather than at each call site, because this is the only door
    // to a session and every caller is about to put words in front of an agent.
    if !owns_session(tmux, task, &session) {
        return Err(Error::NotOurs {
            task: task.id,
            session,
        });
    }
    tmux.list_panes(&session)?
        .into_iter()
        .next()
        .ok_or(Error::NoSession {
            task: task.id,
            session,
        })
}

/// Type `text` at the agent and submit it.
///
/// Control characters are refused. `send-keys -l` stops *tmux* interpreting
/// them, but the agent still receives the bytes: a `\r` would submit half a
/// sentence, and a `\x03` would interrupt the very turn being asked for.
pub fn say(tmux: &Tmux, task: &Task, text: &str) -> Result<()> {
    if text.chars().any(char::is_control) {
        return Err(Error::ControlCharacter);
    }
    let pane = pane(tmux, task)?;
    tmux.send_keys(&pane, text)?;
    tmux.send_key(&pane, "Enter")?;
    Ok(())
}

/// Send a single named key, such as `Escape`.
///
/// A keypress, not text: `send-keys -l` would type `E-s-c-a-p-e`.
pub fn press(tmux: &Tmux, task: &Task, key: &str) -> Result<()> {
    let pane = pane(tmux, task)?;
    tmux.send_key(&pane, key)?;
    Ok(())
}

#[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, with `session_name` recorded.
    fn task_in(dir: &Path, store: &mut Store) -> Task {
        let workspace_root = dir.join("tasks");
        let task = store
            .create_task("a task", "do it", &workspace_root, &[], Utc::now())
            .unwrap();
        std::fs::create_dir_all(&task.workspace_dir).unwrap();
        store
            .set_session_name(task.id, &tmux::session_name(task.id), Utc::now())
            .unwrap();
        store.get_task(task.id).unwrap()
    }

    #[test]
    fn a_session_sitting_somewhere_else_is_not_this_tasks() {
        // Sessions are named from the task id alone, so another marver's task 1
        // claims the same name. Typing into it would put this user's words in
        // front of someone else's agent -- silently, and unrecallably.
        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();
        server
            .tmux
            .new_session(&tmux::session_name(task.id), &elsewhere, (80, 24))
            .unwrap();

        assert!(!owns_session(
            &server.tmux,
            &task,
            &tmux::session_name(task.id)
        ));
        assert!(
            matches!(
                say(&server.tmux, &task, "hello"),
                Err(Error::NotOurs { .. })
            ),
            "a namesake session must be refused, not typed into"
        );
    }

    #[test]
    fn a_session_in_the_tasks_own_workspace_is_reachable() {
        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);

        server
            .tmux
            .new_session(&tmux::session_name(task.id), &task.workspace_dir, (80, 24))
            .unwrap();

        assert!(owns_session(
            &server.tmux,
            &task,
            &tmux::session_name(task.id)
        ));
        say(&server.tmux, &task, "hello").expect("its own session");
    }

    #[test]
    fn control_characters_never_reach_an_agent() {
        // `send-keys -l` stops tmux interpreting them, but the agent still gets
        // the bytes: a `\r` submits half a sentence and a `\x03` interrupts the
        // very turn being asked for.
        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);
        server
            .tmux
            .new_session(&tmux::session_name(task.id), &task.workspace_dir, (80, 24))
            .unwrap();

        for text in ["stop\rnow", "oops\x03", "two\nlines"] {
            assert!(
                matches!(say(&server.tmux, &task, text), Err(Error::ControlCharacter)),
                "{text:?} should be refused"
            );
        }
    }

    #[test]
    fn a_task_with_no_session_at_all_is_an_error_not_a_silent_success() {
        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);

        assert!(matches!(
            say(&server.tmux, &task, "hello"),
            Err(Error::NoSession { .. })
        ));
    }
}