Skip to main content

taimux_cli/
restart.rs

1//! Putting a claude session back on the version that is installed now.
2//!
3//! The part where a mistake costs a turn of work rather than a redraw.
4//! Everything here is built around one rule: **a pane it
5//! cannot be sure about is skipped, with a reason.** Guessing which conversation
6//! a pane is on and then interrupting it is worse than doing nothing.
7//!
8//! The guards, in the order they are applied, and what each is for:
9//!
10//! 1. **Not this pane.** Restarting the pane taimux is running in would kill
11//!    taimux mid-restart.
12//! 2. **Only a stale version.** A pane already on the installed version has
13//!    nothing to gain from being interrupted.
14//! 3. **Idle**, unless `--include-busy`. A turn in flight is work in progress.
15//! 4. **A conversation that could be identified**, from the ladder in `conv`.
16//! 5. **No dialog on screen**, ever, even with `--include-busy`: a half-answered
17//!    permission prompt is the one state where a Ctrl-C means something else.
18//! 6. **No unsent draft, and a settled transcript**, unless `--include-busy`.
19//! 7. **No transcript claimed twice.** Resuming one conversation into two panes
20//!    is the same mistake on a restart as on a restore.
21
22use std::collections::HashMap;
23
24use taimux_core::{conv, state, tmux};
25
26/// Is this transcript quiet enough to interrupt?
27///
28/// Two refusals: an unanswered `tool_use` as the very last record (a tool call is
29/// still in flight), and any activity in the last 45 seconds. The second is
30/// insurance for the first, because the screen reading is the most fragile thing
31/// in this tool and a recently-touched transcript is worth leaving alone whatever
32/// the screen said.
33///
34/// The `tool_use` test is a text match on the final line rather than a parse of
35/// `.message.content[]`, which is the one approximation: an assistant turn whose
36/// own prose quotes that key reads as busy. It fails toward "leave it alone", and
37/// the recency guard covers the same ground, so the cost is a pane skipped rather
38/// than a turn lost.
39pub 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(()); // unknown shape: do not block on a guess
42    };
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(()); // unparseable: not evidence of anything
54    };
55    if now - then < 45 {
56        return Err("active in the last 45s".into());
57    }
58    Ok(())
59}
60
61/// An ISO 8601 UTC timestamp to epoch seconds, which is all claude writes:
62/// `2026-09-02T01:23:45.678Z`.
63///
64/// Hand-rolled rather than shelling to `date -d`, which is what bash did per
65/// pane. Only the shape claude actually emits is accepted; anything else reads as
66/// "no answer", and the caller treats that as not-evidence rather than as busy.
67fn 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
87/// Days since 1970-01-01 for a civil date. Howard Hinnant's algorithm, which is
88/// exact for every date and has no calendar table to get wrong.
89fn 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
99/// Is the screen free of anything a Ctrl-C would mean something else to?
100///
101/// Applied even with `--include-busy`, because this is the one state where the
102/// keystroke that starts a restart is also an answer to a question.
103pub 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
122/// Is the prompt box empty?
123///
124/// Unsent text in it is work nobody has committed yet, and a restart would throw
125/// it away. The non-breaking space claude pads the box with is stripped before
126/// the check, or every box would look occupied.
127pub 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
147/// The version a running process is executing, read from its own `/proc/exe`
148/// rather than from the binary on `$PATH`: a long-lived session goes on running
149/// the release it started under.
150pub 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)"); // replaced by an update
154    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
160/// One pane the plan will act on.
161pub 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    /// Set when at least one pane was skipped for being unresolvable, which gets
176    /// its own paragraph: it is the one skip the reader can act on.
177    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
187/// Everything the plan needs from the outside, so it can be built against
188/// fixtures as well as against a live machine.
189pub 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
200/// Decide what to restart, and say why for everything else.
201///
202/// `agents` is `claude agents --json`, fetched by the caller and only when a
203/// stale non-pane process actually needs naming: the CLI costs about three
204/// seconds to start.
205pub 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    // Every pane's agent pid, so the non-pane sweep below can tell a session
223    // that has a pane from one that has not.
224    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; // nothing to gain from interrupting it
247        }
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                    // resolve() hands back "<path>\t<why>"
275                    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    // Stale claude processes that are not a pane's foreground job. Reported,
330    // never touched: a restart types into a PANE, and these have none, so the
331    // only useful thing to do about a stale one is name it. The naming costs a
332    // `claude agents --json`, so it is fetched lazily and once.
333    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
352/// The plan, as the reader sees it. Byte-for-byte what bash printed, because the
353/// only way to know a port of this is right is to compare it.
354pub 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
382/// Interrupt a session, wait for it to go, and type its replacement.
383///
384/// Two Ctrl-Cs, because the first one arms claude's "press again to exit" and the
385/// second takes it. `/exit` after six waits is for a session that ignores both,
386/// and thirty waits (twelve seconds) is where it gives up rather than typing a
387/// command into a pane that still has a session in it.
388pub 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
415/// Is a pid still there? `/proc` rather than `kill -0`, since nothing is being
416/// signalled and a directory read cannot be mistaken for one.
417fn alive(pid: i32) -> bool {
418    std::path::Path::new(&format!("/proc/{}", pid)).exists()
419}
420
421/// The default answer is yes, so a bare Enter restarts. The plan has already been
422/// printed by then, which is what makes that safe.
423pub fn confirm_yes(answer: &str) -> bool {
424    matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES" | "Yes")
425}
426
427/// The live implementation of everything `plan` needs.
428pub 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
466/// What a session started right now would run, refusing rather than guessing when
467/// the launcher points somewhere unexpected.
468pub 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        // 2026-09-02T01:23:45Z
509        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        // a leap day, which a hand-rolled calendar is where it goes wrong
512        assert_eq!(parse_iso8601("2024-02-29T00:00:00Z"), Some(1709164800));
513    }
514
515    /// Anything not of that exact shape reads as "no answer", which the caller
516    /// treats as not-evidence rather than as busy: guessing busy would skip a
517    /// pane on a malformed line forever.
518    #[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    /// Insurance for the screen reading, which is the most fragile thing here.
536    #[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        // no timestamp: nothing to judge recency by
548        assert!(transcript_is_settled(r#"{"type":"user"}"#, 0).is_ok());
549    }
550
551    /// A dialog is what `state::awaits_input` recognises, so the fixture has to
552    /// be one it would: either the footer in the last few lines, or a numbered
553    /// choice on the LOWEST prompt line. A made-up shape asserts nothing.
554    #[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        // an ordinary idle screen is not a dialog
567        assert!(screen_has_no_dialog("some output\n❯ \n").is_ok());
568    }
569
570    /// A half-pressed Ctrl-C is the one state where the keystroke that starts a
571    /// restart is also an answer to something else.
572    #[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        // the non-breaking space claude pads the box with is not a draft
585        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    /// No box at all means the screen is not what it is expected to be, and that
593    /// is a refusal rather than a shrug.
594    #[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        // …but a genuinely blank screen is not judged at all
601        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        // pointing elsewhere is a refusal that names what it found
628        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
637/// A claude process that is not any pane's foreground job: Zed's ACP bridge, a
638/// background agent, one of the daemon's spare pty hosts.
639///
640/// Reported, never touched. A restart types into a PANE, and these have none, so
641/// the only useful thing to do about a stale one is name it.
642pub 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        // Either its comm is "claude" (pgrep -x claude) or it is running out of
652        // the versions directory (pgrep -f "^<vdir>/").
653        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
668/// The object in a JSON array whose `key` starts with `prefix`, as raw text.
669///
670/// A brace matcher rather than a parser, and rather than the `jq` the bash
671/// version shelled out to. It only has to find one element of one array, and the
672/// depth count is what makes it safe against nested objects: taking everything
673/// between the first `{` and the first `}` would truncate any element with a
674/// nested field.
675fn 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    // back to the opening brace of the object holding it
682    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    // forward to its matching close
700    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
716/// What to say about a claude that has no pane.
717///
718/// `agents_json` is `claude agents --json`, which costs about three seconds of
719/// CLI startup, so it is fetched once by the caller and only when something needs
720/// naming. Its own `pid` field points at the pty-host wrapper rather than at the
721/// session, so it is read for kind, state and name and nothing else.
722pub 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
783/// `basename x .jsonl`: a session id, whether it arrived as one or as a path.
784fn 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    /// The depth count is the whole point: taking everything between the first
808    /// brace and the first close would truncate any element with a nested field,
809    /// and `claude agents --json` has them.
810    #[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    /// `status` is the older field name, and one of the two is what a given
847    /// version emits.
848    #[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    /// A fork names both itself and its parent, because that is the pair you need
856    /// to work out which one is the stale one.
857    #[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    /// A machine with one stale pane, one current one, and whatever else the test
879    /// asks for. The `Env` trait exists for this: the live differential can only
880    /// reach "nothing to restart" while every session is on the installed
881    /// version, so the branch that actually acts needs a fixture.
882    struct Fake {
883        /// pid -> version
884        vers: HashMap<i32, String>,
885        /// pane -> screen
886        screens: HashMap<String, String>,
887        /// pane -> resolved transcript, or the refusal
888        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()); // stale
926        vers.insert(22, "2.1.258".to_string()); // current
927        let mut screens = HashMap::new();
928        // an idle screen with an empty prompt box
929        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    /// The rendered plan is the whole user interface of `restart -n`, so its
971    /// exact shape is what the bash comparison was made on.
972    #[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    /// Restarting the pane taimux is running in would kill taimux mid-restart.
986    #[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        // an activity line: mid-turn
1000        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    /// Even with --include-busy: a half-answered permission prompt is the one
1012    /// state where the Ctrl-C that starts a restart means something else.
1013    #[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    /// A transcript touched in the last 45 seconds is left alone whatever the
1035    /// screen said, because the screen reading is the fragile half.
1036    #[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    /// Two panes on one conversation is the same mistake on a restart as on a
1058    /// restore: the second one to claim it gets skipped rather than resumed.
1059    #[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()); // make the second one stale too
1063        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()); // %1 was not considered at all
1078    }
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    /// --transcript names the conversation outright, which is the escape hatch
1101    /// for a pane the ladder refuses.
1102    #[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}