Skip to main content

omni_dev/
sessions.rs

1//! The cross-window Claude Code session registry engine.
2//!
3//! Maintains the live, authoritative set of running Claude Code sessions across
4//! *every* terminal and VS Code window for the logged-in user, with a coarse
5//! inferred state (working / idle / waiting-for-input / waiting-for-permission).
6//! Fed by three independent feeds that each degrade gracefully — Claude Code
7//! **hooks** (`omni-dev sessions hook`), a **transcript-file watcher** over
8//! `~/.claude/projects/**/*.jsonl`, and the companion VS Code extension
9//! reporting each window's embedded Claude tabs/terminals. See ADR-0052.
10//!
11//! This is the standalone engine, analogous to [`crate::worktrees`],
12//! [`crate::browser`], and [`crate::snowflake`]; the daemon adapter lives in
13//! [`crate::daemon::services::sessions`].
14//!
15//! Like the worktrees engine this is cheap and in-memory — no async setup, no
16//! secret persisted. Two maps live behind a pair of [`std::sync::Mutex`]es that
17//! are **never held across an `.await`** (the Snowflake rule): the *sessions*
18//! keyed by their Claude `session_id`, and the *windows* keyed by the companion's
19//! per-window key (the Claude-embedding reports used to tag a session's source).
20//! Every op is pure CPU under a lock, so liveness reaping happens inline on each
21//! read rather than from a background task — exactly as [`crate::worktrees`] does.
22//!
23//! State is **inferred**, not first-class: Claude Code exposes no dedicated
24//! session-state event, so `working`/`idle` is best-effort (see
25//! [`SessionState::for_event`]). `waiting_for_permission` / `waiting_for_input`
26//! are reliable (they come from a `Notification` hook); the transcript watcher
27//! backstops the "thinking window" where no hook fires.
28//!
29//! The one exception is Feed 4, the [`stream`] tracker behind
30//! `omni-dev claude-wrap`: it reads the exact state out of Claude's stream-json
31//! stdio and reports it as [`SessionEvent::StreamState`], which
32//! [`SessionState::for_event`] applies verbatim. See ADR-0057.
33//!
34//! Feed 5 is pi.dev's: a generated extension in `~/.pi/agent/extensions/` maps
35//! pi's lifecycle events to a state and reports it the same way, tagged
36//! [`Agent::Pi`] (#1901).
37//!
38//! Codex is a second hook feed: `omni-dev sessions hook --agent codex`, run from
39//! `$CODEX_HOME/hooks.json`, maps Codex's events onto the same [`SessionEvent`]s
40//! and tags its sessions [`Agent::Codex`] (#1907, ADR-0087). The
41//! [`codex_watcher`] supplements it from Codex's rollout files and thread locks:
42//! discovery, archive and killed-process ends, and idle liveness (#1909).
43
44use std::collections::{HashMap, VecDeque};
45use std::path::{Path, PathBuf};
46use std::sync::{Mutex, MutexGuard, PoisonError};
47use std::time::Duration;
48
49use chrono::{DateTime, Utc};
50use serde::{Deserialize, Serialize};
51use tokio::sync::watch;
52
53#[cfg(unix)]
54pub mod codex_app_server;
55pub mod codex_watcher;
56pub(crate) mod pid_liveness;
57pub(crate) mod pid_watcher;
58pub mod relocate;
59pub mod stream;
60pub mod watcher;
61
62/// How long a session may go silent before it ages out of the registry, absent
63/// a confirmed-alive pid.
64///
65/// Unlike a VS Code window (which heartbeats every ~10s), a running Claude
66/// session emits nothing while idle at the prompt, so its only liveness signal
67/// used to be activity — a hook event or transcript growth. The TTL is
68/// generous for exactly that reason: a session that has done nothing for this
69/// long is assumed gone (a `claude` that exited without firing `SessionEnd`)
70/// and reaped on the next read.
71///
72/// Since #1916, a session whose pid the [`pid_watcher`] has
73/// independently confirmed alive is kept out of this TTL's reach entirely: the
74/// watcher refreshes `last_seen` itself on a schedule of its own, off this
75/// lock, so `reap_sessions` never needs to know about pids at all. This TTL is
76/// now the fallback for a session with no pid (older hooks, the transcript
77/// watcher, pi.dev), one the watcher has not yet confirmed, or whose pid it
78/// can't confirm (an unprompted spare process, or one another `session_id`
79/// has since taken over). A still-alive idle session bound by this TTL
80/// re-appears the moment it next does anything. See ADR-0052.
81const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
82
83/// How long an **ended** session lingers before it is reaped, so `sessions list`
84/// briefly shows a session that just finished (`SessionEnd` fired → [`end`]) as
85/// `ended` rather than having it vanish instantly.
86///
87/// [`end`]: SessionsRegistry::end
88const ENDED_SESSION_TTL: Duration = Duration::from_secs(10);
89
90/// How long a companion window-embedding report survives without a refresh.
91/// Mirrors the worktrees window TTL (three missed ~10s heartbeats): a window
92/// that crashed without unregistering stops tagging its sessions as VS Code
93/// embedded on the next read.
94const DEFAULT_WINDOW_TTL: Duration = Duration::from_secs(30);
95
96/// Ceiling on live session entries, so a runaway feed cannot grow daemon memory
97/// faster than the TTL reaps it (the worktrees `MAX_WINDOWS` precedent, #1140).
98/// Far above any real concurrent-session count; at the cap a genuinely new
99/// session evicts the longest-silent entry rather than being rejected, so ingest
100/// stays infallible.
101const MAX_SESSIONS: usize = 512;
102
103/// How many replaced agent processes a session remembers (#1948). Enough for a
104/// burst of window reloads, each of which replaces the previous process before
105/// its `SessionEnd` has necessarily arrived; the oldest is forgotten first.
106const MAX_REPLACED_PIDS: usize = 8;
107
108/// Ceiling on live window-embedding reports, mirroring the worktrees registry cap.
109const MAX_WINDOWS: usize = 256;
110
111/// The coarse, inferred lifecycle state of a Claude Code session.
112///
113/// Serialized `snake_case` (`waiting_for_permission`, …) into `list`/`status`
114/// payloads. `waiting_for_*` are **reliable** (a `Notification` hook fires them
115/// directly); `working`/`idle` are best-effort inference from `PreToolUse` /
116/// `Stop` plus the transcript-growth backstop (Claude Code ships no dedicated
117/// state event — ADR-0052).
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum SessionState {
121    /// Session just started (`SessionStart`), before any turn.
122    Starting,
123    /// Actively processing a turn — a tool call (`PreToolUse`/`PostToolUse`), a
124    /// submitted prompt (`UserPromptSubmit`), or observed transcript growth.
125    Working,
126    /// Finished a turn and waiting at the prompt (`Stop`).
127    Idle,
128    /// Blocked on the user for a plain input/idle notification.
129    WaitingForInput,
130    /// Blocked on the user to approve a tool/permission prompt.
131    WaitingForPermission,
132    /// The session ended (`SessionEnd`); reaped shortly after via
133    /// [`ENDED_SESSION_TTL`].
134    Ended,
135}
136
137impl SessionState {
138    /// The state a sighting of `event` implies, given the session's `current`
139    /// state (`None` for a brand-new session). This is the whole inference
140    /// machine, kept in one testable place:
141    ///
142    /// - `SessionStart` → [`Starting`](Self::Starting)
143    /// - `UserPromptSubmit` / `PreToolUse` / `PostToolUse` →
144    ///   [`Working`](Self::Working)
145    /// - `TranscriptGrew` → [`Working`](Self::Working), **except** while
146    ///   [`Starting`](Self::Starting) /
147    ///   [`WaitingForInput`](Self::WaitingForInput) /
148    ///   [`WaitingForPermission`](Self::WaitingForPermission) /
149    ///   [`Ended`](Self::Ended), which it leaves **unchanged** — growth is
150    ///   expected in those states without the session doing anything, so it is
151    ///   not evidence a turn is running (#1418, #1946)
152    /// - `Stop` → [`Idle`](Self::Idle)
153    /// - `Notification(PermissionPrompt)` →
154    ///   [`WaitingForPermission`](Self::WaitingForPermission)
155    /// - `Notification(IdlePrompt | AgentNeedsInput)` →
156    ///   [`WaitingForInput`](Self::WaitingForInput)
157    /// - `Notification(Other)` → **unchanged** (an unclassified notification is
158    ///   not evidence of a state change)
159    /// - `TranscriptDiscovered` → the current state if known, else
160    ///   [`Idle`](Self::Idle) (a passively-discovered session's activity is
161    ///   unknown; a later hook or growth upgrades it)
162    /// - `StreamState(s)` → `s` verbatim (an authoritative stream-json report;
163    ///   the only non-inferred variant — see ADR-0057)
164    #[must_use]
165    pub fn for_event(event: &SessionEvent, current: Option<Self>) -> Self {
166        match event {
167            SessionEvent::SessionStart => Self::Starting,
168            SessionEvent::UserPromptSubmit
169            | SessionEvent::PreToolUse
170            | SessionEvent::PostToolUse => Self::Working,
171            // Growth is only evidence the transcript file got bigger. From most
172            // states that does imply a turn is running, but in two it does not,
173            // and reading it as `working` would overwrite a state a hook
174            // reported directly:
175            //
176            // - `waiting_for_*` — Claude flushes the assistant `tool_use` line
177            //   *before* the prompt it is asking about can be answered, so the
178            //   watcher's next scan would downgrade the wait and the row would
179            //   go quiet exactly when it should be shouting (#1418);
180            // - `ended` — a session's last lines land around `SessionEnd`, so a
181            //   scan inside the ended-linger window would revive the entry and
182            //   hold a phantom `working` row for the whole session TTL;
183            // - `starting` — a resumed session (a VS Code window reload,
184            //   `claude --resume`) keeps its id, and the *old* process appends a
185            //   `cost-state` line to the shared transcript as it exits. The 5s
186            //   scan usually sees that write after the new `SessionStart`, and
187            //   no hook fires while an unprompted session sits idle, so reading
188            //   it as `working` would pin the row busy until the next prompt
189            //   (#1946). Nothing is lost: a hook-fed session's first prompt
190            //   fires `UserPromptSubmit`, and a watcher-only session never
191            //   reaches `starting`, because `SessionStart` is a hook.
192            //
193            // That is ADR-0052's reliable-over-inferred ordering, and the rule
194            // `stream.rs`'s `state` already applies to a permission prompt. Each
195            // is released by any later hook, which is inference-free.
196            SessionEvent::TranscriptGrew => match current {
197                Some(
198                    held @ (Self::Starting
199                    | Self::WaitingForInput
200                    | Self::WaitingForPermission
201                    | Self::Ended),
202                ) => held,
203                _ => Self::Working,
204            },
205            SessionEvent::Stop => Self::Idle,
206            // An authoritative state from a stream-json observer wins outright,
207            // ignoring the inferred `current` — it read the exact state from the
208            // stream rather than guessing from a lifecycle event (ADR-0057).
209            SessionEvent::StreamState(state) => *state,
210            SessionEvent::Notification(NotificationKind::PermissionPrompt) => {
211                Self::WaitingForPermission
212            }
213            SessionEvent::Notification(
214                NotificationKind::IdlePrompt | NotificationKind::AgentNeedsInput,
215            ) => Self::WaitingForInput,
216            // An unclassified notification carries no state signal, and a
217            // passively-discovered transcript's activity is unknown: keep the
218            // current state (or default a brand-new session to Idle).
219            SessionEvent::Notification(NotificationKind::Other)
220            | SessionEvent::TranscriptDiscovered => current.unwrap_or(Self::Idle),
221        }
222    }
223}
224
225/// The classification of a Claude Code `Notification` hook.
226///
227/// Derived by the hook sink from the notification message (the message text is
228/// version-unstable, so classification is best-effort with an
229/// [`Other`](Self::Other) fallback).
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum NotificationKind {
233    /// Claude is asking to run a tool / use a permission — reliably
234    /// [`WaitingForPermission`](SessionState::WaitingForPermission).
235    PermissionPrompt,
236    /// Claude has been idle waiting for the user to respond.
237    IdlePrompt,
238    /// An agent/subagent needs the user's input.
239    AgentNeedsInput,
240    /// A notification we could not classify — carries no state signal.
241    Other,
242}
243
244/// A sighting of a session, from a hook event or the transcript watcher.
245///
246/// Drives the [`SessionState::for_event`] inference and refreshes liveness.
247/// Serialized on the wire as part of an [`ObserveRequest`]; `snake_case`, with
248/// the notification kind nested (`{"notification":"permission_prompt"}`).
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum SessionEvent {
252    /// `SessionStart` hook.
253    SessionStart,
254    /// `UserPromptSubmit` hook — a prompt was submitted.
255    UserPromptSubmit,
256    /// `PreToolUse` hook — about to run a tool.
257    PreToolUse,
258    /// `PostToolUse` hook — a tool finished.
259    PostToolUse,
260    /// `Stop` hook — the turn finished.
261    Stop,
262    /// `Notification` hook, classified into a [`NotificationKind`].
263    Notification(NotificationKind),
264    /// The transcript watcher saw this session's `.jsonl` grow (the
265    /// "thinking-window" backstop, where no hook fires).
266    TranscriptGrew,
267    /// The transcript watcher discovered a session's `.jsonl` it had not seen —
268    /// a session that started before the daemon, or before hooks were installed.
269    TranscriptDiscovered,
270    /// An **authoritative** state reported directly by a stream-json observer —
271    /// the `omni-dev claude-wrap` wrapper reading Claude's `--output-format
272    /// stream-json` stdout, where the exact state is first-class (`init` →
273    /// working, `result` → idle, `can_use_tool` → waiting-for-permission). Unlike
274    /// every other variant this is *not* inferred: [`SessionState::for_event`]
275    /// returns the carried state verbatim. Serialized as
276    /// `{"stream_state":"waiting_for_permission"}` (ADR-0057).
277    StreamState(SessionState),
278}
279
280/// Where a session is running, resolved at [`list`](SessionsRegistry::list) time
281/// by joining a session's `cwd` against the companion's window-embedding reports.
282///
283/// A session whose `cwd` lies under a reporting VS Code window that has ≥1 Claude
284/// tab/terminal is tagged [`VsCode`](Self::VsCode); everything else is
285/// [`Terminal`](Self::Terminal) — meaning "not matched to a reporting VS Code
286/// window" (a bare terminal session, or a VS Code session whose companion is not
287/// installed). Serialized as `{"kind":"terminal"}` /
288/// `{"kind":"vs_code","window_key":"…"}`.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(tag = "kind", rename_all = "snake_case")]
291pub enum Source {
292    /// Not matched to any reporting VS Code window.
293    Terminal,
294    /// Embedded in a VS Code window (matched by `cwd`), carrying that window's
295    /// companion key for a focus action.
296    VsCode {
297        /// The matched window's companion key.
298        window_key: String,
299    },
300}
301
302/// Which coding agent a session belongs to.
303///
304/// The registry is keyed by `session_id` alone. Claude Code's ids are UUID v4,
305/// while pi.dev's and Codex's are both UUID v7, so the version nibble no longer
306/// separates every pair (#1907): single-key identity instead rests on v7's 74
307/// random bits, which make a pi/Codex collision improbable — not impossible, and
308/// not worth a re-key (ADR-0087). The tag lets a consumer tell the agents apart.
309/// Serialized `snake_case`; absent on the wire means [`Claude`](Self::Claude), so
310/// the Claude feeds (hooks, watcher, `claude-wrap`) send it by omission and stay
311/// byte-identical to senders that predate it (#1901).
312///
313/// A daemon built with an older variant set rejects an unknown value
314/// (`unknown variant`), so a newer sink's `observe` is dropped until the daemon
315/// is upgraded — the sinks are fail-open, so that is silent.
316#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "snake_case")]
318pub enum Agent {
319    /// Claude Code — every feed but the pi extension.
320    #[default]
321    Claude,
322    /// pi.dev's coding agent, reported by the extension `sessions install-hooks`
323    /// writes into `~/.pi/agent/extensions/`.
324    Pi,
325    /// OpenAI Codex (CLI, VS Code extension and Desktop), reported by
326    /// `sessions hook --agent codex` from `$CODEX_HOME/hooks.json` (#1907).
327    Codex,
328}
329
330impl Agent {
331    /// The agent's display name, for the tray and other human-facing labels.
332    #[must_use]
333    pub fn display_name(self) -> &'static str {
334        match self {
335            Self::Claude => "Claude",
336            Self::Pi => "pi",
337            Self::Codex => "Codex",
338        }
339    }
340
341    /// Whether this is the default agent, so [`ObserveRequest`] can omit it.
342    #[must_use]
343    pub fn is_claude(&self) -> bool {
344        *self == Self::Claude
345    }
346}
347
348/// An idempotent session sighting sent to the registry — the wire payload of the
349/// `observe` op, and the argument to [`SessionsRegistry::observe`].
350///
351/// The hook sink and the transcript watcher both produce these; every field but
352/// `session_id` and `event` is best-effort and *fills in* missing data on an
353/// existing entry without ever clobbering known data with `None`.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct ObserveRequest {
356    /// The Claude `session_id` (a UUID) — the primary key. Equal to the
357    /// transcript filename stem and (per ADR-0052) the VS Code extension's tab
358    /// key, so the three feeds join without heuristics.
359    pub session_id: String,
360    /// The session's working directory, when known (from the hook `cwd`).
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub cwd: Option<PathBuf>,
363    /// The `~/.claude/projects/**/<session-id>.jsonl` transcript path, when known.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub transcript_path: Option<PathBuf>,
366    /// The event that produced this sighting; drives the state inference.
367    pub event: SessionEvent,
368    /// The repository name enriched from `cwd` by the adapter (git2), when
369    /// resolvable. Stored verbatim; the engine does no disk I/O.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub repo: Option<String>,
372    /// The model id, when a hook reports one.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub model: Option<String>,
375    /// Which agent the session belongs to; omitted for Claude Code.
376    #[serde(default, skip_serializing_if = "Agent::is_claude")]
377    pub agent: Agent,
378    /// The agent process's pid, when known: the hook sink's parent, or
379    /// `claude-wrap`'s wrapped child. Tells a resumed session's new process
380    /// from the old one it replaced, which share a `session_id` (#1948). Every
381    /// other feed (the watchers, the Codex app-server, pi.dev) sends `None`.
382    ///
383    /// This is also the seed for pid-based liveness (#1916): the
384    /// [`pid_watcher`] independently probes every pid it sees here, off this
385    /// wire and off the registry lock, rather than trusting a client-supplied
386    /// process identity — see [`SessionEntry::pid_start`].
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub pid: Option<u32>,
389}
390
391/// A companion report of one VS Code window's embedded Claude sessions.
392///
393/// The wire payload of the `window` op. The companion cannot expose a tab's
394/// `session_id` (Claude Code's extension has no public API — ADR-0052), so it
395/// reports only the *counts* of Claude tabs/terminals plus the window's folders;
396/// the join to a specific session is by `cwd`.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct WindowReport {
399    /// The companion-owned per-window key (also the worktrees registration key).
400    pub key: String,
401    /// The window's workspace-folder absolute paths, for the `cwd` join.
402    #[serde(default)]
403    pub folders: Vec<PathBuf>,
404    /// How many Claude editor tabs (`claudeVSCodePanel` webviews) the window has.
405    #[serde(default)]
406    pub tabs: usize,
407    /// How many Claude Code integrated terminals the window has.
408    #[serde(default)]
409    pub terminals: usize,
410}
411
412impl WindowReport {
413    /// Whether this window has any Claude embedding at all — the gate for
414    /// tagging a matching session as [`Source::VsCode`].
415    #[must_use]
416    fn has_embedding(&self) -> bool {
417        self.tabs > 0 || self.terminals > 0
418    }
419}
420
421/// One live session in the registry.
422///
423/// Serialized verbatim into `list` / `status` payloads; consumers compute age
424/// from `last_seen` (RFC 3339). `source` is resolved at
425/// [`list`](SessionsRegistry::list) time (stored as [`Source::Terminal`] until
426/// then).
427#[derive(Debug, Clone, Serialize)]
428pub struct SessionEntry {
429    /// The Claude `session_id`.
430    pub session_id: String,
431    /// The session's working directory, when known.
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub cwd: Option<PathBuf>,
434    /// The transcript path, when known.
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub transcript_path: Option<PathBuf>,
437    /// The repository name enriched from `cwd`, when resolvable.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub repo: Option<String>,
440    /// The model id, when reported.
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub model: Option<String>,
443    /// Which agent the session belongs to. Set by the first sighting and never
444    /// changed, since a session id belongs to exactly one agent.
445    pub agent: Agent,
446    /// The current inferred state.
447    pub state: SessionState,
448    /// Where the session runs, resolved on read.
449    pub source: Source,
450    /// The most recent event observed for this session.
451    pub last_event: SessionEvent,
452    /// When the session was first observed (RFC 3339).
453    pub started_at: DateTime<Utc>,
454    /// When the registry last heard from this session (RFC 3339).
455    pub last_seen: DateTime<Utc>,
456    /// The agent process that owns the session: the pid of its latest
457    /// pid-bearing sighting that did not come from a replaced process.
458    #[serde(skip)]
459    pub(crate) pid: Option<u32>,
460    /// `pid`'s start-time identity token (#1916), **set only by the
461    /// [`pid_watcher`]**, never from a client-supplied value: it is captured
462    /// the first time the watcher independently confirms `pid` alive, and
463    /// reset to `None` whenever `pid` changes owner (a new pid's identity is
464    /// unconfirmed until the watcher next probes it). `None` until then, so a
465    /// pid the watcher has not yet gotten to on its own schedule simply has no
466    /// opinion on liveness rather than a stale or spoofable one.
467    #[serde(skip)]
468    pub(crate) pid_start: Option<String>,
469    /// Whether this session has had at least one `UserPromptSubmit`. Gates the
470    /// pid-liveness TTL exemption (#1916): a spare process VS Code keeps alive
471    /// that has never been prompted must not be pinned forever just because its
472    /// pid is alive.
473    #[serde(skip)]
474    pub(crate) prompted: bool,
475    /// The processes the session was taken over from, oldest first and capped
476    /// at [`MAX_REPLACED_PIDS`] — the old processes of in-place resumes. An
477    /// `end` from one of them is ignored (#1948).
478    #[serde(skip)]
479    pub(crate) replaced_pids: VecDeque<u32>,
480}
481
482/// One pid-bearing session, as [`SessionsRegistry::pid_liveness_candidates`]
483/// hands it to the [`pid_watcher`](pid_watcher) — a plain data snapshot with no
484/// lock and no process handle attached, so the watcher can probe it entirely
485/// off the registry lock.
486#[derive(Debug, Clone)]
487pub(crate) struct PidCandidate {
488    pub(crate) session_id: String,
489    pub(crate) pid: u32,
490    /// The identity token last confirmed for `pid`, if any (see
491    /// [`SessionEntry::pid_start`]).
492    pub(crate) pid_start: Option<String>,
493    pub(crate) prompted: bool,
494    /// When this session was last seen, used to pick the *currently active*
495    /// session_id among several sharing a pid — not `started_at`, which would
496    /// wrongly keep favouring a `/clear`-abandoned session forever over one a
497    /// later `/resume` reactivated, since creation order never changes but
498    /// `last_seen` moves with whichever session_id is actually receiving hooks.
499    pub(crate) last_seen: DateTime<Utc>,
500}
501
502/// One companion window-embedding report, with its liveness stamp.
503#[derive(Debug, Clone)]
504struct WindowEntry {
505    /// The report as sent by the companion.
506    report: WindowReport,
507    /// When the report last arrived (register or refresh).
508    last_seen: DateTime<Utc>,
509}
510
511/// The cross-window session registry.
512///
513/// The in-memory, TTL-reaped set of running Claude sessions plus the companion
514/// window-embedding reports used to tag a session's [`Source`]. Hosted by
515/// [`SessionsService`](crate::daemon::services::sessions::SessionsService).
516pub struct SessionsRegistry {
517    /// Live sessions keyed by `session_id`.
518    sessions: Mutex<HashMap<String, SessionEntry>>,
519    /// Companion window-embedding reports keyed by window key. Behind its own
520    /// mutex, taken independently of `sessions`, so the two never nest.
521    windows: Mutex<HashMap<String, WindowEntry>>,
522    /// How long a session survives without activity.
523    session_ttl: Duration,
524    /// How long an `ended` session lingers before reaping.
525    ended_ttl: Duration,
526    /// How long a window-embedding report survives without a refresh.
527    window_ttl: Duration,
528    /// A monotonically-bumped version counter, incremented whenever the state a
529    /// subscriber renders changes. A push-subscription consumer holds a
530    /// [`watch::Receiver`] from [`subscribe_changes`](Self::subscribe_changes)
531    /// and wakes on each bump to re-snapshot (#1414) — the
532    /// [`WorktreesRegistry`](crate::worktrees::WorktreesRegistry) arrangement,
533    /// one service over. The counter's *value* is immaterial — only that it
534    /// changed — so a burst coalesces into one wake and the server diffs the
535    /// resulting snapshot to suppress duplicate frames.
536    ///
537    /// `watch` needs no runtime and never blocks, so it fits this engine's
538    /// no-async-setup posture; every [`bump`](Self::bump) happens *after* the map
539    /// guard is dropped, so the `std::Mutex`-never-across-`.await` rule is intact
540    /// (and the watch's own internal lock is never nested under a map lock).
541    changes: watch::Sender<u64>,
542}
543
544impl SessionsRegistry {
545    /// Creates the registry with the default liveness TTLs. Cheap — no I/O.
546    #[must_use]
547    pub fn new() -> Self {
548        Self {
549            sessions: Mutex::new(HashMap::new()),
550            windows: Mutex::new(HashMap::new()),
551            session_ttl: DEFAULT_SESSION_TTL,
552            ended_ttl: ENDED_SESSION_TTL,
553            window_ttl: DEFAULT_WINDOW_TTL,
554            changes: watch::channel(0).0,
555        }
556    }
557
558    /// A change-notification receiver for the push subscription: it observes a
559    /// new value each time the rendered session state changes (see
560    /// [`bump`](Self::bump)). Created with the current version already marked
561    /// seen, so the first [`watch::Receiver::changed`] resolves on the *next*
562    /// change — the subscriber sends its own initial snapshot up front and then
563    /// waits for deltas (#1414).
564    #[must_use]
565    pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
566        self.changes.subscribe()
567    }
568
569    /// Signals subscribers that the rendered state changed. Non-blocking and
570    /// runtime-free; called only *after* a map guard is released so the locks
571    /// never nest. A send never fails here (the sender is owned by the registry,
572    /// which outlives every receiver, and `send_modify` bumps even with no
573    /// receivers).
574    ///
575    /// Callers bump **only on a change a consumer renders** — a new or dropped
576    /// session, a [`SessionState`] transition, a best-effort field taking a new
577    /// value, or a window report that alters the [`Source`] join. Deliberately
578    /// *narrower* than "the serialized payload differs": [`SessionEntry`] carries
579    /// `last_seen` and `last_event`, which churn on every hook event, so bumping
580    /// on those would push a fresh snapshot to every window on every `PreToolUse`
581    /// with the server's snapshot diff unable to suppress any of it. Their deltas
582    /// ride the server's periodic re-sample instead — the same latency the poll
583    /// this replaced already had, for fields nothing renders.
584    pub(crate) fn bump(&self) {
585        self.changes.send_modify(|v| *v = v.wrapping_add(1));
586    }
587
588    /// Locks the sessions map, recovering from a poisoned mutex (a panic in a
589    /// prior critical section must not wedge the whole registry).
590    fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, SessionEntry>> {
591        self.sessions.lock().unwrap_or_else(PoisonError::into_inner)
592    }
593
594    /// Locks the windows map, recovering from a poisoned mutex.
595    fn lock_windows(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
596        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
597    }
598
599    /// Records (upserts) a session sighting, running the [`SessionState`]
600    /// inference and refreshing liveness. Reaps stale entries first, then — only
601    /// when a genuinely new session would grow the map past [`MAX_SESSIONS`] —
602    /// evicts the longest-silent entry. Infallible: an upsert never evicts.
603    ///
604    /// Best-effort fields (`cwd`/`transcript_path`/`repo`/`model`) *fill in* on
605    /// an existing entry and never overwrite known data with `None`, so a later
606    /// hook enriches a watcher-discovered session without a race losing data.
607    ///
608    /// [`bump`](Self::bump)s only when the sighting changed something a consumer
609    /// renders — a brand-new session, a [`SessionState`] transition, a
610    /// best-effort field taking a new value, or a reap that dropped a sibling.
611    /// A repeat sighting that merely refreshes liveness does not, since hooks
612    /// fire on every tool call (the `heartbeat` precedent in
613    /// [`WorktreesRegistry`](crate::worktrees::WorktreesRegistry)).
614    pub fn observe(&self, req: ObserveRequest) {
615        let now = Utc::now();
616        let changed = {
617            let mut sessions = self.lock_sessions();
618            let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, now);
619            let mutated = match sessions.get_mut(&req.session_id) {
620                // A passive re-sighting (the Codex rollout watcher's heartbeat)
621                // must not refresh an ended session, or it would outlive its
622                // short ended-linger window (#1909).
623                Some(entry)
624                    if entry.state == SessionState::Ended
625                        && req.event == SessionEvent::TranscriptDiscovered =>
626                {
627                    false
628                }
629                // A straggling sighting from a process this session's resume
630                // already replaced: ignore it entirely, so a delayed hook from
631                // the old process (anything short of its `SessionEnd`, handled
632                // separately by `end`) cannot move the resumed session's state
633                // out from under the new process that owns it (#1948).
634                Some(entry)
635                    if req
636                        .pid
637                        .is_some_and(|pid| entry.replaced_pids.contains(&pid)) =>
638                {
639                    false
640                }
641                Some(entry) => {
642                    track_pid(entry, req.pid);
643                    entry.prompted |= req.event == SessionEvent::UserPromptSubmit;
644                    let next = SessionState::for_event(&req.event, Some(entry.state));
645                    let state_changed = next != entry.state;
646                    entry.state = next;
647                    entry.last_event = req.event;
648                    entry.last_seen = now;
649                    // Bound to locals rather than folded into the `||` below: every
650                    // field must be filled, and short-circuiting would skip the rest.
651                    let filled_cwd = fill(&mut entry.cwd, req.cwd);
652                    let filled_transcript = fill(&mut entry.transcript_path, req.transcript_path);
653                    let filled_repo = fill(&mut entry.repo, req.repo);
654                    let filled_model = fill(&mut entry.model, req.model);
655                    state_changed || filled_cwd || filled_transcript || filled_repo || filled_model
656                }
657                None => {
658                    if sessions.len() >= MAX_SESSIONS {
659                        evict_oldest_session(&mut sessions);
660                    }
661                    let state = SessionState::for_event(&req.event, None);
662                    let prompted = req.event == SessionEvent::UserPromptSubmit;
663                    sessions.insert(
664                        req.session_id.clone(),
665                        SessionEntry {
666                            session_id: req.session_id,
667                            cwd: req.cwd,
668                            transcript_path: req.transcript_path,
669                            repo: req.repo,
670                            model: req.model,
671                            agent: req.agent,
672                            state,
673                            source: Source::Terminal,
674                            last_event: req.event,
675                            started_at: now,
676                            last_seen: now,
677                            pid: req.pid,
678                            pid_start: None,
679                            prompted,
680                            replaced_pids: VecDeque::new(),
681                        },
682                    );
683                    true
684                }
685            };
686            mutated || reaped > 0
687        };
688        if changed {
689            self.bump();
690        }
691    }
692
693    /// Marks a session ended (`SessionEnd`), so `list` shows it as `ended` for a
694    /// short window ([`ENDED_SESSION_TTL`]) before it is reaped. Returns whether
695    /// the session was known. A no-op for an already-unknown session (a
696    /// duplicate/late `SessionEnd`).
697    ///
698    /// Also a no-op when `pid` is a process the session was taken over from:
699    /// an in-place resume (a VS Code window reload) starts a new process on the
700    /// same `session_id` without waiting for the old one to exit, so the old
701    /// process's `SessionEnd` can arrive after the new one's `SessionStart`
702    /// (#1948). Any other `end` — no pid, the owning pid, or a pid never seen
703    /// (a wrapped hook command whose parent is a per-hook shell) — ends the
704    /// session, so the rule can only ever keep a session the old process no
705    /// longer owns.
706    pub fn end(&self, session_id: &str, _reason: Option<&str>, pid: Option<u32>) -> bool {
707        let now = Utc::now();
708        let (known, reaped) = {
709            let mut sessions = self.lock_sessions();
710            let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, now);
711            let known = match sessions.get_mut(session_id) {
712                // Already ended (a hook and a watcher can both end it): leave the
713                // linger window alone rather than restart it.
714                Some(entry) if entry.state == SessionState::Ended => (true, false),
715                // The replaced process's late `SessionEnd`: the resumed session
716                // lives on under its new process.
717                Some(entry) if pid.is_some_and(|pid| entry.replaced_pids.contains(&pid)) => {
718                    (true, false)
719                }
720                Some(entry) => {
721                    entry.state = SessionState::Ended;
722                    entry.last_event = SessionEvent::Stop;
723                    entry.last_seen = now;
724                    (true, true)
725                }
726                None => (false, false),
727            };
728            (known, reaped)
729        };
730        let (known, flipped) = known;
731        // A known session flipped to `ended`; otherwise only this call's inline
732        // reap could have changed anything.
733        if flipped || reaped > 0 {
734            self.bump();
735        }
736        known
737    }
738
739    /// A cheap, lock-scoped snapshot for the [`pid_watcher`](pid_watcher): one
740    /// [`PidCandidate`] per live, non-`ended` session that carries a pid — the
741    /// watcher does every I/O-bearing liveness check off this lock, on its own
742    /// schedule, so this method itself touches no process and does no I/O.
743    pub(crate) fn pid_liveness_candidates(&self) -> Vec<PidCandidate> {
744        self.lock_sessions()
745            .values()
746            .filter(|e| e.state != SessionState::Ended)
747            .filter_map(|e| {
748                Some(PidCandidate {
749                    session_id: e.session_id.clone(),
750                    pid: e.pid?,
751                    pid_start: e.pid_start.clone(),
752                    prompted: e.prompted,
753                    last_seen: e.last_seen,
754                })
755            })
756            .collect()
757    }
758
759    /// Records that the [`pid_watcher`](pid_watcher) has independently
760    /// confirmed `session_id`'s pid alive: refreshes `last_seen` so
761    /// [`reap_sessions`] never sees it go stale, and captures `pid_start` when
762    /// the entry does not already have one (the watcher's first confirmation of
763    /// a given owner pid). A no-op for an unknown or already-`ended` session —
764    /// the watcher's snapshot can be one tick stale by the time it applies a
765    /// decision. Never bumps: this only refreshes fields nothing renders,
766    /// exactly like a window's unchanged heartbeat refresh.
767    pub(crate) fn confirm_pid_liveness(
768        &self,
769        session_id: &str,
770        now: DateTime<Utc>,
771        pid_start: &str,
772    ) {
773        let mut sessions = self.lock_sessions();
774        if let Some(entry) = sessions.get_mut(session_id) {
775            if entry.state != SessionState::Ended {
776                entry.last_seen = now;
777                if entry.pid_start.is_none() {
778                    entry.pid_start = Some(pid_start.to_string());
779                }
780            }
781        }
782    }
783
784    /// Records (upserts) a companion window-embedding report and refreshes its
785    /// liveness. Reaps stale windows first, then caps like [`observe`](Self::observe).
786    ///
787    /// [`bump`](Self::bump)s only when the report changes the [`Source`] join a
788    /// consumer renders — a new window, different `folders`, or an embedding that
789    /// appeared or vanished — never on the unchanged ~10 s refresh every open
790    /// window sends, which would otherwise put a permanent push floor under the
791    /// daemon proportional to the window count.
792    pub fn report_window(&self, report: WindowReport) {
793        let now = Utc::now();
794        let changed = {
795            let mut windows = self.lock_windows();
796            let reaped = reap_windows(&mut windows, self.window_ttl, now);
797            let mutated = if let Some(previous) = windows.get(&report.key) {
798                previous.report.folders != report.folders
799                    || previous.report.has_embedding() != report.has_embedding()
800            } else {
801                if windows.len() >= MAX_WINDOWS {
802                    evict_oldest_window(&mut windows);
803                }
804                true
805            };
806            windows.insert(
807                report.key.clone(),
808                WindowEntry {
809                    report,
810                    last_seen: now,
811                },
812            );
813            mutated || reaped > 0
814        };
815        if changed {
816            self.bump();
817        }
818    }
819
820    /// Drops a companion window-embedding report (the window closed). Returns
821    /// whether an entry was present.
822    pub fn unregister_window(&self, key: &str) -> bool {
823        let removed = {
824            let mut windows = self.lock_windows();
825            windows.remove(key).is_some()
826        };
827        if removed {
828            self.bump();
829        }
830        removed
831    }
832
833    /// Reaps stale sessions and windows, then returns the live sessions with
834    /// each [`Source`] resolved and sorted for deterministic output.
835    ///
836    /// Two independent locks, each held only for pure-CPU work and never
837    /// nested: the sessions snapshot is taken and the lock dropped, then the
838    /// windows snapshot, then the join runs lock-free. Path matching is a pure
839    /// prefix compare (no canonicalization / disk I/O), honouring the
840    /// `Mutex`-never-across-`.await` and no-I/O-under-lock invariants.
841    ///
842    /// Deliberately does **not** [`bump`](Self::bump), even when its inline reap
843    /// drops an entry: this is the body of every subscription's `snapshot()`, so
844    /// bumping here would feed the stream loop back into itself. A read-path reap
845    /// reaches other subscribers on the server's next periodic re-sample, whose
846    /// diff sees the shrunken list — the [`WorktreesRegistry::list`] arrangement.
847    ///
848    /// [`WorktreesRegistry::list`]: crate::worktrees::WorktreesRegistry::list
849    pub fn list(&self) -> Vec<SessionEntry> {
850        let now = Utc::now();
851        let mut sessions: Vec<SessionEntry> = {
852            let mut guard = self.lock_sessions();
853            reap_sessions(&mut guard, self.session_ttl, self.ended_ttl, now);
854            guard.values().cloned().collect()
855        };
856        let windows: Vec<WindowReport> = {
857            let mut guard = self.lock_windows();
858            reap_windows(&mut guard, self.window_ttl, now);
859            guard
860                .values()
861                .map(|e| e.report.clone())
862                .filter(WindowReport::has_embedding)
863                .collect()
864        };
865        for session in &mut sessions {
866            session.source = resolve_source(session.cwd.as_deref(), &windows);
867        }
868        sessions.sort_by(|a, b| {
869            a.repo
870                .cmp(&b.repo)
871                .then_with(|| a.session_id.cmp(&b.session_id))
872        });
873        sessions
874    }
875
876    /// The first workspace folder of the still-live window a session is embedded
877    /// in, if any — used by the tray "focus" action to resolve a session to a
878    /// folder to open in VS Code. `None` when the session has no `cwd`, or is not
879    /// matched to a reporting window with a folder.
880    pub fn focus_folder(&self, session_id: &str) -> Option<PathBuf> {
881        let cwd = {
882            let sessions = self.lock_sessions();
883            sessions.get(session_id).and_then(|e| e.cwd.clone())
884        }?;
885        let now = Utc::now();
886        let mut windows = self.lock_windows();
887        reap_windows(&mut windows, self.window_ttl, now);
888        windows
889            .values()
890            .map(|e| &e.report)
891            .filter(|w| w.has_embedding())
892            .filter(|w| w.folders.iter().any(|f| cwd.starts_with(f)))
893            .find_map(|w| w.folders.first().cloned())
894    }
895}
896
897impl Default for SessionsRegistry {
898    fn default() -> Self {
899        Self::new()
900    }
901}
902
903/// Records which agent process owns `entry` from a sighting's `pid` (#1948),
904/// moving `pid_start` (#1916) in lockstep so the two are never out of sync.
905///
906/// A sighting from a pid other than the owner is a new process resuming the
907/// session in place (every hook and the `claude-wrap` stream report the agent's
908/// pid), so it takes ownership — along with whatever start-time token came with
909/// it, which may be `None` if that sighting couldn't determine one — and the
910/// old owner joins `replaced_pids`, whose late `end` is ignored. A replaced pid
911/// never becomes the owner again, so a straggling hook from the old process
912/// cannot take the session back. Not a rendered field, so it never decides a
913/// [`bump`](SessionsRegistry::bump).
914///
915/// `observe` already filters out a sighting whose pid is in `replaced_pids`
916/// before calling this, so the only two cases reaching here are the current
917/// owner (a no-op) and a genuinely new one.
918///
919/// A new owner's `pid_start` (#1916) resets to `None`: the previous owner's
920/// token describes a different process, and the [`pid_watcher`] captures the
921/// new owner's own token independently the next time it confirms this pid
922/// alive, rather than trusting anything client-supplied.
923fn track_pid(entry: &mut SessionEntry, pid: Option<u32>) {
924    let Some(pid) = pid else {
925        return;
926    };
927    if entry.pid == Some(pid) {
928        return;
929    }
930    if let Some(owner) = entry.pid.replace(pid) {
931        if entry.replaced_pids.len() >= MAX_REPLACED_PIDS {
932            entry.replaced_pids.pop_front();
933        }
934        entry.replaced_pids.push_back(owner);
935    }
936    entry.pid_start = None;
937}
938
939/// Fills `slot` from `incoming` only when `incoming` carries a value, so a
940/// best-effort field never overwrites known data with `None` on a re-`observe`.
941/// Returns whether the stored value actually changed, which is what decides
942/// whether the sighting is worth a [`bump`](SessionsRegistry::bump) — a hook
943/// re-sending the same `cwd` it sent last time is not.
944fn fill<T: PartialEq>(slot: &mut Option<T>, incoming: Option<T>) -> bool {
945    match incoming {
946        Some(value) if slot.as_ref() != Some(&value) => {
947            *slot = Some(value);
948            true
949        }
950        _ => false,
951    }
952}
953
954/// Resolves a session's [`Source`] by joining its `cwd` against the live
955/// window-embedding reports.
956///
957/// Among the windows whose folder is a prefix of `cwd`, the one with the lowest
958/// key wins (a deterministic tiebreak). A session with no `cwd`, or no matching
959/// window, is [`Source::Terminal`].
960fn resolve_source(cwd: Option<&Path>, windows: &[WindowReport]) -> Source {
961    let Some(cwd) = cwd else {
962        return Source::Terminal;
963    };
964    let matched = windows
965        .iter()
966        .filter(|w| w.folders.iter().any(|f| cwd.starts_with(f)))
967        .min_by(|a, b| a.key.cmp(&b.key));
968    match matched {
969        Some(window) => Source::VsCode {
970            window_key: window.key.clone(),
971        },
972        None => Source::Terminal,
973    }
974}
975
976/// Removes sessions last seen longer than their TTL ago (a shorter
977/// [`ended_ttl`](SessionsRegistry::ended_ttl) for `ended` sessions), returning
978/// how many were dropped. Pure CPU; the caller holds the sessions lock but never
979/// `.await`s under it.
980///
981/// This TTL is the fallback for a session the [`pid_watcher`] cannot vouch for
982/// (no pid, an unconfirmed pid, or one that has never been prompted) — see
983/// [`DEFAULT_SESSION_TTL`]. A pid the watcher has confirmed alive keeps this
984/// function from ever seeing the entry go stale by refreshing `last_seen`
985/// itself, so no pid-specific logic belongs here: every liveness decision that
986/// needs to inspect a process happens off this lock, in the watcher.
987fn reap_sessions(
988    sessions: &mut HashMap<String, SessionEntry>,
989    session_ttl: Duration,
990    ended_ttl: Duration,
991    now: DateTime<Utc>,
992) -> usize {
993    let session_max = session_ttl.as_secs() as i64;
994    let ended_max = ended_ttl.as_secs() as i64;
995    let before = sessions.len();
996    sessions.retain(|_, e| {
997        let max_age = if e.state == SessionState::Ended {
998            ended_max
999        } else {
1000            session_max
1001        };
1002        (now - e.last_seen).num_seconds() <= max_age
1003    });
1004    before - sessions.len()
1005}
1006
1007/// Removes window-embedding reports last refreshed longer than `ttl` ago.
1008fn reap_windows(
1009    windows: &mut HashMap<String, WindowEntry>,
1010    ttl: Duration,
1011    now: DateTime<Utc>,
1012) -> usize {
1013    let max_age = ttl.as_secs() as i64;
1014    let before = windows.len();
1015    windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
1016    before - windows.len()
1017}
1018
1019/// Removes the session with the oldest `last_seen` (ties broken by lowest
1020/// `session_id` for determinism). Called when a new session would exceed
1021/// [`MAX_SESSIONS`].
1022fn evict_oldest_session(sessions: &mut HashMap<String, SessionEntry>) {
1023    let oldest = sessions
1024        .values()
1025        .min_by(|a, b| {
1026            a.last_seen
1027                .cmp(&b.last_seen)
1028                .then_with(|| a.session_id.cmp(&b.session_id))
1029        })
1030        .map(|e| e.session_id.clone());
1031    if let Some(key) = oldest {
1032        sessions.remove(&key);
1033    }
1034}
1035
1036/// Removes the window report with the oldest `last_seen` (ties broken by lowest
1037/// key). Called when a new window would exceed [`MAX_WINDOWS`].
1038fn evict_oldest_window(windows: &mut HashMap<String, WindowEntry>) {
1039    let oldest = windows
1040        .iter()
1041        .min_by(|a, b| a.1.last_seen.cmp(&b.1.last_seen).then_with(|| a.0.cmp(b.0)))
1042        .map(|(k, _)| k.clone());
1043    if let Some(key) = oldest {
1044        windows.remove(&key);
1045    }
1046}
1047
1048#[cfg(test)]
1049#[allow(clippy::unwrap_used, clippy::expect_used)]
1050mod tests {
1051    use super::*;
1052
1053    fn observe_request(session_id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
1054        ObserveRequest {
1055            pid: None,
1056            agent: Agent::Claude,
1057            session_id: session_id.to_string(),
1058            cwd: cwd.map(PathBuf::from),
1059            transcript_path: None,
1060            event,
1061            repo: None,
1062            model: None,
1063        }
1064    }
1065
1066    #[test]
1067    fn list_is_empty_initially() {
1068        let reg = SessionsRegistry::new();
1069        assert!(reg.list().is_empty());
1070    }
1071
1072    #[test]
1073    fn observe_then_list_round_trips_and_infers_state() {
1074        let reg = SessionsRegistry::new();
1075        reg.observe(observe_request(
1076            "s1",
1077            SessionEvent::SessionStart,
1078            Some("/tmp/a"),
1079        ));
1080        let sessions = reg.list();
1081        assert_eq!(sessions.len(), 1);
1082        assert_eq!(sessions[0].session_id, "s1");
1083        assert_eq!(sessions[0].state, SessionState::Starting);
1084        // No window reports → a bare terminal session.
1085        assert_eq!(sessions[0].source, Source::Terminal);
1086    }
1087
1088    #[test]
1089    fn observe_is_idempotent_upsert_advancing_state() {
1090        let reg = SessionsRegistry::new();
1091        reg.observe(observe_request(
1092            "s1",
1093            SessionEvent::SessionStart,
1094            Some("/tmp/a"),
1095        ));
1096        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
1097        let sessions = reg.list();
1098        assert_eq!(sessions.len(), 1, "same session_id upserts, not duplicates");
1099        assert_eq!(sessions[0].state, SessionState::Working);
1100        // The later `observe` had no cwd, but the known one is preserved.
1101        assert_eq!(sessions[0].cwd.as_deref(), Some(Path::new("/tmp/a")));
1102    }
1103
1104    #[test]
1105    fn state_machine_covers_every_event() {
1106        use NotificationKind::*;
1107        use SessionEvent::*;
1108        let cases = [
1109            (SessionStart, SessionState::Starting),
1110            (UserPromptSubmit, SessionState::Working),
1111            (PreToolUse, SessionState::Working),
1112            (PostToolUse, SessionState::Working),
1113            (Stop, SessionState::Idle),
1114            (
1115                Notification(PermissionPrompt),
1116                SessionState::WaitingForPermission,
1117            ),
1118            (Notification(IdlePrompt), SessionState::WaitingForInput),
1119            (Notification(AgentNeedsInput), SessionState::WaitingForInput),
1120            (TranscriptGrew, SessionState::Working),
1121            (TranscriptDiscovered, SessionState::Idle),
1122            // An authoritative stream-json report is returned verbatim.
1123            (
1124                StreamState(SessionState::WaitingForPermission),
1125                SessionState::WaitingForPermission,
1126            ),
1127            (StreamState(SessionState::Idle), SessionState::Idle),
1128        ];
1129        for (event, expected) in cases {
1130            assert_eq!(
1131                SessionState::for_event(&event, None),
1132                expected,
1133                "event {event:?}"
1134            );
1135        }
1136        // An unclassified notification keeps the current state.
1137        assert_eq!(
1138            SessionState::for_event(&Notification(Other), Some(SessionState::Working)),
1139            SessionState::Working
1140        );
1141        // TranscriptDiscovered on a known session keeps its state.
1142        assert_eq!(
1143            SessionState::for_event(&TranscriptDiscovered, Some(SessionState::Working)),
1144            SessionState::Working
1145        );
1146        // An authoritative StreamState overrides any current state — it read the
1147        // exact state from the stream rather than inferring it.
1148        assert_eq!(
1149            SessionState::for_event(
1150                &StreamState(SessionState::Idle),
1151                Some(SessionState::Working)
1152            ),
1153            SessionState::Idle
1154        );
1155        // Growth is expected while a session waits on the user (the transcript
1156        // grows before the prompt is answered) and around `SessionEnd` (the
1157        // final lines land as it exits), so in neither case is it evidence the
1158        // turn is running: the directly reported state stands (#1418). A
1159        // resumed session's old process writes to the shared transcript as it
1160        // exits, after the new one's `SessionStart` (#1946).
1161        for held in [
1162            SessionState::Starting,
1163            SessionState::WaitingForInput,
1164            SessionState::WaitingForPermission,
1165            SessionState::Ended,
1166        ] {
1167            assert_eq!(
1168                SessionState::for_event(&TranscriptGrew, Some(held)),
1169                held,
1170                "growth must not overwrite {held:?}"
1171            );
1172        }
1173        // From every other state growth still means working, as does growth on
1174        // a session whose state is not yet known (covered by the table above).
1175        for other in [SessionState::Working, SessionState::Idle] {
1176            assert_eq!(
1177                SessionState::for_event(&TranscriptGrew, Some(other)),
1178                SessionState::Working,
1179                "growth from {other:?}"
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn end_marks_ended_and_reaps_quickly() {
1186        let reg = SessionsRegistry::new();
1187        reg.observe(observe_request(
1188            "s1",
1189            SessionEvent::PreToolUse,
1190            Some("/tmp/a"),
1191        ));
1192        assert!(reg.end("s1", Some("clear"), None));
1193        // Ending an unknown session is a no-op.
1194        assert!(!reg.end("ghost", None, None));
1195        let sessions = reg.list();
1196        assert_eq!(sessions.len(), 1);
1197        assert_eq!(sessions[0].state, SessionState::Ended);
1198        // Age the ended entry past the short ended TTL: it reaps out.
1199        {
1200            let mut guard = reg.lock_sessions();
1201            guard.get_mut("s1").unwrap().last_seen = Utc::now() - chrono::Duration::seconds(30);
1202        }
1203        assert!(reg.list().is_empty(), "ended entry reaps after ended TTL");
1204    }
1205
1206    // --- pid-based liveness (#1916): the registry side the pid_watcher drives ---
1207
1208    #[test]
1209    fn pid_liveness_candidates_lists_only_non_ended_pid_bearing_sessions() {
1210        let reg = SessionsRegistry::new();
1211        reg.observe(observe_from(
1212            "with_pid",
1213            SessionEvent::UserPromptSubmit,
1214            100,
1215        ));
1216        reg.observe(observe_request(
1217            "no_pid",
1218            SessionEvent::UserPromptSubmit,
1219            None,
1220        ));
1221        reg.observe(observe_from("ended", SessionEvent::UserPromptSubmit, 200));
1222        reg.end("ended", None, Some(200));
1223
1224        let candidates = reg.pid_liveness_candidates();
1225        assert_eq!(candidates.len(), 1);
1226        assert_eq!(candidates[0].session_id, "with_pid");
1227        assert_eq!(candidates[0].pid, 100);
1228        assert!(candidates[0].prompted);
1229        assert_eq!(candidates[0].pid_start, None);
1230    }
1231
1232    #[test]
1233    fn confirm_pid_liveness_refreshes_last_seen_and_captures_the_token_once() {
1234        let reg = SessionsRegistry::new();
1235        reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
1236        let old = Utc::now() - chrono::Duration::seconds(1000);
1237        {
1238            let mut guard = reg.lock_sessions();
1239            guard.get_mut("s1").unwrap().last_seen = old;
1240        }
1241
1242        reg.confirm_pid_liveness("s1", Utc::now(), "tok-1");
1243        {
1244            let guard = reg.lock_sessions();
1245            let entry = &guard["s1"];
1246            assert!(entry.last_seen > old, "last_seen should be refreshed");
1247            assert_eq!(entry.pid_start.as_deref(), Some("tok-1"));
1248        }
1249
1250        // A later confirmation does not overwrite an already-captured token —
1251        // it is fixed at the pid's first independently-confirmed sighting.
1252        reg.confirm_pid_liveness("s1", Utc::now(), "tok-2");
1253        assert_eq!(
1254            reg.lock_sessions()["s1"].pid_start.as_deref(),
1255            Some("tok-1")
1256        );
1257    }
1258
1259    #[test]
1260    fn confirm_pid_liveness_is_a_noop_for_an_unknown_or_ended_session() {
1261        let reg = SessionsRegistry::new();
1262        // Unknown session: no panic.
1263        reg.confirm_pid_liveness("ghost", Utc::now(), "tok");
1264
1265        reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
1266        reg.end("s1", None, Some(100));
1267        let before = reg.lock_sessions()["s1"].last_seen;
1268        reg.confirm_pid_liveness("s1", Utc::now(), "tok");
1269        assert_eq!(
1270            reg.lock_sessions()["s1"].last_seen,
1271            before,
1272            "an ended session must not be revived by a liveness confirmation"
1273        );
1274    }
1275
1276    #[test]
1277    fn stale_working_session_reaps_but_recent_survives() {
1278        let reg = SessionsRegistry::new();
1279        reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
1280        reg.observe(observe_request("stale", SessionEvent::PreToolUse, None));
1281        {
1282            let mut guard = reg.lock_sessions();
1283            guard.get_mut("stale").unwrap().last_seen =
1284                Utc::now() - chrono::Duration::seconds(1000);
1285        }
1286        let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
1287        assert_eq!(ids, vec!["fresh".to_string()]);
1288    }
1289
1290    #[test]
1291    fn source_is_vscode_when_cwd_is_under_a_reporting_window() {
1292        let reg = SessionsRegistry::new();
1293        reg.observe(observe_request(
1294            "s1",
1295            SessionEvent::PreToolUse,
1296            Some("/home/me/proj/sub"),
1297        ));
1298        // A window reporting a Claude tab whose folder is a prefix of the cwd.
1299        reg.report_window(WindowReport {
1300            key: "w1".to_string(),
1301            folders: vec![PathBuf::from("/home/me/proj")],
1302            tabs: 1,
1303            terminals: 0,
1304        });
1305        let sessions = reg.list();
1306        assert_eq!(
1307            sessions[0].source,
1308            Source::VsCode {
1309                window_key: "w1".to_string()
1310            }
1311        );
1312    }
1313
1314    #[test]
1315    fn source_is_terminal_when_window_has_no_embedding() {
1316        let reg = SessionsRegistry::new();
1317        reg.observe(observe_request(
1318            "s1",
1319            SessionEvent::PreToolUse,
1320            Some("/home/me/proj"),
1321        ));
1322        // A window is open on the folder but has no Claude tab/terminal.
1323        reg.report_window(WindowReport {
1324            key: "w1".to_string(),
1325            folders: vec![PathBuf::from("/home/me/proj")],
1326            tabs: 0,
1327            terminals: 0,
1328        });
1329        assert_eq!(reg.list()[0].source, Source::Terminal);
1330    }
1331
1332    #[test]
1333    fn window_report_is_upsert_and_unregister_removes() {
1334        let reg = SessionsRegistry::new();
1335        reg.report_window(WindowReport {
1336            key: "w1".to_string(),
1337            folders: vec![PathBuf::from("/p")],
1338            tabs: 1,
1339            terminals: 0,
1340        });
1341        // Upsert (same key) does not duplicate.
1342        reg.report_window(WindowReport {
1343            key: "w1".to_string(),
1344            folders: vec![PathBuf::from("/p")],
1345            tabs: 2,
1346            terminals: 1,
1347        });
1348        assert!(reg.unregister_window("w1"));
1349        assert!(!reg.unregister_window("w1"));
1350    }
1351
1352    #[test]
1353    fn stale_window_stops_tagging_source() {
1354        let reg = SessionsRegistry::new();
1355        reg.observe(observe_request(
1356            "s1",
1357            SessionEvent::PreToolUse,
1358            Some("/p/sub"),
1359        ));
1360        reg.report_window(WindowReport {
1361            key: "w1".to_string(),
1362            folders: vec![PathBuf::from("/p")],
1363            tabs: 1,
1364            terminals: 0,
1365        });
1366        // Age the window report past the window TTL.
1367        {
1368            let mut guard = reg.lock_windows();
1369            guard.get_mut("w1").unwrap().last_seen = Utc::now() - chrono::Duration::seconds(120);
1370        }
1371        assert_eq!(reg.list()[0].source, Source::Terminal);
1372    }
1373
1374    #[test]
1375    fn resolve_source_prefers_lowest_key_on_overlap() {
1376        // Two windows both cover the cwd; the lowest key wins deterministically.
1377        let windows = vec![
1378            WindowReport {
1379                key: "w2".to_string(),
1380                folders: vec![PathBuf::from("/p")],
1381                tabs: 1,
1382                terminals: 0,
1383            },
1384            WindowReport {
1385                key: "w1".to_string(),
1386                folders: vec![PathBuf::from("/p")],
1387                tabs: 1,
1388                terminals: 0,
1389            },
1390        ];
1391        assert_eq!(
1392            resolve_source(Some(Path::new("/p/x")), &windows),
1393            Source::VsCode {
1394                window_key: "w1".to_string()
1395            }
1396        );
1397        // No cwd → terminal.
1398        assert_eq!(resolve_source(None, &windows), Source::Terminal);
1399    }
1400
1401    #[test]
1402    fn focus_folder_resolves_matching_window_folder() {
1403        let reg = SessionsRegistry::new();
1404        reg.observe(observe_request(
1405            "s1",
1406            SessionEvent::PreToolUse,
1407            Some("/home/me/proj/sub"),
1408        ));
1409        assert!(reg.focus_folder("s1").is_none(), "no window yet");
1410        reg.report_window(WindowReport {
1411            key: "w1".to_string(),
1412            folders: vec![PathBuf::from("/home/me/proj")],
1413            tabs: 1,
1414            terminals: 0,
1415        });
1416        assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/home/me/proj")));
1417        // An unknown session resolves to nothing.
1418        assert!(reg.focus_folder("ghost").is_none());
1419    }
1420
1421    #[test]
1422    fn evict_oldest_session_drops_the_longest_silent() {
1423        let now = Utc::now();
1424        let mut sessions = HashMap::new();
1425        for (id, age) in [("young", 0), ("old", 100), ("older", 200)] {
1426            sessions.insert(
1427                id.to_string(),
1428                SessionEntry {
1429                    pid: None,
1430                    pid_start: None,
1431                    prompted: false,
1432                    replaced_pids: VecDeque::new(),
1433                    agent: Agent::Claude,
1434                    session_id: id.to_string(),
1435                    cwd: None,
1436                    transcript_path: None,
1437                    repo: None,
1438                    model: None,
1439                    state: SessionState::Working,
1440                    source: Source::Terminal,
1441                    last_event: SessionEvent::PreToolUse,
1442                    started_at: now,
1443                    last_seen: now - chrono::Duration::seconds(age),
1444                },
1445            );
1446        }
1447        evict_oldest_session(&mut sessions);
1448        assert!(!sessions.contains_key("older"));
1449        assert!(sessions.contains_key("young"));
1450        assert!(sessions.contains_key("old"));
1451    }
1452
1453    #[test]
1454    fn list_sorts_by_repo_then_session_id() {
1455        let reg = SessionsRegistry::new();
1456        for (id, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
1457            reg.observe(ObserveRequest {
1458                pid: None,
1459                agent: Agent::Claude,
1460                session_id: id.to_string(),
1461                cwd: None,
1462                transcript_path: None,
1463                event: SessionEvent::PreToolUse,
1464                repo: Some(repo.to_string()),
1465                model: None,
1466            });
1467        }
1468        let ordered: Vec<(String, String)> = reg
1469            .list()
1470            .into_iter()
1471            .map(|s| (s.session_id, s.repo.unwrap()))
1472            .collect();
1473        assert_eq!(
1474            ordered,
1475            vec![
1476                ("m".to_string(), "repo-a".to_string()),
1477                ("z".to_string(), "repo-a".to_string()),
1478                ("a".to_string(), "repo-b".to_string()),
1479            ]
1480        );
1481    }
1482
1483    #[test]
1484    fn serialized_session_shapes_are_stable() {
1485        // The wire shape consumers (CLI, extension) read: snake_case state, a
1486        // tagged source, and omitted `None` fields.
1487        let reg = SessionsRegistry::new();
1488        reg.observe(ObserveRequest {
1489            pid: None,
1490            agent: Agent::Claude,
1491            session_id: "s1".to_string(),
1492            cwd: Some(PathBuf::from("/p")),
1493            transcript_path: None,
1494            event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
1495            repo: Some("proj".to_string()),
1496            model: None,
1497        });
1498        let value = serde_json::to_value(&reg.list()[0]).unwrap();
1499        assert_eq!(value["state"], "waiting_for_permission");
1500        assert_eq!(value["source"]["kind"], "terminal");
1501        assert_eq!(value["repo"], "proj");
1502        // Absent optional fields are omitted, not null.
1503        assert!(value.get("model").is_none());
1504        assert!(value.get("transcript_path").is_none());
1505    }
1506
1507    #[test]
1508    fn stream_state_is_authoritative_and_round_trips() {
1509        // The `claude-wrap` wrapper reports the exact state; `observe` applies it
1510        // verbatim and overrides whatever was inferred before.
1511        let reg = SessionsRegistry::new();
1512        reg.observe(observe_request("s1", SessionEvent::PreToolUse, Some("/p")));
1513        assert_eq!(reg.list()[0].state, SessionState::Working);
1514        reg.observe(observe_request(
1515            "s1",
1516            SessionEvent::StreamState(SessionState::WaitingForPermission),
1517            None,
1518        ));
1519        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
1520        // The wire shape is a nested tuple variant, matching `{"notification":…}`.
1521        let value = serde_json::to_value(SessionEvent::StreamState(
1522            SessionState::WaitingForPermission,
1523        ))
1524        .unwrap();
1525        assert_eq!(
1526            value,
1527            serde_json::json!({ "stream_state": "waiting_for_permission" })
1528        );
1529        // …and deserializes back.
1530        let event: SessionEvent = serde_json::from_value(value).unwrap();
1531        assert_eq!(
1532            event,
1533            SessionEvent::StreamState(SessionState::WaitingForPermission)
1534        );
1535    }
1536
1537    #[test]
1538    fn default_constructs_an_empty_registry() {
1539        let reg = SessionsRegistry::default();
1540        assert!(reg.list().is_empty());
1541    }
1542
1543    #[test]
1544    fn agent_is_omitted_for_claude_and_tagged_for_pi_on_the_wire() {
1545        // Claude senders stay byte-identical: no `agent` key.
1546        let claude = serde_json::to_value(observe_request("s", SessionEvent::Stop, None)).unwrap();
1547        assert!(claude.get("agent").is_none(), "{claude}");
1548        // An absent `agent` reads back as Claude.
1549        let parsed: ObserveRequest =
1550            serde_json::from_value(serde_json::json!({ "session_id": "s", "event": "stop" }))
1551                .unwrap();
1552        assert_eq!(parsed.agent, Agent::Claude);
1553        // The exact payload the pi extension sends.
1554        let pi: ObserveRequest = serde_json::from_value(serde_json::json!({
1555            "session_id": "019a0000-0000-7000-8000-000000000000",
1556            "cwd": "/work/repo",
1557            "agent": "pi",
1558            "event": { "stream_state": "waiting_for_input" },
1559        }))
1560        .unwrap();
1561        assert_eq!(pi.agent, Agent::Pi);
1562        assert_eq!(
1563            pi.event,
1564            SessionEvent::StreamState(SessionState::WaitingForInput)
1565        );
1566    }
1567
1568    #[test]
1569    fn agents_have_display_names() {
1570        assert_eq!(Agent::Claude.display_name(), "Claude");
1571        assert_eq!(Agent::Pi.display_name(), "pi");
1572        assert_eq!(Agent::Codex.display_name(), "Codex");
1573    }
1574
1575    #[test]
1576    fn an_ended_session_is_not_refreshed_by_a_passive_sighting_or_a_second_end() {
1577        let reg = SessionsRegistry::new();
1578        reg.observe(observe_request("s", SessionEvent::Stop, None));
1579        assert!(reg.end("s", None, None));
1580        let ended_at = reg.list()[0].last_seen;
1581        let mut changes = reg.subscribe_changes();
1582        changes.mark_unchanged();
1583        std::thread::sleep(std::time::Duration::from_millis(5));
1584        reg.observe(observe_request(
1585            "s",
1586            SessionEvent::TranscriptDiscovered,
1587            None,
1588        ));
1589        assert!(reg.end("s", None, None), "still a known session");
1590        let listed = reg.list();
1591        assert_eq!(listed[0].state, SessionState::Ended);
1592        assert_eq!(
1593            listed[0].last_seen, ended_at,
1594            "the linger window did not restart"
1595        );
1596        assert!(
1597            !changes.has_changed().unwrap(),
1598            "no consumer-visible change"
1599        );
1600        // A hook event still revives it, as before.
1601        reg.observe(observe_request("s", SessionEvent::UserPromptSubmit, None));
1602        assert_eq!(reg.list()[0].state, SessionState::Working);
1603    }
1604
1605    #[test]
1606    fn codex_is_a_tagged_agent_on_the_wire() {
1607        let codex: ObserveRequest = serde_json::from_value(serde_json::json!({
1608            "session_id": "019a0000-0000-7000-8000-000000000001",
1609            "agent": "codex",
1610            "event": { "notification": "permission_prompt" },
1611        }))
1612        .unwrap();
1613        assert_eq!(codex.agent, Agent::Codex);
1614        assert!(!codex.agent.is_claude());
1615        let json = serde_json::to_value(&codex).unwrap();
1616        assert_eq!(json["agent"], "codex");
1617    }
1618
1619    #[test]
1620    fn a_pi_session_is_listed_with_its_agent_and_reported_state() {
1621        let reg = SessionsRegistry::new();
1622        let mut req = observe_request(
1623            "pi-1",
1624            SessionEvent::StreamState(SessionState::Working),
1625            Some("/work/repo"),
1626        );
1627        req.agent = Agent::Pi;
1628        reg.observe(req);
1629        // A later sighting without the tag cannot re-label the session.
1630        reg.observe(observe_request(
1631            "pi-1",
1632            SessionEvent::StreamState(SessionState::Idle),
1633            None,
1634        ));
1635        let listed = reg.list();
1636        assert_eq!(listed.len(), 1);
1637        assert_eq!(listed[0].agent, Agent::Pi);
1638        assert_eq!(listed[0].state, SessionState::Idle);
1639        let json = serde_json::to_value(&listed[0]).unwrap();
1640        assert_eq!(json["agent"], "pi");
1641    }
1642
1643    #[test]
1644    fn fill_only_overwrites_with_a_present_value() {
1645        // `None` leaves the slot; `Some` overwrites it — the re-`observe`
1646        // never-clobber contract.
1647        let mut slot = Some("keep");
1648        fill(&mut slot, None);
1649        assert_eq!(slot, Some("keep"));
1650        fill(&mut slot, Some("new"));
1651        assert_eq!(slot, Some("new"));
1652        // A previously-empty slot fills.
1653        let mut empty: Option<&str> = None;
1654        fill(&mut empty, Some("filled"));
1655        assert_eq!(empty, Some("filled"));
1656    }
1657
1658    #[test]
1659    fn observe_at_session_cap_evicts_the_longest_silent() {
1660        let reg = SessionsRegistry::new();
1661        // Seed a full registry with explicit descending timestamps so the
1662        // highest-numbered id is unambiguously the oldest.
1663        {
1664            let mut sessions = reg.lock_sessions();
1665            let base = Utc::now();
1666            for i in 0..MAX_SESSIONS {
1667                let id = format!("s{i:04}");
1668                sessions.insert(
1669                    id.clone(),
1670                    SessionEntry {
1671                        pid: None,
1672                        pid_start: None,
1673                        prompted: false,
1674                        replaced_pids: VecDeque::new(),
1675                        agent: Agent::Claude,
1676                        session_id: id.clone(),
1677                        cwd: None,
1678                        transcript_path: None,
1679                        repo: None,
1680                        model: None,
1681                        state: SessionState::Working,
1682                        source: Source::Terminal,
1683                        last_event: SessionEvent::PreToolUse,
1684                        started_at: base,
1685                        last_seen: base - chrono::Duration::milliseconds(i as i64),
1686                    },
1687                );
1688            }
1689        }
1690        // A new session at the cap displaces exactly the longest-silent entry.
1691        reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
1692        let sessions = reg.lock_sessions();
1693        assert_eq!(sessions.len(), MAX_SESSIONS);
1694        assert!(sessions.contains_key("fresh"));
1695        assert!(!sessions.contains_key(&format!("s{:04}", MAX_SESSIONS - 1)));
1696        assert!(sessions.contains_key("s0000"));
1697    }
1698
1699    #[test]
1700    fn report_window_at_cap_evicts_the_longest_silent() {
1701        let reg = SessionsRegistry::new();
1702        {
1703            let mut windows = reg.lock_windows();
1704            let base = Utc::now();
1705            for i in 0..MAX_WINDOWS {
1706                let key = format!("w{i:04}");
1707                windows.insert(
1708                    key.clone(),
1709                    WindowEntry {
1710                        report: WindowReport {
1711                            key: key.clone(),
1712                            folders: vec![],
1713                            tabs: 1,
1714                            terminals: 0,
1715                        },
1716                        last_seen: base - chrono::Duration::milliseconds(i as i64),
1717                    },
1718                );
1719            }
1720        }
1721        reg.report_window(WindowReport {
1722            key: "fresh".to_string(),
1723            folders: vec![],
1724            tabs: 1,
1725            terminals: 0,
1726        });
1727        let windows = reg.lock_windows();
1728        assert_eq!(windows.len(), MAX_WINDOWS);
1729        assert!(windows.contains_key("fresh"));
1730        assert!(!windows.contains_key(&format!("w{:04}", MAX_WINDOWS - 1)));
1731        assert!(windows.contains_key("w0000"));
1732    }
1733
1734    #[test]
1735    fn evict_oldest_window_breaks_ties_by_key() {
1736        let now = Utc::now();
1737        let mut windows = HashMap::new();
1738        let at = |key: &str, secs: i64| WindowEntry {
1739            report: WindowReport {
1740                key: key.to_string(),
1741                folders: vec![],
1742                tabs: 1,
1743                terminals: 0,
1744            },
1745            last_seen: now - chrono::Duration::seconds(secs),
1746        };
1747        windows.insert("young".to_string(), at("young", 0));
1748        windows.insert("old-b".to_string(), at("old-b", 10));
1749        windows.insert("old-a".to_string(), at("old-a", 10));
1750        // Oldest `last_seen` is shared; the lowest key loses.
1751        evict_oldest_window(&mut windows);
1752        assert!(!windows.contains_key("old-a"));
1753        assert!(windows.contains_key("old-b"));
1754        assert!(windows.contains_key("young"));
1755        // An empty map is a no-op, not a panic.
1756        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
1757        evict_oldest_window(&mut empty);
1758        assert!(empty.is_empty());
1759    }
1760
1761    // --- Change-notify for the push subscription (#1414) --------------------
1762
1763    /// A window report with one folder, parameterized by whether it embeds Claude.
1764    fn window_report(key: &str, folder: &str, embedded: bool) -> WindowReport {
1765        WindowReport {
1766            key: key.to_string(),
1767            folders: vec![PathBuf::from(folder)],
1768            tabs: usize::from(embedded),
1769            terminals: 0,
1770        }
1771    }
1772
1773    #[test]
1774    fn subscribe_changes_starts_seen_and_a_new_session_bumps() {
1775        let reg = SessionsRegistry::new();
1776        let mut rx = reg.subscribe_changes();
1777        // A fresh receiver has the current version already marked seen.
1778        assert!(!rx.has_changed().unwrap());
1779        reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
1780        assert!(rx.has_changed().unwrap(), "a new session should bump");
1781        // Marking it seen clears the pending change.
1782        rx.borrow_and_update();
1783        assert!(!rx.has_changed().unwrap());
1784    }
1785
1786    #[test]
1787    fn observe_bumps_on_a_state_transition_but_not_on_a_repeat_sighting() {
1788        let reg = SessionsRegistry::new();
1789        reg.observe(observe_request(
1790            "s1",
1791            SessionEvent::PreToolUse,
1792            Some("/tmp/a"),
1793        ));
1794        // Subscribe *after* the insert so its bump is already seen.
1795        let mut rx = reg.subscribe_changes();
1796
1797        // Same event, same cwd: liveness and `last_event`/`last_seen` move, but
1798        // nothing a consumer renders does. Hooks fire on every tool call, so this
1799        // is the hot path that must stay quiet.
1800        reg.observe(observe_request(
1801            "s1",
1802            SessionEvent::PreToolUse,
1803            Some("/tmp/a"),
1804        ));
1805        assert!(
1806            !rx.has_changed().unwrap(),
1807            "a repeat sighting with no visible change must not bump"
1808        );
1809
1810        // `PreToolUse` → `Stop` flips working → idle, which the tree renders.
1811        reg.observe(observe_request("s1", SessionEvent::Stop, None));
1812        assert!(rx.has_changed().unwrap(), "a state transition should bump");
1813        rx.borrow_and_update();
1814
1815        // A best-effort field taking a *new* value is visible too (the tally
1816        // joins sessions to worktree rows by `cwd`).
1817        reg.observe(observe_request("s1", SessionEvent::Stop, Some("/tmp/b")));
1818        assert!(
1819            rx.has_changed().unwrap(),
1820            "a newly-filled `cwd` should bump"
1821        );
1822    }
1823
1824    #[test]
1825    fn transcript_growth_does_not_clobber_a_waiting_session() {
1826        let reg = SessionsRegistry::new();
1827        reg.observe(observe_request(
1828            "s1",
1829            SessionEvent::Notification(NotificationKind::PermissionPrompt),
1830            Some("/tmp/a"),
1831        ));
1832        // Subscribe *after* the insert so its bump is already seen. Never marked
1833        // seen below, so the closing assert catches the release's bump only if
1834        // the growth in between really did stay quiet.
1835        let rx = reg.subscribe_changes();
1836
1837        // The watcher sees the assistant `tool_use` line Claude flushed before
1838        // the prompt could be answered. The wait came from a direct
1839        // `Notification`, so it must survive (#1418).
1840        reg.observe(observe_request(
1841            "s1",
1842            SessionEvent::TranscriptGrew,
1843            Some("/tmp/a"),
1844        ));
1845        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
1846        assert!(
1847            !rx.has_changed().unwrap(),
1848            "state did not change, so nothing a consumer renders did either (#1414)"
1849        );
1850
1851        // Answering the prompt still releases the wait — on the next hook, which
1852        // for an approved tool is the `PostToolUse` that fires when it finishes.
1853        reg.observe(observe_request("s1", SessionEvent::PostToolUse, None));
1854        assert_eq!(reg.list()[0].state, SessionState::Working);
1855        assert!(
1856            rx.has_changed().unwrap(),
1857            "the release is a real transition"
1858        );
1859    }
1860
1861    #[test]
1862    fn transcript_growth_does_not_revive_an_ended_session() {
1863        let reg = SessionsRegistry::new();
1864        reg.observe(observe_request(
1865            "s1",
1866            SessionEvent::PreToolUse,
1867            Some("/tmp/a"),
1868        ));
1869        assert!(reg.end("s1", Some("clear"), None));
1870        // Subscribed after the end so its bump is already seen (see above).
1871        let rx = reg.subscribe_changes();
1872
1873        // The watcher's next scan sees the lines Claude flushed as it exited.
1874        // `SessionEnd` reported the end directly, so the entry must stay `ended`
1875        // and reap on the short ended TTL rather than being revived as a
1876        // `working` phantom that outlives the session by the whole TTL (#1418).
1877        reg.observe(observe_request(
1878            "s1",
1879            SessionEvent::TranscriptGrew,
1880            Some("/tmp/a"),
1881        ));
1882        assert_eq!(reg.list()[0].state, SessionState::Ended);
1883        assert!(
1884            !rx.has_changed().unwrap(),
1885            "state did not change, so nothing a consumer renders did either (#1414)"
1886        );
1887    }
1888
1889    #[test]
1890    fn a_resumed_session_stays_starting_until_its_first_prompt() {
1891        let reg = SessionsRegistry::new();
1892        reg.observe(observe_request(
1893            "s1",
1894            SessionEvent::UserPromptSubmit,
1895            Some("/tmp/a"),
1896        ));
1897        // A VS Code window reload: the old process fires `SessionEnd`, and the
1898        // new one resumes the same session id and fires `SessionStart`.
1899        assert!(reg.end("s1", Some("other"), None));
1900        reg.observe(observe_request(
1901            "s1",
1902            SessionEvent::SessionStart,
1903            Some("/tmp/a"),
1904        ));
1905        assert_eq!(reg.list()[0].state, SessionState::Starting);
1906
1907        // The watcher's next scan sees the `cost-state` line the old process
1908        // appended as it exited. It is not the resumed session doing work, and
1909        // no hook fires while it sits unprompted, so it must not read as
1910        // `working` (#1946).
1911        reg.observe(observe_request(
1912            "s1",
1913            SessionEvent::TranscriptGrew,
1914            Some("/tmp/a"),
1915        ));
1916        assert_eq!(reg.list()[0].state, SessionState::Starting);
1917
1918        // The first prompt still moves it to `working`.
1919        reg.observe(observe_request("s1", SessionEvent::UserPromptSubmit, None));
1920        assert_eq!(reg.list()[0].state, SessionState::Working);
1921    }
1922
1923    #[test]
1924    fn end_bumps_only_for_a_known_session() {
1925        let reg = SessionsRegistry::new();
1926        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
1927        let mut rx = reg.subscribe_changes();
1928
1929        assert!(
1930            !reg.end("ghost", None, None),
1931            "an unknown session is a no-op"
1932        );
1933        assert!(
1934            !rx.has_changed().unwrap(),
1935            "ending an unknown session must not bump"
1936        );
1937
1938        assert!(reg.end("s1", None, None));
1939        assert!(rx.has_changed().unwrap(), "a real end should bump");
1940        rx.borrow_and_update();
1941    }
1942
1943    /// A hook sighting from agent process `pid`.
1944    fn observe_from(session_id: &str, event: SessionEvent, pid: u32) -> ObserveRequest {
1945        ObserveRequest {
1946            pid: Some(pid),
1947            ..observe_request(session_id, event, None)
1948        }
1949    }
1950
1951    #[test]
1952    fn a_late_end_from_the_replaced_process_keeps_a_resumed_session_live() {
1953        let reg = SessionsRegistry::new();
1954        // The old process (pid 100) runs the session; a window reload starts
1955        // the new one (pid 200) on the same session_id before 100 has exited.
1956        reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
1957        reg.observe(observe_from("s1", SessionEvent::Stop, 100));
1958        reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
1959        let rx = reg.subscribe_changes();
1960
1961        // The old process's `SessionEnd` lands last (#1948).
1962        assert!(reg.end("s1", Some("other"), Some(100)));
1963        assert!(!rx.has_changed().unwrap(), "an ignored end must not bump");
1964        let sessions = reg.list();
1965        assert_eq!(sessions.len(), 1);
1966        assert_eq!(sessions[0].state, SessionState::Starting);
1967
1968        // The new process's own `SessionEnd` still ends it.
1969        assert!(reg.end("s1", Some("exit"), Some(200)));
1970        assert_eq!(reg.list()[0].state, SessionState::Ended);
1971    }
1972
1973    #[test]
1974    fn a_straggling_hook_from_the_replaced_process_does_not_take_the_session_back() {
1975        let reg = SessionsRegistry::new();
1976        reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
1977        reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
1978        // A replaced pid never becomes the owner again, so 200 still owns it —
1979        // and the straggling `PostToolUse` must not move the state either
1980        // (`observe`, not only `end`, ignores a replaced pid's sighting).
1981        reg.observe(observe_from("s1", SessionEvent::PostToolUse, 100));
1982        assert_eq!(reg.list()[0].state, SessionState::Starting);
1983        reg.end("s1", None, Some(100));
1984        assert_ne!(reg.list()[0].state, SessionState::Ended);
1985    }
1986
1987    #[test]
1988    fn a_straggling_non_end_hook_from_the_replaced_process_does_not_change_state() {
1989        let reg = SessionsRegistry::new();
1990        reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
1991        assert_eq!(reg.list()[0].state, SessionState::Working);
1992        // The resume: a new process takes over ownership.
1993        reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
1994        assert_eq!(reg.list()[0].state, SessionState::Starting);
1995        let rx = reg.subscribe_changes();
1996
1997        // The old process's last, delayed `Stop` arrives after the resume.
1998        // Left unguarded, `for_event(Stop, Starting)` would move this back to
1999        // `idle`, corrupting the resumed session's freshly-`starting` state.
2000        reg.observe(observe_from("s1", SessionEvent::Stop, 100));
2001        assert_eq!(reg.list()[0].state, SessionState::Starting);
2002        assert!(
2003            !rx.has_changed().unwrap(),
2004            "an ignored sighting must not bump"
2005        );
2006    }
2007
2008    #[test]
2009    fn an_end_not_from_the_replaced_process_still_ends_the_session() {
2010        // No pid (an older sink, or a feed that sends none), the owning pid, and
2011        // a never-seen pid (a wrapped hook command whose parent is a per-hook
2012        // shell) all end the session: only a replaced pid is ignored.
2013        for end_pid in [None, Some(200), Some(999)] {
2014            let reg = SessionsRegistry::new();
2015            reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
2016            reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
2017            assert!(reg.end("s1", None, end_pid));
2018            assert_eq!(
2019                reg.list()[0].state,
2020                SessionState::Ended,
2021                "end with pid {end_pid:?}"
2022            );
2023        }
2024        // A session that was never resumed ends on its own process's end.
2025        let reg = SessionsRegistry::new();
2026        reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
2027        assert!(reg.end("s1", None, Some(100)));
2028        assert_eq!(reg.list()[0].state, SessionState::Ended);
2029    }
2030
2031    #[test]
2032    fn every_process_of_a_reload_burst_is_remembered_as_replaced() {
2033        let reg = SessionsRegistry::new();
2034        for pid in [100, 200, 300] {
2035            reg.observe(observe_from("s1", SessionEvent::SessionStart, pid));
2036        }
2037        // Both earlier processes' ends land after the third process started.
2038        assert!(reg.end("s1", None, Some(100)));
2039        assert!(reg.end("s1", None, Some(200)));
2040        assert_eq!(reg.list()[0].state, SessionState::Starting);
2041    }
2042
2043    #[test]
2044    fn a_stream_report_from_a_new_process_takes_the_session_over() {
2045        // A wrapper-only install: `claude-wrap` reports `StreamState` with the
2046        // child's pid, and no hook ever sends a `SessionStart`.
2047        let idle = || SessionEvent::StreamState(SessionState::Idle);
2048        let reg = SessionsRegistry::new();
2049        reg.observe(observe_from("s1", idle(), 100));
2050        reg.observe(observe_from("s1", idle(), 200));
2051        reg.end("s1", None, Some(100));
2052        assert_eq!(reg.list()[0].state, SessionState::Idle);
2053    }
2054
2055    #[test]
2056    fn replaced_pids_are_capped_oldest_first() {
2057        let reg = SessionsRegistry::new();
2058        let last = u32::try_from(MAX_REPLACED_PIDS).unwrap() + 1;
2059        for pid in 0..=last {
2060            reg.observe(observe_from("s1", SessionEvent::PostToolUse, pid));
2061        }
2062        let guard = reg.lock_sessions();
2063        let replaced = &guard["s1"].replaced_pids;
2064        assert_eq!(replaced.len(), MAX_REPLACED_PIDS);
2065        assert_eq!(replaced.front(), Some(&1), "pid 0 was forgotten first");
2066        assert_eq!(replaced.back(), Some(&(last - 1)));
2067    }
2068
2069    #[test]
2070    fn a_sessions_first_pid_sighting_is_not_a_replacement() {
2071        let reg = SessionsRegistry::new();
2072        // Some feeds (e.g. the transcript watcher) can create an entry with no
2073        // pid at all; its first sighting of one has no prior owner to replace.
2074        reg.observe(observe_request(
2075            "s1",
2076            SessionEvent::TranscriptDiscovered,
2077            None,
2078        ));
2079        reg.observe(observe_from("s1", SessionEvent::PostToolUse, 100));
2080        let guard = reg.lock_sessions();
2081        assert_eq!(guard["s1"].pid, Some(100));
2082        assert!(guard["s1"].replaced_pids.is_empty());
2083    }
2084
2085    #[test]
2086    fn end_then_start_resume_ordering_is_unchanged() {
2087        // #1946's ordering: the old process ends first, then the new one starts.
2088        let reg = SessionsRegistry::new();
2089        reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
2090        assert!(reg.end("s1", None, Some(100)));
2091        reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
2092        assert_eq!(reg.list()[0].state, SessionState::Starting);
2093    }
2094
2095    #[test]
2096    fn window_report_bumps_only_when_it_changes_the_source_join() {
2097        let reg = SessionsRegistry::new();
2098        reg.report_window(window_report("w1", "/p", true));
2099        let mut rx = reg.subscribe_changes();
2100
2101        // The unchanged ~10s refresh every open window sends: liveness only.
2102        reg.report_window(window_report("w1", "/p", true));
2103        assert!(
2104            !rx.has_changed().unwrap(),
2105            "an unchanged window refresh must not bump"
2106        );
2107
2108        // The window's Claude tab closed → its sessions fall back to `terminal`.
2109        reg.report_window(window_report("w1", "/p", false));
2110        assert!(
2111            rx.has_changed().unwrap(),
2112            "an embedding that vanished should bump"
2113        );
2114        rx.borrow_and_update();
2115
2116        // Different folders → a different `cwd`-prefix join.
2117        reg.report_window(window_report("w1", "/q", false));
2118        assert!(rx.has_changed().unwrap(), "changed folders should bump");
2119        rx.borrow_and_update();
2120
2121        // A brand-new window joins the registry.
2122        reg.report_window(window_report("w2", "/r", true));
2123        assert!(rx.has_changed().unwrap(), "a new window should bump");
2124    }
2125
2126    #[test]
2127    fn unregister_window_bumps_only_when_it_removes() {
2128        let reg = SessionsRegistry::new();
2129        reg.report_window(window_report("w1", "/p", true));
2130        let rx = reg.subscribe_changes();
2131
2132        assert!(!reg.unregister_window("ghost"));
2133        assert!(
2134            !rx.has_changed().unwrap(),
2135            "a no-op unregister must not bump"
2136        );
2137
2138        assert!(reg.unregister_window("w1"));
2139        assert!(
2140            rx.has_changed().unwrap(),
2141            "a removing unregister should bump"
2142        );
2143    }
2144
2145    #[tokio::test]
2146    async fn a_burst_of_bumps_coalesces_into_one_wakeup() {
2147        let reg = SessionsRegistry::new();
2148        let mut rx = reg.subscribe_changes();
2149        // Three visible changes back to back, all before anyone awaits.
2150        reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
2151        reg.observe(observe_request("s2", SessionEvent::SessionStart, None));
2152        reg.observe(observe_request("s3", SessionEvent::SessionStart, None));
2153        // `changed()` marks the newest version seen, so the burst is one wakeup…
2154        rx.changed().await.unwrap();
2155        // …and there is nothing left pending for a second one.
2156        assert!(
2157            !rx.has_changed().unwrap(),
2158            "a burst should collapse into a single wakeup"
2159        );
2160    }
2161
2162    #[test]
2163    fn list_does_not_bump_even_when_it_reaps() {
2164        // `list` is the body of every subscription's `snapshot()`, so a bump here
2165        // would feed the stream loop back into itself. A read-path reap reaches
2166        // other subscribers on the server's next periodic re-sample instead.
2167        let reg = SessionsRegistry::new();
2168        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
2169        // Age the entry past its TTL so the next `list` reaps it.
2170        {
2171            let mut sessions = reg.lock_sessions();
2172            let entry = sessions.get_mut("s1").unwrap();
2173            entry.last_seen = Utc::now() - chrono::Duration::seconds(600);
2174        }
2175        let rx = reg.subscribe_changes();
2176        assert!(reg.list().is_empty(), "the stale session should be reaped");
2177        assert!(!rx.has_changed().unwrap(), "`list` must never bump");
2178    }
2179}