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::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
34use crate::daemon::services::worktrees::focus_window;
35use crate::sessions::{ObserveRequest, SessionEntry, SessionState, SessionsRegistry, WindowReport};
36
37/// The sessions service name (the control-socket routing key).
38pub const SERVICE_NAME: &str = "sessions";
39
40/// The tray submenu title.
41const SUBMENU_TITLE: &str = "Claude Sessions";
42
43/// A running background transcript-watcher task and the token that stops it.
44struct WatcherTask {
45    /// Cancelled by `shutdown` to end the watch loop.
46    token: CancellationToken,
47    /// The spawned loop, awaited on shutdown so it fully unwinds.
48    handle: JoinHandle<()>,
49}
50
51/// Hosts the cross-window [`SessionsRegistry`] as a [`DaemonService`].
52pub struct SessionsService {
53    /// The registry this adapter routes ops to. Behind an `Arc` so the
54    /// background transcript-watcher task can feed it off the main thread.
55    registry: Arc<SessionsRegistry>,
56    /// The background transcript-watcher task, once started (`None` in tests /
57    /// with no runtime).
58    watcher: Mutex<Option<WatcherTask>>,
59}
60
61impl SessionsService {
62    /// Creates the service with an empty registry. Cheap — no I/O and no task;
63    /// the daemon calls [`start_watcher`](Self::start_watcher) to begin the
64    /// transcript watcher, while tests use the bare service.
65    #[must_use]
66    pub fn new() -> Self {
67        Self {
68            registry: Arc::new(SessionsRegistry::new()),
69            watcher: Mutex::new(None),
70        }
71    }
72
73    /// Starts the engine-owned transcript watcher (Feed 2): a background task
74    /// that scans `~/.claude/projects/**/*.jsonl` for new/growing transcripts and
75    /// feeds the registry, so a session started before the daemon — or working
76    /// through the hook-silent thinking window — is still discovered and marked
77    /// active. Idempotent, and a no-op outside a tokio runtime (mirroring the
78    /// worktrees menu-refresh and Snowflake keep-alive tasks), so unit tests that
79    /// build a bare service start no watcher.
80    pub fn start_watcher(&self) {
81        if tokio::runtime::Handle::try_current().is_err() {
82            tracing::debug!("no tokio runtime; sessions transcript watcher not started");
83            return;
84        }
85        let mut guard = self.watcher.lock().unwrap_or_else(PoisonError::into_inner);
86        if guard.is_some() {
87            return;
88        }
89        let token = CancellationToken::new();
90        let handle = crate::sessions::watcher::spawn(self.registry.clone(), token.clone());
91        *guard = Some(WatcherTask { token, handle });
92    }
93
94    /// The registry, for tests driving the service directly.
95    #[cfg(test)]
96    pub(crate) fn registry(&self) -> &Arc<SessionsRegistry> {
97        &self.registry
98    }
99}
100
101impl Default for SessionsService {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107#[async_trait]
108impl DaemonService for SessionsService {
109    fn name(&self) -> &'static str {
110        SERVICE_NAME
111    }
112
113    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
114        match op {
115            "observe" => {
116                let mut req: ObserveRequest =
117                    serde_json::from_value(payload).context("invalid `observe` payload")?;
118                if req.session_id.trim().is_empty() {
119                    bail!("`observe` requires a non-empty `session_id`");
120                }
121                // Enrich `repo` from `cwd` on a blocking thread (git2 disk I/O),
122                // exactly like the worktrees adapter — the engine stores only what
123                // it is handed and never touches the disk under its lock. Skip when
124                // a caller already supplied `repo`.
125                if req.repo.is_none() {
126                    if let Some(cwd) = req.cwd.clone() {
127                        req.repo = tokio::task::spawn_blocking(move || repo_name_for(&cwd))
128                            .await
129                            .unwrap_or_default();
130                    }
131                }
132                self.registry.observe(req);
133                Ok(json!({ "ok": true }))
134            }
135            "end" => {
136                let session_id = require_str(&payload, "session_id", "end")?;
137                let reason = payload.get("reason").and_then(Value::as_str);
138                Ok(json!({ "ended": self.registry.end(session_id, reason) }))
139            }
140            "window" => {
141                let req: WindowReport =
142                    serde_json::from_value(payload).context("invalid `window` payload")?;
143                if req.key.trim().is_empty() {
144                    bail!("`window` requires a non-empty `key`");
145                }
146                self.registry.report_window(req);
147                Ok(json!({ "ok": true }))
148            }
149            "window-unregister" => {
150                let key = require_str(&payload, "key", "window-unregister")?;
151                Ok(json!({ "removed": self.registry.unregister_window(key) }))
152            }
153            "list" => Ok(json!({ "sessions": self.registry.list() })),
154            other => bail!("unknown sessions op: {other}"),
155        }
156    }
157
158    fn menu(&self) -> MenuSnapshot {
159        // Pure formatting of stored entries — `repo` was enriched at observe
160        // time, so `menu()` does no git I/O and honours the trait's "cheap, must
161        // not block" contract without a background cache (unlike worktrees).
162        MenuSnapshot {
163            title: SUBMENU_TITLE.to_string(),
164            items: menu_items_for(&self.registry.list()),
165        }
166    }
167
168    async fn menu_action(&self, action_id: &str) -> Result<()> {
169        if let Some(session_id) = action_id.strip_prefix("focus:") {
170            let folder = self.registry.focus_folder(session_id).ok_or_else(|| {
171                anyhow!("session {session_id} is not open in a known VS Code window")
172            })?;
173            focus_window(&folder)?;
174            return Ok(());
175        }
176        bail!("unknown sessions menu action: {action_id}")
177    }
178
179    async fn status(&self) -> ServiceStatus {
180        let sessions = self.registry.list();
181        let summary = status_summary(&sessions);
182        ServiceStatus {
183            name: SERVICE_NAME.to_string(),
184            healthy: true,
185            summary,
186            detail: json!({ "sessions": sessions }),
187        }
188    }
189
190    async fn shutdown(&self) {
191        // Stop the transcript watcher; the registry itself is in-memory with
192        // nothing to drain. Take the task out from under the lock first so the
193        // `std::Mutex` is never held across the `.await`.
194        let task = self
195            .watcher
196            .lock()
197            .unwrap_or_else(PoisonError::into_inner)
198            .take();
199        if let Some(task) = task {
200            task.token.cancel();
201            let _ = task.handle.await;
202        }
203    }
204}
205
206/// Extracts a required string `field` from an op payload, erroring with the op
207/// name when it is absent or not a string.
208fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
209    payload
210        .get(field)
211        .and_then(Value::as_str)
212        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
213}
214
215/// The repository name for a session's `cwd`, derived from the discovered
216/// repo's common dir (so a session inside a linked worktree names its parent
217/// repo, matching the worktrees enrichment). Best-effort: `None` when `cwd` is
218/// not inside a git repo. Pure disk I/O; called on a blocking thread.
219fn repo_name_for(cwd: &Path) -> Option<String> {
220    let repo = Repository::discover(cwd).ok()?;
221    main_repo_name(repo.commondir())
222}
223
224/// The main repository's directory name from git's common dir — the same
225/// derivation the worktrees adapter uses: for the `<repo>/.git` layout the
226/// working-tree directory's name, for a bare `<name>.git` that name with the
227/// suffix stripped.
228fn main_repo_name(commondir: &Path) -> Option<String> {
229    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
230    if file_name == ".git" {
231        commondir
232            .parent()
233            .and_then(Path::file_name)
234            .map(|n| n.to_string_lossy().into_owned())
235    } else {
236        Some(
237            file_name
238                .strip_suffix(".git")
239                .unwrap_or(&file_name)
240                .to_string(),
241        )
242    }
243}
244
245/// A one-line `status` summary: the live session count and a per-state tally.
246fn status_summary(sessions: &[SessionEntry]) -> String {
247    if sessions.is_empty() {
248        return "0 session(s)".to_string();
249    }
250    let mut working = 0;
251    let mut waiting = 0;
252    let mut idle = 0;
253    for s in sessions {
254        match s.state {
255            SessionState::Working | SessionState::Starting => working += 1,
256            SessionState::WaitingForInput | SessionState::WaitingForPermission => waiting += 1,
257            SessionState::Idle | SessionState::Ended => idle += 1,
258        }
259    }
260    format!(
261        "{} session(s): {working} working, {waiting} waiting, {idle} idle",
262        sessions.len()
263    )
264}
265
266/// A short glyph marking a session's state in the tray label.
267fn state_glyph(state: SessionState) -> &'static str {
268    match state {
269        SessionState::Starting => "…",
270        SessionState::Working => "⚙",
271        SessionState::Idle => "◦",
272        SessionState::WaitingForInput => "?",
273        SessionState::WaitingForPermission => "!",
274        SessionState::Ended => "×",
275    }
276}
277
278/// A short human name for a session: its repo, else its cwd basename, else the
279/// truncated session id.
280fn display_name(entry: &SessionEntry) -> String {
281    if let Some(repo) = &entry.repo {
282        return repo.clone();
283    }
284    if let Some(cwd) = &entry.cwd {
285        if let Some(name) = cwd.file_name() {
286            return name.to_string_lossy().into_owned();
287        }
288    }
289    // A session with neither repo nor cwd: a short id prefix is still a handle.
290    entry.session_id.chars().take(8).collect()
291}
292
293/// The tray items for the session set: a placeholder when empty, else one line
294/// per session (`<name> <glyph> <state>`). A session embedded in a VS Code window
295/// is clickable (its click focuses that window via the `focus:` action); a
296/// terminal session is a non-clickable status line, since the daemon has no
297/// window to focus.
298fn menu_items_for(sessions: &[SessionEntry]) -> Vec<MenuItem> {
299    use crate::sessions::Source;
300    if sessions.is_empty() {
301        return vec![MenuItem::Label("No active sessions".to_string())];
302    }
303    sessions
304        .iter()
305        .map(|entry| {
306            let label = format!(
307                "{} {} {}",
308                display_name(entry),
309                state_glyph(entry.state),
310                state_label(entry.state),
311            );
312            match &entry.source {
313                Source::VsCode { .. } => MenuItem::Action(MenuAction {
314                    id: format!("focus:{}", entry.session_id),
315                    label,
316                    enabled: true,
317                }),
318                Source::Terminal => MenuItem::Label(label),
319            }
320        })
321        .collect()
322}
323
324/// A short lowercase label for a session state, for the tray line and any
325/// human-readable rendering.
326fn state_label(state: SessionState) -> &'static str {
327    match state {
328        SessionState::Starting => "starting",
329        SessionState::Working => "working",
330        SessionState::Idle => "idle",
331        SessionState::WaitingForInput => "waiting",
332        SessionState::WaitingForPermission => "permission",
333        SessionState::Ended => "ended",
334    }
335}
336
337#[cfg(test)]
338#[allow(clippy::unwrap_used, clippy::expect_used)]
339mod tests {
340    use super::*;
341    use crate::sessions::{NotificationKind, SessionEvent, Source};
342    use std::path::PathBuf;
343
344    fn service() -> SessionsService {
345        SessionsService::new()
346    }
347
348    #[tokio::test]
349    async fn observe_then_list_round_trips() {
350        let svc = service();
351        let ok = svc
352            .handle(
353                "observe",
354                json!({ "session_id": "s1", "event": "session_start", "cwd": "/tmp/x" }),
355            )
356            .await
357            .unwrap();
358        assert_eq!(ok, json!({ "ok": true }));
359
360        let listed = svc.handle("list", Value::Null).await.unwrap();
361        let sessions = listed["sessions"].as_array().unwrap();
362        assert_eq!(sessions.len(), 1);
363        assert_eq!(sessions[0]["session_id"], "s1");
364        assert_eq!(sessions[0]["state"], "starting");
365    }
366
367    #[tokio::test]
368    async fn observe_rejects_blank_session_id() {
369        let svc = service();
370        let err = svc
371            .handle("observe", json!({ "session_id": "  ", "event": "stop" }))
372            .await
373            .unwrap_err();
374        assert!(err.to_string().contains("session_id"), "{err}");
375    }
376
377    #[tokio::test]
378    async fn end_marks_ended() {
379        let svc = service();
380        svc.handle(
381            "observe",
382            json!({ "session_id": "s1", "event": "pre_tool_use" }),
383        )
384        .await
385        .unwrap();
386        let reply = svc
387            .handle("end", json!({ "session_id": "s1", "reason": "clear" }))
388            .await
389            .unwrap();
390        assert_eq!(reply, json!({ "ended": true }));
391        // Ending an unknown session reports false, not an error.
392        let reply = svc
393            .handle("end", json!({ "session_id": "ghost" }))
394            .await
395            .unwrap();
396        assert_eq!(reply, json!({ "ended": false }));
397    }
398
399    #[tokio::test]
400    async fn window_report_tags_source_and_unregister_removes() {
401        let svc = service();
402        svc.handle(
403            "observe",
404            json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
405        )
406        .await
407        .unwrap();
408        svc.handle(
409            "window",
410            json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
411        )
412        .await
413        .unwrap();
414        let listed = svc.handle("list", Value::Null).await.unwrap();
415        assert_eq!(listed["sessions"][0]["source"]["kind"], "vs_code");
416        assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
417
418        let removed = svc
419            .handle("window-unregister", json!({ "key": "w1" }))
420            .await
421            .unwrap();
422        assert_eq!(removed, json!({ "removed": true }));
423        let listed = svc.handle("list", Value::Null).await.unwrap();
424        assert_eq!(listed["sessions"][0]["source"]["kind"], "terminal");
425    }
426
427    #[tokio::test]
428    async fn unknown_op_errors() {
429        let svc = service();
430        let err = svc.handle("frobnicate", Value::Null).await.unwrap_err();
431        assert!(err.to_string().contains("unknown sessions op"), "{err}");
432    }
433
434    #[tokio::test]
435    async fn status_summarizes_states() {
436        let svc = service();
437        svc.registry().observe(ObserveRequest {
438            session_id: "w".to_string(),
439            cwd: None,
440            transcript_path: None,
441            event: SessionEvent::PreToolUse,
442            repo: None,
443            model: None,
444        });
445        svc.registry().observe(ObserveRequest {
446            session_id: "p".to_string(),
447            cwd: None,
448            transcript_path: None,
449            event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
450            repo: None,
451            model: None,
452        });
453        let status = svc.status().await;
454        assert!(status.healthy);
455        assert!(
456            status.summary.contains("2 session(s)"),
457            "{}",
458            status.summary
459        );
460        assert!(status.summary.contains("1 working"), "{}", status.summary);
461        assert!(status.summary.contains("1 waiting"), "{}", status.summary);
462    }
463
464    #[test]
465    fn menu_items_placeholder_when_empty() {
466        let items = menu_items_for(&[]);
467        assert_eq!(items.len(), 1);
468        assert!(matches!(&items[0], MenuItem::Label(l) if l.contains("No active")));
469    }
470
471    #[test]
472    fn menu_item_is_clickable_only_for_vscode_sessions() {
473        let now = chrono::Utc::now();
474        let base = |source: Source| SessionEntry {
475            session_id: "sid-12345678".to_string(),
476            cwd: Some(PathBuf::from("/p")),
477            transcript_path: None,
478            repo: Some("proj".to_string()),
479            model: None,
480            state: SessionState::Working,
481            source,
482            last_event: SessionEvent::PreToolUse,
483            started_at: now,
484            last_seen: now,
485        };
486        // A terminal session is a non-clickable label.
487        let terminal = menu_items_for(&[base(Source::Terminal)]);
488        assert!(
489            matches!(&terminal[0], MenuItem::Label(l) if l.contains("proj") && l.contains("working"))
490        );
491        // A VS Code session is a clickable focus action.
492        let vscode = menu_items_for(&[base(Source::VsCode {
493            window_key: "w1".to_string(),
494        })]);
495        match &vscode[0] {
496            MenuItem::Action(a) => assert_eq!(a.id, "focus:sid-12345678"),
497            other => panic!("expected an action, got {other:?}"),
498        }
499    }
500
501    #[test]
502    fn repo_name_for_non_repo_is_none() {
503        // A path that is not inside any git repo enriches to no repo.
504        assert_eq!(repo_name_for(Path::new("/nonexistent/xyz")), None);
505    }
506
507    #[test]
508    fn main_repo_name_handles_layouts() {
509        assert_eq!(
510            main_repo_name(Path::new("/home/me/proj/.git")).as_deref(),
511            Some("proj")
512        );
513        assert_eq!(
514            main_repo_name(Path::new("/home/me/bare.git")).as_deref(),
515            Some("bare")
516        );
517    }
518
519    /// Builds a session entry for the pure menu/label tests.
520    fn entry(id: &str, state: SessionState, repo: Option<&str>, cwd: Option<&str>) -> SessionEntry {
521        let now = chrono::Utc::now();
522        SessionEntry {
523            session_id: id.to_string(),
524            cwd: cwd.map(PathBuf::from),
525            transcript_path: None,
526            repo: repo.map(str::to_string),
527            model: None,
528            state,
529            source: Source::Terminal,
530            last_event: SessionEvent::PreToolUse,
531            started_at: now,
532            last_seen: now,
533        }
534    }
535
536    #[test]
537    fn default_constructs_an_empty_service() {
538        let svc = SessionsService::default();
539        assert!(svc.registry().list().is_empty());
540    }
541
542    #[test]
543    fn start_watcher_is_a_noop_outside_a_runtime() {
544        // No tokio runtime → the watcher is not started, and shutdown is a no-op.
545        let svc = SessionsService::new();
546        svc.start_watcher();
547        assert!(svc
548            .watcher
549            .lock()
550            .unwrap_or_else(PoisonError::into_inner)
551            .is_none());
552    }
553
554    #[tokio::test]
555    async fn start_watcher_is_idempotent_and_shutdown_stops_it() {
556        let svc = SessionsService::new();
557        svc.start_watcher();
558        // A second call is a no-op (the guard is already set), not a second task.
559        svc.start_watcher();
560        assert!(svc
561            .watcher
562            .lock()
563            .unwrap_or_else(PoisonError::into_inner)
564            .is_some());
565        // Shutdown cancels and joins the task, clearing the slot.
566        svc.shutdown().await;
567        assert!(svc
568            .watcher
569            .lock()
570            .unwrap_or_else(PoisonError::into_inner)
571            .is_none());
572    }
573
574    #[test]
575    fn menu_renders_every_state_and_name_fallback() {
576        let sessions = vec![
577            entry("s1", SessionState::Starting, Some("repo-a"), Some("/a")),
578            entry("s2", SessionState::Working, None, Some("/home/me/proj")),
579            entry("s3", SessionState::Idle, None, None),
580            entry("s4", SessionState::WaitingForInput, Some("r"), Some("/b")),
581            entry(
582                "s5",
583                SessionState::WaitingForPermission,
584                Some("r"),
585                Some("/c"),
586            ),
587            entry("s6", SessionState::Ended, Some("r"), Some("/d")),
588        ];
589        let items = menu_items_for(&sessions);
590        assert_eq!(items.len(), 6);
591        // Every line is a non-clickable label (all terminal sessions) and carries
592        // the state label + a name (repo, else cwd basename, else id prefix).
593        let labels: Vec<&str> = items
594            .iter()
595            .map(|i| match i {
596                MenuItem::Label(l) => l.as_str(),
597                _ => panic!("terminal sessions render as labels"),
598            })
599            .collect();
600        assert!(labels[0].contains("repo-a") && labels[0].contains("starting"));
601        assert!(labels[1].contains("proj") && labels[1].contains("working")); // cwd basename
602        assert!(labels[2].contains("s3") && labels[2].contains("idle")); // id-prefix fallback
603        assert!(labels[3].contains("waiting"));
604        assert!(labels[4].contains("permission"));
605        assert!(labels[5].contains("ended"));
606    }
607
608    #[test]
609    fn menu_serves_the_snapshot_title() {
610        let svc = SessionsService::new();
611        svc.registry()
612            .observe(observe_req("s1", SessionEvent::Stop, None));
613        let snapshot = svc.menu();
614        assert_eq!(snapshot.title, SUBMENU_TITLE);
615        assert_eq!(snapshot.items.len(), 1);
616    }
617
618    /// A small `observe` builder for the adapter tests.
619    fn observe_req(id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
620        ObserveRequest {
621            session_id: id.to_string(),
622            cwd: cwd.map(PathBuf::from),
623            transcript_path: None,
624            event,
625            repo: None,
626            model: None,
627        }
628    }
629
630    #[tokio::test]
631    async fn menu_action_errors_on_unknown_and_missing_window() {
632        let svc = SessionsService::new();
633        // An unrecognised action id.
634        let err = svc.menu_action("frobnicate").await.unwrap_err();
635        assert!(
636            err.to_string().contains("unknown sessions menu action"),
637            "{err}"
638        );
639        // A focus of a session that resolves to no VS Code window folder.
640        let err = svc.menu_action("focus:nope").await.unwrap_err();
641        assert!(
642            err.to_string()
643                .contains("not open in a known VS Code window"),
644            "{err}"
645        );
646    }
647
648    #[test]
649    fn repo_name_for_resolves_a_real_repo() {
650        let tmp = tempfile::tempdir().unwrap();
651        let repo_dir = tmp.path().join("myrepo");
652        std::fs::create_dir(&repo_dir).unwrap();
653        git2::Repository::init(&repo_dir).unwrap();
654        // A path inside the repo enriches to the repo's directory name.
655        assert_eq!(repo_name_for(&repo_dir).as_deref(), Some("myrepo"));
656    }
657
658    #[tokio::test]
659    async fn status_summary_counts_idle_and_ended() {
660        let svc = SessionsService::new();
661        svc.registry()
662            .observe(observe_req("i", SessionEvent::Stop, None)); // idle
663        svc.registry().end("i2", None); // unknown → no-op
664        svc.registry()
665            .observe(observe_req("i2", SessionEvent::PreToolUse, None));
666        svc.registry().end("i2", Some("done")); // ended
667        let status = svc.status().await;
668        // Idle + ended both count toward the "idle" tally.
669        assert!(status.summary.contains("2 idle"), "{}", status.summary);
670    }
671}