1use std::io::{Read, Write};
13
14use crate::{remote, restart};
15
16fn clear(out: &mut impl Write) {
18 let _ = write!(out, "\x1b[H\x1b[2J");
19}
20
21enum Pressed {
29 Key(char),
30 Unreadable,
32}
33
34fn any_key() {
37 print!(" Press any key…");
38 let _ = std::io::stdout().flush();
39 if let Pressed::Unreadable = read_one_key() {
40 println!("\r\n (no readable terminal to wait on, so this was not held)");
45 let _ = std::io::stdout().flush();
46 }
47}
48
49fn read_one_key() -> Pressed {
50 let Ok(mut tty) = std::fs::File::open("/dev/tty") else {
51 return Pressed::Unreadable;
52 };
53 let raw = crossterm::terminal::enable_raw_mode().is_ok();
55 let mut b = [0u8; 1];
56 let got = match tty.read(&mut b) {
57 Ok(1) => Pressed::Key(b[0] as char),
58 _ => Pressed::Unreadable,
60 };
61 if raw {
62 let _ = crossterm::terminal::disable_raw_mode();
63 }
64 got
65}
66
67pub fn report_failed_child(what: &str, e: &std::io::Error) {
82 let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") else {
83 return;
84 };
85 let _ = write!(
89 tty,
90 "\x1b[H\x1b[2J\x1b[1mtaimux: could not run {}\x1b[0m\r\n\r\n {}\r\n\r\n\
91 \x20 The picker re-runs its OWN binary for this, so the usual cause is\r\n\
92 \x20 that binary moving or being rebuilt underneath a running picker.\r\n\
93 \x20 Closing and reopening the picker picks up the new one.\r\n\r\n\
94 \x20 Press any key…",
95 what, e
96 );
97 let _ = tty.flush();
98 let _ = read_one_key();
99}
100
101fn plan_panes(plan: &str) -> Vec<&str> {
107 let mut on = false;
108 let mut out = Vec::new();
109 for l in plan.lines() {
110 if l.starts_with("to restart (") {
111 on = true;
112 continue;
113 }
114 if l.starts_with("skipped (") {
115 on = false;
116 }
117 if on && l.starts_with(" %") {
118 out.push(l);
119 }
120 }
121 out
122}
123
124fn count_in(plan: &str, heading: &str) -> Option<usize> {
125 plan.lines()
126 .find(|l| l.starts_with(heading))
127 .and_then(|l| l.split(['(', ')']).nth(1))
128 .and_then(|n| n.parse().ok())
129}
130
131fn why_for(plan: &str, pane: &str) -> Option<String> {
133 let needle = format!(" {} ", pane);
134 plan.lines()
135 .find(|l| l.contains(&needle))
136 .map(|l| l.trim_start().to_string())
137}
138
139pub fn note(line: &str) {
147 let log = taimux_core::paths::runtime_dir().join("restart.log");
148 if let Some(d) = log.parent() {
149 let _ = std::fs::create_dir_all(d);
150 }
151 if let Ok(mut f) = std::fs::OpenOptions::new()
152 .create(true)
153 .append(true)
154 .open(&log)
155 {
156 let _ = writeln!(f, "--- {} {}", taimux_core::log::stamp(), line);
157 }
158}
159
160fn detached(
174 cmd: &mut std::process::Command,
175 out: std::fs::File,
176 err: std::fs::File,
177) -> &mut std::process::Command {
178 cmd.stdin(std::process::Stdio::null())
179 .stdout(out)
180 .stderr(err)
181}
182
183pub fn restart_detached(exe: &str, pane: &str, force: bool) {
184 let log = taimux_core::paths::runtime_dir().join("restart.log");
185 note(if pane.is_empty() {
186 "all outdated panes"
187 } else {
188 pane
189 });
190 let mut args: Vec<String> = vec!["restart".into(), "-y".into()];
191 if pane.starts_with('%') {
192 args.push("--pane".into());
193 args.push(pane.into());
194 }
195 if force {
196 args.push("--include-busy".into());
197 }
198 let Ok(out) = std::fs::OpenOptions::new()
199 .create(true)
200 .append(true)
201 .open(&log)
202 else {
203 return;
204 };
205 let Ok(err) = out.try_clone() else { return };
206 let Ok(out2) = out.try_clone() else { return };
207 let spawned = detached(
212 std::process::Command::new("setsid").arg(exe).args(&args),
213 out2,
214 err,
215 )
216 .spawn();
217 if spawned.is_err() {
218 let Ok(out3) = std::fs::OpenOptions::new()
219 .create(true)
220 .append(true)
221 .open(&log)
222 else {
223 return;
224 };
225 let Ok(err2) = out3.try_clone() else { return };
226 let _ = detached(std::process::Command::new(exe).args(&args), out3, err2).spawn();
227 }
228}
229
230pub fn restart_one(exe: &str, pane: &str) {
232 let mut out = std::io::stdout();
233
234 if pane.starts_with("dead:") {
238 clear(&mut out);
239 println!("\x1b[1mtaimux: that session has already ended\x1b[0m\n");
240 println!(" There is nothing running to restart. Press Enter on it instead:");
241 println!(" it opens again in a new window, in its own directory.\n");
242 any_key();
243 return;
244 }
245
246 if let Some(host) = remote::pane_host(pane) {
251 clear(&mut out);
252 println!(
253 "\x1b[1mtaimux: {} is on {}\x1b[0m\n",
254 remote::pane_local(pane),
255 host
256 );
257 println!(" Restarting is local-only. From that host, or from here:\n");
258 println!(
259 " ssh {} taimux restart --pane {}\n",
260 host,
261 remote::pane_local(pane)
262 );
263 any_key();
264 return;
265 }
266 if !pane.starts_with('%') {
267 return; }
269
270 let plan = plan_of(exe, &["-n", "--pane", pane]);
271 if plan.contains("\nto restart (") || plan.starts_with("to restart (") {
272 restart_detached(exe, pane, false);
273 return;
274 }
275
276 clear(&mut out);
277 println!("\x1b[1mtaimux: {} will not restart cleanly\x1b[0m\n", pane);
278 let Some(why) = why_for(&plan, pane) else {
279 println!(" Nothing to do: it is already on the installed version, or it is");
280 println!(" not a claude pane.\n");
281 any_key();
282 return;
283 };
284 println!(" {}", why);
285
286 let forced = plan_of(exe, &["-n", "--pane", pane, "--include-busy"]);
290 if !(forced.contains("\nto restart (") || forced.starts_with("to restart (")) {
291 println!(
292 "\n Forcing would not help:\n {}",
293 why_for(&forced, pane).unwrap_or_else(|| "same refusal".into())
294 );
295 println!();
296 any_key();
297 return;
298 }
299
300 println!("\n Forcing accepts losing an in-flight turn. A pane holding a");
301 println!(" permission dialog is still refused, so nothing gets answered for you.");
302 print!("\n\x1b[1mForce the restart?\x1b[0m [Y/n] ");
303 let _ = out.flush();
304 let go = match read_one_key() {
308 Pressed::Key(c) => restart::confirm_yes(&c.to_string()),
309 Pressed::Unreadable => {
310 print!("\r\n (no readable terminal to answer on, so nothing was restarted)");
311 false
312 }
313 };
314 println!();
315 if go {
316 restart_detached(exe, pane, true);
317 println!(
318 "\n Forced, detached. Watch the version, or read\n {}",
319 taimux_core::paths::runtime_dir()
320 .join("restart.log")
321 .display()
322 );
323 std::thread::sleep(std::time::Duration::from_millis(1200));
324 } else {
325 println!("\n Left alone.");
326 std::thread::sleep(std::time::Duration::from_millis(600));
327 }
328}
329
330pub fn sweep(exe: &str) {
337 let mut out = std::io::stdout();
338 let at = std::time::Instant::now();
345 let plan = plan_of(exe, &["-n"]);
346 let planned = at.elapsed();
347 let n = count_in(&plan, "to restart (");
348 let skipped = count_in(&plan, "skipped (");
349
350 clear(&mut out);
351 println!("\x1b[1mtaimux: restart every outdated session\x1b[0m\n");
352
353 let Some(n) = n.filter(|n| *n >= 1) else {
354 println!("Nothing to restart. Either every session is already on the");
355 println!("installed version, or the ones behind it are busy.");
356 if let Some(s) = skipped {
357 println!("\n {} left alone.", s);
358 }
359 println!();
360 any_key();
361 return;
362 };
363
364 for l in plan_panes(&plan) {
365 println!("{}", l);
366 }
367 if let Some(s) = skipped {
368 println!("\n {} left alone (working, waiting, or unidentified).", s);
369 }
370 print!("\n\x1b[1mRestart {} session(s)?\x1b[0m [Y/n] ", n);
371 let _ = out.flush();
372 let go = match read_one_key() {
376 Pressed::Key(c) => restart::confirm_yes(&c.to_string()),
377 Pressed::Unreadable => {
378 print!("\r\n (no readable terminal to answer on, so nothing was restarted)");
379 false
380 }
381 };
382 println!();
383 if go {
384 note(&format!(
385 "sweep: {} to restart, plan took {:.1}s, {:.1}s from keypress to firing",
386 n,
387 planned.as_secs_f32(),
388 at.elapsed().as_secs_f32()
389 ));
390 restart_detached(exe, "", false);
391 println!(
392 "\nStarted, detached. Watch the version column, or read\n{}",
393 taimux_core::paths::runtime_dir()
394 .join("restart.log")
395 .display()
396 );
397 std::thread::sleep(std::time::Duration::from_millis(1200));
398 } else {
399 println!("\nNothing restarted.");
400 std::thread::sleep(std::time::Duration::from_millis(700));
401 }
402}
403
404fn plan_of(exe: &str, args: &[&str]) -> String {
410 std::process::Command::new(exe)
411 .arg("restart")
412 .args(args)
413 .output()
414 .map(|o| {
415 let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
416 s.push_str(&String::from_utf8_lossy(&o.stderr));
417 s
418 })
419 .unwrap_or_default()
420}
421
422pub fn conversation_of(pane: &str) -> Result<(String, String), String> {
433 if let Some((agent, key)) = taimux_core::index::split_past_id(pane) {
434 return Ok((agent.to_string(), key.to_string()));
435 }
436 if pane == "dead:!" {
437 return Err("that row is a note, not a conversation".into());
438 }
439 if remote::pane_host(pane).is_some() {
440 return Err(format!(
441 "{} is on another host, and a handoff reads its transcript and starts \
442 an agent in its directory, both of which have to happen over there",
443 remote::pane_local(pane)
444 ));
445 }
446 let rows = taimux_core::panes::agent_rows();
447 let row = rows
448 .lines()
449 .map(|l| l.split('\t').collect::<Vec<_>>())
450 .find(|f| f.len() >= 5 && f[0] == pane)
451 .ok_or_else(|| format!("{} is not running an agent, or is not there any more", pane))?;
456 if row[3] != "claude" {
457 return Err(format!(
458 "taimux cannot tell which conversation a {} pane is on, so there is \
459 nothing to hand over. Only claude publishes that",
460 row[3]
461 ));
462 }
463 let pid: i32 = row[4].parse().unwrap_or(0);
464 let cwd = std::fs::read_link(format!("/proc/{}/cwd", pid))
465 .map(|p| p.to_string_lossy().into_owned())
466 .unwrap_or_else(|_| row[2].to_string());
467 match taimux_core::conv::resolve_from_pane(pane, &cwd, pid) {
468 Some(r) => Ok(("claude".into(), r.transcript.to_string_lossy().into_owned())),
469 None => Err("that pane's conversation could not be identified".into()),
470 }
471}
472
473pub fn handoff_one(pane: &str) {
475 let mut out = std::io::stdout();
476 clear(&mut out);
477
478 let (agent, key) = match conversation_of(pane) {
479 Ok(v) => v,
480 Err(why) => {
481 println!("\x1b[1mtaimux: nothing to hand off here\x1b[0m\n");
482 println!(" {}.\n", why);
483 any_key();
484 return;
485 }
486 };
487
488 let targets: Vec<&str> = taimux_core::handoff::installed()
492 .into_iter()
493 .filter(|t| *t != agent)
494 .collect();
495 if targets.is_empty() {
496 println!("\x1b[1mtaimux: no other agent is installed\x1b[0m\n");
497 println!(" A handoff starts a DIFFERENT tool on this conversation, and");
498 println!(" {} is the only one on your PATH.\n", agent);
499 any_key();
500 return;
501 }
502
503 let meta = taimux_core::agents::meta(&agent, &key);
504 let title = if meta.title.is_empty() {
505 "(no title)"
506 } else {
507 &meta.title
508 };
509 println!(
510 "\x1b[1mtaimux: continue this {} conversation elsewhere\x1b[0m\n",
511 taimux_core::handoff::display_name(&agent)
512 );
513 println!(" \x1b[1;36m{}\x1b[0m", title);
514 println!(
515 " \x1b[90m{}\x1b[0m\n",
516 if meta.cwd.is_empty() { "?" } else { &meta.cwd }
517 );
518 println!(" The prompt carries the task, the repository's state and the last");
519 println!(" few turns, and points at the transcript for the rest.\n");
520 for (i, t) in targets.iter().enumerate() {
521 println!(
522 " \x1b[1m{}\x1b[0m {}",
523 i + 1,
524 taimux_core::handoff::display_name(t)
525 );
526 }
527 println!("\n Anything else cancels.");
528 let _ = out.flush();
529
530 let Pressed::Key(c) = read_one_key() else {
531 println!("\r\n (no readable terminal to ask on)");
532 return;
533 };
534 let Some(target) = c
535 .to_digit(10)
536 .and_then(|n| targets.get(n as usize - 1).copied())
537 else {
538 return; };
540
541 let turns = taimux_core::env::var("TAIMUX_HANDOFF_TURNS")
544 .and_then(|v| v.parse().ok())
545 .unwrap_or(taimux_core::handoff::DEFAULT_TURNS);
546 let prompt = taimux_core::handoff::build(&agent, &key, turns);
547 let cmd = match taimux_core::handoff::launch(target, &prompt) {
548 Ok(c) => c,
549 Err(why) => {
550 println!("\r\n\r\n {}.\r\n", why);
551 any_key();
552 return;
553 }
554 };
555 let cwd = if std::path::Path::new(&meta.cwd).is_dir() {
558 meta.cwd.clone()
559 } else {
560 clear(&mut out);
561 println!(
562 "\x1b[1mtaimux: {} is gone\x1b[0m\n",
563 if meta.cwd.is_empty() {
564 "its directory"
565 } else {
566 &meta.cwd
567 }
568 );
569 println!(" A handoff starts an agent in the directory the conversation ran");
570 println!(" in, and that one is no longer there.\n");
571 any_key();
572 return;
573 };
574
575 if taimux_core::tmux::run(&["new-window", "-c", &cwd, &cmd]) {
576 return; }
578 clear(&mut out);
579 println!("\x1b[1mtaimux: tmux would not open a window\x1b[0m\n");
580 any_key();
581}
582
583#[cfg(test)]
584mod tests {
585
586 #[test]
594 fn a_detached_job_does_not_hold_the_terminal() {
595 let dir = std::env::temp_dir().join(format!("taimux-detached-{}", std::process::id()));
596 std::fs::create_dir_all(&dir).unwrap();
597 let logp = dir.join("log");
598 let out = std::fs::File::create(&logp).unwrap();
599 let err = out.try_clone().unwrap();
600
601 let mut cmd = std::process::Command::new("sleep");
602 cmd.arg("30");
603 let had_tty = match std::fs::File::open("/dev/tty") {
608 Ok(tty) => {
609 cmd.stdin(std::process::Stdio::from(tty));
610 true
611 }
612 Err(_) => false,
613 };
614 let mut child = detached(&mut cmd, out, err).spawn().expect("spawn sleep");
615
616 let fd0 = std::fs::read_link(format!("/proc/{}/fd/0", child.id()));
617 let _ = child.kill();
618 let _ = child.wait();
619 let _ = std::fs::remove_dir_all(&dir);
620
621 let Ok(fd0) = fd0 else { return };
623 if !had_tty {
624 eprintln!("no /dev/tty in this environment, so this proves nothing");
627 return;
628 }
629 let fd0 = fd0.to_string_lossy().into_owned();
630 assert!(
631 !fd0.contains("/pts/") && !fd0.contains("/dev/tty"),
632 "a detached job kept a terminal on stdin: {fd0}"
633 );
634 assert!(
635 fd0.contains("null"),
636 "expected /dev/null on stdin, got {fd0}"
637 );
638 }
639
640 use super::*;
641
642 const PLAN: &str = "claude: 2.1.258 installed at /l/claude\n\
643 \n\
644 to restart (2):\n\
645 \x20 %19 platform:4.1 a title\n\
646 \x20 2.1.100 -> 2.1.258, pane map, idle\n\
647 \x20 command claude --resume /t.jsonl\n\
648 \x20 %23 platform:4.5 another\n\
649 \x20 2.1.100 -> 2.1.258, pane map, idle\n\
650 \x20 command claude\n\
651 \n\
652 skipped (3):\n\
653 \x20 %77 main:1.1 2.1.100, run: rerun when idle\n";
654
655 #[test]
659 fn only_the_pane_lines_of_the_plan_are_shown() {
660 assert_eq!(
661 plan_panes(PLAN),
662 vec![
663 " %19 platform:4.1 a title",
664 " %23 platform:4.5 another"
665 ]
666 );
667 }
668
669 #[test]
670 fn the_counts_come_off_the_headings() {
671 assert_eq!(count_in(PLAN, "to restart ("), Some(2));
672 assert_eq!(count_in(PLAN, "skipped ("), Some(3));
673 assert_eq!(count_in("nothing to restart.\n", "to restart ("), None);
674 }
675
676 #[test]
677 fn a_panes_own_reason_is_picked_out_of_the_skip_list() {
678 assert_eq!(
679 why_for(PLAN, "%77").as_deref(),
680 Some("%77 main:1.1 2.1.100, run: rerun when idle")
681 );
682 assert_eq!(why_for(PLAN, "%99"), None);
683 }
684
685 #[test]
688 fn an_empty_plan_is_not_mistaken_for_a_full_one() {
689 let empty = "claude: 2.1.258 installed at /l\n\nnothing to restart.\n";
690 assert!(plan_panes(empty).is_empty());
691 assert_eq!(count_in(empty, "to restart ("), None);
692 }
693}