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