use std::io::{Read, Write};
use crate::{remote, restart};
fn clear(out: &mut impl Write) {
let _ = write!(out, "\x1b[H\x1b[2J");
}
enum Pressed {
Key(char),
Unreadable,
}
fn any_key() {
print!(" Press any key…");
let _ = std::io::stdout().flush();
if let Pressed::Unreadable = read_one_key() {
println!("\r\n (no readable terminal to wait on, so this was not held)");
let _ = std::io::stdout().flush();
}
}
fn read_one_key() -> Pressed {
let Ok(mut tty) = std::fs::File::open("/dev/tty") else {
return Pressed::Unreadable;
};
let raw = crossterm::terminal::enable_raw_mode().is_ok();
let mut b = [0u8; 1];
let got = match tty.read(&mut b) {
Ok(1) => Pressed::Key(b[0] as char),
_ => Pressed::Unreadable,
};
if raw {
let _ = crossterm::terminal::disable_raw_mode();
}
got
}
pub fn report_failed_child(what: &str, e: &std::io::Error) {
let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") else {
return;
};
let _ = write!(
tty,
"\x1b[H\x1b[2J\x1b[1mtaimux: could not run {}\x1b[0m\r\n\r\n {}\r\n\r\n\
\x20 The picker re-runs its OWN binary for this, so the usual cause is\r\n\
\x20 that binary moving or being rebuilt underneath a running picker.\r\n\
\x20 Closing and reopening the picker picks up the new one.\r\n\r\n\
\x20 Press any key…",
what, e
);
let _ = tty.flush();
let _ = read_one_key();
}
fn plan_panes(plan: &str) -> Vec<&str> {
let mut on = false;
let mut out = Vec::new();
for l in plan.lines() {
if l.starts_with("to restart (") {
on = true;
continue;
}
if l.starts_with("skipped (") {
on = false;
}
if on && l.starts_with(" %") {
out.push(l);
}
}
out
}
fn count_in(plan: &str, heading: &str) -> Option<usize> {
plan.lines()
.find(|l| l.starts_with(heading))
.and_then(|l| l.split(['(', ')']).nth(1))
.and_then(|n| n.parse().ok())
}
fn why_for(plan: &str, pane: &str) -> Option<String> {
let needle = format!(" {} ", pane);
plan.lines()
.find(|l| l.contains(&needle))
.map(|l| l.trim_start().to_string())
}
pub fn note(line: &str) {
let log = taimux_core::paths::runtime_dir().join("restart.log");
if let Some(d) = log.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log)
{
let _ = writeln!(f, "--- {} {}", taimux_core::log::stamp(), line);
}
}
fn detached(
cmd: &mut std::process::Command,
out: std::fs::File,
err: std::fs::File,
) -> &mut std::process::Command {
cmd.stdin(std::process::Stdio::null())
.stdout(out)
.stderr(err)
}
pub fn restart_detached(exe: &str, pane: &str, force: bool) {
let log = taimux_core::paths::runtime_dir().join("restart.log");
note(if pane.is_empty() {
"all outdated panes"
} else {
pane
});
let mut args: Vec<String> = vec!["restart".into(), "-y".into()];
if pane.starts_with('%') {
args.push("--pane".into());
args.push(pane.into());
}
if force {
args.push("--include-busy".into());
}
let Ok(out) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log)
else {
return;
};
let Ok(err) = out.try_clone() else { return };
let Ok(out2) = out.try_clone() else { return };
let spawned = detached(
std::process::Command::new("setsid").arg(exe).args(&args),
out2,
err,
)
.spawn();
if spawned.is_err() {
let Ok(out3) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log)
else {
return;
};
let Ok(err2) = out3.try_clone() else { return };
let _ = detached(std::process::Command::new(exe).args(&args), out3, err2).spawn();
}
}
pub fn restart_one(exe: &str, pane: &str) {
let mut out = std::io::stdout();
if pane.starts_with("dead:") {
clear(&mut out);
println!("\x1b[1mtaimux: that session has already ended\x1b[0m\n");
println!(" There is nothing running to restart. Press Enter on it instead:");
println!(" it opens again in a new window, in its own directory.\n");
any_key();
return;
}
if let Some(host) = remote::pane_host(pane) {
clear(&mut out);
println!(
"\x1b[1mtaimux: {} is on {}\x1b[0m\n",
remote::pane_local(pane),
host
);
println!(" Restarting is local-only. From that host, or from here:\n");
println!(
" ssh {} taimux restart --pane {}\n",
host,
remote::pane_local(pane)
);
any_key();
return;
}
if !pane.starts_with('%') {
return; }
let plan = plan_of(exe, &["-n", "--pane", pane]);
if plan.contains("\nto restart (") || plan.starts_with("to restart (") {
restart_detached(exe, pane, false);
return;
}
clear(&mut out);
println!("\x1b[1mtaimux: {} will not restart cleanly\x1b[0m\n", pane);
let Some(why) = why_for(&plan, pane) else {
println!(" Nothing to do: it is already on the installed version, or it is");
println!(" not a claude pane.\n");
any_key();
return;
};
println!(" {}", why);
let forced = plan_of(exe, &["-n", "--pane", pane, "--include-busy"]);
if !(forced.contains("\nto restart (") || forced.starts_with("to restart (")) {
println!(
"\n Forcing would not help:\n {}",
why_for(&forced, pane).unwrap_or_else(|| "same refusal".into())
);
println!();
any_key();
return;
}
println!("\n Forcing accepts losing an in-flight turn. A pane holding a");
println!(" permission dialog is still refused, so nothing gets answered for you.");
print!("\n\x1b[1mForce the restart?\x1b[0m [Y/n] ");
let _ = out.flush();
let go = match read_one_key() {
Pressed::Key(c) => restart::confirm_yes(&c.to_string()),
Pressed::Unreadable => {
print!("\r\n (no readable terminal to answer on, so nothing was restarted)");
false
}
};
println!();
if go {
restart_detached(exe, pane, true);
println!(
"\n Forced, detached. Watch the version, or read\n {}",
taimux_core::paths::runtime_dir()
.join("restart.log")
.display()
);
std::thread::sleep(std::time::Duration::from_millis(1200));
} else {
println!("\n Left alone.");
std::thread::sleep(std::time::Duration::from_millis(600));
}
}
pub fn sweep(exe: &str) {
let mut out = std::io::stdout();
let at = std::time::Instant::now();
let plan = plan_of(exe, &["-n"]);
let planned = at.elapsed();
let n = count_in(&plan, "to restart (");
let skipped = count_in(&plan, "skipped (");
clear(&mut out);
println!("\x1b[1mtaimux: restart every outdated session\x1b[0m\n");
let Some(n) = n.filter(|n| *n >= 1) else {
println!("Nothing to restart. Either every session is already on the");
println!("installed version, or the ones behind it are busy.");
if let Some(s) = skipped {
println!("\n {} left alone.", s);
}
println!();
any_key();
return;
};
for l in plan_panes(&plan) {
println!("{}", l);
}
if let Some(s) = skipped {
println!("\n {} left alone (working, waiting, or unidentified).", s);
}
print!("\n\x1b[1mRestart {} session(s)?\x1b[0m [Y/n] ", n);
let _ = out.flush();
let go = match read_one_key() {
Pressed::Key(c) => restart::confirm_yes(&c.to_string()),
Pressed::Unreadable => {
print!("\r\n (no readable terminal to answer on, so nothing was restarted)");
false
}
};
println!();
if go {
note(&format!(
"sweep: {} to restart, plan took {:.1}s, {:.1}s from keypress to firing",
n,
planned.as_secs_f32(),
at.elapsed().as_secs_f32()
));
restart_detached(exe, "", false);
println!(
"\nStarted, detached. Watch the version column, or read\n{}",
taimux_core::paths::runtime_dir()
.join("restart.log")
.display()
);
std::thread::sleep(std::time::Duration::from_millis(1200));
} else {
println!("\nNothing restarted.");
std::thread::sleep(std::time::Duration::from_millis(700));
}
}
fn plan_of(exe: &str, args: &[&str]) -> String {
std::process::Command::new(exe)
.arg("restart")
.args(args)
.output()
.map(|o| {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&o.stderr));
s
})
.unwrap_or_default()
}
pub fn conversation_of(pane: &str) -> Result<(String, String), String> {
if let Some((agent, key)) = taimux_core::index::split_past_id(pane) {
return Ok((agent.to_string(), key.to_string()));
}
if pane == "dead:!" {
return Err("that row is a note, not a conversation".into());
}
if remote::pane_host(pane).is_some() {
return Err(format!(
"{} is on another host, and a handoff reads its transcript and starts \
an agent in its directory, both of which have to happen over there",
remote::pane_local(pane)
));
}
let rows = taimux_core::panes::agent_rows();
let row = rows
.lines()
.map(|l| l.split('\t').collect::<Vec<_>>())
.find(|f| f.len() >= 5 && f[0] == pane)
.ok_or_else(|| format!("{} is not running an agent, or is not there any more", pane))?;
if row[3] != "claude" {
return Err(format!(
"taimux cannot tell which conversation a {} pane is on, so there is \
nothing to hand over. Only claude publishes that",
row[3]
));
}
let pid: i32 = row[4].parse().unwrap_or(0);
let cwd = std::fs::read_link(format!("/proc/{}/cwd", pid))
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| row[2].to_string());
match taimux_core::conv::resolve_from_pane(pane, &cwd, pid) {
Some(r) => Ok(("claude".into(), r.transcript.to_string_lossy().into_owned())),
None => Err("that pane's conversation could not be identified".into()),
}
}
pub fn handoff_one(pane: &str) {
let mut out = std::io::stdout();
clear(&mut out);
let (agent, key) = match conversation_of(pane) {
Ok(v) => v,
Err(why) => {
println!("\x1b[1mtaimux: nothing to hand off here\x1b[0m\n");
println!(" {}.\n", why);
any_key();
return;
}
};
let targets: Vec<&str> = taimux_core::handoff::installed()
.into_iter()
.filter(|t| *t != agent)
.collect();
if targets.is_empty() {
println!("\x1b[1mtaimux: no other agent is installed\x1b[0m\n");
println!(" A handoff starts a DIFFERENT tool on this conversation, and");
println!(" {} is the only one on your PATH.\n", agent);
any_key();
return;
}
let meta = taimux_core::agents::meta(&agent, &key);
let title = if meta.title.is_empty() {
"(no title)"
} else {
&meta.title
};
println!(
"\x1b[1mtaimux: continue this {} conversation elsewhere\x1b[0m\n",
taimux_core::handoff::display_name(&agent)
);
println!(" \x1b[1;36m{}\x1b[0m", title);
println!(
" \x1b[90m{}\x1b[0m\n",
if meta.cwd.is_empty() { "?" } else { &meta.cwd }
);
println!(" The prompt carries the task, the repository's state and the last");
println!(" few turns, and points at the transcript for the rest.\n");
for (i, t) in targets.iter().enumerate() {
println!(
" \x1b[1m{}\x1b[0m {}",
i + 1,
taimux_core::handoff::display_name(t)
);
}
println!("\n Anything else cancels.");
let _ = out.flush();
let Pressed::Key(c) = read_one_key() else {
println!("\r\n (no readable terminal to ask on)");
return;
};
let Some(target) = c
.to_digit(10)
.and_then(|n| targets.get(n as usize - 1).copied())
else {
return; };
let turns = taimux_core::env::var("TAIMUX_HANDOFF_TURNS")
.and_then(|v| v.parse().ok())
.unwrap_or(taimux_core::handoff::DEFAULT_TURNS);
let prompt = taimux_core::handoff::build(&agent, &key, turns);
let cmd = match taimux_core::handoff::launch(target, &prompt) {
Ok(c) => c,
Err(why) => {
println!("\r\n\r\n {}.\r\n", why);
any_key();
return;
}
};
let cwd = if std::path::Path::new(&meta.cwd).is_dir() {
meta.cwd.clone()
} else {
clear(&mut out);
println!(
"\x1b[1mtaimux: {} is gone\x1b[0m\n",
if meta.cwd.is_empty() {
"its directory"
} else {
&meta.cwd
}
);
println!(" A handoff starts an agent in the directory the conversation ran");
println!(" in, and that one is no longer there.\n");
any_key();
return;
};
if taimux_core::tmux::run(&["new-window", "-c", &cwd, &cmd]) {
return; }
clear(&mut out);
println!("\x1b[1mtaimux: tmux would not open a window\x1b[0m\n");
any_key();
}
#[cfg(test)]
mod tests {
#[test]
fn a_detached_job_does_not_hold_the_terminal() {
let dir = std::env::temp_dir().join(format!("taimux-detached-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let logp = dir.join("log");
let out = std::fs::File::create(&logp).unwrap();
let err = out.try_clone().unwrap();
let mut cmd = std::process::Command::new("sleep");
cmd.arg("30");
let had_tty = match std::fs::File::open("/dev/tty") {
Ok(tty) => {
cmd.stdin(std::process::Stdio::from(tty));
true
}
Err(_) => false,
};
let mut child = detached(&mut cmd, out, err).spawn().expect("spawn sleep");
let fd0 = std::fs::read_link(format!("/proc/{}/fd/0", child.id()));
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&dir);
let Ok(fd0) = fd0 else { return };
if !had_tty {
eprintln!("no /dev/tty in this environment, so this proves nothing");
return;
}
let fd0 = fd0.to_string_lossy().into_owned();
assert!(
!fd0.contains("/pts/") && !fd0.contains("/dev/tty"),
"a detached job kept a terminal on stdin: {fd0}"
);
assert!(
fd0.contains("null"),
"expected /dev/null on stdin, got {fd0}"
);
}
use super::*;
const PLAN: &str = "claude: 2.1.258 installed at /l/claude\n\
\n\
to restart (2):\n\
\x20 %19 platform:4.1 a title\n\
\x20 2.1.100 -> 2.1.258, pane map, idle\n\
\x20 command claude --resume /t.jsonl\n\
\x20 %23 platform:4.5 another\n\
\x20 2.1.100 -> 2.1.258, pane map, idle\n\
\x20 command claude\n\
\n\
skipped (3):\n\
\x20 %77 main:1.1 2.1.100, run: rerun when idle\n";
#[test]
fn only_the_pane_lines_of_the_plan_are_shown() {
assert_eq!(
plan_panes(PLAN),
vec![
" %19 platform:4.1 a title",
" %23 platform:4.5 another"
]
);
}
#[test]
fn the_counts_come_off_the_headings() {
assert_eq!(count_in(PLAN, "to restart ("), Some(2));
assert_eq!(count_in(PLAN, "skipped ("), Some(3));
assert_eq!(count_in("nothing to restart.\n", "to restart ("), None);
}
#[test]
fn a_panes_own_reason_is_picked_out_of_the_skip_list() {
assert_eq!(
why_for(PLAN, "%77").as_deref(),
Some("%77 main:1.1 2.1.100, run: rerun when idle")
);
assert_eq!(why_for(PLAN, "%99"), None);
}
#[test]
fn an_empty_plan_is_not_mistaken_for_a_full_one() {
let empty = "claude: 2.1.258 installed at /l\n\nnothing to restart.\n";
assert!(plan_panes(empty).is_empty());
assert_eq!(count_in(empty, "to restart ("), None);
}
}