marver 0.0.28

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.
//!
//! Used by rejecting a diff, resuming a paused task, and sending a todo. Two
//! rules:
//!
//! - Target a pane, not a session. `send-keys` takes a pane, and without the
//!   `=` prefix `marver-1` also matches `marver-12`.
//! - Send the text and the submission separately. `send-keys -l` would take an
//!   appended `Enter` as five characters.

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>;

/// Whether `session` is this task's, rather than a namesake.
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.
pub fn pane(tmux: &Tmux, task: &Task) -> Result<String> {
    let Some(session) = task.session_name.clone() else {
        return Err(Error::NoSession {
            task: task.id,
            session: "none recorded".to_string(),
        });
    };
    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.
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`.
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(None, task.id), Utc::now())
            .unwrap();
        store.get_task(task.id).unwrap()
    }

    #[test]
    fn a_session_sitting_somewhere_else_is_not_this_tasks() {
        // A session someone else opened under this name — the prefix keeps two
        // marvers apart, but nothing stops a person naming a session 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();
        server
            .tmux
            .new_session(&tmux::session_name(None, task.id), &elsewhere, (80, 24))
            .unwrap();

        assert!(!owns_session(
            &server.tmux,
            &task,
            &tmux::session_name(None, 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(None, task.id),
                &task.workspace_dir,
                (80, 24),
            )
            .unwrap();

        assert!(owns_session(
            &server.tmux,
            &task,
            &tmux::session_name(None, 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(None, 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 { .. })
        ));
    }
}