Skip to main content

omni_dev/daemon/services/
sessions.rs

1//! The Claude Code sessions daemon service.
2//!
3//! A thin adapter that hosts the cross-window [`SessionsRegistry`] under the
4//! daemon's lifecycle and exposes the ingest ops (`observe`/`end`/`window`/
5//! `window-unregister`) and the read op (`list`) over the control socket, plus a
6//! tray submenu with a per-session "focus" action for sessions embedded in a VS
7//! Code window.
8//!
9//! All registry state and liveness logic (the two `Mutex<HashMap>`s, TTL
10//! reaping, the entry caps, the `Source` join) lives in [`crate::sessions`]; this
11//! adapter only routes ops, enriches a session's `repo` from its `cwd` via
12//! `git2` (the disk I/O the engine deliberately avoids, off the registry lock on
13//! a blocking thread — the worktrees `git_status` precedent, #1186), renders the
14//! menu/status, and drives the shared VS Code launcher. Like the Snowflake and
15//! worktrees services it is a cheap, in-memory adapter — no async setup, no
16//! secret persisted.
17//!
18//! Phase 3 additionally starts the engine-owned **transcript watcher**
19//! ([`start_watcher`](SessionsService::start_watcher)) so sessions started before
20//! the daemon — or working through the hook-silent "thinking window" — are still
21//! discovered and marked active. See ADR-0052.
22
23use std::path::{Path, PathBuf};
24use std::sync::{Arc, Mutex, PoisonError};
25use std::time::Duration;
26
27use anyhow::{anyhow, bail, Context, Result};
28use async_trait::async_trait;
29use git2::Repository;
30use serde_json::{json, Value};
31use tokio::task::JoinHandle;
32use tokio_util::sync::CancellationToken;
33
34use crate::daemon::service::{
35    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
36};
37use crate::daemon::services::worktrees::focus_window;
38use crate::sessions::{
39    DeliveryStats, EventStamp, ObserveRequest, Origin, SessionEntry, SessionState,
40    SessionsRegistry, WindowReport,
41};
42
43/// The sessions service name (the control-socket routing key).
44pub const SERVICE_NAME: &str = "sessions";
45
46/// The tray submenu title. Not "Claude Sessions": the service tracks every
47/// agent the `agent` tag names, and each row says which (#1908).
48const SUBMENU_TITLE: &str = "Agent Sessions";
49
50/// The running background watcher tasks — the Claude transcript watcher and the
51/// Codex rollout watcher — and the one token that stops them both.
52struct WatcherTask {
53    /// Cancelled by `shutdown` to end the watch loops.
54    token: CancellationToken,
55    /// The spawned loops, awaited on shutdown so they fully unwind.
56    handles: Vec<JoinHandle<()>>,
57}
58
59/// Hosts the cross-window [`SessionsRegistry`] as a [`DaemonService`].
60pub struct SessionsService {
61    /// The registry this adapter routes ops to. Behind an `Arc` so the
62    /// background transcript-watcher task can feed it off the main thread.
63    registry: Arc<SessionsRegistry>,
64    /// The background transcript-watcher task, once started (`None` in tests /
65    /// with no runtime).
66    watcher: Mutex<Option<WatcherTask>>,
67    /// Where the hook sink journals events (`<socket dir>/sessions`), when the
68    /// daemon was given one (#2108). `None` runs without the journal feed, as
69    /// tests that build a bare service do.
70    journal_dir: Option<PathBuf>,
71}
72
73impl SessionsService {
74    /// Creates the service with an empty registry. Cheap — no I/O and no task;
75    /// the daemon calls [`start_watcher`](Self::start_watcher) to begin the
76    /// transcript watcher, while tests use the bare service.
77    #[must_use]
78    pub fn new() -> Self {
79        Self {
80            registry: Arc::new(SessionsRegistry::new()),
81            watcher: Mutex::new(None),
82            journal_dir: None,
83        }
84    }
85
86    /// Reads the hook journals under `dir` (see
87    /// [`crate::sessions::journal`]): replayed at startup and tailed while the
88    /// daemon runs, once [`start_watcher`](Self::start_watcher) starts the task.
89    #[must_use]
90    pub fn with_journal_dir(mut self, dir: PathBuf) -> Self {
91        self.journal_dir = Some(dir);
92        self
93    }
94
95    /// Starts the engine-owned transcript watcher (Feed 2): a background task
96    /// that scans `~/.claude/projects/**/*.jsonl` for new/growing transcripts and
97    /// feeds the registry, so a session started before the daemon — or working
98    /// through the hook-silent thinking window — is still discovered and marked
99    /// active. Alongside it runs the Codex rollout watcher (#1909), which
100    /// discovers Codex sessions from `$CODEX_HOME/sessions/` and ends archived
101    /// or killed ones. Idempotent, and a no-op outside a tokio runtime (mirroring the
102    /// worktrees menu-refresh and Snowflake keep-alive tasks), so unit tests that
103    /// build a bare service start no watcher.
104    pub fn start_watcher(&self) {
105        if tokio::runtime::Handle::try_current().is_err() {
106            tracing::debug!("no tokio runtime; sessions transcript watcher not started");
107            return;
108        }
109        let mut guard = self.watcher.lock().unwrap_or_else(PoisonError::into_inner);
110        if guard.is_some() {
111            return;
112        }
113        let token = CancellationToken::new();
114        let mut handles = vec![
115            crate::sessions::watcher::spawn(self.registry.clone(), token.clone()),
116            crate::sessions::codex_watcher::spawn(self.registry.clone(), token.clone()),
117            crate::sessions::pid_watcher::spawn(self.registry.clone(), token.clone()),
118        ];
119        if let Some(dir) = &self.journal_dir {
120            handles.push(crate::sessions::journal_watcher::spawn(
121                self.registry.clone(),
122                dir.clone(),
123                Arc::new(|cwd: &Path| repo_name_for(cwd)),
124                token.clone(),
125            ));
126        }
127        *guard = Some(WatcherTask { token, handles });
128    }
129
130    /// The registry, for tests driving the service directly.
131    #[cfg(test)]
132    pub(crate) fn registry(&self) -> &Arc<SessionsRegistry> {
133        &self.registry
134    }
135}
136
137impl Default for SessionsService {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143#[async_trait]
144impl DaemonService for SessionsService {
145    fn name(&self) -> &'static str {
146        SERVICE_NAME
147    }
148
149    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
150        match op {
151            "observe" => {
152                // The hook sink's additive `stamp` (#2108): read apart from the
153                // request, which an older sink's payload and every other feed
154                // lack. A malformed one is as good as none.
155                let stamp = stamp_of(&payload);
156                let mut req: ObserveRequest =
157                    serde_json::from_value(payload).context("invalid `observe` payload")?;
158                if req.session_id.trim().is_empty() {
159                    bail!("`observe` requires a non-empty `session_id`");
160                }
161                // Enrich `repo` from `cwd` on a blocking thread (git2 disk I/O),
162                // exactly like the worktrees adapter — the engine stores only what
163                // it is handed and never touches the disk under its lock. Skip when
164                // a caller already supplied `repo`.
165                if req.repo.is_none() {
166                    if let Some(cwd) = req.cwd.clone() {
167                        req.repo = match tokio::task::spawn_blocking(move || repo_name_for(&cwd))
168                            .await
169                        {
170                            Ok(repo) => repo,
171                            Err(error) => {
172                                tracing::warn!(session_id = %req.session_id, %error, "sessions_repo_enrichment_failed");
173                                None
174                            }
175                        };
176                    }
177                }
178                self.registry.observe_stamped(req, stamp, Origin::Socket);
179                Ok(json!({ "ok": true }))
180            }
181            "end" => {
182                let session_id = require_str(&payload, "session_id", "end")?;
183                let reason = payload.get("reason").and_then(Value::as_str);
184                // The hook sink's parent pid, when sent: lets the registry ignore
185                // the late `SessionEnd` of a process a resume replaced (#1948).
186                let pid = payload
187                    .get("pid")
188                    .and_then(Value::as_u64)
189                    .and_then(|pid| u32::try_from(pid).ok());
190                let ended = self.registry.end_stamped(
191                    session_id,
192                    reason,
193                    pid,
194                    stamp_of(&payload),
195                    Origin::Socket,
196                );
197                Ok(json!({ "ended": ended }))
198            }
199            "window" => {
200                let req: WindowReport =
201                    serde_json::from_value(payload).context("invalid `window` payload")?;
202                if req.key.trim().is_empty() {
203                    bail!("`window` requires a non-empty `key`");
204                }
205                self.registry.report_window(req);
206                Ok(json!({ "ok": true }))
207            }
208            "window-unregister" => {
209                let key = require_str(&payload, "key", "window-unregister")?;
210                Ok(json!({ "removed": self.registry.unregister_window(key) }))
211            }
212            "list" => Ok(sessions_payload(&self.registry)),
213            other => bail!("unknown sessions op: {other}"),
214        }
215    }
216
217    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
218        // The single streaming op: a live push of the same `sessions` snapshot
219        // `list` serves. Every other op falls through to the request→reply
220        // `handle`.
221        if op != "subscribe" {
222            return None;
223        }
224        Some(Box::new(SessionsStream {
225            registry: self.registry.clone(),
226            // Capture the change source *now* — before the server takes its
227            // initial snapshot — so a change racing that snapshot still wakes us.
228            changes: self.registry.subscribe_changes(),
229        }))
230    }
231
232    fn menu(&self) -> MenuSnapshot {
233        // Pure formatting of stored entries — `repo` was enriched at observe
234        // time, so `menu()` does no git I/O and honours the trait's "cheap, must
235        // not block" contract without a background cache (unlike worktrees).
236        MenuSnapshot {
237            title: SUBMENU_TITLE.to_string(),
238            items: menu_items_for(&self.registry.list()),
239        }
240    }
241
242    async fn menu_action(&self, action_id: &str) -> Result<()> {
243        if let Some(session_id) = action_id.strip_prefix("focus:") {
244            let folder = self.registry.focus_folder(session_id).ok_or_else(|| {
245                anyhow!("session {session_id} is not open in a known VS Code window")
246            })?;
247            focus_window(&folder)?;
248            return Ok(());
249        }
250        bail!("unknown sessions menu action: {action_id}")
251    }
252
253    async fn status(&self) -> ServiceStatus {
254        let sessions = self.registry.list();
255        let delivery = self.registry.delivery();
256        let summary = format!(
257            "{}{}",
258            status_summary(&sessions),
259            delivery_summary(delivery)
260        );
261        ServiceStatus {
262            name: SERVICE_NAME.to_string(),
263            healthy: true,
264            summary,
265            detail: json!({ "sessions": sessions, "delivery": delivery }),
266        }
267    }
268
269    fn credit_accept_outage(&self, outage: Duration) {
270        self.registry.credit_window_outage(outage);
271    }
272
273    async fn shutdown(&self) {
274        // Stop the transcript watcher; the registry itself is in-memory with
275        // nothing to drain. Take the task out from under the lock first so the
276        // `std::Mutex` is never held across the `.await`.
277        let task = self
278            .watcher
279            .lock()
280            .unwrap_or_else(PoisonError::into_inner)
281            .take();
282        if let Some(task) = task {
283            task.token.cancel();
284            for handle in task.handles {
285                let _ = handle.await;
286            }
287        }
288    }
289}
290
291/// The live session set as the wire payload — the single body both the one-shot
292/// `list` op and the `subscribe` stream serve, so a fetch and a push can never
293/// disagree.
294fn sessions_payload(registry: &SessionsRegistry) -> Value {
295    json!({ "sessions": registry.list() })
296}
297
298/// The live push stream behind the `subscribe` op (#1414).
299///
300/// Unlike the worktrees stream this needs no coalescing snapshot cache (#1303):
301/// `repo` is enriched at `observe` time, so [`SessionsRegistry::list`] is pure
302/// CPU under a lock with no disk I/O — cheap enough to run once per subscriber
303/// per wakeup, and cheap enough that [`snapshot`](ServiceStream::snapshot) needs
304/// no blocking thread.
305struct SessionsStream {
306    /// The registry each snapshot is read from.
307    registry: Arc<SessionsRegistry>,
308    /// Wakes on each consumer-visible change (a new or ended session, a state
309    /// transition, a window report that alters the source join). A burst
310    /// coalesces into one wakeup; the server's diff drops any snapshot that ends
311    /// up identical.
312    changes: tokio::sync::watch::Receiver<u64>,
313}
314
315#[async_trait]
316impl ServiceStream for SessionsStream {
317    async fn changed(&mut self) {
318        // `watch::Receiver::changed` marks the newest version seen, so a burst of
319        // bumps collapses into a single wakeup. If every sender is gone (the
320        // registry — and thus the daemon — is tearing down) it returns `Err`;
321        // park instead of returning, so this arm can never spin the server's
322        // `select!` (the tick and shutdown arms still drive teardown).
323        if self.changes.changed().await.is_err() {
324            std::future::pending::<()>().await;
325        }
326    }
327
328    async fn snapshot(&self) -> Value {
329        sessions_payload(&self.registry)
330    }
331}
332
333/// The [`EventStamp`] a hook sink attached to an `observe` or `end` payload, if
334/// any and well-formed (#2108).
335fn stamp_of(payload: &Value) -> Option<EventStamp> {
336    serde_json::from_value(payload.get("stamp")?.clone()).ok()
337}
338
339/// Extracts a required string `field` from an op payload, erroring with the op
340/// name when it is absent or not a string.
341fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
342    payload
343        .get(field)
344        .and_then(Value::as_str)
345        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
346}
347
348/// The repository name for a session's `cwd`, derived from the discovered
349/// repo's common dir (so a session inside a linked worktree names its parent
350/// repo, matching the worktrees enrichment). Best-effort: `None` when `cwd` is
351/// not inside a git repo. Pure disk I/O; called on a blocking thread.
352fn repo_name_for(cwd: &Path) -> Option<String> {
353    let repo = Repository::discover(cwd).ok()?;
354    main_repo_name(repo.commondir())
355}
356
357/// The main repository's directory name from git's common dir — the same
358/// derivation the worktrees adapter uses: for the `<repo>/.git` layout the
359/// working-tree directory's name, for a bare `<name>.git` that name with the
360/// suffix stripped.
361fn main_repo_name(commondir: &Path) -> Option<String> {
362    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
363    if file_name == ".git" {
364        commondir
365            .parent()
366            .and_then(Path::file_name)
367            .map(|n| n.to_string_lossy().into_owned())
368    } else {
369        Some(
370            file_name
371                .strip_suffix(".git")
372                .unwrap_or(&file_name)
373                .to_string(),
374        )
375    }
376}
377
378/// A one-line `status` summary: the live session count and a per-state tally.
379fn status_summary(sessions: &[SessionEntry]) -> String {
380    if sessions.is_empty() {
381        return "0 session(s)".to_string();
382    }
383    let mut working = 0;
384    let mut waiting = 0;
385    let mut idle = 0;
386    for s in sessions {
387        match s.state {
388            SessionState::Working => working += 1,
389            SessionState::WaitingForInput | SessionState::WaitingForPermission => waiting += 1,
390            // A session that has not been prompted yet is not busy (#1946).
391            SessionState::Starting | SessionState::Idle | SessionState::Ended => idle += 1,
392        }
393    }
394    format!(
395        "{} session(s): {working} working, {waiting} waiting, {idle} idle",
396        sessions.len()
397    )
398}
399
400/// The `status` summary's tail for [`DeliveryStats`] (#2136): how the daemon has
401/// been hearing about events. Empty until the first one, so an idle daemon's
402/// summary is unchanged. A steady `0 recovered` and `0 superseded` means the
403/// socket is keeping up.
404fn delivery_summary(delivery: DeliveryStats) -> String {
405    if delivery == DeliveryStats::default() {
406        return String::new();
407    }
408    format!(
409        " · events: {} via socket, {} recovered from journals, {} superseded, {} replayed",
410        delivery.socket, delivery.recovered, delivery.superseded, delivery.replayed
411    )
412}
413
414/// A short glyph marking a session's state in the tray label.
415fn state_glyph(state: SessionState) -> &'static str {
416    match state {
417        SessionState::Starting => "…",
418        SessionState::Working => "⚙",
419        SessionState::Idle => "◦",
420        SessionState::WaitingForInput => "?",
421        SessionState::WaitingForPermission => "!",
422        SessionState::Ended => "×",
423    }
424}
425
426/// A short human name for a session: its repo, else its cwd basename, else the
427/// truncated session id.
428fn display_name(entry: &SessionEntry) -> String {
429    if let Some(repo) = &entry.repo {
430        return repo.clone();
431    }
432    if let Some(cwd) = &entry.cwd {
433        if let Some(name) = cwd.file_name() {
434            return name.to_string_lossy().into_owned();
435        }
436    }
437    // A session with neither repo nor cwd: a short id prefix is still a handle.
438    entry.session_id.chars().take(8).collect()
439}
440
441/// The tray items for the session set: a placeholder when empty, else one line
442/// per session (`<agent> · <name> <glyph> <state>`). A session embedded in a VS Code window
443/// is clickable (its click focuses that window via the `focus:` action); a
444/// terminal session is a non-clickable status line, since the daemon has no
445/// window to focus.
446fn menu_items_for(sessions: &[SessionEntry]) -> Vec<MenuItem> {
447    use crate::sessions::Source;
448    if sessions.is_empty() {
449        return vec![MenuItem::Label("No active sessions".to_string())];
450    }
451    sessions
452        .iter()
453        .map(|entry| {
454            let label = format!(
455                "{} · {} {} {}",
456                entry.agent.display_name(),
457                display_name(entry),
458                state_glyph(entry.state),
459                state_label(entry.state),
460            );
461            match &entry.source {
462                Source::VsCode { .. } => MenuItem::Action(MenuAction {
463                    id: format!("focus:{}", entry.session_id),
464                    label,
465                    enabled: true,
466                }),
467                Source::Terminal => MenuItem::Label(label),
468            }
469        })
470        .collect()
471}
472
473/// A short lowercase label for a session state, for the tray line and any
474/// human-readable rendering.
475fn state_label(state: SessionState) -> &'static str {
476    match state {
477        SessionState::Starting => "starting",
478        SessionState::Working => "working",
479        SessionState::Idle => "idle",
480        SessionState::WaitingForInput => "waiting",
481        SessionState::WaitingForPermission => "permission",
482        SessionState::Ended => "ended",
483    }
484}
485
486#[cfg(test)]
487#[allow(clippy::unwrap_used, clippy::expect_used)]
488mod tests {
489    use super::*;
490    use crate::sessions::{NotificationKind, SessionEvent, Source};
491    use std::collections::VecDeque;
492    use std::path::PathBuf;
493
494    fn service() -> SessionsService {
495        SessionsService::new()
496    }
497
498    #[tokio::test]
499    async fn observe_then_list_round_trips() {
500        let svc = service();
501        let ok = svc
502            .handle(
503                "observe",
504                json!({ "session_id": "s1", "event": "session_start", "cwd": "/tmp/x" }),
505            )
506            .await
507            .unwrap();
508        assert_eq!(ok, json!({ "ok": true }));
509
510        let listed = svc.handle("list", Value::Null).await.unwrap();
511        let sessions = listed["sessions"].as_array().unwrap();
512        assert_eq!(sessions.len(), 1);
513        assert_eq!(sessions[0]["session_id"], "s1");
514        assert_eq!(sessions[0]["state"], "starting");
515    }
516
517    #[tokio::test]
518    async fn observe_rejects_blank_session_id() {
519        let svc = service();
520        let err = svc
521            .handle("observe", json!({ "session_id": "  ", "event": "stop" }))
522            .await
523            .unwrap_err();
524        assert!(err.to_string().contains("session_id"), "{err}");
525    }
526
527    #[tokio::test]
528    async fn end_marks_ended() {
529        let svc = service();
530        svc.handle(
531            "observe",
532            json!({ "session_id": "s1", "event": "pre_tool_use" }),
533        )
534        .await
535        .unwrap();
536        let reply = svc
537            .handle("end", json!({ "session_id": "s1", "reason": "clear" }))
538            .await
539            .unwrap();
540        assert_eq!(reply, json!({ "ended": true }));
541        // Ending an unknown session reports false, not an error.
542        let reply = svc
543            .handle("end", json!({ "session_id": "ghost" }))
544            .await
545            .unwrap();
546        assert_eq!(reply, json!({ "ended": false }));
547    }
548
549    #[tokio::test]
550    async fn a_started_service_replays_the_journals_beside_its_socket() {
551        // #2108: a session whose SessionStart was journaled while the daemon was
552        // down is listed once the daemon is up, with its cwd.
553        use crate::sessions::journal::{self, JournalRecord};
554        let tmp = tempfile::tempdir().unwrap();
555        let dir =
556            crate::daemon::paths::sessions_journal_dir_for_socket(&tmp.path().join("daemon.sock"));
557        let id = "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c";
558        let stamp = EventStamp {
559            ts: chrono::Utc::now() - chrono::Duration::seconds(15),
560            seq: "7-1".to_string(),
561        };
562        let req = ObserveRequest {
563            agent_id: None,
564            session_id: id.to_string(),
565            cwd: Some(PathBuf::from("/nonexistent/proj")),
566            transcript_path: None,
567            event: SessionEvent::SessionStart,
568            repo: None,
569            model: None,
570            agent: crate::sessions::Agent::Claude,
571            pid: None,
572        };
573        journal::append(&dir, &JournalRecord::observe(&req, &stamp)).unwrap();
574
575        let svc = SessionsService::new().with_journal_dir(dir);
576        svc.start_watcher();
577        // The transcript and rollout watchers run too, so look for this session
578        // among whatever else the machine has.
579        let mut found = None;
580        for _ in 0..100 {
581            let listed = svc.handle("list", Value::Null).await.unwrap();
582            found = listed["sessions"]
583                .as_array()
584                .unwrap()
585                .iter()
586                .find(|s| s["session_id"] == id)
587                .cloned();
588            if found.is_some() {
589                break;
590            }
591            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
592        }
593        let found = found.expect("the journaled session is listed");
594        assert_eq!(found["state"], "starting");
595        assert_eq!(found["cwd"], "/nonexistent/proj");
596        svc.shutdown().await;
597    }
598
599    #[tokio::test]
600    async fn a_stamped_event_delivered_twice_is_applied_once() {
601        // #2108: the hook sink posts every event it also journaled, and an event
602        // re-delivered with the same stamp must not undo a later one.
603        let svc = service();
604        let working = json!({
605            "ts": "2026-10-03T03:40:00Z", "seq": "9-1",
606        });
607        let stopped = json!({
608            "ts": "2026-10-03T03:40:05Z", "seq": "9-2",
609        });
610        for (event, stamp) in [
611            ("pre_tool_use", &working),
612            ("stop", &stopped),
613            ("pre_tool_use", &working),
614        ] {
615            svc.handle(
616                "observe",
617                json!({ "session_id": "s1", "event": event, "stamp": stamp }),
618            )
619            .await
620            .unwrap();
621        }
622        let listed = svc.handle("list", Value::Null).await.unwrap();
623        assert_eq!(listed["sessions"][0]["state"], "idle");
624    }
625
626    #[tokio::test]
627    async fn a_malformed_stamp_is_ignored_not_an_error() {
628        let svc = service();
629        for stamp in [
630            json!("nope"),
631            json!({ "ts": "yesterday", "seq": 3 }),
632            Value::Null,
633        ] {
634            svc.handle(
635                "observe",
636                json!({ "session_id": "s1", "event": "pre_tool_use", "stamp": stamp }),
637            )
638            .await
639            .unwrap();
640            svc.handle("end", json!({ "session_id": "s1", "stamp": stamp }))
641                .await
642                .unwrap();
643        }
644        assert_eq!(svc.registry().list().len(), 1);
645    }
646
647    #[tokio::test]
648    async fn a_stamped_end_is_deduplicated_too() {
649        let svc = service();
650        svc.handle(
651            "observe",
652            json!({ "session_id": "s1", "event": "session_start",
653                    "stamp": { "ts": "2026-10-03T03:40:00Z", "seq": "9-1" } }),
654        )
655        .await
656        .unwrap();
657        let end =
658            json!({ "session_id": "s1", "stamp": { "ts": "2026-10-03T03:41:00Z", "seq": "9-2" } });
659        assert_eq!(
660            svc.handle("end", end.clone()).await.unwrap(),
661            json!({ "ended": true })
662        );
663        // The re-delivered end is dropped, and the session stays ended once.
664        assert_eq!(
665            svc.handle("end", end).await.unwrap(),
666            json!({ "ended": true })
667        );
668        let listed = svc.handle("list", Value::Null).await.unwrap();
669        assert_eq!(listed["sessions"][0]["state"], "ended");
670    }
671
672    #[tokio::test]
673    async fn window_report_tags_source_and_unregister_removes() {
674        let svc = service();
675        svc.handle(
676            "observe",
677            json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
678        )
679        .await
680        .unwrap();
681        svc.handle(
682            "window",
683            json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
684        )
685        .await
686        .unwrap();
687        let listed = svc.handle("list", Value::Null).await.unwrap();
688        assert_eq!(listed["sessions"][0]["source"]["kind"], "vs_code");
689        assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
690
691        let removed = svc
692            .handle("window-unregister", json!({ "key": "w1" }))
693            .await
694            .unwrap();
695        assert_eq!(removed, json!({ "removed": true }));
696        let listed = svc.handle("list", Value::Null).await.unwrap();
697        assert_eq!(listed["sessions"][0]["source"]["kind"], "terminal");
698    }
699
700    /// The adapter forwards a daemon-level accept outage (#2111) to the registry's
701    /// window reports — covered in depth in `crate::sessions`; here it just has to
702    /// reach them and leave a live report live, so the session keeps its window.
703    #[tokio::test]
704    async fn credit_accept_outage_reaches_the_window_reports_without_dropping_a_live_one() {
705        let svc = service();
706        svc.handle(
707            "observe",
708            json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
709        )
710        .await
711        .unwrap();
712        svc.handle(
713            "window",
714            json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
715        )
716        .await
717        .unwrap();
718
719        svc.credit_accept_outage(std::time::Duration::from_secs(5));
720
721        let listed = svc.handle("list", Value::Null).await.unwrap();
722        assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
723    }
724
725    #[tokio::test]
726    async fn unknown_op_errors() {
727        let svc = service();
728        let err = svc.handle("frobnicate", Value::Null).await.unwrap_err();
729        assert!(err.to_string().contains("unknown sessions op"), "{err}");
730    }
731
732    #[tokio::test]
733    async fn status_summarizes_states() {
734        let svc = service();
735        svc.registry().observe(ObserveRequest {
736            agent_id: None,
737            pid: None,
738            agent: crate::sessions::Agent::Claude,
739            session_id: "w".to_string(),
740            cwd: None,
741            transcript_path: None,
742            event: SessionEvent::PreToolUse,
743            repo: None,
744            model: None,
745        });
746        svc.registry().observe(ObserveRequest {
747            agent_id: None,
748            pid: None,
749            agent: crate::sessions::Agent::Claude,
750            session_id: "p".to_string(),
751            cwd: None,
752            transcript_path: None,
753            event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
754            repo: None,
755            model: None,
756        });
757        let status = svc.status().await;
758        assert!(status.healthy);
759        assert!(
760            status.summary.contains("2 session(s)"),
761            "{}",
762            status.summary
763        );
764        assert!(status.summary.contains("1 working"), "{}", status.summary);
765        assert!(status.summary.contains("1 waiting"), "{}", status.summary);
766    }
767
768    #[test]
769    fn menu_items_placeholder_when_empty() {
770        let items = menu_items_for(&[]);
771        assert_eq!(items.len(), 1);
772        assert!(matches!(&items[0], MenuItem::Label(l) if l.contains("No active")));
773    }
774
775    #[test]
776    fn menu_item_is_clickable_only_for_vscode_sessions() {
777        let now = chrono::Utc::now();
778        let base = |source: Source| SessionEntry {
779            subagent_base: None,
780            subagent_waits: std::collections::HashMap::new(),
781            pid: None,
782            pid_start: None,
783            prompted: false,
784            streamed: false,
785            replaced_pids: VecDeque::new(),
786            recent_seqs: VecDeque::new(),
787            superseded_seqs: VecDeque::new(),
788            latest_stamp_ts: None,
789            reported: false,
790            agent: crate::sessions::Agent::Claude,
791            session_id: "sid-12345678".to_string(),
792            cwd: Some(PathBuf::from("/p")),
793            transcript_path: None,
794            repo: Some("proj".to_string()),
795            model: None,
796            state: SessionState::Working,
797            source,
798            last_event: SessionEvent::PreToolUse,
799            started_at: now,
800            last_seen: now,
801            last_active: std::time::Duration::ZERO,
802        };
803        // A terminal session is a non-clickable label.
804        let terminal = menu_items_for(&[base(Source::Terminal)]);
805        assert!(
806            matches!(&terminal[0], MenuItem::Label(l) if l.contains("proj") && l.contains("working"))
807        );
808        // A VS Code session is a clickable focus action.
809        let vscode = menu_items_for(&[base(Source::VsCode {
810            window_key: "w1".to_string(),
811        })]);
812        match &vscode[0] {
813            MenuItem::Action(a) => assert_eq!(a.id, "focus:sid-12345678"),
814            other => panic!("expected an action, got {other:?}"),
815        }
816    }
817
818    #[test]
819    fn repo_name_for_non_repo_is_none() {
820        // A path that is not inside any git repo enriches to no repo.
821        assert_eq!(repo_name_for(Path::new("/nonexistent/xyz")), None);
822    }
823
824    #[test]
825    fn main_repo_name_handles_layouts() {
826        assert_eq!(
827            main_repo_name(Path::new("/home/me/proj/.git")).as_deref(),
828            Some("proj")
829        );
830        assert_eq!(
831            main_repo_name(Path::new("/home/me/bare.git")).as_deref(),
832            Some("bare")
833        );
834    }
835
836    /// Builds a session entry for the pure menu/label tests.
837    fn entry(id: &str, state: SessionState, repo: Option<&str>, cwd: Option<&str>) -> SessionEntry {
838        let now = chrono::Utc::now();
839        SessionEntry {
840            subagent_base: None,
841            subagent_waits: std::collections::HashMap::new(),
842            pid: None,
843            pid_start: None,
844            prompted: false,
845            streamed: false,
846            replaced_pids: VecDeque::new(),
847            recent_seqs: VecDeque::new(),
848            superseded_seqs: VecDeque::new(),
849            latest_stamp_ts: None,
850            reported: false,
851            agent: crate::sessions::Agent::Claude,
852            session_id: id.to_string(),
853            cwd: cwd.map(PathBuf::from),
854            transcript_path: None,
855            repo: repo.map(str::to_string),
856            model: None,
857            state,
858            source: Source::Terminal,
859            last_event: SessionEvent::PreToolUse,
860            started_at: now,
861            last_seen: now,
862            last_active: std::time::Duration::ZERO,
863        }
864    }
865
866    #[test]
867    fn default_constructs_an_empty_service() {
868        let svc = SessionsService::default();
869        assert!(svc.registry().list().is_empty());
870    }
871
872    #[test]
873    fn start_watcher_is_a_noop_outside_a_runtime() {
874        // No tokio runtime → the watcher is not started, and shutdown is a no-op.
875        let svc = SessionsService::new();
876        svc.start_watcher();
877        assert!(svc
878            .watcher
879            .lock()
880            .unwrap_or_else(PoisonError::into_inner)
881            .is_none());
882    }
883
884    #[tokio::test]
885    async fn start_watcher_is_idempotent_and_shutdown_stops_it() {
886        let svc = SessionsService::new();
887        svc.start_watcher();
888        // A second call is a no-op (the guard is already set), not a second task.
889        svc.start_watcher();
890        assert!(svc
891            .watcher
892            .lock()
893            .unwrap_or_else(PoisonError::into_inner)
894            .is_some());
895        // Shutdown cancels and joins the task, clearing the slot.
896        svc.shutdown().await;
897        assert!(svc
898            .watcher
899            .lock()
900            .unwrap_or_else(PoisonError::into_inner)
901            .is_none());
902    }
903
904    #[test]
905    fn menu_renders_every_state_and_name_fallback() {
906        let sessions = vec![
907            entry("s1", SessionState::Starting, Some("repo-a"), Some("/a")),
908            entry("s2", SessionState::Working, None, Some("/home/me/proj")),
909            entry("s3", SessionState::Idle, None, None),
910            entry("s4", SessionState::WaitingForInput, Some("r"), Some("/b")),
911            entry(
912                "s5",
913                SessionState::WaitingForPermission,
914                Some("r"),
915                Some("/c"),
916            ),
917            entry("s6", SessionState::Ended, Some("r"), Some("/d")),
918        ];
919        let items = menu_items_for(&sessions);
920        assert_eq!(items.len(), 6);
921        // Every line is a non-clickable label (all terminal sessions) and carries
922        // the state label + a name (repo, else cwd basename, else id prefix).
923        let labels: Vec<&str> = items
924            .iter()
925            .map(|i| match i {
926                MenuItem::Label(l) => l.as_str(),
927                _ => panic!("terminal sessions render as labels"),
928            })
929            .collect();
930        assert!(labels[0].contains("repo-a") && labels[0].contains("starting"));
931        assert!(labels[1].contains("proj") && labels[1].contains("working")); // cwd basename
932        assert!(labels[2].contains("s3") && labels[2].contains("idle")); // id-prefix fallback
933        assert!(labels[3].contains("waiting"));
934        assert!(labels[4].contains("permission"));
935        assert!(labels[5].contains("ended"));
936    }
937
938    #[test]
939    fn menu_rows_are_prefixed_with_their_agent() {
940        let mut pi = entry("p1", SessionState::Idle, Some("repo-p"), None);
941        pi.agent = crate::sessions::Agent::Pi;
942        let mut codex = entry("c1", SessionState::Working, Some("repo-c"), None);
943        codex.agent = crate::sessions::Agent::Codex;
944        let claude = entry("s1", SessionState::Idle, Some("repo-a"), None);
945        let items = menu_items_for(&[claude, pi, codex]);
946        let labels: Vec<&str> = items
947            .iter()
948            .map(|i| match i {
949                MenuItem::Label(l) => l.as_str(),
950                _ => panic!("terminal sessions render as labels"),
951            })
952            .collect();
953        assert_eq!(labels[0], "Claude · repo-a ◦ idle");
954        assert_eq!(labels[1], "pi · repo-p ◦ idle");
955        assert_eq!(labels[2], "Codex · repo-c ⚙ working");
956        assert_eq!(SUBMENU_TITLE, "Agent Sessions");
957    }
958
959    #[test]
960    fn menu_serves_the_snapshot_title() {
961        let svc = SessionsService::new();
962        svc.registry()
963            .observe(observe_req("s1", SessionEvent::Stop, None));
964        let snapshot = svc.menu();
965        assert_eq!(snapshot.title, SUBMENU_TITLE);
966        assert_eq!(snapshot.items.len(), 1);
967    }
968
969    /// A small `observe` builder for the adapter tests.
970    fn observe_req(id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
971        ObserveRequest {
972            agent_id: None,
973            pid: None,
974            agent: crate::sessions::Agent::Claude,
975            session_id: id.to_string(),
976            cwd: cwd.map(PathBuf::from),
977            transcript_path: None,
978            event,
979            repo: None,
980            model: None,
981        }
982    }
983
984    #[tokio::test]
985    async fn menu_action_errors_on_unknown_and_missing_window() {
986        let svc = SessionsService::new();
987        // An unrecognised action id.
988        let err = svc.menu_action("frobnicate").await.unwrap_err();
989        assert!(
990            err.to_string().contains("unknown sessions menu action"),
991            "{err}"
992        );
993        // A focus of a session that resolves to no VS Code window folder.
994        let err = svc.menu_action("focus:nope").await.unwrap_err();
995        assert!(
996            err.to_string()
997                .contains("not open in a known VS Code window"),
998            "{err}"
999        );
1000    }
1001
1002    #[tokio::test]
1003    async fn end_op_ignores_the_replaced_process_by_pid() {
1004        let svc = SessionsService::new();
1005        for pid in [100, 200] {
1006            svc.handle(
1007                "observe",
1008                json!({ "session_id": "s1", "event": "session_start", "pid": pid }),
1009            )
1010            .await
1011            .unwrap();
1012        }
1013        svc.handle("end", json!({ "session_id": "s1", "pid": 100 }))
1014            .await
1015            .unwrap();
1016        assert_eq!(svc.registry().list()[0].state, SessionState::Starting);
1017        svc.handle("end", json!({ "session_id": "s1", "pid": 200 }))
1018            .await
1019            .unwrap();
1020        assert_eq!(svc.registry().list()[0].state, SessionState::Ended);
1021    }
1022
1023    #[test]
1024    fn repo_name_for_resolves_a_real_repo() {
1025        let tmp = tempfile::tempdir().unwrap();
1026        let repo_dir = tmp.path().join("myrepo");
1027        std::fs::create_dir(&repo_dir).unwrap();
1028        git2::Repository::init(&repo_dir).unwrap();
1029        // A path inside the repo enriches to the repo's directory name.
1030        assert_eq!(repo_name_for(&repo_dir).as_deref(), Some("myrepo"));
1031    }
1032
1033    #[tokio::test]
1034    async fn status_summary_counts_idle_and_ended() {
1035        let svc = SessionsService::new();
1036        svc.registry()
1037            .observe(observe_req("i", SessionEvent::Stop, None)); // idle
1038        svc.registry().end("i2", None, None); // unknown → no-op
1039        svc.registry()
1040            .observe(observe_req("i2", SessionEvent::PreToolUse, None));
1041        svc.registry().end("i2", Some("done"), None); // ended
1042        svc.registry()
1043            .observe(observe_req("s", SessionEvent::SessionStart, None)); // starting
1044        let status = svc.status().await;
1045        // Idle, ended and an unprompted `starting` session all count toward the
1046        // "idle" tally (#1946).
1047        assert!(status.summary.contains("3 idle"), "{}", status.summary);
1048        assert!(status.summary.contains("0 working"), "{}", status.summary);
1049    }
1050
1051    #[tokio::test]
1052    async fn status_reports_how_events_were_delivered() {
1053        let svc = service();
1054        // An idle daemon's summary is unchanged.
1055        let quiet = svc.status().await;
1056        assert!(!quiet.summary.contains("events:"), "{}", quiet.summary);
1057        assert_eq!(
1058            quiet.detail["delivery"],
1059            json!({ "socket": 0, "recovered": 0, "superseded": 0, "replayed": 0 })
1060        );
1061
1062        svc.handle(
1063            "observe",
1064            json!({ "session_id": "s1", "event": "pre_tool_use" }),
1065        )
1066        .await
1067        .unwrap();
1068        svc.handle("observe", json!({ "session_id": "s1", "event": "stop" }))
1069            .await
1070            .unwrap();
1071        let status = svc.status().await;
1072        assert!(
1073            status.summary.contains(
1074                "events: 2 via socket, 0 recovered from journals, 0 superseded, 0 replayed"
1075            ),
1076            "{}",
1077            status.summary
1078        );
1079        assert_eq!(status.detail["delivery"]["socket"], 2);
1080        // The session counts are still first.
1081        assert!(
1082            status.summary.starts_with("1 session(s)"),
1083            "{}",
1084            status.summary
1085        );
1086    }
1087
1088    #[test]
1089    fn delivery_summary_is_empty_until_the_first_event() {
1090        assert_eq!(delivery_summary(DeliveryStats::default()), "");
1091        assert_eq!(
1092            delivery_summary(DeliveryStats {
1093                socket: 0,
1094                recovered: 1,
1095                superseded: 0,
1096                replayed: 0
1097            }),
1098            " · events: 0 via socket, 1 recovered from journals, 0 superseded, 0 replayed"
1099        );
1100    }
1101
1102    // --- Push subscription (#1414) -----------------------------------------
1103
1104    #[tokio::test]
1105    async fn subscribe_streams_only_for_the_subscribe_op() {
1106        let svc = service();
1107        // The one streaming op yields a stream; every other op declines, so the
1108        // server dispatches them normally through `handle`.
1109        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
1110        assert!(svc.subscribe("list", &Value::Null).is_none());
1111        assert!(svc.subscribe("observe", &Value::Null).is_none());
1112        assert!(svc.subscribe("window", &Value::Null).is_none());
1113        assert!(svc.subscribe("bogus", &Value::Null).is_none());
1114    }
1115
1116    #[tokio::test]
1117    async fn subscribe_snapshot_matches_the_list_op() {
1118        let svc = service();
1119        let stream = svc
1120            .subscribe("subscribe", &Value::Null)
1121            .expect("subscribe stream");
1122        // Empty registry: the push and the one-shot fetch agree.
1123        assert_eq!(stream.snapshot().await, json!({ "sessions": [] }));
1124
1125        svc.handle(
1126            "observe",
1127            json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/tmp/x" }),
1128        )
1129        .await
1130        .unwrap();
1131        // …and still agree byte-for-byte once there is state to serve, which is
1132        // what lets the extension treat a pushed frame and a `list` reply the same.
1133        let snap = stream.snapshot().await;
1134        let listed = svc.handle("list", Value::Null).await.unwrap();
1135        assert_eq!(snap, listed);
1136        assert_eq!(snap["sessions"][0]["state"], json!("working"));
1137    }
1138
1139    #[tokio::test]
1140    async fn subscribe_changed_wakes_on_a_visible_change() {
1141        let svc = service();
1142        let mut stream = svc
1143            .subscribe("subscribe", &Value::Null)
1144            .expect("subscribe stream");
1145        // Idle: `changed()` must not resolve without a registry change.
1146        tokio::select! {
1147            () = stream.changed() => panic!("changed resolved with no registry change"),
1148            () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
1149        }
1150        // A new session bumps the change-notify → `changed()` resolves promptly.
1151        svc.handle(
1152            "observe",
1153            json!({ "session_id": "s1", "event": "session_start" }),
1154        )
1155        .await
1156        .unwrap();
1157        tokio::time::timeout(std::time::Duration::from_secs(2), stream.changed())
1158            .await
1159            .expect("changed should resolve after a visible change");
1160    }
1161
1162    #[tokio::test]
1163    async fn changed_parks_when_every_sender_is_gone() {
1164        // With the sender dropped, `watch::Receiver::changed` returns `Err`
1165        // immediately and forever. `changed()` must park on that rather than
1166        // return, or the arm would spin the server's `select!` for the rest of
1167        // the connection's life.
1168        let (tx, rx) = tokio::sync::watch::channel(0u64);
1169        let mut stream = SessionsStream {
1170            registry: Arc::new(SessionsRegistry::new()),
1171            changes: rx,
1172        };
1173        drop(tx);
1174        tokio::select! {
1175            () = stream.changed() => panic!("changed resolved after the sender was dropped"),
1176            () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
1177        }
1178    }
1179}