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(®.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}