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