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> {
65 taimux_core::turn::epoch_ms(s).map(|ms| ms.div_euclid(1000))
66}
67
68pub fn screen_has_no_dialog(screen: &str) -> Result<(), String> {
73 if state::awaits_input(screen) {
74 return Err("a dialog is waiting for an answer".into());
75 }
76 let tail: Vec<&str> = screen
77 .lines()
78 .filter(|l| !l.trim().is_empty())
79 .rev()
80 .take(4)
81 .collect();
82 if tail
83 .iter()
84 .any(|l| l.contains("Press Ctrl-C again to exit"))
85 {
86 return Err("a Ctrl-C is already half-pressed".into());
87 }
88 Ok(())
89}
90
91pub fn screen_has_no_draft(screen: &str) -> Result<(), String> {
97 let txt: Vec<&str> = screen.lines().filter(|l| !l.trim().is_empty()).collect();
98 if txt.is_empty() {
99 return Ok(());
100 }
101 let Some(box_line) = txt.iter().rev().find(|l| l.contains('❯')) else {
102 return Err("no prompt box on screen".into());
103 };
104 let after = box_line.split_once('❯').map(|(_, r)| r).unwrap_or("");
105 let rest: String = after
106 .chars()
107 .filter(|c| !c.is_whitespace() && *c != '\u{a0}')
108 .collect();
109 if rest.is_empty() {
110 Ok(())
111 } else {
112 Err("unsent text in the prompt box".into())
113 }
114}
115
116const BOX_ROWS: usize = 6;
131
132type Cmd = Vec<String>;
134
135type ZoomSteps = (Vec<Cmd>, Vec<Cmd>);
137
138fn zoom_steps(
154 pane: &str,
155 zoomed: bool,
156 pane_rows: usize,
157 window_rows: usize,
158 active: &str,
159 last: &str,
160) -> Option<ZoomSteps> {
161 if pane_rows >= BOX_ROWS || window_rows < BOX_ROWS || window_rows <= pane_rows {
162 return None;
163 }
164 let cmd = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Cmd>();
165
166 let mut go = Vec::new();
167 if zoomed {
172 go.push(cmd(&["resize-pane", "-Z", "-t", pane]));
173 }
174 go.push(cmd(&["resize-pane", "-Z", "-t", pane]));
175
176 let mut back = vec![cmd(&["resize-pane", "-Z", "-t", pane])];
177 if active != pane {
183 if !last.is_empty() && last != pane {
184 back.push(cmd(&["select-pane", "-t", last]));
185 }
186 if !active.is_empty() {
187 back.push(cmd(&["select-pane", "-t", active]));
188 }
189 }
190 if zoomed && !active.is_empty() {
194 back.push(cmd(&["resize-pane", "-Z", "-t", active]));
195 }
196 Some((go, back))
197}
198
199struct Restoring(Vec<Cmd>);
205
206impl Drop for Restoring {
207 fn drop(&mut self) {
208 for c in &self.0 {
209 tmux::run(&c.iter().map(String::as_str).collect::<Vec<_>>());
210 }
211 }
212}
213
214pub fn capture_zoomed(pane: &str) -> Option<String> {
227 if !taimux_core::env::on("TAIMUX_ZOOM_TO_READ") {
228 return None;
229 }
230 let geom = tmux::ask(&[
231 "display-message",
232 "-p",
233 "-t",
234 pane,
235 "-F",
236 "#{window_zoomed_flag}\t#{pane_height}\t#{window_height}\t#{window_id}",
237 ])?;
238 let g: Vec<&str> = geom.trim_end().split('\t').collect();
239 if g.len() < 4 {
240 return None;
241 }
242 let (zoomed, win) = (g[0] == "1", g[3]);
243 let pane_rows: usize = g[1].parse().ok()?;
244 let window_rows: usize = g[2].parse().ok()?;
245
246 let mut active = String::new();
247 let mut last = String::new();
248 for l in tmux::ask(&[
249 "list-panes",
250 "-t",
251 win,
252 "-F",
253 "#{pane_id}\t#{pane_active}\t#{pane_last}",
254 ])?
255 .lines()
256 {
257 let c: Vec<&str> = l.split('\t').collect();
258 if c.len() < 3 {
259 continue;
260 }
261 if c[1] == "1" {
262 active = c[0].to_string();
263 }
264 if c[2] == "1" {
265 last = c[0].to_string();
266 }
267 }
268
269 let (go, back) = zoom_steps(pane, zoomed, pane_rows, window_rows, &active, &last)?;
270 for c in &go {
271 tmux::run(&c.iter().map(String::as_str).collect::<Vec<_>>());
272 }
273 let _restore = Restoring(back);
274
275 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
276 loop {
277 let screen = tmux::capture(pane).unwrap_or_default();
278 if screen.contains('❯') || std::time::Instant::now() >= deadline {
279 return Some(screen);
280 }
281 std::thread::sleep(std::time::Duration::from_millis(10));
282 }
283}
284
285pub fn version_of_pid(pid: i32, versions_dir: &str) -> Option<String> {
289 let exe = std::fs::read_link(format!("/proc/{}/exe", pid)).ok()?;
290 let exe = exe.to_string_lossy();
291 let exe = exe.trim_end_matches(" (deleted)"); let prefix = format!("{}/", versions_dir);
293 exe.strip_prefix(&prefix)
294 .filter(|rest| !rest.is_empty())
295 .map(|rest| rest.split('/').next().unwrap_or(rest).to_string())
296}
297
298pub struct Planned {
300 pub pane: String,
301 pub target: String,
302 pub pid: i32,
303 pub cmd: String,
304 pub title: String,
305 pub via: String,
306}
307
308pub struct Plan {
309 pub newver: String,
310 pub launcher: String,
311 pub go: Vec<Planned>,
312 pub skipped: Vec<String>,
313 pub unresolved: bool,
316}
317
318pub struct Opts {
319 pub include_busy: bool,
320 pub only_panes: Vec<String>,
321 pub force_transcript: Option<String>,
322 pub self_pane: String,
323}
324
325pub trait Env {
328 fn capture(&self, pane: &str) -> String;
329 fn capture_zoomed(&self, _pane: &str) -> Option<String> {
336 None
337 }
338 fn hook_state(&self, pane: &str, pid: i32) -> Option<String>;
339 fn version_of_pid(&self, pid: i32) -> Option<String>;
340 fn cwd_of(&self, pid: i32) -> Option<String>;
341 fn argv_of(&self, pid: i32) -> Vec<String>;
342 fn resolve(&self, pane: &str, cwd: &str, title: &str, pid: i32) -> Result<String, String>;
343 fn read_transcript(&self, path: &str) -> Option<String>;
344 fn now(&self) -> i64;
345}
346
347pub fn plan(
353 rows: &str,
354 newver: &str,
355 launcher: &str,
356 o: &Opts,
357 e: &dyn Env,
358 versions_dir: &str,
359 agents: &dyn Fn() -> String,
360) -> Plan {
361 let mut p = Plan {
362 newver: newver.to_string(),
363 launcher: launcher.to_string(),
364 go: Vec::new(),
365 skipped: Vec::new(),
366 unresolved: false,
367 };
368 let mut claimed: HashMap<String, String> = HashMap::new();
369 let mut matched: Vec<i32> = Vec::new();
372
373 for line in rows.lines() {
374 let f: Vec<&str> = line.split('\t').collect();
375 if f.len() < 7 || f[3] != "claude" {
376 continue;
377 }
378 let (id, tgt, cwd, title) = (f[0], f[1], f[2], f[6]);
379 let pid: i32 = f[4].parse().unwrap_or(0);
380
381 let Some(ver) = (pid != 0).then(|| e.version_of_pid(pid)).flatten() else {
382 p.skipped.push(format!(
383 "{} {} looks like claude but no session process was found under the pane",
384 id, tgt
385 ));
386 continue;
387 };
388 matched.push(pid);
389 if !o.only_panes.is_empty() && !o.only_panes.iter().any(|w| w == id) {
390 continue;
391 }
392 if ver == newver {
393 continue; }
395 if id == o.self_pane {
396 p.skipped.push(format!(
397 "{} {} {}, this pane: restart it by hand (killing it would kill taimux)",
398 id, tgt, ver
399 ));
400 continue;
401 }
402
403 let mut screen = e.capture(id);
404 if !screen.contains('❯') {
413 if let Some(bigger) = e.capture_zoomed(id) {
414 screen = bigger;
415 }
416 }
417 let st = state::merge(&screen, e.hook_state(id, pid).as_deref());
418 if st.as_str() != "idle" && !o.include_busy {
419 p.skipped.push(format!(
420 "{} {} {}, {}: rerun when idle, or --include-busy",
421 id,
422 tgt,
423 ver,
424 st.as_str()
425 ));
426 continue;
427 }
428
429 let ccwd = e.cwd_of(pid).unwrap_or_else(|| cwd.to_string());
430 let (transcript, via) = match &o.force_transcript {
431 Some(t) => (t.clone(), "--transcript, given".to_string()),
432 None => match e.resolve(id, &ccwd, title, pid) {
433 Ok(t) => {
434 let (path, why) = t.split_once('\t').unwrap_or((t.as_str(), ""));
436 (path.to_string(), why.to_string())
437 }
438 Err(why) => {
439 p.skipped
440 .push(format!("{} {} {}, unresolved: {}", id, tgt, ver, why));
441 p.unresolved = true;
442 continue;
443 }
444 },
445 };
446
447 if let Err(why) = screen_has_no_dialog(&screen) {
448 p.skipped
449 .push(format!("{} {} {}, not settled: {}", id, tgt, ver, why));
450 continue;
451 }
452 if !o.include_busy {
453 let settled =
454 screen_has_no_draft(&screen).and_then(|()| match e.read_transcript(&transcript) {
455 Some(text) => transcript_is_settled(&text, e.now()),
456 None => Ok(()),
457 });
458 if let Err(why) = settled {
459 p.skipped
460 .push(format!("{} {} {}, not settled: {}", id, tgt, ver, why));
461 continue;
462 }
463 }
464
465 if let Some(first) = claimed.get(&transcript) {
466 p.skipped.push(format!(
467 "{} {} {}, resolves to the same transcript as {}",
468 id, tgt, ver, first
469 ));
470 continue;
471 }
472 claimed.insert(transcript.clone(), id.to_string());
473
474 let Some(cmd) = conv::build_cmd(&e.argv_of(pid), &transcript, &ccwd, cwd) else {
475 p.skipped
476 .push(format!("{} {} {}, could not read its argv", id, tgt, ver));
477 continue;
478 };
479 p.go.push(Planned {
480 pane: id.to_string(),
481 target: tgt.to_string(),
482 pid,
483 cmd,
484 title: title.to_string(),
485 via: format!("{} -> {}, {}, {}", ver, newver, via, st.as_str()),
486 });
487 }
488
489 let mut agents_json: Option<String> = None;
494 for np in nonpane_pids(&matched, versions_dir) {
495 let Some(ver) = e.version_of_pid(np) else {
496 continue;
497 };
498 if ver == newver {
499 continue;
500 }
501 let j = agents_json.get_or_insert_with(agents);
502 p.skipped.push(format!(
503 "pid {} {}, not a tmux pane: {}",
504 np,
505 ver,
506 describe_nonpane(&e.argv_of(np), j)
507 ));
508 }
509 p
510}
511
512pub fn render(p: &Plan) -> String {
515 let mut s = format!("claude: {} installed at {}\n\n", p.newver, p.launcher);
516 if p.go.is_empty() {
517 s.push_str("nothing to restart.\n");
518 } else {
519 s.push_str(&format!("to restart ({}):\n", p.go.len()));
520 for g in &p.go {
521 s.push_str(&format!(" {:<5} {:<14} {}\n", g.pane, g.target, g.title));
522 s.push_str(&format!(" {}\n", g.via));
523 s.push_str(&format!(" {}\n", g.cmd));
524 }
525 }
526 if !p.skipped.is_empty() {
527 s.push_str(&format!("\nskipped ({}):\n", p.skipped.len()));
528 for k in &p.skipped {
529 s.push_str(&format!(" {}\n", k));
530 }
531 }
532 if p.unresolved {
533 s.push_str(
534 "\nAn unresolved pane means guessing, so it was left alone: restart it by hand\n\
535 with `claude -c` in that pane, or from its /resume picker. Each session records\n\
536 its pane at the next SessionStart, so a pane resolves cleanly once restarted.\n",
537 );
538 }
539 s
540}
541
542pub fn restart_pane(pane: &str, pid: i32, cmd: &str) -> bool {
549 use std::thread::sleep;
550 use std::time::Duration;
551
552 tmux::run(&["send-keys", "-t", pane, "C-c"]);
553 sleep(Duration::from_millis(300));
554 tmux::run(&["send-keys", "-t", pane, "C-c"]);
555
556 let mut waited = 0;
557 let mut sent_exit = false;
558 while alive(pid) {
559 sleep(Duration::from_millis(400));
560 waited += 1;
561 if waited >= 6 && !sent_exit {
562 tmux::run(&["send-keys", "-t", pane, "/exit", "Enter"]);
563 sent_exit = true;
564 }
565 if waited >= 30 {
566 return false;
567 }
568 }
569 sleep(Duration::from_millis(500));
570 tmux::run(&["send-keys", "-t", pane, "C-c"]);
571 sleep(Duration::from_millis(200));
572 tmux::run(&["send-keys", "-t", pane, cmd, "Enter"])
573}
574
575fn alive(pid: i32) -> bool {
578 std::path::Path::new(&format!("/proc/{}", pid)).exists()
579}
580
581pub fn confirm_yes(answer: &str) -> bool {
584 matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES" | "Yes")
585}
586
587pub struct Live {
589 pub versions_dir: String,
590}
591
592impl Env for Live {
593 fn capture(&self, pane: &str) -> String {
594 tmux::ask_raw(&["capture-pane", "-p", "-t", pane]).unwrap_or_default()
595 }
596 fn capture_zoomed(&self, pane: &str) -> Option<String> {
597 capture_zoomed(pane)
598 }
599 fn hook_state(&self, pane: &str, pid: i32) -> Option<String> {
600 taimux_core::hook::hook_state_of(pane, pid)
601 }
602 fn version_of_pid(&self, pid: i32) -> Option<String> {
603 version_of_pid(pid, &self.versions_dir)
604 }
605 fn cwd_of(&self, pid: i32) -> Option<String> {
606 std::fs::read_link(format!("/proc/{}/cwd", pid))
607 .ok()
608 .map(|p| p.to_string_lossy().into_owned())
609 .filter(|s| !s.is_empty())
610 }
611 fn argv_of(&self, pid: i32) -> Vec<String> {
612 conv::argv_of(pid)
613 }
614 fn resolve(&self, pane: &str, cwd: &str, title: &str, pid: i32) -> Result<String, String> {
615 conv::resolve(pane, cwd, title, pid)
616 .map(|r| format!("{}\t{}", r.transcript.display(), r.why))
617 }
618 fn read_transcript(&self, path: &str) -> Option<String> {
619 std::fs::read_to_string(path).ok()
620 }
621 fn now(&self) -> i64 {
622 std::time::SystemTime::now()
623 .duration_since(std::time::UNIX_EPOCH)
624 .map(|d| d.as_secs() as i64)
625 .unwrap_or(0)
626 }
627}
628
629pub fn installed(launcher: &str, versions_dir: &str) -> Result<String, String> {
632 let target = std::fs::canonicalize(launcher)
633 .map(|p| p.to_string_lossy().into_owned())
634 .unwrap_or_default();
635 let prefix = format!("{}/", versions_dir);
636 match target.strip_prefix(&prefix) {
637 Some(rest) if !rest.is_empty() => Ok(rest.split('/').next().unwrap_or(rest).to_string()),
638 _ => Err(format!(
639 "restart: {} does not point into {} (got '{}')",
640 launcher,
641 versions_dir,
642 if target.is_empty() {
643 "nothing"
644 } else {
645 &target
646 }
647 )),
648 }
649}
650
651pub fn versions_dir() -> String {
652 format!(
653 "{}/.local/share/claude/versions",
654 std::env::var("HOME").unwrap_or_default()
655 )
656}
657
658pub fn launcher() -> String {
659 format!(
660 "{}/.local/bin/claude",
661 std::env::var("HOME").unwrap_or_default()
662 )
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn a_timestamp_becomes_epoch_seconds() {
671 assert_eq!(parse_iso8601("2026-09-02T01:23:45.678Z"), Some(1788312225));
673 assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), Some(0));
674 assert_eq!(parse_iso8601("2024-02-29T00:00:00Z"), Some(1709164800));
676 }
677
678 #[test]
682 fn an_unparseable_timestamp_is_no_answer() {
683 assert_eq!(parse_iso8601(""), None);
684 assert_eq!(parse_iso8601("yesterday"), None);
685 assert_eq!(parse_iso8601("2026-09-02 01:23:45"), None);
686 assert_eq!(parse_iso8601("2026-13-02T01:23:45Z"), None);
687 }
688
689 #[test]
690 fn a_tool_call_still_running_is_not_settled() {
691 let t = r#"{"type":"assistant","message":{"content":[{"type":"tool_use"}]},"timestamp":"2020-01-01T00:00:00Z"}"#;
692 assert_eq!(
693 transcript_is_settled(t, 1788312225),
694 Err("a tool call is still running".into())
695 );
696 }
697
698 #[test]
700 fn recent_activity_is_not_settled_whatever_the_screen_said() {
701 let t = r#"{"type":"user","timestamp":"2026-09-02T01:23:45Z"}"#;
702 assert!(transcript_is_settled(t, 1788312225 + 10).is_err());
703 assert!(transcript_is_settled(t, 1788312225 + 100).is_ok());
704 }
705
706 #[test]
707 fn an_empty_or_odd_transcript_does_not_block() {
708 assert!(transcript_is_settled("", 0).is_ok());
709 assert!(transcript_is_settled("not json at all\n", 0).is_ok());
710 assert!(transcript_is_settled(r#"{"type":"user"}"#, 0).is_ok());
712 }
713
714 #[test]
718 fn a_dialog_on_screen_blocks_a_restart() {
719 let footer = "some output\n\nDo you want to proceed?\n";
720 assert_eq!(
721 screen_has_no_dialog(footer),
722 Err("a dialog is waiting for an answer".into())
723 );
724 let choice = "some output\n1. Yes\n2. No\n❯ 1. Yes\n";
725 assert_eq!(
726 screen_has_no_dialog(choice),
727 Err("a dialog is waiting for an answer".into())
728 );
729 assert!(screen_has_no_dialog("some output\n❯ \n").is_ok());
731 }
732
733 #[test]
736 fn a_half_pressed_ctrl_c_blocks_a_restart() {
737 let screen = "work\n\nPress Ctrl-C again to exit\n";
738 assert_eq!(
739 screen_has_no_dialog(screen),
740 Err("a Ctrl-C is already half-pressed".into())
741 );
742 }
743
744 #[test]
745 fn an_empty_prompt_box_is_no_draft() {
746 assert!(screen_has_no_draft("stuff\n❯ \n").is_ok());
747 assert!(screen_has_no_draft("stuff\n❯ \u{a0}\u{a0}\n").is_ok());
749 assert_eq!(
750 screen_has_no_draft("stuff\n❯ half a thought\n"),
751 Err("unsent text in the prompt box".into())
752 );
753 }
754
755 #[test]
758 fn a_pane_tall_enough_to_read_is_left_alone() {
759 assert!(zoom_steps("%1", false, 6, 40, "%2", "%3").is_none());
761 assert!(zoom_steps("%1", false, 40, 40, "%2", "%3").is_none());
762 assert!(zoom_steps("%1", false, 3, 3, "%2", "%3").is_none());
764 assert!(zoom_steps("%1", false, 3, 5, "%2", "%3").is_none());
765 }
766
767 #[test]
771 fn a_short_pane_is_zoomed_and_the_selection_put_back() {
772 let (go, back) = zoom_steps("%1", false, 3, 17, "%2", "%3").unwrap();
773 assert_eq!(go, vec![vec!["resize-pane", "-Z", "-t", "%1"]]);
774 assert_eq!(
775 back,
776 vec![
777 vec!["resize-pane", "-Z", "-t", "%1"],
778 vec!["select-pane", "-t", "%3"],
779 vec!["select-pane", "-t", "%2"],
780 ]
781 );
782 }
783
784 #[test]
788 fn a_window_zoomed_elsewhere_is_handed_back_zoomed() {
789 let (go, back) = zoom_steps("%1", true, 3, 17, "%2", "%3").unwrap();
790 assert_eq!(
791 go,
792 vec![
793 vec!["resize-pane", "-Z", "-t", "%1"],
794 vec!["resize-pane", "-Z", "-t", "%1"],
795 ]
796 );
797 assert_eq!(
798 back,
799 vec![
800 vec!["resize-pane", "-Z", "-t", "%1"],
801 vec!["select-pane", "-t", "%3"],
802 vec!["select-pane", "-t", "%2"],
803 vec!["resize-pane", "-Z", "-t", "%2"],
804 ]
805 );
806 }
807
808 #[test]
811 fn an_active_short_pane_has_no_selection_to_restore() {
812 let (go, back) = zoom_steps("%1", false, 3, 17, "%1", "%3").unwrap();
813 assert_eq!(go.len(), 1);
814 assert_eq!(back, vec![vec!["resize-pane", "-Z", "-t", "%1"]]);
815 }
816
817 #[test]
820 fn no_prompt_box_is_a_refusal() {
821 assert_eq!(
822 screen_has_no_draft("just some text\n"),
823 Err("no prompt box on screen".into())
824 );
825 assert!(screen_has_no_draft("").is_ok());
827 assert!(screen_has_no_draft(" \n\n").is_ok());
828 }
829
830 #[test]
831 fn a_bare_enter_confirms() {
832 assert!(confirm_yes(""));
833 assert!(confirm_yes("y"));
834 assert!(confirm_yes("YES"));
835 assert!(!confirm_yes("n"));
836 assert!(!confirm_yes("no"));
837 assert!(!confirm_yes("maybe"));
838 }
839
840 #[test]
841 fn the_launcher_must_point_into_the_versions_dir() {
842 let root = std::env::temp_dir().join(format!("jmrs{}", std::process::id()));
843 let vers = root.join("versions");
844 std::fs::create_dir_all(&vers).unwrap();
845 std::fs::write(vers.join("2.1.258"), "x").unwrap();
846 let link = root.join("claude");
847 std::os::unix::fs::symlink(vers.join("2.1.258"), &link).unwrap();
848 assert_eq!(
849 installed(&link.to_string_lossy(), &vers.to_string_lossy()),
850 Ok("2.1.258".into())
851 );
852 std::fs::write(root.join("elsewhere"), "x").unwrap();
854 std::fs::remove_file(&link).unwrap();
855 std::os::unix::fs::symlink(root.join("elsewhere"), &link).unwrap();
856 let e = installed(&link.to_string_lossy(), &vers.to_string_lossy()).expect_err("refused");
857 assert!(e.contains("elsewhere"));
858 let _ = std::fs::remove_dir_all(&root);
859 }
860}
861
862pub fn nonpane_pids(matched: &[i32], versions_dir: &str) -> Vec<i32> {
868 let mut out = Vec::new();
869 for e in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
870 let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<i32>().ok()) else {
871 continue;
872 };
873 if matched.contains(&pid) {
874 continue;
875 }
876 let comm = std::fs::read_to_string(format!("/proc/{}/comm", pid)).unwrap_or_default();
879 let argv = conv::argv_of(pid);
880 let is_claude = comm.trim() == "claude"
881 || argv
882 .first()
883 .map(|a| a.starts_with(&format!("{}/", versions_dir)))
884 .unwrap_or(false);
885 if is_claude {
886 out.push(pid);
887 }
888 }
889 out.sort_unstable();
890 out
891}
892
893fn json_object_with_prefix(text: &str, key: &str, prefix: &str) -> Option<String> {
901 let needle = format!("\"{}\":\"{}", key, prefix);
902 let at = text.find(&needle).or_else(|| {
903 let spaced = format!("\"{}\": \"{}", key, prefix);
904 text.find(&spaced)
905 })?;
906 let mut depth = 0i32;
908 let bytes = text.as_bytes();
909 let mut start = None;
910 for i in (0..at).rev() {
911 match bytes[i] {
912 b'}' => depth += 1,
913 b'{' => {
914 if depth == 0 {
915 start = Some(i);
916 break;
917 }
918 depth -= 1;
919 }
920 _ => {}
921 }
922 }
923 let start = start?;
924 let mut depth = 0i32;
926 for i in start..bytes.len() {
927 match bytes[i] {
928 b'{' => depth += 1,
929 b'}' => {
930 depth -= 1;
931 if depth == 0 {
932 return Some(text[start..=i].to_string());
933 }
934 }
935 _ => {}
936 }
937 }
938 None
939}
940
941pub fn describe_nonpane(argv: &[String], agents_json: &str) -> String {
948 if argv.is_empty() {
949 return "gone".into();
950 }
951 let (mut sid, mut res, mut forked) = (String::new(), String::new(), false);
952 for i in 1..argv.len() {
953 match argv[i].as_str() {
954 "--fork-session" => forked = true,
955 "--session-id" => sid = argv.get(i + 1).cloned().unwrap_or_default(),
956 "-r" | "--resume" => res = argv.get(i + 1).cloned().unwrap_or_default(),
957 _ => {}
958 }
959 }
960 let own = base_id(if sid.is_empty() { &res } else { &sid });
961 let parent = if forked && !res.is_empty() {
962 base_id(&res)
963 } else {
964 String::new()
965 };
966
967 let (mut kind, mut st, mut name) = ("?".to_string(), "?".to_string(), String::new());
968 if !own.is_empty() {
969 if let Some(obj) = json_object_with_prefix(agents_json, "sessionId", &own) {
970 let f = |k: &str| taimux_core::json::field(&obj, k);
971 let k = f("kind");
972 if !k.is_empty() {
973 kind = k;
974 }
975 let s = {
976 let a = f("state");
977 if a.is_empty() {
978 f("status")
979 } else {
980 a
981 }
982 };
983 if !s.is_empty() {
984 st = s;
985 }
986 name = f("name");
987 }
988 }
989 let mut out = format!("{} {}", kind, st);
990 if forked {
991 out.push_str(" fork");
992 }
993 if !name.is_empty() {
994 out.push_str(&format!(" \"{}\"", name));
995 }
996 if !own.is_empty() {
997 out.push_str(&format!(" [{}]", own.chars().take(8).collect::<String>()));
998 }
999 if !parent.is_empty() {
1000 out.push_str(&format!(
1001 " of [{}]",
1002 parent.chars().take(8).collect::<String>()
1003 ));
1004 }
1005 out
1006}
1007
1008fn base_id(s: &str) -> String {
1010 if s.is_empty() {
1011 return String::new();
1012 }
1013 let b = s.rsplit('/').next().unwrap_or(s);
1014 b.strip_suffix(".jsonl").unwrap_or(b).to_string()
1015}
1016
1017#[cfg(test)]
1018mod nonpane_tests {
1019 use super::*;
1020
1021 fn v(a: &[&str]) -> Vec<String> {
1022 a.iter().map(|s| s.to_string()).collect()
1023 }
1024
1025 #[test]
1026 fn a_session_id_is_read_out_of_a_path_or_taken_as_it_stands() {
1027 assert_eq!(base_id("/a/b/263946b5-9bd7.jsonl"), "263946b5-9bd7");
1028 assert_eq!(base_id("263946b5"), "263946b5");
1029 assert_eq!(base_id(""), "");
1030 }
1031
1032 #[test]
1036 fn the_right_object_comes_back_whole() {
1037 let j = r#"[{"sessionId":"aaa111","kind":"task","meta":{"a":1},"name":"first"},
1038 {"sessionId":"bbb222","kind":"agent","name":"second"}]"#;
1039 let o = json_object_with_prefix(j, "sessionId", "bbb222").expect("found");
1040 assert!(o.contains("second"));
1041 assert!(!o.contains("first"));
1042 let o = json_object_with_prefix(j, "sessionId", "aaa").expect("found by prefix");
1043 assert!(o.contains("first"));
1044 assert!(
1045 o.contains("\"meta\":{\"a\":1}"),
1046 "nested field truncated: {}",
1047 o
1048 );
1049 assert!(json_object_with_prefix(j, "sessionId", "zzz").is_none());
1050 }
1051
1052 #[test]
1053 fn a_process_with_no_argv_is_simply_gone() {
1054 assert_eq!(describe_nonpane(&[], "[]"), "gone");
1055 }
1056
1057 #[test]
1058 fn an_unnamed_session_still_says_what_it_can() {
1059 let d = describe_nonpane(&v(&["claude", "--session-id", "263946b5-9bd7"]), "[]");
1060 assert_eq!(d, "? ? [263946b5]");
1061 }
1062
1063 #[test]
1064 fn a_named_one_says_kind_state_and_name() {
1065 let j =
1066 r#"[{"sessionId":"263946b5-9bd7","kind":"task","state":"running","name":"the thing"}]"#;
1067 let d = describe_nonpane(&v(&["claude", "--session-id", "263946b5-9bd7"]), j);
1068 assert_eq!(d, "task running \"the thing\" [263946b5]");
1069 }
1070
1071 #[test]
1074 fn status_stands_in_for_state() {
1075 let j = r#"[{"sessionId":"aaa11111","kind":"agent","status":"idle"}]"#;
1076 let d = describe_nonpane(&v(&["claude", "--session-id", "aaa11111"]), j);
1077 assert_eq!(d, "agent idle [aaa11111]");
1078 }
1079
1080 #[test]
1083 fn a_fork_names_its_parent_too() {
1084 let d = describe_nonpane(
1085 &v(&[
1086 "claude",
1087 "--session-id",
1088 "child111",
1089 "--fork-session",
1090 "--resume",
1091 "/p/parent22.jsonl",
1092 ]),
1093 "[]",
1094 );
1095 assert_eq!(d, "? ? fork [child111] of [parent22]");
1096 }
1097}
1098
1099#[cfg(test)]
1100mod plan_tests {
1101 use super::*;
1102
1103 struct Fake {
1108 vers: HashMap<i32, String>,
1110 screens: HashMap<String, String>,
1112 resolved: HashMap<String, Result<String, String>>,
1114 zoomed: HashMap<String, String>,
1117 zooms: std::cell::Cell<usize>,
1120 transcript: String,
1121 now: i64,
1122 }
1123
1124 impl Env for Fake {
1125 fn capture(&self, pane: &str) -> String {
1126 self.screens.get(pane).cloned().unwrap_or_default()
1127 }
1128 fn capture_zoomed(&self, pane: &str) -> Option<String> {
1129 let bigger = self.zoomed.get(pane).cloned();
1130 if bigger.is_some() {
1131 self.zooms.set(self.zooms.get() + 1);
1132 }
1133 bigger
1134 }
1135 fn hook_state(&self, _pane: &str, _pid: i32) -> Option<String> {
1136 None
1137 }
1138 fn version_of_pid(&self, pid: i32) -> Option<String> {
1139 self.vers.get(&pid).cloned()
1140 }
1141 fn cwd_of(&self, _pid: i32) -> Option<String> {
1142 Some("/w".into())
1143 }
1144 fn argv_of(&self, _pid: i32) -> Vec<String> {
1145 vec!["claude".into()]
1146 }
1147 fn resolve(&self, pane: &str, _c: &str, _t: &str, _p: i32) -> Result<String, String> {
1148 self.resolved
1149 .get(pane)
1150 .cloned()
1151 .unwrap_or_else(|| Err("no candidate".into()))
1152 }
1153 fn read_transcript(&self, _path: &str) -> Option<String> {
1154 Some(self.transcript.clone())
1155 }
1156 fn now(&self) -> i64 {
1157 self.now
1158 }
1159 }
1160
1161 fn fake() -> Fake {
1162 let mut vers = HashMap::new();
1163 vers.insert(11, "2.1.100".to_string()); vers.insert(22, "2.1.258".to_string()); let mut screens = HashMap::new();
1166 screens.insert("%1".to_string(), "some output\n❯ \n".to_string());
1168 screens.insert("%2".to_string(), "some output\n❯ \n".to_string());
1169 let mut resolved = HashMap::new();
1170 resolved.insert("%1".to_string(), Ok("/t/a.jsonl\tpane map".to_string()));
1171 Fake {
1172 vers,
1173 screens,
1174 resolved,
1175 zoomed: HashMap::new(),
1176 zooms: std::cell::Cell::new(0),
1177 transcript: r#"{"type":"user","timestamp":"2020-01-01T00:00:00Z"}"#.to_string(),
1178 now: 1788312225,
1179 }
1180 }
1181
1182 fn opts() -> Opts {
1183 Opts {
1184 include_busy: false,
1185 only_panes: Vec::new(),
1186 force_transcript: None,
1187 self_pane: String::new(),
1188 }
1189 }
1190
1191 const ROWS: &str = "%1\tw:1.1\t/w\tclaude\t11\tclaude\tproj: the stale one\n\
1192 %2\tw:2.1\t/w\tclaude\t22\tclaude\tproj: the current one";
1193
1194 fn plan_of(e: &Fake, o: &Opts) -> Plan {
1195 plan(ROWS, "2.1.258", "/l/claude", o, e, "/nowhere", &|| {
1196 "[]".into()
1197 })
1198 }
1199
1200 #[test]
1201 fn a_stale_pane_is_planned_and_a_current_one_is_not() {
1202 let p = plan_of(&fake(), &opts());
1203 assert_eq!(p.go.len(), 1);
1204 assert_eq!(p.go[0].pane, "%1");
1205 assert_eq!(p.go[0].cmd, "command claude --resume /t/a.jsonl");
1206 assert_eq!(p.go[0].via, "2.1.100 -> 2.1.258, pane map, idle");
1207 assert!(p.skipped.is_empty());
1208 }
1209
1210 #[test]
1213 fn the_rendered_plan_reads_the_way_it_always_did() {
1214 let out = render(&plan_of(&fake(), &opts()));
1215 assert_eq!(
1216 out,
1217 "claude: 2.1.258 installed at /l/claude\n\n\
1218 to restart (1):\n\
1219 \x20 %1 w:1.1 proj: the stale one\n\
1220 \x20 2.1.100 -> 2.1.258, pane map, idle\n\
1221 \x20 command claude --resume /t/a.jsonl\n"
1222 );
1223 }
1224
1225 #[test]
1227 fn this_pane_is_never_restarted() {
1228 let mut o = opts();
1229 o.self_pane = "%1".into();
1230 let p = plan_of(&fake(), &o);
1231 assert!(p.go.is_empty());
1232 assert!(p.skipped[0].contains("this pane"));
1233 assert!(p.skipped[0].contains("killing it would kill taimux"));
1234 }
1235
1236 #[test]
1237 fn a_pane_that_is_not_idle_waits_for_include_busy() {
1238 let mut e = fake();
1239 e.screens
1241 .insert("%1".into(), "Twisting… (35s · ↓ 1.6k tokens)\n❯ \n".into());
1242 let p = plan_of(&e, &opts());
1243 assert!(p.go.is_empty());
1244 assert!(p.skipped[0].contains("rerun when idle, or --include-busy"));
1245
1246 let mut o = opts();
1247 o.include_busy = true;
1248 assert_eq!(plan_of(&e, &o).go.len(), 1);
1249 }
1250
1251 #[test]
1254 fn a_dialog_blocks_a_restart_even_with_include_busy() {
1255 let mut e = fake();
1256 e.screens
1257 .insert("%1".into(), "output\n\nDo you want to proceed?\n".into());
1258 let mut o = opts();
1259 o.include_busy = true;
1260 let p = plan_of(&e, &o);
1261 assert!(p.go.is_empty());
1262 assert!(p.skipped[0].contains("a dialog is waiting for an answer"));
1263 }
1264
1265 #[test]
1266 fn an_unsent_draft_is_left_alone() {
1267 let mut e = fake();
1268 e.screens
1269 .insert("%1".into(), "output\n❯ half a thought\n".into());
1270 let p = plan_of(&e, &opts());
1271 assert!(p.skipped[0].contains("unsent text in the prompt box"));
1272 }
1273
1274 #[test]
1278 fn a_pane_too_short_for_its_box_is_read_zoomed() {
1279 let mut e = fake();
1280 e.screens.insert(
1281 "%1".into(),
1282 " current: 2.1.100 · latest…\n────────\n".into(),
1283 );
1284 e.zoomed.insert("%1".into(), "some output\n❯ \n".into());
1285 let p = plan_of(&e, &opts());
1286 assert_eq!(e.zooms.get(), 1);
1287 assert_eq!(p.go.len(), 1);
1288 assert_eq!(p.go[0].pane, "%1");
1289 assert!(p.skipped.is_empty());
1290 }
1291
1292 #[test]
1297 fn a_draft_hidden_by_a_short_pane_still_refuses() {
1298 let mut e = fake();
1299 e.screens
1300 .insert("%1".into(), " current: 2.1.100…\n".into());
1301 e.zoomed
1302 .insert("%1".into(), "output\n❯ half a thought\n".into());
1303 let p = plan_of(&e, &opts());
1304 assert_eq!(e.zooms.get(), 1);
1305 assert!(p.go.is_empty());
1306 assert!(p.skipped[0].contains("unsent text in the prompt box"));
1307 }
1308
1309 #[test]
1313 fn a_dialog_hidden_by_a_short_pane_is_found_by_the_zoom() {
1314 let mut e = fake();
1315 e.screens.insert("%1".into(), " Bash command\n".into());
1316 e.zoomed.insert(
1317 "%1".into(),
1318 "Do you want to proceed?\n❯ 1. Yes\n 2. No\n".into(),
1319 );
1320 let mut o = opts();
1321 o.include_busy = true;
1322 let p = plan_of(&e, &o);
1323 assert!(p.go.is_empty());
1324 assert!(p.skipped[0].contains("a dialog is waiting for an answer"));
1325 }
1326
1327 #[test]
1330 fn a_pane_that_shows_its_box_is_not_zoomed() {
1331 let e = fake();
1332 let p = plan_of(&e, &opts());
1333 assert_eq!(e.zooms.get(), 0);
1334 assert_eq!(p.go.len(), 1);
1335 }
1336
1337 #[test]
1340 fn a_recently_active_transcript_is_left_alone() {
1341 let mut e = fake();
1342 e.transcript = r#"{"type":"user","timestamp":"2026-09-02T01:23:45Z"}"#.into();
1343 e.now = 1788312225 + 10;
1344 let p = plan_of(&e, &opts());
1345 assert!(p.skipped[0].contains("active in the last 45s"));
1346 }
1347
1348 #[test]
1349 fn an_unresolved_pane_says_so_and_earns_the_paragraph() {
1350 let mut e = fake();
1351 e.resolved
1352 .insert("%1".into(), Err("3 transcripts share this title".into()));
1353 let p = plan_of(&e, &opts());
1354 assert!(p.go.is_empty());
1355 assert!(p.unresolved);
1356 assert!(p.skipped[0].contains("unresolved: 3 transcripts share this title"));
1357 assert!(render(&p).contains("restart it by hand"));
1358 }
1359
1360 #[test]
1363 fn one_transcript_is_never_resumed_into_two_panes() {
1364 let mut e = fake();
1365 e.vers.insert(22, "2.1.100".into()); e.resolved
1367 .insert("%2".into(), Ok("/t/a.jsonl\tpane map".into()));
1368 let p = plan_of(&e, &opts());
1369 assert_eq!(p.go.len(), 1);
1370 assert_eq!(p.go[0].pane, "%1");
1371 assert!(p.skipped[0].contains("resolves to the same transcript as %1"));
1372 }
1373
1374 #[test]
1375 fn only_panes_narrows_the_plan_without_changing_the_verdicts() {
1376 let mut o = opts();
1377 o.only_panes = vec!["%2".into()];
1378 let p = plan_of(&fake(), &o);
1379 assert!(p.go.is_empty());
1380 assert!(p.skipped.is_empty()); }
1382
1383 #[test]
1384 fn a_pane_with_no_session_process_is_reported_not_dropped() {
1385 let rows = "%9\tw:9.9\t/w\tclaude\t0\tclaude\tno pid here";
1386 let p = plan(rows, "2.1.258", "/l", &opts(), &fake(), "/nowhere", &|| {
1387 "[]".into()
1388 });
1389 assert!(p.go.is_empty());
1390 assert_eq!(p.skipped.len(), 1);
1391 assert!(p.skipped[0].contains("no session process was found"));
1392 }
1393
1394 #[test]
1395 fn nothing_to_restart_says_so() {
1396 let mut e = fake();
1397 e.vers.insert(11, "2.1.258".into());
1398 let out = render(&plan_of(&e, &opts()));
1399 assert!(out.contains("nothing to restart."));
1400 assert!(!out.contains("to restart ("));
1401 }
1402
1403 #[test]
1406 fn a_given_transcript_overrides_the_ladder() {
1407 let mut e = fake();
1408 e.resolved.insert("%1".into(), Err("no candidate".into()));
1409 let mut o = opts();
1410 o.force_transcript = Some("/given.jsonl".into());
1411 let p = plan_of(&e, &o);
1412 assert_eq!(p.go.len(), 1);
1413 assert!(p.go[0].via.contains("--transcript, given"));
1414 assert!(p.go[0].cmd.contains("--resume /given.jsonl"));
1415 }
1416}