netsky-sh 0.1.2

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

/// 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 status = Command::new(TMUX).args(&args).status()?;

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

    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(())
}

/// 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"));
    }
}