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`. The parse is `turn::epoch_ms`, which the state
63/// reading needs to the millisecond; this only wants the second.
64fn parse_iso8601(s: &str) -> Option<i64> {
65    taimux_core::turn::epoch_ms(s).map(|ms| ms.div_euclid(1000))
66}
67
68/// Is the screen free of anything a Ctrl-C would mean something else to?
69///
70/// Applied even with `--include-busy`, because this is the one state where the
71/// keystroke that starts a restart is also an answer to a question.
72pub 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
91/// Is the prompt box empty?
92///
93/// Unsent text in it is work nobody has committed yet, and a restart would throw
94/// it away. The non-breaking space claude pads the box with is stripped before
95/// the check, or every box would look occupied.
96pub 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
116/// The rows claude needs before it draws its prompt box at all.
117///
118/// Measured against 2.1.278 in a tmux server of its own, a row at a time: at six
119/// the `❯` is on screen and whatever has been typed into it with it, at five the
120/// last row is the box's own top rule and neither the glyph nor the draft is
121/// anywhere on the pane, and by three nothing of the box survives. Width does not
122/// move it, 46, 54, 63, 80 and 213 columns all break in the same place.
123///
124/// A pane under it is not a pane in a strange state, it is a pane that cannot be
125/// READ: the box, an unsent draft inside it and a dialog over it go off the
126/// bottom together, so `screen_has_no_draft` refuses it and would go on refusing
127/// it for as long as the pane stays that size. Nine of the fourteen outdated
128/// panes on this machine were exactly that, which is how a sweep meant to clear
129/// the outdated list offered to clear four of them.
130const BOX_ROWS: usize = 6;
131
132/// One tmux command line, as the arguments it is made of.
133type Cmd = Vec<String>;
134
135/// Getting a pane to a readable size, and putting the window back after.
136type ZoomSteps = (Vec<Cmd>, Vec<Cmd>);
137
138/// What it takes to read a pane at a size claude will draw on, and what it takes
139/// to put the window back afterwards.
140///
141/// Split out from the running of it so the ORDER can be tested without a tmux to
142/// run it against, the order being the whole of the difficulty. `None` when there
143/// is nothing to gain: a pane already tall enough, or a window no taller than the
144/// pane it holds, where zooming would hand it the rows it already has.
145///
146/// **Zoom rather than `resize-pane -y`**, and only a crowded window shows why:
147/// seven panes in seventeen rows share eleven rows of content, six of which are
148/// already spoken for by the others, so the tallest any one of them can be made
149/// is five, one short of what the box needs. Zoom is the only growth that does
150/// not have to come out of a sibling. It also leaves the LAYOUT untouched, so
151/// putting the window back is a matter of the zoom flag and the active pane
152/// rather than of replaying a layout string and hoping it lands.
153fn 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    // `-Z` toggles the WINDOW's zoom whatever pane it is pointed at, so on a
168    // window that arrives zoomed the first one only ever switches the OTHER pane
169    // off, and a second is what zooms this one. Two identical command lines in a
170    // row is not a duplicated push.
171    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    // Zooming made this pane the active one and pushed whatever was active into
178    // the window's "last pane", so both have to go back, and in that order:
179    // selecting the old last pane first leaves the old active one current with
180    // the right pane behind it. `prefix + ;` is the user's binding, not taimux's
181    // to spend. A pane that was active already has neither to restore.
182    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    // A window that arrives zoomed is zoomed on its active pane by definition,
191    // which is why that pane is never this one: it would have the window's full
192    // height and be refused above.
193    if zoomed && !active.is_empty() {
194        back.push(cmd(&["resize-pane", "-Z", "-t", active]));
195    }
196    Some((go, back))
197}
198
199/// The commands that put the window back, run when this goes out of scope.
200///
201/// A guard rather than a line at the end of the function because the read
202/// between the two can panic, and a window left zoomed on a pane nobody chose is
203/// a worse outcome than a pane left unread.
204struct 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
214/// One pane's screen, read at a size claude will draw its prompt box on.
215///
216/// `None` when the pane did not need it or the window cannot give it, in which
217/// case nothing was touched and the screen already in hand is the best there is.
218///
219/// The wait is not padding. claude redraws on the SIGWINCH and does it fast,
220/// measured at 13 to 16ms across six runs with the draft already in the first
221/// readable frame, but a capture taken with no wait at all comes back with no box
222/// at all: tmux does not reflow an old frame into the new rows, it hands over
223/// what is there and claude fills it a moment later. So the poll is what makes
224/// the read real, and its ceiling is thirty times the measurement rather than a
225/// guess at it.
226pub 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
285/// The version a running process is executing, read from its own `/proc/exe`
286/// rather than from the binary on `$PATH`: a long-lived session goes on running
287/// the release it started under.
288pub 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)"); // replaced by an update
292    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
298/// One pane the plan will act on.
299pub 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    /// Set when at least one pane was skipped for being unresolvable, which gets
314    /// its own paragraph: it is the one skip the reader can act on.
315    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
325/// Everything the plan needs from the outside, so it can be built against
326/// fixtures as well as against a live machine.
327pub trait Env {
328    fn capture(&self, pane: &str) -> String;
329    /// The same pane read at a size claude will draw its prompt box on, for the
330    /// one that is too short to show one at the size it is.
331    ///
332    /// `None` by default, and for every fixture: a screen handed over by a test
333    /// is already the screen that test means, and only the live implementation
334    /// has a window to zoom.
335    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
347/// Decide what to restart, and say why for everything else.
348///
349/// `agents` is `claude agents --json`, fetched by the caller and only when a
350/// stale non-pane process actually needs naming: the CLI costs about three
351/// seconds to start.
352pub 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    // Every pane's agent pid, so the non-pane sweep below can tell a session
370    // that has a pane from one that has not.
371    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; // nothing to gain from interrupting it
394        }
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        // A screen with no prompt box on it is either a session doing something
405        // unusual or a pane too short to draw one, and those want opposite
406        // answers: the first is a refusal, the second is a measurement that has
407        // not been taken yet. Taking it costs the window a zoom for the
408        // milliseconds claude needs to redraw, and buys a reading of the box, of
409        // anything typed into it and of any dialog over it, none of which is on
410        // the pane at the size it sits at. It happens here rather than after the
411        // state check on purpose: `merge` is reading the same blank screen.
412        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                    // resolve() hands back "<path>\t<why>"
435                    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    // Stale claude processes that are not a pane's foreground job. Reported,
490    // never touched: a restart types into a PANE, and these have none, so the
491    // only useful thing to do about a stale one is name it. The naming costs a
492    // `claude agents --json`, so it is fetched lazily and once.
493    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
512/// The plan, as the reader sees it. Byte-for-byte what bash printed, because the
513/// only way to know a port of this is right is to compare it.
514pub 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
542/// Interrupt a session, wait for it to go, and type its replacement.
543///
544/// Two Ctrl-Cs, because the first one arms claude's "press again to exit" and the
545/// second takes it. `/exit` after six waits is for a session that ignores both,
546/// and thirty waits (twelve seconds) is where it gives up rather than typing a
547/// command into a pane that still has a session in it.
548pub 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
575/// Is a pid still there? `/proc` rather than `kill -0`, since nothing is being
576/// signalled and a directory read cannot be mistaken for one.
577fn alive(pid: i32) -> bool {
578    std::path::Path::new(&format!("/proc/{}", pid)).exists()
579}
580
581/// The default answer is yes, so a bare Enter restarts. The plan has already been
582/// printed by then, which is what makes that safe.
583pub fn confirm_yes(answer: &str) -> bool {
584    matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES" | "Yes")
585}
586
587/// The live implementation of everything `plan` needs.
588pub 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
629/// What a session started right now would run, refusing rather than guessing when
630/// the launcher points somewhere unexpected.
631pub 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        // 2026-09-02T01:23:45Z
672        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        // a leap day, which a hand-rolled calendar is where it goes wrong
675        assert_eq!(parse_iso8601("2024-02-29T00:00:00Z"), Some(1709164800));
676    }
677
678    /// Anything not of that exact shape reads as "no answer", which the caller
679    /// treats as not-evidence rather than as busy: guessing busy would skip a
680    /// pane on a malformed line forever.
681    #[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    /// Insurance for the screen reading, which is the most fragile thing here.
699    #[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        // no timestamp: nothing to judge recency by
711        assert!(transcript_is_settled(r#"{"type":"user"}"#, 0).is_ok());
712    }
713
714    /// A dialog is what `state::awaits_input` recognises, so the fixture has to
715    /// be one it would: either the footer in the last few lines, or a numbered
716    /// choice on the LOWEST prompt line. A made-up shape asserts nothing.
717    #[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        // an ordinary idle screen is not a dialog
730        assert!(screen_has_no_dialog("some output\n❯ \n").is_ok());
731    }
732
733    /// A half-pressed Ctrl-C is the one state where the keystroke that starts a
734    /// restart is also an answer to something else.
735    #[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        // the non-breaking space claude pads the box with is not a draft
748        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    /// The four rows a pane has to be given before any of this can be asked of
756    /// it, and the two it can be left at.
757    #[test]
758    fn a_pane_tall_enough_to_read_is_left_alone() {
759        // already showing its box: nothing to gain, and nothing touched
760        assert!(zoom_steps("%1", false, 6, 40, "%2", "%3").is_none());
761        assert!(zoom_steps("%1", false, 40, 40, "%2", "%3").is_none());
762        // a window no taller than the pane has no rows to lend it
763        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    /// The common shape: a three-row pane in a window nobody has zoomed. One
768    /// zoom out and back, and the pane that was active is active again with the
769    /// pane that was behind it still behind it.
770    #[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    /// A window that arrives zoomed on another pane takes TWO `-Z` to zoom this
785    /// one, because the first is spent switching the other one off, and it is
786    /// owed a re-zoom at the end.
787    #[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    /// Zooming the pane that is already active changes no selection, so putting
809    /// one back would be the only thing that moved it.
810    #[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    /// No box at all means the screen is not what it is expected to be, and that
818    /// is a refusal rather than a shrug.
819    #[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        // …but a genuinely blank screen is not judged at all
826        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        // pointing elsewhere is a refusal that names what it found
853        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
862/// A claude process that is not any pane's foreground job: Zed's ACP bridge, a
863/// background agent, one of the daemon's spare pty hosts.
864///
865/// Reported, never touched. A restart types into a PANE, and these have none, so
866/// the only useful thing to do about a stale one is name it.
867pub 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        // Either its comm is "claude" (pgrep -x claude) or it is running out of
877        // the versions directory (pgrep -f "^<vdir>/").
878        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
893/// The object in a JSON array whose `key` starts with `prefix`, as raw text.
894///
895/// A brace matcher rather than a parser, and rather than the `jq` the bash
896/// version shelled out to. It only has to find one element of one array, and the
897/// depth count is what makes it safe against nested objects: taking everything
898/// between the first `{` and the first `}` would truncate any element with a
899/// nested field.
900fn 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    // back to the opening brace of the object holding it
907    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    // forward to its matching close
925    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
941/// What to say about a claude that has no pane.
942///
943/// `agents_json` is `claude agents --json`, which costs about three seconds of
944/// CLI startup, so it is fetched once by the caller and only when something needs
945/// naming. Its own `pid` field points at the pty-host wrapper rather than at the
946/// session, so it is read for kind, state and name and nothing else.
947pub 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
1008/// `basename x .jsonl`: a session id, whether it arrived as one or as a path.
1009fn 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    /// The depth count is the whole point: taking everything between the first
1033    /// brace and the first close would truncate any element with a nested field,
1034    /// and `claude agents --json` has them.
1035    #[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    /// `status` is the older field name, and one of the two is what a given
1072    /// version emits.
1073    #[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    /// A fork names both itself and its parent, because that is the pair you need
1081    /// to work out which one is the stale one.
1082    #[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    /// A machine with one stale pane, one current one, and whatever else the test
1104    /// asks for. The `Env` trait exists for this: the live differential can only
1105    /// reach "nothing to restart" while every session is on the installed
1106    /// version, so the branch that actually acts needs a fixture.
1107    struct Fake {
1108        /// pid -> version
1109        vers: HashMap<i32, String>,
1110        /// pane -> screen
1111        screens: HashMap<String, String>,
1112        /// pane -> resolved transcript, or the refusal
1113        resolved: HashMap<String, Result<String, String>>,
1114        /// pane -> what the same pane shows once it has been zoomed, for a pane
1115        /// too short to draw a prompt box at the size it sits at
1116        zoomed: HashMap<String, String>,
1117        /// how many panes were zoomed to be read, since a zoom is something the
1118        /// user watching that window sees happen
1119        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()); // stale
1164        vers.insert(22, "2.1.258".to_string()); // current
1165        let mut screens = HashMap::new();
1166        // an idle screen with an empty prompt box
1167        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    /// The rendered plan is the whole user interface of `restart -n`, so its
1211    /// exact shape is what the bash comparison was made on.
1212    #[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    /// Restarting the pane taimux is running in would kill taimux mid-restart.
1226    #[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        // an activity line: mid-turn
1240        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    /// Even with --include-busy: a half-answered permission prompt is the one
1252    /// state where the Ctrl-C that starts a restart means something else.
1253    #[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    /// The pane this whole thing is for: three rows, no box on it, and a session
1275    /// sitting idle behind that. Read at a size it can be read at, it is an
1276    /// ordinary restart.
1277    #[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    /// And the point of reading it rather than assuming: a draft is invisible at
1293    /// three rows too, so the zoomed read is the only thing that can find one.
1294    /// Refused here for what is actually in the box, not for the box being
1295    /// missing.
1296    #[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    /// A dialog is off the bottom of a short pane with the box, so the zoomed
1310    /// read is what finds that too, and a dialog is refused with or without
1311    /// `--include-busy`.
1312    #[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    /// A pane already showing its box is never zoomed: the read is already good,
1328    /// and a zoom is something the person watching that window sees happen.
1329    #[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    /// A transcript touched in the last 45 seconds is left alone whatever the
1338    /// screen said, because the screen reading is the fragile half.
1339    #[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    /// Two panes on one conversation is the same mistake on a restart as on a
1361    /// restore: the second one to claim it gets skipped rather than resumed.
1362    #[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()); // make the second one stale too
1366        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()); // %1 was not considered at all
1381    }
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    /// --transcript names the conversation outright, which is the escape hatch
1404    /// for a pane the ladder refuses.
1405    #[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}