Skip to main content

omni_dev/
sessions.rs

1//! The cross-window Claude Code session registry engine.
2//!
3//! Maintains the live, authoritative set of running Claude Code sessions across
4//! *every* terminal and VS Code window for the logged-in user, with a coarse
5//! inferred state (working / idle / waiting-for-input / waiting-for-permission).
6//! Fed by three independent feeds that each degrade gracefully — Claude Code
7//! **hooks** (`omni-dev sessions hook`), a **transcript-file watcher** over
8//! `~/.claude/projects/**/*.jsonl`, and the companion VS Code extension
9//! reporting each window's embedded Claude tabs/terminals. See ADR-0052.
10//!
11//! This is the standalone engine, analogous to [`crate::worktrees`],
12//! [`crate::browser`], and [`crate::snowflake`]; the daemon adapter lives in
13//! [`crate::daemon::services::sessions`].
14//!
15//! Like the worktrees engine this is cheap and in-memory — no async setup, no
16//! secret persisted. Two maps live behind a pair of [`std::sync::Mutex`]es that
17//! are **never held across an `.await`** (the Snowflake rule): the *sessions*
18//! keyed by their Claude `session_id`, and the *windows* keyed by the companion's
19//! per-window key (the Claude-embedding reports used to tag a session's source).
20//! Every op is pure CPU under a lock, so liveness reaping happens inline on each
21//! read rather than from a background task — exactly as [`crate::worktrees`] does.
22//!
23//! State is **inferred**, not first-class: Claude Code exposes no dedicated
24//! session-state event, so `working`/`idle` is best-effort (see
25//! [`SessionState::for_event`]). `waiting_for_permission` / `waiting_for_input`
26//! are reliable (they come from a `Notification` hook); the transcript watcher
27//! backstops the "thinking window" where no hook fires.
28//!
29//! The one exception is Feed 4, the [`stream`] tracker behind
30//! `omni-dev claude-wrap`: it reads the exact state out of Claude's stream-json
31//! stdio and reports it as [`SessionEvent::StreamState`], which
32//! [`SessionState::for_event`] applies verbatim. See ADR-0057.
33//!
34//! Feed 5 is pi.dev's: a generated extension in `~/.pi/agent/extensions/` maps
35//! pi's lifecycle events to a state and reports it the same way, tagged
36//! [`Agent::Pi`] (#1901).
37//!
38//! Codex is a second hook feed: `omni-dev sessions hook --agent codex`, run from
39//! `$CODEX_HOME/hooks.json`, maps Codex's events onto the same [`SessionEvent`]s
40//! and tags its sessions [`Agent::Codex`] (#1907, ADR-0087). The
41//! [`codex_watcher`] supplements it from Codex's rollout files and thread locks:
42//! discovery, archive and killed-process ends, and idle liveness (#1909).
43
44use std::collections::{HashMap, VecDeque};
45use std::path::{Path, PathBuf};
46use std::sync::{Mutex, MutexGuard, PoisonError};
47use std::time::Duration;
48
49use chrono::{DateTime, Utc};
50use serde::{Deserialize, Serialize};
51use tokio::sync::watch;
52
53use crate::utils::awake_clock::AwakeClock;
54
55#[cfg(unix)]
56pub mod codex_app_server;
57pub mod codex_watcher;
58#[cfg(unix)]
59pub mod journal;
60#[cfg(unix)]
61pub(crate) mod journal_watcher;
62pub(crate) mod pid_liveness;
63pub(crate) mod pid_watcher;
64pub mod relocate;
65pub mod stream;
66pub mod watcher;
67
68/// How long a session may go silent before it ages out of the registry, absent
69/// a confirmed-alive pid.
70///
71/// Unlike a VS Code window (which heartbeats every ~10s), a running Claude
72/// session emits nothing while idle at the prompt, so its only liveness signal
73/// used to be activity — a hook event or transcript growth. The TTL is
74/// generous for exactly that reason: a session that has done nothing for this
75/// long is assumed gone (a `claude` that exited without firing `SessionEnd`)
76/// and reaped on the next read.
77///
78/// The TTL is measured in *awake* time (#2108): see [`AwakeClock`]. A system
79/// sleep of any length ages no session, so the first read after a wake-up no
80/// longer reaps every live one.
81///
82/// Since #1916, a session whose pid the [`pid_watcher`] has
83/// independently confirmed alive is kept out of this TTL's reach entirely: the
84/// watcher refreshes `last_active` itself on a schedule of its own, off this
85/// lock, so `reap_sessions` never needs to know about pids at all. This TTL is
86/// now the fallback for a session with no pid (older hooks, the transcript
87/// watcher, pi.dev), one the watcher has not yet confirmed, or whose pid it
88/// can't confirm (an unprompted spare process, one another `session_id`
89/// has since taken over, or one the stream wrapper reports — #1454). A
90/// still-alive idle session bound by this TTL re-appears the moment it next
91/// does anything. See ADR-0052.
92const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
93
94/// How long an **ended** session lingers before it is reaped, so `sessions list`
95/// briefly shows a session that just finished (`SessionEnd` fired → [`end`]) as
96/// `ended` rather than having it vanish instantly.
97///
98/// [`end`]: SessionsRegistry::end
99const ENDED_SESSION_TTL: Duration = Duration::from_secs(10);
100
101/// How long a companion window-embedding report survives without a refresh.
102/// Mirrors the worktrees window TTL (three missed ~10s heartbeats): a window
103/// that crashed without unregistering stops tagging its sessions as VS Code
104/// embedded on the next read. Measured in awake time like the session TTL
105/// (#2108): a window cannot heartbeat while the machine sleeps either.
106const DEFAULT_WINDOW_TTL: Duration = Duration::from_secs(30);
107
108/// Ceiling on live session entries, so a runaway feed cannot grow daemon memory
109/// faster than the TTL reaps it (the worktrees `MAX_WINDOWS` precedent, #1140).
110/// Far above any real concurrent-session count; at the cap a genuinely new
111/// session evicts the longest-silent entry rather than being rejected, so ingest
112/// stays infallible.
113const MAX_SESSIONS: usize = 512;
114
115/// How many replaced agent processes a session remembers (#1948). Enough for a
116/// burst of window reloads, each of which replaces the previous process before
117/// its `SessionEnd` has necessarily arrived; the oldest is forgotten first.
118const MAX_REPLACED_PIDS: usize = 8;
119
120/// Ceiling on live window-embedding reports, mirroring the worktrees registry cap.
121const MAX_WINDOWS: usize = 256;
122
123/// How far behind the newest applied event a **socket** event may be and still
124/// be recognised as out of order and dropped (#2108). The window exists because a
125/// socket event can be overtaken by its own journal copy: the adapter awaits a
126/// git lookup before applying an `observe`, and the journal watcher can apply a
127/// newer event in that gap. It is short so a wall clock stepped back by more than
128/// this can never freeze a session; the journal route has no such window, since a
129/// stale journal event is only ever redundant.
130const SOCKET_REORDER_WINDOW: chrono::Duration = chrono::Duration::seconds(10);
131
132/// Whether `id` is a canonical hyphenated UUID (8-4-4-4-12 hex digits).
133///
134/// The only shape of id a Codex rollout's thread id or a hook journal is named
135/// for: Claude's session ids are UUID v4 and Codex's v7. Checking the shape, not
136/// just stripping separators, is what keeps a crafted id from naming a path.
137#[must_use]
138pub fn is_session_uuid(id: &str) -> bool {
139    let mut groups = id.split('-');
140    [8, 4, 4, 4, 12].into_iter().all(|len| {
141        groups
142            .next()
143            .is_some_and(|g| g.len() == len && g.bytes().all(|b| b.is_ascii_hexdigit()))
144    }) && groups.next().is_none()
145}
146
147/// How many recent event `seq`s a session remembers, to drop the copy of an
148/// event that reaches the registry by a second route (#2108). A hook delivers
149/// each event twice — once on the socket, once through its journal — and only
150/// the first may count.
151const MAX_RECENT_SEQS: usize = 32;
152
153/// The identity of one hook event: when it fired and a per-event nonce.
154///
155/// Stamped by the hook sink, written into the event's journal record and sent
156/// again on the socket POST, so the daemon can tell a copy it already applied
157/// from one it has not (#2108). `seq` is unique per event across processes
158/// (`<hook pid>-<unix nanos>`); `ts` is the sink's wall clock at the moment the
159/// event arrived, which orders a journal replay against newer events that
160/// already got through.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct EventStamp {
163    /// When the hook fired, by the sink's wall clock.
164    pub ts: DateTime<Utc>,
165    /// The event's nonce.
166    pub seq: String,
167}
168
169/// Which route an event reached the registry by.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub(crate) enum Origin {
172    /// The control socket: the fast path, and what every feed that posts to the
173    /// daemon uses — the hooks, the stream wrappers, the pi.dev extension. Always
174    /// applied, as before — only an exact duplicate is dropped.
175    Socket,
176    /// The daemon's own watchers (transcript growth, Codex rollouts, pid
177    /// liveness), which call the registry in-process. Applied exactly as the
178    /// socket's are, but never counted by [`DeliveryStats`]: nothing was
179    /// delivered.
180    Local,
181    /// A hook's durable journal, tailed: caught up after a dropped POST. Also
182    /// dropped when older than what the session has already applied, so a
183    /// late-read event can never undo a newer one.
184    Journal,
185    /// A hook's journal replayed by the daemon's **startup** scan: what a restart
186    /// found, or events fired while the daemon was down. The same rules as
187    /// [`Journal`](Self::Journal); a separate origin only so [`DeliveryStats`] can
188    /// tell a replay from a recovery. A journal a running daemon first sees later
189    /// is a [`Journal`](Self::Journal) tail.
190    Replay,
191}
192
193impl Origin {
194    /// Whether the event was read from a journal, by either route.
195    fn is_journal(self) -> bool {
196        matches!(self, Self::Journal | Self::Replay)
197    }
198}
199
200/// How events have reached the registry since the daemon started (#2136): the
201/// numbers that say whether the socket is keeping up. Monotonic and in memory.
202///
203/// Every hook delivers each event twice, by the socket and through its journal,
204/// so a healthy daemon sees `recovered == 0` and `superseded == 0`: every journal
205/// copy is a duplicate of an event the socket already delivered. Together,
206/// `recovered` and `superseded` are every POST that never arrived.
207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
208pub struct DeliveryStats {
209    /// Events applied from the control socket, whichever feed posted them: the
210    /// hooks, but also the stream wrappers and the pi.dev extension. Not the
211    /// daemon's own watchers, which never touch the socket.
212    pub socket: u64,
213    /// Events applied from a tailed journal whose socket copy never arrived: the
214    /// dropped POSTs the journal rescued. A copy that arrived a moment after the
215    /// journal poll read the line is not counted, since the poll can win that
216    /// race.
217    pub recovered: u64,
218    /// Events read from a tailed journal whose socket copy never arrived, but
219    /// which the registry ignored because a newer event of the session had
220    /// already been applied: dropped POSTs the journal could not rescue, since
221    /// applying them would undo that newer event. Counted like `recovered`, and
222    /// likewise taken back if the POST turns out to be only late.
223    pub superseded: u64,
224    /// Events applied by the startup replay of the journals: a restart, or events
225    /// fired while the daemon was down.
226    pub replayed: u64,
227}
228
229/// What one event's arrival does to [`DeliveryStats`] beyond being counted as
230/// applied: a correction to an earlier count, or a new one.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232enum Correction {
233    /// Nothing more.
234    None,
235    /// The socket copy of an event a tailed journal had already applied: the poll
236    /// won a race, so that event was not a dropped POST after all.
237    RacedByJournal,
238    /// The socket copy of an event a tailed journal had found overtaken: the POST
239    /// was only late.
240    LateAfterSuperseded,
241    /// A tailed journal event with no socket copy, ignored because a newer event
242    /// had already been applied.
243    Superseded,
244}
245
246/// The raw counters behind [`DeliveryStats`].
247///
248/// `recovered` and `superseded` are each one counter, incremented per event and
249/// decremented when its socket copy turns up, so a snapshot is never a difference
250/// of two values read at different instants.
251#[derive(Debug, Default)]
252struct DeliveryCounters {
253    socket: std::sync::atomic::AtomicU64,
254    recovered: std::sync::atomic::AtomicU64,
255    superseded: std::sync::atomic::AtomicU64,
256    replayed: std::sync::atomic::AtomicU64,
257}
258
259impl DeliveryCounters {
260    /// Records how one event fared: `applied` by `origin`, and whatever
261    /// `correction` it makes to an earlier count.
262    fn record(&self, origin: Origin, applied: bool, correction: Correction) {
263        use std::sync::atomic::Ordering::Relaxed;
264        let route = match origin {
265            Origin::Socket => Some(&self.socket),
266            Origin::Journal => Some(&self.recovered),
267            Origin::Replay => Some(&self.replayed),
268            Origin::Local => None,
269        };
270        if let (true, Some(counter)) = (applied, route) {
271            counter.fetch_add(1, Relaxed);
272        }
273        match correction {
274            Correction::None => {}
275            // Never below the events actually counted.
276            Correction::RacedByJournal => saturating_decrement(&self.recovered),
277            Correction::LateAfterSuperseded => saturating_decrement(&self.superseded),
278            Correction::Superseded => {
279                self.superseded.fetch_add(1, Relaxed);
280            }
281        }
282    }
283
284    fn snapshot(&self) -> DeliveryStats {
285        use std::sync::atomic::Ordering::Relaxed;
286        DeliveryStats {
287            socket: self.socket.load(Relaxed),
288            recovered: self.recovered.load(Relaxed),
289            superseded: self.superseded.load(Relaxed),
290            replayed: self.replayed.load(Relaxed),
291        }
292    }
293}
294
295/// Takes one off `counter`, stopping at zero.
296///
297/// A `compare_exchange` loop rather than `fetch_update`, which newer toolchains
298/// deprecate in favour of `try_update`, which is newer than the minimum
299/// supported Rust version.
300fn saturating_decrement(counter: &std::sync::atomic::AtomicU64) {
301    use std::sync::atomic::Ordering::Relaxed;
302    let mut current = counter.load(Relaxed);
303    while current > 0 {
304        match counter.compare_exchange_weak(current, current - 1, Relaxed, Relaxed) {
305            Ok(_) => break,
306            Err(actual) => current = actual,
307        }
308    }
309}
310
311/// The coarse, inferred lifecycle state of a Claude Code session.
312///
313/// Serialized `snake_case` (`waiting_for_permission`, …) into `list`/`status`
314/// payloads. `waiting_for_*` are **reliable** (a `Notification` hook fires them
315/// directly); `working`/`idle` are best-effort inference from `PreToolUse` /
316/// `Stop` plus the transcript-growth backstop (Claude Code ships no dedicated
317/// state event — ADR-0052).
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum SessionState {
321    /// Session just started (`SessionStart`), before any turn.
322    Starting,
323    /// Actively processing a turn — a tool call (`PreToolUse`/`PostToolUse`), a
324    /// submitted prompt (`UserPromptSubmit`), or observed transcript growth.
325    Working,
326    /// Finished a turn and waiting at the prompt (`Stop`).
327    Idle,
328    /// Blocked on the user for a plain input/idle notification.
329    WaitingForInput,
330    /// Blocked on the user to approve a tool/permission prompt.
331    WaitingForPermission,
332    /// The session ended (`SessionEnd`); reaped shortly after via
333    /// [`ENDED_SESSION_TTL`].
334    Ended,
335}
336
337impl SessionState {
338    /// The state a sighting of `event` implies, given the session's `current`
339    /// state (`None` for a brand-new session). This is the whole inference
340    /// machine, kept in one testable place:
341    ///
342    /// - `SessionStart` → [`Starting`](Self::Starting)
343    /// - `UserPromptSubmit` / `PreToolUse` / `PostToolUse` →
344    ///   [`Working`](Self::Working)
345    /// - `TranscriptGrew` → [`Working`](Self::Working), **except** while
346    ///   [`Starting`](Self::Starting) /
347    ///   [`WaitingForInput`](Self::WaitingForInput) /
348    ///   [`WaitingForPermission`](Self::WaitingForPermission) /
349    ///   [`Ended`](Self::Ended), which it leaves **unchanged** — growth is
350    ///   expected in those states without the session doing anything, so it is
351    ///   not evidence a turn is running (#1418, #1946)
352    /// - `Stop` → [`Idle`](Self::Idle)
353    /// - `Notification(PermissionPrompt)` →
354    ///   [`WaitingForPermission`](Self::WaitingForPermission)
355    /// - `Notification(IdlePrompt | AgentNeedsInput)` →
356    ///   [`WaitingForInput`](Self::WaitingForInput)
357    /// - `Notification(Other)` → **unchanged** (an unclassified notification is
358    ///   not evidence of a state change)
359    /// - `TranscriptDiscovered` → the current state if known, else
360    ///   [`Idle`](Self::Idle) (a passively-discovered session's activity is
361    ///   unknown; a later hook or growth upgrades it)
362    /// - `RolloutTurn(Open)` → as `TranscriptGrew`: [`Working`](Self::Working),
363    ///   unless a state a hook reported directly is held
364    /// - `RolloutTurn(Closed)` → [`Idle`](Self::Idle) from
365    ///   [`Working`](Self::Working) only; every other known state is
366    ///   **unchanged** (#2135)
367    /// - `StreamState(s)` → `s` verbatim (an authoritative stream-json report;
368    ///   the only non-inferred variant — see ADR-0057)
369    #[must_use]
370    pub fn for_event(event: &SessionEvent, current: Option<Self>) -> Self {
371        match event {
372            SessionEvent::SessionStart => Self::Starting,
373            SessionEvent::UserPromptSubmit
374            | SessionEvent::PreToolUse
375            | SessionEvent::PostToolUse => Self::Working,
376            // Growth is only evidence the transcript file got bigger. From most
377            // states that does imply a turn is running, but in two it does not,
378            // and reading it as `working` would overwrite a state a hook
379            // reported directly:
380            //
381            // - `waiting_for_*` — Claude flushes the assistant `tool_use` line
382            //   *before* the prompt it is asking about can be answered, so the
383            //   watcher's next scan would downgrade the wait and the row would
384            //   go quiet exactly when it should be shouting (#1418);
385            // - `ended` — a session's last lines land around `SessionEnd`, so a
386            //   scan inside the ended-linger window would revive the entry and
387            //   hold a phantom `working` row for the whole session TTL;
388            // - `starting` — a resumed session (a VS Code window reload,
389            //   `claude --resume`) keeps its id, and the *old* process appends a
390            //   `cost-state` line to the shared transcript as it exits. The 5s
391            //   scan usually sees that write after the new `SessionStart`, and
392            //   no hook fires while an unprompted session sits idle, so reading
393            //   it as `working` would pin the row busy until the next prompt
394            //   (#1946). Nothing is lost: a hook-fed session's first prompt
395            //   fires `UserPromptSubmit`, and a watcher-only session never
396            //   reaches `starting`, because `SessionStart` is a hook.
397            //
398            // That is ADR-0052's reliable-over-inferred ordering, and the rule
399            // `stream.rs`'s `state` already applies to a permission prompt. Each
400            // is released by any later hook, which is inference-free.
401            //
402            // A rollout whose last turn marker is open is the same evidence from
403            // the Codex side (#2135), so it follows the same rule.
404            SessionEvent::TranscriptGrew | SessionEvent::RolloutTurn(RolloutTurn::Open) => {
405                match current {
406                    Some(
407                        held @ (Self::Starting
408                        | Self::WaitingForInput
409                        | Self::WaitingForPermission
410                        | Self::Ended),
411                    ) => held,
412                    _ => Self::Working,
413                }
414            }
415            // A closed turn can end a `working` turn and nothing else: it is
416            // passive, so it cannot release a wait (ADR-0052), and `idle`,
417            // `starting` and `ended` are already as quiet as it says.
418            SessionEvent::RolloutTurn(RolloutTurn::Closed) => match current {
419                Some(Self::Working) | None => Self::Idle,
420                Some(other) => other,
421            },
422            SessionEvent::Stop => Self::Idle,
423            // An authoritative state from a stream-json observer wins outright,
424            // ignoring the inferred `current` — it read the exact state from the
425            // stream rather than guessing from a lifecycle event (ADR-0057).
426            SessionEvent::StreamState(state) => *state,
427            SessionEvent::Notification(NotificationKind::PermissionPrompt) => {
428                Self::WaitingForPermission
429            }
430            SessionEvent::Notification(
431                NotificationKind::IdlePrompt | NotificationKind::AgentNeedsInput,
432            ) => Self::WaitingForInput,
433            // An unclassified notification carries no state signal, and a
434            // passively-discovered transcript's activity is unknown: keep the
435            // current state (or default a brand-new session to Idle).
436            SessionEvent::Notification(NotificationKind::Other)
437            | SessionEvent::TranscriptDiscovered => current.unwrap_or(Self::Idle),
438        }
439    }
440}
441
442/// The classification of a Claude Code `Notification` hook.
443///
444/// Derived by the hook sink from the notification message (the message text is
445/// version-unstable, so classification is best-effort with an
446/// [`Other`](Self::Other) fallback).
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "snake_case")]
449pub enum NotificationKind {
450    /// Claude is asking to run a tool / use a permission — reliably
451    /// [`WaitingForPermission`](SessionState::WaitingForPermission).
452    PermissionPrompt,
453    /// Claude has been idle waiting for the user to respond.
454    IdlePrompt,
455    /// An agent/subagent needs the user's input.
456    AgentNeedsInput,
457    /// A notification we could not classify — carries no state signal.
458    Other,
459}
460
461/// What a Codex rollout's last turn marker says about its thread (#2135).
462///
463/// Read off the rollout's `event_msg` records by `type` alone: `task_started`
464/// opens a turn, and `task_complete` or `turn_aborted` closes it. Only the *last*
465/// marker is evidence — the counts are not (a rollout can carry a turn left open
466/// by a crash, or a stray extra completion).
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "snake_case")]
469pub enum RolloutTurn {
470    /// The last marker is `task_started`: a turn is running.
471    Open,
472    /// The last marker is `task_complete` or `turn_aborted`: no turn is running.
473    Closed,
474}
475
476/// A sighting of a session, from a hook event or the transcript watcher.
477///
478/// Drives the [`SessionState::for_event`] inference and refreshes liveness.
479/// Serialized on the wire as part of an [`ObserveRequest`]; `snake_case`, with
480/// the notification kind nested (`{"notification":"permission_prompt"}`).
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(rename_all = "snake_case")]
483pub enum SessionEvent {
484    /// `SessionStart` hook.
485    SessionStart,
486    /// `UserPromptSubmit` hook — a prompt was submitted.
487    UserPromptSubmit,
488    /// `PreToolUse` hook — about to run a tool.
489    PreToolUse,
490    /// `PostToolUse` hook — a tool finished.
491    PostToolUse,
492    /// `Stop` hook — the turn finished.
493    Stop,
494    /// `Notification` hook, classified into a [`NotificationKind`].
495    Notification(NotificationKind),
496    /// The transcript watcher saw this session's `.jsonl` grow (the
497    /// "thinking-window" backstop, where no hook fires).
498    TranscriptGrew,
499    /// The transcript watcher discovered a session's `.jsonl` it had not seen —
500    /// a session that started before the daemon, or before hooks were installed.
501    TranscriptDiscovered,
502    /// The Codex rollout watcher read the turn marker closest to the end of a
503    /// session's rollout, while the thread's lock was held — the one place a
504    /// Codex session with no hooks says whether a turn is running (#2135). Passive
505    /// evidence like [`TranscriptGrew`](Self::TranscriptGrew): it never overrides a
506    /// state a hook reported directly, and an open turn does not start `working`
507    /// over an `idle` that was reported (see [`SessionEntry::reported`]).
508    /// Serialized as `{"rollout_turn":"open"}`.
509    RolloutTurn(RolloutTurn),
510    /// An **authoritative** state reported directly by a stream-json observer —
511    /// the `omni-dev claude-wrap` wrapper reading Claude's `--output-format
512    /// stream-json` stdout, where the exact state is first-class (`init` →
513    /// working, `result` → idle, `can_use_tool` → waiting-for-permission). Unlike
514    /// every other variant this is *not* inferred: [`SessionState::for_event`]
515    /// returns the carried state verbatim. Serialized as
516    /// `{"stream_state":"waiting_for_permission"}` (ADR-0057).
517    StreamState(SessionState),
518}
519
520/// Where a session is running, resolved at [`list`](SessionsRegistry::list) time
521/// by joining a session's `cwd` against the companion's window-embedding reports.
522///
523/// A session whose `cwd` lies under a reporting VS Code window that has ≥1 Claude
524/// tab/terminal is tagged [`VsCode`](Self::VsCode); everything else is
525/// [`Terminal`](Self::Terminal) — meaning "not matched to a reporting VS Code
526/// window" (a bare terminal session, or a VS Code session whose companion is not
527/// installed). Serialized as `{"kind":"terminal"}` /
528/// `{"kind":"vs_code","window_key":"…"}`.
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(tag = "kind", rename_all = "snake_case")]
531pub enum Source {
532    /// Not matched to any reporting VS Code window.
533    Terminal,
534    /// Embedded in a VS Code window (matched by `cwd`), carrying that window's
535    /// companion key for a focus action.
536    VsCode {
537        /// The matched window's companion key.
538        window_key: String,
539    },
540}
541
542/// Which coding agent a session belongs to.
543///
544/// The registry is keyed by `session_id` alone. Claude Code's ids are UUID v4,
545/// while pi.dev's and Codex's are both UUID v7, so the version nibble no longer
546/// separates every pair (#1907): single-key identity instead rests on v7's 74
547/// random bits, which make a pi/Codex collision improbable — not impossible, and
548/// not worth a re-key (ADR-0087). The tag lets a consumer tell the agents apart.
549/// Serialized `snake_case`; absent on the wire means [`Claude`](Self::Claude), so
550/// the Claude feeds (hooks, watcher, `claude-wrap`) send it by omission and stay
551/// byte-identical to senders that predate it (#1901).
552///
553/// A daemon built with an older variant set rejects an unknown value
554/// (`unknown variant`), so a newer sink's `observe` is dropped until the daemon
555/// is upgraded — the sinks are fail-open, so that is silent.
556#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "snake_case")]
558pub enum Agent {
559    /// Claude Code — every feed but the pi extension.
560    #[default]
561    Claude,
562    /// pi.dev's coding agent, reported by the extension `sessions install-hooks`
563    /// writes into `~/.pi/agent/extensions/`.
564    Pi,
565    /// OpenAI Codex (CLI, VS Code extension and Desktop), reported by
566    /// `sessions hook --agent codex` from `$CODEX_HOME/hooks.json` (#1907).
567    Codex,
568}
569
570impl Agent {
571    /// The agent's display name, for the tray and other human-facing labels.
572    #[must_use]
573    pub fn display_name(self) -> &'static str {
574        match self {
575            Self::Claude => "Claude",
576            Self::Pi => "pi",
577            Self::Codex => "Codex",
578        }
579    }
580
581    /// Whether this is the default agent, so [`ObserveRequest`] can omit it.
582    #[must_use]
583    pub fn is_claude(&self) -> bool {
584        *self == Self::Claude
585    }
586}
587
588/// An idempotent session sighting sent to the registry — the wire payload of the
589/// `observe` op, and the argument to [`SessionsRegistry::observe`].
590///
591/// The hook sink and the transcript watcher both produce these; every field but
592/// `session_id` and `event` is best-effort and *fills in* missing data on an
593/// existing entry without ever clobbering known data with `None`.
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct ObserveRequest {
596    /// Claude subagent identity; absent for parent hooks and other feeds.
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub agent_id: Option<String>,
599    /// The Claude `session_id` (a UUID) — the primary key. Equal to the
600    /// transcript filename stem and (per ADR-0052) the VS Code extension's tab
601    /// key, so the three feeds join without heuristics.
602    pub session_id: String,
603    /// The session's working directory, when known (from the hook `cwd`).
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub cwd: Option<PathBuf>,
606    /// The `~/.claude/projects/**/<session-id>.jsonl` transcript path, when known.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub transcript_path: Option<PathBuf>,
609    /// The event that produced this sighting; drives the state inference.
610    pub event: SessionEvent,
611    /// The repository name enriched from `cwd` by the adapter (git2), when
612    /// resolvable. Stored verbatim; the engine does no disk I/O.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub repo: Option<String>,
615    /// The model id, when a hook reports one.
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub model: Option<String>,
618    /// Which agent the session belongs to; omitted for Claude Code.
619    #[serde(default, skip_serializing_if = "Agent::is_claude")]
620    pub agent: Agent,
621    /// The agent process's pid, when known: the hook sink's parent, or
622    /// `claude-wrap`'s wrapped child. Tells a resumed session's new process
623    /// from the old one it replaced, which share a `session_id` (#1948). Every
624    /// other feed (the watchers, the Codex app-server, pi.dev) sends `None`.
625    ///
626    /// This is also the seed for pid-based liveness (#1916): the
627    /// [`pid_watcher`] independently probes every pid it sees here, off this
628    /// wire and off the registry lock, rather than trusting a client-supplied
629    /// process identity — see [`SessionEntry::pid_start`].
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub pid: Option<u32>,
632}
633
634/// A companion report of one VS Code window's embedded Claude sessions.
635///
636/// The wire payload of the `window` op. The companion cannot expose a tab's
637/// `session_id` (Claude Code's extension has no public API — ADR-0052), so it
638/// reports only the *counts* of Claude tabs/terminals plus the window's folders;
639/// the join to a specific session is by `cwd`.
640#[derive(Debug, Clone, Serialize, Deserialize)]
641pub struct WindowReport {
642    /// The companion-owned per-window key (also the worktrees registration key).
643    pub key: String,
644    /// The window's workspace-folder absolute paths, for the `cwd` join.
645    #[serde(default)]
646    pub folders: Vec<PathBuf>,
647    /// How many Claude editor tabs (`claudeVSCodePanel` webviews) the window has.
648    #[serde(default)]
649    pub tabs: usize,
650    /// How many Claude Code integrated terminals the window has.
651    #[serde(default)]
652    pub terminals: usize,
653}
654
655impl WindowReport {
656    /// Whether this window has any Claude embedding at all — the gate for
657    /// tagging a matching session as [`Source::VsCode`].
658    #[must_use]
659    fn has_embedding(&self) -> bool {
660        self.tabs > 0 || self.terminals > 0
661    }
662}
663
664/// One live session in the registry.
665///
666/// Serialized verbatim into `list` / `status` payloads; consumers compute age
667/// from `last_seen` (RFC 3339). `source` is resolved at
668/// [`list`](SessionsRegistry::list) time (stored as [`Source::Terminal`] until
669/// then).
670#[derive(Debug, Clone, Serialize)]
671pub struct SessionEntry {
672    /// Parent state hidden by outstanding subagent waits.
673    #[serde(skip)]
674    pub(crate) subagent_base: Option<SessionState>,
675    /// Outstanding waits, released only by their owning subagent.
676    #[serde(skip)]
677    pub(crate) subagent_waits: HashMap<String, SessionState>,
678    /// The Claude `session_id`.
679    pub session_id: String,
680    /// The session's working directory, when known.
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub cwd: Option<PathBuf>,
683    /// The transcript path, when known.
684    #[serde(skip_serializing_if = "Option::is_none")]
685    pub transcript_path: Option<PathBuf>,
686    /// The repository name enriched from `cwd`, when resolvable.
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub repo: Option<String>,
689    /// The model id, when reported.
690    #[serde(skip_serializing_if = "Option::is_none")]
691    pub model: Option<String>,
692    /// Which agent the session belongs to. Set by the first sighting and never
693    /// changed, since a session id belongs to exactly one agent.
694    pub agent: Agent,
695    /// The current inferred state.
696    pub state: SessionState,
697    /// Where the session runs, resolved on read.
698    pub source: Source,
699    /// The most recent event observed for this session.
700    pub last_event: SessionEvent,
701    /// When the session was first observed (RFC 3339).
702    pub started_at: DateTime<Utc>,
703    /// When the registry last heard from this session (RFC 3339). Wall-clock,
704    /// for display only: liveness is measured on `last_active`.
705    pub last_seen: DateTime<Utc>,
706    /// When the registry last heard from this session, on the registry's
707    /// [`AwakeClock`] — the stamp [`reap_sessions`] compares against, so a system
708    /// sleep never ages an entry (#2108).
709    #[serde(skip)]
710    pub(crate) last_active: Duration,
711    /// The agent process that owns the session: the pid of its latest
712    /// pid-bearing sighting that did not come from a replaced process.
713    #[serde(skip)]
714    pub(crate) pid: Option<u32>,
715    /// `pid`'s start-time identity token (#1916), **set only by the
716    /// [`pid_watcher`]**, never from a client-supplied value: it is captured
717    /// the first time the watcher independently confirms `pid` alive, and
718    /// reset to `None` whenever `pid` changes owner (a new pid's identity is
719    /// unconfirmed until the watcher next probes it). `None` until then, so a
720    /// pid the watcher has not yet gotten to on its own schedule simply has no
721    /// opinion on liveness rather than a stale or spoofable one.
722    #[serde(skip)]
723    pub(crate) pid_start: Option<String>,
724    /// Whether this session has had at least one `UserPromptSubmit`. Gates the
725    /// pid-liveness TTL exemption (#1916): a spare process VS Code keeps alive
726    /// that has never been prompted must not be pinned forever just because its
727    /// pid is alive.
728    #[serde(skip)]
729    pub(crate) prompted: bool,
730    /// Whether a Claude `claude-wrap` stream observer has reported this session
731    /// (an authoritative [`SessionEvent::StreamState`]). Excludes it from the
732    /// pid-liveness TTL exemption (#1454): the stream wrapper is only ever
733    /// attached to a VS Code/SDK-driven `claude`, whose extension keeps a
734    /// process per chat rather than per visible tab, so a live pid says nothing
735    /// about whether anyone can still see the chat. Its busy states are kept
736    /// fresh by the wrapper's own keep-alive instead. Sticky across hooks, but
737    /// reset when a different process takes the session over ([`track_pid`]).
738    #[serde(skip)]
739    pub(crate) streamed: bool,
740    /// The processes the session was taken over from, oldest first and capped
741    /// at [`MAX_REPLACED_PIDS`] — the old processes of in-place resumes. An
742    /// `end` from one of them is ignored (#1948).
743    #[serde(skip)]
744    pub(crate) replaced_pids: VecDeque<u32>,
745    /// The `seq`s of the most recent stamped events applied, newest last and
746    /// capped at [`MAX_RECENT_SEQS`], so the second copy of an event (socket and
747    /// journal both deliver every hook) is dropped (#2108).
748    #[serde(skip)]
749    pub(crate) recent_seqs: VecDeque<(String, Origin)>,
750    /// The `seq`s of the most recent tailed-journal events found overtaken by a
751    /// newer one and counted as [`DeliveryStats::superseded`], capped at
752    /// [`MAX_RECENT_SEQS`]. Kept apart from `recent_seqs`, which decides what is a
753    /// duplicate: an event not applied is not one, and a socket copy of it that
754    /// turns up must still be handled as it always was (#2136).
755    #[serde(skip)]
756    pub(crate) superseded_seqs: VecDeque<String>,
757    /// The newest `ts` among the stamped events applied: a journal event older
758    /// than this is stale and dropped (#2108).
759    #[serde(skip)]
760    pub(crate) latest_stamp_ts: Option<DateTime<Utc>>,
761    /// Whether the current `state` was asserted directly — by a hook or an
762    /// authoritative stream report — rather than defaulted or inferred from
763    /// passive evidence (growth, a discovered transcript, a rollout's turn
764    /// marker). `last_event` cannot say: the Codex watcher's heartbeat overwrites
765    /// it without touching the state. An open rollout turn does not start
766    /// `working` from an `idle` that was reported (#2135).
767    #[serde(skip)]
768    pub(crate) reported: bool,
769}
770
771/// Why a stamped event is not applied to its session (#2108).
772#[derive(Debug, Clone, Copy, PartialEq, Eq)]
773enum Skip {
774    /// A copy of an event already applied, by either route.
775    Duplicate,
776    /// A journal event older than one already applied.
777    JournalStale,
778    /// A socket event older than one already applied, within
779    /// [`SOCKET_REORDER_WINDOW`].
780    SocketReordered,
781}
782
783impl Skip {
784    /// The `outcome` the event is logged with.
785    fn outcome(self) -> &'static str {
786        match self {
787            Self::Duplicate => "duplicate_ignored",
788            Self::JournalStale => "journal_stale_ignored",
789            Self::SocketReordered => "socket_reordered_ignored",
790        }
791    }
792}
793
794impl SessionEntry {
795    /// Why a stamped event must not be applied to this session, if so: it is a
796    /// duplicate of one already applied, or older than one — any age for the
797    /// journal route, within [`SOCKET_REORDER_WINDOW`] for the socket.
798    fn stamp_skip(&self, stamp: Option<&EventStamp>, origin: Origin) -> Option<Skip> {
799        let stamp = stamp?;
800        if self.seq_origin(&stamp.seq).is_some() {
801            return Some(Skip::Duplicate);
802        }
803        let behind = self.latest_stamp_ts.map(|latest| latest - stamp.ts);
804        match (origin, behind) {
805            (Origin::Journal | Origin::Replay, Some(behind))
806                if behind > chrono::Duration::zero() =>
807            {
808                Some(Skip::JournalStale)
809            }
810            (Origin::Socket, Some(behind))
811                if behind > chrono::Duration::zero() && behind <= SOCKET_REORDER_WINDOW =>
812            {
813                Some(Skip::SocketReordered)
814            }
815            _ => None,
816        }
817    }
818
819    /// The route the event with this `seq` was applied by, if it is among the
820    /// recent ones.
821    fn seq_origin(&self, seq: &str) -> Option<Origin> {
822        self.recent_seqs
823            .iter()
824            .find(|(recent, _)| recent == seq)
825            .map(|(_, origin)| *origin)
826    }
827
828    /// Updates what the session remembers of `stamp`'s event, now that it has
829    /// arrived by `origin` and been applied or ignored (`skip`), and says what
830    /// that does to the delivery counts (#2136).
831    fn settle_stamp(
832        &mut self,
833        stamp: &EventStamp,
834        origin: Origin,
835        skip: Option<Skip>,
836    ) -> Correction {
837        let correction =
838            if origin == Origin::Socket && self.seq_origin(&stamp.seq) == Some(Origin::Journal) {
839                // The poll beat the POST. Marked confirmed, so a further copy of it
840                // is not taken back again. A *replayed* event is not matched: a
841                // replay is not counted as a recovery, so its socket copy has nothing
842                // to take back.
843                self.confirm_by_socket(&stamp.seq);
844                Correction::RacedByJournal
845            } else if origin == Origin::Socket && self.forget_superseded(&stamp.seq) {
846                Correction::LateAfterSuperseded
847            } else if origin == Origin::Journal
848                && skip == Some(Skip::JournalStale)
849                && self.remember_superseded(&stamp.seq)
850            {
851                Correction::Superseded
852            } else {
853                Correction::None
854            };
855        // The ring holds what was applied and also what reached the socket but was
856        // dropped as out of order. That event did arrive, so its journal copy
857        // must be a known duplicate rather than look like a POST that never came.
858        if matches!(skip, None | Some(Skip::SocketReordered)) {
859            self.record_stamp(stamp, origin);
860        }
861        correction
862    }
863
864    /// Marks the event with this `seq` as confirmed by the socket.
865    fn confirm_by_socket(&mut self, seq: &str) {
866        if let Some((_, origin)) = self.recent_seqs.iter_mut().find(|(s, _)| s == seq) {
867            *origin = Origin::Socket;
868        }
869    }
870
871    /// Remembers that a tailed-journal event was counted as superseded. `false`
872    /// if it already had been, so a journal read twice does not count twice.
873    fn remember_superseded(&mut self, seq: &str) -> bool {
874        if self.superseded_seqs.iter().any(|s| s == seq) {
875            return false;
876        }
877        if self.superseded_seqs.len() >= MAX_RECENT_SEQS {
878            self.superseded_seqs.pop_front();
879        }
880        self.superseded_seqs.push_back(seq.to_owned());
881        true
882    }
883
884    /// Forgets a superseded event whose socket copy has turned up. `false` if it
885    /// was not one.
886    fn forget_superseded(&mut self, seq: &str) -> bool {
887        let Some(at) = self.superseded_seqs.iter().position(|s| s == seq) else {
888            return false;
889        };
890        self.superseded_seqs.remove(at);
891        true
892    }
893
894    /// Remembers an event's stamp for [`stamp_skip`](Self::stamp_skip).
895    fn record_stamp(&mut self, stamp: &EventStamp, origin: Origin) {
896        if self.recent_seqs.len() >= MAX_RECENT_SEQS {
897            self.recent_seqs.pop_front();
898        }
899        self.recent_seqs.push_back((stamp.seq.clone(), origin));
900        // The newest, not the latest to arrive: events from concurrent hooks can
901        // reach the socket out of order, and a journal event older than any of
902        // them is stale either way.
903        self.latest_stamp_ts = Some(self.latest_stamp_ts.map_or(stamp.ts, |t| t.max(stamp.ts)));
904    }
905}
906
907/// When an event is taken to have been seen, for the wall-clock `last_seen`: now
908/// for the socket, and the event's own `ts` for a journal event (never in the
909/// future), so a replayed history cannot look fresh (#2108).
910fn seen_at(stamp: Option<&EventStamp>, origin: Origin, now: DateTime<Utc>) -> DateTime<Utc> {
911    match (origin, stamp) {
912        (Origin::Journal | Origin::Replay, Some(stamp)) => stamp.ts.min(now),
913        _ => now,
914    }
915}
916
917/// Whether `event` asserts a state directly (a hook, or an authoritative stream
918/// report), as opposed to passive evidence about the session: a watcher's
919/// discovery, transcript growth or a rollout's turn marker.
920fn asserts_state(event: SessionEvent) -> bool {
921    !matches!(
922        event,
923        SessionEvent::TranscriptDiscovered
924            | SessionEvent::TranscriptGrew
925            | SessionEvent::RolloutTurn(_)
926    )
927}
928
929/// Whether `event` is an open rollout turn arriving at an `idle` that was
930/// reported directly. The turn marker is read off a file Codex flushes on its own
931/// schedule, so it can trail a `Stop` hook (or the wrapper's `idle`) by a scan and
932/// would start `working` again over it: a reported state stands, and the next hook
933/// or poll restarts the row if a turn really is running. An `idle` that was only
934/// defaulted or inferred has nothing to defend, which is the hook-less session
935/// this event exists for (#2135).
936fn open_turn_over_reported_idle(entry: &SessionEntry, event: SessionEvent) -> bool {
937    event == SessionEvent::RolloutTurn(RolloutTurn::Open)
938        && entry.state == SessionState::Idle
939        && entry.reported
940}
941
942/// Subagent activity shares the parent's session id but does not run its turn.
943fn subagent_state(
944    entry: &mut SessionEntry,
945    event: SessionEvent,
946    agent_id: Option<&str>,
947) -> SessionState {
948    if let Some(id) = agent_id.filter(|id| !id.trim().is_empty()) {
949        match event {
950            SessionEvent::Notification(
951                NotificationKind::PermissionPrompt
952                | NotificationKind::IdlePrompt
953                | NotificationKind::AgentNeedsInput,
954            ) => {
955                entry.subagent_base.get_or_insert(entry.state);
956                entry
957                    .subagent_waits
958                    .insert(id.to_owned(), SessionState::for_event(&event, None));
959            }
960            SessionEvent::PostToolUse => {
961                entry.subagent_waits.remove(id);
962            }
963            _ => {}
964        }
965        if entry
966            .subagent_waits
967            .values()
968            .any(|s| *s == SessionState::WaitingForPermission)
969        {
970            return SessionState::WaitingForPermission;
971        }
972        if !entry.subagent_waits.is_empty() {
973            return SessionState::WaitingForInput;
974        }
975        return entry.subagent_base.take().unwrap_or(entry.state);
976    }
977    let next = SessionState::for_event(&event, Some(entry.state));
978    // Passive evidence cannot release a wait. Parent hooks remain the fallback
979    // release when the parent Task completes or a new turn starts.
980    if !matches!(
981        event,
982        SessionEvent::TranscriptDiscovered
983            | SessionEvent::TranscriptGrew
984            | SessionEvent::RolloutTurn(_)
985            | SessionEvent::Notification(NotificationKind::Other)
986    ) {
987        entry.subagent_waits.clear();
988        entry.subagent_base = None;
989    }
990    next
991}
992
993/// One pid-bearing session, as [`SessionsRegistry::pid_liveness_candidates`]
994/// hands it to the [`pid_watcher`](pid_watcher) — a plain data snapshot with no
995/// lock and no process handle attached, so the watcher can probe it entirely
996/// off the registry lock.
997#[derive(Debug, Clone)]
998pub(crate) struct PidCandidate {
999    pub(crate) session_id: String,
1000    pub(crate) pid: u32,
1001    /// The identity token last confirmed for `pid`, if any (see
1002    /// [`SessionEntry::pid_start`]).
1003    pub(crate) pid_start: Option<String>,
1004    pub(crate) prompted: bool,
1005    /// Whether the session is reported by the stream wrapper — see
1006    /// [`SessionEntry::streamed`]. Such a session is never exempted from the TTL.
1007    pub(crate) streamed: bool,
1008    /// When this session was last seen, used to pick the *currently active*
1009    /// session_id among several sharing a pid — not `started_at`, which would
1010    /// wrongly keep favouring a `/clear`-abandoned session forever over one a
1011    /// later `/resume` reactivated, since creation order never changes but
1012    /// `last_seen` moves with whichever session_id is actually receiving hooks.
1013    pub(crate) last_seen: DateTime<Utc>,
1014}
1015
1016/// One companion window-embedding report, with its liveness stamp.
1017#[derive(Debug, Clone)]
1018struct WindowEntry {
1019    /// The report as sent by the companion.
1020    report: WindowReport,
1021    /// When the report last arrived (register or refresh), on the registry's
1022    /// [`AwakeClock`]. [`reap_windows`] compares against it, so a system sleep
1023    /// never ages a window report (#2108); a window's wall-clock arrival time is
1024    /// shown nowhere, so none is kept.
1025    last_active: Duration,
1026    /// When this key first registered, preserved across refreshes. Ranks
1027    /// overlapping windows in [`pick_window`] (#1451): a reload registers a new
1028    /// key, so it outranks the stale predecessor still inside its TTL, while
1029    /// two live windows keep a stable winner — `last_seen` would flap between
1030    /// them on every heartbeat.
1031    registered_at: DateTime<Utc>,
1032}
1033
1034/// The cross-window session registry.
1035///
1036/// The in-memory, TTL-reaped set of running Claude sessions plus the companion
1037/// window-embedding reports used to tag a session's [`Source`]. Hosted by
1038/// [`SessionsService`](crate::daemon::services::sessions::SessionsService).
1039pub struct SessionsRegistry {
1040    /// Live sessions keyed by `session_id`.
1041    sessions: Mutex<HashMap<String, SessionEntry>>,
1042    /// Companion window-embedding reports keyed by window key. Behind its own
1043    /// mutex, taken independently of `sessions`, so the two never nest.
1044    windows: Mutex<HashMap<String, WindowEntry>>,
1045    /// How long a session survives without activity.
1046    session_ttl: Duration,
1047    /// How long an `ended` session lingers before reaping.
1048    ended_ttl: Duration,
1049    /// How long a window-embedding report survives without a refresh.
1050    window_ttl: Duration,
1051    /// The clock every TTL is measured on; see [`AwakeClock`].
1052    clock: AwakeClock,
1053    /// A monotonically-bumped version counter, incremented whenever the state a
1054    /// subscriber renders changes. A push-subscription consumer holds a
1055    /// [`watch::Receiver`] from [`subscribe_changes`](Self::subscribe_changes)
1056    /// and wakes on each bump to re-snapshot (#1414) — the
1057    /// [`WorktreesRegistry`](crate::worktrees::WorktreesRegistry) arrangement,
1058    /// one service over. The counter's *value* is immaterial — only that it
1059    /// changed — so a burst coalesces into one wake and the server diffs the
1060    /// resulting snapshot to suppress duplicate frames.
1061    ///
1062    /// `watch` needs no runtime and never blocks, so it fits this engine's
1063    /// no-async-setup posture; every [`bump`](Self::bump) happens *after* the map
1064    /// guard is dropped, so the `std::Mutex`-never-across-`.await` rule is intact
1065    /// (and the watch's own internal lock is never nested under a map lock).
1066    changes: watch::Sender<u64>,
1067    /// How events have reached the registry (#2136).
1068    delivery: DeliveryCounters,
1069}
1070
1071impl SessionsRegistry {
1072    /// Creates the registry with the default liveness TTLs. Cheap — no I/O.
1073    #[must_use]
1074    pub fn new() -> Self {
1075        Self {
1076            sessions: Mutex::new(HashMap::new()),
1077            windows: Mutex::new(HashMap::new()),
1078            session_ttl: DEFAULT_SESSION_TTL,
1079            ended_ttl: ENDED_SESSION_TTL,
1080            window_ttl: DEFAULT_WINDOW_TTL,
1081            clock: AwakeClock::new(),
1082            changes: watch::channel(0).0,
1083            delivery: DeliveryCounters::default(),
1084        }
1085    }
1086
1087    /// How events have reached the registry since it was created (#2136).
1088    #[must_use]
1089    pub fn delivery(&self) -> DeliveryStats {
1090        self.delivery.snapshot()
1091    }
1092
1093    /// A registry that never reaps, for assessing a journal off to the side
1094    /// (#2108): the real state machine replays a session's events, and the caller
1095    /// reads the resulting entry, without any TTL deciding it was already gone.
1096    #[must_use]
1097    pub(crate) fn scratch() -> Self {
1098        Self {
1099            session_ttl: Duration::MAX,
1100            ended_ttl: Duration::MAX,
1101            window_ttl: Duration::MAX,
1102            ..Self::new()
1103        }
1104    }
1105
1106    /// The ids of the sessions that are live — present and not `ended` — lower-cased
1107    /// like the journal file names, so the journal watcher can tell a journal that
1108    /// is still wanted from an orphan whatever case the hook sent the id in.
1109    pub(crate) fn live_session_ids(&self) -> std::collections::HashSet<String> {
1110        self.lock_sessions()
1111            .values()
1112            .filter(|entry| entry.state != SessionState::Ended)
1113            .map(|entry| entry.session_id.to_ascii_lowercase())
1114            .collect()
1115    }
1116
1117    /// A change-notification receiver for the push subscription: it observes a
1118    /// new value each time the rendered session state changes (see
1119    /// [`bump`](Self::bump)). Created with the current version already marked
1120    /// seen, so the first [`watch::Receiver::changed`] resolves on the *next*
1121    /// change — the subscriber sends its own initial snapshot up front and then
1122    /// waits for deltas (#1414).
1123    #[must_use]
1124    pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
1125        self.changes.subscribe()
1126    }
1127
1128    /// Signals subscribers that the rendered state changed. Non-blocking and
1129    /// runtime-free; called only *after* a map guard is released so the locks
1130    /// never nest. A send never fails here (the sender is owned by the registry,
1131    /// which outlives every receiver, and `send_modify` bumps even with no
1132    /// receivers).
1133    ///
1134    /// Callers bump **only on a change a consumer renders** — a new or dropped
1135    /// session, a [`SessionState`] transition, a best-effort field taking a new
1136    /// value, or a window report that alters the [`Source`] join. Deliberately
1137    /// *narrower* than "the serialized payload differs": [`SessionEntry`] carries
1138    /// `last_seen` and `last_event`, which churn on every hook event, so bumping
1139    /// on those would push a fresh snapshot to every window on every `PreToolUse`
1140    /// with the server's snapshot diff unable to suppress any of it. Their deltas
1141    /// ride the server's periodic re-sample instead — the same latency the poll
1142    /// this replaced already had, for fields nothing renders.
1143    pub(crate) fn bump(&self) {
1144        self.changes.send_modify(|v| *v = v.wrapping_add(1));
1145    }
1146
1147    /// Locks the sessions map, recovering from a poisoned mutex (a panic in a
1148    /// prior critical section must not wedge the whole registry).
1149    fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, SessionEntry>> {
1150        self.sessions.lock().unwrap_or_else(PoisonError::into_inner)
1151    }
1152
1153    /// Locks the windows map, recovering from a poisoned mutex.
1154    fn lock_windows(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
1155        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
1156    }
1157
1158    /// Settles what one event's arrival changes in the session's stamp ring and
1159    /// the delivery counters (#2108, #2136), once it has been applied or skipped.
1160    /// `applied` is whether the event counts as delivered by `origin`.
1161    fn settle_delivery(
1162        &self,
1163        sessions: &mut HashMap<String, SessionEntry>,
1164        session_id: &str,
1165        stamp: Option<&EventStamp>,
1166        origin: Origin,
1167        skip: Option<Skip>,
1168        applied: bool,
1169    ) {
1170        let correction = match (stamp, sessions.get_mut(session_id)) {
1171            (Some(stamp), Some(entry)) => entry.settle_stamp(stamp, origin, skip),
1172            _ => Correction::None,
1173        };
1174        self.delivery.record(origin, applied, correction);
1175    }
1176
1177    /// Records (upserts) a session sighting, running the [`SessionState`]
1178    /// inference and refreshing liveness. Reaps stale entries first, then — only
1179    /// when a genuinely new session would grow the map past [`MAX_SESSIONS`] —
1180    /// evicts the longest-silent entry. Infallible: an upsert never evicts.
1181    ///
1182    /// Best-effort fields (`cwd`/`transcript_path`/`repo`/`model`) *fill in* on
1183    /// an existing entry and never overwrite known data with `None`, so a later
1184    /// hook enriches a watcher-discovered session without a race losing data.
1185    ///
1186    /// [`bump`](Self::bump)s only when the sighting changed something a consumer
1187    /// renders — a brand-new session, a [`SessionState`] transition, a
1188    /// best-effort field taking a new value, or a reap that dropped a sibling.
1189    /// A repeat sighting that merely refreshes liveness does not, since hooks
1190    /// fire on every tool call (the `heartbeat` precedent in
1191    /// [`WorktreesRegistry`](crate::worktrees::WorktreesRegistry)).
1192    ///
1193    /// For the daemon's own in-process feeds: it is the `Local` origin, which
1194    /// [`DeliveryStats`] does not count. The control socket's feed goes through
1195    /// `observe_stamped` as the `Socket` origin.
1196    pub fn observe(&self, req: ObserveRequest) {
1197        self.observe_stamped(req, None, Origin::Local);
1198    }
1199
1200    /// [`observe`](Self::observe) for an event that carries an [`EventStamp`] and
1201    /// arrived by `origin` (#2108). A duplicate of an event already applied is
1202    /// dropped whatever its route, and a [`Journal`](Origin::Journal) event older
1203    /// than one already applied is dropped too; everything else behaves exactly as
1204    /// `observe`, except that a journal event's wall-clock `last_seen` is the
1205    /// event's own timestamp rather than now.
1206    pub(crate) fn observe_stamped(
1207        &self,
1208        req: ObserveRequest,
1209        stamp: Option<EventStamp>,
1210        origin: Origin,
1211    ) {
1212        let agent_id = req
1213            .agent_id
1214            .as_deref()
1215            .filter(|id| req.agent == Agent::Claude && !id.trim().is_empty());
1216        let session_id = req.session_id.clone();
1217        let event = req.event;
1218        let agent = req.agent;
1219        let pid = req.pid;
1220        let now = Utc::now();
1221        let seen = seen_at(stamp.as_ref(), origin, now);
1222        let awake = self.clock.now();
1223        let (changed, old_state, new_state, outcome, reaped) = {
1224            let mut sessions = self.lock_sessions();
1225            let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, awake);
1226            let old_state = sessions.get(&session_id).map(|entry| entry.state);
1227            let skip = sessions
1228                .get(&session_id)
1229                .and_then(|entry| entry.stamp_skip(stamp.as_ref(), origin));
1230            let mut outcome = "created";
1231            let mutated = match sessions.get_mut(&req.session_id) {
1232                // The second copy of an event already applied, or a journal
1233                // event older than one that was (#2108).
1234                Some(_) if skip.is_some() => {
1235                    outcome = skip.map_or("skipped", Skip::outcome);
1236                    false
1237                }
1238                // A passive re-sighting (the Codex rollout watcher's heartbeat, or
1239                // a turn marker it read) must not refresh an ended session, or it
1240                // would outlive its short ended-linger window (#1909, #2135).
1241                Some(entry)
1242                    if entry.state == SessionState::Ended
1243                        && (matches!(
1244                            req.event,
1245                            SessionEvent::TranscriptDiscovered | SessionEvent::RolloutTurn(_)
1246                        ) || agent_id.is_some()) =>
1247                {
1248                    outcome = "ended_passive_ignored";
1249                    false
1250                }
1251                // A straggling sighting from a process this session's resume
1252                // already replaced: ignore it entirely, so a delayed hook from
1253                // the old process (anything short of its `SessionEnd`, handled
1254                // separately by `end`) cannot move the resumed session's state
1255                // out from under the new process that owns it (#1948).
1256                Some(entry)
1257                    if req
1258                        .pid
1259                        .is_some_and(|pid| entry.replaced_pids.contains(&pid)) =>
1260                {
1261                    outcome = "replaced_pid_ignored";
1262                    false
1263                }
1264                Some(entry) => {
1265                    track_pid(entry, req.pid);
1266                    entry.prompted |= req.event == SessionEvent::UserPromptSubmit;
1267                    entry.streamed |= is_claude_stream_report(&req);
1268                    let next = if open_turn_over_reported_idle(entry, req.event) {
1269                        entry.state
1270                    } else {
1271                        subagent_state(entry, req.event, agent_id)
1272                    };
1273                    let state_changed = next != entry.state;
1274                    // A direct assertion makes the state reported; passive
1275                    // evidence only unmakes it by changing the state itself.
1276                    if asserts_state(req.event) {
1277                        entry.reported = true;
1278                    } else if state_changed {
1279                        entry.reported = false;
1280                    }
1281                    entry.state = next;
1282                    entry.last_event = req.event;
1283                    entry.last_seen = if origin.is_journal() {
1284                        entry.last_seen.max(seen)
1285                    } else {
1286                        now
1287                    };
1288                    entry.last_active = awake;
1289                    // Bound to locals rather than folded into the `||` below: every
1290                    // field must be filled, and short-circuiting would skip the rest.
1291                    let filled_cwd = fill(&mut entry.cwd, req.cwd);
1292                    let filled_transcript = fill(&mut entry.transcript_path, req.transcript_path);
1293                    let filled_repo = fill(&mut entry.repo, req.repo);
1294                    let filled_model = fill(&mut entry.model, req.model);
1295                    let metadata_changed =
1296                        filled_cwd || filled_transcript || filled_repo || filled_model;
1297                    outcome = if state_changed {
1298                        "state_changed"
1299                    } else if metadata_changed {
1300                        "metadata_enriched"
1301                    } else {
1302                        "heartbeat_only"
1303                    };
1304                    state_changed || metadata_changed
1305                }
1306                None => {
1307                    if sessions.len() >= MAX_SESSIONS {
1308                        evict_oldest_session(&mut sessions);
1309                    }
1310                    let state = if agent_id.is_some() {
1311                        SessionState::Idle
1312                    } else {
1313                        SessionState::for_event(&req.event, None)
1314                    };
1315                    let prompted = req.event == SessionEvent::UserPromptSubmit;
1316                    let streamed = is_claude_stream_report(&req);
1317                    let session_id = req.session_id.clone();
1318                    let mut entry = SessionEntry {
1319                        subagent_base: None,
1320                        subagent_waits: HashMap::new(),
1321                        session_id: req.session_id,
1322                        cwd: req.cwd,
1323                        transcript_path: req.transcript_path,
1324                        repo: req.repo,
1325                        model: req.model,
1326                        agent: req.agent,
1327                        state,
1328                        source: Source::Terminal,
1329                        last_event: req.event,
1330                        started_at: seen,
1331                        last_seen: seen,
1332                        last_active: awake,
1333                        pid: req.pid,
1334                        pid_start: None,
1335                        prompted,
1336                        streamed,
1337                        replaced_pids: VecDeque::new(),
1338                        recent_seqs: VecDeque::new(),
1339                        superseded_seqs: VecDeque::new(),
1340                        latest_stamp_ts: None,
1341                        reported: asserts_state(req.event),
1342                    };
1343                    entry.state = subagent_state(&mut entry, req.event, agent_id);
1344                    sessions.insert(session_id, entry);
1345                    true
1346                }
1347            };
1348            self.settle_delivery(
1349                &mut sessions,
1350                &session_id,
1351                stamp.as_ref(),
1352                origin,
1353                skip,
1354                skip.is_none(),
1355            );
1356            (
1357                mutated || reaped > 0,
1358                old_state,
1359                sessions.get(&session_id).map(|entry| entry.state),
1360                outcome,
1361                reaped,
1362            )
1363        };
1364        if changed {
1365            self.bump();
1366        }
1367        tracing::debug!(%session_id, ?agent, ?pid, ?agent_id, ?event, ?old_state, ?new_state, outcome,
1368            ?origin, reaped, bumped = changed, "session_observed");
1369    }
1370
1371    /// Marks a session ended (`SessionEnd`), so `list` shows it as `ended` for a
1372    /// short window ([`ENDED_SESSION_TTL`]) before it is reaped. Returns whether
1373    /// the session was known. A no-op for an already-unknown session (a
1374    /// duplicate/late `SessionEnd`).
1375    ///
1376    /// Also a no-op when `pid` is a process the session was taken over from:
1377    /// an in-place resume (a VS Code window reload) starts a new process on the
1378    /// same `session_id` without waiting for the old one to exit, so the old
1379    /// process's `SessionEnd` can arrive after the new one's `SessionStart`
1380    /// (#1948). Any other `end` — no pid, the owning pid, or a pid never seen
1381    /// (a wrapped hook command whose parent is a per-hook shell) — ends the
1382    /// session, so the rule can only ever keep a session the old process no
1383    /// longer owns.
1384    ///
1385    /// For the daemon's own in-process feeds, as [`observe`](Self::observe) is
1386    /// (the `Local` origin).
1387    pub fn end(&self, session_id: &str, reason: Option<&str>, pid: Option<u32>) -> bool {
1388        self.end_stamped(session_id, reason, pid, None, Origin::Local)
1389    }
1390
1391    /// [`end`](Self::end) for an event that carries an [`EventStamp`] and arrived
1392    /// by `origin` (#2108): a duplicate, or a journal `SessionEnd` older than an
1393    /// event the session has since applied (a resume), is ignored.
1394    pub(crate) fn end_stamped(
1395        &self,
1396        session_id: &str,
1397        _reason: Option<&str>,
1398        pid: Option<u32>,
1399        stamp: Option<EventStamp>,
1400        origin: Origin,
1401    ) -> bool {
1402        let now = Utc::now();
1403        let seen = seen_at(stamp.as_ref(), origin, now);
1404        let awake = self.clock.now();
1405        let (known, reaped, old_state, outcome) = {
1406            let mut sessions = self.lock_sessions();
1407            let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, awake);
1408            let old_state = sessions.get(session_id).map(|entry| entry.state);
1409            let skip = sessions
1410                .get(session_id)
1411                .and_then(|entry| entry.stamp_skip(stamp.as_ref(), origin));
1412            let mut outcome = "unknown";
1413            let known = match sessions.get_mut(session_id) {
1414                Some(_) if skip.is_some() => {
1415                    outcome = skip.map_or("skipped", Skip::outcome);
1416                    (true, false)
1417                }
1418                // Already ended (a hook and a watcher can both end it): leave the
1419                // linger window alone rather than restart it.
1420                Some(entry) if entry.state == SessionState::Ended => {
1421                    outcome = "already_ended";
1422                    (true, false)
1423                }
1424                // The replaced process's late `SessionEnd`: the resumed session
1425                // lives on under its new process.
1426                Some(entry) if pid.is_some_and(|pid| entry.replaced_pids.contains(&pid)) => {
1427                    outcome = "replaced_pid_ignored";
1428                    (true, false)
1429                }
1430                Some(entry) => {
1431                    outcome = "ended";
1432                    entry.state = SessionState::Ended;
1433                    entry.last_event = SessionEvent::Stop;
1434                    entry.last_seen = if origin.is_journal() {
1435                        entry.last_seen.max(seen)
1436                    } else {
1437                        now
1438                    };
1439                    entry.last_active = awake;
1440                    (true, true)
1441                }
1442                None => (false, false),
1443            };
1444            // A socket `end` is a delivery whether or not the registry still
1445            // holds the session. A journal `end` for a session it does not hold
1446            // applied to nothing, and would only inflate `recovered`.
1447            self.settle_delivery(
1448                &mut sessions,
1449                session_id,
1450                stamp.as_ref(),
1451                origin,
1452                skip,
1453                skip.is_none() && (origin == Origin::Socket || known.0),
1454            );
1455            (known, reaped, old_state, outcome)
1456        };
1457        let (known, flipped) = known;
1458        // A known session flipped to `ended`; otherwise only this call's inline
1459        // reap could have changed anything.
1460        let bumped = flipped || reaped > 0;
1461        if bumped {
1462            self.bump();
1463        }
1464        tracing::debug!(
1465            session_id,
1466            ?pid,
1467            ?old_state,
1468            outcome,
1469            ?origin,
1470            reaped,
1471            bumped,
1472            "session_end"
1473        );
1474        known
1475    }
1476
1477    /// A cheap, lock-scoped snapshot for the [`pid_watcher`](pid_watcher): one
1478    /// [`PidCandidate`] per live, non-`ended` session that carries a pid — the
1479    /// watcher does every I/O-bearing liveness check off this lock, on its own
1480    /// schedule, so this method itself touches no process and does no I/O.
1481    pub(crate) fn pid_liveness_candidates(&self) -> Vec<PidCandidate> {
1482        self.lock_sessions()
1483            .values()
1484            .filter(|e| e.state != SessionState::Ended)
1485            .filter_map(|e| {
1486                Some(PidCandidate {
1487                    session_id: e.session_id.clone(),
1488                    pid: e.pid?,
1489                    pid_start: e.pid_start.clone(),
1490                    prompted: e.prompted,
1491                    streamed: e.streamed,
1492                    last_seen: e.last_seen,
1493                })
1494            })
1495            .collect()
1496    }
1497
1498    /// Records that the [`pid_watcher`](pid_watcher) has independently
1499    /// confirmed `session_id`'s pid alive: refreshes `last_seen` so
1500    /// [`reap_sessions`] never sees it go stale, and captures `pid_start` when
1501    /// the entry does not already have one (the watcher's first confirmation of
1502    /// a given owner pid). A no-op for an unknown or already-`ended` session —
1503    /// the watcher's snapshot can be one tick stale by the time it applies a
1504    /// decision. Never bumps: this only refreshes fields nothing renders,
1505    /// exactly like a window's unchanged heartbeat refresh.
1506    pub(crate) fn confirm_pid_liveness(
1507        &self,
1508        session_id: &str,
1509        now: DateTime<Utc>,
1510        pid_start: &str,
1511    ) {
1512        let awake = self.clock.now();
1513        let mut sessions = self.lock_sessions();
1514        if let Some(entry) = sessions.get_mut(session_id) {
1515            if entry.state != SessionState::Ended {
1516                entry.last_seen = now;
1517                entry.last_active = awake;
1518                if entry.pid_start.is_none() {
1519                    entry.pid_start = Some(pid_start.to_string());
1520                }
1521            }
1522        }
1523    }
1524
1525    /// Records (upserts) a companion window-embedding report and refreshes its
1526    /// liveness. Reaps stale windows first, then caps like [`observe`](Self::observe).
1527    ///
1528    /// [`bump`](Self::bump)s only when the report changes the [`Source`] join a
1529    /// consumer renders — a new window, different `folders`, or an embedding that
1530    /// appeared or vanished — never on the unchanged ~10 s refresh every open
1531    /// window sends, which would otherwise put a permanent push floor under the
1532    /// daemon proportional to the window count.
1533    pub fn report_window(&self, report: WindowReport) {
1534        let window_key = report.key.clone();
1535        let folder_count = report.folders.len();
1536        let now = Utc::now();
1537        let awake = self.clock.now();
1538        let (changed, outcome, reaped) = {
1539            let mut windows = self.lock_windows();
1540            let reaped = reap_windows(&mut windows, self.window_ttl, awake);
1541            let outcome = if windows.contains_key(&report.key) {
1542                "refresh"
1543            } else {
1544                "registered"
1545            };
1546            let (mutated, registered_at) = if let Some(previous) = windows.get(&report.key) {
1547                (
1548                    previous.report.folders != report.folders
1549                        || previous.report.has_embedding() != report.has_embedding(),
1550                    previous.registered_at,
1551                )
1552            } else {
1553                if windows.len() >= MAX_WINDOWS {
1554                    evict_oldest_window(&mut windows);
1555                }
1556                (true, now)
1557            };
1558            windows.insert(
1559                report.key.clone(),
1560                WindowEntry {
1561                    report,
1562                    last_active: awake,
1563                    registered_at,
1564                },
1565            );
1566            (
1567                mutated || reaped > 0,
1568                if outcome == "refresh" && mutated {
1569                    "embedding_changed"
1570                } else {
1571                    outcome
1572                },
1573                reaped,
1574            )
1575        };
1576        if changed {
1577            self.bump();
1578        }
1579        tracing::debug!(%window_key, folder_count, outcome, reaped, bumped = changed, "session_window_reported");
1580    }
1581
1582    /// Drops a companion window-embedding report (the window closed). Returns
1583    /// whether an entry was present.
1584    pub fn unregister_window(&self, key: &str) -> bool {
1585        let removed = {
1586            let mut windows = self.lock_windows();
1587            windows.remove(key).is_some()
1588        };
1589        if removed {
1590            self.bump();
1591        }
1592        tracing::debug!(
1593            window_key = key,
1594            removed,
1595            bumped = removed,
1596            "session_window_unregistered"
1597        );
1598        removed
1599    }
1600
1601    /// Pauses the window-report liveness clock for `outage`: advances every live
1602    /// report's `last_active` by that long, never past now (#2111).
1603    ///
1604    /// For a stretch when the daemon could not accept connections, so no window's
1605    /// ~10 s refresh could arrive. Without this the first `window` op after the
1606    /// outage reaps every other report that has been quiet more than the 30 s TTL,
1607    /// and the sessions in those windows flip from `vscode` to `terminal` until each
1608    /// window reports again — the same failure
1609    /// [`WorktreesRegistry::credit_outage`] fixes for the window registry.
1610    ///
1611    /// `outage` is measured on a monotonic [`Instant`](std::time::Instant), the
1612    /// same awake time as the [`AwakeClock`] the reports are stamped on, so the
1613    /// two compose: the credit shifts a stamp by time the daemon was awake but
1614    /// deaf, and a system sleep still ages nothing (#2108).
1615    ///
1616    /// Only window reports are credited. A session's own `last_active` is not: a
1617    /// live one has a five-minute TTL that an outage rarely approaches, and an
1618    /// ended one's short linger is not worth stretching. Not a visible change, so
1619    /// it does not bump the change-notify.
1620    ///
1621    /// [`WorktreesRegistry::credit_outage`]: crate::worktrees::WorktreesRegistry::credit_outage
1622    pub fn credit_window_outage(&self, outage: Duration) {
1623        let awake = self.clock.now();
1624        for entry in self.lock_windows().values_mut() {
1625            // Saturate rather than overflow on an absurd credit.
1626            entry.last_active = entry
1627                .last_active
1628                .checked_add(outage)
1629                .map_or(awake, |credited| credited.min(awake));
1630        }
1631    }
1632
1633    /// Reaps stale sessions and windows, then returns the live sessions with
1634    /// each [`Source`] resolved and sorted for deterministic output.
1635    ///
1636    /// Two independent locks, each held only for pure-CPU work and never
1637    /// nested: the sessions snapshot is taken and the lock dropped, then the
1638    /// windows snapshot, then the join runs lock-free. Path matching is a pure
1639    /// prefix compare (no canonicalization / disk I/O), honouring the
1640    /// `Mutex`-never-across-`.await` and no-I/O-under-lock invariants.
1641    ///
1642    /// Deliberately does **not** [`bump`](Self::bump), even when its inline reap
1643    /// drops an entry: this is the body of every subscription's `snapshot()`, so
1644    /// bumping here would feed the stream loop back into itself. A read-path reap
1645    /// reaches other subscribers on the server's next periodic re-sample, whose
1646    /// diff sees the shrunken list — the [`WorktreesRegistry::list`] arrangement.
1647    ///
1648    /// [`WorktreesRegistry::list`]: crate::worktrees::WorktreesRegistry::list
1649    pub fn list(&self) -> Vec<SessionEntry> {
1650        let awake = self.clock.now();
1651        let mut sessions: Vec<SessionEntry> = {
1652            let mut guard = self.lock_sessions();
1653            reap_sessions(&mut guard, self.session_ttl, self.ended_ttl, awake);
1654            guard.values().cloned().collect()
1655        };
1656        let windows: Vec<WindowEntry> = {
1657            let mut guard = self.lock_windows();
1658            reap_windows(&mut guard, self.window_ttl, awake);
1659            guard
1660                .values()
1661                .filter(|e| e.report.has_embedding())
1662                .cloned()
1663                .collect()
1664        };
1665        for session in &mut sessions {
1666            session.source = resolve_source(session.cwd.as_deref(), &windows);
1667        }
1668        sessions.sort_by(|a, b| {
1669            a.repo
1670                .cmp(&b.repo)
1671                .then_with(|| a.session_id.cmp(&b.session_id))
1672        });
1673        sessions
1674    }
1675
1676    /// The first workspace folder of the still-live window a session is embedded
1677    /// in, if any — used by the tray "focus" action to resolve a session to a
1678    /// folder to open in VS Code. `None` when the session has no `cwd`, or is not
1679    /// matched to a reporting window with a folder.
1680    pub fn focus_folder(&self, session_id: &str) -> Option<PathBuf> {
1681        let cwd = {
1682            let sessions = self.lock_sessions();
1683            sessions.get(session_id).and_then(|e| e.cwd.clone())
1684        }?;
1685        let awake = self.clock.now();
1686        let mut windows = self.lock_windows();
1687        reap_windows(&mut windows, self.window_ttl, awake);
1688        pick_window(&cwd, windows.values().filter(|e| e.report.has_embedding()))
1689            .and_then(|w| w.folders.first().cloned())
1690    }
1691}
1692
1693impl Default for SessionsRegistry {
1694    fn default() -> Self {
1695        Self::new()
1696    }
1697}
1698
1699/// Records which agent process owns `entry` from a sighting's `pid` (#1948),
1700/// moving `pid_start` (#1916) in lockstep so the two are never out of sync.
1701///
1702/// A sighting from a pid other than the owner is a new process resuming the
1703/// session in place (every hook and the `claude-wrap` stream report the agent's
1704/// pid), so it takes ownership — along with whatever start-time token came with
1705/// it, which may be `None` if that sighting couldn't determine one — and the
1706/// old owner joins `replaced_pids`, whose late `end` is ignored. A replaced pid
1707/// never becomes the owner again, so a straggling hook from the old process
1708/// cannot take the session back. Not a rendered field, so it never decides a
1709/// [`bump`](SessionsRegistry::bump).
1710///
1711/// `observe` already filters out a sighting whose pid is in `replaced_pids`
1712/// before calling this, so the only two cases reaching here are the current
1713/// owner (a no-op) and a genuinely new one.
1714///
1715/// A new owner's `pid_start` (#1916) resets to `None`: the previous owner's
1716/// token describes a different process, and the [`pid_watcher`] captures the
1717/// new owner's own token independently the next time it confirms this pid
1718/// alive, rather than trusting anything client-supplied.
1719fn track_pid(entry: &mut SessionEntry, pid: Option<u32>) {
1720    let Some(pid) = pid else {
1721        return;
1722    };
1723    if entry.pid == Some(pid) {
1724        return;
1725    }
1726    if let Some(owner) = entry.pid.replace(pid) {
1727        if entry.replaced_pids.len() >= MAX_REPLACED_PIDS {
1728            entry.replaced_pids.pop_front();
1729        }
1730        entry.replaced_pids.push_back(owner);
1731        // A different process now owns the session, and whether *it* is wrapped
1732        // is learned from its own next stream report (a terminal `--resume` of
1733        // a chat the extension once ran is not).
1734        entry.streamed = false;
1735    }
1736    entry.pid_start = None;
1737}
1738
1739/// Fills `slot` from `incoming` only when `incoming` carries a value, so a
1740/// best-effort field never overwrites known data with `None` on a re-`observe`.
1741/// Returns whether the stored value actually changed, which is what decides
1742/// whether the sighting is worth a [`bump`](SessionsRegistry::bump) — a hook
1743/// re-sending the same `cwd` it sent last time is not.
1744fn fill<T: PartialEq>(slot: &mut Option<T>, incoming: Option<T>) -> bool {
1745    match incoming {
1746        Some(value) if slot.as_ref() != Some(&value) => {
1747            *slot = Some(value);
1748            true
1749        }
1750        _ => false,
1751    }
1752}
1753
1754/// Resolves a session's [`Source`] by joining its `cwd` against the live
1755/// window-embedding reports.
1756///
1757/// Among the windows whose folder is a prefix of `cwd`, [`pick_window`] chooses
1758/// the winner. A session with no `cwd`, or no matching window, is
1759/// [`Source::Terminal`].
1760fn resolve_source(cwd: Option<&Path>, windows: &[WindowEntry]) -> Source {
1761    let Some(cwd) = cwd else {
1762        tracing::trace!(outcome = "missing_cwd", "session_attribution_miss");
1763        return Source::Terminal;
1764    };
1765    if let Some(window) = pick_window(cwd, windows) {
1766        Source::VsCode {
1767            window_key: window.key.clone(),
1768        }
1769    } else {
1770        tracing::debug!(cwd = %cwd.display(), window_count = windows.len(), "session_attribution_miss");
1771        for window in windows {
1772            tracing::trace!(window_key = %window.report.key, folders = ?window.report.folders, "session_attribution_candidate");
1773        }
1774        Source::Terminal
1775    }
1776}
1777
1778/// Picks the window a session at `cwd` is attributed to, among the windows with
1779/// a folder that is a prefix of it: the longest matching folder (the most
1780/// specific root, so a later-opened parent-folder window does not outrank the
1781/// window the session actually runs in), then the most recently registered,
1782/// then the lowest key (#1451).
1783///
1784/// A window reload registers a fresh companion-generated key while the old
1785/// registration can live on for up to the window TTL, so the newest
1786/// registration is the live window. Ranking by key alone — the earlier rule —
1787/// picked whichever UUID sorted lowest. `registered_at` rather than
1788/// `last_seen`, because every open window refreshes `last_seen` on its own
1789/// heartbeat, so two live windows on one folder would alternate. The key tail
1790/// only keeps equal stamps deterministic. Shared by [`resolve_source`] and
1791/// [`SessionsRegistry::focus_folder`] so the tray focuses the window the tree
1792/// attributes the session to.
1793fn pick_window<'a>(
1794    cwd: &Path,
1795    windows: impl IntoIterator<Item = &'a WindowEntry>,
1796) -> Option<&'a WindowReport> {
1797    windows
1798        .into_iter()
1799        .filter_map(|e| Some((e, match_depth(cwd, &e.report.folders)?)))
1800        .min_by(|(a, a_depth), (b, b_depth)| {
1801            b_depth
1802                .cmp(a_depth)
1803                .then_with(|| b.registered_at.cmp(&a.registered_at))
1804                .then_with(|| a.report.key.cmp(&b.report.key))
1805        })
1806        .map(|(e, _)| &e.report)
1807}
1808
1809/// The component count of the longest of `folders` that is a prefix of `cwd`,
1810/// or `None` when none is.
1811fn match_depth(cwd: &Path, folders: &[PathBuf]) -> Option<usize> {
1812    folders
1813        .iter()
1814        .filter(|f| cwd.starts_with(f))
1815        .map(|f| f.components().count())
1816        .max()
1817}
1818
1819/// Whether `req` is a Claude session's authoritative stream-wrapper report —
1820/// the signal [`SessionEntry::streamed`] latches on. Codex's wrapper also emits
1821/// `StreamState`, but it polls an app-server it owns and re-asserts every poll,
1822/// and pi's extension carries no pid, so neither is affected.
1823fn is_claude_stream_report(req: &ObserveRequest) -> bool {
1824    req.agent == Agent::Claude && matches!(req.event, SessionEvent::StreamState(_))
1825}
1826
1827/// Removes sessions last active longer than their TTL ago on the awake clock
1828/// (a shorter [`ended_ttl`](SessionsRegistry::ended_ttl) for `ended` sessions),
1829/// returning how many were dropped. `awake` is the registry's [`AwakeClock`]
1830/// reading, so time spent asleep never counts (#2108). Pure CPU; the caller
1831/// holds the sessions lock but never `.await`s under it.
1832///
1833/// This TTL is the fallback for a session the [`pid_watcher`] cannot vouch for
1834/// (no pid, an unconfirmed pid, or one that has never been prompted) — see
1835/// [`DEFAULT_SESSION_TTL`]. A pid the watcher has confirmed alive keeps this
1836/// function from ever seeing the entry go stale by refreshing `last_active`
1837/// itself, so no pid-specific logic belongs here: every liveness decision that
1838/// needs to inspect a process happens off this lock, in the watcher.
1839fn reap_sessions(
1840    sessions: &mut HashMap<String, SessionEntry>,
1841    session_ttl: Duration,
1842    ended_ttl: Duration,
1843    awake: Duration,
1844) -> usize {
1845    let before = sessions.len();
1846    sessions.retain(|_, e| {
1847        let max_age = if e.state == SessionState::Ended {
1848            ended_ttl
1849        } else {
1850            session_ttl
1851        };
1852        let keep = awake.saturating_sub(e.last_active) <= max_age;
1853        if !keep {
1854            tracing::trace!(session_id = %e.session_id, reason = if e.state == SessionState::Ended { "ended_ttl" } else { "session_ttl" }, "session_reaped");
1855        }
1856        keep
1857    });
1858    let count = before - sessions.len();
1859    if count > 0 {
1860        tracing::debug!(count, "sessions_reaped");
1861    }
1862    count
1863}
1864
1865/// Removes window-embedding reports last refreshed longer than `ttl` ago on the
1866/// awake clock.
1867fn reap_windows(
1868    windows: &mut HashMap<String, WindowEntry>,
1869    ttl: Duration,
1870    awake: Duration,
1871) -> usize {
1872    let before = windows.len();
1873    windows.retain(|key, e| {
1874        let keep = awake.saturating_sub(e.last_active) <= ttl;
1875        if !keep {
1876            tracing::trace!(window_key = %key, reason = "window_ttl", "session_window_reaped");
1877        }
1878        keep
1879    });
1880    let count = before - windows.len();
1881    if count > 0 {
1882        tracing::debug!(count, "session_windows_reaped");
1883    }
1884    count
1885}
1886
1887/// Removes the session with the oldest `last_active` (ties broken by lowest
1888/// `session_id` for determinism). Called when a new session would exceed
1889/// [`MAX_SESSIONS`].
1890fn evict_oldest_session(sessions: &mut HashMap<String, SessionEntry>) {
1891    let oldest = sessions
1892        .values()
1893        .min_by(|a, b| {
1894            a.last_active
1895                .cmp(&b.last_active)
1896                .then_with(|| a.session_id.cmp(&b.session_id))
1897        })
1898        .map(|e| e.session_id.clone());
1899    if let Some(key) = oldest {
1900        sessions.remove(&key);
1901        tracing::debug!(session_id = %key, reason = "capacity", "session_evicted");
1902    }
1903}
1904
1905/// Removes the window report with the oldest `last_active` (ties broken by lowest
1906/// key). Called when a new window would exceed [`MAX_WINDOWS`].
1907fn evict_oldest_window(windows: &mut HashMap<String, WindowEntry>) {
1908    let oldest = windows
1909        .iter()
1910        .min_by(|a, b| {
1911            a.1.last_active
1912                .cmp(&b.1.last_active)
1913                .then_with(|| a.0.cmp(b.0))
1914        })
1915        .map(|(k, _)| k.clone());
1916    if let Some(key) = oldest {
1917        windows.remove(&key);
1918        tracing::debug!(window_key = %key, reason = "capacity", "session_window_evicted");
1919    }
1920}
1921
1922#[cfg(test)]
1923#[allow(clippy::unwrap_used, clippy::expect_used)]
1924mod tests {
1925    use super::*;
1926
1927    #[test]
1928    fn registry_diagnostics_distinguish_transition_heartbeat_and_unknown_end() {
1929        let registry = SessionsRegistry::new();
1930        let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
1931            registry.observe(observe_request(
1932                "diagnostics",
1933                SessionEvent::UserPromptSubmit,
1934                Some("/project"),
1935            ));
1936            registry.observe(observe_request(
1937                "diagnostics",
1938                SessionEvent::UserPromptSubmit,
1939                Some("/project"),
1940            ));
1941            registry.end("missing", None, None);
1942            registry.end("diagnostics", None, None);
1943        });
1944        assert!(logs.contains("state_changed") || logs.contains("created"));
1945        assert!(logs.contains("heartbeat_only"));
1946        assert!(logs.contains("unknown"));
1947        assert!(logs.contains("outcome=\"ended\""), "{logs}");
1948        assert!(logs.contains("bumped=true"));
1949        assert!(logs.contains("bumped=false"));
1950    }
1951
1952    #[test]
1953    fn attribution_misses_are_logged_with_each_candidate_window() {
1954        let windows = vec![window_entry("w1", 20), window_entry("w2", 5)];
1955        let logs = crate::test_support::capture_at(tracing::Level::TRACE, || {
1956            assert_eq!(
1957                resolve_source(Some(Path::new("/elsewhere/x")), &windows),
1958                Source::Terminal
1959            );
1960            assert_eq!(resolve_source(None, &windows), Source::Terminal);
1961        });
1962        assert!(logs.contains("session_attribution_miss"), "{logs}");
1963        assert!(logs.contains("missing_cwd"), "{logs}");
1964        assert_eq!(
1965            logs.matches("session_attribution_candidate").count(),
1966            2,
1967            "{logs}"
1968        );
1969        assert!(
1970            logs.contains("window_key=w1") && logs.contains("window_key=w2"),
1971            "{logs}"
1972        );
1973    }
1974
1975    fn observe_request(session_id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
1976        ObserveRequest {
1977            agent_id: None,
1978            pid: None,
1979            agent: Agent::Claude,
1980            session_id: session_id.to_string(),
1981            cwd: cwd.map(PathBuf::from),
1982            transcript_path: None,
1983            event,
1984            repo: None,
1985            model: None,
1986        }
1987    }
1988
1989    #[test]
1990    fn list_is_empty_initially() {
1991        let reg = SessionsRegistry::new();
1992        assert!(reg.list().is_empty());
1993    }
1994
1995    #[test]
1996    fn observe_then_list_round_trips_and_infers_state() {
1997        let reg = SessionsRegistry::new();
1998        reg.observe(observe_request(
1999            "s1",
2000            SessionEvent::SessionStart,
2001            Some("/tmp/a"),
2002        ));
2003        let sessions = reg.list();
2004        assert_eq!(sessions.len(), 1);
2005        assert_eq!(sessions[0].session_id, "s1");
2006        assert_eq!(sessions[0].state, SessionState::Starting);
2007        // No window reports → a bare terminal session.
2008        assert_eq!(sessions[0].source, Source::Terminal);
2009    }
2010
2011    #[test]
2012    fn observe_is_idempotent_upsert_advancing_state() {
2013        let reg = SessionsRegistry::new();
2014        reg.observe(observe_request(
2015            "s1",
2016            SessionEvent::SessionStart,
2017            Some("/tmp/a"),
2018        ));
2019        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
2020        let sessions = reg.list();
2021        assert_eq!(sessions.len(), 1, "same session_id upserts, not duplicates");
2022        assert_eq!(sessions[0].state, SessionState::Working);
2023        // The later `observe` had no cwd, but the known one is preserved.
2024        assert_eq!(sessions[0].cwd.as_deref(), Some(Path::new("/tmp/a")));
2025    }
2026
2027    #[test]
2028    fn state_machine_covers_every_event() {
2029        use NotificationKind::*;
2030        use SessionEvent::*;
2031        let cases = [
2032            (SessionStart, SessionState::Starting),
2033            (UserPromptSubmit, SessionState::Working),
2034            (PreToolUse, SessionState::Working),
2035            (PostToolUse, SessionState::Working),
2036            (Stop, SessionState::Idle),
2037            (
2038                Notification(PermissionPrompt),
2039                SessionState::WaitingForPermission,
2040            ),
2041            (Notification(IdlePrompt), SessionState::WaitingForInput),
2042            (Notification(AgentNeedsInput), SessionState::WaitingForInput),
2043            (TranscriptGrew, SessionState::Working),
2044            (TranscriptDiscovered, SessionState::Idle),
2045            // A rollout's last turn marker, on a session not seen before (#2135).
2046            (RolloutTurn(super::RolloutTurn::Open), SessionState::Working),
2047            (RolloutTurn(super::RolloutTurn::Closed), SessionState::Idle),
2048            // An authoritative stream-json report is returned verbatim.
2049            (
2050                StreamState(SessionState::WaitingForPermission),
2051                SessionState::WaitingForPermission,
2052            ),
2053            (StreamState(SessionState::Idle), SessionState::Idle),
2054        ];
2055        for (event, expected) in cases {
2056            assert_eq!(
2057                SessionState::for_event(&event, None),
2058                expected,
2059                "event {event:?}"
2060            );
2061        }
2062        // An unclassified notification keeps the current state.
2063        assert_eq!(
2064            SessionState::for_event(&Notification(Other), Some(SessionState::Working)),
2065            SessionState::Working
2066        );
2067        // TranscriptDiscovered on a known session keeps its state.
2068        assert_eq!(
2069            SessionState::for_event(&TranscriptDiscovered, Some(SessionState::Working)),
2070            SessionState::Working
2071        );
2072        // An authoritative StreamState overrides any current state — it read the
2073        // exact state from the stream rather than inferring it.
2074        assert_eq!(
2075            SessionState::for_event(
2076                &StreamState(SessionState::Idle),
2077                Some(SessionState::Working)
2078            ),
2079            SessionState::Idle
2080        );
2081        // Growth is expected while a session waits on the user (the transcript
2082        // grows before the prompt is answered) and around `SessionEnd` (the
2083        // final lines land as it exits), so in neither case is it evidence the
2084        // turn is running: the directly reported state stands (#1418). A
2085        // resumed session's old process writes to the shared transcript as it
2086        // exits, after the new one's `SessionStart` (#1946).
2087        for held in [
2088            SessionState::Starting,
2089            SessionState::WaitingForInput,
2090            SessionState::WaitingForPermission,
2091            SessionState::Ended,
2092        ] {
2093            assert_eq!(
2094                SessionState::for_event(&TranscriptGrew, Some(held)),
2095                held,
2096                "growth must not overwrite {held:?}"
2097            );
2098        }
2099        // From every other state growth still means working, as does growth on
2100        // a session whose state is not yet known (covered by the table above).
2101        for other in [SessionState::Working, SessionState::Idle] {
2102            assert_eq!(
2103                SessionState::for_event(&TranscriptGrew, Some(other)),
2104                SessionState::Working,
2105                "growth from {other:?}"
2106            );
2107        }
2108    }
2109
2110    #[test]
2111    fn end_marks_ended_and_reaps_quickly() {
2112        let reg = SessionsRegistry::new();
2113        reg.observe(observe_request(
2114            "s1",
2115            SessionEvent::PreToolUse,
2116            Some("/tmp/a"),
2117        ));
2118        assert!(reg.end("s1", Some("clear"), None));
2119        // Ending an unknown session is a no-op.
2120        assert!(!reg.end("ghost", None, None));
2121        let sessions = reg.list();
2122        assert_eq!(sessions.len(), 1);
2123        assert_eq!(sessions[0].state, SessionState::Ended);
2124        // Let the ended entry sit awake past the short ended TTL: it reaps out.
2125        reg.clock.advance(Duration::from_secs(30));
2126        assert!(reg.list().is_empty(), "ended entry reaps after ended TTL");
2127    }
2128
2129    // --- pid-based liveness (#1916): the registry side the pid_watcher drives ---
2130
2131    #[test]
2132    fn pid_liveness_candidates_lists_only_non_ended_pid_bearing_sessions() {
2133        let reg = SessionsRegistry::new();
2134        reg.observe(observe_from(
2135            "with_pid",
2136            SessionEvent::UserPromptSubmit,
2137            100,
2138        ));
2139        reg.observe(observe_request(
2140            "no_pid",
2141            SessionEvent::UserPromptSubmit,
2142            None,
2143        ));
2144        reg.observe(observe_from("ended", SessionEvent::UserPromptSubmit, 200));
2145        reg.end("ended", None, Some(200));
2146
2147        let candidates = reg.pid_liveness_candidates();
2148        assert_eq!(candidates.len(), 1);
2149        assert_eq!(candidates[0].session_id, "with_pid");
2150        assert_eq!(candidates[0].pid, 100);
2151        assert!(candidates[0].prompted);
2152        assert_eq!(candidates[0].pid_start, None);
2153    }
2154
2155    #[test]
2156    fn only_a_claude_stream_report_marks_a_session_streamed() {
2157        // Hooks alone never mark it, even for a prompted session.
2158        let reg = SessionsRegistry::new();
2159        reg.observe(observe_from("hooked", SessionEvent::UserPromptSubmit, 100));
2160        let candidates = reg.pid_liveness_candidates();
2161        assert!(candidates[0].prompted);
2162        assert!(!candidates[0].streamed);
2163
2164        // A Claude stream report does, whether it created the entry or arrived
2165        // later, and it is sticky across the hooks that follow.
2166        reg.observe(observe_from(
2167            "hooked",
2168            SessionEvent::StreamState(SessionState::Idle),
2169            100,
2170        ));
2171        reg.observe(observe_from("hooked", SessionEvent::Stop, 100));
2172        let candidates = reg.pid_liveness_candidates();
2173        assert!(candidates[0].prompted && candidates[0].streamed);
2174
2175        let reg = SessionsRegistry::new();
2176        reg.observe(observe_from(
2177            "wrapped",
2178            SessionEvent::StreamState(SessionState::Working),
2179            100,
2180        ));
2181        assert!(reg.pid_liveness_candidates()[0].streamed);
2182
2183        // A different process taking the session over (a terminal `--resume` of
2184        // a chat the extension once ran) is not known to be wrapped.
2185        let reg = SessionsRegistry::new();
2186        reg.observe(observe_from(
2187            "resumed",
2188            SessionEvent::StreamState(SessionState::Idle),
2189            100,
2190        ));
2191        assert!(reg.pid_liveness_candidates()[0].streamed);
2192        reg.observe(observe_from("resumed", SessionEvent::SessionStart, 200));
2193        assert!(!reg.pid_liveness_candidates()[0].streamed);
2194
2195        // Another agent's `StreamState` (Codex's wrapper, pi's extension) is not
2196        // a Claude chat the extension is pinning.
2197        for agent in [Agent::Codex, Agent::Pi] {
2198            let reg = SessionsRegistry::new();
2199            reg.observe(ObserveRequest {
2200                agent,
2201                ..observe_from("other", SessionEvent::StreamState(SessionState::Idle), 100)
2202            });
2203            assert!(!reg.pid_liveness_candidates()[0].streamed, "{agent:?}");
2204        }
2205    }
2206
2207    #[test]
2208    fn confirm_pid_liveness_refreshes_last_seen_and_captures_the_token_once() {
2209        let reg = SessionsRegistry::new();
2210        reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
2211        let old = Utc::now() - chrono::Duration::seconds(1000);
2212        let old_active = reg.lock_sessions()["s1"].last_active;
2213        {
2214            let mut guard = reg.lock_sessions();
2215            guard.get_mut("s1").unwrap().last_seen = old;
2216        }
2217        reg.clock.advance(Duration::from_secs(1000));
2218
2219        reg.confirm_pid_liveness("s1", Utc::now(), "tok-1");
2220        {
2221            let guard = reg.lock_sessions();
2222            let entry = &guard["s1"];
2223            assert!(entry.last_seen > old, "last_seen should be refreshed");
2224            assert!(
2225                entry.last_active > old_active + Duration::from_secs(999),
2226                "the awake stamp the TTL reads should be refreshed too"
2227            );
2228            assert_eq!(entry.pid_start.as_deref(), Some("tok-1"));
2229        }
2230
2231        // A later confirmation does not overwrite an already-captured token —
2232        // it is fixed at the pid's first independently-confirmed sighting.
2233        reg.confirm_pid_liveness("s1", Utc::now(), "tok-2");
2234        assert_eq!(
2235            reg.lock_sessions()["s1"].pid_start.as_deref(),
2236            Some("tok-1")
2237        );
2238    }
2239
2240    #[test]
2241    fn confirm_pid_liveness_is_a_noop_for_an_unknown_or_ended_session() {
2242        let reg = SessionsRegistry::new();
2243        // Unknown session: no panic.
2244        reg.confirm_pid_liveness("ghost", Utc::now(), "tok");
2245
2246        reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
2247        reg.end("s1", None, Some(100));
2248        let before = reg.lock_sessions()["s1"].last_seen;
2249        let before_active = reg.lock_sessions()["s1"].last_active;
2250        reg.clock.advance(Duration::from_secs(1));
2251        reg.confirm_pid_liveness("s1", Utc::now(), "tok");
2252        assert_eq!(
2253            reg.lock_sessions()["s1"].last_seen,
2254            before,
2255            "an ended session must not be revived by a liveness confirmation"
2256        );
2257        assert_eq!(reg.lock_sessions()["s1"].last_active, before_active);
2258    }
2259
2260    #[test]
2261    fn stale_working_session_reaps_but_recent_survives() {
2262        let reg = SessionsRegistry::new();
2263        reg.observe(observe_request("stale", SessionEvent::PreToolUse, None));
2264        reg.clock.advance(Duration::from_secs(1000));
2265        reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
2266        let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
2267        assert_eq!(ids, vec!["fresh".to_string()]);
2268    }
2269
2270    #[test]
2271    fn source_is_vscode_when_cwd_is_under_a_reporting_window() {
2272        let reg = SessionsRegistry::new();
2273        reg.observe(observe_request(
2274            "s1",
2275            SessionEvent::PreToolUse,
2276            Some("/home/me/proj/sub"),
2277        ));
2278        // A window reporting a Claude tab whose folder is a prefix of the cwd.
2279        reg.report_window(WindowReport {
2280            key: "w1".to_string(),
2281            folders: vec![PathBuf::from("/home/me/proj")],
2282            tabs: 1,
2283            terminals: 0,
2284        });
2285        let sessions = reg.list();
2286        assert_eq!(
2287            sessions[0].source,
2288            Source::VsCode {
2289                window_key: "w1".to_string()
2290            }
2291        );
2292    }
2293
2294    #[test]
2295    fn source_is_terminal_when_window_has_no_embedding() {
2296        let reg = SessionsRegistry::new();
2297        reg.observe(observe_request(
2298            "s1",
2299            SessionEvent::PreToolUse,
2300            Some("/home/me/proj"),
2301        ));
2302        // A window is open on the folder but has no Claude tab/terminal.
2303        reg.report_window(WindowReport {
2304            key: "w1".to_string(),
2305            folders: vec![PathBuf::from("/home/me/proj")],
2306            tabs: 0,
2307            terminals: 0,
2308        });
2309        assert_eq!(reg.list()[0].source, Source::Terminal);
2310    }
2311
2312    #[test]
2313    fn window_report_is_upsert_and_unregister_removes() {
2314        let reg = SessionsRegistry::new();
2315        reg.report_window(WindowReport {
2316            key: "w1".to_string(),
2317            folders: vec![PathBuf::from("/p")],
2318            tabs: 1,
2319            terminals: 0,
2320        });
2321        // Upsert (same key) does not duplicate.
2322        reg.report_window(WindowReport {
2323            key: "w1".to_string(),
2324            folders: vec![PathBuf::from("/p")],
2325            tabs: 2,
2326            terminals: 1,
2327        });
2328        assert!(reg.unregister_window("w1"));
2329        assert!(!reg.unregister_window("w1"));
2330    }
2331
2332    #[test]
2333    fn stale_window_stops_tagging_source() {
2334        let reg = SessionsRegistry::new();
2335        reg.observe(observe_request(
2336            "s1",
2337            SessionEvent::PreToolUse,
2338            Some("/p/sub"),
2339        ));
2340        reg.report_window(WindowReport {
2341            key: "w1".to_string(),
2342            folders: vec![PathBuf::from("/p")],
2343            tabs: 1,
2344            terminals: 0,
2345        });
2346        // Let the window report sit awake past the window TTL.
2347        reg.clock.advance(Duration::from_secs(120));
2348        assert_eq!(reg.list()[0].source, Source::Terminal);
2349    }
2350
2351    #[test]
2352    fn credit_window_outage_keeps_a_report_the_outage_alone_would_have_expired() {
2353        let reg = SessionsRegistry::new();
2354        let report = |key: &str| WindowReport {
2355            key: key.to_string(),
2356            folders: vec![PathBuf::from("/p")],
2357            tabs: 1,
2358            terminals: 0,
2359        };
2360        reg.report_window(report("kept"));
2361        reg.report_window(report("long-gone"));
2362        // Room on the awake clock to back-date a stamp by minutes.
2363        reg.clock.advance(Duration::from_secs(1000));
2364        {
2365            let awake = reg.clock.now();
2366            let mut guard = reg.lock_windows();
2367            // 40 s silent is past the 30 s TTL, but 25 s of it was the outage.
2368            guard.get_mut("kept").unwrap().last_active =
2369                awake.saturating_sub(Duration::from_secs(40));
2370            guard.get_mut("long-gone").unwrap().last_active =
2371                awake.saturating_sub(Duration::from_secs(300));
2372        }
2373        // Any read reaps; the already-dead report goes before the credit lands.
2374        reg.credit_window_outage(Duration::from_secs(25));
2375        reg.list();
2376        let guard = reg.lock_windows();
2377        assert!(guard.contains_key("kept"), "the outage must not age it out");
2378        assert!(
2379            !guard.contains_key("long-gone"),
2380            "a report 300 s silent is dead however long the outage was"
2381        );
2382    }
2383
2384    #[test]
2385    fn credit_window_outage_never_moves_last_active_into_the_future() {
2386        let reg = SessionsRegistry::new();
2387        reg.report_window(WindowReport {
2388            key: "w".to_string(),
2389            folders: vec![PathBuf::from("/p")],
2390            tabs: 1,
2391            terminals: 0,
2392        });
2393        reg.credit_window_outage(Duration::from_secs(3600));
2394        assert!(reg.lock_windows()["w"].last_active <= reg.clock.now());
2395        // A credit too large to add is saturated to now, not a panic.
2396        reg.credit_window_outage(Duration::MAX);
2397        assert!(reg.lock_windows()["w"].last_active <= reg.clock.now());
2398    }
2399
2400    #[test]
2401    fn credit_window_outage_is_not_a_visible_change() {
2402        let reg = SessionsRegistry::new();
2403        reg.report_window(WindowReport {
2404            key: "w".to_string(),
2405            folders: vec![PathBuf::from("/p")],
2406            tabs: 1,
2407            terminals: 0,
2408        });
2409        let rx = reg.subscribe_changes();
2410        reg.credit_window_outage(Duration::from_secs(5));
2411        assert!(!rx.has_changed().unwrap());
2412    }
2413
2414    /// A window entry covering `/p`, registered `age_secs` ago.
2415    fn window_entry(key: &str, age_secs: i64) -> WindowEntry {
2416        let now = Utc::now();
2417        WindowEntry {
2418            report: window_report(key, "/p", true),
2419            last_active: Duration::ZERO,
2420            registered_at: now - chrono::Duration::seconds(age_secs),
2421        }
2422    }
2423
2424    fn vscode(key: &str) -> Source {
2425        Source::VsCode {
2426            window_key: key.to_string(),
2427        }
2428    }
2429
2430    #[test]
2431    fn resolve_source_prefers_newest_registration_over_key_order() {
2432        // `w1` sorts lowest but registered earlier: the newer `w2` wins (#1451).
2433        let windows = vec![window_entry("w1", 20), window_entry("w2", 5)];
2434        assert_eq!(
2435            resolve_source(Some(Path::new("/p/x")), &windows),
2436            vscode("w2")
2437        );
2438        // No cwd → terminal.
2439        assert_eq!(resolve_source(None, &windows), Source::Terminal);
2440    }
2441
2442    #[test]
2443    fn resolve_source_breaks_equal_registrations_by_lowest_key() {
2444        let now = Utc::now();
2445        let at_now = |key: &str| WindowEntry {
2446            report: window_report(key, "/p", true),
2447            last_active: Duration::ZERO,
2448            registered_at: now,
2449        };
2450        let windows = vec![at_now("w2"), at_now("w1")];
2451        assert_eq!(
2452            resolve_source(Some(Path::new("/p/x")), &windows),
2453            vscode("w1")
2454        );
2455    }
2456
2457    #[test]
2458    fn reload_overlap_attributes_to_the_new_window_even_if_its_key_sorts_higher() {
2459        // The #1451 scenario: the old registration is still live inside its
2460        // TTL (and even still heartbeating) when the reloaded window registers.
2461        let reg = SessionsRegistry::new();
2462        reg.observe(observe_request(
2463            "s1",
2464            SessionEvent::PreToolUse,
2465            Some("/p/sub"),
2466        ));
2467        reg.report_window(window_report("39-old", "/p", true));
2468        {
2469            let mut guard = reg.lock_windows();
2470            guard.get_mut("39-old").unwrap().registered_at =
2471                Utc::now() - chrono::Duration::seconds(60);
2472        }
2473        reg.report_window(window_report("52-new", "/p", true));
2474        assert_eq!(reg.list()[0].source, vscode("52-new"));
2475        // A heartbeat from the old window must not take the session back.
2476        reg.report_window(window_report("39-old", "/p", true));
2477        assert_eq!(reg.list()[0].source, vscode("52-new"));
2478    }
2479
2480    #[test]
2481    fn a_more_specific_folder_outranks_a_newer_parent_window() {
2482        let now = Utc::now();
2483        let entry = |key: &str, folder: &str, age_secs: i64| WindowEntry {
2484            report: window_report(key, folder, true),
2485            last_active: Duration::ZERO,
2486            registered_at: now - chrono::Duration::seconds(age_secs),
2487        };
2488        // `parent` registered later and has the lower key, but `/repo/sub` is the
2489        // root the session actually runs in.
2490        let windows = vec![
2491            entry("a-parent", "/repo", 1),
2492            entry("z-sub", "/repo/sub", 50),
2493        ];
2494        assert_eq!(
2495            resolve_source(Some(Path::new("/repo/sub/x")), &windows),
2496            vscode("z-sub")
2497        );
2498        // A session outside `/repo/sub` still falls to the parent window.
2499        assert_eq!(
2500            resolve_source(Some(Path::new("/repo/other")), &windows),
2501            vscode("a-parent")
2502        );
2503    }
2504
2505    #[test]
2506    fn refreshing_a_window_preserves_its_registration_time() {
2507        let reg = SessionsRegistry::new();
2508        reg.report_window(window_report("w1", "/p", true));
2509        let first = reg.lock_windows()["w1"].registered_at;
2510        let first_active = reg.lock_windows()["w1"].last_active;
2511        reg.clock.advance(Duration::from_secs(5));
2512        reg.report_window(window_report("w1", "/p", true));
2513        let guard = reg.lock_windows();
2514        assert_eq!(guard["w1"].registered_at, first);
2515        assert!(guard["w1"].last_active > first_active);
2516    }
2517
2518    #[test]
2519    fn reregistering_after_unregister_gets_a_fresh_registration_time() {
2520        let reg = SessionsRegistry::new();
2521        reg.report_window(window_report("w1", "/p", true));
2522        let first = reg.lock_windows()["w1"].registered_at;
2523        std::thread::sleep(std::time::Duration::from_millis(5));
2524        assert!(reg.unregister_window("w1"));
2525        reg.report_window(window_report("w1", "/p", true));
2526        assert!(reg.lock_windows()["w1"].registered_at > first);
2527    }
2528
2529    #[test]
2530    fn focus_folder_agrees_with_list_on_overlap() {
2531        let reg = SessionsRegistry::new();
2532        reg.observe(observe_request(
2533            "s1",
2534            SessionEvent::PreToolUse,
2535            Some("/p/sub"),
2536        ));
2537        let mut old = window_report("a-old", "/p", true);
2538        old.folders = vec![PathBuf::from("/p/old-root"), PathBuf::from("/p")];
2539        reg.report_window(old);
2540        {
2541            let mut guard = reg.lock_windows();
2542            guard.get_mut("a-old").unwrap().registered_at =
2543                Utc::now() - chrono::Duration::seconds(60);
2544        }
2545        reg.report_window(window_report("z-new", "/p", true));
2546        assert_eq!(reg.list()[0].source, vscode("z-new"));
2547        // Not the old window's first folder `/p/old-root`.
2548        assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/p")));
2549    }
2550
2551    #[test]
2552    fn focus_folder_resolves_matching_window_folder() {
2553        let reg = SessionsRegistry::new();
2554        reg.observe(observe_request(
2555            "s1",
2556            SessionEvent::PreToolUse,
2557            Some("/home/me/proj/sub"),
2558        ));
2559        assert!(reg.focus_folder("s1").is_none(), "no window yet");
2560        reg.report_window(WindowReport {
2561            key: "w1".to_string(),
2562            folders: vec![PathBuf::from("/home/me/proj")],
2563            tabs: 1,
2564            terminals: 0,
2565        });
2566        assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/home/me/proj")));
2567        // An unknown session resolves to nothing.
2568        assert!(reg.focus_folder("ghost").is_none());
2569    }
2570
2571    #[test]
2572    fn evict_oldest_session_drops_the_longest_silent() {
2573        let now = Utc::now();
2574        let mut sessions = HashMap::new();
2575        for (id, age) in [("young", 0_u64), ("old", 100), ("older", 200)] {
2576            sessions.insert(
2577                id.to_string(),
2578                SessionEntry {
2579                    subagent_base: None,
2580                    subagent_waits: HashMap::new(),
2581                    pid: None,
2582                    pid_start: None,
2583                    prompted: false,
2584                    streamed: false,
2585                    replaced_pids: VecDeque::new(),
2586                    recent_seqs: VecDeque::new(),
2587                    superseded_seqs: VecDeque::new(),
2588                    latest_stamp_ts: None,
2589                    reported: false,
2590                    agent: Agent::Claude,
2591                    session_id: id.to_string(),
2592                    cwd: None,
2593                    transcript_path: None,
2594                    repo: None,
2595                    model: None,
2596                    state: SessionState::Working,
2597                    source: Source::Terminal,
2598                    last_event: SessionEvent::PreToolUse,
2599                    started_at: now,
2600                    last_seen: now,
2601                    last_active: Duration::from_secs(1000 - age),
2602                },
2603            );
2604        }
2605        evict_oldest_session(&mut sessions);
2606        assert!(!sessions.contains_key("older"));
2607        assert!(sessions.contains_key("young"));
2608        assert!(sessions.contains_key("old"));
2609        // An empty map is a no-op, not a panic.
2610        let mut empty: HashMap<String, SessionEntry> = HashMap::new();
2611        evict_oldest_session(&mut empty);
2612        assert!(empty.is_empty());
2613    }
2614
2615    #[test]
2616    fn list_sorts_by_repo_then_session_id() {
2617        let reg = SessionsRegistry::new();
2618        for (id, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
2619            reg.observe(ObserveRequest {
2620                agent_id: None,
2621                pid: None,
2622                agent: Agent::Claude,
2623                session_id: id.to_string(),
2624                cwd: None,
2625                transcript_path: None,
2626                event: SessionEvent::PreToolUse,
2627                repo: Some(repo.to_string()),
2628                model: None,
2629            });
2630        }
2631        let ordered: Vec<(String, String)> = reg
2632            .list()
2633            .into_iter()
2634            .map(|s| (s.session_id, s.repo.unwrap()))
2635            .collect();
2636        assert_eq!(
2637            ordered,
2638            vec![
2639                ("m".to_string(), "repo-a".to_string()),
2640                ("z".to_string(), "repo-a".to_string()),
2641                ("a".to_string(), "repo-b".to_string()),
2642            ]
2643        );
2644    }
2645
2646    #[test]
2647    fn serialized_session_shapes_are_stable() {
2648        // The wire shape consumers (CLI, extension) read: snake_case state, a
2649        // tagged source, and omitted `None` fields.
2650        let reg = SessionsRegistry::new();
2651        reg.observe(ObserveRequest {
2652            agent_id: None,
2653            pid: None,
2654            agent: Agent::Claude,
2655            session_id: "s1".to_string(),
2656            cwd: Some(PathBuf::from("/p")),
2657            transcript_path: None,
2658            event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
2659            repo: Some("proj".to_string()),
2660            model: None,
2661        });
2662        let value = serde_json::to_value(&reg.list()[0]).unwrap();
2663        assert_eq!(value["state"], "waiting_for_permission");
2664        assert_eq!(value["source"]["kind"], "terminal");
2665        assert_eq!(value["repo"], "proj");
2666        // Absent optional fields are omitted, not null.
2667        assert!(value.get("model").is_none());
2668        assert!(value.get("transcript_path").is_none());
2669    }
2670
2671    #[test]
2672    fn stream_state_is_authoritative_and_round_trips() {
2673        // The `claude-wrap` wrapper reports the exact state; `observe` applies it
2674        // verbatim and overrides whatever was inferred before.
2675        let reg = SessionsRegistry::new();
2676        reg.observe(observe_request("s1", SessionEvent::PreToolUse, Some("/p")));
2677        assert_eq!(reg.list()[0].state, SessionState::Working);
2678        reg.observe(observe_request(
2679            "s1",
2680            SessionEvent::StreamState(SessionState::WaitingForPermission),
2681            None,
2682        ));
2683        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2684        // The wire shape is a nested tuple variant, matching `{"notification":…}`.
2685        let value = serde_json::to_value(SessionEvent::StreamState(
2686            SessionState::WaitingForPermission,
2687        ))
2688        .unwrap();
2689        assert_eq!(
2690            value,
2691            serde_json::json!({ "stream_state": "waiting_for_permission" })
2692        );
2693        // …and deserializes back.
2694        let event: SessionEvent = serde_json::from_value(value).unwrap();
2695        assert_eq!(
2696            event,
2697            SessionEvent::StreamState(SessionState::WaitingForPermission)
2698        );
2699    }
2700
2701    #[test]
2702    fn default_constructs_an_empty_registry() {
2703        let reg = SessionsRegistry::default();
2704        assert!(reg.list().is_empty());
2705    }
2706
2707    #[test]
2708    fn agent_is_omitted_for_claude_and_tagged_for_pi_on_the_wire() {
2709        // Claude senders stay byte-identical: no `agent` key.
2710        let claude = serde_json::to_value(observe_request("s", SessionEvent::Stop, None)).unwrap();
2711        assert!(claude.get("agent").is_none(), "{claude}");
2712        // An absent `agent` reads back as Claude.
2713        let parsed: ObserveRequest =
2714            serde_json::from_value(serde_json::json!({ "session_id": "s", "event": "stop" }))
2715                .unwrap();
2716        assert_eq!(parsed.agent, Agent::Claude);
2717        // The exact payload the pi extension sends.
2718        let pi: ObserveRequest = serde_json::from_value(serde_json::json!({
2719            "session_id": "019a0000-0000-7000-8000-000000000000",
2720            "cwd": "/work/repo",
2721            "agent": "pi",
2722            "event": { "stream_state": "waiting_for_input" },
2723        }))
2724        .unwrap();
2725        assert_eq!(pi.agent, Agent::Pi);
2726        assert_eq!(
2727            pi.event,
2728            SessionEvent::StreamState(SessionState::WaitingForInput)
2729        );
2730    }
2731
2732    #[test]
2733    fn agents_have_display_names() {
2734        assert_eq!(Agent::Claude.display_name(), "Claude");
2735        assert_eq!(Agent::Pi.display_name(), "pi");
2736        assert_eq!(Agent::Codex.display_name(), "Codex");
2737    }
2738
2739    #[test]
2740    fn an_ended_session_is_not_refreshed_by_a_passive_sighting_or_a_second_end() {
2741        let reg = SessionsRegistry::new();
2742        reg.observe(observe_request("s", SessionEvent::Stop, None));
2743        assert!(reg.end("s", None, None));
2744        let ended_at = reg.list()[0].last_seen;
2745        let mut changes = reg.subscribe_changes();
2746        changes.mark_unchanged();
2747        std::thread::sleep(std::time::Duration::from_millis(5));
2748        reg.observe(observe_request(
2749            "s",
2750            SessionEvent::TranscriptDiscovered,
2751            None,
2752        ));
2753        assert!(reg.end("s", None, None), "still a known session");
2754        let listed = reg.list();
2755        assert_eq!(listed[0].state, SessionState::Ended);
2756        assert_eq!(
2757            listed[0].last_seen, ended_at,
2758            "the linger window did not restart"
2759        );
2760        assert!(
2761            !changes.has_changed().unwrap(),
2762            "no consumer-visible change"
2763        );
2764        // A hook event still revives it, as before.
2765        reg.observe(observe_request("s", SessionEvent::UserPromptSubmit, None));
2766        assert_eq!(reg.list()[0].state, SessionState::Working);
2767    }
2768
2769    #[test]
2770    fn codex_is_a_tagged_agent_on_the_wire() {
2771        let codex: ObserveRequest = serde_json::from_value(serde_json::json!({
2772            "session_id": "019a0000-0000-7000-8000-000000000001",
2773            "agent": "codex",
2774            "event": { "notification": "permission_prompt" },
2775        }))
2776        .unwrap();
2777        assert_eq!(codex.agent, Agent::Codex);
2778        assert!(!codex.agent.is_claude());
2779        let json = serde_json::to_value(&codex).unwrap();
2780        assert_eq!(json["agent"], "codex");
2781    }
2782
2783    #[test]
2784    fn a_pi_session_is_listed_with_its_agent_and_reported_state() {
2785        let reg = SessionsRegistry::new();
2786        let mut req = observe_request(
2787            "pi-1",
2788            SessionEvent::StreamState(SessionState::Working),
2789            Some("/work/repo"),
2790        );
2791        req.agent = Agent::Pi;
2792        reg.observe(req);
2793        // A later sighting without the tag cannot re-label the session.
2794        reg.observe(observe_request(
2795            "pi-1",
2796            SessionEvent::StreamState(SessionState::Idle),
2797            None,
2798        ));
2799        let listed = reg.list();
2800        assert_eq!(listed.len(), 1);
2801        assert_eq!(listed[0].agent, Agent::Pi);
2802        assert_eq!(listed[0].state, SessionState::Idle);
2803        let json = serde_json::to_value(&listed[0]).unwrap();
2804        assert_eq!(json["agent"], "pi");
2805    }
2806
2807    #[test]
2808    fn fill_only_overwrites_with_a_present_value() {
2809        // `None` leaves the slot; `Some` overwrites it — the re-`observe`
2810        // never-clobber contract.
2811        let mut slot = Some("keep");
2812        fill(&mut slot, None);
2813        assert_eq!(slot, Some("keep"));
2814        fill(&mut slot, Some("new"));
2815        assert_eq!(slot, Some("new"));
2816        // A previously-empty slot fills.
2817        let mut empty: Option<&str> = None;
2818        fill(&mut empty, Some("filled"));
2819        assert_eq!(empty, Some("filled"));
2820    }
2821
2822    #[test]
2823    fn observe_at_session_cap_evicts_the_longest_silent() {
2824        let reg = SessionsRegistry::new();
2825        // Seed a full registry with explicit descending timestamps so the
2826        // highest-numbered id is unambiguously the oldest.
2827        {
2828            let mut sessions = reg.lock_sessions();
2829            let base = Utc::now();
2830            for i in 0..MAX_SESSIONS {
2831                let id = format!("s{i:04}");
2832                sessions.insert(
2833                    id.clone(),
2834                    SessionEntry {
2835                        subagent_base: None,
2836                        subagent_waits: HashMap::new(),
2837                        pid: None,
2838                        pid_start: None,
2839                        prompted: false,
2840                        streamed: false,
2841                        replaced_pids: VecDeque::new(),
2842                        recent_seqs: VecDeque::new(),
2843                        superseded_seqs: VecDeque::new(),
2844                        latest_stamp_ts: None,
2845                        reported: false,
2846                        agent: Agent::Claude,
2847                        session_id: id.clone(),
2848                        cwd: None,
2849                        transcript_path: None,
2850                        repo: None,
2851                        model: None,
2852                        state: SessionState::Working,
2853                        source: Source::Terminal,
2854                        last_event: SessionEvent::PreToolUse,
2855                        started_at: base,
2856                        last_seen: base,
2857                        last_active: Duration::from_millis(1_000_000 - i as u64),
2858                    },
2859                );
2860            }
2861        }
2862        // A new session at the cap displaces exactly the longest-silent entry.
2863        reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
2864        let sessions = reg.lock_sessions();
2865        assert_eq!(sessions.len(), MAX_SESSIONS);
2866        assert!(sessions.contains_key("fresh"));
2867        assert!(!sessions.contains_key(&format!("s{:04}", MAX_SESSIONS - 1)));
2868        assert!(sessions.contains_key("s0000"));
2869    }
2870
2871    #[test]
2872    fn report_window_at_cap_evicts_the_longest_silent() {
2873        let reg = SessionsRegistry::new();
2874        {
2875            let mut windows = reg.lock_windows();
2876            let base = Utc::now();
2877            for i in 0..MAX_WINDOWS {
2878                let key = format!("w{i:04}");
2879                windows.insert(
2880                    key.clone(),
2881                    WindowEntry {
2882                        report: WindowReport {
2883                            key: key.clone(),
2884                            folders: vec![],
2885                            tabs: 1,
2886                            terminals: 0,
2887                        },
2888                        last_active: Duration::from_millis(1_000_000 - i as u64),
2889                        registered_at: base,
2890                    },
2891                );
2892            }
2893        }
2894        reg.report_window(WindowReport {
2895            key: "fresh".to_string(),
2896            folders: vec![],
2897            tabs: 1,
2898            terminals: 0,
2899        });
2900        let windows = reg.lock_windows();
2901        assert_eq!(windows.len(), MAX_WINDOWS);
2902        assert!(windows.contains_key("fresh"));
2903        assert!(!windows.contains_key(&format!("w{:04}", MAX_WINDOWS - 1)));
2904        assert!(windows.contains_key("w0000"));
2905    }
2906
2907    #[test]
2908    fn evict_oldest_window_breaks_ties_by_key() {
2909        let now = Utc::now();
2910        let mut windows = HashMap::new();
2911        let at = |key: &str, secs: u64| WindowEntry {
2912            report: WindowReport {
2913                key: key.to_string(),
2914                folders: vec![],
2915                tabs: 1,
2916                terminals: 0,
2917            },
2918            last_active: Duration::from_secs(100 - secs),
2919            registered_at: now,
2920        };
2921        windows.insert("young".to_string(), at("young", 0));
2922        windows.insert("old-b".to_string(), at("old-b", 10));
2923        windows.insert("old-a".to_string(), at("old-a", 10));
2924        // Oldest `last_active` is shared; the lowest key loses.
2925        evict_oldest_window(&mut windows);
2926        assert!(!windows.contains_key("old-a"));
2927        assert!(windows.contains_key("old-b"));
2928        assert!(windows.contains_key("young"));
2929        // An empty map is a no-op, not a panic.
2930        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
2931        evict_oldest_window(&mut empty);
2932        assert!(empty.is_empty());
2933    }
2934
2935    // --- Change-notify for the push subscription (#1414) --------------------
2936
2937    /// A window report with one folder, parameterized by whether it embeds Claude.
2938    fn window_report(key: &str, folder: &str, embedded: bool) -> WindowReport {
2939        WindowReport {
2940            key: key.to_string(),
2941            folders: vec![PathBuf::from(folder)],
2942            tabs: usize::from(embedded),
2943            terminals: 0,
2944        }
2945    }
2946
2947    #[test]
2948    fn subscribe_changes_starts_seen_and_a_new_session_bumps() {
2949        let reg = SessionsRegistry::new();
2950        let mut rx = reg.subscribe_changes();
2951        // A fresh receiver has the current version already marked seen.
2952        assert!(!rx.has_changed().unwrap());
2953        reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
2954        assert!(rx.has_changed().unwrap(), "a new session should bump");
2955        // Marking it seen clears the pending change.
2956        rx.borrow_and_update();
2957        assert!(!rx.has_changed().unwrap());
2958    }
2959
2960    #[test]
2961    fn observe_bumps_on_a_state_transition_but_not_on_a_repeat_sighting() {
2962        let reg = SessionsRegistry::new();
2963        reg.observe(observe_request(
2964            "s1",
2965            SessionEvent::PreToolUse,
2966            Some("/tmp/a"),
2967        ));
2968        // Subscribe *after* the insert so its bump is already seen.
2969        let mut rx = reg.subscribe_changes();
2970
2971        // Same event, same cwd: liveness and `last_event`/`last_seen` move, but
2972        // nothing a consumer renders does. Hooks fire on every tool call, so this
2973        // is the hot path that must stay quiet.
2974        reg.observe(observe_request(
2975            "s1",
2976            SessionEvent::PreToolUse,
2977            Some("/tmp/a"),
2978        ));
2979        assert!(
2980            !rx.has_changed().unwrap(),
2981            "a repeat sighting with no visible change must not bump"
2982        );
2983
2984        // `PreToolUse` → `Stop` flips working → idle, which the tree renders.
2985        reg.observe(observe_request("s1", SessionEvent::Stop, None));
2986        assert!(rx.has_changed().unwrap(), "a state transition should bump");
2987        rx.borrow_and_update();
2988
2989        // A best-effort field taking a *new* value is visible too (the tally
2990        // joins sessions to worktree rows by `cwd`).
2991        reg.observe(observe_request("s1", SessionEvent::Stop, Some("/tmp/b")));
2992        assert!(
2993            rx.has_changed().unwrap(),
2994            "a newly-filled `cwd` should bump"
2995        );
2996    }
2997
2998    #[test]
2999    fn transcript_growth_does_not_clobber_a_waiting_session() {
3000        let reg = SessionsRegistry::new();
3001        reg.observe(observe_request(
3002            "s1",
3003            SessionEvent::Notification(NotificationKind::PermissionPrompt),
3004            Some("/tmp/a"),
3005        ));
3006        // Subscribe *after* the insert so its bump is already seen. Never marked
3007        // seen below, so the closing assert catches the release's bump only if
3008        // the growth in between really did stay quiet.
3009        let rx = reg.subscribe_changes();
3010
3011        // The watcher sees the assistant `tool_use` line Claude flushed before
3012        // the prompt could be answered. The wait came from a direct
3013        // `Notification`, so it must survive (#1418).
3014        reg.observe(observe_request(
3015            "s1",
3016            SessionEvent::TranscriptGrew,
3017            Some("/tmp/a"),
3018        ));
3019        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
3020        assert!(
3021            !rx.has_changed().unwrap(),
3022            "state did not change, so nothing a consumer renders did either (#1414)"
3023        );
3024
3025        // Answering the prompt still releases the wait — on the next hook, which
3026        // for an approved tool is the `PostToolUse` that fires when it finishes.
3027        reg.observe(observe_request("s1", SessionEvent::PostToolUse, None));
3028        assert_eq!(reg.list()[0].state, SessionState::Working);
3029        assert!(
3030            rx.has_changed().unwrap(),
3031            "the release is a real transition"
3032        );
3033    }
3034
3035    #[test]
3036    fn transcript_growth_does_not_revive_an_ended_session() {
3037        let reg = SessionsRegistry::new();
3038        reg.observe(observe_request(
3039            "s1",
3040            SessionEvent::PreToolUse,
3041            Some("/tmp/a"),
3042        ));
3043        assert!(reg.end("s1", Some("clear"), None));
3044        // Subscribed after the end so its bump is already seen (see above).
3045        let rx = reg.subscribe_changes();
3046
3047        // The watcher's next scan sees the lines Claude flushed as it exited.
3048        // `SessionEnd` reported the end directly, so the entry must stay `ended`
3049        // and reap on the short ended TTL rather than being revived as a
3050        // `working` phantom that outlives the session by the whole TTL (#1418).
3051        reg.observe(observe_request(
3052            "s1",
3053            SessionEvent::TranscriptGrew,
3054            Some("/tmp/a"),
3055        ));
3056        assert_eq!(reg.list()[0].state, SessionState::Ended);
3057        assert!(
3058            !rx.has_changed().unwrap(),
3059            "state did not change, so nothing a consumer renders did either (#1414)"
3060        );
3061    }
3062
3063    #[test]
3064    fn a_rollout_turn_marker_moves_working_and_idle_and_nothing_else() {
3065        // #2135: an open turn is `transcript_grew`'s evidence from the Codex
3066        // side, so it is held by exactly the states growth is held by; a closed
3067        // turn ends a `working` turn and leaves every other known state alone.
3068        let open = SessionEvent::RolloutTurn(RolloutTurn::Open);
3069        let closed = SessionEvent::RolloutTurn(RolloutTurn::Closed);
3070        for held in [
3071            SessionState::Starting,
3072            SessionState::WaitingForInput,
3073            SessionState::WaitingForPermission,
3074            SessionState::Ended,
3075        ] {
3076            assert_eq!(SessionState::for_event(&open, Some(held)), held, "{held:?}");
3077            assert_eq!(
3078                SessionState::for_event(&closed, Some(held)),
3079                held,
3080                "{held:?}"
3081            );
3082        }
3083        for other in [SessionState::Working, SessionState::Idle] {
3084            assert_eq!(
3085                SessionState::for_event(&open, Some(other)),
3086                SessionState::Working,
3087                "open from {other:?}"
3088            );
3089            assert_eq!(
3090                SessionState::for_event(&closed, Some(other)),
3091                SessionState::Idle,
3092                "closed from {other:?}"
3093            );
3094        }
3095    }
3096
3097    #[test]
3098    fn a_hookless_codex_session_follows_its_rollouts_turns() {
3099        let reg = SessionsRegistry::new();
3100        let turn = |turn| ObserveRequest {
3101            agent: Agent::Codex,
3102            ..observe_request(
3103                "019a0000-0000-7000-8000-00000000c0de",
3104                SessionEvent::RolloutTurn(turn),
3105                Some("/work/repo"),
3106            )
3107        };
3108        // First sighted mid-turn: working, with no hook ever having spoken.
3109        reg.observe(turn(RolloutTurn::Open));
3110        assert_eq!(reg.list()[0].state, SessionState::Working);
3111        reg.observe(turn(RolloutTurn::Closed));
3112        assert_eq!(reg.list()[0].state, SessionState::Idle);
3113        reg.observe(turn(RolloutTurn::Open));
3114        assert_eq!(reg.list()[0].state, SessionState::Working);
3115    }
3116
3117    #[test]
3118    fn a_rollout_turn_marker_neither_overrides_nor_releases_a_reported_wait() {
3119        let reg = SessionsRegistry::new();
3120        reg.observe(observe_request(
3121            "s1",
3122            SessionEvent::Notification(NotificationKind::PermissionPrompt),
3123            Some("/tmp/a"),
3124        ));
3125        let rx = reg.subscribe_changes();
3126        for turn in [RolloutTurn::Open, RolloutTurn::Closed] {
3127            reg.observe(observe_request(
3128                "s1",
3129                SessionEvent::RolloutTurn(turn),
3130                Some("/tmp/a"),
3131            ));
3132            assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
3133        }
3134        assert!(
3135            !rx.has_changed().unwrap(),
3136            "state did not change, so nothing a consumer renders did either (#1414)"
3137        );
3138        // The authoritative report still has the last word, as it always does.
3139        reg.observe(observe_request(
3140            "s1",
3141            SessionEvent::StreamState(SessionState::Idle),
3142            None,
3143        ));
3144        assert_eq!(reg.list()[0].state, SessionState::Idle);
3145    }
3146
3147    #[test]
3148    fn an_open_rollout_turn_does_not_start_working_over_a_reported_idle() {
3149        // A `Stop` hook (or the wrapper's `idle`) can beat Codex flushing
3150        // `task_complete`, so an open marker read in between would start the row
3151        // again; what was reported stands, and the next hook restarts it (#2135).
3152        let open = SessionEvent::RolloutTurn(RolloutTurn::Open);
3153        let closed = SessionEvent::RolloutTurn(RolloutTurn::Closed);
3154        for reported in [
3155            SessionEvent::Stop,
3156            SessionEvent::StreamState(SessionState::Idle),
3157        ] {
3158            let reg = SessionsRegistry::new();
3159            reg.observe(observe_request("s", reported, None));
3160            assert_eq!(reg.list()[0].state, SessionState::Idle);
3161            // The watcher's own heartbeat overwrites `last_event` without
3162            // touching the state, so it must not make the idle look unreported.
3163            reg.observe(observe_request(
3164                "s",
3165                SessionEvent::TranscriptDiscovered,
3166                None,
3167            ));
3168            reg.observe(observe_request("s", open, None));
3169            assert_eq!(reg.list()[0].state, SessionState::Idle, "{reported:?}");
3170            // A hook that really starts a turn still does.
3171            reg.observe(observe_request("s", SessionEvent::UserPromptSubmit, None));
3172            assert_eq!(reg.list()[0].state, SessionState::Working);
3173        }
3174        // A closed turn still ends a `working` turn a hook reported (the heal for
3175        // a `Stop` that never arrives), and the idle it leaves is the rollout's
3176        // own, not a reported one, so the next open turn starts it again.
3177        let reg = SessionsRegistry::new();
3178        reg.observe(observe_request("s", SessionEvent::UserPromptSubmit, None));
3179        reg.observe(observe_request("s", closed, None));
3180        assert_eq!(reg.list()[0].state, SessionState::Idle);
3181        reg.observe(observe_request("s", open, None));
3182        assert_eq!(reg.list()[0].state, SessionState::Working);
3183    }
3184
3185    #[test]
3186    fn a_session_first_seen_from_its_rollout_has_no_reported_state_to_defend() {
3187        // The hook-less session this event exists for: `idle` is only a default.
3188        for first in [
3189            SessionEvent::TranscriptDiscovered,
3190            SessionEvent::RolloutTurn(RolloutTurn::Closed),
3191        ] {
3192            let reg = SessionsRegistry::new();
3193            reg.observe(observe_request("s", first, None));
3194            assert_eq!(reg.list()[0].state, SessionState::Idle);
3195            reg.observe(observe_request(
3196                "s",
3197                SessionEvent::RolloutTurn(RolloutTurn::Open),
3198                None,
3199            ));
3200            assert_eq!(reg.list()[0].state, SessionState::Working, "{first:?}");
3201        }
3202    }
3203
3204    #[test]
3205    fn a_rollout_turn_marker_does_not_revive_an_ended_session() {
3206        let reg = SessionsRegistry::new();
3207        reg.observe(observe_request("s", SessionEvent::Stop, None));
3208        assert!(reg.end("s", None, None));
3209        let ended_at = reg.list()[0].last_seen;
3210        let rx = reg.subscribe_changes();
3211        std::thread::sleep(std::time::Duration::from_millis(5));
3212        reg.observe(observe_request(
3213            "s",
3214            SessionEvent::RolloutTurn(RolloutTurn::Open),
3215            None,
3216        ));
3217        let listed = reg.list();
3218        assert_eq!(listed[0].state, SessionState::Ended);
3219        assert_eq!(
3220            listed[0].last_seen, ended_at,
3221            "the linger window did not restart"
3222        );
3223        assert!(!rx.has_changed().unwrap(), "no consumer-visible change");
3224    }
3225
3226    #[test]
3227    fn a_rollout_turn_marker_is_a_wire_event() {
3228        for (turn, name) in [(RolloutTurn::Open, "open"), (RolloutTurn::Closed, "closed")] {
3229            let event = SessionEvent::RolloutTurn(turn);
3230            let json = serde_json::to_value(event).unwrap();
3231            assert_eq!(json, serde_json::json!({ "rollout_turn": name }));
3232            assert_eq!(serde_json::from_value::<SessionEvent>(json).unwrap(), event);
3233        }
3234    }
3235
3236    #[test]
3237    fn a_resumed_session_stays_starting_until_its_first_prompt() {
3238        let reg = SessionsRegistry::new();
3239        reg.observe(observe_request(
3240            "s1",
3241            SessionEvent::UserPromptSubmit,
3242            Some("/tmp/a"),
3243        ));
3244        // A VS Code window reload: the old process fires `SessionEnd`, and the
3245        // new one resumes the same session id and fires `SessionStart`.
3246        assert!(reg.end("s1", Some("other"), None));
3247        reg.observe(observe_request(
3248            "s1",
3249            SessionEvent::SessionStart,
3250            Some("/tmp/a"),
3251        ));
3252        assert_eq!(reg.list()[0].state, SessionState::Starting);
3253
3254        // The watcher's next scan sees the `cost-state` line the old process
3255        // appended as it exited. It is not the resumed session doing work, and
3256        // no hook fires while it sits unprompted, so it must not read as
3257        // `working` (#1946).
3258        reg.observe(observe_request(
3259            "s1",
3260            SessionEvent::TranscriptGrew,
3261            Some("/tmp/a"),
3262        ));
3263        assert_eq!(reg.list()[0].state, SessionState::Starting);
3264
3265        // The first prompt still moves it to `working`.
3266        reg.observe(observe_request("s1", SessionEvent::UserPromptSubmit, None));
3267        assert_eq!(reg.list()[0].state, SessionState::Working);
3268    }
3269
3270    #[test]
3271    fn end_bumps_only_for_a_known_session() {
3272        let reg = SessionsRegistry::new();
3273        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3274        let mut rx = reg.subscribe_changes();
3275
3276        assert!(
3277            !reg.end("ghost", None, None),
3278            "an unknown session is a no-op"
3279        );
3280        assert!(
3281            !rx.has_changed().unwrap(),
3282            "ending an unknown session must not bump"
3283        );
3284
3285        assert!(reg.end("s1", None, None));
3286        assert!(rx.has_changed().unwrap(), "a real end should bump");
3287        rx.borrow_and_update();
3288    }
3289
3290    /// A hook sighting from agent process `pid`.
3291    fn observe_from(session_id: &str, event: SessionEvent, pid: u32) -> ObserveRequest {
3292        ObserveRequest {
3293            agent_id: None,
3294            pid: Some(pid),
3295            ..observe_request(session_id, event, None)
3296        }
3297    }
3298
3299    #[test]
3300    fn a_late_end_from_the_replaced_process_keeps_a_resumed_session_live() {
3301        let reg = SessionsRegistry::new();
3302        // The old process (pid 100) runs the session; a window reload starts
3303        // the new one (pid 200) on the same session_id before 100 has exited.
3304        reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3305        reg.observe(observe_from("s1", SessionEvent::Stop, 100));
3306        reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3307        let rx = reg.subscribe_changes();
3308
3309        // The old process's `SessionEnd` lands last (#1948).
3310        assert!(reg.end("s1", Some("other"), Some(100)));
3311        assert!(!rx.has_changed().unwrap(), "an ignored end must not bump");
3312        let sessions = reg.list();
3313        assert_eq!(sessions.len(), 1);
3314        assert_eq!(sessions[0].state, SessionState::Starting);
3315
3316        // The new process's own `SessionEnd` still ends it.
3317        assert!(reg.end("s1", Some("exit"), Some(200)));
3318        assert_eq!(reg.list()[0].state, SessionState::Ended);
3319    }
3320
3321    #[test]
3322    fn a_straggling_hook_from_the_replaced_process_does_not_take_the_session_back() {
3323        let reg = SessionsRegistry::new();
3324        reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3325        reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3326        // A replaced pid never becomes the owner again, so 200 still owns it —
3327        // and the straggling `PostToolUse` must not move the state either
3328        // (`observe`, not only `end`, ignores a replaced pid's sighting).
3329        reg.observe(observe_from("s1", SessionEvent::PostToolUse, 100));
3330        assert_eq!(reg.list()[0].state, SessionState::Starting);
3331        reg.end("s1", None, Some(100));
3332        assert_ne!(reg.list()[0].state, SessionState::Ended);
3333    }
3334
3335    #[test]
3336    fn a_straggling_non_end_hook_from_the_replaced_process_does_not_change_state() {
3337        let reg = SessionsRegistry::new();
3338        reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
3339        assert_eq!(reg.list()[0].state, SessionState::Working);
3340        // The resume: a new process takes over ownership.
3341        reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3342        assert_eq!(reg.list()[0].state, SessionState::Starting);
3343        let rx = reg.subscribe_changes();
3344
3345        // The old process's last, delayed `Stop` arrives after the resume.
3346        // Left unguarded, `for_event(Stop, Starting)` would move this back to
3347        // `idle`, corrupting the resumed session's freshly-`starting` state.
3348        reg.observe(observe_from("s1", SessionEvent::Stop, 100));
3349        assert_eq!(reg.list()[0].state, SessionState::Starting);
3350        assert!(
3351            !rx.has_changed().unwrap(),
3352            "an ignored sighting must not bump"
3353        );
3354    }
3355
3356    #[test]
3357    fn an_end_not_from_the_replaced_process_still_ends_the_session() {
3358        // No pid (an older sink, or a feed that sends none), the owning pid, and
3359        // a never-seen pid (a wrapped hook command whose parent is a per-hook
3360        // shell) all end the session: only a replaced pid is ignored.
3361        for end_pid in [None, Some(200), Some(999)] {
3362            let reg = SessionsRegistry::new();
3363            reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3364            reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3365            assert!(reg.end("s1", None, end_pid));
3366            assert_eq!(
3367                reg.list()[0].state,
3368                SessionState::Ended,
3369                "end with pid {end_pid:?}"
3370            );
3371        }
3372        // A session that was never resumed ends on its own process's end.
3373        let reg = SessionsRegistry::new();
3374        reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3375        assert!(reg.end("s1", None, Some(100)));
3376        assert_eq!(reg.list()[0].state, SessionState::Ended);
3377    }
3378
3379    #[test]
3380    fn every_process_of_a_reload_burst_is_remembered_as_replaced() {
3381        let reg = SessionsRegistry::new();
3382        for pid in [100, 200, 300] {
3383            reg.observe(observe_from("s1", SessionEvent::SessionStart, pid));
3384        }
3385        // Both earlier processes' ends land after the third process started.
3386        assert!(reg.end("s1", None, Some(100)));
3387        assert!(reg.end("s1", None, Some(200)));
3388        assert_eq!(reg.list()[0].state, SessionState::Starting);
3389    }
3390
3391    #[test]
3392    fn a_stream_report_from_a_new_process_takes_the_session_over() {
3393        // A wrapper-only install: `claude-wrap` reports `StreamState` with the
3394        // child's pid, and no hook ever sends a `SessionStart`.
3395        let idle = || SessionEvent::StreamState(SessionState::Idle);
3396        let reg = SessionsRegistry::new();
3397        reg.observe(observe_from("s1", idle(), 100));
3398        reg.observe(observe_from("s1", idle(), 200));
3399        reg.end("s1", None, Some(100));
3400        assert_eq!(reg.list()[0].state, SessionState::Idle);
3401    }
3402
3403    #[test]
3404    fn replaced_pids_are_capped_oldest_first() {
3405        let reg = SessionsRegistry::new();
3406        let last = u32::try_from(MAX_REPLACED_PIDS).unwrap() + 1;
3407        for pid in 0..=last {
3408            reg.observe(observe_from("s1", SessionEvent::PostToolUse, pid));
3409        }
3410        let guard = reg.lock_sessions();
3411        let replaced = &guard["s1"].replaced_pids;
3412        assert_eq!(replaced.len(), MAX_REPLACED_PIDS);
3413        assert_eq!(replaced.front(), Some(&1), "pid 0 was forgotten first");
3414        assert_eq!(replaced.back(), Some(&(last - 1)));
3415    }
3416
3417    #[test]
3418    fn a_sessions_first_pid_sighting_is_not_a_replacement() {
3419        let reg = SessionsRegistry::new();
3420        // Some feeds (e.g. the transcript watcher) can create an entry with no
3421        // pid at all; its first sighting of one has no prior owner to replace.
3422        reg.observe(observe_request(
3423            "s1",
3424            SessionEvent::TranscriptDiscovered,
3425            None,
3426        ));
3427        reg.observe(observe_from("s1", SessionEvent::PostToolUse, 100));
3428        let guard = reg.lock_sessions();
3429        assert_eq!(guard["s1"].pid, Some(100));
3430        assert!(guard["s1"].replaced_pids.is_empty());
3431    }
3432
3433    #[test]
3434    fn end_then_start_resume_ordering_is_unchanged() {
3435        // #1946's ordering: the old process ends first, then the new one starts.
3436        let reg = SessionsRegistry::new();
3437        reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3438        assert!(reg.end("s1", None, Some(100)));
3439        reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3440        assert_eq!(reg.list()[0].state, SessionState::Starting);
3441    }
3442
3443    #[test]
3444    fn window_report_bumps_only_when_it_changes_the_source_join() {
3445        let reg = SessionsRegistry::new();
3446        reg.report_window(window_report("w1", "/p", true));
3447        let mut rx = reg.subscribe_changes();
3448
3449        // The unchanged ~10s refresh every open window sends: liveness only.
3450        reg.report_window(window_report("w1", "/p", true));
3451        assert!(
3452            !rx.has_changed().unwrap(),
3453            "an unchanged window refresh must not bump"
3454        );
3455
3456        // The window's Claude tab closed → its sessions fall back to `terminal`.
3457        reg.report_window(window_report("w1", "/p", false));
3458        assert!(
3459            rx.has_changed().unwrap(),
3460            "an embedding that vanished should bump"
3461        );
3462        rx.borrow_and_update();
3463
3464        // Different folders → a different `cwd`-prefix join.
3465        reg.report_window(window_report("w1", "/q", false));
3466        assert!(rx.has_changed().unwrap(), "changed folders should bump");
3467        rx.borrow_and_update();
3468
3469        // A brand-new window joins the registry.
3470        reg.report_window(window_report("w2", "/r", true));
3471        assert!(rx.has_changed().unwrap(), "a new window should bump");
3472    }
3473
3474    #[test]
3475    fn unregister_window_bumps_only_when_it_removes() {
3476        let reg = SessionsRegistry::new();
3477        reg.report_window(window_report("w1", "/p", true));
3478        let rx = reg.subscribe_changes();
3479
3480        assert!(!reg.unregister_window("ghost"));
3481        assert!(
3482            !rx.has_changed().unwrap(),
3483            "a no-op unregister must not bump"
3484        );
3485
3486        assert!(reg.unregister_window("w1"));
3487        assert!(
3488            rx.has_changed().unwrap(),
3489            "a removing unregister should bump"
3490        );
3491    }
3492
3493    #[tokio::test]
3494    async fn a_burst_of_bumps_coalesces_into_one_wakeup() {
3495        let reg = SessionsRegistry::new();
3496        let mut rx = reg.subscribe_changes();
3497        // Three visible changes back to back, all before anyone awaits.
3498        reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
3499        reg.observe(observe_request("s2", SessionEvent::SessionStart, None));
3500        reg.observe(observe_request("s3", SessionEvent::SessionStart, None));
3501        // `changed()` marks the newest version seen, so the burst is one wakeup…
3502        rx.changed().await.unwrap();
3503        // …and there is nothing left pending for a second one.
3504        assert!(
3505            !rx.has_changed().unwrap(),
3506            "a burst should collapse into a single wakeup"
3507        );
3508    }
3509
3510    #[test]
3511    fn list_does_not_bump_even_when_it_reaps() {
3512        // `list` is the body of every subscription's `snapshot()`, so a bump here
3513        // would feed the stream loop back into itself. A read-path reap reaches
3514        // other subscribers on the server's next periodic re-sample instead.
3515        let reg = SessionsRegistry::new();
3516        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3517        // Let the entry sit awake past its TTL so the next `list` reaps it.
3518        reg.clock.advance(Duration::from_secs(600));
3519        let rx = reg.subscribe_changes();
3520        assert!(reg.list().is_empty(), "the stale session should be reaped");
3521        assert!(!rx.has_changed().unwrap(), "`list` must never bump");
3522    }
3523
3524    // --- Sleep-safe TTLs (#2108) ---------------------------------------------
3525
3526    /// A hair under the session TTL, still inside it.
3527    fn just_inside_the_ttl() -> Duration {
3528        DEFAULT_SESSION_TTL
3529            .checked_sub(Duration::from_secs(1))
3530            .unwrap()
3531    }
3532
3533    /// Moves every wall-clock stamp back by `by`, as a laptop sleeping that long
3534    /// would, without any awake time passing.
3535    fn jump_wall_clock_forward(reg: &SessionsRegistry, by: chrono::Duration) {
3536        for entry in reg.lock_sessions().values_mut() {
3537            entry.last_seen -= by;
3538        }
3539    }
3540
3541    #[test]
3542    fn a_wall_clock_jump_with_no_awake_time_reaps_nothing() {
3543        // The 2026-10-03 incident: a 422s sleep (and here, far longer) used to
3544        // reap every live session on the first read after the wake-up.
3545        let reg = SessionsRegistry::new();
3546        reg.observe(observe_request("working", SessionEvent::PreToolUse, None));
3547        reg.observe(observe_request("idle", SessionEvent::Stop, None));
3548        reg.observe(observe_request("ended", SessionEvent::PreToolUse, None));
3549        reg.end("ended", None, None);
3550        reg.report_window(window_report("w1", "/p", true));
3551
3552        jump_wall_clock_forward(&reg, chrono::Duration::days(3));
3553
3554        let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
3555        assert_eq!(
3556            ids,
3557            vec![
3558                "ended".to_string(),
3559                "idle".to_string(),
3560                "working".to_string()
3561            ],
3562            "sleeping must not age a session, not even an ended one"
3563        );
3564        assert_eq!(reg.lock_windows().len(), 1, "nor a window report");
3565        // Display still reads the wall clock: the stale `last_seen` is shown.
3566        assert!(reg.list()[0].last_seen < Utc::now() - chrono::Duration::days(2));
3567    }
3568
3569    #[test]
3570    fn awake_time_past_the_ttl_still_reaps() {
3571        let reg = SessionsRegistry::new();
3572        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3573        reg.report_window(window_report("w1", "/p", true));
3574        reg.clock.advance(just_inside_the_ttl());
3575        assert_eq!(reg.list().len(), 1, "inside the TTL it survives");
3576        assert_eq!(reg.lock_windows().len(), 0, "the window TTL is shorter");
3577        reg.clock.advance(Duration::from_secs(2));
3578        assert!(
3579            reg.list().is_empty(),
3580            "past the TTL of awake silence it reaps"
3581        );
3582    }
3583
3584    #[test]
3585    fn activity_after_a_sleep_restarts_the_awake_ttl() {
3586        let reg = SessionsRegistry::new();
3587        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3588        reg.clock.advance(just_inside_the_ttl());
3589        jump_wall_clock_forward(&reg, chrono::Duration::hours(8));
3590        // A hook after the wake-up refreshes the awake stamp as well.
3591        reg.observe(observe_request("s1", SessionEvent::PostToolUse, None));
3592        reg.clock.advance(just_inside_the_ttl());
3593        assert_eq!(reg.list().len(), 1);
3594    }
3595
3596    // --- Stamped events: dedupe and ordering across socket and journal (#2108) --
3597
3598    fn at(ts: &str) -> DateTime<Utc> {
3599        ts.parse().unwrap()
3600    }
3601
3602    fn stamp(ts: &str, seq: &str) -> EventStamp {
3603        EventStamp {
3604            ts: at(ts),
3605            seq: seq.to_string(),
3606        }
3607    }
3608
3609    fn state_of(reg: &SessionsRegistry, id: &str) -> SessionState {
3610        reg.lock_sessions()[id].state
3611    }
3612
3613    #[test]
3614    fn the_second_copy_of_a_stamped_event_is_dropped_whatever_its_route() {
3615        let reg = SessionsRegistry::new();
3616        let work = stamp("2026-10-03T03:40:00Z", "1-a");
3617        let stop = stamp("2026-10-03T03:40:05Z", "1-b");
3618        reg.observe_stamped(
3619            observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3620            Some(work.clone()),
3621            Origin::Socket,
3622        );
3623        reg.observe_stamped(
3624            observe_request("s", SessionEvent::Stop, None),
3625            Some(stop),
3626            Origin::Socket,
3627        );
3628        assert_eq!(state_of(&reg, "s"), SessionState::Idle);
3629        // The journal then hands over the working event again, and so would a
3630        // retried POST: neither may undo the `Stop`.
3631        for origin in [Origin::Journal, Origin::Socket] {
3632            reg.observe_stamped(
3633                observe_request("s", SessionEvent::PreToolUse, None),
3634                Some(work.clone()),
3635                origin,
3636            );
3637            assert_eq!(state_of(&reg, "s"), SessionState::Idle, "{origin:?}");
3638        }
3639    }
3640
3641    #[test]
3642    fn a_journal_event_older_than_one_already_applied_is_stale() {
3643        let reg = SessionsRegistry::new();
3644        reg.observe_stamped(
3645            observe_request("s", SessionEvent::Stop, Some("/p")),
3646            Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3647            Origin::Socket,
3648        );
3649        // A `PreToolUse` whose POST was dropped, read from the journal late.
3650        reg.observe_stamped(
3651            observe_request("s", SessionEvent::PreToolUse, None),
3652            Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3653            Origin::Journal,
3654        );
3655        assert_eq!(state_of(&reg, "s"), SessionState::Idle);
3656        // A newer journal event is applied.
3657        reg.observe_stamped(
3658            observe_request("s", SessionEvent::PreToolUse, None),
3659            Some(stamp("2026-10-03T03:41:00Z", "1-c")),
3660            Origin::Journal,
3661        );
3662        assert_eq!(state_of(&reg, "s"), SessionState::Working);
3663    }
3664
3665    #[test]
3666    fn a_socket_event_overtaken_by_a_newer_one_is_dropped_but_never_for_a_clock_step() {
3667        // The adapter awaits a git lookup before applying an `observe`, so the
3668        // journal route can apply a newer event first; the late socket copy of
3669        // the older one must not undo it.
3670        let reg = SessionsRegistry::new();
3671        reg.observe_stamped(
3672            observe_request("s", SessionEvent::Stop, Some("/p")),
3673            Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3674            Origin::Journal,
3675        );
3676        reg.observe_stamped(
3677            observe_request("s", SessionEvent::PreToolUse, None),
3678            Some(stamp("2026-10-03T03:40:03Z", "1-a")),
3679            Origin::Socket,
3680        );
3681        assert_eq!(state_of(&reg, "s"), SessionState::Idle);
3682        // A wall clock stepped back by minutes must not freeze the session: an
3683        // event that far behind is applied, however it arrived.
3684        reg.observe_stamped(
3685            observe_request("s", SessionEvent::PreToolUse, None),
3686            Some(stamp("2026-10-03T03:30:00Z", "1-c")),
3687            Origin::Socket,
3688        );
3689        assert_eq!(state_of(&reg, "s"), SessionState::Working);
3690    }
3691
3692    #[test]
3693    fn unstamped_events_are_never_skipped() {
3694        let reg = SessionsRegistry::new();
3695        reg.observe(observe_request("s", SessionEvent::Stop, Some("/p")));
3696        reg.observe(observe_request("s", SessionEvent::PreToolUse, None));
3697        assert_eq!(state_of(&reg, "s"), SessionState::Working);
3698        reg.observe(observe_request("s", SessionEvent::Stop, None));
3699        assert_eq!(state_of(&reg, "s"), SessionState::Idle);
3700    }
3701
3702    #[test]
3703    fn a_journal_end_older_than_a_resume_does_not_end_the_resumed_session() {
3704        let reg = SessionsRegistry::new();
3705        reg.observe_stamped(
3706            observe_request("s", SessionEvent::SessionStart, Some("/p")),
3707            Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3708            Origin::Socket,
3709        );
3710        // `/clear`: the end was journaled at 03:41, but the resumed session's
3711        // own start (posted fine) is at 03:42.
3712        reg.observe_stamped(
3713            observe_request("s", SessionEvent::SessionStart, None),
3714            Some(stamp("2026-10-03T03:42:00Z", "1-c")),
3715            Origin::Socket,
3716        );
3717        let ended = reg.end_stamped(
3718            "s",
3719            Some("clear"),
3720            None,
3721            Some(stamp("2026-10-03T03:41:00Z", "1-b")),
3722            Origin::Journal,
3723        );
3724        assert!(ended, "the session is known");
3725        assert_eq!(state_of(&reg, "s"), SessionState::Starting);
3726        // The same end, newer than everything applied, does end it...
3727        reg.end_stamped(
3728            "s",
3729            None,
3730            None,
3731            Some(stamp("2026-10-03T03:43:00Z", "1-d")),
3732            Origin::Journal,
3733        );
3734        assert_eq!(state_of(&reg, "s"), SessionState::Ended);
3735        // ...once, and a duplicate of it is dropped.
3736        let ended_at = reg.lock_sessions()["s"].last_seen;
3737        reg.end_stamped(
3738            "s",
3739            None,
3740            None,
3741            Some(stamp("2026-10-03T03:43:00Z", "1-d")),
3742            Origin::Socket,
3743        );
3744        assert_eq!(reg.lock_sessions()["s"].last_seen, ended_at);
3745    }
3746
3747    #[test]
3748    fn a_journal_event_keeps_its_own_timestamp_but_not_its_own_age() {
3749        let reg = SessionsRegistry::new();
3750        let long_ago = Utc::now() - chrono::Duration::hours(2);
3751        let st = EventStamp {
3752            ts: long_ago,
3753            seq: "1-a".to_string(),
3754        };
3755        reg.observe_stamped(
3756            observe_request("s", SessionEvent::UserPromptSubmit, Some("/p")),
3757            Some(st),
3758            Origin::Journal,
3759        );
3760        let listed = reg.list();
3761        assert_eq!(listed.len(), 1, "replay is not reaped on arrival");
3762        assert_eq!(listed[0].last_seen, long_ago, "history must not look fresh");
3763        assert_eq!(listed[0].started_at, long_ago);
3764        // Its TTL runs from now, in awake time.
3765        reg.clock.advance(just_inside_the_ttl());
3766        assert_eq!(reg.list().len(), 1);
3767        reg.clock.advance(Duration::from_secs(2));
3768        assert!(reg.list().is_empty());
3769    }
3770
3771    #[test]
3772    fn a_journal_timestamp_in_the_future_is_clamped_to_now() {
3773        let reg = SessionsRegistry::new();
3774        let future = Utc::now() + chrono::Duration::hours(1);
3775        reg.observe_stamped(
3776            observe_request("s", SessionEvent::Stop, Some("/p")),
3777            Some(EventStamp {
3778                ts: future,
3779                seq: "1-a".to_string(),
3780            }),
3781            Origin::Journal,
3782        );
3783        assert!(reg.list()[0].last_seen <= Utc::now());
3784    }
3785
3786    #[test]
3787    fn only_the_newest_seqs_are_remembered() {
3788        let reg = SessionsRegistry::new();
3789        for i in 0..=MAX_RECENT_SEQS {
3790            reg.observe_stamped(
3791                observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3792                Some(stamp("2026-10-03T03:40:00Z", &format!("1-{i}"))),
3793                Origin::Socket,
3794            );
3795        }
3796        let guard = reg.lock_sessions();
3797        assert_eq!(guard["s"].recent_seqs.len(), MAX_RECENT_SEQS);
3798        assert!(guard["s"].seq_origin("1-0").is_none());
3799        assert_eq!(
3800            guard["s"].seq_origin(&format!("1-{MAX_RECENT_SEQS}")),
3801            Some(Origin::Socket)
3802        );
3803    }
3804
3805    #[test]
3806    fn a_duplicate_does_not_bump_and_does_not_refresh_liveness() {
3807        let reg = SessionsRegistry::new();
3808        let st = stamp("2026-10-03T03:40:00Z", "1-a");
3809        reg.observe_stamped(
3810            observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3811            Some(st.clone()),
3812            Origin::Socket,
3813        );
3814        let active = reg.lock_sessions()["s"].last_active;
3815        let rx = reg.subscribe_changes();
3816        reg.clock.advance(Duration::from_secs(10));
3817        reg.observe_stamped(
3818            observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3819            Some(st),
3820            Origin::Journal,
3821        );
3822        assert!(!rx.has_changed().unwrap());
3823        assert_eq!(reg.lock_sessions()["s"].last_active, active);
3824    }
3825
3826    // --- Delivery counters (#2136) --------------------------------------------
3827
3828    #[test]
3829    fn socket_events_are_counted_stamped_or_not() {
3830        let reg = SessionsRegistry::new();
3831        reg.observe_stamped(
3832            observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3833            None,
3834            Origin::Socket,
3835        );
3836        reg.observe_stamped(
3837            observe_request("s", SessionEvent::Stop, None),
3838            Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3839            Origin::Socket,
3840        );
3841        reg.end_stamped("s", None, None, None, Origin::Socket);
3842        assert_eq!(
3843            reg.delivery(),
3844            DeliveryStats {
3845                socket: 3,
3846                recovered: 0,
3847                superseded: 0,
3848                replayed: 0
3849            }
3850        );
3851    }
3852
3853    #[test]
3854    fn the_daemons_own_feeds_are_applied_but_not_counted_as_deliveries() {
3855        // The watchers call `observe`/`end` in-process: nothing came over the
3856        // socket, so counting them would swamp the hooks' real numbers.
3857        let reg = SessionsRegistry::new();
3858        reg.observe(observe_request("s", SessionEvent::PreToolUse, Some("/p")));
3859        reg.observe(observe_request("s", SessionEvent::TranscriptGrew, None));
3860        assert_eq!(reg.lock_sessions()["s"].state, SessionState::Working);
3861        assert!(reg.end("s", None, None));
3862        assert_eq!(reg.lock_sessions()["s"].state, SessionState::Ended);
3863        assert_eq!(reg.delivery(), DeliveryStats::default());
3864    }
3865
3866    #[test]
3867    fn a_journal_event_whose_post_never_came_is_recovered_and_a_replay_is_not() {
3868        let reg = SessionsRegistry::new();
3869        reg.observe_stamped(
3870            observe_request("s", SessionEvent::SessionStart, Some("/p")),
3871            Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3872            Origin::Replay,
3873        );
3874        reg.observe_stamped(
3875            observe_request("s", SessionEvent::PreToolUse, None),
3876            Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3877            Origin::Journal,
3878        );
3879        reg.end_stamped(
3880            "s",
3881            None,
3882            None,
3883            Some(stamp("2026-10-03T03:40:09Z", "1-c")),
3884            Origin::Journal,
3885        );
3886        assert_eq!(
3887            reg.delivery(),
3888            DeliveryStats {
3889                socket: 0,
3890                recovered: 2,
3891                superseded: 0,
3892                replayed: 1
3893            }
3894        );
3895    }
3896
3897    #[test]
3898    fn a_socket_copy_that_lost_the_race_to_the_poll_is_not_a_recovery() {
3899        // The journal poll can read a line in the instant between the sink's
3900        // journal write and its POST. The POST then arrives as a duplicate, which
3901        // takes the event back out of `recovered` (and is not a socket delivery).
3902        let reg = SessionsRegistry::new();
3903        let st = stamp("2026-10-03T03:40:00Z", "1-a");
3904        reg.observe_stamped(
3905            observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3906            Some(st.clone()),
3907            Origin::Journal,
3908        );
3909        assert_eq!(reg.delivery().recovered, 1, "provisionally recovered");
3910        reg.observe_stamped(
3911            observe_request("s", SessionEvent::PreToolUse, None),
3912            Some(st),
3913            Origin::Socket,
3914        );
3915        assert_eq!(
3916            reg.delivery(),
3917            DeliveryStats {
3918                socket: 0,
3919                recovered: 0,
3920                superseded: 0,
3921                replayed: 0
3922            }
3923        );
3924    }
3925
3926    #[test]
3927    fn the_journal_copy_of_a_socket_event_counts_for_nothing() {
3928        let reg = SessionsRegistry::new();
3929        let st = stamp("2026-10-03T03:40:00Z", "1-a");
3930        reg.observe_stamped(
3931            observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3932            Some(st.clone()),
3933            Origin::Socket,
3934        );
3935        reg.observe_stamped(
3936            observe_request("s", SessionEvent::PreToolUse, None),
3937            Some(st.clone()),
3938            Origin::Journal,
3939        );
3940        // A retried POST of the same event is a duplicate too.
3941        reg.observe_stamped(
3942            observe_request("s", SessionEvent::PreToolUse, None),
3943            Some(st),
3944            Origin::Socket,
3945        );
3946        assert_eq!(
3947            reg.delivery(),
3948            DeliveryStats {
3949                socket: 1,
3950                recovered: 0,
3951                superseded: 0,
3952                replayed: 0
3953            }
3954        );
3955    }
3956
3957    #[test]
3958    fn an_orphan_journal_end_is_not_counted_as_a_recovery() {
3959        // A journal `end` for a session the registry does not hold applies to
3960        // nothing, so it is not a recovery.
3961        let reg = SessionsRegistry::new();
3962        reg.end_stamped(
3963            "ghost",
3964            None,
3965            None,
3966            Some(stamp("2026-10-03T03:41:00Z", "2-a")),
3967            Origin::Journal,
3968        );
3969        assert_eq!(reg.delivery(), DeliveryStats::default());
3970    }
3971
3972    /// A session whose newest applied event is `1-b` at 03:40:05, from the socket.
3973    fn session_at_0340_05() -> SessionsRegistry {
3974        let reg = SessionsRegistry::new();
3975        reg.observe_stamped(
3976            observe_request("s", SessionEvent::Stop, Some("/p")),
3977            Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3978            Origin::Socket,
3979        );
3980        reg
3981    }
3982
3983    #[test]
3984    fn a_dropped_post_that_a_newer_event_overtook_is_superseded() {
3985        // The POST of `1-a` never arrived, and the socket delivered the newer
3986        // `1-b` before the journal poll read `1-a`'s line. The registry rightly
3987        // ignores it, but it was still a dropped POST.
3988        let reg = session_at_0340_05();
3989        let dropped = stamp("2026-10-03T03:40:00Z", "1-a");
3990        reg.observe_stamped(
3991            observe_request("s", SessionEvent::PreToolUse, None),
3992            Some(dropped.clone()),
3993            Origin::Journal,
3994        );
3995        assert_eq!(
3996            reg.delivery(),
3997            DeliveryStats {
3998                socket: 1,
3999                recovered: 0,
4000                superseded: 1,
4001                replayed: 0
4002            }
4003        );
4004        // Reading the same line again (a rewritten journal) counts it once.
4005        reg.observe_stamped(
4006            observe_request("s", SessionEvent::PreToolUse, None),
4007            Some(dropped),
4008            Origin::Journal,
4009        );
4010        assert_eq!(reg.delivery().superseded, 1);
4011        assert_eq!(
4012            reg.lock_sessions()["s"].state,
4013            SessionState::Idle,
4014            "and the newer event still stands"
4015        );
4016    }
4017
4018    #[test]
4019    fn a_stale_journal_end_whose_post_never_came_is_superseded() {
4020        // A `SessionEnd` older than an event the session has since applied (a
4021        // resume) is ignored all the same.
4022        let reg = session_at_0340_05();
4023        reg.end_stamped(
4024            "s",
4025            None,
4026            None,
4027            Some(stamp("2026-10-03T03:40:00Z", "1-a")),
4028            Origin::Journal,
4029        );
4030        assert_eq!(reg.delivery().superseded, 1);
4031        assert_eq!(reg.lock_sessions()["s"].state, SessionState::Idle);
4032    }
4033
4034    #[test]
4035    fn a_late_post_takes_a_superseded_event_back_out() {
4036        // The journal poll read `1-a` in the instant before its POST arrived. The
4037        // POST then lands a moment late, ignored as out of order, and the event
4038        // was not a drop after all; a client retry takes nothing back again.
4039        let reg = session_at_0340_05();
4040        let late = stamp("2026-10-03T03:40:00Z", "1-a");
4041        reg.observe_stamped(
4042            observe_request("s", SessionEvent::PreToolUse, None),
4043            Some(late.clone()),
4044            Origin::Journal,
4045        );
4046        assert_eq!(reg.delivery().superseded, 1, "provisionally dropped");
4047        for _ in 0..2 {
4048            reg.observe_stamped(
4049                observe_request("s", SessionEvent::PreToolUse, None),
4050                Some(late.clone()),
4051                Origin::Socket,
4052            );
4053        }
4054        assert_eq!(
4055            reg.delivery(),
4056            DeliveryStats {
4057                socket: 1,
4058                recovered: 0,
4059                superseded: 0,
4060                replayed: 0
4061            }
4062        );
4063    }
4064
4065    #[test]
4066    fn a_post_late_enough_to_be_applied_is_a_delivery_after_all() {
4067        // Older than the newest event by more than the reorder window, the socket
4068        // applies it: counted as a socket delivery, and not as a drop.
4069        let reg = session_at_0340_05();
4070        let late = stamp("2026-10-03T03:39:00Z", "1-a");
4071        reg.observe_stamped(
4072            observe_request("s", SessionEvent::PreToolUse, None),
4073            Some(late.clone()),
4074            Origin::Journal,
4075        );
4076        reg.observe_stamped(
4077            observe_request("s", SessionEvent::PreToolUse, None),
4078            Some(late),
4079            Origin::Socket,
4080        );
4081        assert_eq!(
4082            reg.delivery(),
4083            DeliveryStats {
4084                socket: 2,
4085                recovered: 0,
4086                superseded: 0,
4087                replayed: 0
4088            }
4089        );
4090    }
4091
4092    #[test]
4093    fn the_journal_copy_of_a_socket_event_dropped_as_out_of_order_is_not_a_drop() {
4094        // Concurrent hooks reach the socket out of order: `1-a` arrives after the
4095        // newer `1-b` and is ignored. Its POST did arrive, so when the journal
4096        // poll reads the same line later, that is a duplicate, not a missing POST.
4097        let reg = session_at_0340_05();
4098        let reordered = stamp("2026-10-03T03:40:00Z", "1-a");
4099        reg.observe_stamped(
4100            observe_request("s", SessionEvent::PreToolUse, None),
4101            Some(reordered.clone()),
4102            Origin::Socket,
4103        );
4104        reg.observe_stamped(
4105            observe_request("s", SessionEvent::PreToolUse, None),
4106            Some(reordered),
4107            Origin::Journal,
4108        );
4109        assert_eq!(
4110            reg.delivery(),
4111            DeliveryStats {
4112                socket: 1,
4113                recovered: 0,
4114                superseded: 0,
4115                replayed: 0
4116            }
4117        );
4118    }
4119
4120    #[test]
4121    fn a_stale_replayed_event_is_counted_as_nothing() {
4122        // A replay is not a recovery and has no socket copy to race, so one that
4123        // is also stale is simply ignored.
4124        let reg = session_at_0340_05();
4125        reg.observe_stamped(
4126            observe_request("s", SessionEvent::PreToolUse, None),
4127            Some(stamp("2026-10-03T03:40:00Z", "1-a")),
4128            Origin::Replay,
4129        );
4130        assert_eq!(
4131            reg.delivery(),
4132            DeliveryStats {
4133                socket: 1,
4134                recovered: 0,
4135                superseded: 0,
4136                replayed: 0
4137            }
4138        );
4139    }
4140
4141    #[test]
4142    fn superseded_events_are_remembered_only_up_to_the_ring_size() {
4143        let reg = session_at_0340_05();
4144        for i in 0..=MAX_RECENT_SEQS {
4145            reg.observe_stamped(
4146                observe_request("s", SessionEvent::PreToolUse, None),
4147                Some(stamp("2026-10-03T03:40:00Z", &format!("9-{i}"))),
4148                Origin::Journal,
4149            );
4150        }
4151        assert_eq!(reg.delivery().superseded, MAX_RECENT_SEQS as u64 + 1);
4152        let guard = reg.lock_sessions();
4153        assert_eq!(guard["s"].superseded_seqs.len(), MAX_RECENT_SEQS);
4154        assert!(!guard["s"].superseded_seqs.contains(&"9-0".to_string()));
4155    }
4156
4157    #[test]
4158    fn delivery_stats_serialize_with_the_documented_names() {
4159        let value = serde_json::to_value(DeliveryStats {
4160            socket: 5,
4161            recovered: 2,
4162            superseded: 3,
4163            replayed: 1,
4164        })
4165        .unwrap();
4166        assert_eq!(
4167            value,
4168            serde_json::json!({ "socket": 5, "recovered": 2, "superseded": 3, "replayed": 1 })
4169        );
4170    }
4171
4172    #[test]
4173    fn the_socket_copy_of_a_replayed_event_takes_nothing_back_from_recoveries() {
4174        let reg = SessionsRegistry::new();
4175        let replayed = stamp("2026-10-03T03:40:00Z", "1-a");
4176        reg.observe_stamped(
4177            observe_request("s", SessionEvent::SessionStart, Some("/p")),
4178            Some(replayed.clone()),
4179            Origin::Replay,
4180        );
4181        reg.observe_stamped(
4182            observe_request("s", SessionEvent::PreToolUse, None),
4183            Some(stamp("2026-10-03T03:40:05Z", "1-b")),
4184            Origin::Journal,
4185        );
4186        // A POST that was queued while the daemon started lands after the replay.
4187        reg.observe_stamped(
4188            observe_request("s", SessionEvent::SessionStart, None),
4189            Some(replayed),
4190            Origin::Socket,
4191        );
4192        assert_eq!(
4193            reg.delivery(),
4194            DeliveryStats {
4195                socket: 0,
4196                recovered: 1,
4197                superseded: 0,
4198                replayed: 1
4199            },
4200            "the real recovery is not masked"
4201        );
4202    }
4203
4204    #[test]
4205    fn repeated_socket_copies_of_one_raced_event_take_it_back_only_once() {
4206        let reg = SessionsRegistry::new();
4207        let raced = stamp("2026-10-03T03:40:00Z", "1-a");
4208        reg.observe_stamped(
4209            observe_request("s", SessionEvent::PreToolUse, Some("/p")),
4210            Some(raced.clone()),
4211            Origin::Journal,
4212        );
4213        reg.observe_stamped(
4214            observe_request("s", SessionEvent::PostToolUse, None),
4215            Some(stamp("2026-10-03T03:40:05Z", "1-b")),
4216            Origin::Journal,
4217        );
4218        assert_eq!(reg.delivery().recovered, 2);
4219        // A client retry delivers the first event's POST twice.
4220        for _ in 0..2 {
4221            reg.observe_stamped(
4222                observe_request("s", SessionEvent::PreToolUse, None),
4223                Some(raced.clone()),
4224                Origin::Socket,
4225            );
4226        }
4227        assert_eq!(reg.delivery().recovered, 1, "the other recovery stands");
4228    }
4229
4230    #[test]
4231    fn a_socket_end_counts_even_for_a_session_the_registry_does_not_hold() {
4232        let reg = SessionsRegistry::new();
4233        reg.end_stamped(
4234            "ghost",
4235            None,
4236            None,
4237            Some(stamp("2026-10-03T03:41:00Z", "9-a")),
4238            Origin::Socket,
4239        );
4240        assert_eq!(reg.delivery().socket, 1);
4241    }
4242}