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