Skip to main content

taimux_cli/
resurrect.rs

1//! Teaching tmux-resurrect which conversation each pane was on.
2//!
3//! tmux-resurrect saves a pane's command line and restores it verbatim, which for
4//! a claude pane means a NEW session in the right directory: the conversation is
5//! lost. This rewrites the save file so each pane comes back on the conversation
6//! it was actually having.
7//!
8//! Every rule here exists because the failure mode is losing a saved layout, and
9//! that is not recoverable:
10//!
11//! - **The join is of one moment.** The save file names a pane by
12//!   session/window/pane INDEX, `print-cmds` names it by pane ID, and only tmux
13//!   joins the two, so both are read while the panes are still live.
14//! - **Only field 11 is ever touched**, so the line count cannot drop, and a
15//!   rewrite that lost lines is refused rather than written.
16//! - **The temporary file lives beside the target**, not in `/tmp`: across
17//!   filesystems `mv` stops being a rename and stops being atomic. Two continuum
18//!   saves DO overlap in practice, so a half-written save is a real outcome.
19//! - **The symlink is followed**, not replaced: `last` points at the timestamped
20//!   save and resurrect reads it through that link.
21//! - **A pane with nothing to replay still comes back as claude**, in the right
22//!   directory. An empty pane where a session used to be is silent; a claude
23//!   prompt there says come and look at this one.
24
25use std::collections::HashMap;
26use std::path::Path;
27
28/// A pane, as the save file names it: session, window index, pane index.
29type Key = (String, String, String);
30
31/// The pane records in a save file, split into fields.
32///
33/// Tab-separated, and the command is field 11 with a leading `:` marker that
34/// resurrect puts there.
35fn pane_lines(save: &str) -> Vec<Vec<&str>> {
36    save.lines()
37        .filter(|l| l.starts_with("pane\t"))
38        .map(|l| l.split('\t').collect())
39        .collect()
40}
41
42/// The command a save file holds for one pane, stripped back to a FRESH claude:
43/// everything it was launched with except the flags that claim a conversation.
44///
45/// This is the fallback for a pane that read as claude but had no live session
46/// process under it, so there is nothing to resume. Same three shapes dropped as
47/// on a restart, and for the same reason.
48pub fn fresh_from_saved(key: &Key, save: &str) -> String {
49    let mut out = String::from("command claude");
50    for f in pane_lines(save) {
51        if f.len() < 11 {
52            continue;
53        }
54        if (f[1].to_string(), f[2].to_string(), f[5].to_string()) != *key {
55            continue;
56        }
57        // field 11 opens with the marker resurrect adds
58        let cmd = f[10].strip_prefix(':').unwrap_or(f[10]);
59        let words: Vec<&str> = cmd.split_whitespace().collect();
60        // Skip the program, which is one word OR two: a save file that taimux
61        // has already rewritten once opens with `command claude`.
62        //
63        // **This is a deliberate divergence from bash**, and the one place in
64        // this port where the old behaviour was a bug rather than a decision. The
65        // awk dropped `$1` only, so a field 11 reading `command claude --model
66        // opus` came back as `command claude claude --model opus`: the restored
67        // pane would start a fresh session prompted with the word "claude".
68        // Reproduced against the bash function before it was deleted. It bites
69        // exactly when a pane loses its session process AFTER a previous
70        // resurrect pass has rewritten its command, which is not a rare pair.
71        let mut i = 1;
72        if words.first() == Some(&"command") && words.len() > 1 {
73            i = 2;
74        }
75        while i < words.len() {
76            match words[i] {
77                "--fork-session" | "-c" | "--continue" => {}
78                "--resume" | "-r" | "--session-id" => {
79                    if i + 1 < words.len() && !words[i + 1].starts_with('-') {
80                        i += 1;
81                    }
82                }
83                w => {
84                    out.push(' ');
85                    out.push_str(w);
86                }
87            }
88            i += 1;
89        }
90        break;
91    }
92    out
93}
94
95/// The save file with each mapped pane's command replaced.
96///
97/// Field 11 and nothing else, so the line count is unchanged by construction:
98/// that is what makes the "did it lose lines?" check below meaningful.
99pub fn rewrite(map: &HashMap<Key, String>, save: &str) -> String {
100    let mut out = String::new();
101    for line in save.lines() {
102        if let Some(rest) = line.strip_prefix("pane\t") {
103            let mut f: Vec<&str> = rest.split('\t').collect();
104            // fields shift by one because the "pane" tag was stripped
105            if f.len() >= 10 {
106                let key = (f[0].to_string(), f[1].to_string(), f[4].to_string());
107                if let Some(cmd) = map.get(&key) {
108                    let replaced = format!(":{}", cmd);
109                    f[9] = &replaced;
110                    out.push_str("pane\t");
111                    out.push_str(&f.join("\t"));
112                    out.push('\n');
113                    continue;
114                }
115            }
116        }
117        out.push_str(line);
118        out.push('\n');
119    }
120    out
121}
122
123pub struct Outcome {
124    pub resumed: usize,
125    pub fresh: usize,
126    pub lost: usize,
127    pub notes: Vec<String>,
128    pub map: HashMap<Key, String>,
129}
130
131impl Outcome {
132    pub fn total(&self) -> usize {
133        self.resumed + self.fresh + self.lost
134    }
135
136    pub fn summary(&self) -> String {
137        format!(
138            "{} claude pane(s): {} resumed, {} fresh, {} left alone",
139            self.total(),
140            self.resumed,
141            self.fresh,
142            self.lost
143        )
144    }
145}
146
147/// Work out what each pane should come back as.
148///
149/// `panes` is `tmux list-panes -a -F '#{pane_id}\t#{session_name}\t#{window_index}\t#{pane_index}'`
150/// and `records` is `print-cmds`. Both are read while the panes are live, which
151/// is what makes the join of one moment.
152pub fn decide(panes: &str, records: &str, save: &str) -> Outcome {
153    let mut by_id: HashMap<&str, Key> = HashMap::new();
154    for l in panes.lines() {
155        let f: Vec<&str> = l.split('\t').collect();
156        if f.len() >= 4 {
157            by_id.insert(f[0], (f[1].into(), f[2].into(), f[3].into()));
158        }
159    }
160
161    let mut o = Outcome {
162        resumed: 0,
163        fresh: 0,
164        lost: 0,
165        notes: Vec::new(),
166        map: HashMap::new(),
167    };
168    for l in records.lines() {
169        // Only the command may be empty, and it is last: `print-cmds` guarantees
170        // the other five are populated, because an empty field anywhere else
171        // would shift every value after it.
172        let f: Vec<&str> = l.split('\t').collect();
173        if f.is_empty() || f[0].is_empty() {
174            continue;
175        }
176        let (pane, tgt) = (f[0], f.get(1).copied().unwrap_or(""));
177        let status = f.get(3).copied().unwrap_or("");
178        let why = f.get(4).copied().unwrap_or("");
179        let cmd = f.get(5).copied().unwrap_or("");
180
181        let Some(key) = by_id.get(pane) else {
182            o.lost += 1;
183            o.notes.push(format!(
184                "{}: pane {} vanished between the two reads",
185                tgt, pane
186            ));
187            continue;
188        };
189        let cmd = if cmd.is_empty() {
190            o.fresh += 1;
191            o.notes.push(format!(
192                "{}: fresh session, {}, DIG",
193                tgt,
194                if why.is_empty() {
195                    "no live session process"
196                } else {
197                    why
198                }
199            ));
200            fresh_from_saved(key, save)
201        } else if status == "resume" {
202            o.resumed += 1;
203            cmd.to_string()
204        } else {
205            o.fresh += 1;
206            o.notes.push(format!("{}: fresh session, {}", tgt, why));
207            cmd.to_string()
208        };
209        o.map.insert(key.clone(), cmd);
210    }
211    o
212}
213
214/// Write the rewritten save file over the target, atomically, refusing anything
215/// that lost lines.
216pub fn commit(target: &Path, body: &str, original: &str) -> Result<(), String> {
217    if body.lines().count() < original.lines().count() {
218        return Err("rewrite dropped lines, kept the original".into());
219    }
220    let dir = target.parent().unwrap_or(Path::new("."));
221    let tmp = dir.join(format!(".taimux-resurrect.{}.tmp", std::process::id()));
222    std::fs::write(&tmp, body).map_err(|e| format!("rewrite failed: {}", e))?;
223    // The saved file's own mode, so a rewrite does not change who can read it.
224    if let Ok(m) = std::fs::metadata(target) {
225        let _ = std::fs::set_permissions(&tmp, m.permissions());
226    }
227    std::fs::rename(&tmp, target).map_err(|e| {
228        let _ = std::fs::remove_file(&tmp);
229        format!("rewrite failed: {}", e)
230    })
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    /// A save file's pane record, in the eleven fields tmux-resurrect really
238    /// writes. Taken from a live save file rather than guessed, because guessing
239    /// put the command in field 10 and every assertion here then passed for the
240    /// wrong reason:
241    ///
242    ///   pane  adm  1  1  :*Z  1  _  :/home/p/dir  0  bash  :
243    fn pane(sess: &str, win: &str, idx: &str, cmd: &str) -> String {
244        format!(
245            "pane\t{}\t{}\t1\t:*Z\t{}\t_\t:/w\t0\tbash\t:{}\n",
246            sess, win, idx, cmd
247        )
248    }
249
250    fn key(s: &str, w: &str, p: &str) -> Key {
251        (s.into(), w.into(), p.into())
252    }
253
254    #[test]
255    fn only_the_command_field_changes() {
256        let save = format!(
257            "{}{}",
258            pane("main", "1", "0", "claude"),
259            "window\tmain\t1\n"
260        );
261        let mut map = HashMap::new();
262        map.insert(
263            key("main", "1", "0"),
264            "command claude --resume /t.jsonl".into(),
265        );
266        let out = rewrite(&map, &save);
267        assert!(out.contains(":command claude --resume /t.jsonl"));
268        // every other field survives, and so does every other line
269        assert!(out.contains(":*Z")); // the flags field, untouched
270        assert!(out.contains("window\tmain\t1"));
271        assert_eq!(out.lines().count(), save.lines().count());
272    }
273
274    /// A pane the map does not mention is left exactly as it was: a rewrite has
275    /// no business touching a shell or an editor.
276    #[test]
277    fn an_unmapped_pane_is_untouched() {
278        let save = pane("main", "1", "0", "vim");
279        let out = rewrite(&HashMap::new(), &save);
280        assert_eq!(out, save);
281    }
282
283    #[test]
284    fn a_fresh_command_drops_every_claim_on_a_conversation() {
285        let save = pane(
286            "main",
287            "1",
288            "0",
289            "claude --model opus --resume /old.jsonl --fork-session",
290        );
291        assert_eq!(
292            fresh_from_saved(&key("main", "1", "0"), &save),
293            "command claude --model opus"
294        );
295    }
296
297    /// A save file taimux has already rewritten once opens with `command
298    /// claude`, two words. bash dropped only the first, so this came back as
299    /// `command claude claude --model opus` and the restored pane started a fresh
300    /// session prompted with the word "claude". Reproduced against the bash
301    /// function before deleting it; this is the one deliberate divergence in the
302    /// port.
303    #[test]
304    fn a_command_prefixed_save_does_not_double_the_program() {
305        let save = pane(
306            "main",
307            "1",
308            "0",
309            "command claude --model opus --resume /old.jsonl",
310        );
311        assert_eq!(
312            fresh_from_saved(&key("main", "1", "0"), &save),
313            "command claude --model opus"
314        );
315    }
316
317    #[test]
318    fn a_pane_the_save_does_not_hold_still_gives_a_bare_claude() {
319        assert_eq!(
320            fresh_from_saved(&key("nope", "9", "9"), &pane("main", "1", "0", "claude")),
321            "command claude"
322        );
323    }
324
325    #[test]
326    fn a_resumable_record_is_counted_and_mapped() {
327        let panes = "%1\tmain\t1\t0\n";
328        let records =
329            "%1\tmain:1.0\t/w\tresume\tpane map, idle\tcommand claude --resume /t.jsonl\n";
330        let o = decide(panes, records, "");
331        assert_eq!((o.resumed, o.fresh, o.lost), (1, 0, 0));
332        assert_eq!(
333            o.map.get(&key("main", "1", "0")).map(|s| s.as_str()),
334            Some("command claude --resume /t.jsonl")
335        );
336        assert_eq!(
337            o.summary(),
338            "1 claude pane(s): 1 resumed, 0 fresh, 0 left alone"
339        );
340    }
341
342    /// An unresolved record still comes back as claude, just without a
343    /// conversation, and it says so in the notes.
344    #[test]
345    fn an_unresolved_record_comes_back_fresh_with_a_note() {
346        let panes = "%1\tmain\t1\t0\n";
347        let records = "%1\tmain:1.0\t/w\tunresolved\t3 share this title, idle\tcommand claude\n";
348        let o = decide(panes, records, "");
349        assert_eq!((o.resumed, o.fresh, o.lost), (0, 1, 0));
350        assert!(o.notes[0].contains("fresh session, 3 share this title"));
351    }
352
353    /// A record with no command at all had no live session process under it, and
354    /// the save file is where its argv comes from instead. Marked DIG, because it
355    /// is the case worth looking at.
356    #[test]
357    fn a_record_with_no_command_falls_back_to_the_save_file() {
358        let panes = "%1\tmain\t1\t0\n";
359        let records = "%1\tmain:1.0\t/w\tunresolved\tno session process\t\n";
360        let save = pane("main", "1", "0", "claude --resume /old.jsonl");
361        let o = decide(panes, records, &save);
362        assert_eq!((o.resumed, o.fresh, o.lost), (0, 1, 0));
363        assert_eq!(
364            o.map.get(&key("main", "1", "0")).map(|s| s.as_str()),
365            Some("command claude")
366        );
367        assert!(o.notes[0].contains("DIG"));
368    }
369
370    /// The two reads are of one moment, but not the SAME instant: a pane can go
371    /// between them, and that is counted rather than guessed at.
372    #[test]
373    fn a_pane_that_vanished_between_the_reads_is_left_alone() {
374        let records = "%9\tmain:9.9\t/w\tresume\tpane map, idle\tcommand claude\n";
375        let o = decide("%1\tmain\t1\t0\n", records, "");
376        assert_eq!((o.resumed, o.fresh, o.lost), (0, 0, 1));
377        assert!(o.notes[0].contains("vanished between the two reads"));
378        assert!(o.map.is_empty());
379    }
380
381    /// Losing the saved layout is not recoverable, so a rewrite that dropped
382    /// lines is refused. Only field 11 is ever touched, so it cannot happen, and
383    /// that is exactly why the check is cheap enough to keep.
384    #[test]
385    fn a_rewrite_that_lost_lines_is_refused() {
386        let d = std::env::temp_dir().join(format!("jmrr{}", std::process::id()));
387        std::fs::create_dir_all(&d).unwrap();
388        let f = d.join("last");
389        std::fs::write(&f, "a\nb\nc\n").unwrap();
390        assert_eq!(
391            commit(&f, "a\nb\n", "a\nb\nc\n"),
392            Err("rewrite dropped lines, kept the original".into())
393        );
394        assert_eq!(std::fs::read_to_string(&f).unwrap(), "a\nb\nc\n");
395        assert!(commit(&f, "a\nb\nc\n", "a\nb\nc\n").is_ok());
396        let _ = std::fs::remove_dir_all(&d);
397    }
398
399    /// The temporary file goes beside the target, or `mv` stops being atomic
400    /// across filesystems. Checked by there being nothing left behind.
401    #[test]
402    fn nothing_is_left_behind_beside_the_target() {
403        let d = std::env::temp_dir().join(format!("jmrr2{}", std::process::id()));
404        std::fs::create_dir_all(&d).unwrap();
405        let f = d.join("last");
406        std::fs::write(&f, "x\n").unwrap();
407        commit(&f, "y\n", "x\n").expect("committed");
408        assert_eq!(std::fs::read_to_string(&f).unwrap(), "y\n");
409        let left: Vec<_> = std::fs::read_dir(&d)
410            .unwrap()
411            .flatten()
412            .map(|e| e.file_name().to_string_lossy().into_owned())
413            .collect();
414        assert_eq!(left, vec!["last".to_string()]);
415        let _ = std::fs::remove_dir_all(&d);
416    }
417}