use std::process::{Command, Stdio};
use tracing::{info, warn};
pub fn tmux_bin() -> String {
std::env::var("TMUX_BIN").unwrap_or_else(|_| "tmux".to_string())
}
pub enum WindowScope<'a> {
Session(&'a str),
All,
}
pub struct NewSession<'a> {
pub session: &'a str,
pub cwd: &'a str,
pub window_name: Option<&'a str>,
pub socket: Option<&'a str>,
}
#[derive(Debug, thiserror::Error)]
pub enum TmuxError {
#[error("tmux {op} failed to spawn: {source}")]
Spawn {
op: &'static str,
#[source]
source: std::io::Error,
},
#[error("tmux {op} exited {code:?}: {stderr}")]
NonZero {
op: &'static str,
code: Option<i32>,
stderr: String,
},
#[error("tmux {op} produced no output")]
EmptyOutput { op: &'static str },
}
pub struct Tmux {
bin: String,
}
impl Default for Tmux {
fn default() -> Self {
Self::new()
}
}
impl Tmux {
pub fn new() -> Self {
Self { bin: tmux_bin() }
}
pub fn with_bin(bin: impl Into<String>) -> Self {
Self { bin: bin.into() }
}
fn base(&self, socket: Option<&str>) -> Command {
let mut cmd = Command::new(&self.bin);
if let Some(s) = socket {
cmd.args(["-S", s]);
}
cmd
}
pub fn kill_window(&self, socket: Option<&str>, target: &str) -> bool {
let mut cmd = self.base(socket);
cmd.args(["kill-window", "-t", target]);
run_lenient(cmd, &format!("tmux kill-window -t {target}"))
}
pub fn kill_session(&self, socket: Option<&str>, session: &str) -> bool {
let mut cmd = self.base(socket);
cmd.args(["kill-session", "-t", session]);
run_lenient(cmd, &format!("tmux kill-session -t {session}"))
}
pub fn find_window_by_path(
&self,
socket: Option<&str>,
session: Option<&str>,
worktree_path: &str,
) -> Option<String> {
let scope = match session {
Some(name) => WindowScope::Session(name),
None => WindowScope::All,
};
let out = self.list_windows_raw(socket, scope, "#{window_id}\t#{pane_current_path}")?;
out.lines().find_map(|line| {
let (wid, path) = line.split_once('\t')?;
if path.trim_end() != worktree_path {
return None;
}
let wid = wid.trim();
(!wid.is_empty()).then(|| wid.to_string())
})
}
pub fn list_session_windows(
&self,
socket: Option<&str>,
session: &str,
) -> Option<(bool, Vec<String>)> {
let out = self.list_windows_raw(
socket,
WindowScope::Session(session),
"#{session_attached}\t#{window_name}",
)?;
let mut attached = false;
let mut names = Vec::new();
for line in out.lines() {
let Some((att, name)) = line.split_once('\t') else {
continue;
};
if att.trim() != "0" {
attached = true;
}
let name = name.trim_end();
if !name.is_empty() {
names.push(name.to_string());
}
}
Some((attached, names))
}
fn list_windows_raw(
&self,
socket: Option<&str>,
scope: WindowScope<'_>,
format: &str,
) -> Option<String> {
let mut cmd = self.base(socket);
match scope {
WindowScope::Session(name) => cmd.args(["list-windows", "-t", name]),
WindowScope::All => cmd.args(["list-windows", "-a"]),
};
cmd.args(["-F", format]);
cmd.stderr(Stdio::null());
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[allow(dead_code)]
pub fn new_session(&self, params: &NewSession<'_>) -> Result<String, TmuxError> {
let mut cmd = self.base(params.socket);
cmd.args(["new-session", "-d", "-s", params.session, "-c", params.cwd]);
if let Some(name) = params.window_name {
cmd.args(["-n", name]);
}
cmd.args(["-P", "-F", "#{pane_id}"]);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let out = cmd.output().map_err(|source| TmuxError::Spawn {
op: "new-session",
source,
})?;
if !out.status.success() {
return Err(TmuxError::NonZero {
op: "new-session",
code: out.status.code(),
stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
});
}
let pane_id = String::from_utf8_lossy(&out.stdout).trim().to_string();
if pane_id.is_empty() {
return Err(TmuxError::EmptyOutput { op: "new-session" });
}
if params.window_name.is_some() {
let _ = self
.base(params.socket)
.args([
"set-window-option",
"-w",
"-t",
&pane_id,
"automatic-rename",
"off",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
Ok(pane_id)
}
}
fn run_lenient(mut cmd: Command, label: &str) -> bool {
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
match cmd.output() {
Ok(out) if out.status.success() => {
info!(target: "orchestratectl::supervise", step = label, "cleanup step ok");
eprintln!("supervisor cleanup: {label}: ok");
true
}
Ok(out) => {
let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
warn!(
target: "orchestratectl::supervise",
step = label,
code = out.status.code(),
detail = %detail,
"cleanup step non-zero (treated as already-done/refused; continuing)"
);
eprintln!("supervisor cleanup: {label}: non-zero exit (continuing): {detail}");
false
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
step = label,
error = %e,
"cleanup step could not spawn (continuing)"
);
eprintln!("supervisor cleanup: {label}: spawn failed (continuing): {e}");
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn fake_tmux(dir: &std::path::Path, body: &str) -> String {
let path = dir.join("tmux");
let log = dir.join("tmux.log");
std::fs::write(
&path,
format!(
"#!/bin/sh\necho \"$@\" >> {log}\n{body}\n",
log = log.display(),
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path.to_string_lossy().into_owned()
}
fn log_of(dir: &std::path::Path) -> String {
std::fs::read_to_string(dir.join("tmux.log")).unwrap_or_default()
}
#[test]
fn kill_window_threads_socket_before_verb() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(tmp.path(), ""));
assert!(tmux.kill_window(Some("/tmp/sock-7"), "@42"));
let log = log_of(tmp.path());
assert!(
log.contains("-S /tmp/sock-7 kill-window -t @42"),
"socket must precede the verb: {log:?}"
);
}
#[test]
fn kill_window_reports_false_on_missing() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(tmp.path(), "exit 1"));
assert!(!tmux.kill_window(None, "@99"));
}
#[test]
fn kill_session_no_socket() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(tmp.path(), ""));
assert!(tmux.kill_session(None, "headless"));
assert!(log_of(tmp.path()).contains("kill-session -t headless"));
}
#[test]
fn find_window_by_path_requires_exact_cwd_match() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(
tmp.path(),
r#"case "$*" in *list-windows*) printf '@7\t/wt/foo/src\n@9\t/wt/foo\n';; esac"#,
));
assert_eq!(
tmux.find_window_by_path(None, Some("headless"), "/wt/foo"),
Some("@9".to_string())
);
assert!(log_of(tmp.path()).contains("list-windows -t headless"));
}
#[test]
fn find_window_by_path_scopes_to_all_when_no_session() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(
tmp.path(),
r#"case "$*" in *list-windows*) printf '@9\t/wt/foo\n';; esac"#,
));
assert_eq!(
tmux.find_window_by_path(None, None, "/wt/foo"),
Some("@9".to_string())
);
assert!(log_of(tmp.path()).contains("list-windows -a"));
}
#[test]
fn find_window_by_path_none_on_nonzero() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(tmp.path(), "exit 1"));
assert_eq!(tmux.find_window_by_path(None, Some("s"), "/wt/foo"), None);
}
#[test]
fn list_session_windows_parses_attached_and_names() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(
tmp.path(),
r#"case "$*" in *list-windows*) printf '0\tzsh\n1\t🎬 wt/x\n';; esac"#,
));
let (attached, names) = tmux.list_session_windows(None, "headless").unwrap();
assert!(
attached,
"a non-zero session_attached means a human is in it"
);
assert_eq!(names, vec!["zsh".to_string(), "🎬 wt/x".to_string()]);
}
#[test]
fn list_session_windows_none_when_session_gone() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(tmp.path(), "exit 1"));
assert!(tmux.list_session_windows(None, "gone").is_none());
}
#[test]
fn new_session_headless_returns_pane_id_and_disables_rename() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(
tmp.path(),
r#"case "$*" in *new-session*) echo '%3';; esac"#,
));
let pane = tmux
.new_session(&NewSession {
session: "headless",
cwd: "/tmp",
window_name: Some("wt/x"),
socket: Some("/tmp/sock"),
})
.unwrap();
assert_eq!(pane, "%3");
let log = log_of(tmp.path());
assert!(
log.contains(
"-S /tmp/sock new-session -d -s headless -c /tmp -n wt/x -P -F #{pane_id}"
),
"new-session argv: {log:?}"
);
assert!(
log.contains("set-window-option -w -t %3 automatic-rename off"),
"named window must disable automatic-rename: {log:?}"
);
}
#[test]
fn new_session_surfaces_nonzero() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(
tmp.path(),
r#"case "$*" in *new-session*) echo boom >&2; exit 1;; esac"#,
));
let err = tmux
.new_session(&NewSession {
session: "headless",
cwd: "/tmp",
window_name: None,
socket: None,
})
.unwrap_err();
match err {
TmuxError::NonZero { stderr, .. } => assert_eq!(stderr, "boom"),
other => panic!("expected NonZero, got {other:?}"),
}
}
#[test]
fn new_session_without_window_name_skips_rename_off() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(
tmp.path(),
r#"case "$*" in *new-session*) echo '%1';; esac"#,
));
let pane = tmux
.new_session(&NewSession {
session: "headless",
cwd: "/tmp",
window_name: None,
socket: None,
})
.unwrap();
assert_eq!(pane, "%1");
assert!(
!log_of(tmp.path()).contains("set-window-option"),
"no window name means no automatic-rename toggle"
);
}
#[test]
fn new_session_empty_pane_id_is_a_protocol_error() {
let tmp = tempfile::tempdir().unwrap();
let tmux = Tmux::with_bin(fake_tmux(tmp.path(), ""));
let err = tmux
.new_session(&NewSession {
session: "headless",
cwd: "/tmp",
window_name: Some("wt/x"),
socket: None,
})
.unwrap_err();
assert!(matches!(err, TmuxError::EmptyOutput { .. }), "{err:?}");
assert!(
!log_of(tmp.path()).contains("set-window-option"),
"must not target an empty pane id"
);
}
}