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