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
147pub fn version_of_pid(pid: i32, versions_dir: &str) -> Option<String> {
151 let exe = std::fs::read_link(format!("/proc/{}/exe", pid)).ok()?;
152 let exe = exe.to_string_lossy();
153 let exe = exe.trim_end_matches(" (deleted)"); let prefix = format!("{}/", versions_dir);
155 exe.strip_prefix(&prefix)
156 .filter(|rest| !rest.is_empty())
157 .map(|rest| rest.split('/').next().unwrap_or(rest).to_string())
158}
159
160pub struct Planned {
162 pub pane: String,
163 pub target: String,
164 pub pid: i32,
165 pub cmd: String,
166 pub title: String,
167 pub via: String,
168}
169
170pub struct Plan {
171 pub newver: String,
172 pub launcher: String,
173 pub go: Vec<Planned>,
174 pub skipped: Vec<String>,
175 pub unresolved: bool,
178}
179
180pub struct Opts {
181 pub include_busy: bool,
182 pub only_panes: Vec<String>,
183 pub force_transcript: Option<String>,
184 pub self_pane: String,
185}
186
187pub trait Env {
190 fn capture(&self, pane: &str) -> String;
191 fn hook_state(&self, pane: &str, pid: i32) -> Option<String>;
192 fn version_of_pid(&self, pid: i32) -> Option<String>;
193 fn cwd_of(&self, pid: i32) -> Option<String>;
194 fn argv_of(&self, pid: i32) -> Vec<String>;
195 fn resolve(&self, pane: &str, cwd: &str, title: &str, pid: i32) -> Result<String, String>;
196 fn read_transcript(&self, path: &str) -> Option<String>;
197 fn now(&self) -> i64;
198}
199
200pub fn plan(
206 rows: &str,
207 newver: &str,
208 launcher: &str,
209 o: &Opts,
210 e: &dyn Env,
211 versions_dir: &str,
212 agents: &dyn Fn() -> String,
213) -> Plan {
214 let mut p = Plan {
215 newver: newver.to_string(),
216 launcher: launcher.to_string(),
217 go: Vec::new(),
218 skipped: Vec::new(),
219 unresolved: false,
220 };
221 let mut claimed: HashMap<String, String> = HashMap::new();
222 let mut matched: Vec<i32> = Vec::new();
225
226 for line in rows.lines() {
227 let f: Vec<&str> = line.split('\t').collect();
228 if f.len() < 7 || f[3] != "claude" {
229 continue;
230 }
231 let (id, tgt, cwd, title) = (f[0], f[1], f[2], f[6]);
232 let pid: i32 = f[4].parse().unwrap_or(0);
233
234 let Some(ver) = (pid != 0).then(|| e.version_of_pid(pid)).flatten() else {
235 p.skipped.push(format!(
236 "{} {} looks like claude but no session process was found under the pane",
237 id, tgt
238 ));
239 continue;
240 };
241 matched.push(pid);
242 if !o.only_panes.is_empty() && !o.only_panes.iter().any(|w| w == id) {
243 continue;
244 }
245 if ver == newver {
246 continue; }
248 if id == o.self_pane {
249 p.skipped.push(format!(
250 "{} {} {}, this pane: restart it by hand (killing it would kill taimux)",
251 id, tgt, ver
252 ));
253 continue;
254 }
255
256 let screen = e.capture(id);
257 let st = state::merge(&screen, e.hook_state(id, pid).as_deref());
258 if st.as_str() != "idle" && !o.include_busy {
259 p.skipped.push(format!(
260 "{} {} {}, {}: rerun when idle, or --include-busy",
261 id,
262 tgt,
263 ver,
264 st.as_str()
265 ));
266 continue;
267 }
268
269 let ccwd = e.cwd_of(pid).unwrap_or_else(|| cwd.to_string());
270 let (transcript, via) = match &o.force_transcript {
271 Some(t) => (t.clone(), "--transcript, given".to_string()),
272 None => match e.resolve(id, &ccwd, title, pid) {
273 Ok(t) => {
274 let (path, why) = t.split_once('\t').unwrap_or((t.as_str(), ""));
276 (path.to_string(), why.to_string())
277 }
278 Err(why) => {
279 p.skipped
280 .push(format!("{} {} {}, unresolved: {}", id, tgt, ver, why));
281 p.unresolved = true;
282 continue;
283 }
284 },
285 };
286
287 if let Err(why) = screen_has_no_dialog(&screen) {
288 p.skipped
289 .push(format!("{} {} {}, not settled: {}", id, tgt, ver, why));
290 continue;
291 }
292 if !o.include_busy {
293 let settled =
294 screen_has_no_draft(&screen).and_then(|()| match e.read_transcript(&transcript) {
295 Some(text) => transcript_is_settled(&text, e.now()),
296 None => Ok(()),
297 });
298 if let Err(why) = settled {
299 p.skipped
300 .push(format!("{} {} {}, not settled: {}", id, tgt, ver, why));
301 continue;
302 }
303 }
304
305 if let Some(first) = claimed.get(&transcript) {
306 p.skipped.push(format!(
307 "{} {} {}, resolves to the same transcript as {}",
308 id, tgt, ver, first
309 ));
310 continue;
311 }
312 claimed.insert(transcript.clone(), id.to_string());
313
314 let Some(cmd) = conv::build_cmd(&e.argv_of(pid), &transcript, &ccwd, cwd) else {
315 p.skipped
316 .push(format!("{} {} {}, could not read its argv", id, tgt, ver));
317 continue;
318 };
319 p.go.push(Planned {
320 pane: id.to_string(),
321 target: tgt.to_string(),
322 pid,
323 cmd,
324 title: title.to_string(),
325 via: format!("{} -> {}, {}, {}", ver, newver, via, st.as_str()),
326 });
327 }
328
329 let mut agents_json: Option<String> = None;
334 for np in nonpane_pids(&matched, versions_dir) {
335 let Some(ver) = e.version_of_pid(np) else {
336 continue;
337 };
338 if ver == newver {
339 continue;
340 }
341 let j = agents_json.get_or_insert_with(agents);
342 p.skipped.push(format!(
343 "pid {} {}, not a tmux pane: {}",
344 np,
345 ver,
346 describe_nonpane(&e.argv_of(np), j)
347 ));
348 }
349 p
350}
351
352pub fn render(p: &Plan) -> String {
355 let mut s = format!("claude: {} installed at {}\n\n", p.newver, p.launcher);
356 if p.go.is_empty() {
357 s.push_str("nothing to restart.\n");
358 } else {
359 s.push_str(&format!("to restart ({}):\n", p.go.len()));
360 for g in &p.go {
361 s.push_str(&format!(" {:<5} {:<14} {}\n", g.pane, g.target, g.title));
362 s.push_str(&format!(" {}\n", g.via));
363 s.push_str(&format!(" {}\n", g.cmd));
364 }
365 }
366 if !p.skipped.is_empty() {
367 s.push_str(&format!("\nskipped ({}):\n", p.skipped.len()));
368 for k in &p.skipped {
369 s.push_str(&format!(" {}\n", k));
370 }
371 }
372 if p.unresolved {
373 s.push_str(
374 "\nAn unresolved pane means guessing, so it was left alone: restart it by hand\n\
375 with `claude -c` in that pane, or from its /resume picker. Each session records\n\
376 its pane at the next SessionStart, so a pane resolves cleanly once restarted.\n",
377 );
378 }
379 s
380}
381
382pub fn restart_pane(pane: &str, pid: i32, cmd: &str) -> bool {
389 use std::thread::sleep;
390 use std::time::Duration;
391
392 tmux::run(&["send-keys", "-t", pane, "C-c"]);
393 sleep(Duration::from_millis(300));
394 tmux::run(&["send-keys", "-t", pane, "C-c"]);
395
396 let mut waited = 0;
397 let mut sent_exit = false;
398 while alive(pid) {
399 sleep(Duration::from_millis(400));
400 waited += 1;
401 if waited >= 6 && !sent_exit {
402 tmux::run(&["send-keys", "-t", pane, "/exit", "Enter"]);
403 sent_exit = true;
404 }
405 if waited >= 30 {
406 return false;
407 }
408 }
409 sleep(Duration::from_millis(500));
410 tmux::run(&["send-keys", "-t", pane, "C-c"]);
411 sleep(Duration::from_millis(200));
412 tmux::run(&["send-keys", "-t", pane, cmd, "Enter"])
413}
414
415fn alive(pid: i32) -> bool {
418 std::path::Path::new(&format!("/proc/{}", pid)).exists()
419}
420
421pub fn confirm_yes(answer: &str) -> bool {
424 matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES" | "Yes")
425}
426
427pub struct Live {
429 pub versions_dir: String,
430}
431
432impl Env for Live {
433 fn capture(&self, pane: &str) -> String {
434 tmux::ask_raw(&["capture-pane", "-p", "-t", pane]).unwrap_or_default()
435 }
436 fn hook_state(&self, pane: &str, pid: i32) -> Option<String> {
437 taimux_core::hook::hook_state_of(pane, pid)
438 }
439 fn version_of_pid(&self, pid: i32) -> Option<String> {
440 version_of_pid(pid, &self.versions_dir)
441 }
442 fn cwd_of(&self, pid: i32) -> Option<String> {
443 std::fs::read_link(format!("/proc/{}/cwd", pid))
444 .ok()
445 .map(|p| p.to_string_lossy().into_owned())
446 .filter(|s| !s.is_empty())
447 }
448 fn argv_of(&self, pid: i32) -> Vec<String> {
449 conv::argv_of(pid)
450 }
451 fn resolve(&self, pane: &str, cwd: &str, title: &str, pid: i32) -> Result<String, String> {
452 conv::resolve(pane, cwd, title, pid)
453 .map(|r| format!("{}\t{}", r.transcript.display(), r.why))
454 }
455 fn read_transcript(&self, path: &str) -> Option<String> {
456 std::fs::read_to_string(path).ok()
457 }
458 fn now(&self) -> i64 {
459 std::time::SystemTime::now()
460 .duration_since(std::time::UNIX_EPOCH)
461 .map(|d| d.as_secs() as i64)
462 .unwrap_or(0)
463 }
464}
465
466pub fn installed(launcher: &str, versions_dir: &str) -> Result<String, String> {
469 let target = std::fs::canonicalize(launcher)
470 .map(|p| p.to_string_lossy().into_owned())
471 .unwrap_or_default();
472 let prefix = format!("{}/", versions_dir);
473 match target.strip_prefix(&prefix) {
474 Some(rest) if !rest.is_empty() => Ok(rest.split('/').next().unwrap_or(rest).to_string()),
475 _ => Err(format!(
476 "restart: {} does not point into {} (got '{}')",
477 launcher,
478 versions_dir,
479 if target.is_empty() {
480 "nothing"
481 } else {
482 &target
483 }
484 )),
485 }
486}
487
488pub fn versions_dir() -> String {
489 format!(
490 "{}/.local/share/claude/versions",
491 std::env::var("HOME").unwrap_or_default()
492 )
493}
494
495pub fn launcher() -> String {
496 format!(
497 "{}/.local/bin/claude",
498 std::env::var("HOME").unwrap_or_default()
499 )
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn a_timestamp_becomes_epoch_seconds() {
508 assert_eq!(parse_iso8601("2026-09-02T01:23:45.678Z"), Some(1788312225));
510 assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), Some(0));
511 assert_eq!(parse_iso8601("2024-02-29T00:00:00Z"), Some(1709164800));
513 }
514
515 #[test]
519 fn an_unparseable_timestamp_is_no_answer() {
520 assert_eq!(parse_iso8601(""), None);
521 assert_eq!(parse_iso8601("yesterday"), None);
522 assert_eq!(parse_iso8601("2026-09-02 01:23:45"), None);
523 assert_eq!(parse_iso8601("2026-13-02T01:23:45Z"), None);
524 }
525
526 #[test]
527 fn a_tool_call_still_running_is_not_settled() {
528 let t = r#"{"type":"assistant","message":{"content":[{"type":"tool_use"}]},"timestamp":"2020-01-01T00:00:00Z"}"#;
529 assert_eq!(
530 transcript_is_settled(t, 1788312225),
531 Err("a tool call is still running".into())
532 );
533 }
534
535 #[test]
537 fn recent_activity_is_not_settled_whatever_the_screen_said() {
538 let t = r#"{"type":"user","timestamp":"2026-09-02T01:23:45Z"}"#;
539 assert!(transcript_is_settled(t, 1788312225 + 10).is_err());
540 assert!(transcript_is_settled(t, 1788312225 + 100).is_ok());
541 }
542
543 #[test]
544 fn an_empty_or_odd_transcript_does_not_block() {
545 assert!(transcript_is_settled("", 0).is_ok());
546 assert!(transcript_is_settled("not json at all\n", 0).is_ok());
547 assert!(transcript_is_settled(r#"{"type":"user"}"#, 0).is_ok());
549 }
550
551 #[test]
555 fn a_dialog_on_screen_blocks_a_restart() {
556 let footer = "some output\n\nDo you want to proceed?\n";
557 assert_eq!(
558 screen_has_no_dialog(footer),
559 Err("a dialog is waiting for an answer".into())
560 );
561 let choice = "some output\n1. Yes\n2. No\n❯ 1. Yes\n";
562 assert_eq!(
563 screen_has_no_dialog(choice),
564 Err("a dialog is waiting for an answer".into())
565 );
566 assert!(screen_has_no_dialog("some output\n❯ \n").is_ok());
568 }
569
570 #[test]
573 fn a_half_pressed_ctrl_c_blocks_a_restart() {
574 let screen = "work\n\nPress Ctrl-C again to exit\n";
575 assert_eq!(
576 screen_has_no_dialog(screen),
577 Err("a Ctrl-C is already half-pressed".into())
578 );
579 }
580
581 #[test]
582 fn an_empty_prompt_box_is_no_draft() {
583 assert!(screen_has_no_draft("stuff\n❯ \n").is_ok());
584 assert!(screen_has_no_draft("stuff\n❯ \u{a0}\u{a0}\n").is_ok());
586 assert_eq!(
587 screen_has_no_draft("stuff\n❯ half a thought\n"),
588 Err("unsent text in the prompt box".into())
589 );
590 }
591
592 #[test]
595 fn no_prompt_box_is_a_refusal() {
596 assert_eq!(
597 screen_has_no_draft("just some text\n"),
598 Err("no prompt box on screen".into())
599 );
600 assert!(screen_has_no_draft("").is_ok());
602 assert!(screen_has_no_draft(" \n\n").is_ok());
603 }
604
605 #[test]
606 fn a_bare_enter_confirms() {
607 assert!(confirm_yes(""));
608 assert!(confirm_yes("y"));
609 assert!(confirm_yes("YES"));
610 assert!(!confirm_yes("n"));
611 assert!(!confirm_yes("no"));
612 assert!(!confirm_yes("maybe"));
613 }
614
615 #[test]
616 fn the_launcher_must_point_into_the_versions_dir() {
617 let root = std::env::temp_dir().join(format!("jmrs{}", std::process::id()));
618 let vers = root.join("versions");
619 std::fs::create_dir_all(&vers).unwrap();
620 std::fs::write(vers.join("2.1.258"), "x").unwrap();
621 let link = root.join("claude");
622 std::os::unix::fs::symlink(vers.join("2.1.258"), &link).unwrap();
623 assert_eq!(
624 installed(&link.to_string_lossy(), &vers.to_string_lossy()),
625 Ok("2.1.258".into())
626 );
627 std::fs::write(root.join("elsewhere"), "x").unwrap();
629 std::fs::remove_file(&link).unwrap();
630 std::os::unix::fs::symlink(root.join("elsewhere"), &link).unwrap();
631 let e = installed(&link.to_string_lossy(), &vers.to_string_lossy()).expect_err("refused");
632 assert!(e.contains("elsewhere"));
633 let _ = std::fs::remove_dir_all(&root);
634 }
635}
636
637pub fn nonpane_pids(matched: &[i32], versions_dir: &str) -> Vec<i32> {
643 let mut out = Vec::new();
644 for e in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
645 let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<i32>().ok()) else {
646 continue;
647 };
648 if matched.contains(&pid) {
649 continue;
650 }
651 let comm = std::fs::read_to_string(format!("/proc/{}/comm", pid)).unwrap_or_default();
654 let argv = conv::argv_of(pid);
655 let is_claude = comm.trim() == "claude"
656 || argv
657 .first()
658 .map(|a| a.starts_with(&format!("{}/", versions_dir)))
659 .unwrap_or(false);
660 if is_claude {
661 out.push(pid);
662 }
663 }
664 out.sort_unstable();
665 out
666}
667
668fn json_object_with_prefix(text: &str, key: &str, prefix: &str) -> Option<String> {
676 let needle = format!("\"{}\":\"{}", key, prefix);
677 let at = text.find(&needle).or_else(|| {
678 let spaced = format!("\"{}\": \"{}", key, prefix);
679 text.find(&spaced)
680 })?;
681 let mut depth = 0i32;
683 let bytes = text.as_bytes();
684 let mut start = None;
685 for i in (0..at).rev() {
686 match bytes[i] {
687 b'}' => depth += 1,
688 b'{' => {
689 if depth == 0 {
690 start = Some(i);
691 break;
692 }
693 depth -= 1;
694 }
695 _ => {}
696 }
697 }
698 let start = start?;
699 let mut depth = 0i32;
701 for i in start..bytes.len() {
702 match bytes[i] {
703 b'{' => depth += 1,
704 b'}' => {
705 depth -= 1;
706 if depth == 0 {
707 return Some(text[start..=i].to_string());
708 }
709 }
710 _ => {}
711 }
712 }
713 None
714}
715
716pub fn describe_nonpane(argv: &[String], agents_json: &str) -> String {
723 if argv.is_empty() {
724 return "gone".into();
725 }
726 let (mut sid, mut res, mut forked) = (String::new(), String::new(), false);
727 for i in 1..argv.len() {
728 match argv[i].as_str() {
729 "--fork-session" => forked = true,
730 "--session-id" => sid = argv.get(i + 1).cloned().unwrap_or_default(),
731 "-r" | "--resume" => res = argv.get(i + 1).cloned().unwrap_or_default(),
732 _ => {}
733 }
734 }
735 let own = base_id(if sid.is_empty() { &res } else { &sid });
736 let parent = if forked && !res.is_empty() {
737 base_id(&res)
738 } else {
739 String::new()
740 };
741
742 let (mut kind, mut st, mut name) = ("?".to_string(), "?".to_string(), String::new());
743 if !own.is_empty() {
744 if let Some(obj) = json_object_with_prefix(agents_json, "sessionId", &own) {
745 let f = |k: &str| taimux_core::json::field(&obj, k);
746 let k = f("kind");
747 if !k.is_empty() {
748 kind = k;
749 }
750 let s = {
751 let a = f("state");
752 if a.is_empty() {
753 f("status")
754 } else {
755 a
756 }
757 };
758 if !s.is_empty() {
759 st = s;
760 }
761 name = f("name");
762 }
763 }
764 let mut out = format!("{} {}", kind, st);
765 if forked {
766 out.push_str(" fork");
767 }
768 if !name.is_empty() {
769 out.push_str(&format!(" \"{}\"", name));
770 }
771 if !own.is_empty() {
772 out.push_str(&format!(" [{}]", own.chars().take(8).collect::<String>()));
773 }
774 if !parent.is_empty() {
775 out.push_str(&format!(
776 " of [{}]",
777 parent.chars().take(8).collect::<String>()
778 ));
779 }
780 out
781}
782
783fn base_id(s: &str) -> String {
785 if s.is_empty() {
786 return String::new();
787 }
788 let b = s.rsplit('/').next().unwrap_or(s);
789 b.strip_suffix(".jsonl").unwrap_or(b).to_string()
790}
791
792#[cfg(test)]
793mod nonpane_tests {
794 use super::*;
795
796 fn v(a: &[&str]) -> Vec<String> {
797 a.iter().map(|s| s.to_string()).collect()
798 }
799
800 #[test]
801 fn a_session_id_is_read_out_of_a_path_or_taken_as_it_stands() {
802 assert_eq!(base_id("/a/b/263946b5-9bd7.jsonl"), "263946b5-9bd7");
803 assert_eq!(base_id("263946b5"), "263946b5");
804 assert_eq!(base_id(""), "");
805 }
806
807 #[test]
811 fn the_right_object_comes_back_whole() {
812 let j = r#"[{"sessionId":"aaa111","kind":"task","meta":{"a":1},"name":"first"},
813 {"sessionId":"bbb222","kind":"agent","name":"second"}]"#;
814 let o = json_object_with_prefix(j, "sessionId", "bbb222").expect("found");
815 assert!(o.contains("second"));
816 assert!(!o.contains("first"));
817 let o = json_object_with_prefix(j, "sessionId", "aaa").expect("found by prefix");
818 assert!(o.contains("first"));
819 assert!(
820 o.contains("\"meta\":{\"a\":1}"),
821 "nested field truncated: {}",
822 o
823 );
824 assert!(json_object_with_prefix(j, "sessionId", "zzz").is_none());
825 }
826
827 #[test]
828 fn a_process_with_no_argv_is_simply_gone() {
829 assert_eq!(describe_nonpane(&[], "[]"), "gone");
830 }
831
832 #[test]
833 fn an_unnamed_session_still_says_what_it_can() {
834 let d = describe_nonpane(&v(&["claude", "--session-id", "263946b5-9bd7"]), "[]");
835 assert_eq!(d, "? ? [263946b5]");
836 }
837
838 #[test]
839 fn a_named_one_says_kind_state_and_name() {
840 let j =
841 r#"[{"sessionId":"263946b5-9bd7","kind":"task","state":"running","name":"the thing"}]"#;
842 let d = describe_nonpane(&v(&["claude", "--session-id", "263946b5-9bd7"]), j);
843 assert_eq!(d, "task running \"the thing\" [263946b5]");
844 }
845
846 #[test]
849 fn status_stands_in_for_state() {
850 let j = r#"[{"sessionId":"aaa11111","kind":"agent","status":"idle"}]"#;
851 let d = describe_nonpane(&v(&["claude", "--session-id", "aaa11111"]), j);
852 assert_eq!(d, "agent idle [aaa11111]");
853 }
854
855 #[test]
858 fn a_fork_names_its_parent_too() {
859 let d = describe_nonpane(
860 &v(&[
861 "claude",
862 "--session-id",
863 "child111",
864 "--fork-session",
865 "--resume",
866 "/p/parent22.jsonl",
867 ]),
868 "[]",
869 );
870 assert_eq!(d, "? ? fork [child111] of [parent22]");
871 }
872}
873
874#[cfg(test)]
875mod plan_tests {
876 use super::*;
877
878 struct Fake {
883 vers: HashMap<i32, String>,
885 screens: HashMap<String, String>,
887 resolved: HashMap<String, Result<String, String>>,
889 transcript: String,
890 now: i64,
891 }
892
893 impl Env for Fake {
894 fn capture(&self, pane: &str) -> String {
895 self.screens.get(pane).cloned().unwrap_or_default()
896 }
897 fn hook_state(&self, _pane: &str, _pid: i32) -> Option<String> {
898 None
899 }
900 fn version_of_pid(&self, pid: i32) -> Option<String> {
901 self.vers.get(&pid).cloned()
902 }
903 fn cwd_of(&self, _pid: i32) -> Option<String> {
904 Some("/w".into())
905 }
906 fn argv_of(&self, _pid: i32) -> Vec<String> {
907 vec!["claude".into()]
908 }
909 fn resolve(&self, pane: &str, _c: &str, _t: &str, _p: i32) -> Result<String, String> {
910 self.resolved
911 .get(pane)
912 .cloned()
913 .unwrap_or_else(|| Err("no candidate".into()))
914 }
915 fn read_transcript(&self, _path: &str) -> Option<String> {
916 Some(self.transcript.clone())
917 }
918 fn now(&self) -> i64 {
919 self.now
920 }
921 }
922
923 fn fake() -> Fake {
924 let mut vers = HashMap::new();
925 vers.insert(11, "2.1.100".to_string()); vers.insert(22, "2.1.258".to_string()); let mut screens = HashMap::new();
928 screens.insert("%1".to_string(), "some output\n❯ \n".to_string());
930 screens.insert("%2".to_string(), "some output\n❯ \n".to_string());
931 let mut resolved = HashMap::new();
932 resolved.insert("%1".to_string(), Ok("/t/a.jsonl\tpane map".to_string()));
933 Fake {
934 vers,
935 screens,
936 resolved,
937 transcript: r#"{"type":"user","timestamp":"2020-01-01T00:00:00Z"}"#.to_string(),
938 now: 1788312225,
939 }
940 }
941
942 fn opts() -> Opts {
943 Opts {
944 include_busy: false,
945 only_panes: Vec::new(),
946 force_transcript: None,
947 self_pane: String::new(),
948 }
949 }
950
951 const ROWS: &str = "%1\tw:1.1\t/w\tclaude\t11\tclaude\tproj: the stale one\n\
952 %2\tw:2.1\t/w\tclaude\t22\tclaude\tproj: the current one";
953
954 fn plan_of(e: &Fake, o: &Opts) -> Plan {
955 plan(ROWS, "2.1.258", "/l/claude", o, e, "/nowhere", &|| {
956 "[]".into()
957 })
958 }
959
960 #[test]
961 fn a_stale_pane_is_planned_and_a_current_one_is_not() {
962 let p = plan_of(&fake(), &opts());
963 assert_eq!(p.go.len(), 1);
964 assert_eq!(p.go[0].pane, "%1");
965 assert_eq!(p.go[0].cmd, "command claude --resume /t/a.jsonl");
966 assert_eq!(p.go[0].via, "2.1.100 -> 2.1.258, pane map, idle");
967 assert!(p.skipped.is_empty());
968 }
969
970 #[test]
973 fn the_rendered_plan_reads_the_way_it_always_did() {
974 let out = render(&plan_of(&fake(), &opts()));
975 assert_eq!(
976 out,
977 "claude: 2.1.258 installed at /l/claude\n\n\
978 to restart (1):\n\
979 \x20 %1 w:1.1 proj: the stale one\n\
980 \x20 2.1.100 -> 2.1.258, pane map, idle\n\
981 \x20 command claude --resume /t/a.jsonl\n"
982 );
983 }
984
985 #[test]
987 fn this_pane_is_never_restarted() {
988 let mut o = opts();
989 o.self_pane = "%1".into();
990 let p = plan_of(&fake(), &o);
991 assert!(p.go.is_empty());
992 assert!(p.skipped[0].contains("this pane"));
993 assert!(p.skipped[0].contains("killing it would kill taimux"));
994 }
995
996 #[test]
997 fn a_pane_that_is_not_idle_waits_for_include_busy() {
998 let mut e = fake();
999 e.screens
1001 .insert("%1".into(), "Twisting… (35s · ↓ 1.6k tokens)\n❯ \n".into());
1002 let p = plan_of(&e, &opts());
1003 assert!(p.go.is_empty());
1004 assert!(p.skipped[0].contains("rerun when idle, or --include-busy"));
1005
1006 let mut o = opts();
1007 o.include_busy = true;
1008 assert_eq!(plan_of(&e, &o).go.len(), 1);
1009 }
1010
1011 #[test]
1014 fn a_dialog_blocks_a_restart_even_with_include_busy() {
1015 let mut e = fake();
1016 e.screens
1017 .insert("%1".into(), "output\n\nDo you want to proceed?\n".into());
1018 let mut o = opts();
1019 o.include_busy = true;
1020 let p = plan_of(&e, &o);
1021 assert!(p.go.is_empty());
1022 assert!(p.skipped[0].contains("a dialog is waiting for an answer"));
1023 }
1024
1025 #[test]
1026 fn an_unsent_draft_is_left_alone() {
1027 let mut e = fake();
1028 e.screens
1029 .insert("%1".into(), "output\n❯ half a thought\n".into());
1030 let p = plan_of(&e, &opts());
1031 assert!(p.skipped[0].contains("unsent text in the prompt box"));
1032 }
1033
1034 #[test]
1037 fn a_recently_active_transcript_is_left_alone() {
1038 let mut e = fake();
1039 e.transcript = r#"{"type":"user","timestamp":"2026-09-02T01:23:45Z"}"#.into();
1040 e.now = 1788312225 + 10;
1041 let p = plan_of(&e, &opts());
1042 assert!(p.skipped[0].contains("active in the last 45s"));
1043 }
1044
1045 #[test]
1046 fn an_unresolved_pane_says_so_and_earns_the_paragraph() {
1047 let mut e = fake();
1048 e.resolved
1049 .insert("%1".into(), Err("3 transcripts share this title".into()));
1050 let p = plan_of(&e, &opts());
1051 assert!(p.go.is_empty());
1052 assert!(p.unresolved);
1053 assert!(p.skipped[0].contains("unresolved: 3 transcripts share this title"));
1054 assert!(render(&p).contains("restart it by hand"));
1055 }
1056
1057 #[test]
1060 fn one_transcript_is_never_resumed_into_two_panes() {
1061 let mut e = fake();
1062 e.vers.insert(22, "2.1.100".into()); e.resolved
1064 .insert("%2".into(), Ok("/t/a.jsonl\tpane map".into()));
1065 let p = plan_of(&e, &opts());
1066 assert_eq!(p.go.len(), 1);
1067 assert_eq!(p.go[0].pane, "%1");
1068 assert!(p.skipped[0].contains("resolves to the same transcript as %1"));
1069 }
1070
1071 #[test]
1072 fn only_panes_narrows_the_plan_without_changing_the_verdicts() {
1073 let mut o = opts();
1074 o.only_panes = vec!["%2".into()];
1075 let p = plan_of(&fake(), &o);
1076 assert!(p.go.is_empty());
1077 assert!(p.skipped.is_empty()); }
1079
1080 #[test]
1081 fn a_pane_with_no_session_process_is_reported_not_dropped() {
1082 let rows = "%9\tw:9.9\t/w\tclaude\t0\tclaude\tno pid here";
1083 let p = plan(rows, "2.1.258", "/l", &opts(), &fake(), "/nowhere", &|| {
1084 "[]".into()
1085 });
1086 assert!(p.go.is_empty());
1087 assert_eq!(p.skipped.len(), 1);
1088 assert!(p.skipped[0].contains("no session process was found"));
1089 }
1090
1091 #[test]
1092 fn nothing_to_restart_says_so() {
1093 let mut e = fake();
1094 e.vers.insert(11, "2.1.258".into());
1095 let out = render(&plan_of(&e, &opts()));
1096 assert!(out.contains("nothing to restart."));
1097 assert!(!out.contains("to restart ("));
1098 }
1099
1100 #[test]
1103 fn a_given_transcript_overrides_the_ladder() {
1104 let mut e = fake();
1105 e.resolved.insert("%1".into(), Err("no candidate".into()));
1106 let mut o = opts();
1107 o.force_transcript = Some("/given.jsonl".into());
1108 let p = plan_of(&e, &o);
1109 assert_eq!(p.go.len(), 1);
1110 assert!(p.go[0].via.contains("--transcript, given"));
1111 assert!(p.go[0].cmd.contains("--resume /given.jsonl"));
1112 }
1113}