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
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36use std::sync::{Mutex, MutexGuard, PoisonError};
37use std::time::Duration;
38
39use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41use tokio::sync::watch;
42
43pub mod stream;
44pub mod watcher;
45
46/// How long a session may go silent before it ages out of the registry.
47///
48/// Unlike a VS Code window (which heartbeats every ~10s), a running Claude
49/// session emits nothing while idle at the prompt, so its only liveness signal
50/// is activity — a hook event or transcript growth. The TTL is therefore
51/// generous: a session that has done nothing for this long is assumed gone (a
52/// `claude` that exited without firing `SessionEnd`) and reaped on the next read.
53/// A still-alive idle session re-appears the moment it next does anything. This
54/// is the accepted limitation of the hook-based approach — see ADR-0052.
55const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
56
57/// How long an **ended** session lingers before it is reaped, so `sessions list`
58/// briefly shows a session that just finished (`SessionEnd` fired → [`end`]) as
59/// `ended` rather than having it vanish instantly.
60///
61/// [`end`]: SessionsRegistry::end
62const ENDED_SESSION_TTL: Duration = Duration::from_secs(10);
63
64/// How long a companion window-embedding report survives without a refresh.
65/// Mirrors the worktrees window TTL (three missed ~10s heartbeats): a window
66/// that crashed without unregistering stops tagging its sessions as VS Code
67/// embedded on the next read.
68const DEFAULT_WINDOW_TTL: Duration = Duration::from_secs(30);
69
70/// Ceiling on live session entries, so a runaway feed cannot grow daemon memory
71/// faster than the TTL reaps it (the worktrees `MAX_WINDOWS` precedent, #1140).
72/// Far above any real concurrent-session count; at the cap a genuinely new
73/// session evicts the longest-silent entry rather than being rejected, so ingest
74/// stays infallible.
75const MAX_SESSIONS: usize = 512;
76
77/// Ceiling on live window-embedding reports, mirroring the worktrees registry cap.
78const MAX_WINDOWS: usize = 256;
79
80/// The coarse, inferred lifecycle state of a Claude Code session.
81///
82/// Serialized `snake_case` (`waiting_for_permission`, …) into `list`/`status`
83/// payloads. `waiting_for_*` are **reliable** (a `Notification` hook fires them
84/// directly); `working`/`idle` are best-effort inference from `PreToolUse` /
85/// `Stop` plus the transcript-growth backstop (Claude Code ships no dedicated
86/// state event — ADR-0052).
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum SessionState {
90    /// Session just started (`SessionStart`), before any turn.
91    Starting,
92    /// Actively processing a turn — a tool call (`PreToolUse`/`PostToolUse`), a
93    /// submitted prompt (`UserPromptSubmit`), or observed transcript growth.
94    Working,
95    /// Finished a turn and waiting at the prompt (`Stop`).
96    Idle,
97    /// Blocked on the user for a plain input/idle notification.
98    WaitingForInput,
99    /// Blocked on the user to approve a tool/permission prompt.
100    WaitingForPermission,
101    /// The session ended (`SessionEnd`); reaped shortly after via
102    /// [`ENDED_SESSION_TTL`].
103    Ended,
104}
105
106impl SessionState {
107    /// The state a sighting of `event` implies, given the session's `current`
108    /// state (`None` for a brand-new session). This is the whole inference
109    /// machine, kept in one testable place:
110    ///
111    /// - `SessionStart` → [`Starting`](Self::Starting)
112    /// - `UserPromptSubmit` / `PreToolUse` / `PostToolUse` →
113    ///   [`Working`](Self::Working)
114    /// - `TranscriptGrew` → [`Working`](Self::Working), **except** while
115    ///   [`WaitingForInput`](Self::WaitingForInput) /
116    ///   [`WaitingForPermission`](Self::WaitingForPermission) /
117    ///   [`Ended`](Self::Ended), which it leaves **unchanged** — growth is
118    ///   expected in those states without the session doing anything, so it is
119    ///   not evidence the turn resumed (#1418)
120    /// - `Stop` → [`Idle`](Self::Idle)
121    /// - `Notification(PermissionPrompt)` →
122    ///   [`WaitingForPermission`](Self::WaitingForPermission)
123    /// - `Notification(IdlePrompt | AgentNeedsInput)` →
124    ///   [`WaitingForInput`](Self::WaitingForInput)
125    /// - `Notification(Other)` → **unchanged** (an unclassified notification is
126    ///   not evidence of a state change)
127    /// - `TranscriptDiscovered` → the current state if known, else
128    ///   [`Idle`](Self::Idle) (a passively-discovered session's activity is
129    ///   unknown; a later hook or growth upgrades it)
130    /// - `StreamState(s)` → `s` verbatim (an authoritative stream-json report;
131    ///   the only non-inferred variant — see ADR-0057)
132    #[must_use]
133    pub fn for_event(event: &SessionEvent, current: Option<Self>) -> Self {
134        match event {
135            SessionEvent::SessionStart => Self::Starting,
136            SessionEvent::UserPromptSubmit
137            | SessionEvent::PreToolUse
138            | SessionEvent::PostToolUse => Self::Working,
139            // Growth is only evidence the transcript file got bigger. From most
140            // states that does imply a turn is running, but in two it does not,
141            // and reading it as `working` would overwrite a state a hook
142            // reported directly:
143            //
144            // - `waiting_for_*` — Claude flushes the assistant `tool_use` line
145            //   *before* the prompt it is asking about can be answered, so the
146            //   watcher's next scan would downgrade the wait and the row would
147            //   go quiet exactly when it should be shouting (#1418);
148            // - `ended` — a session's last lines land around `SessionEnd`, so a
149            //   scan inside the ended-linger window would revive the entry and
150            //   hold a phantom `working` row for the whole session TTL.
151            //
152            // That is ADR-0052's reliable-over-inferred ordering, and the rule
153            // `stream.rs`'s `state` already applies to a permission prompt. Both
154            // states are released by any later hook, which is inference-free.
155            SessionEvent::TranscriptGrew => match current {
156                Some(held @ (Self::WaitingForInput | Self::WaitingForPermission | Self::Ended)) => {
157                    held
158                }
159                _ => Self::Working,
160            },
161            SessionEvent::Stop => Self::Idle,
162            // An authoritative state from a stream-json observer wins outright,
163            // ignoring the inferred `current` — it read the exact state from the
164            // stream rather than guessing from a lifecycle event (ADR-0057).
165            SessionEvent::StreamState(state) => *state,
166            SessionEvent::Notification(NotificationKind::PermissionPrompt) => {
167                Self::WaitingForPermission
168            }
169            SessionEvent::Notification(
170                NotificationKind::IdlePrompt | NotificationKind::AgentNeedsInput,
171            ) => Self::WaitingForInput,
172            // An unclassified notification carries no state signal, and a
173            // passively-discovered transcript's activity is unknown: keep the
174            // current state (or default a brand-new session to Idle).
175            SessionEvent::Notification(NotificationKind::Other)
176            | SessionEvent::TranscriptDiscovered => current.unwrap_or(Self::Idle),
177        }
178    }
179}
180
181/// The classification of a Claude Code `Notification` hook.
182///
183/// Derived by the hook sink from the notification message (the message text is
184/// version-unstable, so classification is best-effort with an
185/// [`Other`](Self::Other) fallback).
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum NotificationKind {
189    /// Claude is asking to run a tool / use a permission — reliably
190    /// [`WaitingForPermission`](SessionState::WaitingForPermission).
191    PermissionPrompt,
192    /// Claude has been idle waiting for the user to respond.
193    IdlePrompt,
194    /// An agent/subagent needs the user's input.
195    AgentNeedsInput,
196    /// A notification we could not classify — carries no state signal.
197    Other,
198}
199
200/// A sighting of a session, from a hook event or the transcript watcher.
201///
202/// Drives the [`SessionState::for_event`] inference and refreshes liveness.
203/// Serialized on the wire as part of an [`ObserveRequest`]; `snake_case`, with
204/// the notification kind nested (`{"notification":"permission_prompt"}`).
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum SessionEvent {
208    /// `SessionStart` hook.
209    SessionStart,
210    /// `UserPromptSubmit` hook — a prompt was submitted.
211    UserPromptSubmit,
212    /// `PreToolUse` hook — about to run a tool.
213    PreToolUse,
214    /// `PostToolUse` hook — a tool finished.
215    PostToolUse,
216    /// `Stop` hook — the turn finished.
217    Stop,
218    /// `Notification` hook, classified into a [`NotificationKind`].
219    Notification(NotificationKind),
220    /// The transcript watcher saw this session's `.jsonl` grow (the
221    /// "thinking-window" backstop, where no hook fires).
222    TranscriptGrew,
223    /// The transcript watcher discovered a session's `.jsonl` it had not seen —
224    /// a session that started before the daemon, or before hooks were installed.
225    TranscriptDiscovered,
226    /// An **authoritative** state reported directly by a stream-json observer —
227    /// the `omni-dev claude-wrap` wrapper reading Claude's `--output-format
228    /// stream-json` stdout, where the exact state is first-class (`init` →
229    /// working, `result` → idle, `can_use_tool` → waiting-for-permission). Unlike
230    /// every other variant this is *not* inferred: [`SessionState::for_event`]
231    /// returns the carried state verbatim. Serialized as
232    /// `{"stream_state":"waiting_for_permission"}` (ADR-0057).
233    StreamState(SessionState),
234}
235
236/// Where a session is running, resolved at [`list`](SessionsRegistry::list) time
237/// by joining a session's `cwd` against the companion's window-embedding reports.
238///
239/// A session whose `cwd` lies under a reporting VS Code window that has ≥1 Claude
240/// tab/terminal is tagged [`VsCode`](Self::VsCode); everything else is
241/// [`Terminal`](Self::Terminal) — meaning "not matched to a reporting VS Code
242/// window" (a bare terminal session, or a VS Code session whose companion is not
243/// installed). Serialized as `{"kind":"terminal"}` /
244/// `{"kind":"vs_code","window_key":"…"}`.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(tag = "kind", rename_all = "snake_case")]
247pub enum Source {
248    /// Not matched to any reporting VS Code window.
249    Terminal,
250    /// Embedded in a VS Code window (matched by `cwd`), carrying that window's
251    /// companion key for a focus action.
252    VsCode {
253        /// The matched window's companion key.
254        window_key: String,
255    },
256}
257
258/// An idempotent session sighting sent to the registry — the wire payload of the
259/// `observe` op, and the argument to [`SessionsRegistry::observe`].
260///
261/// The hook sink and the transcript watcher both produce these; every field but
262/// `session_id` and `event` is best-effort and *fills in* missing data on an
263/// existing entry without ever clobbering known data with `None`.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ObserveRequest {
266    /// The Claude `session_id` (a UUID) — the primary key. Equal to the
267    /// transcript filename stem and (per ADR-0052) the VS Code extension's tab
268    /// key, so the three feeds join without heuristics.
269    pub session_id: String,
270    /// The session's working directory, when known (from the hook `cwd`).
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub cwd: Option<PathBuf>,
273    /// The `~/.claude/projects/**/<session-id>.jsonl` transcript path, when known.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub transcript_path: Option<PathBuf>,
276    /// The event that produced this sighting; drives the state inference.
277    pub event: SessionEvent,
278    /// The repository name enriched from `cwd` by the adapter (git2), when
279    /// resolvable. Stored verbatim; the engine does no disk I/O.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub repo: Option<String>,
282    /// The model id, when a hook reports one.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub model: Option<String>,
285}
286
287/// A companion report of one VS Code window's embedded Claude sessions.
288///
289/// The wire payload of the `window` op. The companion cannot expose a tab's
290/// `session_id` (Claude Code's extension has no public API — ADR-0052), so it
291/// reports only the *counts* of Claude tabs/terminals plus the window's folders;
292/// the join to a specific session is by `cwd`.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct WindowReport {
295    /// The companion-owned per-window key (also the worktrees registration key).
296    pub key: String,
297    /// The window's workspace-folder absolute paths, for the `cwd` join.
298    #[serde(default)]
299    pub folders: Vec<PathBuf>,
300    /// How many Claude editor tabs (`claudeVSCodePanel` webviews) the window has.
301    #[serde(default)]
302    pub tabs: usize,
303    /// How many Claude Code integrated terminals the window has.
304    #[serde(default)]
305    pub terminals: usize,
306}
307
308impl WindowReport {
309    /// Whether this window has any Claude embedding at all — the gate for
310    /// tagging a matching session as [`Source::VsCode`].
311    #[must_use]
312    fn has_embedding(&self) -> bool {
313        self.tabs > 0 || self.terminals > 0
314    }
315}
316
317/// One live session in the registry.
318///
319/// Serialized verbatim into `list` / `status` payloads; consumers compute age
320/// from `last_seen` (RFC 3339). `source` is resolved at
321/// [`list`](SessionsRegistry::list) time (stored as [`Source::Terminal`] until
322/// then).
323#[derive(Debug, Clone, Serialize)]
324pub struct SessionEntry {
325    /// The Claude `session_id`.
326    pub session_id: String,
327    /// The session's working directory, when known.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub cwd: Option<PathBuf>,
330    /// The transcript path, when known.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub transcript_path: Option<PathBuf>,
333    /// The repository name enriched from `cwd`, when resolvable.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub repo: Option<String>,
336    /// The model id, when reported.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub model: Option<String>,
339    /// The current inferred state.
340    pub state: SessionState,
341    /// Where the session runs, resolved on read.
342    pub source: Source,
343    /// The most recent event observed for this session.
344    pub last_event: SessionEvent,
345    /// When the session was first observed (RFC 3339).
346    pub started_at: DateTime<Utc>,
347    /// When the registry last heard from this session (RFC 3339).
348    pub last_seen: DateTime<Utc>,
349}
350
351/// One companion window-embedding report, with its liveness stamp.
352#[derive(Debug, Clone)]
353struct WindowEntry {
354    /// The report as sent by the companion.
355    report: WindowReport,
356    /// When the report last arrived (register or refresh).
357    last_seen: DateTime<Utc>,
358}
359
360/// The cross-window session registry.
361///
362/// The in-memory, TTL-reaped set of running Claude sessions plus the companion
363/// window-embedding reports used to tag a session's [`Source`]. Hosted by
364/// [`SessionsService`](crate::daemon::services::sessions::SessionsService).
365pub struct SessionsRegistry {
366    /// Live sessions keyed by `session_id`.
367    sessions: Mutex<HashMap<String, SessionEntry>>,
368    /// Companion window-embedding reports keyed by window key. Behind its own
369    /// mutex, taken independently of `sessions`, so the two never nest.
370    windows: Mutex<HashMap<String, WindowEntry>>,
371    /// How long a session survives without activity.
372    session_ttl: Duration,
373    /// How long an `ended` session lingers before reaping.
374    ended_ttl: Duration,
375    /// How long a window-embedding report survives without a refresh.
376    window_ttl: Duration,
377    /// A monotonically-bumped version counter, incremented whenever the state a
378    /// subscriber renders changes. A push-subscription consumer holds a
379    /// [`watch::Receiver`] from [`subscribe_changes`](Self::subscribe_changes)
380    /// and wakes on each bump to re-snapshot (#1414) — the
381    /// [`WorktreesRegistry`](crate::worktrees::WorktreesRegistry) arrangement,
382    /// one service over. The counter's *value* is immaterial — only that it
383    /// changed — so a burst coalesces into one wake and the server diffs the
384    /// resulting snapshot to suppress duplicate frames.
385    ///
386    /// `watch` needs no runtime and never blocks, so it fits this engine's
387    /// no-async-setup posture; every [`bump`](Self::bump) happens *after* the map
388    /// guard is dropped, so the `std::Mutex`-never-across-`.await` rule is intact
389    /// (and the watch's own internal lock is never nested under a map lock).
390    changes: watch::Sender<u64>,
391}
392
393impl SessionsRegistry {
394    /// Creates the registry with the default liveness TTLs. Cheap — no I/O.
395    #[must_use]
396    pub fn new() -> Self {
397        Self {
398            sessions: Mutex::new(HashMap::new()),
399            windows: Mutex::new(HashMap::new()),
400            session_ttl: DEFAULT_SESSION_TTL,
401            ended_ttl: ENDED_SESSION_TTL,
402            window_ttl: DEFAULT_WINDOW_TTL,
403            changes: watch::channel(0).0,
404        }
405    }
406
407    /// A change-notification receiver for the push subscription: it observes a
408    /// new value each time the rendered session state changes (see
409    /// [`bump`](Self::bump)). Created with the current version already marked
410    /// seen, so the first [`watch::Receiver::changed`] resolves on the *next*
411    /// change — the subscriber sends its own initial snapshot up front and then
412    /// waits for deltas (#1414).
413    #[must_use]
414    pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
415        self.changes.subscribe()
416    }
417
418    /// Signals subscribers that the rendered state changed. Non-blocking and
419    /// runtime-free; called only *after* a map guard is released so the locks
420    /// never nest. A send never fails here (the sender is owned by the registry,
421    /// which outlives every receiver, and `send_modify` bumps even with no
422    /// receivers).
423    ///
424    /// Callers bump **only on a change a consumer renders** — a new or dropped
425    /// session, a [`SessionState`] transition, a best-effort field taking a new
426    /// value, or a window report that alters the [`Source`] join. Deliberately
427    /// *narrower* than "the serialized payload differs": [`SessionEntry`] carries
428    /// `last_seen` and `last_event`, which churn on every hook event, so bumping
429    /// on those would push a fresh snapshot to every window on every `PreToolUse`
430    /// with the server's snapshot diff unable to suppress any of it. Their deltas
431    /// ride the server's periodic re-sample instead — the same latency the poll
432    /// this replaced already had, for fields nothing renders.
433    pub(crate) fn bump(&self) {
434        self.changes.send_modify(|v| *v = v.wrapping_add(1));
435    }
436
437    /// Locks the sessions map, recovering from a poisoned mutex (a panic in a
438    /// prior critical section must not wedge the whole registry).
439    fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, SessionEntry>> {
440        self.sessions.lock().unwrap_or_else(PoisonError::into_inner)
441    }
442
443    /// Locks the windows map, recovering from a poisoned mutex.
444    fn lock_windows(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
445        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
446    }
447
448    /// Records (upserts) a session sighting, running the [`SessionState`]
449    /// inference and refreshing liveness. Reaps stale entries first, then — only
450    /// when a genuinely new session would grow the map past [`MAX_SESSIONS`] —
451    /// evicts the longest-silent entry. Infallible: an upsert never evicts.
452    ///
453    /// Best-effort fields (`cwd`/`transcript_path`/`repo`/`model`) *fill in* on
454    /// an existing entry and never overwrite known data with `None`, so a later
455    /// hook enriches a watcher-discovered session without a race losing data.
456    ///
457    /// [`bump`](Self::bump)s only when the sighting changed something a consumer
458    /// renders — a brand-new session, a [`SessionState`] transition, a
459    /// best-effort field taking a new value, or a reap that dropped a sibling.
460    /// A repeat sighting that merely refreshes liveness does not, since hooks
461    /// fire on every tool call (the `heartbeat` precedent in
462    /// [`WorktreesRegistry`](crate::worktrees::WorktreesRegistry)).
463    pub fn observe(&self, req: ObserveRequest) {
464        let now = Utc::now();
465        let changed = {
466            let mut sessions = self.lock_sessions();
467            let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, now);
468            let mutated = if let Some(entry) = sessions.get_mut(&req.session_id) {
469                let next = SessionState::for_event(&req.event, Some(entry.state));
470                let state_changed = next != entry.state;
471                entry.state = next;
472                entry.last_event = req.event;
473                entry.last_seen = now;
474                // Bound to locals rather than folded into the `||` below: every
475                // field must be filled, and short-circuiting would skip the rest.
476                let filled_cwd = fill(&mut entry.cwd, req.cwd);
477                let filled_transcript = fill(&mut entry.transcript_path, req.transcript_path);
478                let filled_repo = fill(&mut entry.repo, req.repo);
479                let filled_model = fill(&mut entry.model, req.model);
480                state_changed || filled_cwd || filled_transcript || filled_repo || filled_model
481            } else {
482                if sessions.len() >= MAX_SESSIONS {
483                    evict_oldest_session(&mut sessions);
484                }
485                let state = SessionState::for_event(&req.event, None);
486                sessions.insert(
487                    req.session_id.clone(),
488                    SessionEntry {
489                        session_id: req.session_id,
490                        cwd: req.cwd,
491                        transcript_path: req.transcript_path,
492                        repo: req.repo,
493                        model: req.model,
494                        state,
495                        source: Source::Terminal,
496                        last_event: req.event,
497                        started_at: now,
498                        last_seen: now,
499                    },
500                );
501                true
502            };
503            mutated || reaped > 0
504        };
505        if changed {
506            self.bump();
507        }
508    }
509
510    /// Marks a session ended (`SessionEnd`), so `list` shows it as `ended` for a
511    /// short window ([`ENDED_SESSION_TTL`]) before it is reaped. Returns whether
512    /// the session was known. A no-op for an already-unknown session (a
513    /// duplicate/late `SessionEnd`).
514    pub fn end(&self, session_id: &str, _reason: Option<&str>) -> bool {
515        let now = Utc::now();
516        let (known, reaped) = {
517            let mut sessions = self.lock_sessions();
518            let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, now);
519            let known = match sessions.get_mut(session_id) {
520                Some(entry) => {
521                    entry.state = SessionState::Ended;
522                    entry.last_event = SessionEvent::Stop;
523                    entry.last_seen = now;
524                    true
525                }
526                None => false,
527            };
528            (known, reaped)
529        };
530        // A known session flipped to `ended`; an unknown one changed nothing, so
531        // only this call's inline reap could have.
532        if known || reaped > 0 {
533            self.bump();
534        }
535        known
536    }
537
538    /// Records (upserts) a companion window-embedding report and refreshes its
539    /// liveness. Reaps stale windows first, then caps like [`observe`](Self::observe).
540    ///
541    /// [`bump`](Self::bump)s only when the report changes the [`Source`] join a
542    /// consumer renders — a new window, different `folders`, or an embedding that
543    /// appeared or vanished — never on the unchanged ~10 s refresh every open
544    /// window sends, which would otherwise put a permanent push floor under the
545    /// daemon proportional to the window count.
546    pub fn report_window(&self, report: WindowReport) {
547        let now = Utc::now();
548        let changed = {
549            let mut windows = self.lock_windows();
550            let reaped = reap_windows(&mut windows, self.window_ttl, now);
551            let mutated = if let Some(previous) = windows.get(&report.key) {
552                previous.report.folders != report.folders
553                    || previous.report.has_embedding() != report.has_embedding()
554            } else {
555                if windows.len() >= MAX_WINDOWS {
556                    evict_oldest_window(&mut windows);
557                }
558                true
559            };
560            windows.insert(
561                report.key.clone(),
562                WindowEntry {
563                    report,
564                    last_seen: now,
565                },
566            );
567            mutated || reaped > 0
568        };
569        if changed {
570            self.bump();
571        }
572    }
573
574    /// Drops a companion window-embedding report (the window closed). Returns
575    /// whether an entry was present.
576    pub fn unregister_window(&self, key: &str) -> bool {
577        let removed = {
578            let mut windows = self.lock_windows();
579            windows.remove(key).is_some()
580        };
581        if removed {
582            self.bump();
583        }
584        removed
585    }
586
587    /// Reaps stale sessions and windows, then returns the live sessions with
588    /// each [`Source`] resolved and sorted for deterministic output.
589    ///
590    /// Two independent locks, each held only for pure-CPU work and never
591    /// nested: the sessions snapshot is taken and the lock dropped, then the
592    /// windows snapshot, then the join runs lock-free. Path matching is a pure
593    /// prefix compare (no canonicalization / disk I/O), honouring the
594    /// `Mutex`-never-across-`.await` and no-I/O-under-lock invariants.
595    ///
596    /// Deliberately does **not** [`bump`](Self::bump), even when its inline reap
597    /// drops an entry: this is the body of every subscription's `snapshot()`, so
598    /// bumping here would feed the stream loop back into itself. A read-path reap
599    /// reaches other subscribers on the server's next periodic re-sample, whose
600    /// diff sees the shrunken list — the [`WorktreesRegistry::list`] arrangement.
601    ///
602    /// [`WorktreesRegistry::list`]: crate::worktrees::WorktreesRegistry::list
603    pub fn list(&self) -> Vec<SessionEntry> {
604        let now = Utc::now();
605        let mut sessions: Vec<SessionEntry> = {
606            let mut guard = self.lock_sessions();
607            reap_sessions(&mut guard, self.session_ttl, self.ended_ttl, now);
608            guard.values().cloned().collect()
609        };
610        let windows: Vec<WindowReport> = {
611            let mut guard = self.lock_windows();
612            reap_windows(&mut guard, self.window_ttl, now);
613            guard
614                .values()
615                .map(|e| e.report.clone())
616                .filter(WindowReport::has_embedding)
617                .collect()
618        };
619        for session in &mut sessions {
620            session.source = resolve_source(session.cwd.as_deref(), &windows);
621        }
622        sessions.sort_by(|a, b| {
623            a.repo
624                .cmp(&b.repo)
625                .then_with(|| a.session_id.cmp(&b.session_id))
626        });
627        sessions
628    }
629
630    /// The first workspace folder of the still-live window a session is embedded
631    /// in, if any — used by the tray "focus" action to resolve a session to a
632    /// folder to open in VS Code. `None` when the session has no `cwd`, or is not
633    /// matched to a reporting window with a folder.
634    pub fn focus_folder(&self, session_id: &str) -> Option<PathBuf> {
635        let cwd = {
636            let sessions = self.lock_sessions();
637            sessions.get(session_id).and_then(|e| e.cwd.clone())
638        }?;
639        let now = Utc::now();
640        let mut windows = self.lock_windows();
641        reap_windows(&mut windows, self.window_ttl, now);
642        windows
643            .values()
644            .map(|e| &e.report)
645            .filter(|w| w.has_embedding())
646            .filter(|w| w.folders.iter().any(|f| cwd.starts_with(f)))
647            .find_map(|w| w.folders.first().cloned())
648    }
649}
650
651impl Default for SessionsRegistry {
652    fn default() -> Self {
653        Self::new()
654    }
655}
656
657/// Fills `slot` from `incoming` only when `incoming` carries a value, so a
658/// best-effort field never overwrites known data with `None` on a re-`observe`.
659/// Returns whether the stored value actually changed, which is what decides
660/// whether the sighting is worth a [`bump`](SessionsRegistry::bump) — a hook
661/// re-sending the same `cwd` it sent last time is not.
662fn fill<T: PartialEq>(slot: &mut Option<T>, incoming: Option<T>) -> bool {
663    match incoming {
664        Some(value) if slot.as_ref() != Some(&value) => {
665            *slot = Some(value);
666            true
667        }
668        _ => false,
669    }
670}
671
672/// Resolves a session's [`Source`] by joining its `cwd` against the live
673/// window-embedding reports.
674///
675/// Among the windows whose folder is a prefix of `cwd`, the one with the lowest
676/// key wins (a deterministic tiebreak). A session with no `cwd`, or no matching
677/// window, is [`Source::Terminal`].
678fn resolve_source(cwd: Option<&Path>, windows: &[WindowReport]) -> Source {
679    let Some(cwd) = cwd else {
680        return Source::Terminal;
681    };
682    let matched = windows
683        .iter()
684        .filter(|w| w.folders.iter().any(|f| cwd.starts_with(f)))
685        .min_by(|a, b| a.key.cmp(&b.key));
686    match matched {
687        Some(window) => Source::VsCode {
688            window_key: window.key.clone(),
689        },
690        None => Source::Terminal,
691    }
692}
693
694/// Removes sessions last seen longer than their TTL ago (a shorter
695/// [`ended_ttl`](SessionsRegistry::ended_ttl) for `ended` sessions), returning
696/// how many were dropped. Pure CPU; the caller holds the sessions lock but never
697/// `.await`s under it.
698fn reap_sessions(
699    sessions: &mut HashMap<String, SessionEntry>,
700    session_ttl: Duration,
701    ended_ttl: Duration,
702    now: DateTime<Utc>,
703) -> usize {
704    let session_max = session_ttl.as_secs() as i64;
705    let ended_max = ended_ttl.as_secs() as i64;
706    let before = sessions.len();
707    sessions.retain(|_, e| {
708        let max_age = if e.state == SessionState::Ended {
709            ended_max
710        } else {
711            session_max
712        };
713        (now - e.last_seen).num_seconds() <= max_age
714    });
715    before - sessions.len()
716}
717
718/// Removes window-embedding reports last refreshed longer than `ttl` ago.
719fn reap_windows(
720    windows: &mut HashMap<String, WindowEntry>,
721    ttl: Duration,
722    now: DateTime<Utc>,
723) -> usize {
724    let max_age = ttl.as_secs() as i64;
725    let before = windows.len();
726    windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
727    before - windows.len()
728}
729
730/// Removes the session with the oldest `last_seen` (ties broken by lowest
731/// `session_id` for determinism). Called when a new session would exceed
732/// [`MAX_SESSIONS`].
733fn evict_oldest_session(sessions: &mut HashMap<String, SessionEntry>) {
734    let oldest = sessions
735        .values()
736        .min_by(|a, b| {
737            a.last_seen
738                .cmp(&b.last_seen)
739                .then_with(|| a.session_id.cmp(&b.session_id))
740        })
741        .map(|e| e.session_id.clone());
742    if let Some(key) = oldest {
743        sessions.remove(&key);
744    }
745}
746
747/// Removes the window report with the oldest `last_seen` (ties broken by lowest
748/// key). Called when a new window would exceed [`MAX_WINDOWS`].
749fn evict_oldest_window(windows: &mut HashMap<String, WindowEntry>) {
750    let oldest = windows
751        .iter()
752        .min_by(|a, b| a.1.last_seen.cmp(&b.1.last_seen).then_with(|| a.0.cmp(b.0)))
753        .map(|(k, _)| k.clone());
754    if let Some(key) = oldest {
755        windows.remove(&key);
756    }
757}
758
759#[cfg(test)]
760#[allow(clippy::unwrap_used, clippy::expect_used)]
761mod tests {
762    use super::*;
763
764    fn observe_request(session_id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
765        ObserveRequest {
766            session_id: session_id.to_string(),
767            cwd: cwd.map(PathBuf::from),
768            transcript_path: None,
769            event,
770            repo: None,
771            model: None,
772        }
773    }
774
775    #[test]
776    fn list_is_empty_initially() {
777        let reg = SessionsRegistry::new();
778        assert!(reg.list().is_empty());
779    }
780
781    #[test]
782    fn observe_then_list_round_trips_and_infers_state() {
783        let reg = SessionsRegistry::new();
784        reg.observe(observe_request(
785            "s1",
786            SessionEvent::SessionStart,
787            Some("/tmp/a"),
788        ));
789        let sessions = reg.list();
790        assert_eq!(sessions.len(), 1);
791        assert_eq!(sessions[0].session_id, "s1");
792        assert_eq!(sessions[0].state, SessionState::Starting);
793        // No window reports → a bare terminal session.
794        assert_eq!(sessions[0].source, Source::Terminal);
795    }
796
797    #[test]
798    fn observe_is_idempotent_upsert_advancing_state() {
799        let reg = SessionsRegistry::new();
800        reg.observe(observe_request(
801            "s1",
802            SessionEvent::SessionStart,
803            Some("/tmp/a"),
804        ));
805        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
806        let sessions = reg.list();
807        assert_eq!(sessions.len(), 1, "same session_id upserts, not duplicates");
808        assert_eq!(sessions[0].state, SessionState::Working);
809        // The later `observe` had no cwd, but the known one is preserved.
810        assert_eq!(sessions[0].cwd.as_deref(), Some(Path::new("/tmp/a")));
811    }
812
813    #[test]
814    fn state_machine_covers_every_event() {
815        use NotificationKind::*;
816        use SessionEvent::*;
817        let cases = [
818            (SessionStart, SessionState::Starting),
819            (UserPromptSubmit, SessionState::Working),
820            (PreToolUse, SessionState::Working),
821            (PostToolUse, SessionState::Working),
822            (Stop, SessionState::Idle),
823            (
824                Notification(PermissionPrompt),
825                SessionState::WaitingForPermission,
826            ),
827            (Notification(IdlePrompt), SessionState::WaitingForInput),
828            (Notification(AgentNeedsInput), SessionState::WaitingForInput),
829            (TranscriptGrew, SessionState::Working),
830            (TranscriptDiscovered, SessionState::Idle),
831            // An authoritative stream-json report is returned verbatim.
832            (
833                StreamState(SessionState::WaitingForPermission),
834                SessionState::WaitingForPermission,
835            ),
836            (StreamState(SessionState::Idle), SessionState::Idle),
837        ];
838        for (event, expected) in cases {
839            assert_eq!(
840                SessionState::for_event(&event, None),
841                expected,
842                "event {event:?}"
843            );
844        }
845        // An unclassified notification keeps the current state.
846        assert_eq!(
847            SessionState::for_event(&Notification(Other), Some(SessionState::Working)),
848            SessionState::Working
849        );
850        // TranscriptDiscovered on a known session keeps its state.
851        assert_eq!(
852            SessionState::for_event(&TranscriptDiscovered, Some(SessionState::Working)),
853            SessionState::Working
854        );
855        // An authoritative StreamState overrides any current state — it read the
856        // exact state from the stream rather than inferring it.
857        assert_eq!(
858            SessionState::for_event(
859                &StreamState(SessionState::Idle),
860                Some(SessionState::Working)
861            ),
862            SessionState::Idle
863        );
864        // Growth is expected while a session waits on the user (the transcript
865        // grows before the prompt is answered) and around `SessionEnd` (the
866        // final lines land as it exits), so in neither case is it evidence the
867        // turn is running: the directly reported state stands (#1418).
868        for held in [
869            SessionState::WaitingForInput,
870            SessionState::WaitingForPermission,
871            SessionState::Ended,
872        ] {
873            assert_eq!(
874                SessionState::for_event(&TranscriptGrew, Some(held)),
875                held,
876                "growth must not overwrite {held:?}"
877            );
878        }
879        // From every other state growth still means working, as does growth on
880        // a session whose state is not yet known (covered by the table above).
881        for other in [
882            SessionState::Working,
883            SessionState::Idle,
884            SessionState::Starting,
885        ] {
886            assert_eq!(
887                SessionState::for_event(&TranscriptGrew, Some(other)),
888                SessionState::Working,
889                "growth from {other:?}"
890            );
891        }
892    }
893
894    #[test]
895    fn end_marks_ended_and_reaps_quickly() {
896        let reg = SessionsRegistry::new();
897        reg.observe(observe_request(
898            "s1",
899            SessionEvent::PreToolUse,
900            Some("/tmp/a"),
901        ));
902        assert!(reg.end("s1", Some("clear")));
903        // Ending an unknown session is a no-op.
904        assert!(!reg.end("ghost", None));
905        let sessions = reg.list();
906        assert_eq!(sessions.len(), 1);
907        assert_eq!(sessions[0].state, SessionState::Ended);
908        // Age the ended entry past the short ended TTL: it reaps out.
909        {
910            let mut guard = reg.lock_sessions();
911            guard.get_mut("s1").unwrap().last_seen = Utc::now() - chrono::Duration::seconds(30);
912        }
913        assert!(reg.list().is_empty(), "ended entry reaps after ended TTL");
914    }
915
916    #[test]
917    fn stale_working_session_reaps_but_recent_survives() {
918        let reg = SessionsRegistry::new();
919        reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
920        reg.observe(observe_request("stale", SessionEvent::PreToolUse, None));
921        {
922            let mut guard = reg.lock_sessions();
923            guard.get_mut("stale").unwrap().last_seen =
924                Utc::now() - chrono::Duration::seconds(1000);
925        }
926        let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
927        assert_eq!(ids, vec!["fresh".to_string()]);
928    }
929
930    #[test]
931    fn source_is_vscode_when_cwd_is_under_a_reporting_window() {
932        let reg = SessionsRegistry::new();
933        reg.observe(observe_request(
934            "s1",
935            SessionEvent::PreToolUse,
936            Some("/home/me/proj/sub"),
937        ));
938        // A window reporting a Claude tab whose folder is a prefix of the cwd.
939        reg.report_window(WindowReport {
940            key: "w1".to_string(),
941            folders: vec![PathBuf::from("/home/me/proj")],
942            tabs: 1,
943            terminals: 0,
944        });
945        let sessions = reg.list();
946        assert_eq!(
947            sessions[0].source,
948            Source::VsCode {
949                window_key: "w1".to_string()
950            }
951        );
952    }
953
954    #[test]
955    fn source_is_terminal_when_window_has_no_embedding() {
956        let reg = SessionsRegistry::new();
957        reg.observe(observe_request(
958            "s1",
959            SessionEvent::PreToolUse,
960            Some("/home/me/proj"),
961        ));
962        // A window is open on the folder but has no Claude tab/terminal.
963        reg.report_window(WindowReport {
964            key: "w1".to_string(),
965            folders: vec![PathBuf::from("/home/me/proj")],
966            tabs: 0,
967            terminals: 0,
968        });
969        assert_eq!(reg.list()[0].source, Source::Terminal);
970    }
971
972    #[test]
973    fn window_report_is_upsert_and_unregister_removes() {
974        let reg = SessionsRegistry::new();
975        reg.report_window(WindowReport {
976            key: "w1".to_string(),
977            folders: vec![PathBuf::from("/p")],
978            tabs: 1,
979            terminals: 0,
980        });
981        // Upsert (same key) does not duplicate.
982        reg.report_window(WindowReport {
983            key: "w1".to_string(),
984            folders: vec![PathBuf::from("/p")],
985            tabs: 2,
986            terminals: 1,
987        });
988        assert!(reg.unregister_window("w1"));
989        assert!(!reg.unregister_window("w1"));
990    }
991
992    #[test]
993    fn stale_window_stops_tagging_source() {
994        let reg = SessionsRegistry::new();
995        reg.observe(observe_request(
996            "s1",
997            SessionEvent::PreToolUse,
998            Some("/p/sub"),
999        ));
1000        reg.report_window(WindowReport {
1001            key: "w1".to_string(),
1002            folders: vec![PathBuf::from("/p")],
1003            tabs: 1,
1004            terminals: 0,
1005        });
1006        // Age the window report past the window TTL.
1007        {
1008            let mut guard = reg.lock_windows();
1009            guard.get_mut("w1").unwrap().last_seen = Utc::now() - chrono::Duration::seconds(120);
1010        }
1011        assert_eq!(reg.list()[0].source, Source::Terminal);
1012    }
1013
1014    #[test]
1015    fn resolve_source_prefers_lowest_key_on_overlap() {
1016        // Two windows both cover the cwd; the lowest key wins deterministically.
1017        let windows = vec![
1018            WindowReport {
1019                key: "w2".to_string(),
1020                folders: vec![PathBuf::from("/p")],
1021                tabs: 1,
1022                terminals: 0,
1023            },
1024            WindowReport {
1025                key: "w1".to_string(),
1026                folders: vec![PathBuf::from("/p")],
1027                tabs: 1,
1028                terminals: 0,
1029            },
1030        ];
1031        assert_eq!(
1032            resolve_source(Some(Path::new("/p/x")), &windows),
1033            Source::VsCode {
1034                window_key: "w1".to_string()
1035            }
1036        );
1037        // No cwd → terminal.
1038        assert_eq!(resolve_source(None, &windows), Source::Terminal);
1039    }
1040
1041    #[test]
1042    fn focus_folder_resolves_matching_window_folder() {
1043        let reg = SessionsRegistry::new();
1044        reg.observe(observe_request(
1045            "s1",
1046            SessionEvent::PreToolUse,
1047            Some("/home/me/proj/sub"),
1048        ));
1049        assert!(reg.focus_folder("s1").is_none(), "no window yet");
1050        reg.report_window(WindowReport {
1051            key: "w1".to_string(),
1052            folders: vec![PathBuf::from("/home/me/proj")],
1053            tabs: 1,
1054            terminals: 0,
1055        });
1056        assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/home/me/proj")));
1057        // An unknown session resolves to nothing.
1058        assert!(reg.focus_folder("ghost").is_none());
1059    }
1060
1061    #[test]
1062    fn evict_oldest_session_drops_the_longest_silent() {
1063        let now = Utc::now();
1064        let mut sessions = HashMap::new();
1065        for (id, age) in [("young", 0), ("old", 100), ("older", 200)] {
1066            sessions.insert(
1067                id.to_string(),
1068                SessionEntry {
1069                    session_id: id.to_string(),
1070                    cwd: None,
1071                    transcript_path: None,
1072                    repo: None,
1073                    model: None,
1074                    state: SessionState::Working,
1075                    source: Source::Terminal,
1076                    last_event: SessionEvent::PreToolUse,
1077                    started_at: now,
1078                    last_seen: now - chrono::Duration::seconds(age),
1079                },
1080            );
1081        }
1082        evict_oldest_session(&mut sessions);
1083        assert!(!sessions.contains_key("older"));
1084        assert!(sessions.contains_key("young"));
1085        assert!(sessions.contains_key("old"));
1086    }
1087
1088    #[test]
1089    fn list_sorts_by_repo_then_session_id() {
1090        let reg = SessionsRegistry::new();
1091        for (id, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
1092            reg.observe(ObserveRequest {
1093                session_id: id.to_string(),
1094                cwd: None,
1095                transcript_path: None,
1096                event: SessionEvent::PreToolUse,
1097                repo: Some(repo.to_string()),
1098                model: None,
1099            });
1100        }
1101        let ordered: Vec<(String, String)> = reg
1102            .list()
1103            .into_iter()
1104            .map(|s| (s.session_id, s.repo.unwrap()))
1105            .collect();
1106        assert_eq!(
1107            ordered,
1108            vec![
1109                ("m".to_string(), "repo-a".to_string()),
1110                ("z".to_string(), "repo-a".to_string()),
1111                ("a".to_string(), "repo-b".to_string()),
1112            ]
1113        );
1114    }
1115
1116    #[test]
1117    fn serialized_session_shapes_are_stable() {
1118        // The wire shape consumers (CLI, extension) read: snake_case state, a
1119        // tagged source, and omitted `None` fields.
1120        let reg = SessionsRegistry::new();
1121        reg.observe(ObserveRequest {
1122            session_id: "s1".to_string(),
1123            cwd: Some(PathBuf::from("/p")),
1124            transcript_path: None,
1125            event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
1126            repo: Some("proj".to_string()),
1127            model: None,
1128        });
1129        let value = serde_json::to_value(&reg.list()[0]).unwrap();
1130        assert_eq!(value["state"], "waiting_for_permission");
1131        assert_eq!(value["source"]["kind"], "terminal");
1132        assert_eq!(value["repo"], "proj");
1133        // Absent optional fields are omitted, not null.
1134        assert!(value.get("model").is_none());
1135        assert!(value.get("transcript_path").is_none());
1136    }
1137
1138    #[test]
1139    fn stream_state_is_authoritative_and_round_trips() {
1140        // The `claude-wrap` wrapper reports the exact state; `observe` applies it
1141        // verbatim and overrides whatever was inferred before.
1142        let reg = SessionsRegistry::new();
1143        reg.observe(observe_request("s1", SessionEvent::PreToolUse, Some("/p")));
1144        assert_eq!(reg.list()[0].state, SessionState::Working);
1145        reg.observe(observe_request(
1146            "s1",
1147            SessionEvent::StreamState(SessionState::WaitingForPermission),
1148            None,
1149        ));
1150        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
1151        // The wire shape is a nested tuple variant, matching `{"notification":…}`.
1152        let value = serde_json::to_value(SessionEvent::StreamState(
1153            SessionState::WaitingForPermission,
1154        ))
1155        .unwrap();
1156        assert_eq!(
1157            value,
1158            serde_json::json!({ "stream_state": "waiting_for_permission" })
1159        );
1160        // …and deserializes back.
1161        let event: SessionEvent = serde_json::from_value(value).unwrap();
1162        assert_eq!(
1163            event,
1164            SessionEvent::StreamState(SessionState::WaitingForPermission)
1165        );
1166    }
1167
1168    #[test]
1169    fn default_constructs_an_empty_registry() {
1170        let reg = SessionsRegistry::default();
1171        assert!(reg.list().is_empty());
1172    }
1173
1174    #[test]
1175    fn fill_only_overwrites_with_a_present_value() {
1176        // `None` leaves the slot; `Some` overwrites it — the re-`observe`
1177        // never-clobber contract.
1178        let mut slot = Some("keep");
1179        fill(&mut slot, None);
1180        assert_eq!(slot, Some("keep"));
1181        fill(&mut slot, Some("new"));
1182        assert_eq!(slot, Some("new"));
1183        // A previously-empty slot fills.
1184        let mut empty: Option<&str> = None;
1185        fill(&mut empty, Some("filled"));
1186        assert_eq!(empty, Some("filled"));
1187    }
1188
1189    #[test]
1190    fn observe_at_session_cap_evicts_the_longest_silent() {
1191        let reg = SessionsRegistry::new();
1192        // Seed a full registry with explicit descending timestamps so the
1193        // highest-numbered id is unambiguously the oldest.
1194        {
1195            let mut sessions = reg.lock_sessions();
1196            let base = Utc::now();
1197            for i in 0..MAX_SESSIONS {
1198                let id = format!("s{i:04}");
1199                sessions.insert(
1200                    id.clone(),
1201                    SessionEntry {
1202                        session_id: id.clone(),
1203                        cwd: None,
1204                        transcript_path: None,
1205                        repo: None,
1206                        model: None,
1207                        state: SessionState::Working,
1208                        source: Source::Terminal,
1209                        last_event: SessionEvent::PreToolUse,
1210                        started_at: base,
1211                        last_seen: base - chrono::Duration::milliseconds(i as i64),
1212                    },
1213                );
1214            }
1215        }
1216        // A new session at the cap displaces exactly the longest-silent entry.
1217        reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
1218        let sessions = reg.lock_sessions();
1219        assert_eq!(sessions.len(), MAX_SESSIONS);
1220        assert!(sessions.contains_key("fresh"));
1221        assert!(!sessions.contains_key(&format!("s{:04}", MAX_SESSIONS - 1)));
1222        assert!(sessions.contains_key("s0000"));
1223    }
1224
1225    #[test]
1226    fn report_window_at_cap_evicts_the_longest_silent() {
1227        let reg = SessionsRegistry::new();
1228        {
1229            let mut windows = reg.lock_windows();
1230            let base = Utc::now();
1231            for i in 0..MAX_WINDOWS {
1232                let key = format!("w{i:04}");
1233                windows.insert(
1234                    key.clone(),
1235                    WindowEntry {
1236                        report: WindowReport {
1237                            key: key.clone(),
1238                            folders: vec![],
1239                            tabs: 1,
1240                            terminals: 0,
1241                        },
1242                        last_seen: base - chrono::Duration::milliseconds(i as i64),
1243                    },
1244                );
1245            }
1246        }
1247        reg.report_window(WindowReport {
1248            key: "fresh".to_string(),
1249            folders: vec![],
1250            tabs: 1,
1251            terminals: 0,
1252        });
1253        let windows = reg.lock_windows();
1254        assert_eq!(windows.len(), MAX_WINDOWS);
1255        assert!(windows.contains_key("fresh"));
1256        assert!(!windows.contains_key(&format!("w{:04}", MAX_WINDOWS - 1)));
1257        assert!(windows.contains_key("w0000"));
1258    }
1259
1260    #[test]
1261    fn evict_oldest_window_breaks_ties_by_key() {
1262        let now = Utc::now();
1263        let mut windows = HashMap::new();
1264        let at = |key: &str, secs: i64| WindowEntry {
1265            report: WindowReport {
1266                key: key.to_string(),
1267                folders: vec![],
1268                tabs: 1,
1269                terminals: 0,
1270            },
1271            last_seen: now - chrono::Duration::seconds(secs),
1272        };
1273        windows.insert("young".to_string(), at("young", 0));
1274        windows.insert("old-b".to_string(), at("old-b", 10));
1275        windows.insert("old-a".to_string(), at("old-a", 10));
1276        // Oldest `last_seen` is shared; the lowest key loses.
1277        evict_oldest_window(&mut windows);
1278        assert!(!windows.contains_key("old-a"));
1279        assert!(windows.contains_key("old-b"));
1280        assert!(windows.contains_key("young"));
1281        // An empty map is a no-op, not a panic.
1282        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
1283        evict_oldest_window(&mut empty);
1284        assert!(empty.is_empty());
1285    }
1286
1287    // --- Change-notify for the push subscription (#1414) --------------------
1288
1289    /// A window report with one folder, parameterized by whether it embeds Claude.
1290    fn window_report(key: &str, folder: &str, embedded: bool) -> WindowReport {
1291        WindowReport {
1292            key: key.to_string(),
1293            folders: vec![PathBuf::from(folder)],
1294            tabs: usize::from(embedded),
1295            terminals: 0,
1296        }
1297    }
1298
1299    #[test]
1300    fn subscribe_changes_starts_seen_and_a_new_session_bumps() {
1301        let reg = SessionsRegistry::new();
1302        let mut rx = reg.subscribe_changes();
1303        // A fresh receiver has the current version already marked seen.
1304        assert!(!rx.has_changed().unwrap());
1305        reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
1306        assert!(rx.has_changed().unwrap(), "a new session should bump");
1307        // Marking it seen clears the pending change.
1308        rx.borrow_and_update();
1309        assert!(!rx.has_changed().unwrap());
1310    }
1311
1312    #[test]
1313    fn observe_bumps_on_a_state_transition_but_not_on_a_repeat_sighting() {
1314        let reg = SessionsRegistry::new();
1315        reg.observe(observe_request(
1316            "s1",
1317            SessionEvent::PreToolUse,
1318            Some("/tmp/a"),
1319        ));
1320        // Subscribe *after* the insert so its bump is already seen.
1321        let mut rx = reg.subscribe_changes();
1322
1323        // Same event, same cwd: liveness and `last_event`/`last_seen` move, but
1324        // nothing a consumer renders does. Hooks fire on every tool call, so this
1325        // is the hot path that must stay quiet.
1326        reg.observe(observe_request(
1327            "s1",
1328            SessionEvent::PreToolUse,
1329            Some("/tmp/a"),
1330        ));
1331        assert!(
1332            !rx.has_changed().unwrap(),
1333            "a repeat sighting with no visible change must not bump"
1334        );
1335
1336        // `PreToolUse` → `Stop` flips working → idle, which the tree renders.
1337        reg.observe(observe_request("s1", SessionEvent::Stop, None));
1338        assert!(rx.has_changed().unwrap(), "a state transition should bump");
1339        rx.borrow_and_update();
1340
1341        // A best-effort field taking a *new* value is visible too (the tally
1342        // joins sessions to worktree rows by `cwd`).
1343        reg.observe(observe_request("s1", SessionEvent::Stop, Some("/tmp/b")));
1344        assert!(
1345            rx.has_changed().unwrap(),
1346            "a newly-filled `cwd` should bump"
1347        );
1348    }
1349
1350    #[test]
1351    fn transcript_growth_does_not_clobber_a_waiting_session() {
1352        let reg = SessionsRegistry::new();
1353        reg.observe(observe_request(
1354            "s1",
1355            SessionEvent::Notification(NotificationKind::PermissionPrompt),
1356            Some("/tmp/a"),
1357        ));
1358        // Subscribe *after* the insert so its bump is already seen. Never marked
1359        // seen below, so the closing assert catches the release's bump only if
1360        // the growth in between really did stay quiet.
1361        let rx = reg.subscribe_changes();
1362
1363        // The watcher sees the assistant `tool_use` line Claude flushed before
1364        // the prompt could be answered. The wait came from a direct
1365        // `Notification`, so it must survive (#1418).
1366        reg.observe(observe_request(
1367            "s1",
1368            SessionEvent::TranscriptGrew,
1369            Some("/tmp/a"),
1370        ));
1371        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
1372        assert!(
1373            !rx.has_changed().unwrap(),
1374            "state did not change, so nothing a consumer renders did either (#1414)"
1375        );
1376
1377        // Answering the prompt still releases the wait — on the next hook, which
1378        // for an approved tool is the `PostToolUse` that fires when it finishes.
1379        reg.observe(observe_request("s1", SessionEvent::PostToolUse, None));
1380        assert_eq!(reg.list()[0].state, SessionState::Working);
1381        assert!(
1382            rx.has_changed().unwrap(),
1383            "the release is a real transition"
1384        );
1385    }
1386
1387    #[test]
1388    fn transcript_growth_does_not_revive_an_ended_session() {
1389        let reg = SessionsRegistry::new();
1390        reg.observe(observe_request(
1391            "s1",
1392            SessionEvent::PreToolUse,
1393            Some("/tmp/a"),
1394        ));
1395        assert!(reg.end("s1", Some("clear")));
1396        // Subscribed after the end so its bump is already seen (see above).
1397        let rx = reg.subscribe_changes();
1398
1399        // The watcher's next scan sees the lines Claude flushed as it exited.
1400        // `SessionEnd` reported the end directly, so the entry must stay `ended`
1401        // and reap on the short ended TTL rather than being revived as a
1402        // `working` phantom that outlives the session by the whole TTL (#1418).
1403        reg.observe(observe_request(
1404            "s1",
1405            SessionEvent::TranscriptGrew,
1406            Some("/tmp/a"),
1407        ));
1408        assert_eq!(reg.list()[0].state, SessionState::Ended);
1409        assert!(
1410            !rx.has_changed().unwrap(),
1411            "state did not change, so nothing a consumer renders did either (#1414)"
1412        );
1413    }
1414
1415    #[test]
1416    fn end_bumps_only_for_a_known_session() {
1417        let reg = SessionsRegistry::new();
1418        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
1419        let mut rx = reg.subscribe_changes();
1420
1421        assert!(!reg.end("ghost", None), "an unknown session is a no-op");
1422        assert!(
1423            !rx.has_changed().unwrap(),
1424            "ending an unknown session must not bump"
1425        );
1426
1427        assert!(reg.end("s1", None));
1428        assert!(rx.has_changed().unwrap(), "a real end should bump");
1429        rx.borrow_and_update();
1430    }
1431
1432    #[test]
1433    fn window_report_bumps_only_when_it_changes_the_source_join() {
1434        let reg = SessionsRegistry::new();
1435        reg.report_window(window_report("w1", "/p", true));
1436        let mut rx = reg.subscribe_changes();
1437
1438        // The unchanged ~10s refresh every open window sends: liveness only.
1439        reg.report_window(window_report("w1", "/p", true));
1440        assert!(
1441            !rx.has_changed().unwrap(),
1442            "an unchanged window refresh must not bump"
1443        );
1444
1445        // The window's Claude tab closed → its sessions fall back to `terminal`.
1446        reg.report_window(window_report("w1", "/p", false));
1447        assert!(
1448            rx.has_changed().unwrap(),
1449            "an embedding that vanished should bump"
1450        );
1451        rx.borrow_and_update();
1452
1453        // Different folders → a different `cwd`-prefix join.
1454        reg.report_window(window_report("w1", "/q", false));
1455        assert!(rx.has_changed().unwrap(), "changed folders should bump");
1456        rx.borrow_and_update();
1457
1458        // A brand-new window joins the registry.
1459        reg.report_window(window_report("w2", "/r", true));
1460        assert!(rx.has_changed().unwrap(), "a new window should bump");
1461    }
1462
1463    #[test]
1464    fn unregister_window_bumps_only_when_it_removes() {
1465        let reg = SessionsRegistry::new();
1466        reg.report_window(window_report("w1", "/p", true));
1467        let rx = reg.subscribe_changes();
1468
1469        assert!(!reg.unregister_window("ghost"));
1470        assert!(
1471            !rx.has_changed().unwrap(),
1472            "a no-op unregister must not bump"
1473        );
1474
1475        assert!(reg.unregister_window("w1"));
1476        assert!(
1477            rx.has_changed().unwrap(),
1478            "a removing unregister should bump"
1479        );
1480    }
1481
1482    #[tokio::test]
1483    async fn a_burst_of_bumps_coalesces_into_one_wakeup() {
1484        let reg = SessionsRegistry::new();
1485        let mut rx = reg.subscribe_changes();
1486        // Three visible changes back to back, all before anyone awaits.
1487        reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
1488        reg.observe(observe_request("s2", SessionEvent::SessionStart, None));
1489        reg.observe(observe_request("s3", SessionEvent::SessionStart, None));
1490        // `changed()` marks the newest version seen, so the burst is one wakeup…
1491        rx.changed().await.unwrap();
1492        // …and there is nothing left pending for a second one.
1493        assert!(
1494            !rx.has_changed().unwrap(),
1495            "a burst should collapse into a single wakeup"
1496        );
1497    }
1498
1499    #[test]
1500    fn list_does_not_bump_even_when_it_reaps() {
1501        // `list` is the body of every subscription's `snapshot()`, so a bump here
1502        // would feed the stream loop back into itself. A read-path reap reaches
1503        // other subscribers on the server's next periodic re-sample instead.
1504        let reg = SessionsRegistry::new();
1505        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
1506        // Age the entry past its TTL so the next `list` reaps it.
1507        {
1508            let mut sessions = reg.lock_sessions();
1509            let entry = sessions.get_mut("s1").unwrap();
1510            entry.last_seen = Utc::now() - chrono::Duration::seconds(600);
1511        }
1512        let rx = reg.subscribe_changes();
1513        assert!(reg.list().is_empty(), "the stale session should be reaped");
1514        assert!(!rx.has_changed().unwrap(), "`list` must never bump");
1515    }
1516}