netsky-sh 0.1.7

Shell utilities for netsky: tmux, git, process wrapping
Documentation
//! Tmux session management.
//!
//! netsky spawn path relies on two behaviors not available in the upstream
//! `dkdc-sh` fork: (a) detached session creation with a shell command and
//! per-session env vars propagated via `tmux new-session -e`; (b) no
//! `remain-on-exit on` — our sessions run `claude` or `codex` which own the
//! pane for their lifetime, and we want the session to stay visible when the
//! process exits so watchdogs can inspect the dead pane.

use std::path::Path;
use std::process::{Command, Stdio};

use crate::{Error, require};

const TMUX: &str = "tmux";

/// Required prefix for tmux session names created by test code.
///
/// Any test that spawns a tmux session must use this prefix so it
/// cannot collide with the real constellation (`agent0`, `agent<N>`,
/// `agentinfinity`, `netsky-ticker`). See [`new_test_session_detached`]
/// and [`is_reserved_session_name`].
pub const TEST_SESSION_PREFIX: &str = "test-";

/// True when `name` is one of the real constellation's reserved tmux
/// session names: `agent<digits>`, `agentinfinity`, or `netsky-ticker`.
///
/// Test helpers reject these names to guarantee a test can never kill
/// or overwrite a live constellation pane.
pub fn is_reserved_session_name(name: &str) -> bool {
    if name == "agentinfinity" || name == "netsky-ticker" {
        return true;
    }
    matches!(
        name.strip_prefix("agent"),
        Some(rest) if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
    )
}

/// Validate a tmux session name. Permitted chars: alphanumeric, `-`, `_`.
/// Empty names are rejected.
pub fn validate_session_name(name: &str) -> Result<(), Error> {
    if name.is_empty()
        || !name
            .chars()
            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
    {
        return Err(Error::Tmux(format!("invalid session name: {name}")));
    }
    Ok(())
}

