Skip to main content

supercode_harness/
session_tree.rs

1//! The session tree: every session running on this machine under the session that started it, read from recorded
2//! facts and saying which ones a parent was inferred from (`harness.v1.sessions.tree`, `supercode sessions tree`).
3//!
4//! A session's starter is, first, what was recorded: the machine daemon's launch journal (`launches.jsonl`, the `by`
5//! of the launch that first started the session, joined by its native session or its pane), or Codex's own
6//! `parent_thread_id` on a subagent thread. A resume (`supercode open <id>`) is not a start: the session keeps its
7//! first starter and the resume is shown as `resumed_by`. A session started outside the daemon (a `codex exec` or a
8//! `claude -p` a session's shell ran) has no launch record, and its starter is inferred from its process: the nearest
9//! process above it that is another running session, or, when its parents have exited (`nohup … &`), the session its
10//! environment names (`CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, `SUPERCODE_TEAMS_PANE`, which each harness and the
11//! daemon set for the processes they start). Every parent says which of these it came from; a session none of them
12//! names is a root, started outside any session.
13//!
14//! [`record`] keeps the tree in `<teams home>/sessions-tree.jsonl` when its shape changes (with a heartbeat while it
15//! does not), so [`recorded_tree`] answers what was running, and under whom, at a past moment — after a reboot too.
16//! Reading only: nothing here acts on a session.
17
18use std::collections::{HashMap, HashSet};
19use std::io::{Read, Seek, SeekFrom, Write};
20use std::path::PathBuf;
21
22use serde_json::{json, Value};
23use supercode_interchange::sidecar::ms_to_rfc3339;
24
25use crate::mail_route::{LiveSession, LiveSessions};
26use crate::mailbox::MailAddress;
27use crate::HarnessHomes;
28
29/// The processes a session's launcher is made of between it and a parent that exited: when only these stand between
30/// a session and launchd, its parent is gone and its environment is the record of who started it.
31const LAUNCHER_PROGRAMS: &[&str] = &[
32    "codex",
33    "claude",
34    "node",
35    "bun",
36    "deno",
37    "sh",
38    "bash",
39    "zsh",
40    "fish",
41    "dash",
42    "nohup",
43    "env",
44    "setsid",
45    "timeout",
46    "npx",
47    "npm",
48    "caffeinate",
49];
50
51/// A heartbeat is written at most this often while the tree's shape is unchanged: a little under the machine daemon's
52/// ten-minute offer, so each offer writes one.
53const HEARTBEAT_MS: i64 = 9 * 60 * 1000;
54/// The journal is rolled over (to `sessions-tree.jsonl.1`) past this size.
55const JOURNAL_MAX_BYTES: u64 = 16 * 1024 * 1024;
56
57fn now_ms() -> i64 {
58    std::time::SystemTime::now()
59        .duration_since(std::time::UNIX_EPOCH)
60        .map(|elapsed| elapsed.as_millis() as i64)
61        .unwrap_or_default()
62}
63
64/// The tree's journal.
65pub fn journal_path() -> PathBuf {
66    crate::teams::teams_home().join("sessions-tree.jsonl")
67}
68
69// ---- recorded facts ---------------------------------------------------------------------------------------------
70
71/// One launch, as the daemon's journal last recorded it.
72#[derive(Debug, Clone, Default)]
73struct Launch {
74    key: String,
75    by: Option<String>,
76    /// When the launch was issued (its first record).
77    at: i64,
78    /// The session it reopened, when it was a resume.
79    resumes: Option<String>,
80    /// The harness it launched (`claude-code`, `codex`), when it named one.
81    kind: Option<String>,
82}
83
84/// What the daemon's launch journal says: each session's first start (by native session id) and each pane's latest
85/// launch.
86#[derive(Debug, Default)]
87struct Journal {
88    origins: HashMap<String, Launch>,
89    panes: HashMap<String, Launch>,
90}
91
92/// The session id an `open:` launch key reopens (`open:<harness>:<id>`, `open:native:<harness>:<id>`): how a journal
93/// written before launches said `resumes` names it.
94fn resumed_by_key(key: &str) -> Option<String> {
95    let rest = key.strip_prefix("open:")?;
96    let rest = rest.strip_prefix("native:").unwrap_or(rest);
97    let (_, id) = rest.split_once(':')?;
98    (!id.is_empty()).then(|| id.to_string())
99}
100
101fn read_journal() -> Journal {
102    let mut journal = Journal::default();
103    let Ok(text) = std::fs::read_to_string(crate::teams::teams_home().join("launches.jsonl"))
104    else {
105        return journal;
106    };
107    // A key's first record is when it was issued; a key reused for a later launch (a board's main, relaunched) is
108    // issued again, on its new pane.
109    let mut issued: HashMap<(String, String), i64> = HashMap::new();
110    for line in text.lines() {
111        let Ok(record) = serde_json::from_str::<Value>(line) else {
112            continue;
113        };
114        let (Some(key), Some(t)) = (record["key"].as_str(), record["t"].as_i64()) else {
115            continue;
116        };
117        let pane = record["pane"].as_str().map(str::to_string);
118        let at = *issued
119            .entry((key.to_string(), pane.clone().unwrap_or_default()))
120            .or_insert(t);
121        let resumes = record["resumes"]
122            .as_str()
123            .map(str::to_string)
124            .or_else(|| resumed_by_key(key));
125        let launch = Launch {
126            key: key.to_string(),
127            by: record["by"].as_str().map(str::to_string),
128            at,
129            resumes: resumes.clone(),
130            kind: record["kind"].as_str().map(str::to_string),
131        };
132        // The first launch that landed in a session and did not reopen it is the one that started it.
133        if let (Some(native), None) = (record["native_session"].as_str(), &resumes) {
134            journal
135                .origins
136                .entry(native.to_string())
137                .or_insert_with(|| launch.clone());
138        }
139        if let Some(pane) = pane {
140            journal.panes.insert(pane, launch);
141        }
142    }
143    journal
144}
145
146/// One process: its parent and its program's name.
147#[derive(Debug, Clone)]
148struct Process {
149    ppid: u32,
150    program: String,
151}
152
153#[cfg(unix)]
154fn processes() -> HashMap<u32, Process> {
155    let Ok(output) = std::process::Command::new("ps")
156        .args(["-axo", "pid=,ppid=,comm="])
157        .output()
158    else {
159        return HashMap::new();
160    };
161    String::from_utf8_lossy(&output.stdout)
162        .lines()
163        .filter_map(|line| {
164            let mut words = line.split_whitespace();
165            let pid = words.next()?.parse().ok()?;
166            let ppid = words.next()?.parse().ok()?;
167            let command = words.collect::<Vec<_>>().join(" ");
168            let program = command
169                .rsplit('/')
170                .next()
171                .unwrap_or_default()
172                .trim_start_matches('-')
173                .to_string();
174            Some((pid, Process { ppid, program }))
175        })
176        .collect()
177}
178
179#[cfg(not(unix))]
180fn processes() -> HashMap<u32, Process> {
181    HashMap::new()
182}
183
184/// `pid` and the processes above it, nearest first, up to (not including) launchd.
185fn ancestry(pid: u32, table: &HashMap<u32, Process>) -> Vec<u32> {
186    let mut chain = vec![pid];
187    let mut current = pid;
188    while let Some(process) = table.get(&current) {
189        if process.ppid <= 1 || chain.contains(&process.ppid) || chain.len() > 64 {
190            break;
191        }
192        chain.push(process.ppid);
193        current = process.ppid;
194    }
195    chain
196}
197
198/// Whether only a launcher's own processes stand between `pid` and launchd: its starter's process has exited and it
199/// was handed to launchd (`nohup … &` from a shell that ended).
200fn orphaned(pid: u32, table: &HashMap<u32, Process>) -> bool {
201    let chain = ancestry(pid, table);
202    let top = chain.last().and_then(|top| table.get(top));
203    top.is_some_and(|top| top.ppid == 1)
204        && chain.iter().all(|pid| {
205            table
206                .get(pid)
207                .is_some_and(|process| LAUNCHER_PROGRAMS.contains(&process.program.as_str()))
208        })
209}
210
211/// The variables a process started with that name who started it: `CLAUDE_CODE_SESSION_ID` (Claude Code sets it for
212/// what its tools run), `CODEX_THREAD_ID` (Codex, likewise) and `SUPERCODE_TEAMS_PANE` (the daemon, for its panes).
213#[cfg(target_os = "macos")]
214fn starter_environment(pid: u32) -> HashMap<String, String> {
215    // `ps -E` prints the environment the process started with after its command.
216    let Ok(output) = std::process::Command::new("ps")
217        .args(["-E", "-o", "command=", "-p", &pid.to_string()])
218        .output()
219    else {
220        return HashMap::new();
221    };
222    let text = String::from_utf8_lossy(&output.stdout);
223    let variables = starter_variables(text.split_whitespace());
224    variables
225}
226
227#[cfg(target_os = "linux")]
228fn starter_environment(pid: u32) -> HashMap<String, String> {
229    let Ok(environ) = std::fs::read(format!("/proc/{pid}/environ")) else {
230        return HashMap::new();
231    };
232    let variables = starter_variables(
233        environ
234            .split(|byte| *byte == 0)
235            .filter_map(|entry| std::str::from_utf8(entry).ok()),
236    );
237    variables
238}
239
240#[cfg(not(any(target_os = "macos", target_os = "linux")))]
241fn starter_environment(_pid: u32) -> HashMap<String, String> {
242    HashMap::new()
243}
244
245#[cfg(any(target_os = "macos", target_os = "linux"))]
246fn starter_variables<'a>(entries: impl Iterator<Item = &'a str>) -> HashMap<String, String> {
247    entries
248        .filter_map(|entry| entry.split_once('='))
249        .filter(|(name, value)| {
250            matches!(
251                *name,
252                "CLAUDE_CODE_SESSION_ID" | "CODEX_THREAD_ID" | "SUPERCODE_TEAMS_PANE"
253            ) && !value.is_empty()
254        })
255        .map(|(name, value)| (name.to_string(), value.to_string()))
256        .collect()
257}
258
259/// A daemon pane's name (`p_…`).
260fn is_daemon_pane(name: &str) -> bool {
261    name.strip_prefix("p_").is_some_and(|rest| {
262        !rest.is_empty()
263            && rest
264                .chars()
265                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
266    })
267}
268
269// ---- the live tree ----------------------------------------------------------------------------------------------
270
271/// How a node's parent is known.
272#[derive(Debug, Clone)]
273struct StartedBy {
274    /// The starting session's address (or a board's), `None` for a person or when nothing names one.
275    parent: Option<String>,
276    /// `launch`, `codex_thread`, `process_parent`, `process_environment`, or `none`.
277    evidence: &'static str,
278    /// Whether a record says so (the launch journal, Codex's rollout); false when read off the process.
279    recorded: bool,
280    detail: Option<String>,
281    launch_key: Option<String>,
282    at: Option<i64>,
283}
284
285impl StartedBy {
286    fn none() -> Self {
287        Self {
288            parent: None,
289            evidence: "none",
290            recorded: false,
291            detail: None,
292            launch_key: None,
293            at: None,
294        }
295    }
296
297    fn from_launch(launch: &Launch, detail: &str) -> Self {
298        Self {
299            parent: launch.by.clone(),
300            evidence: "launch",
301            recorded: true,
302            detail: Some(detail.to_string()),
303            launch_key: Some(launch.key.clone()),
304            at: Some(launch.at),
305        }
306    }
307
308    fn json(&self) -> Value {
309        let kind = match (&self.parent, self.evidence) {
310            (Some(_), _) => "session",
311            (None, "launch" | "pane_launch") => "person",
312            _ => "unrecorded",
313        };
314        let mut value = json!({"kind": kind, "evidence": self.evidence, "recorded": self.recorded});
315        if let Some(detail) = &self.detail {
316            value["detail"] = json!(detail);
317        }
318        if let Some(key) = &self.launch_key {
319            value["launch_key"] = json!(key);
320        }
321        if let Some(at) = self.at {
322            value["at"] = json!(ms_to_rfc3339(at));
323        }
324        value
325    }
326}
327
328/// The running sessions on this machine as a tree. `sessions` is flat, each with its `parent` (an address, or null
329/// for a root) and how that parent is known; `parents_elsewhere` names each parent that is not running here (on
330/// another machine, or ended), so every `parent` resolves to a row.
331pub fn live_tree(homes: &HarnessHomes, ask_other_machines: bool) -> Value {
332    let roots_read = std::thread::spawn(crate::mail_route::daemon_pane_roots);
333    let codex_read = std::thread::spawn(crate::codex_peer::session_processes);
334    let live = LiveSessions::read(homes);
335    let pane_roots = roots_read.join().unwrap_or_default();
336    let codex_pids = codex_read.join().unwrap_or_default();
337    let table = processes();
338    let journal = read_journal();
339    let machine = crate::mailbox::local_machine_name();
340
341    let pid_of = |session: &LiveSession| -> Option<u32> {
342        session.pid.or_else(|| {
343            (session.address.harness == "codex")
344                .then(|| codex_pids.get(&session.address.session_id).copied())
345                .flatten()
346        })
347    };
348    let root_panes: HashMap<u32, &String> = pane_roots
349        .iter()
350        .map(|(pane, root)| (*root, pane))
351        .collect();
352    let pane_of = |session: &LiveSession| -> Option<String> {
353        if let Some(pane) = session
354            .tmux
355            .as_deref()
356            .and_then(|tmux| tmux.split(':').next())
357            .filter(|name| is_daemon_pane(name))
358        {
359            return Some(pane.to_string());
360        }
361        let pid = pid_of(session)?;
362        ancestry(pid, &table)
363            .iter()
364            .find_map(|ancestor| root_panes.get(ancestor).map(|pane| pane.to_string()))
365    };
366
367    let sessions = live.all();
368    let pids: Vec<Option<u32>> = sessions.iter().map(pid_of).collect();
369    let panes: Vec<Option<String>> = sessions.iter().map(pane_of).collect();
370    let by_pid: HashMap<u32, String> = sessions
371        .iter()
372        .zip(&pids)
373        .filter_map(|(session, pid)| pid.map(|pid| (pid, session.address.to_string())))
374        .collect();
375    // The nearest process above each session that is another running session's.
376    let process_parents: Vec<Option<String>> = pids
377        .iter()
378        .map(|pid| {
379            ancestry((*pid)?, &table)
380                .into_iter()
381                .skip(1)
382                .find_map(|ancestor| by_pid.get(&ancestor).cloned())
383        })
384        .collect();
385    // Each pane's own session: the one in it no other session's process runs.
386    let by_pane: HashMap<&str, String> = sessions
387        .iter()
388        .zip(&panes)
389        .zip(&process_parents)
390        .filter(|(_, parent)| parent.is_none())
391        .filter_map(|((session, pane), _)| {
392            pane.as_deref()
393                .map(|pane| (pane, session.address.to_string()))
394        })
395        .collect();
396    let address_of = |harness: &str, id: &str| {
397        MailAddress::new(&machine, harness, id)
398            .ok()
399            .map(|a| a.to_string())
400    };
401
402    let mut rows: Vec<Value> = Vec::new();
403    for (((session, pid), pane), process_parent) in
404        sessions.iter().zip(&pids).zip(&panes).zip(&process_parents)
405    {
406        let id = session.address.session_id.as_str();
407        let address = session.address.to_string();
408        let pane_launch = pane.as_deref().and_then(|pane| journal.panes.get(pane));
409        let resume = pane_launch.filter(|launch| launch.resumes.as_deref() == Some(id));
410        let started = if let Some(origin) = journal.origins.get(id) {
411            StartedBy::from_launch(origin, "its launch recorded this session")
412        // Resumed, and its first launch did not record the session (no first input, or a daemon from before
413        // `native_session`): the parent the tree's own record kept for it before.
414        } else if let Some(first) = resume.and_then(|_| first_recorded_start(&address)) {
415            first
416        } else if let Some(parent) = process_parent {
417            StartedBy {
418                parent: Some(parent.clone()),
419                evidence: "process_parent",
420                recorded: false,
421                detail: Some("a parent process of it is that session's".into()),
422                launch_key: None,
423                at: None,
424            }
425        // A pane's launch started the session the pane runs, not what that session's own processes run in it: a
426        // session process above this one (just above) is the nearer starter.
427        } else if let Some(launch) = pane_launch.filter(|_| resume.is_none()) {
428            // The launch recorded the program it ran, which is its pane's root process (a Codex runs as the child of
429            // its pane's wrapper). Any other session in the pane (a second `claude` typed at a shell the launch ran,
430            // a `codex exec` a script there started) is only inferred to be the launch's.
431            let root = pane
432                .as_deref()
433                .and_then(|pane| pane_roots.get(pane))
434                .copied();
435            let launched = pid.is_some_and(|pid| {
436                Some(pid) == root
437                    || (table.get(&pid).map(|process| process.ppid) == root
438                        && launch.kind.as_deref() == Some(session.address.harness.as_str()))
439            });
440            let mut started = StartedBy::from_launch(launch, "its pane's launch ran it");
441            if !launched {
442                started.evidence = "pane_launch";
443                started.recorded = false;
444                started.detail = Some(
445                    "it runs in that launch's pane; the launch ran another program there".into(),
446                );
447            }
448            started
449        } else if let Some(started) = pid.filter(|pid| orphaned(*pid, &table)).and_then(|pid| {
450            environment_starter(
451                pid,
452                &session.address,
453                pane.as_deref(),
454                &by_pane,
455                &by_pid,
456                &address_of,
457            )
458        }) {
459            started
460        } else {
461            StartedBy::none()
462        };
463        let mut row = json!({
464            "kind": "session",
465            "address": address,
466            "harness": session.address.harness,
467            "session_id": id,
468            "name": session.name.rsplit_once('@').map_or(session.name.as_str(), |(name, _)| name),
469            "folder": session.cwd,
470            "state": session.status,
471            "pane": pane,
472            "pid": pid,
473            "parent": started.parent,
474            "started_by": started.json(),
475            "resumed_by": Value::Null,
476        });
477        if let Some(resume) = resume {
478            row["resumed_by"] =
479                json!({"by": resume.by, "launch_key": resume.key, "at": ms_to_rfc3339(resume.at)});
480        }
481        rows.push(row);
482    }
483    for thread in live.codex_threads() {
484        let short: String = thread.address.session_id.chars().take(8).collect();
485        rows.push(json!({
486            "kind": "codex_thread",
487            "address": thread.address.to_string(),
488            "harness": "codex",
489            "session_id": thread.address.session_id,
490            "name": format!("codex-{short}"),
491            "folder": thread.cwd,
492            "state": thread.status,
493            "pane": Value::Null,
494            "pid": Value::Null,
495            "parent": thread.parent.to_string(),
496            "started_by": {"kind": "session", "evidence": "codex_thread", "recorded": true,
497                "detail": "Codex recorded it as a subagent thread of that conversation"},
498            "resumed_by": Value::Null,
499        }));
500    }
501    let tree = tree_of(rows, &machine, ask_other_machines);
502    json!({"machine": machine, "at": ms_to_rfc3339(now_ms()), "sessions": tree.0, "parents_elsewhere": tree.1})
503}
504
505/// The session the environment of an orphaned process names as its starter. Each of its variables names a session
506/// that started something above it; when several do (a Claude session a Codex shell ran from a Claude session), the
507/// nearest is the one whose own environment names all the others.
508fn environment_starter(
509    pid: u32,
510    own: &MailAddress,
511    own_pane: Option<&str>,
512    by_pane: &HashMap<&str, String>,
513    by_pid: &HashMap<u32, String>,
514    address_of: &dyn Fn(&str, &str) -> Option<String>,
515) -> Option<StartedBy> {
516    let environment = starter_environment(pid);
517    let mut named: Vec<(String, &'static str)> = Vec::new();
518    for (variable, harness) in [
519        ("CLAUDE_CODE_SESSION_ID", "claude-code"),
520        ("CODEX_THREAD_ID", "codex"),
521    ] {
522        if let Some(id) = environment
523            .get(variable)
524            .filter(|id| **id != own.session_id)
525        {
526            if let Some(address) = address_of(harness, id) {
527                named.push((address, variable));
528            }
529        }
530    }
531    if let Some(address) = environment
532        .get("SUPERCODE_TEAMS_PANE")
533        .filter(|pane| Some(pane.as_str()) != own_pane)
534        .and_then(|pane| by_pane.get(pane.as_str()))
535        .filter(|address| **address != own.to_string())
536    {
537        if !named.iter().any(|(named, _)| named == address) {
538            named.push((address.clone(), "SUPERCODE_TEAMS_PANE"));
539        }
540    }
541    let pid_of: HashMap<&String, u32> = by_pid
542        .iter()
543        .map(|(pid, address)| (address, *pid))
544        .collect();
545    let nearest = if named.len() > 1 {
546        named.iter().find(|(address, _)| {
547            pid_of.get(address).is_some_and(|pid| {
548                let theirs = starter_environment(*pid);
549                let theirs: HashSet<String> = theirs
550                    .iter()
551                    .filter_map(|(variable, id)| match variable.as_str() {
552                        "CLAUDE_CODE_SESSION_ID" => address_of("claude-code", id),
553                        "CODEX_THREAD_ID" => address_of("codex", id),
554                        _ => None,
555                    })
556                    .collect();
557                named
558                    .iter()
559                    .filter(|(other, _)| other != address)
560                    .all(|(other, _)| theirs.contains(other))
561            })
562        })
563    } else {
564        named.first()
565    };
566    let (parent, variable) = nearest?.clone();
567    Some(StartedBy {
568        parent: Some(parent),
569        evidence: "process_environment",
570        recorded: false,
571        detail: Some(format!(
572            "its parent process has exited; its environment's {variable} names that session"
573        )),
574        launch_key: None,
575        at: None,
576    })
577}
578
579/// Rows with `children` (addresses, in order) filled in, and a row for each parent not among them. A parent edge that
580/// would close a cycle is dropped (its node becomes a root, said in its `started_by`).
581fn tree_of(
582    mut rows: Vec<Value>,
583    machine: &str,
584    ask_other_machines: bool,
585) -> (Vec<Value>, Vec<Value>) {
586    let index: HashMap<String, usize> = rows
587        .iter()
588        .enumerate()
589        .filter_map(|(i, row)| Some((row["address"].as_str()?.to_string(), i)))
590        .collect();
591    for i in 0..rows.len() {
592        // Only a node on the cycle has its edge dropped (the first one met), not one that merely leads into it.
593        let mut seen = HashSet::from([i]);
594        let mut current = rows[i]["parent"]
595            .as_str()
596            .and_then(|parent| index.get(parent))
597            .copied();
598        while let Some(next) = current {
599            if next == i {
600                rows[i]["parent"] = Value::Null;
601                rows[i]["started_by"]["cycle"] = json!(true);
602                break;
603            }
604            if !seen.insert(next) {
605                break;
606            }
607            current = rows[next]["parent"]
608                .as_str()
609                .and_then(|parent| index.get(parent))
610                .copied();
611        }
612    }
613    let mut children: HashMap<String, Vec<String>> = HashMap::new();
614    let mut elsewhere: Vec<String> = Vec::new();
615    for row in &rows {
616        if let Some(parent) = row["parent"].as_str() {
617            children
618                .entry(parent.to_string())
619                .or_default()
620                .push(row["address"].as_str().unwrap_or_default().to_string());
621            if !index.contains_key(parent) && !elsewhere.iter().any(|known| known == parent) {
622                elsewhere.push(parent.to_string());
623            }
624        }
625    }
626    for row in &mut rows {
627        let address = row["address"].as_str().unwrap_or_default().to_string();
628        row["children"] = json!(children.remove(&address).unwrap_or_default());
629    }
630    // A parent on another machine is named by that machine's own session list, asked once per machine in parallel:
631    // whether it is running there, and as what.
632    let machines: HashSet<String> = elsewhere
633        .iter()
634        .filter_map(|address| MailAddress::parse(address).ok())
635        .map(|address| address.machine)
636        .filter(|other| other != machine && ask_other_machines)
637        .collect();
638    let asking: Vec<_> = machines
639        .into_iter()
640        .map(|other| {
641            std::thread::spawn(move || {
642                let listed = crate::mailbox::teams_mail(&other, &json!({"op": "list"}))
643                    .ok()
644                    .and_then(|answer| answer["sessions"].as_array().cloned());
645                (other, listed)
646            })
647        })
648        .collect();
649    let asked: Vec<(String, Option<Vec<Value>>)> = asking
650        .into_iter()
651        .filter_map(|handle| handle.join().ok())
652        .collect();
653    let elsewhere = elsewhere
654        .into_iter()
655        .map(|address| {
656            let parsed = MailAddress::parse(&address).ok();
657            let listing = parsed.as_ref().and_then(|parsed| {
658                asked
659                    .iter()
660                    .find(|(other, _)| *other == parsed.machine)
661                    .and_then(|(_, listed)| listed.as_ref())
662            });
663            let row =
664                listing.and_then(|rows| rows.iter().find(|row| row["address"] == address.as_str()));
665            let name = row
666                .and_then(|row| row["name"].as_str())
667                .map(|name| {
668                    name.rsplit_once('@')
669                        .map_or(name, |(name, _)| name)
670                        .to_string()
671                })
672                .or_else(|| parsed.as_ref().and_then(crate::mail_route::remembered_name));
673            let here = parsed.as_ref().is_some_and(|a| a.machine == machine);
674            json!({
675                "address": address,
676                "machine": parsed.as_ref().map(|a| a.machine.clone()),
677                "name": name,
678                // On this machine and not running: it ended (or was never listed here). Elsewhere: another machine's.
679                "here": here,
680                // Whether its machine lists it running now: false here (it is not), null when its machine was not
681                // asked or did not answer.
682                "running": if here { Some(false) } else { listing.map(|_| row.is_some()) },
683                "state": row.and_then(|row| row["status"].as_str()),
684                "children": children.get(&address).cloned().unwrap_or_default(),
685            })
686        })
687        .collect();
688    (rows, elsewhere)
689}
690
691// ---- the record ------------------------------------------------------------------------------------------------
692
693/// The first start the tree's record kept for `address` that a record backed (a launch or a Codex thread), as that
694/// start: how a resumed session keeps its first starter when its first launch named no native session.
695fn first_recorded_start(address: &str) -> Option<StartedBy> {
696    let path = journal_path();
697    for file in [path.with_extension("jsonl.1"), path] {
698        let Ok(text) = std::fs::read_to_string(&file) else {
699            continue;
700        };
701        for line in text.lines().filter(|line| line.contains(address)) {
702            let Ok(record) = serde_json::from_str::<Value>(line) else {
703                continue;
704            };
705            let found = record["sessions"]
706                .as_array()
707                .into_iter()
708                .flatten()
709                .find(|row| {
710                    row["address"] == address
711                        && row["started_by"]["recorded"] == true
712                        && row["started_by"]["evidence"] == "launch"
713                        && row["resumed_by"].is_null()
714                });
715            if let Some(row) = found {
716                let started = &row["started_by"];
717                return Some(StartedBy {
718                    parent: row["parent"].as_str().map(str::to_string),
719                    evidence: "launch",
720                    recorded: true,
721                    detail: Some(format!(
722                        "its first launch, as the session tree recorded it at {}",
723                        record["t"].as_i64().map(ms_to_rfc3339).unwrap_or_default()
724                    )),
725                    launch_key: started["launch_key"].as_str().map(str::to_string),
726                    at: started["at"]
727                        .as_str()
728                        .and_then(supercode_interchange::sidecar::rfc3339_to_ms),
729                });
730            }
731        }
732    }
733    None
734}
735
736/// What a tree's shape is, for telling a change from a heartbeat: each node, its parent and its pane. State, names
737/// and pids come and go without the tree changing.
738fn shape(tree: &Value) -> Vec<(String, String, String)> {
739    let mut shape: Vec<(String, String, String)> = tree["sessions"]
740        .as_array()
741        .into_iter()
742        .flatten()
743        .map(|row| {
744            let text = |key: &str| row[key].as_str().unwrap_or_default().to_string();
745            (text("address"), text("parent"), text("pane"))
746        })
747        .collect();
748    shape.sort();
749    shape
750}
751
752/// The journal's last full record and the last record of any kind, read from its tail.
753fn journal_tail() -> (Option<Value>, Option<i64>) {
754    let Ok(mut file) = std::fs::File::open(journal_path()) else {
755        return (None, None);
756    };
757    let length = file.metadata().map(|meta| meta.len()).unwrap_or_default();
758    let start = length.saturating_sub(4 * 1024 * 1024);
759    if file.seek(SeekFrom::Start(start)).is_err() {
760        return (None, None);
761    }
762    let mut text = String::new();
763    if file.read_to_string(&mut text).is_err() {
764        return (None, None);
765    }
766    let mut last_at = None;
767    for line in text.lines().rev() {
768        let Ok(record) = serde_json::from_str::<Value>(line) else {
769            continue;
770        };
771        last_at = last_at.or_else(|| record["t"].as_i64());
772        if record.get("sessions").is_some() {
773            return (Some(record), last_at);
774        }
775    }
776    (None, last_at)
777}
778
779/// Append `tree` to the journal when its shape differs from the last recorded one, else a heartbeat when the last
780/// record is [`HEARTBEAT_MS`] old. Returns what was written: `"tree"`, `"heartbeat"` or `"nothing"`.
781pub fn record(tree: &Value) -> std::io::Result<&'static str> {
782    let path = journal_path();
783    if let Some(parent) = path.parent() {
784        std::fs::create_dir_all(parent)?;
785    }
786    if std::fs::metadata(&path).is_ok_and(|meta| meta.len() > JOURNAL_MAX_BYTES) {
787        std::fs::rename(&path, path.with_extension("jsonl.1"))?;
788    }
789    let now = now_ms();
790    let (last, last_at) = journal_tail();
791    let line = match &last {
792        Some(last) if shape(last) == shape(tree) => {
793            if last_at.is_some_and(|at| now - at < HEARTBEAT_MS) {
794                return Ok("nothing");
795            }
796            json!({"t": now, "unchanged_since": last["t"]})
797        }
798        _ => json!({"t": now, "machine": tree["machine"], "sessions": tree["sessions"],
799            "parents_elsewhere": tree["parents_elsewhere"]}),
800    };
801    let written = if line.get("sessions").is_some() {
802        "tree"
803    } else {
804        "heartbeat"
805    };
806    let mut file = std::fs::OpenOptions::new()
807        .create(true)
808        .append(true)
809        .open(&path)?;
810    file.write_all(format!("{line}\n").as_bytes())?;
811    Ok(written)
812}
813
814/// The tree as recorded at `at_ms`: the last recorded tree at or before it, when it was recorded, the last moment the
815/// record confirms it unchanged (a heartbeat or the next record), and the next change after it. `None` when nothing
816/// was recorded by then.
817pub fn recorded_tree(at_ms: i64) -> Option<Value> {
818    let path = journal_path();
819    let mut text = std::fs::read_to_string(path.with_extension("jsonl.1")).unwrap_or_default();
820    text.push_str(&std::fs::read_to_string(&path).unwrap_or_default());
821    let mut found: Option<Value> = None;
822    let mut confirmed: Option<i64> = None;
823    let mut next_change: Option<i64> = None;
824    for line in text.lines() {
825        let Ok(record) = serde_json::from_str::<Value>(line) else {
826            continue;
827        };
828        let Some(t) = record["t"].as_i64() else {
829            continue;
830        };
831        let full = record.get("sessions").is_some();
832        if t <= at_ms {
833            if full {
834                found = Some(record);
835            }
836            confirmed = Some(t);
837        } else if full {
838            next_change = Some(t);
839            break;
840        } else if found.is_some() && next_change.is_none() {
841            // A heartbeat after the moment still confirms the tree held through it.
842            confirmed = Some(t);
843        }
844    }
845    let mut tree = found?;
846    let recorded_at = tree["t"].as_i64().unwrap_or_default();
847    tree["at"] = json!(ms_to_rfc3339(at_ms));
848    tree["recorded_at"] = json!(ms_to_rfc3339(recorded_at));
849    tree["confirmed_through"] = json!(confirmed.map(ms_to_rfc3339));
850    tree["next_change_at"] = json!(next_change.map(ms_to_rfc3339));
851    if let Some(object) = tree.as_object_mut() {
852        object.remove("t");
853    }
854    Some(tree)
855}