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