/// Check if a tmux session exists.
pub fn has_session(name: &str) -> bool {
    if validate_session_name(name).is_err() {
        return false;
    }
    Command::new(TMUX)
        .args(["has-session", "-t", name])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

/// Create a new detached tmux session that runs `cmd` in a shell, with an
/// optional working directory and a list of env vars propagated into the
/// session. Sessions keep their last pane after `cmd` exits so the
/// watchdog can see the failure state.
pub fn new_session_detached(
    name: &str,
    cmd: &str,
    cwd: Option<&Path>,
    env: &[(&str, &str)],
) -> Result<(), Error> {
    validate_session_name(name)?;
    require(TMUX)?;

    if has_session(name) {
        return Err(Error::Tmux(format!("session '{name}' already exists")));
    }

    let mut args: Vec<String> = vec!["new-session".into(), "-d".into(), "-s".into(), name.into()];
    if let Some(dir) = cwd {
        args.push("-c".into());
        args.push(dir.display().to_string());
    }
    for (k, v) in env {
        args.push("-e".into());
        args.push(format!("{k}={v}"));
    }
    args.push(cmd.into());

    let output = Command::new(TMUX).args(&args).output()?;

    if !output.status.success() {
        // Surface tmux's own stderr so the operator sees the actual
        // reason (server not responding, missing terminfo, dead pane,
        // permissions on /tmp). Empty stderr stays empty so the message
        // doesn't grow a trailing colon.
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let detail = if stderr.is_empty() {
            String::new()
        } else {
            format!(": {stderr}")
        };
        return Err(Error::Tmux(format!(
            "failed to create session '{name}'{detail}"
        )));
    }

    let status = Command::new(TMUX)
        .args(["set-option", "-t", name, "remain-on-exit", "on"])
        .status()?;
    if !status.success() {
        return Err(Error::Tmux(format!(
            "failed to enable remain-on-exit for session '{name}'"
        )));
    }
    Ok(())
}

/// Test-only variant of [`new_session_detached`]. Requires `name` to
/// start with [`TEST_SESSION_PREFIX`] and refuses reserved constellation
/// names. Use from any test / smoke script that owns tmux session
/// naming so a future contributor cannot accidentally clobber the live
/// `agent0`, `agent<N>`, `agentinfinity`, or `netsky-ticker` session.
pub fn new_test_session_detached(
    name: &str,
    cmd: &str,
    cwd: Option<&Path>,
    env: &[(&str, &str)],
) -> Result<(), Error> {
    if !name.starts_with(TEST_SESSION_PREFIX) {
        return Err(Error::Tmux(format!(
            "test tmux session '{name}' must start with '{TEST_SESSION_PREFIX}'"
        )));
    }
    if is_reserved_session_name(name) {
        return Err(Error::Tmux(format!(
            "refusing to create tmux session '{name}': reserved constellation name"
        )));
    }
    new_session_detached(name, cmd, cwd, env)
}

/// True when a tmux session exists and its pane is still running.
pub fn session_is_alive(name: &str) -> bool {
    if !has_session(name) {
        return false;
    }

    let output = Command::new(TMUX)
        .args(["list-panes", "-t", name, "-F", "#{pane_dead}"])
        .output();
    match output {
        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
            .lines()
            .any(|line| line.trim() == "0"),
        _ => false,
    }
}

/// Kill a tmux session. Idempotent — Ok if already gone.
pub fn kill_session(name: &str) -> Result<(), Error> {
    validate_session_name(name)?;
    require(TMUX)?;

    if !has_session(name) {
        return Ok(());
    }

    let status = Command::new(TMUX)
        .args(["kill-session", "-t", name])
        .status()?;

    if !status.success() {
        return Err(Error::Tmux(format!("failed to kill session '{name}'")));
    }
    Ok(())
}

/// Attach to a tmux session (takes over the terminal).
pub fn attach(name: &str) -> Result<(), Error> {
    validate_session_name(name)?;
    require(TMUX)?;

    if !has_session(name) {
        return Err(Error::Tmux(format!("session '{name}' does not exist")));
    }

    let status = Command::new(TMUX).args(["attach", "-t", name]).status()?;
    if !status.success() {
        return Err(Error::Tmux(format!("failed to attach to session '{name}'")));
    }
    Ok(())
}

/// Send keys followed by Enter to a tmux session.
pub fn send_keys(name: &str, keys: &str) -> Result<(), Error> {
    validate_session_name(name)?;
    require(TMUX)?;

    if !has_session(name) {
        return Err(Error::Tmux(format!("session '{name}' does not exist")));
    }

    let status = Command::new(TMUX)
        .args(["send-keys", "-t", name, keys, "Enter"])
        .status()?;
    if !status.success() {
        return Err(Error::Tmux(format!(
            "failed to send keys to session '{name}'"
        )));
    }
    Ok(())
}

/// Capture output from a tmux pane. `lines = None` captures visible;
/// `Some(n)` captures the last `n` lines of history.
pub fn capture_pane(name: &str, lines: Option<usize>) -> Result<String, Error> {
    validate_session_name(name)?;
    require(TMUX)?;

    if !has_session(name) {
        return Err(Error::Tmux(format!("session '{name}' does not exist")));
    }

    let start_arg;
    let mut args: Vec<&str> = vec!["capture-pane", "-t", name, "-p"];
    if let Some(n) = lines {
        start_arg = format!("-{n}");
        args.extend(["-S", &start_arg]);
    }

    let output = Command::new(TMUX).args(&args).output()?;
    if !output.status.success() {
        return Err(Error::Tmux(format!(
            "failed to capture pane from session '{name}'"
        )));
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

/// List all current tmux session names. Empty vec if the tmux server
/// isn't running or has no sessions.
pub fn list_sessions() -> Vec<String> {
    let output = Command::new(TMUX)
        .args(["list-sessions", "-F", "#{session_name}"])
        .output();
    match output {
        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
            .lines()
            .map(|s| s.to_string())
            .collect(),
        _ => Vec::new(),
    }
}

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

    #[test]
    fn validate_names() {
        assert!(validate_session_name("agent0").is_ok());
        assert!(validate_session_name("agent-infinity").is_ok());
        assert!(validate_session_name("agent_0").is_ok());
        assert!(validate_session_name("").is_err());
        assert!(validate_session_name("bad name").is_err());
        assert!(validate_session_name("bad:name").is_err());
        assert!(validate_session_name("bad.name").is_err());
    }

    #[test]
    fn has_session_nonexistent() {
        assert!(!has_session("netsky_sh_test_nonexistent_99999"));
        assert!(!session_is_alive("netsky_sh_test_nonexistent_99999"));
    }

    #[test]
    fn reserved_names_are_detected() {
        assert!(is_reserved_session_name("agent0"));
        assert!(is_reserved_session_name("agent7"));
        assert!(is_reserved_session_name("agent999"));
        assert!(is_reserved_session_name("agentinfinity"));
        assert!(is_reserved_session_name("netsky-ticker"));
    }

    #[test]
    fn reserved_names_exclude_test_prefixed_and_arbitrary() {
        assert!(!is_reserved_session_name("test-agent0"));
        assert!(!is_reserved_session_name("test-agent97"));
        assert!(!is_reserved_session_name("test-agentinfinity"));
        assert!(!is_reserved_session_name("agentfoo"));
        assert!(!is_reserved_session_name("scratch"));
        assert!(!is_reserved_session_name(""));
    }

    #[test]
    fn new_test_session_rejects_missing_prefix() {
        let err = new_test_session_detached("agent97", "sleep 1", None, &[]).unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("must start with 'test-'"), "msg={msg}");
    }

    #[test]
    fn new_test_session_rejects_reserved_test_prefix_edge() {
        // `test-` prefix is required, so reserved names are already out
        // of reach. Guard against a contributor removing the prefix
        // check: a name that both starts with `test-` AND matches a
        // reserved shape is impossible today, but the second guard
        // keeps the invariant explicit.
        assert!(is_reserved_session_name("agent0"));
        assert!(!"agent0".starts_with(TEST_SESSION_PREFIX));
    }
}