use std::path::{Path, PathBuf};
use std::process::Command;
const PREFIX: &str = "cctop";
pub fn available() -> bool {
Command::new("tmux")
.arg("-V")
.output()
.is_ok_and(|out| out.status.success())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Install {
pub manager: &'static str,
pub argv: Vec<String>,
}
impl Install {
pub fn shown(&self) -> &str {
self.argv.last().map(String::as_str).unwrap_or_default()
}
}
const MANAGERS: &[(&str, &str, &[&str], bool)] = &[
("Homebrew", "brew", &["brew install tmux"], false),
(
"apt",
"apt-get",
&["apt-get update", "apt-get install -y tmux"],
true,
),
("dnf", "dnf", &["dnf install -y tmux"], true),
("yum", "yum", &["yum install -y tmux"], true),
("pacman", "pacman", &["pacman -S --noconfirm tmux"], true),
(
"zypper",
"zypper",
&["zypper --non-interactive install tmux"],
true,
),
("apk", "apk", &["apk add tmux"], true),
];
pub fn installer() -> Option<Install> {
let root = is_root();
let (manager, _, commands, needs_root) = MANAGERS
.iter()
.find(|(_, bin, _, _)| crate::shim::is_command(bin))?;
let elevate = match (needs_root, root) {
(false, _) | (true, true) => "",
(true, false) if crate::shim::is_command("sudo") => "sudo ",
(true, false) => return None,
};
let script = commands
.iter()
.map(|command| format!("{elevate}{command}"))
.collect::<Vec<_>>()
.join(" && ");
Some(Install {
manager,
argv: vec!["sh".to_string(), "-c".to_string(), script],
})
}
#[cfg(unix)]
fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
fn is_root() -> bool {
false
}
pub fn attach_or_create(argv: &[String], name: &str, cwd: Option<&Path>) -> Vec<String> {
let mut out = vec![
"tmux".to_string(),
"new-session".to_string(),
"-A".to_string(),
"-s".to_string(),
name.to_string(),
];
if let Some(dir) = cwd.filter(|d| d.is_dir()) {
out.push("-c".into());
out.push(dir.to_string_lossy().into_owned());
}
out.push("--".into());
out.extend(argv.iter().cloned());
out
}
const HISTORY_LINES: &str = "50000";
pub fn prepare(argv: &[String], name: &str, cwd: Option<&Path>) {
if exists(name) {
return;
}
let dir = cwd.filter(|d| d.is_dir()).map(|d| d.to_string_lossy());
let mut create = vec!["new-session", "-d", "-P", "-F", "#{window_id}", "-s", name];
if let Some(dir) = &dir {
create.extend(["-c", dir]);
}
create.extend(["--", "sleep", "86400"]);
let Ok(out) = Command::new("tmux").args(&create).output() else {
return;
};
if !out.status.success() {
return;
}
let placeholder = String::from_utf8_lossy(&out.stdout).trim().to_string();
let _ = Command::new("tmux")
.args(["set-option", "-t", name, "history-limit", HISTORY_LINES])
.args([";", "set-option", "-t", name, "mouse", "on"])
.args([";", "set-option", "-t", name, "status", "off"])
.output();
let mut window = vec!["new-window", "-t", name];
if let Some(dir) = &dir {
window.extend(["-c", dir]);
}
window.push("--");
window.extend(argv.iter().map(String::as_str));
let made = Command::new("tmux")
.args(&window)
.output()
.is_ok_and(|out| out.status.success());
if !made {
let _ = kill(name);
return;
}
let _ = Command::new("tmux")
.args(["kill-window", "-t", &placeholder])
.output();
}
pub fn attach(name: &str) -> Vec<String> {
vec![
"tmux".to_string(),
"attach-session".to_string(),
"-t".to_string(),
format!("={name}"),
]
}
pub fn name_for_session(provider: &str, session_id: &str) -> String {
let id: String = session_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
sanitize(&format!("{PREFIX}-{provider}-{id}"))
}
pub fn free_name(label: &str) -> String {
let base = sanitize(&format!("{PREFIX}-{label}"));
if !exists(&base) {
return base;
}
(2..100)
.map(|n| format!("{base}-{n}"))
.find(|name| !exists(name))
.unwrap_or(base)
}
fn sanitize(text: &str) -> String {
let cleaned: String = text
.chars()
.map(|c| match c {
c if c.is_ascii_alphanumeric() || c == '-' || c == '_' => c,
_ => '-',
})
.collect();
let mut out = String::with_capacity(cleaned.len());
for c in cleaned.chars() {
if c == '-' && out.ends_with('-') {
continue;
}
out.push(c);
}
out.trim_matches('-').to_string()
}
pub fn exists(name: &str) -> bool {
Command::new("tmux")
.args(["has-session", "-t", &format!("={name}")])
.output()
.is_ok_and(|out| out.status.success())
}
pub fn kill(name: &str) -> Result<(), String> {
let out = Command::new("tmux")
.args(["kill-session", "-t", &format!("={name}")])
.output()
.map_err(|e| format!("tmux: {e}"))?;
if out.status.success() {
return Ok(());
}
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
pub fn quiet(name: &str) {
let _ = Command::new("tmux")
.args(["set-option", "-t", name, "status", "off"])
.output();
}
pub fn mouse(name: &str) {
let _ = Command::new("tmux")
.args(["set-option", "-t", name, "mouse", "on"])
.output();
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Running {
pub name: String,
pub pid: Option<u32>,
pub cwd: Option<PathBuf>,
pub attached: bool,
}
pub fn running() -> Vec<Running> {
let Ok(out) = Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{session_name}\t#{pane_pid}\t#{pane_current_path}\t#{session_attached}\t#{session_created}",
])
.output()
else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let prefix = format!("{PREFIX}-");
let mut found: Vec<(u64, Running)> = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut parts = line.split('\t');
let Some(name) = parts
.next()
.map(str::trim)
.filter(|n| n.starts_with(&prefix))
else {
continue;
};
if found.iter().any(|(_, s)| s.name == name) {
continue;
}
let pid = parts.next().and_then(|p| p.trim().parse::<u32>().ok());
let cwd = parts
.next()
.map(str::trim)
.filter(|p| !p.is_empty())
.map(PathBuf::from);
let attached = parts.next().map(str::trim).is_some_and(|a| a != "0");
let created = parts
.next()
.and_then(|c| c.trim().parse::<u64>().ok())
.unwrap_or(0);
found.push((
created,
Running {
name: name.to_string(),
pid,
cwd,
attached,
},
));
}
found.sort_by_key(|(created, _)| std::cmp::Reverse(*created));
found.into_iter().map(|(_, s)| s).collect()
}
pub fn sessions() -> Vec<String> {
running().into_iter().map(|s| s.name).collect()
}
pub fn agent_pid(name: &str) -> Option<u32> {
let out = Command::new("tmux")
.args(["list-panes", "-t", &format!("={name}"), "-F", "#{pane_pid}"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(|line| line.trim().parse().ok())
}
pub fn holding(pid: u32) -> Option<String> {
running()
.into_iter()
.find(|agent| agent.pid == Some(pid))
.map(|agent| agent.name)
}
#[cfg(test)]
pub fn test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_offered_install_is_a_command_that_could_run() {
let Some(install) = installer() else {
return;
};
assert_eq!(install.argv[0], "sh");
assert_eq!(install.argv[1], "-c");
assert_eq!(install.shown(), install.argv[2]);
assert!(install.shown().contains("tmux"));
if let Some(rest) = install.shown().strip_prefix("sudo ") {
assert!(crate::shim::is_command("sudo"));
assert!(!rest.contains("&& apt-get install") || rest.contains("&& sudo"));
}
}
#[test]
fn the_wrapper_attaches_or_creates_and_passes_the_agent_through() {
let argv: Vec<String> = ["claude", "--resume", "abc"]
.iter()
.map(|s| s.to_string())
.collect();
let dir = std::env::temp_dir();
let out = attach_or_create(&argv, "cctop-claude-abc", Some(&dir));
assert_eq!(
&out[..5],
&["tmux", "new-session", "-A", "-s", "cctop-claude-abc"]
);
let sep = out.iter().position(|a| a == "--").expect("separator");
assert_eq!(&out[sep + 1..], &argv[..]);
assert!(out.contains(&"-c".to_string()));
let gone = attach_or_create(&argv, "n", Some(Path::new("/nonexistent/gone")));
assert!(!gone.contains(&"-c".to_string()));
}
#[test]
fn a_session_always_maps_to_the_same_tmux_name() {
let a = name_for_session("claude", "32cca860-b503-43f8-8b95-7b75880abb8b");
let b = name_for_session("claude", "32cca860-b503-43f8-8b95-7b75880abb8b");
assert_eq!(a, b);
assert_ne!(a, name_for_session("claude", "other-id"));
assert_ne!(
a,
name_for_session("codex", "32cca860-b503-43f8-8b95-7b75880abb8b")
);
}
#[test]
fn two_sessions_never_share_a_name() {
let first = name_for_session("codex", "rollout-2026-08-06T16-47-27-019fd565-d014-7b71");
let second = name_for_session("codex", "rollout-2026-08-06T16-47-59-019fd999-aaaa-8c62");
assert_ne!(
first, second,
"two Codex sessions from the same minute share a tmux session"
);
assert_ne!(
name_for_session("claude", "32cca860-b503-43f8-8b95-7b75880abb8a"),
name_for_session("claude", "32cca860-b503-43f8-8b95-7b75880abb8b")
);
assert_ne!(
name_for_session("code", "x-abc"),
name_for_session("codex", "abc")
);
}
#[test]
fn names_carry_nothing_tmux_would_read_as_an_address() {
for name in [
name_for_session("claude", "/home/x/proj:1.2"),
free_name("claude --resume /a/b"),
sanitize("--leading and trailing--"),
] {
assert!(
!name.contains(['.', ':', ' ', '/']),
"unsafe tmux name: {name}"
);
assert!(!name.starts_with('-') && !name.ends_with('-'), "{name}");
assert!(!name.contains("--"), "collapsed runs: {name}");
}
}
#[test]
fn names_stay_inside_the_cctop_namespace() {
assert!(name_for_session("claude", "abc").starts_with("cctop-"));
assert!(free_name("zsh").starts_with("cctop-"));
}
#[test]
fn a_session_reports_the_agent_inside_it() {
if !available() {
eprintln!("skipping: tmux not installed");
return;
}
let _turn = test_lock();
let ours = format!("cctop-probe-{}", std::process::id());
let theirs = format!("outside-probe-{}", std::process::id());
let dir = std::env::temp_dir();
for name in [&ours, &theirs] {
assert!(
Command::new("tmux")
.args([
"new-session",
"-d",
"-s",
name,
"-c",
&dir.to_string_lossy(),
"--",
"sh",
"-c",
"sleep 30",
])
.status()
.is_ok_and(|s| s.success()),
"could not start {name}"
);
}
let found = wait_for(|| running().into_iter().find(|s| s.name == ours));
let pid = agent_pid(&ours);
let listed = sessions();
let outsider = running().into_iter().any(|s| s.name == theirs);
let back = pid.and_then(holding);
quiet(&ours);
let status = Command::new("tmux")
.args(["show-options", "-t", &ours, "status"])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string());
for name in [&ours, &theirs] {
let _ = kill(name);
}
let found = found.expect("the session cctop just created was not listed");
assert_eq!(found.pid, pid, "the two ways of asking disagreed");
assert!(found.pid.is_some_and(|p| p > 0), "no agent pid: {found:?}");
assert_eq!(found.cwd.as_deref(), Some(dir.as_path()));
assert!(!found.attached);
assert!(listed.contains(&ours));
assert_eq!(back.as_deref(), Some(ours.as_str()));
assert_eq!(status.as_deref(), Some("status off"), "status bar left on");
assert!(!outsider, "a session outside cctop's namespace was listed");
assert!(!listed.contains(&theirs));
assert_eq!(agent_pid(&ours), None);
assert!(!exists(&ours));
}
#[test]
fn a_prepared_session_holds_the_agent_with_room_to_scroll_back() {
if !available() {
eprintln!("skipping: tmux not installed");
return;
}
let _turn = test_lock();
let name = format!("cctop-prep-{}", std::process::id());
let dir = std::env::temp_dir();
let argv = vec!["sh".to_string(), "-c".to_string(), "sleep 30".to_string()];
prepare(&argv, &name, Some(&dir));
let ask = |fmt: &str| {
Command::new("tmux")
.args(["display-message", "-p", "-t", &name, fmt])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
let history = wait_for(|| ask("#{history_limit}"));
let windows = ask("#{session_windows}");
let mouse = ask("#{mouse}");
let agent = agent_pid(&name);
let _ = kill(&name);
assert_eq!(
history.as_deref(),
Some(HISTORY_LINES),
"the agent's pane kept tmux's default scrollback"
);
assert_eq!(mouse.as_deref(), Some("1"), "the wheel would do nothing");
assert_eq!(
windows.as_deref(),
Some("1"),
"placeholder window left over"
);
assert!(agent.is_some_and(|p| p > 0), "no agent in the session");
}
fn wait_for<T>(mut f: impl FnMut() -> Option<T>) -> Option<T> {
for _ in 0..50 {
if let Some(v) = f() {
return Some(v);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
None
}
}