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