1use std::collections::HashMap;
23
24use taimux_core::{conv, state, tmux};
25
26pub fn transcript_is_settled(text: &str, now: i64) -> Result<(), String> {
40 let Some(last) = text.lines().rfind(|l| !l.trim().is_empty()) else {
41 return Ok(()); };
43 if taimux_core::json::field(last, "type") == "assistant"
44 && (last.contains("\"type\":\"tool_use\"") || last.contains("\"type\": \"tool_use\""))
45 {
46 return Err("a tool call is still running".into());
47 }
48 let ts = taimux_core::json::field(last, "timestamp");
49 if ts.is_empty() {
50 return Ok(());
51 }
52 let Some(then) = parse_iso8601(&ts) else {
53 return Ok(()); };
55 if now - then < 45 {
56 return Err("active in the last 45s".into());
57 }
58 Ok(())
59}
60
61fn parse_iso8601(s: &str) -> Option<i64> {
68 let b = s.as_bytes();
69 if b.len() < 19
70 || b[4] != b'-'
71 || b[7] != b'-'
72 || b[10] != b'T'
73 || b[13] != b':'
74 || b[16] != b':'
75 {
76 return None;
77 }
78 let n = |a: usize, z: usize| s[a..z].parse::<i64>().ok();
79 let (y, mo, d) = (n(0, 4)?, n(5, 7)?, n(8, 10)?);
80 let (h, mi, sec) = (n(11, 13)?, n(14, 16)?, n(17, 19)?);
81 if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
82 return None;
83 }
84 Some(days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + sec)
85}
86
87fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
90 let y = if m <= 2 { y - 1 } else { y };
91 let era = if y >= 0 { y } else { y - 399 } / 400;
92 let yoe = y - era * 400;
93 let mp = (m + 9) % 12;
94 let doy = (153 * mp + 2) / 5 + d - 1;
95 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
96 era * 146097 + doe - 719468
97}
98
99pub fn screen_has_no_dialog(screen: &str) -> Result<(), String> {
104 if state::awaits_input(screen) {
105 return Err("a dialog is waiting for an answer".into());
106 }
107 let tail: Vec<&str> = screen
108 .lines()
109 .filter(|l| !l.trim().is_empty())
110 .rev()
111 .take(4)
112 .collect();
113 if tail
114 .iter()
115 .any(|l| l.contains("Press Ctrl-C again to exit"))
116 {
117 return Err("a Ctrl-C is already half-pressed".into());
118 }
119 Ok(())
120}
121
122pub fn screen_has_no_draft(screen: &str) -> Result<(), String> {
128 let txt: Vec<&str> = screen.lines().filter(|l| !l.trim().is_empty()).collect();
129 if txt.is_empty() {
130 return Ok(());
131 }
132 let Some(box_line) = txt.iter().rev().find(|l| l.contains('❯')) else {
133 return Err("no prompt box on screen".into());
134 };
135 let after = box_line.split_once('❯').map(|(_, r)| r).unwrap_or("");
136 let rest: String = after
137 .chars()
138 .filter(|c| !c.is_whitespace() && *c != '\u{a0}')
139 .collect();
140 if rest.is_empty() {
141 Ok(())
142 } else {
143 Err("unsent text in the prompt box".into())
144 }
145}
146
147const BOX_ROWS: usize = 6;
162
163type Cmd = Vec<String>;
165
166type ZoomSteps = (Vec<Cmd>, Vec<Cmd>);
168
169fn zoom_steps(
185 pane: &str,
186 zoomed: bool,
187 pane_rows: usize,
188 window_rows: usize,
189 active: &str,
190 last: &str,
191) -> Option<ZoomSteps> {
192 if pane_rows >= BOX_ROWS || window_rows < BOX_ROWS || window_rows <= pane_rows {
193 return None;
194 }
195 let cmd = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Cmd>();
196
197 let mut go = Vec::new();
198 if zoomed {
203 go.push(cmd(&["resize-pane", "-Z", "-t", pane]));
204 }
205 go.push(cmd(&["resize-pane", "-Z", "-t", pane]));
206
207 let mut back = vec![cmd(&["resize-pane", "-Z", "-t", pane])];
208 if active != pane {
214 if !last.is_empty() && last != pane {
215 back.push(cmd(&["select-pane", "-t", last]));
216 }
217 if !active.is_empty() {
218 back.push(cmd(&["select-pane", "-t", active]));
219 }
220 }
221 if zoomed && !active.is_empty() {
225 back.push(cmd(&["resize-pane", "-Z", "-t", active]));
226 }
227 Some((go, back))
228}
229
230struct Restoring(Vec<Cmd>);
236
237impl Drop for Restoring {
238 fn drop(&mut self) {
239 for c in &self.0 {
240 tmux::run(&c.iter().map(String::as_str).collect::<Vec<_>>());
241 }
242 }
243}
244
245pub fn capture_zoomed(pane: &str) -> Option<String> {
258 if !taimux_core::env::on("TAIMUX_ZOOM_TO_READ") {
259 return None;
260 }
261 let geom = tmux::ask(&[
262 "display-message",
263 "-p",
264 "-t",
265 pane,
266 "-F",
267 "#{window_zoomed_flag}\t#{pane_height}\t#{window_height}\t#{window_id}",
268 ])?;
269 let g: Vec<&str> = geom.trim_end().split('\t').collect();
270 if g.len() < 4 {
271 return None;
272 }
273 let (zoomed, win) = (g[0] == "1", g[3]);
274 let pane_rows: usize = g[1].parse().ok()?;
275 let window_rows: usize = g[2].parse().ok()?;
276
277 let mut active = String::new();
278 let mut last = String::new();
279 for l in tmux::ask(&[
280 "list-panes",
281 "-t",
282 win,
283 "-F",
284 "#{pane_id}\t#{pane_active}\t#{pane_last}",
285 ])?
286 .lines()
287 {
288 let c: Vec<&str> = l.split('\t').collect();
289 if c.len() < 3 {
290 continue;
291 }
292 if c[1] == "1" {
293 active = c[0].to_string();
294 }
295 if c[2] == "1" {
296 last = c[0].to_string();
297 }
298 }
299
300 let (go, back) = zoom_steps(pane, zoomed, pane_rows, window_rows, &active, &last)?;
301 for c in &go {
302 tmux::run(&c.iter().map(String::as_str).collect::<Vec<_>>());
303 }
304 let _restore = Restoring(back);
305
306 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
307 loop {
308 let screen = tmux::capture(pane).unwrap_or_default();
309 if screen.contains('❯') || std::time::Instant::now() >= deadline {
310 return Some(screen);
311 }
312 std::thread::sleep(std::time::Duration::from_millis(10));
313 }
314}
315
316pub fn version_of_pid(pid: i32, versions_dir: &str) -> Option<String> {
320 let exe = std::fs::read_link(format!("/proc/{}/exe", pid)).ok()?;
321 let exe = exe.to_string_lossy();
322 let exe = exe.trim_end_matches(" (deleted)"); let prefix = format!("{}/", versions_dir);
324 exe.strip_prefix(&prefix)
325 .filter(|rest| !rest.is_empty())
326 .map(|rest| rest.split('/').next().unwrap_or(rest).to_string())
327}
328
329pub struct Planned {
331 pub pane: String,
332 pub target: String,
333 pub pid: i32,
334 pub cmd: String,
335 pub title: String,
336 pub via: String,
337}
338
339pub struct Plan {
340 pub newver: String,
341 pub launcher: String,
342 pub go: Vec<Planned>,
343 pub skipped: Vec<String>,
344 pub unresolved: bool,
347}
348
349pub struct Opts {
350 pub include_busy: bool,
351 pub only_panes: Vec<String>,
352 pub force_transcript: Option<String>,
353 pub self_pane: String,
354}
355
356pub trait Env {
359 fn capture(&self, pane: &str) -> String;
360 fn capture_zoomed(&self, _pane: &str) -> Option<String> {
367 None
368 }
369 fn hook_state(&self, pane: &str, pid: i32) -> Option<String>;
370 fn version_of_pid(&self, pid: i32) -> Option<String>;
371 fn cwd_of(&self, pid: i32) -> Option<String>;
372 fn argv_of(&self, pid: i32) -> Vec<String>;
373 fn resolve(&self, pane: &str, cwd: &str, title: &str, pid: i32) -> Result<String, String>;
374 fn read_transcript(&self, path: &str) -> Option<String>;
375 fn now(&self) -> i64;
376}
377
378pub fn plan(
384 rows: &str,
385 newver: &str,
386 launcher: &str,
387 o: &Opts,
388 e: &dyn Env,
389 versions_dir: &str,
390 agents: &dyn Fn() -> String,
391) -> Plan {
392 let mut p = Plan {
393 newver: newver.to_string(),
394 launcher: launcher.to_string(),
395 go: Vec::new(),
396 skipped: Vec::new(),
397 unresolved: false,
398 };
399 let mut claimed: HashMap<String, String> = HashMap::new();
400 let mut matched: Vec<i32> = Vec::new();
403
404 for line in rows.lines() {
405 let f: Vec<&str> = line.split('\t').collect();
406 if f.len() < 7 || f[3] != "claude" {
407 continue;
408 }
409 let (id, tgt, cwd, title) = (f[0], f[1], f[2], f[6]);
410 let pid: i32 = f[4].parse().unwrap_or(0);
411
412 let Some(ver) = (pid != 0).then(|| e.version_of_pid(pid)).flatten() else {
413 p.skipped.push(format!(
414 "{} {} looks like claude but no session process was found under the pane",
415 id, tgt
416 ));
417 continue;
418 };
419 matched.push(pid);
420 if !o.only_panes.is_empty() && !o.only_panes.iter().any(|w| w == id) {
421 continue;
422 }
423 if ver == newver {
424 continue; }
426 if id == o.self_pane {
427 p.skipped.push(format!(
428 "{} {} {}, this pane: restart it by hand (killing it would kill taimux)",
429 id, tgt, ver
430 ));
431 continue;
432 }
433
434 let mut screen = e.capture(id);
435 if !screen.contains('❯') {
444 if let Some(bigger) = e.capture_zoomed(id) {
445 screen = bigger;
446 }
447 }
448 let st = state::merge(&screen, e.hook_state(id, pid).as_deref());
449 if st.as_str() != "idle" && !o.include_busy {
450 p.skipped.push(format!(
451 "{} {} {}, {}: rerun when idle, or --include-busy",
452 id,
453 tgt,
454 ver,
455 st.as_str()
456 ));
457 continue;
458 }
459
460 let ccwd = e.cwd_of(pid).unwrap_or_else(|| cwd.to_string());
461 let (transcript, via) = match &o.force_transcript {
462 Some(t) => (t.clone(), "--transcript, given".to_string()),
463 None => match e.resolve(id, &ccwd, title, pid) {
464 Ok(t) => {
465 let (path, why) = t.split_once('\t').unwrap_or((t.as_str(), ""));
467 (path.to_string(), why.to_string())
468 }
469 Err(why) => {
470 p.skipped
471 .push(format!("{} {} {}, unresolved: {}", id, tgt, ver, why));
472 p.unresolved = true;
473 continue;
474 }
475 },
476 };
477
478 if let Err(why) = screen_has_no_dialog(&screen) {
479 p.skipped
480 .push(format!("{} {} {}, not settled: {}", id, tgt, ver, why));
481 continue;
482 }
483 if !o.include_busy {
484 let settled =
485 screen_has_no_draft(&screen).and_then(|()| match e.read_transcript(&transcript) {
486 Some(text) => transcript_is_settled(&text, e.now()),
487 None => Ok(()),
488 });
489 if let Err(why) = settled {
490 p.skipped
491 .push(format!("{} {} {}, not settled: {}", id, tgt, ver, why));
492 continue;
493 }
494 }
495
496 if let Some(first) = claimed.get(&transcript) {
497 p.skipped.push(format!(
498 "{} {} {}, resolves to the same transcript as {}",
499 id, tgt, ver, first
500 ));
501 continue;
502 }
503 claimed.insert(transcript.clone(), id.to_string());
504
505 let Some(cmd) = conv::build_cmd(&e.argv_of(pid), &transcript, &ccwd, cwd) else {
506 p.skipped
507 .push(format!("{} {} {}, could not read its argv", id, tgt, ver));
508 continue;
509 };
510 p.go.push(Planned {
511 pane: id.to_string(),
512 target: tgt.to_string(),
513 pid,
514 cmd,
515 title: title.to_string(),
516 via: format!("{} -> {}, {}, {}", ver, newver, via, st.as_str()),
517 });
518 }
519
520 let mut agents_json: Option<String> = None;
525 for np in nonpane_pids(&matched, versions_dir) {
526 let Some(ver) = e.version_of_pid(np) else {
527 continue;
528 };
529 if ver == newver {
530 continue;
531 }
532 let j = agents_json.get_or_insert_with(agents);
533 p.skipped.push(format!(
534 "pid {} {}, not a tmux pane: {}",
535 np,
536 ver,
537 describe_nonpane(&e.argv_of(np), j)
538 ));
539 }
540 p
541}
542
543pub fn render(p: &Plan) -> String {
546 let mut s = format!("claude: {} installed at {}\n\n", p.newver, p.launcher);
547 if p.go.is_empty() {
548 s.push_str("nothing to restart.\n");
549 } else {
550 s.push_str(&format!("to restart ({}):\n", p.go.len()));
551 for g in &p.go {
552 s.push_str(&format!(" {:<5} {:<14} {}\n", g.pane, g.target, g.title));
553 s.push_str(&format!(" {}\n", g.via));
554 s.push_str(&format!(" {}\n", g.cmd));
555 }
556 }
557 if !p.skipped.is_empty() {
558 s.push_str(&format!("\nskipped ({}):\n", p.skipped.len()));
559 for k in &p.skipped {
560 s.push_str(&format!(" {}\n", k));
561 }
562 }
563 if p.unresolved {
564 s.push_str(
565 "\nAn unresolved pane means guessing, so it was left alone: restart it by hand\n\
566 with `claude -c` in that pane, or from its /resume picker. Each session records\n\
567 its pane at the next SessionStart, so a pane resolves cleanly once restarted.\n",
568 );
569 }
570 s
571}
572
573pub fn restart_pane(pane: &str, pid: i32, cmd: &str) -> bool {
580 use std::thread::sleep;
581 use std::time::Duration;
582
583 tmux::run(&["send-keys", "-t", pane, "C-c"]);
584 sleep(Duration::from_millis(300));
585 tmux::run(&["send-keys", "-t", pane, "C-c"]);
586
587 let mut waited = 0;
588 let mut sent_exit = false;
589 while alive(pid) {
590 sleep(Duration::from_millis(400));
591 waited += 1;
592 if waited >= 6 && !sent_exit {
593 tmux::run(&["send-keys", "-t", pane, "/exit", "Enter"]);
594 sent_exit = true;
595 }
596 if waited >= 30 {
597 return false;
598 }
599 }
600 sleep(Duration::from_millis(500));
601 tmux::run(&["send-keys", "-t", pane, "C-c"]);
602 sleep(Duration::from_millis(200));
603 tmux::run(&["send-keys", "-t", pane, cmd, "Enter"])
604}
605
606fn alive(pid: i32) -> bool {
609 std::path::Path::new(&format!("/proc/{}", pid)).exists()
610}
611
612pub fn confirm_yes(answer: &str) -> bool {
615 matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES" | "Yes")
616}
617
618pub struct Live {
620 pub versions_dir: String,
621}
622
623impl Env for Live {
624 fn capture(&self, pane: &str) -> String {
625 tmux::ask_raw(&["capture-pane", "-p", "-t", pane]).unwrap_or_default()
626 }
627 fn capture_zoomed(&self, pane: &str) -> Option<String> {
628 capture_zoomed(pane)
629 }
630 fn hook_state(&self, pane: &str, pid: i32) -> Option<String> {
631 taimux_core::hook::hook_state_of(pane, pid)
632 }
633 fn version_of_pid(&self, pid: i32) -> Option<String> {
634 version_of_pid(pid, &self.versions_dir)
635 }
636 fn cwd_of(&self, pid: i32) -> Option<String> {
637 std::fs::read_link(format!("/proc/{}/cwd", pid))
638 .ok()
639 .map(|p| p.to_string_lossy().into_owned())
640 .filter(|s| !s.is_empty())
641 }
642 fn argv_of(&self, pid: i32) -> Vec<String> {
643 conv::argv_of(pid)
644 }
645 fn resolve(&self, pane: &str, cwd: &str, title: &str, pid: i32) -> Result<String, String> {
646 conv::resolve(pane, cwd, title, pid)
647 .map(|r| format!("{}\t{}", r.transcript.display(), r.why))
648 }
649 fn read_transcript(&self, path: &str) -> Option<String> {
650 std::fs::read_to_string(path).ok()
651 }
652 fn now(&self) -> i64 {
653 std::time::SystemTime::now()
654 .duration_since(std::time::UNIX_EPOCH)
655 .map(|d| d.as_secs() as i64)
656 .unwrap_or(0)
657 }
658}
659
660pub fn installed(launcher: &str, versions_dir: &str) -> Result<String, String> {
663 let target = std::fs::canonicalize(launcher)
664 .map(|p| p.to_string_lossy().into_owned())
665 .unwrap_or_default();
666 let prefix = format!("{}/", versions_dir);
667 match target.strip_prefix(&prefix) {
668 Some(rest) if !rest.is_empty() => Ok(rest.split('/').next().unwrap_or(rest).to_string()),
669 _ => Err(format!(
670 "restart: {} does not point into {} (got '{}')",
671 launcher,
672 versions_dir,
673 if target.is_empty() {
674 "nothing"
675 } else {
676 &target
677 }
678 )),
679 }
680}
681
682pub fn versions_dir() -> String {
683 format!(
684 "{}/.local/share/claude/versions",
685 std::env::var("HOME").unwrap_or_default()
686 )
687}
688
689pub fn launcher() -> String {
690 format!(
691 "{}/.local/bin/claude",
692 std::env::var("HOME").unwrap_or_default()
693 )
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn a_timestamp_becomes_epoch_seconds() {
702 assert_eq!(parse_iso8601("2026-09-02T01:23:45.678Z"), Some(1788312225));
704 assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), Some(0));
705 assert_eq!(parse_iso8601("2024-02-29T00:00:00Z"), Some(1709164800));
707 }
708
709 #[test]
713 fn an_unparseable_timestamp_is_no_answer() {
714 assert_eq!(parse_iso8601(""), None);
715 assert_eq!(parse_iso8601("yesterday"), None);
716 assert_eq!(parse_iso8601("2026-09-02 01:23:45"), None);
717 assert_eq!(parse_iso8601("2026-13-02T01:23:45Z"), None);
718 }
719
720 #[test]
721 fn a_tool_call_still_running_is_not_settled() {
722 let t = r#"{"type":"assistant","message":{"content":[{"type":"tool_use"}]},"timestamp":"2020-01-01T00:00:00Z"}"#;
723 assert_eq!(
724 transcript_is_settled(t, 1788312225),
725 Err("a tool call is still running".into())
726 );
727 }
728
729 #[test]
731 fn recent_activity_is_not_settled_whatever_the_screen_said() {
732 let t = r#"{"type":"user","timestamp":"2026-09-02T01:23:45Z"}"#;
733 assert!(transcript_is_settled(t, 1788312225 + 10).is_err());
734 assert!(transcript_is_settled(t, 1788312225 + 100).is_ok());
735 }
736
737 #[test]
738 fn an_empty_or_odd_transcript_does_not_block() {
739 assert!(transcript_is_settled("", 0).is_ok());
740 assert!(transcript_is_settled("not json at all\n", 0).is_ok());
741 assert!(transcript_is_settled(r#"{"type":"user"}"#, 0).is_ok());
743 }
744
745 #[test]
749 fn a_dialog_on_screen_blocks_a_restart() {
750 let footer = "some output\n\nDo you want to proceed?\n";
751 assert_eq!(
752 screen_has_no_dialog(footer),
753 Err("a dialog is waiting for an answer".into())
754 );
755 let choice = "some output\n1. Yes\n2. No\n❯ 1. Yes\n";
756 assert_eq!(
757 screen_has_no_dialog(choice),
758 Err("a dialog is waiting for an answer".into())
759 );
760 assert!(screen_has_no_dialog("some output\n❯ \n").is_ok());
762 }
763
764 #[test]
767 fn a_half_pressed_ctrl_c_blocks_a_restart() {
768 let screen = "work\n\nPress Ctrl-C again to exit\n";
769 assert_eq!(
770 screen_has_no_dialog(screen),
771 Err("a Ctrl-C is already half-pressed".into())
772 );
773 }
774
775 #[test]
776 fn an_empty_prompt_box_is_no_draft() {
777 assert!(screen_has_no_draft("stuff\n❯ \n").is_ok());
778 assert!(screen_has_no_draft("stuff\n❯ \u{a0}\u{a0}\n").is_ok());
780 assert_eq!(
781 screen_has_no_draft("stuff\n❯ half a thought\n"),
782 Err("unsent text in the prompt box".into())
783 );
784 }
785
786 #[test]
789 fn a_pane_tall_enough_to_read_is_left_alone() {
790 assert!(zoom_steps("%1", false, 6, 40, "%2", "%3").is_none());
792 assert!(zoom_steps("%1", false, 40, 40, "%2", "%3").is_none());
793 assert!(zoom_steps("%1", false, 3, 3, "%2", "%3").is_none());
795 assert!(zoom_steps("%1", false, 3, 5, "%2", "%3").is_none());
796 }
797
798 #[test]
802 fn a_short_pane_is_zoomed_and_the_selection_put_back() {
803 let (go, back) = zoom_steps("%1", false, 3, 17, "%2", "%3").unwrap();
804 assert_eq!(go, vec![vec!["resize-pane", "-Z", "-t", "%1"]]);
805 assert_eq!(
806 back,
807 vec![
808 vec!["resize-pane", "-Z", "-t", "%1"],
809 vec!["select-pane", "-t", "%3"],
810 vec!["select-pane", "-t", "%2"],
811 ]
812 );
813 }
814
815 #[test]
819 fn a_window_zoomed_elsewhere_is_handed_back_zoomed() {
820 let (go, back) = zoom_steps("%1", true, 3, 17, "%2", "%3").unwrap();
821 assert_eq!(
822 go,
823 vec![
824 vec!["resize-pane", "-Z", "-t", "%1"],
825 vec!["resize-pane", "-Z", "-t", "%1"],
826 ]
827 );
828 assert_eq!(
829 back,
830 vec![
831 vec!["resize-pane", "-Z", "-t", "%1"],
832 vec!["select-pane", "-t", "%3"],
833 vec!["select-pane", "-t", "%2"],
834 vec!["resize-pane", "-Z", "-t", "%2"],
835 ]
836 );
837 }
838
839 #[test]
842 fn an_active_short_pane_has_no_selection_to_restore() {
843 let (go, back) = zoom_steps("%1", false, 3, 17, "%1", "%3").unwrap();
844 assert_eq!(go.len(), 1);
845 assert_eq!(back, vec![vec!["resize-pane", "-Z", "-t", "%1"]]);
846 }
847
848 #[test]
851 fn no_prompt_box_is_a_refusal() {
852 assert_eq!(
853 screen_has_no_draft("just some text\n"),
854 Err("no prompt box on screen".into())
855 );
856 assert!(screen_has_no_draft("").is_ok());
858 assert!(screen_has_no_draft(" \n\n").is_ok());
859 }
860
861 #[test]
862 fn a_bare_enter_confirms() {
863 assert!(confirm_yes(""));
864 assert!(confirm_yes("y"));
865 assert!(confirm_yes("YES"));
866 assert!(!confirm_yes("n"));
867 assert!(!confirm_yes("no"));
868 assert!(!confirm_yes("maybe"));
869 }
870
871 #[test]
872 fn the_launcher_must_point_into_the_versions_dir() {
873 let root = std::env::temp_dir().join(format!("jmrs{}", std::process::id()));
874 let vers = root.join("versions");
875 std::fs::create_dir_all(&vers).unwrap();
876 std::fs::write(vers.join("2.1.258"), "x").unwrap();
877 let link = root.join("claude");
878 std::os::unix::fs::symlink(vers.join("2.1.258"), &link).unwrap();
879 assert_eq!(
880 installed(&link.to_string_lossy(), &vers.to_string_lossy()),
881 Ok("2.1.258".into())
882 );
883 std::fs::write(root.join("elsewhere"), "x").unwrap();
885 std::fs::remove_file(&link).unwrap();
886 std::os::unix::fs::symlink(root.join("elsewhere"), &link).unwrap();
887 let e = installed(&link.to_string_lossy(), &vers.to_string_lossy()).expect_err("refused");
888 assert!(e.contains("elsewhere"));
889 let _ = std::fs::remove_dir_all(&root);
890 }
891}
892
893pub fn nonpane_pids(matched: &[i32], versions_dir: &str) -> Vec<i32> {
899 let mut out = Vec::new();
900 for e in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
901 let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<i32>().ok()) else {
902 continue;
903 };
904 if matched.contains(&pid) {
905 continue;
906 }
907 let comm = std::fs::read_to_string(format!("/proc/{}/comm", pid)).unwrap_or_default();
910 let argv = conv::argv_of(pid);
911 let is_claude = comm.trim() == "claude"
912 || argv
913 .first()
914 .map(|a| a.starts_with(&format!("{}/", versions_dir)))
915 .unwrap_or(false);
916 if is_claude {
917 out.push(pid);
918 }
919 }
920 out.sort_unstable();
921 out
922}
923
924fn json_object_with_prefix(text: &str, key: &str, prefix: &str) -> Option<String> {
932 let needle = format!("\"{}\":\"{}", key, prefix);
933 let at = text.find(&needle).or_else(|| {
934 let spaced = format!("\"{}\": \"{}", key, prefix);
935 text.find(&spaced)
936 })?;
937 let mut depth = 0i32;
939 let bytes = text.as_bytes();
940 let mut start = None;
941 for i in (0..at).rev() {
942 match bytes[i] {
943 b'}' => depth += 1,
944 b'{' => {
945 if depth == 0 {
946 start = Some(i);
947 break;
948 }
949 depth -= 1;
950 }
951 _ => {}
952 }
953 }
954 let start = start?;
955 let mut depth = 0i32;
957 for i in start..bytes.len() {
958 match bytes[i] {
959 b'{' => depth += 1,
960 b'}' => {
961 depth -= 1;
962 if depth == 0 {
963 return Some(text[start..=i].to_string());
964 }
965 }
966 _ => {}
967 }
968 }
969 None
970}
971
972pub fn describe_nonpane(argv: &[String], agents_json: &str) -> String {
979 if argv.is_empty() {
980 return "gone".into();
981 }
982 let (mut sid, mut res, mut forked) = (String::new(), String::new(), false);
983 for i in 1..argv.len() {
984 match argv[i].as_str() {
985 "--fork-session" => forked = true,
986 "--session-id" => sid = argv.get(i + 1).cloned().unwrap_or_default(),
987 "-r" | "--resume" => res = argv.get(i + 1).cloned().unwrap_or_default(),
988 _ => {}
989 }
990 }
991 let own = base_id(if sid.is_empty() { &res } else { &sid });
992 let parent = if forked && !res.is_empty() {
993 base_id(&res)
994 } else {
995 String::new()
996 };
997
998 let (mut kind, mut st, mut name) = ("?".to_string(), "?".to_string(), String::new());
999 if !own.is_empty() {
1000 if let Some(obj) = json_object_with_prefix(agents_json, "sessionId", &own) {
1001 let f = |k: &str| taimux_core::json::field(&obj, k);
1002 let k = f("kind");
1003 if !k.is_empty() {
1004 kind = k;
1005 }
1006 let s = {
1007 let a = f("state");
1008 if a.is_empty() {
1009 f("status")
1010 } else {
1011 a
1012 }
1013 };
1014 if !s.is_empty() {
1015 st = s;
1016 }
1017 name = f("name");
1018 }
1019 }
1020 let mut out = format!("{} {}", kind, st);
1021 if forked {
1022 out.push_str(" fork");
1023 }
1024 if !name.is_empty() {
1025 out.push_str(&format!(" \"{}\"", name));
1026 }
1027 if !own.is_empty() {
1028 out.push_str(&format!(" [{}]", own.chars().take(8).collect::<String>()));
1029 }
1030 if !parent.is_empty() {
1031 out.push_str(&format!(
1032 " of [{}]",
1033 parent.chars().take(8).collect::<String>()
1034 ));
1035 }
1036 out
1037}
1038
1039fn base_id(s: &str) -> String {
1041 if s.is_empty() {
1042 return String::new();
1043 }
1044 let b = s.rsplit('/').next().unwrap_or(s);
1045 b.strip_suffix(".jsonl").unwrap_or(b).to_string()
1046}
1047
1048#[cfg(test)]
1049mod nonpane_tests {
1050 use super::*;
1051
1052 fn v(a: &[&str]) -> Vec<String> {
1053 a.iter().map(|s| s.to_string()).collect()
1054 }
1055
1056 #[test]
1057 fn a_session_id_is_read_out_of_a_path_or_taken_as_it_stands() {
1058 assert_eq!(base_id("/a/b/263946b5-9bd7.jsonl"), "263946b5-9bd7");
1059 assert_eq!(base_id("263946b5"), "263946b5");
1060 assert_eq!(base_id(""), "");
1061 }
1062
1063 #[test]
1067 fn the_right_object_comes_back_whole() {
1068 let j = r#"[{"sessionId":"aaa111","kind":"task","meta":{"a":1},"name":"first"},
1069 {"sessionId":"bbb222","kind":"agent","name":"second"}]"#;
1070 let o = json_object_with_prefix(j, "sessionId", "bbb222").expect("found");
1071 assert!(o.contains("second"));
1072 assert!(!o.contains("first"));
1073 let o = json_object_with_prefix(j, "sessionId", "aaa").expect("found by prefix");
1074 assert!(o.contains("first"));
1075 assert!(
1076 o.contains("\"meta\":{\"a\":1}"),
1077 "nested field truncated: {}",
1078 o
1079 );
1080 assert!(json_object_with_prefix(j, "sessionId", "zzz").is_none());
1081 }
1082
1083 #[test]
1084 fn a_process_with_no_argv_is_simply_gone() {
1085 assert_eq!(describe_nonpane(&[], "[]"), "gone");
1086 }
1087
1088 #[test]
1089 fn an_unnamed_session_still_says_what_it_can() {
1090 let d = describe_nonpane(&v(&["claude", "--session-id", "263946b5-9bd7"]), "[]");
1091 assert_eq!(d, "? ? [263946b5]");
1092 }
1093
1094 #[test]
1095 fn a_named_one_says_kind_state_and_name() {
1096 let j =
1097 r#"[{"sessionId":"263946b5-9bd7","kind":"task","state":"running","name":"the thing"}]"#;
1098 let d = describe_nonpane(&v(&["claude", "--session-id", "263946b5-9bd7"]), j);
1099 assert_eq!(d, "task running \"the thing\" [263946b5]");
1100 }
1101
1102 #[test]
1105 fn status_stands_in_for_state() {
1106 let j = r#"[{"sessionId":"aaa11111","kind":"agent","status":"idle"}]"#;
1107 let d = describe_nonpane(&v(&["claude", "--session-id", "aaa11111"]), j);
1108 assert_eq!(d, "agent idle [aaa11111]");
1109 }
1110
1111 #[test]
1114 fn a_fork_names_its_parent_too() {
1115 let d = describe_nonpane(
1116 &v(&[
1117 "claude",
1118 "--session-id",
1119 "child111",
1120 "--fork-session",
1121 "--resume",
1122 "/p/parent22.jsonl",
1123 ]),
1124 "[]",
1125 );
1126 assert_eq!(d, "? ? fork [child111] of [parent22]");
1127 }
1128}
1129
1130#[cfg(test)]
1131mod plan_tests {
1132 use super::*;
1133
1134 struct Fake {
1139 vers: HashMap<i32, String>,
1141 screens: HashMap<String, String>,
1143 resolved: HashMap<String, Result<String, String>>,
1145 zoomed: HashMap<String, String>,
1148 zooms: std::cell::Cell<usize>,
1151 transcript: String,
1152 now: i64,
1153 }
1154
1155 impl Env for Fake {
1156 fn capture(&self, pane: &str) -> String {
1157 self.screens.get(pane).cloned().unwrap_or_default()
1158 }
1159 fn capture_zoomed(&self, pane: &str) -> Option<String> {
1160 let bigger = self.zoomed.get(pane).cloned();
1161 if bigger.is_some() {
1162 self.zooms.set(self.zooms.get() + 1);
1163 }
1164 bigger
1165 }
1166 fn hook_state(&self, _pane: &str, _pid: i32) -> Option<String> {
1167 None
1168 }
1169 fn version_of_pid(&self, pid: i32) -> Option<String> {
1170 self.vers.get(&pid).cloned()
1171 }
1172 fn cwd_of(&self, _pid: i32) -> Option<String> {
1173 Some("/w".into())
1174 }
1175 fn argv_of(&self, _pid: i32) -> Vec<String> {
1176 vec!["claude".into()]
1177 }
1178 fn resolve(&self, pane: &str, _c: &str, _t: &str, _p: i32) -> Result<String, String> {
1179 self.resolved
1180 .get(pane)
1181 .cloned()
1182 .unwrap_or_else(|| Err("no candidate".into()))
1183 }
1184 fn read_transcript(&self, _path: &str) -> Option<String> {
1185 Some(self.transcript.clone())
1186 }
1187 fn now(&self) -> i64 {
1188 self.now
1189 }
1190 }
1191
1192 fn fake() -> Fake {
1193 let mut vers = HashMap::new();
1194 vers.insert(11, "2.1.100".to_string()); vers.insert(22, "2.1.258".to_string()); let mut screens = HashMap::new();
1197 screens.insert("%1".to_string(), "some output\n❯ \n".to_string());
1199 screens.insert("%2".to_string(), "some output\n❯ \n".to_string());
1200 let mut resolved = HashMap::new();
1201 resolved.insert("%1".to_string(), Ok("/t/a.jsonl\tpane map".to_string()));
1202 Fake {
1203 vers,
1204 screens,
1205 resolved,
1206 zoomed: HashMap::new(),
1207 zooms: std::cell::Cell::new(0),
1208 transcript: r#"{"type":"user","timestamp":"2020-01-01T00:00:00Z"}"#.to_string(),
1209 now: 1788312225,
1210 }
1211 }
1212
1213 fn opts() -> Opts {
1214 Opts {
1215 include_busy: false,
1216 only_panes: Vec::new(),
1217 force_transcript: None,
1218 self_pane: String::new(),
1219 }
1220 }
1221
1222 const ROWS: &str = "%1\tw:1.1\t/w\tclaude\t11\tclaude\tproj: the stale one\n\
1223 %2\tw:2.1\t/w\tclaude\t22\tclaude\tproj: the current one";
1224
1225 fn plan_of(e: &Fake, o: &Opts) -> Plan {
1226 plan(ROWS, "2.1.258", "/l/claude", o, e, "/nowhere", &|| {
1227 "[]".into()
1228 })
1229 }
1230
1231 #[test]
1232 fn a_stale_pane_is_planned_and_a_current_one_is_not() {
1233 let p = plan_of(&fake(), &opts());
1234 assert_eq!(p.go.len(), 1);
1235 assert_eq!(p.go[0].pane, "%1");
1236 assert_eq!(p.go[0].cmd, "command claude --resume /t/a.jsonl");
1237 assert_eq!(p.go[0].via, "2.1.100 -> 2.1.258, pane map, idle");
1238 assert!(p.skipped.is_empty());
1239 }
1240
1241 #[test]
1244 fn the_rendered_plan_reads_the_way_it_always_did() {
1245 let out = render(&plan_of(&fake(), &opts()));
1246 assert_eq!(
1247 out,
1248 "claude: 2.1.258 installed at /l/claude\n\n\
1249 to restart (1):\n\
1250 \x20 %1 w:1.1 proj: the stale one\n\
1251 \x20 2.1.100 -> 2.1.258, pane map, idle\n\
1252 \x20 command claude --resume /t/a.jsonl\n"
1253 );
1254 }
1255
1256 #[test]
1258 fn this_pane_is_never_restarted() {
1259 let mut o = opts();
1260 o.self_pane = "%1".into();
1261 let p = plan_of(&fake(), &o);
1262 assert!(p.go.is_empty());
1263 assert!(p.skipped[0].contains("this pane"));
1264 assert!(p.skipped[0].contains("killing it would kill taimux"));
1265 }
1266
1267 #[test]
1268 fn a_pane_that_is_not_idle_waits_for_include_busy() {
1269 let mut e = fake();
1270 e.screens
1272 .insert("%1".into(), "Twisting… (35s · ↓ 1.6k tokens)\n❯ \n".into());
1273 let p = plan_of(&e, &opts());
1274 assert!(p.go.is_empty());
1275 assert!(p.skipped[0].contains("rerun when idle, or --include-busy"));
1276
1277 let mut o = opts();
1278 o.include_busy = true;
1279 assert_eq!(plan_of(&e, &o).go.len(), 1);
1280 }
1281
1282 #[test]
1285 fn a_dialog_blocks_a_restart_even_with_include_busy() {
1286 let mut e = fake();
1287 e.screens
1288 .insert("%1".into(), "output\n\nDo you want to proceed?\n".into());
1289 let mut o = opts();
1290 o.include_busy = true;
1291 let p = plan_of(&e, &o);
1292 assert!(p.go.is_empty());
1293 assert!(p.skipped[0].contains("a dialog is waiting for an answer"));
1294 }
1295
1296 #[test]
1297 fn an_unsent_draft_is_left_alone() {
1298 let mut e = fake();
1299 e.screens
1300 .insert("%1".into(), "output\n❯ half a thought\n".into());
1301 let p = plan_of(&e, &opts());
1302 assert!(p.skipped[0].contains("unsent text in the prompt box"));
1303 }
1304
1305 #[test]
1309 fn a_pane_too_short_for_its_box_is_read_zoomed() {
1310 let mut e = fake();
1311 e.screens.insert(
1312 "%1".into(),
1313 " current: 2.1.100 · latest…\n────────\n".into(),
1314 );
1315 e.zoomed.insert("%1".into(), "some output\n❯ \n".into());
1316 let p = plan_of(&e, &opts());
1317 assert_eq!(e.zooms.get(), 1);
1318 assert_eq!(p.go.len(), 1);
1319 assert_eq!(p.go[0].pane, "%1");
1320 assert!(p.skipped.is_empty());
1321 }
1322
1323 #[test]
1328 fn a_draft_hidden_by_a_short_pane_still_refuses() {
1329 let mut e = fake();
1330 e.screens
1331 .insert("%1".into(), " current: 2.1.100…\n".into());
1332 e.zoomed
1333 .insert("%1".into(), "output\n❯ half a thought\n".into());
1334 let p = plan_of(&e, &opts());
1335 assert_eq!(e.zooms.get(), 1);
1336 assert!(p.go.is_empty());
1337 assert!(p.skipped[0].contains("unsent text in the prompt box"));
1338 }
1339
1340 #[test]
1344 fn a_dialog_hidden_by_a_short_pane_is_found_by_the_zoom() {
1345 let mut e = fake();
1346 e.screens.insert("%1".into(), " Bash command\n".into());
1347 e.zoomed.insert(
1348 "%1".into(),
1349 "Do you want to proceed?\n❯ 1. Yes\n 2. No\n".into(),
1350 );
1351 let mut o = opts();
1352 o.include_busy = true;
1353 let p = plan_of(&e, &o);
1354 assert!(p.go.is_empty());
1355 assert!(p.skipped[0].contains("a dialog is waiting for an answer"));
1356 }
1357
1358 #[test]
1361 fn a_pane_that_shows_its_box_is_not_zoomed() {
1362 let e = fake();
1363 let p = plan_of(&e, &opts());
1364 assert_eq!(e.zooms.get(), 0);
1365 assert_eq!(p.go.len(), 1);
1366 }
1367
1368 #[test]
1371 fn a_recently_active_transcript_is_left_alone() {
1372 let mut e = fake();
1373 e.transcript = r#"{"type":"user","timestamp":"2026-09-02T01:23:45Z"}"#.into();
1374 e.now = 1788312225 + 10;
1375 let p = plan_of(&e, &opts());
1376 assert!(p.skipped[0].contains("active in the last 45s"));
1377 }
1378
1379 #[test]
1380 fn an_unresolved_pane_says_so_and_earns_the_paragraph() {
1381 let mut e = fake();
1382 e.resolved
1383 .insert("%1".into(), Err("3 transcripts share this title".into()));
1384 let p = plan_of(&e, &opts());
1385 assert!(p.go.is_empty());
1386 assert!(p.unresolved);
1387 assert!(p.skipped[0].contains("unresolved: 3 transcripts share this title"));
1388 assert!(render(&p).contains("restart it by hand"));
1389 }
1390
1391 #[test]
1394 fn one_transcript_is_never_resumed_into_two_panes() {
1395 let mut e = fake();
1396 e.vers.insert(22, "2.1.100".into()); e.resolved
1398 .insert("%2".into(), Ok("/t/a.jsonl\tpane map".into()));
1399 let p = plan_of(&e, &opts());
1400 assert_eq!(p.go.len(), 1);
1401 assert_eq!(p.go[0].pane, "%1");
1402 assert!(p.skipped[0].contains("resolves to the same transcript as %1"));
1403 }
1404
1405 #[test]
1406 fn only_panes_narrows_the_plan_without_changing_the_verdicts() {
1407 let mut o = opts();
1408 o.only_panes = vec!["%2".into()];
1409 let p = plan_of(&fake(), &o);
1410 assert!(p.go.is_empty());
1411 assert!(p.skipped.is_empty()); }
1413
1414 #[test]
1415 fn a_pane_with_no_session_process_is_reported_not_dropped() {
1416 let rows = "%9\tw:9.9\t/w\tclaude\t0\tclaude\tno pid here";
1417 let p = plan(rows, "2.1.258", "/l", &opts(), &fake(), "/nowhere", &|| {
1418 "[]".into()
1419 });
1420 assert!(p.go.is_empty());
1421 assert_eq!(p.skipped.len(), 1);
1422 assert!(p.skipped[0].contains("no session process was found"));
1423 }
1424
1425 #[test]
1426 fn nothing_to_restart_says_so() {
1427 let mut e = fake();
1428 e.vers.insert(11, "2.1.258".into());
1429 let out = render(&plan_of(&e, &opts()));
1430 assert!(out.contains("nothing to restart."));
1431 assert!(!out.contains("to restart ("));
1432 }
1433
1434 #[test]
1437 fn a_given_transcript_overrides_the_ladder() {
1438 let mut e = fake();
1439 e.resolved.insert("%1".into(), Err("no candidate".into()));
1440 let mut o = opts();
1441 o.force_transcript = Some("/given.jsonl".into());
1442 let p = plan_of(&e, &o);
1443 assert_eq!(p.go.len(), 1);
1444 assert!(p.go[0].via.contains("--transcript, given"));
1445 assert!(p.go[0].cmd.contains("--resume /given.jsonl"));
1446 }
1447}