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 rows claude needs before it draws its prompt box at all.
148///
149/// Measured against 2.1.278 in a tmux server of its own, a row at a time: at six
150/// the `❯` is on screen and whatever has been typed into it with it, at five the
151/// last row is the box's own top rule and neither the glyph nor the draft is
152/// anywhere on the pane, and by three nothing of the box survives. Width does not
153/// move it, 46, 54, 63, 80 and 213 columns all break in the same place.
154///
155/// A pane under it is not a pane in a strange state, it is a pane that cannot be
156/// READ: the box, an unsent draft inside it and a dialog over it go off the
157/// bottom together, so `screen_has_no_draft` refuses it and would go on refusing
158/// it for as long as the pane stays that size. Nine of the fourteen outdated
159/// panes on this machine were exactly that, which is how a sweep meant to clear
160/// the outdated list offered to clear four of them.
161const BOX_ROWS: usize = 6;
162
163/// One tmux command line, as the arguments it is made of.
164type Cmd = Vec<String>;
165
166/// Getting a pane to a readable size, and putting the window back after.
167type ZoomSteps = (Vec<Cmd>, Vec<Cmd>);
168
169/// What it takes to read a pane at a size claude will draw on, and what it takes
170/// to put the window back afterwards.
171///
172/// Split out from the running of it so the ORDER can be tested without a tmux to
173/// run it against, the order being the whole of the difficulty. `None` when there
174/// is nothing to gain: a pane already tall enough, or a window no taller than the
175/// pane it holds, where zooming would hand it the rows it already has.
176///
177/// **Zoom rather than `resize-pane -y`**, and only a crowded window shows why:
178/// seven panes in seventeen rows share eleven rows of content, six of which are
179/// already spoken for by the others, so the tallest any one of them can be made
180/// is five, one short of what the box needs. Zoom is the only growth that does
181/// not have to come out of a sibling. It also leaves the LAYOUT untouched, so
182/// putting the window back is a matter of the zoom flag and the active pane
183/// rather than of replaying a layout string and hoping it lands.
184fn zoom_steps(
185    pane: &str,
186    zoomed: bool,
187    pane_rows: usize,
188    window_rows: usize,
189    active: &str,
190    last: &str,
191) -> Option<ZoomSteps> {
192    if pane_rows >= BOX_ROWS || window_rows < BOX_ROWS || window_rows <= pane_rows {
193        return None;
194    }
195    let cmd = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Cmd>();
196
197    let mut go = Vec::new();
198    // `-Z` toggles the WINDOW's zoom whatever pane it is pointed at, so on a
199    // window that arrives zoomed the first one only ever switches the OTHER pane
200    // off, and a second is what zooms this one. Two identical command lines in a
201    // row is not a duplicated push.
202    if zoomed {
203        go.push(cmd(&["resize-pane", "-Z", "-t", pane]));
204    }
205    go.push(cmd(&["resize-pane", "-Z", "-t", pane]));
206
207    let mut back = vec![cmd(&["resize-pane", "-Z", "-t", pane])];
208    // Zooming made this pane the active one and pushed whatever was active into
209    // the window's "last pane", so both have to go back, and in that order:
210    // selecting the old last pane first leaves the old active one current with
211    // the right pane behind it. `prefix + ;` is the user's binding, not taimux's
212    // to spend. A pane that was active already has neither to restore.
213    if active != pane {
214        if !last.is_empty() && last != pane {
215            back.push(cmd(&["select-pane", "-t", last]));
216        }
217        if !active.is_empty() {
218            back.push(cmd(&["select-pane", "-t", active]));
219        }
220    }
221    // A window that arrives zoomed is zoomed on its active pane by definition,
222    // which is why that pane is never this one: it would have the window's full
223    // height and be refused above.
224    if zoomed && !active.is_empty() {
225        back.push(cmd(&["resize-pane", "-Z", "-t", active]));
226    }
227    Some((go, back))
228}
229
230/// The commands that put the window back, run when this goes out of scope.
231///
232/// A guard rather than a line at the end of the function because the read
233/// between the two can panic, and a window left zoomed on a pane nobody chose is
234/// a worse outcome than a pane left unread.
235struct Restoring(Vec<Cmd>);
236
237impl Drop for Restoring {
238    fn drop(&mut self) {
239        for c in &self.0 {
240            tmux::run(&c.iter().map(String::as_str).collect::<Vec<_>>());
241        }
242    }
243}
244
245/// One pane's screen, read at a size claude will draw its prompt box on.
246///
247/// `None` when the pane did not need it or the window cannot give it, in which
248/// case nothing was touched and the screen already in hand is the best there is.
249///
250/// The wait is not padding. claude redraws on the SIGWINCH and does it fast,
251/// measured at 13 to 16ms across six runs with the draft already in the first
252/// readable frame, but a capture taken with no wait at all comes back with no box
253/// at all: tmux does not reflow an old frame into the new rows, it hands over
254/// what is there and claude fills it a moment later. So the poll is what makes
255/// the read real, and its ceiling is thirty times the measurement rather than a
256/// guess at it.
257pub fn capture_zoomed(pane: &str) -> Option<String> {
258    if !taimux_core::env::on("TAIMUX_ZOOM_TO_READ") {
259        return None;
260    }
261    let geom = tmux::ask(&[
262        "display-message",
263        "-p",
264        "-t",
265        pane,
266        "-F",
267        "#{window_zoomed_flag}\t#{pane_height}\t#{window_height}\t#{window_id}",
268    ])?;
269    let g: Vec<&str> = geom.trim_end().split('\t').collect();
270    if g.len() < 4 {
271        return None;
272    }
273    let (zoomed, win) = (g[0] == "1", g[3]);
274    let pane_rows: usize = g[1].parse().ok()?;
275    let window_rows: usize = g[2].parse().ok()?;
276
277    let mut active = String::new();
278    let mut last = String::new();
279    for l in tmux::ask(&[
280        "list-panes",
281        "-t",
282        win,
283        "-F",
284        "#{pane_id}\t#{pane_active}\t#{pane_last}",
285    ])?
286    .lines()
287    {
288        let c: Vec<&str> = l.split('\t').collect();
289        if c.len() < 3 {
290            continue;
291        }
292        if c[1] == "1" {
293            active = c[0].to_string();
294        }
295        if c[2] == "1" {
296            last = c[0].to_string();
297        }
298    }
299
300    let (go, back) = zoom_steps(pane, zoomed, pane_rows, window_rows, &active, &last)?;
301    for c in &go {
302        tmux::run(&c.iter().map(String::as_str).collect::<Vec<_>>());
303    }
304    let _restore = Restoring(back);
305
306    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
307    loop {
308        let screen = tmux::capture(pane).unwrap_or_default();
309        if screen.contains('❯') || std::time::Instant::now() >= deadline {
310            return Some(screen);
311        }
312        std::thread::sleep(std::time::Duration::from_millis(10));
313    }
314}
315
316/// The version a running process is executing, read from its own `/proc/exe`
317/// rather than from the binary on `$PATH`: a long-lived session goes on running
318/// the release it started under.
319pub fn version_of_pid(pid: i32, versions_dir: &str) -> Option<String> {
320    let exe = std::fs::read_link(format!("/proc/{}/exe", pid)).ok()?;
321    let exe = exe.to_string_lossy();
322    let exe = exe.trim_end_matches(" (deleted)"); // replaced by an update
323    let prefix = format!("{}/", versions_dir);
324    exe.strip_prefix(&prefix)
325        .filter(|rest| !rest.is_empty())
326        .map(|rest| rest.split('/').next().unwrap_or(rest).to_string())
327}
328
329/// One pane the plan will act on.
330pub struct Planned {
331    pub pane: String,
332    pub target: String,
333    pub pid: i32,
334    pub cmd: String,
335    pub title: String,
336    pub via: String,
337}
338
339pub struct Plan {
340    pub newver: String,
341    pub launcher: String,
342    pub go: Vec<Planned>,
343    pub skipped: Vec<String>,
344    /// Set when at least one pane was skipped for being unresolvable, which gets
345    /// its own paragraph: it is the one skip the reader can act on.
346    pub unresolved: bool,
347}
348
349pub struct Opts {
350    pub include_busy: bool,
351    pub only_panes: Vec<String>,
352    pub force_transcript: Option<String>,
353    pub self_pane: String,
354}
355
356/// Everything the plan needs from the outside, so it can be built against
357/// fixtures as well as against a live machine.
358pub trait Env {
359    fn capture(&self, pane: &str) -> String;
360    /// The same pane read at a size claude will draw its prompt box on, for the
361    /// one that is too short to show one at the size it is.
362    ///
363    /// `None` by default, and for every fixture: a screen handed over by a test
364    /// is already the screen that test means, and only the live implementation
365    /// has a window to zoom.
366    fn capture_zoomed(&self, _pane: &str) -> Option<String> {
367        None
368    }
369    fn hook_state(&self, pane: &str, pid: i32) -> Option<String>;
370    fn version_of_pid(&self, pid: i32) -> Option<String>;
371    fn cwd_of(&self, pid: i32) -> Option<String>;
372    fn argv_of(&self, pid: i32) -> Vec<String>;
373    fn resolve(&self, pane: &str, cwd: &str, title: &str, pid: i32) -> Result<String, String>;
374    fn read_transcript(&self, path: &str) -> Option<String>;
375    fn now(&self) -> i64;
376}
377
378/// Decide what to restart, and say why for everything else.
379///
380/// `agents` is `claude agents --json`, fetched by the caller and only when a
381/// stale non-pane process actually needs naming: the CLI costs about three
382/// seconds to start.
383pub fn plan(
384    rows: &str,
385    newver: &str,
386    launcher: &str,
387    o: &Opts,
388    e: &dyn Env,
389    versions_dir: &str,
390    agents: &dyn Fn() -> String,
391) -> Plan {
392    let mut p = Plan {
393        newver: newver.to_string(),
394        launcher: launcher.to_string(),
395        go: Vec::new(),
396        skipped: Vec::new(),
397        unresolved: false,
398    };
399    let mut claimed: HashMap<String, String> = HashMap::new();
400    // Every pane's agent pid, so the non-pane sweep below can tell a session
401    // that has a pane from one that has not.
402    let mut matched: Vec<i32> = Vec::new();
403
404    for line in rows.lines() {
405        let f: Vec<&str> = line.split('\t').collect();
406        if f.len() < 7 || f[3] != "claude" {
407            continue;
408        }
409        let (id, tgt, cwd, title) = (f[0], f[1], f[2], f[6]);
410        let pid: i32 = f[4].parse().unwrap_or(0);
411
412        let Some(ver) = (pid != 0).then(|| e.version_of_pid(pid)).flatten() else {
413            p.skipped.push(format!(
414                "{} {}  looks like claude but no session process was found under the pane",
415                id, tgt
416            ));
417            continue;
418        };
419        matched.push(pid);
420        if !o.only_panes.is_empty() && !o.only_panes.iter().any(|w| w == id) {
421            continue;
422        }
423        if ver == newver {
424            continue; // nothing to gain from interrupting it
425        }
426        if id == o.self_pane {
427            p.skipped.push(format!(
428                "{} {}  {}, this pane: restart it by hand (killing it would kill taimux)",
429                id, tgt, ver
430            ));
431            continue;
432        }
433
434        let mut screen = e.capture(id);
435        // A screen with no prompt box on it is either a session doing something
436        // unusual or a pane too short to draw one, and those want opposite
437        // answers: the first is a refusal, the second is a measurement that has
438        // not been taken yet. Taking it costs the window a zoom for the
439        // milliseconds claude needs to redraw, and buys a reading of the box, of
440        // anything typed into it and of any dialog over it, none of which is on
441        // the pane at the size it sits at. It happens here rather than after the
442        // state check on purpose: `merge` is reading the same blank screen.
443        if !screen.contains('❯') {
444            if let Some(bigger) = e.capture_zoomed(id) {
445                screen = bigger;
446            }
447        }
448        let st = state::merge(&screen, e.hook_state(id, pid).as_deref());
449        if st.as_str() != "idle" && !o.include_busy {
450            p.skipped.push(format!(
451                "{} {}  {}, {}: rerun when idle, or --include-busy",
452                id,
453                tgt,
454                ver,
455                st.as_str()
456            ));
457            continue;
458        }
459
460        let ccwd = e.cwd_of(pid).unwrap_or_else(|| cwd.to_string());
461        let (transcript, via) = match &o.force_transcript {
462            Some(t) => (t.clone(), "--transcript, given".to_string()),
463            None => match e.resolve(id, &ccwd, title, pid) {
464                Ok(t) => {
465                    // resolve() hands back "<path>\t<why>"
466                    let (path, why) = t.split_once('\t').unwrap_or((t.as_str(), ""));
467                    (path.to_string(), why.to_string())
468                }
469                Err(why) => {
470                    p.skipped
471                        .push(format!("{} {}  {}, unresolved: {}", id, tgt, ver, why));
472                    p.unresolved = true;
473                    continue;
474                }
475            },
476        };
477
478        if let Err(why) = screen_has_no_dialog(&screen) {
479            p.skipped
480                .push(format!("{} {}  {}, not settled: {}", id, tgt, ver, why));
481            continue;
482        }
483        if !o.include_busy {
484            let settled =
485                screen_has_no_draft(&screen).and_then(|()| match e.read_transcript(&transcript) {
486                    Some(text) => transcript_is_settled(&text, e.now()),
487                    None => Ok(()),
488                });
489            if let Err(why) = settled {
490                p.skipped
491                    .push(format!("{} {}  {}, not settled: {}", id, tgt, ver, why));
492                continue;
493            }
494        }
495
496        if let Some(first) = claimed.get(&transcript) {
497            p.skipped.push(format!(
498                "{} {}  {}, resolves to the same transcript as {}",
499                id, tgt, ver, first
500            ));
501            continue;
502        }
503        claimed.insert(transcript.clone(), id.to_string());
504
505        let Some(cmd) = conv::build_cmd(&e.argv_of(pid), &transcript, &ccwd, cwd) else {
506            p.skipped
507                .push(format!("{} {}  {}, could not read its argv", id, tgt, ver));
508            continue;
509        };
510        p.go.push(Planned {
511            pane: id.to_string(),
512            target: tgt.to_string(),
513            pid,
514            cmd,
515            title: title.to_string(),
516            via: format!("{} -> {}, {}, {}", ver, newver, via, st.as_str()),
517        });
518    }
519
520    // Stale claude processes that are not a pane's foreground job. Reported,
521    // never touched: a restart types into a PANE, and these have none, so the
522    // only useful thing to do about a stale one is name it. The naming costs a
523    // `claude agents --json`, so it is fetched lazily and once.
524    let mut agents_json: Option<String> = None;
525    for np in nonpane_pids(&matched, versions_dir) {
526        let Some(ver) = e.version_of_pid(np) else {
527            continue;
528        };
529        if ver == newver {
530            continue;
531        }
532        let j = agents_json.get_or_insert_with(agents);
533        p.skipped.push(format!(
534            "pid {}  {}, not a tmux pane: {}",
535            np,
536            ver,
537            describe_nonpane(&e.argv_of(np), j)
538        ));
539    }
540    p
541}
542
543/// The plan, as the reader sees it. Byte-for-byte what bash printed, because the
544/// only way to know a port of this is right is to compare it.
545pub fn render(p: &Plan) -> String {
546    let mut s = format!("claude: {} installed at {}\n\n", p.newver, p.launcher);
547    if p.go.is_empty() {
548        s.push_str("nothing to restart.\n");
549    } else {
550        s.push_str(&format!("to restart ({}):\n", p.go.len()));
551        for g in &p.go {
552            s.push_str(&format!("  {:<5} {:<14} {}\n", g.pane, g.target, g.title));
553            s.push_str(&format!("        {}\n", g.via));
554            s.push_str(&format!("        {}\n", g.cmd));
555        }
556    }
557    if !p.skipped.is_empty() {
558        s.push_str(&format!("\nskipped ({}):\n", p.skipped.len()));
559        for k in &p.skipped {
560            s.push_str(&format!("  {}\n", k));
561        }
562    }
563    if p.unresolved {
564        s.push_str(
565            "\nAn unresolved pane means guessing, so it was left alone: restart it by hand\n\
566             with `claude -c` in that pane, or from its /resume picker. Each session records\n\
567             its pane at the next SessionStart, so a pane resolves cleanly once restarted.\n",
568        );
569    }
570    s
571}
572
573/// Interrupt a session, wait for it to go, and type its replacement.
574///
575/// Two Ctrl-Cs, because the first one arms claude's "press again to exit" and the
576/// second takes it. `/exit` after six waits is for a session that ignores both,
577/// and thirty waits (twelve seconds) is where it gives up rather than typing a
578/// command into a pane that still has a session in it.
579pub fn restart_pane(pane: &str, pid: i32, cmd: &str) -> bool {
580    use std::thread::sleep;
581    use std::time::Duration;
582
583    tmux::run(&["send-keys", "-t", pane, "C-c"]);
584    sleep(Duration::from_millis(300));
585    tmux::run(&["send-keys", "-t", pane, "C-c"]);
586
587    let mut waited = 0;
588    let mut sent_exit = false;
589    while alive(pid) {
590        sleep(Duration::from_millis(400));
591        waited += 1;
592        if waited >= 6 && !sent_exit {
593            tmux::run(&["send-keys", "-t", pane, "/exit", "Enter"]);
594            sent_exit = true;
595        }
596        if waited >= 30 {
597            return false;
598        }
599    }
600    sleep(Duration::from_millis(500));
601    tmux::run(&["send-keys", "-t", pane, "C-c"]);
602    sleep(Duration::from_millis(200));
603    tmux::run(&["send-keys", "-t", pane, cmd, "Enter"])
604}
605
606/// Is a pid still there? `/proc` rather than `kill -0`, since nothing is being
607/// signalled and a directory read cannot be mistaken for one.
608fn alive(pid: i32) -> bool {
609    std::path::Path::new(&format!("/proc/{}", pid)).exists()
610}
611
612/// The default answer is yes, so a bare Enter restarts. The plan has already been
613/// printed by then, which is what makes that safe.
614pub fn confirm_yes(answer: &str) -> bool {
615    matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES" | "Yes")
616}
617
618/// The live implementation of everything `plan` needs.
619pub struct Live {
620    pub versions_dir: String,
621}
622
623impl Env for Live {
624    fn capture(&self, pane: &str) -> String {
625        tmux::ask_raw(&["capture-pane", "-p", "-t", pane]).unwrap_or_default()
626    }
627    fn capture_zoomed(&self, pane: &str) -> Option<String> {
628        capture_zoomed(pane)
629    }
630    fn hook_state(&self, pane: &str, pid: i32) -> Option<String> {
631        taimux_core::hook::hook_state_of(pane, pid)
632    }
633    fn version_of_pid(&self, pid: i32) -> Option<String> {
634        version_of_pid(pid, &self.versions_dir)
635    }
636    fn cwd_of(&self, pid: i32) -> Option<String> {
637        std::fs::read_link(format!("/proc/{}/cwd", pid))
638            .ok()
639            .map(|p| p.to_string_lossy().into_owned())
640            .filter(|s| !s.is_empty())
641    }
642    fn argv_of(&self, pid: i32) -> Vec<String> {
643        conv::argv_of(pid)
644    }
645    fn resolve(&self, pane: &str, cwd: &str, title: &str, pid: i32) -> Result<String, String> {
646        conv::resolve(pane, cwd, title, pid)
647            .map(|r| format!("{}\t{}", r.transcript.display(), r.why))
648    }
649    fn read_transcript(&self, path: &str) -> Option<String> {
650        std::fs::read_to_string(path).ok()
651    }
652    fn now(&self) -> i64 {
653        std::time::SystemTime::now()
654            .duration_since(std::time::UNIX_EPOCH)
655            .map(|d| d.as_secs() as i64)
656            .unwrap_or(0)
657    }
658}
659
660/// What a session started right now would run, refusing rather than guessing when
661/// the launcher points somewhere unexpected.
662pub fn installed(launcher: &str, versions_dir: &str) -> Result<String, String> {
663    let target = std::fs::canonicalize(launcher)
664        .map(|p| p.to_string_lossy().into_owned())
665        .unwrap_or_default();
666    let prefix = format!("{}/", versions_dir);
667    match target.strip_prefix(&prefix) {
668        Some(rest) if !rest.is_empty() => Ok(rest.split('/').next().unwrap_or(rest).to_string()),
669        _ => Err(format!(
670            "restart: {} does not point into {} (got '{}')",
671            launcher,
672            versions_dir,
673            if target.is_empty() {
674                "nothing"
675            } else {
676                &target
677            }
678        )),
679    }
680}
681
682pub fn versions_dir() -> String {
683    format!(
684        "{}/.local/share/claude/versions",
685        std::env::var("HOME").unwrap_or_default()
686    )
687}
688
689pub fn launcher() -> String {
690    format!(
691        "{}/.local/bin/claude",
692        std::env::var("HOME").unwrap_or_default()
693    )
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn a_timestamp_becomes_epoch_seconds() {
702        // 2026-09-02T01:23:45Z
703        assert_eq!(parse_iso8601("2026-09-02T01:23:45.678Z"), Some(1788312225));
704        assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), Some(0));
705        // a leap day, which a hand-rolled calendar is where it goes wrong
706        assert_eq!(parse_iso8601("2024-02-29T00:00:00Z"), Some(1709164800));
707    }
708
709    /// Anything not of that exact shape reads as "no answer", which the caller
710    /// treats as not-evidence rather than as busy: guessing busy would skip a
711    /// pane on a malformed line forever.
712    #[test]
713    fn an_unparseable_timestamp_is_no_answer() {
714        assert_eq!(parse_iso8601(""), None);
715        assert_eq!(parse_iso8601("yesterday"), None);
716        assert_eq!(parse_iso8601("2026-09-02 01:23:45"), None);
717        assert_eq!(parse_iso8601("2026-13-02T01:23:45Z"), None);
718    }
719
720    #[test]
721    fn a_tool_call_still_running_is_not_settled() {
722        let t = r#"{"type":"assistant","message":{"content":[{"type":"tool_use"}]},"timestamp":"2020-01-01T00:00:00Z"}"#;
723        assert_eq!(
724            transcript_is_settled(t, 1788312225),
725            Err("a tool call is still running".into())
726        );
727    }
728
729    /// Insurance for the screen reading, which is the most fragile thing here.
730    #[test]
731    fn recent_activity_is_not_settled_whatever_the_screen_said() {
732        let t = r#"{"type":"user","timestamp":"2026-09-02T01:23:45Z"}"#;
733        assert!(transcript_is_settled(t, 1788312225 + 10).is_err());
734        assert!(transcript_is_settled(t, 1788312225 + 100).is_ok());
735    }
736
737    #[test]
738    fn an_empty_or_odd_transcript_does_not_block() {
739        assert!(transcript_is_settled("", 0).is_ok());
740        assert!(transcript_is_settled("not json at all\n", 0).is_ok());
741        // no timestamp: nothing to judge recency by
742        assert!(transcript_is_settled(r#"{"type":"user"}"#, 0).is_ok());
743    }
744
745    /// A dialog is what `state::awaits_input` recognises, so the fixture has to
746    /// be one it would: either the footer in the last few lines, or a numbered
747    /// choice on the LOWEST prompt line. A made-up shape asserts nothing.
748    #[test]
749    fn a_dialog_on_screen_blocks_a_restart() {
750        let footer = "some output\n\nDo you want to proceed?\n";
751        assert_eq!(
752            screen_has_no_dialog(footer),
753            Err("a dialog is waiting for an answer".into())
754        );
755        let choice = "some output\n1. Yes\n2. No\n❯ 1. Yes\n";
756        assert_eq!(
757            screen_has_no_dialog(choice),
758            Err("a dialog is waiting for an answer".into())
759        );
760        // an ordinary idle screen is not a dialog
761        assert!(screen_has_no_dialog("some output\n❯ \n").is_ok());
762    }
763
764    /// A half-pressed Ctrl-C is the one state where the keystroke that starts a
765    /// restart is also an answer to something else.
766    #[test]
767    fn a_half_pressed_ctrl_c_blocks_a_restart() {
768        let screen = "work\n\nPress Ctrl-C again to exit\n";
769        assert_eq!(
770            screen_has_no_dialog(screen),
771            Err("a Ctrl-C is already half-pressed".into())
772        );
773    }
774
775    #[test]
776    fn an_empty_prompt_box_is_no_draft() {
777        assert!(screen_has_no_draft("stuff\n❯ \n").is_ok());
778        // the non-breaking space claude pads the box with is not a draft
779        assert!(screen_has_no_draft("stuff\n❯ \u{a0}\u{a0}\n").is_ok());
780        assert_eq!(
781            screen_has_no_draft("stuff\n❯ half a thought\n"),
782            Err("unsent text in the prompt box".into())
783        );
784    }
785
786    /// The four rows a pane has to be given before any of this can be asked of
787    /// it, and the two it can be left at.
788    #[test]
789    fn a_pane_tall_enough_to_read_is_left_alone() {
790        // already showing its box: nothing to gain, and nothing touched
791        assert!(zoom_steps("%1", false, 6, 40, "%2", "%3").is_none());
792        assert!(zoom_steps("%1", false, 40, 40, "%2", "%3").is_none());
793        // a window no taller than the pane has no rows to lend it
794        assert!(zoom_steps("%1", false, 3, 3, "%2", "%3").is_none());
795        assert!(zoom_steps("%1", false, 3, 5, "%2", "%3").is_none());
796    }
797
798    /// The common shape: a three-row pane in a window nobody has zoomed. One
799    /// zoom out and back, and the pane that was active is active again with the
800    /// pane that was behind it still behind it.
801    #[test]
802    fn a_short_pane_is_zoomed_and_the_selection_put_back() {
803        let (go, back) = zoom_steps("%1", false, 3, 17, "%2", "%3").unwrap();
804        assert_eq!(go, vec![vec!["resize-pane", "-Z", "-t", "%1"]]);
805        assert_eq!(
806            back,
807            vec![
808                vec!["resize-pane", "-Z", "-t", "%1"],
809                vec!["select-pane", "-t", "%3"],
810                vec!["select-pane", "-t", "%2"],
811            ]
812        );
813    }
814
815    /// A window that arrives zoomed on another pane takes TWO `-Z` to zoom this
816    /// one, because the first is spent switching the other one off, and it is
817    /// owed a re-zoom at the end.
818    #[test]
819    fn a_window_zoomed_elsewhere_is_handed_back_zoomed() {
820        let (go, back) = zoom_steps("%1", true, 3, 17, "%2", "%3").unwrap();
821        assert_eq!(
822            go,
823            vec![
824                vec!["resize-pane", "-Z", "-t", "%1"],
825                vec!["resize-pane", "-Z", "-t", "%1"],
826            ]
827        );
828        assert_eq!(
829            back,
830            vec![
831                vec!["resize-pane", "-Z", "-t", "%1"],
832                vec!["select-pane", "-t", "%3"],
833                vec!["select-pane", "-t", "%2"],
834                vec!["resize-pane", "-Z", "-t", "%2"],
835            ]
836        );
837    }
838
839    /// Zooming the pane that is already active changes no selection, so putting
840    /// one back would be the only thing that moved it.
841    #[test]
842    fn an_active_short_pane_has_no_selection_to_restore() {
843        let (go, back) = zoom_steps("%1", false, 3, 17, "%1", "%3").unwrap();
844        assert_eq!(go.len(), 1);
845        assert_eq!(back, vec![vec!["resize-pane", "-Z", "-t", "%1"]]);
846    }
847
848    /// No box at all means the screen is not what it is expected to be, and that
849    /// is a refusal rather than a shrug.
850    #[test]
851    fn no_prompt_box_is_a_refusal() {
852        assert_eq!(
853            screen_has_no_draft("just some text\n"),
854            Err("no prompt box on screen".into())
855        );
856        // …but a genuinely blank screen is not judged at all
857        assert!(screen_has_no_draft("").is_ok());
858        assert!(screen_has_no_draft("   \n\n").is_ok());
859    }
860
861    #[test]
862    fn a_bare_enter_confirms() {
863        assert!(confirm_yes(""));
864        assert!(confirm_yes("y"));
865        assert!(confirm_yes("YES"));
866        assert!(!confirm_yes("n"));
867        assert!(!confirm_yes("no"));
868        assert!(!confirm_yes("maybe"));
869    }
870
871    #[test]
872    fn the_launcher_must_point_into_the_versions_dir() {
873        let root = std::env::temp_dir().join(format!("jmrs{}", std::process::id()));
874        let vers = root.join("versions");
875        std::fs::create_dir_all(&vers).unwrap();
876        std::fs::write(vers.join("2.1.258"), "x").unwrap();
877        let link = root.join("claude");
878        std::os::unix::fs::symlink(vers.join("2.1.258"), &link).unwrap();
879        assert_eq!(
880            installed(&link.to_string_lossy(), &vers.to_string_lossy()),
881            Ok("2.1.258".into())
882        );
883        // pointing elsewhere is a refusal that names what it found
884        std::fs::write(root.join("elsewhere"), "x").unwrap();
885        std::fs::remove_file(&link).unwrap();
886        std::os::unix::fs::symlink(root.join("elsewhere"), &link).unwrap();
887        let e = installed(&link.to_string_lossy(), &vers.to_string_lossy()).expect_err("refused");
888        assert!(e.contains("elsewhere"));
889        let _ = std::fs::remove_dir_all(&root);
890    }
891}
892
893/// A claude process that is not any pane's foreground job: Zed's ACP bridge, a
894/// background agent, one of the daemon's spare pty hosts.
895///
896/// Reported, never touched. A restart types into a PANE, and these have none, so
897/// the only useful thing to do about a stale one is name it.
898pub fn nonpane_pids(matched: &[i32], versions_dir: &str) -> Vec<i32> {
899    let mut out = Vec::new();
900    for e in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
901        let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<i32>().ok()) else {
902            continue;
903        };
904        if matched.contains(&pid) {
905            continue;
906        }
907        // Either its comm is "claude" (pgrep -x claude) or it is running out of
908        // the versions directory (pgrep -f "^<vdir>/").
909        let comm = std::fs::read_to_string(format!("/proc/{}/comm", pid)).unwrap_or_default();
910        let argv = conv::argv_of(pid);
911        let is_claude = comm.trim() == "claude"
912            || argv
913                .first()
914                .map(|a| a.starts_with(&format!("{}/", versions_dir)))
915                .unwrap_or(false);
916        if is_claude {
917            out.push(pid);
918        }
919    }
920    out.sort_unstable();
921    out
922}
923
924/// The object in a JSON array whose `key` starts with `prefix`, as raw text.
925///
926/// A brace matcher rather than a parser, and rather than the `jq` the bash
927/// version shelled out to. It only has to find one element of one array, and the
928/// depth count is what makes it safe against nested objects: taking everything
929/// between the first `{` and the first `}` would truncate any element with a
930/// nested field.
931fn json_object_with_prefix(text: &str, key: &str, prefix: &str) -> Option<String> {
932    let needle = format!("\"{}\":\"{}", key, prefix);
933    let at = text.find(&needle).or_else(|| {
934        let spaced = format!("\"{}\": \"{}", key, prefix);
935        text.find(&spaced)
936    })?;
937    // back to the opening brace of the object holding it
938    let mut depth = 0i32;
939    let bytes = text.as_bytes();
940    let mut start = None;
941    for i in (0..at).rev() {
942        match bytes[i] {
943            b'}' => depth += 1,
944            b'{' => {
945                if depth == 0 {
946                    start = Some(i);
947                    break;
948                }
949                depth -= 1;
950            }
951            _ => {}
952        }
953    }
954    let start = start?;
955    // forward to its matching close
956    let mut depth = 0i32;
957    for i in start..bytes.len() {
958        match bytes[i] {
959            b'{' => depth += 1,
960            b'}' => {
961                depth -= 1;
962                if depth == 0 {
963                    return Some(text[start..=i].to_string());
964                }
965            }
966            _ => {}
967        }
968    }
969    None
970}
971
972/// What to say about a claude that has no pane.
973///
974/// `agents_json` is `claude agents --json`, which costs about three seconds of
975/// CLI startup, so it is fetched once by the caller and only when something needs
976/// naming. Its own `pid` field points at the pty-host wrapper rather than at the
977/// session, so it is read for kind, state and name and nothing else.
978pub fn describe_nonpane(argv: &[String], agents_json: &str) -> String {
979    if argv.is_empty() {
980        return "gone".into();
981    }
982    let (mut sid, mut res, mut forked) = (String::new(), String::new(), false);
983    for i in 1..argv.len() {
984        match argv[i].as_str() {
985            "--fork-session" => forked = true,
986            "--session-id" => sid = argv.get(i + 1).cloned().unwrap_or_default(),
987            "-r" | "--resume" => res = argv.get(i + 1).cloned().unwrap_or_default(),
988            _ => {}
989        }
990    }
991    let own = base_id(if sid.is_empty() { &res } else { &sid });
992    let parent = if forked && !res.is_empty() {
993        base_id(&res)
994    } else {
995        String::new()
996    };
997
998    let (mut kind, mut st, mut name) = ("?".to_string(), "?".to_string(), String::new());
999    if !own.is_empty() {
1000        if let Some(obj) = json_object_with_prefix(agents_json, "sessionId", &own) {
1001            let f = |k: &str| taimux_core::json::field(&obj, k);
1002            let k = f("kind");
1003            if !k.is_empty() {
1004                kind = k;
1005            }
1006            let s = {
1007                let a = f("state");
1008                if a.is_empty() {
1009                    f("status")
1010                } else {
1011                    a
1012                }
1013            };
1014            if !s.is_empty() {
1015                st = s;
1016            }
1017            name = f("name");
1018        }
1019    }
1020    let mut out = format!("{} {}", kind, st);
1021    if forked {
1022        out.push_str(" fork");
1023    }
1024    if !name.is_empty() {
1025        out.push_str(&format!(" \"{}\"", name));
1026    }
1027    if !own.is_empty() {
1028        out.push_str(&format!(" [{}]", own.chars().take(8).collect::<String>()));
1029    }
1030    if !parent.is_empty() {
1031        out.push_str(&format!(
1032            " of [{}]",
1033            parent.chars().take(8).collect::<String>()
1034        ));
1035    }
1036    out
1037}
1038
1039/// `basename x .jsonl`: a session id, whether it arrived as one or as a path.
1040fn base_id(s: &str) -> String {
1041    if s.is_empty() {
1042        return String::new();
1043    }
1044    let b = s.rsplit('/').next().unwrap_or(s);
1045    b.strip_suffix(".jsonl").unwrap_or(b).to_string()
1046}
1047
1048#[cfg(test)]
1049mod nonpane_tests {
1050    use super::*;
1051
1052    fn v(a: &[&str]) -> Vec<String> {
1053        a.iter().map(|s| s.to_string()).collect()
1054    }
1055
1056    #[test]
1057    fn a_session_id_is_read_out_of_a_path_or_taken_as_it_stands() {
1058        assert_eq!(base_id("/a/b/263946b5-9bd7.jsonl"), "263946b5-9bd7");
1059        assert_eq!(base_id("263946b5"), "263946b5");
1060        assert_eq!(base_id(""), "");
1061    }
1062
1063    /// The depth count is the whole point: taking everything between the first
1064    /// brace and the first close would truncate any element with a nested field,
1065    /// and `claude agents --json` has them.
1066    #[test]
1067    fn the_right_object_comes_back_whole() {
1068        let j = r#"[{"sessionId":"aaa111","kind":"task","meta":{"a":1},"name":"first"},
1069                    {"sessionId":"bbb222","kind":"agent","name":"second"}]"#;
1070        let o = json_object_with_prefix(j, "sessionId", "bbb222").expect("found");
1071        assert!(o.contains("second"));
1072        assert!(!o.contains("first"));
1073        let o = json_object_with_prefix(j, "sessionId", "aaa").expect("found by prefix");
1074        assert!(o.contains("first"));
1075        assert!(
1076            o.contains("\"meta\":{\"a\":1}"),
1077            "nested field truncated: {}",
1078            o
1079        );
1080        assert!(json_object_with_prefix(j, "sessionId", "zzz").is_none());
1081    }
1082
1083    #[test]
1084    fn a_process_with_no_argv_is_simply_gone() {
1085        assert_eq!(describe_nonpane(&[], "[]"), "gone");
1086    }
1087
1088    #[test]
1089    fn an_unnamed_session_still_says_what_it_can() {
1090        let d = describe_nonpane(&v(&["claude", "--session-id", "263946b5-9bd7"]), "[]");
1091        assert_eq!(d, "? ? [263946b5]");
1092    }
1093
1094    #[test]
1095    fn a_named_one_says_kind_state_and_name() {
1096        let j =
1097            r#"[{"sessionId":"263946b5-9bd7","kind":"task","state":"running","name":"the thing"}]"#;
1098        let d = describe_nonpane(&v(&["claude", "--session-id", "263946b5-9bd7"]), j);
1099        assert_eq!(d, "task running \"the thing\" [263946b5]");
1100    }
1101
1102    /// `status` is the older field name, and one of the two is what a given
1103    /// version emits.
1104    #[test]
1105    fn status_stands_in_for_state() {
1106        let j = r#"[{"sessionId":"aaa11111","kind":"agent","status":"idle"}]"#;
1107        let d = describe_nonpane(&v(&["claude", "--session-id", "aaa11111"]), j);
1108        assert_eq!(d, "agent idle [aaa11111]");
1109    }
1110
1111    /// A fork names both itself and its parent, because that is the pair you need
1112    /// to work out which one is the stale one.
1113    #[test]
1114    fn a_fork_names_its_parent_too() {
1115        let d = describe_nonpane(
1116            &v(&[
1117                "claude",
1118                "--session-id",
1119                "child111",
1120                "--fork-session",
1121                "--resume",
1122                "/p/parent22.jsonl",
1123            ]),
1124            "[]",
1125        );
1126        assert_eq!(d, "? ? fork [child111] of [parent22]");
1127    }
1128}
1129
1130#[cfg(test)]
1131mod plan_tests {
1132    use super::*;
1133
1134    /// A machine with one stale pane, one current one, and whatever else the test
1135    /// asks for. The `Env` trait exists for this: the live differential can only
1136    /// reach "nothing to restart" while every session is on the installed
1137    /// version, so the branch that actually acts needs a fixture.
1138    struct Fake {
1139        /// pid -> version
1140        vers: HashMap<i32, String>,
1141        /// pane -> screen
1142        screens: HashMap<String, String>,
1143        /// pane -> resolved transcript, or the refusal
1144        resolved: HashMap<String, Result<String, String>>,
1145        /// pane -> what the same pane shows once it has been zoomed, for a pane
1146        /// too short to draw a prompt box at the size it sits at
1147        zoomed: HashMap<String, String>,
1148        /// how many panes were zoomed to be read, since a zoom is something the
1149        /// user watching that window sees happen
1150        zooms: std::cell::Cell<usize>,
1151        transcript: String,
1152        now: i64,
1153    }
1154
1155    impl Env for Fake {
1156        fn capture(&self, pane: &str) -> String {
1157            self.screens.get(pane).cloned().unwrap_or_default()
1158        }
1159        fn capture_zoomed(&self, pane: &str) -> Option<String> {
1160            let bigger = self.zoomed.get(pane).cloned();
1161            if bigger.is_some() {
1162                self.zooms.set(self.zooms.get() + 1);
1163            }
1164            bigger
1165        }
1166        fn hook_state(&self, _pane: &str, _pid: i32) -> Option<String> {
1167            None
1168        }
1169        fn version_of_pid(&self, pid: i32) -> Option<String> {
1170            self.vers.get(&pid).cloned()
1171        }
1172        fn cwd_of(&self, _pid: i32) -> Option<String> {
1173            Some("/w".into())
1174        }
1175        fn argv_of(&self, _pid: i32) -> Vec<String> {
1176            vec!["claude".into()]
1177        }
1178        fn resolve(&self, pane: &str, _c: &str, _t: &str, _p: i32) -> Result<String, String> {
1179            self.resolved
1180                .get(pane)
1181                .cloned()
1182                .unwrap_or_else(|| Err("no candidate".into()))
1183        }
1184        fn read_transcript(&self, _path: &str) -> Option<String> {
1185            Some(self.transcript.clone())
1186        }
1187        fn now(&self) -> i64 {
1188            self.now
1189        }
1190    }
1191
1192    fn fake() -> Fake {
1193        let mut vers = HashMap::new();
1194        vers.insert(11, "2.1.100".to_string()); // stale
1195        vers.insert(22, "2.1.258".to_string()); // current
1196        let mut screens = HashMap::new();
1197        // an idle screen with an empty prompt box
1198        screens.insert("%1".to_string(), "some output\n❯ \n".to_string());
1199        screens.insert("%2".to_string(), "some output\n❯ \n".to_string());
1200        let mut resolved = HashMap::new();
1201        resolved.insert("%1".to_string(), Ok("/t/a.jsonl\tpane map".to_string()));
1202        Fake {
1203            vers,
1204            screens,
1205            resolved,
1206            zoomed: HashMap::new(),
1207            zooms: std::cell::Cell::new(0),
1208            transcript: r#"{"type":"user","timestamp":"2020-01-01T00:00:00Z"}"#.to_string(),
1209            now: 1788312225,
1210        }
1211    }
1212
1213    fn opts() -> Opts {
1214        Opts {
1215            include_busy: false,
1216            only_panes: Vec::new(),
1217            force_transcript: None,
1218            self_pane: String::new(),
1219        }
1220    }
1221
1222    const ROWS: &str = "%1\tw:1.1\t/w\tclaude\t11\tclaude\tproj: the stale one\n\
1223                        %2\tw:2.1\t/w\tclaude\t22\tclaude\tproj: the current one";
1224
1225    fn plan_of(e: &Fake, o: &Opts) -> Plan {
1226        plan(ROWS, "2.1.258", "/l/claude", o, e, "/nowhere", &|| {
1227            "[]".into()
1228        })
1229    }
1230
1231    #[test]
1232    fn a_stale_pane_is_planned_and_a_current_one_is_not() {
1233        let p = plan_of(&fake(), &opts());
1234        assert_eq!(p.go.len(), 1);
1235        assert_eq!(p.go[0].pane, "%1");
1236        assert_eq!(p.go[0].cmd, "command claude --resume /t/a.jsonl");
1237        assert_eq!(p.go[0].via, "2.1.100 -> 2.1.258, pane map, idle");
1238        assert!(p.skipped.is_empty());
1239    }
1240
1241    /// The rendered plan is the whole user interface of `restart -n`, so its
1242    /// exact shape is what the bash comparison was made on.
1243    #[test]
1244    fn the_rendered_plan_reads_the_way_it_always_did() {
1245        let out = render(&plan_of(&fake(), &opts()));
1246        assert_eq!(
1247            out,
1248            "claude: 2.1.258 installed at /l/claude\n\n\
1249             to restart (1):\n\
1250             \x20 %1    w:1.1          proj: the stale one\n\
1251             \x20       2.1.100 -> 2.1.258, pane map, idle\n\
1252             \x20       command claude --resume /t/a.jsonl\n"
1253        );
1254    }
1255
1256    /// Restarting the pane taimux is running in would kill taimux mid-restart.
1257    #[test]
1258    fn this_pane_is_never_restarted() {
1259        let mut o = opts();
1260        o.self_pane = "%1".into();
1261        let p = plan_of(&fake(), &o);
1262        assert!(p.go.is_empty());
1263        assert!(p.skipped[0].contains("this pane"));
1264        assert!(p.skipped[0].contains("killing it would kill taimux"));
1265    }
1266
1267    #[test]
1268    fn a_pane_that_is_not_idle_waits_for_include_busy() {
1269        let mut e = fake();
1270        // an activity line: mid-turn
1271        e.screens
1272            .insert("%1".into(), "Twisting… (35s · ↓ 1.6k tokens)\n❯ \n".into());
1273        let p = plan_of(&e, &opts());
1274        assert!(p.go.is_empty());
1275        assert!(p.skipped[0].contains("rerun when idle, or --include-busy"));
1276
1277        let mut o = opts();
1278        o.include_busy = true;
1279        assert_eq!(plan_of(&e, &o).go.len(), 1);
1280    }
1281
1282    /// Even with --include-busy: a half-answered permission prompt is the one
1283    /// state where the Ctrl-C that starts a restart means something else.
1284    #[test]
1285    fn a_dialog_blocks_a_restart_even_with_include_busy() {
1286        let mut e = fake();
1287        e.screens
1288            .insert("%1".into(), "output\n\nDo you want to proceed?\n".into());
1289        let mut o = opts();
1290        o.include_busy = true;
1291        let p = plan_of(&e, &o);
1292        assert!(p.go.is_empty());
1293        assert!(p.skipped[0].contains("a dialog is waiting for an answer"));
1294    }
1295
1296    #[test]
1297    fn an_unsent_draft_is_left_alone() {
1298        let mut e = fake();
1299        e.screens
1300            .insert("%1".into(), "output\n❯ half a thought\n".into());
1301        let p = plan_of(&e, &opts());
1302        assert!(p.skipped[0].contains("unsent text in the prompt box"));
1303    }
1304
1305    /// The pane this whole thing is for: three rows, no box on it, and a session
1306    /// sitting idle behind that. Read at a size it can be read at, it is an
1307    /// ordinary restart.
1308    #[test]
1309    fn a_pane_too_short_for_its_box_is_read_zoomed() {
1310        let mut e = fake();
1311        e.screens.insert(
1312            "%1".into(),
1313            "  current: 2.1.100 · latest…\n────────\n".into(),
1314        );
1315        e.zoomed.insert("%1".into(), "some output\n❯ \n".into());
1316        let p = plan_of(&e, &opts());
1317        assert_eq!(e.zooms.get(), 1);
1318        assert_eq!(p.go.len(), 1);
1319        assert_eq!(p.go[0].pane, "%1");
1320        assert!(p.skipped.is_empty());
1321    }
1322
1323    /// And the point of reading it rather than assuming: a draft is invisible at
1324    /// three rows too, so the zoomed read is the only thing that can find one.
1325    /// Refused here for what is actually in the box, not for the box being
1326    /// missing.
1327    #[test]
1328    fn a_draft_hidden_by_a_short_pane_still_refuses() {
1329        let mut e = fake();
1330        e.screens
1331            .insert("%1".into(), "  current: 2.1.100…\n".into());
1332        e.zoomed
1333            .insert("%1".into(), "output\n❯ half a thought\n".into());
1334        let p = plan_of(&e, &opts());
1335        assert_eq!(e.zooms.get(), 1);
1336        assert!(p.go.is_empty());
1337        assert!(p.skipped[0].contains("unsent text in the prompt box"));
1338    }
1339
1340    /// A dialog is off the bottom of a short pane with the box, so the zoomed
1341    /// read is what finds that too, and a dialog is refused with or without
1342    /// `--include-busy`.
1343    #[test]
1344    fn a_dialog_hidden_by_a_short_pane_is_found_by_the_zoom() {
1345        let mut e = fake();
1346        e.screens.insert("%1".into(), "  Bash command\n".into());
1347        e.zoomed.insert(
1348            "%1".into(),
1349            "Do you want to proceed?\n❯ 1. Yes\n  2. No\n".into(),
1350        );
1351        let mut o = opts();
1352        o.include_busy = true;
1353        let p = plan_of(&e, &o);
1354        assert!(p.go.is_empty());
1355        assert!(p.skipped[0].contains("a dialog is waiting for an answer"));
1356    }
1357
1358    /// A pane already showing its box is never zoomed: the read is already good,
1359    /// and a zoom is something the person watching that window sees happen.
1360    #[test]
1361    fn a_pane_that_shows_its_box_is_not_zoomed() {
1362        let e = fake();
1363        let p = plan_of(&e, &opts());
1364        assert_eq!(e.zooms.get(), 0);
1365        assert_eq!(p.go.len(), 1);
1366    }
1367
1368    /// A transcript touched in the last 45 seconds is left alone whatever the
1369    /// screen said, because the screen reading is the fragile half.
1370    #[test]
1371    fn a_recently_active_transcript_is_left_alone() {
1372        let mut e = fake();
1373        e.transcript = r#"{"type":"user","timestamp":"2026-09-02T01:23:45Z"}"#.into();
1374        e.now = 1788312225 + 10;
1375        let p = plan_of(&e, &opts());
1376        assert!(p.skipped[0].contains("active in the last 45s"));
1377    }
1378
1379    #[test]
1380    fn an_unresolved_pane_says_so_and_earns_the_paragraph() {
1381        let mut e = fake();
1382        e.resolved
1383            .insert("%1".into(), Err("3 transcripts share this title".into()));
1384        let p = plan_of(&e, &opts());
1385        assert!(p.go.is_empty());
1386        assert!(p.unresolved);
1387        assert!(p.skipped[0].contains("unresolved: 3 transcripts share this title"));
1388        assert!(render(&p).contains("restart it by hand"));
1389    }
1390
1391    /// Two panes on one conversation is the same mistake on a restart as on a
1392    /// restore: the second one to claim it gets skipped rather than resumed.
1393    #[test]
1394    fn one_transcript_is_never_resumed_into_two_panes() {
1395        let mut e = fake();
1396        e.vers.insert(22, "2.1.100".into()); // make the second one stale too
1397        e.resolved
1398            .insert("%2".into(), Ok("/t/a.jsonl\tpane map".into()));
1399        let p = plan_of(&e, &opts());
1400        assert_eq!(p.go.len(), 1);
1401        assert_eq!(p.go[0].pane, "%1");
1402        assert!(p.skipped[0].contains("resolves to the same transcript as %1"));
1403    }
1404
1405    #[test]
1406    fn only_panes_narrows_the_plan_without_changing_the_verdicts() {
1407        let mut o = opts();
1408        o.only_panes = vec!["%2".into()];
1409        let p = plan_of(&fake(), &o);
1410        assert!(p.go.is_empty());
1411        assert!(p.skipped.is_empty()); // %1 was not considered at all
1412    }
1413
1414    #[test]
1415    fn a_pane_with_no_session_process_is_reported_not_dropped() {
1416        let rows = "%9\tw:9.9\t/w\tclaude\t0\tclaude\tno pid here";
1417        let p = plan(rows, "2.1.258", "/l", &opts(), &fake(), "/nowhere", &|| {
1418            "[]".into()
1419        });
1420        assert!(p.go.is_empty());
1421        assert_eq!(p.skipped.len(), 1);
1422        assert!(p.skipped[0].contains("no session process was found"));
1423    }
1424
1425    #[test]
1426    fn nothing_to_restart_says_so() {
1427        let mut e = fake();
1428        e.vers.insert(11, "2.1.258".into());
1429        let out = render(&plan_of(&e, &opts()));
1430        assert!(out.contains("nothing to restart."));
1431        assert!(!out.contains("to restart ("));
1432    }
1433
1434    /// --transcript names the conversation outright, which is the escape hatch
1435    /// for a pane the ladder refuses.
1436    #[test]
1437    fn a_given_transcript_overrides_the_ladder() {
1438        let mut e = fake();
1439        e.resolved.insert("%1".into(), Err("no candidate".into()));
1440        let mut o = opts();
1441        o.force_transcript = Some("/given.jsonl".into());
1442        let p = plan_of(&e, &o);
1443        assert_eq!(p.go.len(), 1);
1444        assert!(p.go[0].via.contains("--transcript, given"));
1445        assert!(p.go[0].cmd.contains("--resume /given.jsonl"));
1446    }
1447}