Skip to main content

omni_dev/
sessions.rs

1//! The cross-window Claude Code session registry engine.
2//!
3//! Maintains the live, authoritative set of running Claude Code sessions across
4//! *every* terminal and VS Code window for the logged-in user, with a coarse
5//! inferred state (working / idle / waiting-for-input / waiting-for-permission).
6//! Fed by three independent feeds that each degrade gracefully — Claude Code
7//! **hooks** (`omni-dev sessions hook`), a **transcript-file watcher** over
8//! `~/.claude/projects/**/*.jsonl`, and the companion VS Code extension
9//! reporting each window's embedded Claude tabs/terminals. See ADR-0052.
10//!
11//! This is the standalone engine, analogous to [`crate::worktrees`],
12//! [`crate::browser`], and [`crate::snowflake`]; the daemon adapter lives in
13//! [`crate::daemon::services::sessions`].
14//!
15//! Like the worktrees engine this is cheap and in-memory — no async setup, no
16//! secret persisted. Two maps live behind a pair of [`std::sync::Mutex`]es that
17//! are **never held across an `.await`** (the Snowflake rule): the *sessions*
18//! keyed by their Claude `session_id`, and the *windows* keyed by the companion's
19//! per-window key (the Claude-embedding reports used to tag a session's source).
20//! Every op is pure CPU under a lock, so liveness reaping happens inline on each
21//! read rather than from a background task — exactly as [`crate::worktrees`] does.
22//!
23//! State is **inferred**, not first-class: Claude Code exposes no dedicated
24//! session-state event, so `working`/`idle` is best-effort (see
25//! [`SessionState::for_event`]). `waiting_for_permission` / `waiting_for_input`
26//! are reliable (they come from a `Notification` hook); the transcript watcher
27//! backstops the "thinking window" where no hook fires.
28
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::{Mutex, MutexGuard, PoisonError};
32use std::time::Duration;
33
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37pub mod watcher;
38
39/// How long a session may go silent before it ages out of the registry.
40///
41/// Unlike a VS Code window (which heartbeats every ~10s), a running Claude
42/// session emits nothing while idle at the prompt, so its only liveness signal
43/// is activity — a hook event or transcript growth. The TTL is therefore
44/// generous: a session that has done nothing for this long is assumed gone (a
45/// `claude` that exited without firing `SessionEnd`) and reaped on the next read.
46/// A still-alive idle session re-appears the moment it next does anything. This
47/// is the accepted limitation of the hook-based approach — see ADR-0052.
48const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
49
50/// How long an **ended** session lingers before it is reaped, so `sessions list`
51/// briefly shows a session that just finished (`SessionEnd` fired → [`end`]) as
52/// `ended` rather than having it vanish instantly.
53///
54/// [`end`]: SessionsRegistry::end
55const ENDED_SESSION_TTL: Duration = Duration::from_secs(10);
56
57/// How long a companion window-embedding report survives without a refresh.
58/// Mirrors the worktrees window TTL (three missed ~10s heartbeats): a window
59/// that crashed without unregistering stops tagging its sessions as VS Code
60/// embedded on the next read.
61const DEFAULT_WINDOW_TTL: Duration = Duration::from_secs(30);
62
63/// Ceiling on live session entries, so a runaway feed cannot grow daemon memory
64/// faster than the TTL reaps it (the worktrees `MAX_WINDOWS` precedent, #1140).
65/// Far above any real concurrent-session count; at the cap a genuinely new
66/// session evicts the longest-silent entry rather than being rejected, so ingest
67/// stays infallible.
68const MAX_SESSIONS: usize = 512;
69
70/// Ceiling on live window-embedding reports, mirroring the worktrees registry cap.
71const MAX_WINDOWS: usize = 256;
72
73/// The coarse, inferred lifecycle state of a Claude Code session.
74///
75/// Serialized `snake_case` (`waiting_for_permission`, …) into `list`/`status`
76/// payloads. `waiting_for_*` are **reliable** (a `Notification` hook fires them
77/// directly); `working`/`idle` are best-effort inference from `PreToolUse` /
78/// `Stop` plus the transcript-growth backstop (Claude Code ships no dedicated
79/// state event — ADR-0052).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum SessionState {
83    /// Session just started (`SessionStart`), before any turn.
84    Starting,
85    /// Actively processing a turn — a tool call (`PreToolUse`/`PostToolUse`), a
86    /// submitted prompt (`UserPromptSubmit`), or observed transcript growth.
87    Working,
88    /// Finished a turn and waiting at the prompt (`Stop`).
89    Idle,
90    /// Blocked on the user for a plain input/idle notification.
91    WaitingForInput,
92    /// Blocked on the user to approve a tool/permission prompt.
93    WaitingForPermission,
94    /// The session ended (`SessionEnd`); reaped shortly after via
95    /// [`ENDED_SESSION_TTL`].
96    Ended,
97}
98
99impl SessionState {
100    /// The state a sighting of `event` implies, given the session's `current`
101    /// state (`None` for a brand-new session). This is the whole inference
102    /// machine, kept in one testable place:
103    ///
104    /// - `SessionStart` → [`Starting`](Self::Starting)
105    /// - `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `TranscriptGrew` →
106    ///   [`Working`](Self::Working)
107    /// - `Stop` → [`Idle`](Self::Idle)
108    /// - `Notification(PermissionPrompt)` →
109    ///   [`WaitingForPermission`](Self::WaitingForPermission)
110    /// - `Notification(IdlePrompt | AgentNeedsInput)` →
111    ///   [`WaitingForInput`](Self::WaitingForInput)
112    /// - `Notification(Other)` → **unchanged** (an unclassified notification is
113    ///   not evidence of a state change)
114    /// - `TranscriptDiscovered` → the current state if known, else
115    ///   [`Idle`](Self::Idle) (a passively-discovered session's activity is
116    ///   unknown; a later hook or growth upgrades it)
117    #[must_use]
118    pub fn for_event(event: &SessionEvent, current: Option<Self>) -> Self {
119        match event {
120            SessionEvent::SessionStart => Self::Starting,
121            SessionEvent::UserPromptSubmit
122            | SessionEvent::PreToolUse
123            | SessionEvent::PostToolUse
124            | SessionEvent::TranscriptGrew => Self::Working,
125            SessionEvent::Stop => Self::Idle,
126            SessionEvent::Notification(NotificationKind::PermissionPrompt) => {
127                Self::WaitingForPermission
128            }
129            SessionEvent::Notification(
130                NotificationKind::IdlePrompt | NotificationKind::AgentNeedsInput,
131            ) => Self::WaitingForInput,
132            // An unclassified notification carries no state signal, and a
133            // passively-discovered transcript's activity is unknown: keep the
134            // current state (or default a brand-new session to Idle).
135            SessionEvent::Notification(NotificationKind::Other)
136            | SessionEvent::TranscriptDiscovered => current.unwrap_or(Self::Idle),
137        }
138    }
139}
140
141/// The classification of a Claude Code `Notification` hook.
142///
143/// Derived by the hook sink from the notification message (the message text is
144/// version-unstable, so classification is best-effort with an
145/// [`Other`](Self::Other) fallback).
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum NotificationKind {
149    /// Claude is asking to run a tool / use a permission — reliably
150    /// [`WaitingForPermission`](SessionState::WaitingForPermission).
151    PermissionPrompt,
152    /// Claude has been idle waiting for the user to respond.
153    IdlePrompt,
154    /// An agent/subagent needs the user's input.
155    AgentNeedsInput,
156    /// A notification we could not classify — carries no state signal.
157    Other,
158}
159
160/// A sighting of a session, from a hook event or the transcript watcher.
161///
162/// Drives the [`SessionState::for_event`] inference and refreshes liveness.
163/// Serialized on the wire as part of an [`ObserveRequest`]; `snake_case`, with
164/// the notification kind nested (`{"notification":"permission_prompt"}`).
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum SessionEvent {
168    /// `SessionStart` hook.
169    SessionStart,
170    /// `UserPromptSubmit` hook — a prompt was submitted.
171    UserPromptSubmit,
172    /// `PreToolUse` hook — about to run a tool.
173    PreToolUse,
174    /// `PostToolUse` hook — a tool finished.
175    PostToolUse,
176    /// `Stop` hook — the turn finished.
177    Stop,
178    /// `Notification` hook, classified into a [`NotificationKind`].
179    Notification(NotificationKind),
180    /// The transcript watcher saw this session's `.jsonl` grow (the
181    /// "thinking-window" backstop, where no hook fires).
182    TranscriptGrew,
183    /// The transcript watcher discovered a session's `.jsonl` it had not seen —
184    /// a session that started before the daemon, or before hooks were installed.
185    TranscriptDiscovered,
186}
187
188/// Where a session is running, resolved at [`list`](SessionsRegistry::list) time
189/// by joining a session's `cwd` against the companion's window-embedding reports.
190///
191/// A session whose `cwd` lies under a reporting VS Code window that has ≥1 Claude
192/// tab/terminal is tagged [`VsCode`](Self::VsCode); everything else is
193/// [`Terminal`](Self::Terminal) — meaning "not matched to a reporting VS Code
194/// window" (a bare terminal session, or a VS Code session whose companion is not
195/// installed). Serialized as `{"kind":"terminal"}` /
196/// `{"kind":"vs_code","window_key":"…"}`.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(tag = "kind", rename_all = "snake_case")]
199pub enum Source {
200    /// Not matched to any reporting VS Code window.
201    Terminal,
202    /// Embedded in a VS Code window (matched by `cwd`), carrying that window's
203    /// companion key for a focus action.
204    VsCode {
205        /// The matched window's companion key.
206        window_key: String,
207    },
208}
209
210/// An idempotent session sighting sent to the registry — the wire payload of the
211/// `observe` op, and the argument to [`SessionsRegistry::observe`].
212///
213/// The hook sink and the transcript watcher both produce these; every field but
214/// `session_id` and `event` is best-effort and *fills in* missing data on an
215/// existing entry without ever clobbering known data with `None`.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ObserveRequest {
218    /// The Claude `session_id` (a UUID) — the primary key. Equal to the
219    /// transcript filename stem and (per ADR-0052) the VS Code extension's tab
220    /// key, so the three feeds join without heuristics.
221    pub session_id: String,
222    /// The session's working directory, when known (from the hook `cwd`).
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub cwd: Option<PathBuf>,
225    /// The `~/.claude/projects/**/<session-id>.jsonl` transcript path, when known.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub transcript_path: Option<PathBuf>,
228    /// The event that produced this sighting; drives the state inference.
229    pub event: SessionEvent,
230    /// The repository name enriched from `cwd` by the adapter (git2), when
231    /// resolvable. Stored verbatim; the engine does no disk I/O.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub repo: Option<String>,
234    /// The model id, when a hook reports one.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub model: Option<String>,
237}
238
239/// A companion report of one VS Code window's embedded Claude sessions.
240///
241/// The wire payload of the `window` op. The companion cannot expose a tab's
242/// `session_id` (Claude Code's extension has no public API — ADR-0052), so it
243/// reports only the *counts* of Claude tabs/terminals plus the window's folders;
244/// the join to a specific session is by `cwd`.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct WindowReport {
247    /// The companion-owned per-window key (also the worktrees registration key).
248    pub key: String,
249    /// The window's workspace-folder absolute paths, for the `cwd` join.
250    #[serde(default)]
251    pub folders: Vec<PathBuf>,
252    /// How many Claude editor tabs (`claudeVSCodePanel` webviews) the window has.
253    #[serde(default)]
254    pub tabs: usize,
255    /// How many Claude Code integrated terminals the window has.
256    #[serde(default)]
257    pub terminals: usize,
258}
259
260impl WindowReport {
261    /// Whether this window has any Claude embedding at all — the gate for
262    /// tagging a matching session as [`Source::VsCode`].
263    #[must_use]
264    fn has_embedding(&self) -> bool {
265        self.tabs > 0 || self.terminals > 0
266    }
267}
268
269/// One live session in the registry.
270///
271/// Serialized verbatim into `list` / `status` payloads; consumers compute age
272/// from `last_seen` (RFC 3339). `source` is resolved at
273/// [`list`](SessionsRegistry::list) time (stored as [`Source::Terminal`] until
274/// then).
275#[derive(Debug, Clone, Serialize)]
276pub struct SessionEntry {
277    /// The Claude `session_id`.
278    pub session_id: String,
279    /// The session's working directory, when known.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub cwd: Option<PathBuf>,
282    /// The transcript path, when known.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub transcript_path: Option<PathBuf>,
285    /// The repository name enriched from `cwd`, when resolvable.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub repo: Option<String>,
288    /// The model id, when reported.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub model: Option<String>,
291    /// The current inferred state.
292    pub state: SessionState,
293    /// Where the session runs, resolved on read.
294    pub source: Source,
295    /// The most recent event observed for this session.
296    pub last_event: SessionEvent,
297    /// When the session was first observed (RFC 3339).
298    pub started_at: DateTime<Utc>,
299    /// When the registry last heard from this session (RFC 3339).
300    pub last_seen: DateTime<Utc>,
301}
302
303/// One companion window-embedding report, with its liveness stamp.
304#[derive(Debug, Clone)]
305struct WindowEntry {
306    /// The report as sent by the companion.
307    report: WindowReport,
308    /// When the report last arrived (register or refresh).
309    last_seen: DateTime<Utc>,
310}
311
312/// The cross-window session registry.
313///
314/// The in-memory, TTL-reaped set of running Claude sessions plus the companion
315/// window-embedding reports used to tag a session's [`Source`]. Hosted by
316/// [`SessionsService`](crate::daemon::services::sessions::SessionsService).
317pub struct SessionsRegistry {
318    /// Live sessions keyed by `session_id`.
319    sessions: Mutex<HashMap<String, SessionEntry>>,
320    /// Companion window-embedding reports keyed by window key. Behind its own
321    /// mutex, taken independently of `sessions`, so the two never nest.
322    windows: Mutex<HashMap<String, WindowEntry>>,
323    /// How long a session survives without activity.
324    session_ttl: Duration,
325    /// How long an `ended` session lingers before reaping.
326    ended_ttl: Duration,
327    /// How long a window-embedding report survives without a refresh.
328    window_ttl: Duration,
329}
330
331impl SessionsRegistry {
332    /// Creates the registry with the default liveness TTLs. Cheap — no I/O.
333    #[must_use]
334    pub fn new() -> Self {
335        Self {
336            sessions: Mutex::new(HashMap::new()),
337            windows: Mutex::new(HashMap::new()),
338            session_ttl: DEFAULT_SESSION_TTL,
339            ended_ttl: ENDED_SESSION_TTL,
340            window_ttl: DEFAULT_WINDOW_TTL,
341        }
342    }
343
344    /// Locks the sessions map, recovering from a poisoned mutex (a panic in a
345    /// prior critical section must not wedge the whole registry).
346    fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, SessionEntry>> {
347        self.sessions.lock().unwrap_or_else(PoisonError::into_inner)
348    }
349
350    /// Locks the windows map, recovering from a poisoned mutex.
351    fn lock_windows(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
352        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
353    }
354
355    /// Records (upserts) a session sighting, running the [`SessionState`]
356    /// inference and refreshing liveness. Reaps stale entries first, then — only
357    /// when a genuinely new session would grow the map past [`MAX_SESSIONS`] —
358    /// evicts the longest-silent entry. Infallible: an upsert never evicts.
359    ///
360    /// Best-effort fields (`cwd`/`transcript_path`/`repo`/`model`) *fill in* on
361    /// an existing entry and never overwrite known data with `None`, so a later
362    /// hook enriches a watcher-discovered session without a race losing data.
363    pub fn observe(&self, req: ObserveRequest) {
364        let now = Utc::now();
365        let mut sessions = self.lock_sessions();
366        reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, now);
367        if let Some(entry) = sessions.get_mut(&req.session_id) {
368            entry.state = SessionState::for_event(&req.event, Some(entry.state));
369            entry.last_event = req.event;
370            entry.last_seen = now;
371            fill(&mut entry.cwd, req.cwd);
372            fill(&mut entry.transcript_path, req.transcript_path);
373            fill(&mut entry.repo, req.repo);
374            fill(&mut entry.model, req.model);
375        } else {
376            if sessions.len() >= MAX_SESSIONS {
377                evict_oldest_session(&mut sessions);
378            }
379            let state = SessionState::for_event(&req.event, None);
380            sessions.insert(
381                req.session_id.clone(),
382                SessionEntry {
383                    session_id: req.session_id,
384                    cwd: req.cwd,
385                    transcript_path: req.transcript_path,
386                    repo: req.repo,
387                    model: req.model,
388                    state,
389                    source: Source::Terminal,
390                    last_event: req.event,
391                    started_at: now,
392                    last_seen: now,
393                },
394            );
395        }
396    }
397
398    /// Marks a session ended (`SessionEnd`), so `list` shows it as `ended` for a
399    /// short window ([`ENDED_SESSION_TTL`]) before it is reaped. Returns whether
400    /// the session was known. A no-op for an already-unknown session (a
401    /// duplicate/late `SessionEnd`).
402    pub fn end(&self, session_id: &str, _reason: Option<&str>) -> bool {
403        let now = Utc::now();
404        let mut sessions = self.lock_sessions();
405        reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, now);
406        match sessions.get_mut(session_id) {
407            Some(entry) => {
408                entry.state = SessionState::Ended;
409                entry.last_event = SessionEvent::Stop;
410                entry.last_seen = now;
411                true
412            }
413            None => false,
414        }
415    }
416
417    /// Records (upserts) a companion window-embedding report and refreshes its
418    /// liveness. Reaps stale windows first, then caps like [`observe`](Self::observe).
419    pub fn report_window(&self, report: WindowReport) {
420        let now = Utc::now();
421        let mut windows = self.lock_windows();
422        reap_windows(&mut windows, self.window_ttl, now);
423        if !windows.contains_key(&report.key) && windows.len() >= MAX_WINDOWS {
424            evict_oldest_window(&mut windows);
425        }
426        windows.insert(
427            report.key.clone(),
428            WindowEntry {
429                report,
430                last_seen: now,
431            },
432        );
433    }
434
435    /// Drops a companion window-embedding report (the window closed). Returns
436    /// whether an entry was present.
437    pub fn unregister_window(&self, key: &str) -> bool {
438        let mut windows = self.lock_windows();
439        windows.remove(key).is_some()
440    }
441
442    /// Reaps stale sessions and windows, then returns the live sessions with
443    /// each [`Source`] resolved and sorted for deterministic output.
444    ///
445    /// Two independent locks, each held only for pure-CPU work and never
446    /// nested: the sessions snapshot is taken and the lock dropped, then the
447    /// windows snapshot, then the join runs lock-free. Path matching is a pure
448    /// prefix compare (no canonicalization / disk I/O), honouring the
449    /// `Mutex`-never-across-`.await` and no-I/O-under-lock invariants.
450    pub fn list(&self) -> Vec<SessionEntry> {
451        let now = Utc::now();
452        let mut sessions: Vec<SessionEntry> = {
453            let mut guard = self.lock_sessions();
454            reap_sessions(&mut guard, self.session_ttl, self.ended_ttl, now);
455            guard.values().cloned().collect()
456        };
457        let windows: Vec<WindowReport> = {
458            let mut guard = self.lock_windows();
459            reap_windows(&mut guard, self.window_ttl, now);
460            guard
461                .values()
462                .map(|e| e.report.clone())
463                .filter(WindowReport::has_embedding)
464                .collect()
465        };
466        for session in &mut sessions {
467            session.source = resolve_source(session.cwd.as_deref(), &windows);
468        }
469        sessions.sort_by(|a, b| {
470            a.repo
471                .cmp(&b.repo)
472                .then_with(|| a.session_id.cmp(&b.session_id))
473        });
474        sessions
475    }
476
477    /// The first workspace folder of the still-live window a session is embedded
478    /// in, if any — used by the tray "focus" action to resolve a session to a
479    /// folder to open in VS Code. `None` when the session has no `cwd`, or is not
480    /// matched to a reporting window with a folder.
481    pub fn focus_folder(&self, session_id: &str) -> Option<PathBuf> {
482        let cwd = {
483            let sessions = self.lock_sessions();
484            sessions.get(session_id).and_then(|e| e.cwd.clone())
485        }?;
486        let now = Utc::now();
487        let mut windows = self.lock_windows();
488        reap_windows(&mut windows, self.window_ttl, now);
489        windows
490            .values()
491            .map(|e| &e.report)
492            .filter(|w| w.has_embedding())
493            .filter(|w| w.folders.iter().any(|f| cwd.starts_with(f)))
494            .find_map(|w| w.folders.first().cloned())
495    }
496}
497
498impl Default for SessionsRegistry {
499    fn default() -> Self {
500        Self::new()
501    }
502}
503
504/// Fills `slot` from `incoming` only when `incoming` carries a value, so a
505/// best-effort field never overwrites known data with `None` on a re-`observe`.
506fn fill<T>(slot: &mut Option<T>, incoming: Option<T>) {
507    if let Some(value) = incoming {
508        *slot = Some(value);
509    }
510}
511
512/// Resolves a session's [`Source`] by joining its `cwd` against the live
513/// window-embedding reports.
514///
515/// Among the windows whose folder is a prefix of `cwd`, the one with the lowest
516/// key wins (a deterministic tiebreak). A session with no `cwd`, or no matching
517/// window, is [`Source::Terminal`].
518fn resolve_source(cwd: Option<&Path>, windows: &[WindowReport]) -> Source {
519    let Some(cwd) = cwd else {
520        return Source::Terminal;
521    };
522    let matched = windows
523        .iter()
524        .filter(|w| w.folders.iter().any(|f| cwd.starts_with(f)))
525        .min_by(|a, b| a.key.cmp(&b.key));
526    match matched {
527        Some(window) => Source::VsCode {
528            window_key: window.key.clone(),
529        },
530        None => Source::Terminal,
531    }
532}
533
534/// Removes sessions last seen longer than their TTL ago (a shorter
535/// [`ended_ttl`](SessionsRegistry::ended_ttl) for `ended` sessions), returning
536/// how many were dropped. Pure CPU; the caller holds the sessions lock but never
537/// `.await`s under it.
538fn reap_sessions(
539    sessions: &mut HashMap<String, SessionEntry>,
540    session_ttl: Duration,
541    ended_ttl: Duration,
542    now: DateTime<Utc>,
543) -> usize {
544    let session_max = session_ttl.as_secs() as i64;
545    let ended_max = ended_ttl.as_secs() as i64;
546    let before = sessions.len();
547    sessions.retain(|_, e| {
548        let max_age = if e.state == SessionState::Ended {
549            ended_max
550        } else {
551            session_max
552        };
553        (now - e.last_seen).num_seconds() <= max_age
554    });
555    before - sessions.len()
556}
557
558/// Removes window-embedding reports last refreshed longer than `ttl` ago.
559fn reap_windows(
560    windows: &mut HashMap<String, WindowEntry>,
561    ttl: Duration,
562    now: DateTime<Utc>,
563) -> usize {
564    let max_age = ttl.as_secs() as i64;
565    let before = windows.len();
566    windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
567    before - windows.len()
568}
569
570/// Removes the session with the oldest `last_seen` (ties broken by lowest
571/// `session_id` for determinism). Called when a new session would exceed
572/// [`MAX_SESSIONS`].
573fn evict_oldest_session(sessions: &mut HashMap<String, SessionEntry>) {
574    let oldest = sessions
575        .values()
576        .min_by(|a, b| {
577            a.last_seen
578                .cmp(&b.last_seen)
579                .then_with(|| a.session_id.cmp(&b.session_id))
580        })
581        .map(|e| e.session_id.clone());
582    if let Some(key) = oldest {
583        sessions.remove(&key);
584    }
585}
586
587/// Removes the window report with the oldest `last_seen` (ties broken by lowest
588/// key). Called when a new window would exceed [`MAX_WINDOWS`].
589fn evict_oldest_window(windows: &mut HashMap<String, WindowEntry>) {
590    let oldest = windows
591        .iter()
592        .min_by(|a, b| a.1.last_seen.cmp(&b.1.last_seen).then_with(|| a.0.cmp(b.0)))
593        .map(|(k, _)| k.clone());
594    if let Some(key) = oldest {
595        windows.remove(&key);
596    }
597}
598
599#[cfg(test)]
600#[allow(clippy::unwrap_used, clippy::expect_used)]
601mod tests {
602    use super::*;
603
604    fn observe_request(session_id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
605        ObserveRequest {
606            session_id: session_id.to_string(),
607            cwd: cwd.map(PathBuf::from),
608            transcript_path: None,
609            event,
610            repo: None,
611            model: None,
612        }
613    }
614
615    #[test]
616    fn list_is_empty_initially() {
617        let reg = SessionsRegistry::new();
618        assert!(reg.list().is_empty());
619    }
620
621    #[test]
622    fn observe_then_list_round_trips_and_infers_state() {
623        let reg = SessionsRegistry::new();
624        reg.observe(observe_request(
625            "s1",
626            SessionEvent::SessionStart,
627            Some("/tmp/a"),
628        ));
629        let sessions = reg.list();
630        assert_eq!(sessions.len(), 1);
631        assert_eq!(sessions[0].session_id, "s1");
632        assert_eq!(sessions[0].state, SessionState::Starting);
633        // No window reports → a bare terminal session.
634        assert_eq!(sessions[0].source, Source::Terminal);
635    }
636
637    #[test]
638    fn observe_is_idempotent_upsert_advancing_state() {
639        let reg = SessionsRegistry::new();
640        reg.observe(observe_request(
641            "s1",
642            SessionEvent::SessionStart,
643            Some("/tmp/a"),
644        ));
645        reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
646        let sessions = reg.list();
647        assert_eq!(sessions.len(), 1, "same session_id upserts, not duplicates");
648        assert_eq!(sessions[0].state, SessionState::Working);
649        // The later `observe` had no cwd, but the known one is preserved.
650        assert_eq!(sessions[0].cwd.as_deref(), Some(Path::new("/tmp/a")));
651    }
652
653    #[test]
654    fn state_machine_covers_every_event() {
655        use NotificationKind::*;
656        use SessionEvent::*;
657        let cases = [
658            (SessionStart, SessionState::Starting),
659            (UserPromptSubmit, SessionState::Working),
660            (PreToolUse, SessionState::Working),
661            (PostToolUse, SessionState::Working),
662            (Stop, SessionState::Idle),
663            (
664                Notification(PermissionPrompt),
665                SessionState::WaitingForPermission,
666            ),
667            (Notification(IdlePrompt), SessionState::WaitingForInput),
668            (Notification(AgentNeedsInput), SessionState::WaitingForInput),
669            (TranscriptGrew, SessionState::Working),
670            (TranscriptDiscovered, SessionState::Idle),
671        ];
672        for (event, expected) in cases {
673            assert_eq!(
674                SessionState::for_event(&event, None),
675                expected,
676                "event {event:?}"
677            );
678        }
679        // An unclassified notification keeps the current state.
680        assert_eq!(
681            SessionState::for_event(&Notification(Other), Some(SessionState::Working)),
682            SessionState::Working
683        );
684        // TranscriptDiscovered on a known session keeps its state.
685        assert_eq!(
686            SessionState::for_event(&TranscriptDiscovered, Some(SessionState::Working)),
687            SessionState::Working
688        );
689    }
690
691    #[test]
692    fn end_marks_ended_and_reaps_quickly() {
693        let reg = SessionsRegistry::new();
694        reg.observe(observe_request(
695            "s1",
696            SessionEvent::PreToolUse,
697            Some("/tmp/a"),
698        ));
699        assert!(reg.end("s1", Some("clear")));
700        // Ending an unknown session is a no-op.
701        assert!(!reg.end("ghost", None));
702        let sessions = reg.list();
703        assert_eq!(sessions.len(), 1);
704        assert_eq!(sessions[0].state, SessionState::Ended);
705        // Age the ended entry past the short ended TTL: it reaps out.
706        {
707            let mut guard = reg.lock_sessions();
708            guard.get_mut("s1").unwrap().last_seen = Utc::now() - chrono::Duration::seconds(30);
709        }
710        assert!(reg.list().is_empty(), "ended entry reaps after ended TTL");
711    }
712
713    #[test]
714    fn stale_working_session_reaps_but_recent_survives() {
715        let reg = SessionsRegistry::new();
716        reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
717        reg.observe(observe_request("stale", SessionEvent::PreToolUse, None));
718        {
719            let mut guard = reg.lock_sessions();
720            guard.get_mut("stale").unwrap().last_seen =
721                Utc::now() - chrono::Duration::seconds(1000);
722        }
723        let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
724        assert_eq!(ids, vec!["fresh".to_string()]);
725    }
726
727    #[test]
728    fn source_is_vscode_when_cwd_is_under_a_reporting_window() {
729        let reg = SessionsRegistry::new();
730        reg.observe(observe_request(
731            "s1",
732            SessionEvent::PreToolUse,
733            Some("/home/me/proj/sub"),
734        ));
735        // A window reporting a Claude tab whose folder is a prefix of the cwd.
736        reg.report_window(WindowReport {
737            key: "w1".to_string(),
738            folders: vec![PathBuf::from("/home/me/proj")],
739            tabs: 1,
740            terminals: 0,
741        });
742        let sessions = reg.list();
743        assert_eq!(
744            sessions[0].source,
745            Source::VsCode {
746                window_key: "w1".to_string()
747            }
748        );
749    }
750
751    #[test]
752    fn source_is_terminal_when_window_has_no_embedding() {
753        let reg = SessionsRegistry::new();
754        reg.observe(observe_request(
755            "s1",
756            SessionEvent::PreToolUse,
757            Some("/home/me/proj"),
758        ));
759        // A window is open on the folder but has no Claude tab/terminal.
760        reg.report_window(WindowReport {
761            key: "w1".to_string(),
762            folders: vec![PathBuf::from("/home/me/proj")],
763            tabs: 0,
764            terminals: 0,
765        });
766        assert_eq!(reg.list()[0].source, Source::Terminal);
767    }
768
769    #[test]
770    fn window_report_is_upsert_and_unregister_removes() {
771        let reg = SessionsRegistry::new();
772        reg.report_window(WindowReport {
773            key: "w1".to_string(),
774            folders: vec![PathBuf::from("/p")],
775            tabs: 1,
776            terminals: 0,
777        });
778        // Upsert (same key) does not duplicate.
779        reg.report_window(WindowReport {
780            key: "w1".to_string(),
781            folders: vec![PathBuf::from("/p")],
782            tabs: 2,
783            terminals: 1,
784        });
785        assert!(reg.unregister_window("w1"));
786        assert!(!reg.unregister_window("w1"));
787    }
788
789    #[test]
790    fn stale_window_stops_tagging_source() {
791        let reg = SessionsRegistry::new();
792        reg.observe(observe_request(
793            "s1",
794            SessionEvent::PreToolUse,
795            Some("/p/sub"),
796        ));
797        reg.report_window(WindowReport {
798            key: "w1".to_string(),
799            folders: vec![PathBuf::from("/p")],
800            tabs: 1,
801            terminals: 0,
802        });
803        // Age the window report past the window TTL.
804        {
805            let mut guard = reg.lock_windows();
806            guard.get_mut("w1").unwrap().last_seen = Utc::now() - chrono::Duration::seconds(120);
807        }
808        assert_eq!(reg.list()[0].source, Source::Terminal);
809    }
810
811    #[test]
812    fn resolve_source_prefers_lowest_key_on_overlap() {
813        // Two windows both cover the cwd; the lowest key wins deterministically.
814        let windows = vec![
815            WindowReport {
816                key: "w2".to_string(),
817                folders: vec![PathBuf::from("/p")],
818                tabs: 1,
819                terminals: 0,
820            },
821            WindowReport {
822                key: "w1".to_string(),
823                folders: vec![PathBuf::from("/p")],
824                tabs: 1,
825                terminals: 0,
826            },
827        ];
828        assert_eq!(
829            resolve_source(Some(Path::new("/p/x")), &windows),
830            Source::VsCode {
831                window_key: "w1".to_string()
832            }
833        );
834        // No cwd → terminal.
835        assert_eq!(resolve_source(None, &windows), Source::Terminal);
836    }
837
838    #[test]
839    fn focus_folder_resolves_matching_window_folder() {
840        let reg = SessionsRegistry::new();
841        reg.observe(observe_request(
842            "s1",
843            SessionEvent::PreToolUse,
844            Some("/home/me/proj/sub"),
845        ));
846        assert!(reg.focus_folder("s1").is_none(), "no window yet");
847        reg.report_window(WindowReport {
848            key: "w1".to_string(),
849            folders: vec![PathBuf::from("/home/me/proj")],
850            tabs: 1,
851            terminals: 0,
852        });
853        assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/home/me/proj")));
854        // An unknown session resolves to nothing.
855        assert!(reg.focus_folder("ghost").is_none());
856    }
857
858    #[test]
859    fn evict_oldest_session_drops_the_longest_silent() {
860        let now = Utc::now();
861        let mut sessions = HashMap::new();
862        for (id, age) in [("young", 0), ("old", 100), ("older", 200)] {
863            sessions.insert(
864                id.to_string(),
865                SessionEntry {
866                    session_id: id.to_string(),
867                    cwd: None,
868                    transcript_path: None,
869                    repo: None,
870                    model: None,
871                    state: SessionState::Working,
872                    source: Source::Terminal,
873                    last_event: SessionEvent::PreToolUse,
874                    started_at: now,
875                    last_seen: now - chrono::Duration::seconds(age),
876                },
877            );
878        }
879        evict_oldest_session(&mut sessions);
880        assert!(!sessions.contains_key("older"));
881        assert!(sessions.contains_key("young"));
882        assert!(sessions.contains_key("old"));
883    }
884
885    #[test]
886    fn list_sorts_by_repo_then_session_id() {
887        let reg = SessionsRegistry::new();
888        for (id, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
889            reg.observe(ObserveRequest {
890                session_id: id.to_string(),
891                cwd: None,
892                transcript_path: None,
893                event: SessionEvent::PreToolUse,
894                repo: Some(repo.to_string()),
895                model: None,
896            });
897        }
898        let ordered: Vec<(String, String)> = reg
899            .list()
900            .into_iter()
901            .map(|s| (s.session_id, s.repo.unwrap()))
902            .collect();
903        assert_eq!(
904            ordered,
905            vec![
906                ("m".to_string(), "repo-a".to_string()),
907                ("z".to_string(), "repo-a".to_string()),
908                ("a".to_string(), "repo-b".to_string()),
909            ]
910        );
911    }
912
913    #[test]
914    fn serialized_session_shapes_are_stable() {
915        // The wire shape consumers (CLI, extension) read: snake_case state, a
916        // tagged source, and omitted `None` fields.
917        let reg = SessionsRegistry::new();
918        reg.observe(ObserveRequest {
919            session_id: "s1".to_string(),
920            cwd: Some(PathBuf::from("/p")),
921            transcript_path: None,
922            event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
923            repo: Some("proj".to_string()),
924            model: None,
925        });
926        let value = serde_json::to_value(&reg.list()[0]).unwrap();
927        assert_eq!(value["state"], "waiting_for_permission");
928        assert_eq!(value["source"]["kind"], "terminal");
929        assert_eq!(value["repo"], "proj");
930        // Absent optional fields are omitted, not null.
931        assert!(value.get("model").is_none());
932        assert!(value.get("transcript_path").is_none());
933    }
934
935    #[test]
936    fn default_constructs_an_empty_registry() {
937        let reg = SessionsRegistry::default();
938        assert!(reg.list().is_empty());
939    }
940
941    #[test]
942    fn fill_only_overwrites_with_a_present_value() {
943        // `None` leaves the slot; `Some` overwrites it — the re-`observe`
944        // never-clobber contract.
945        let mut slot = Some("keep");
946        fill(&mut slot, None);
947        assert_eq!(slot, Some("keep"));
948        fill(&mut slot, Some("new"));
949        assert_eq!(slot, Some("new"));
950        // A previously-empty slot fills.
951        let mut empty: Option<&str> = None;
952        fill(&mut empty, Some("filled"));
953        assert_eq!(empty, Some("filled"));
954    }
955
956    #[test]
957    fn observe_at_session_cap_evicts_the_longest_silent() {
958        let reg = SessionsRegistry::new();
959        // Seed a full registry with explicit descending timestamps so the
960        // highest-numbered id is unambiguously the oldest.
961        {
962            let mut sessions = reg.lock_sessions();
963            let base = Utc::now();
964            for i in 0..MAX_SESSIONS {
965                let id = format!("s{i:04}");
966                sessions.insert(
967                    id.clone(),
968                    SessionEntry {
969                        session_id: id.clone(),
970                        cwd: None,
971                        transcript_path: None,
972                        repo: None,
973                        model: None,
974                        state: SessionState::Working,
975                        source: Source::Terminal,
976                        last_event: SessionEvent::PreToolUse,
977                        started_at: base,
978                        last_seen: base - chrono::Duration::milliseconds(i as i64),
979                    },
980                );
981            }
982        }
983        // A new session at the cap displaces exactly the longest-silent entry.
984        reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
985        let sessions = reg.lock_sessions();
986        assert_eq!(sessions.len(), MAX_SESSIONS);
987        assert!(sessions.contains_key("fresh"));
988        assert!(!sessions.contains_key(&format!("s{:04}", MAX_SESSIONS - 1)));
989        assert!(sessions.contains_key("s0000"));
990    }
991
992    #[test]
993    fn report_window_at_cap_evicts_the_longest_silent() {
994        let reg = SessionsRegistry::new();
995        {
996            let mut windows = reg.lock_windows();
997            let base = Utc::now();
998            for i in 0..MAX_WINDOWS {
999                let key = format!("w{i:04}");
1000                windows.insert(
1001                    key.clone(),
1002                    WindowEntry {
1003                        report: WindowReport {
1004                            key: key.clone(),
1005                            folders: vec![],
1006                            tabs: 1,
1007                            terminals: 0,
1008                        },
1009                        last_seen: base - chrono::Duration::milliseconds(i as i64),
1010                    },
1011                );
1012            }
1013        }
1014        reg.report_window(WindowReport {
1015            key: "fresh".to_string(),
1016            folders: vec![],
1017            tabs: 1,
1018            terminals: 0,
1019        });
1020        let windows = reg.lock_windows();
1021        assert_eq!(windows.len(), MAX_WINDOWS);
1022        assert!(windows.contains_key("fresh"));
1023        assert!(!windows.contains_key(&format!("w{:04}", MAX_WINDOWS - 1)));
1024        assert!(windows.contains_key("w0000"));
1025    }
1026
1027    #[test]
1028    fn evict_oldest_window_breaks_ties_by_key() {
1029        let now = Utc::now();
1030        let mut windows = HashMap::new();
1031        let at = |key: &str, secs: i64| WindowEntry {
1032            report: WindowReport {
1033                key: key.to_string(),
1034                folders: vec![],
1035                tabs: 1,
1036                terminals: 0,
1037            },
1038            last_seen: now - chrono::Duration::seconds(secs),
1039        };
1040        windows.insert("young".to_string(), at("young", 0));
1041        windows.insert("old-b".to_string(), at("old-b", 10));
1042        windows.insert("old-a".to_string(), at("old-a", 10));
1043        // Oldest `last_seen` is shared; the lowest key loses.
1044        evict_oldest_window(&mut windows);
1045        assert!(!windows.contains_key("old-a"));
1046        assert!(windows.contains_key("old-b"));
1047        assert!(windows.contains_key("young"));
1048        // An empty map is a no-op, not a panic.
1049        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
1050        evict_oldest_window(&mut empty);
1051        assert!(empty.is_empty());
1052    }
1053}