1use 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
39pub const SERVICE_NAME: &str = "sessions";
41
42const SUBMENU_TITLE: &str = "Claude Sessions";
44
45struct WatcherTask {
47 token: CancellationToken,
49 handle: JoinHandle<()>,
51}
52
53pub struct SessionsService {
55 registry: Arc<SessionsRegistry>,
58 watcher: Mutex<Option<WatcherTask>>,
61}
62
63impl SessionsService {
64 #[must_use]
68 pub fn new() -> Self {
69 Self {
70 registry: Arc::new(SessionsRegistry::new()),
71 watcher: Mutex::new(None),
72 }
73 }
74
75 pub fn start_watcher(&self) {
83 if tokio::runtime::Handle::try_current().is_err() {
84 tracing::debug!("no tokio runtime; sessions transcript watcher not started");
85 return;
86 }
87 let mut guard = self.watcher.lock().unwrap_or_else(PoisonError::into_inner);
88 if guard.is_some() {
89 return;
90 }
91 let token = CancellationToken::new();
92 let handle = crate::sessions::watcher::spawn(self.registry.clone(), token.clone());
93 *guard = Some(WatcherTask { token, handle });
94 }
95
96 #[cfg(test)]
98 pub(crate) fn registry(&self) -> &Arc<SessionsRegistry> {
99 &self.registry
100 }
101}
102
103impl Default for SessionsService {
104 fn default() -> Self {
105 Self::new()
106 }
107}
108
109#[async_trait]
110impl DaemonService for SessionsService {
111 fn name(&self) -> &'static str {
112 SERVICE_NAME
113 }
114
115 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
116 match op {
117 "observe" => {
118 let mut req: ObserveRequest =
119 serde_json::from_value(payload).context("invalid `observe` payload")?;
120 if req.session_id.trim().is_empty() {
121 bail!("`observe` requires a non-empty `session_id`");
122 }
123 if req.repo.is_none() {
128 if let Some(cwd) = req.cwd.clone() {
129 req.repo = tokio::task::spawn_blocking(move || repo_name_for(&cwd))
130 .await
131 .unwrap_or_default();
132 }
133 }
134 self.registry.observe(req);
135 Ok(json!({ "ok": true }))
136 }
137 "end" => {
138 let session_id = require_str(&payload, "session_id", "end")?;
139 let reason = payload.get("reason").and_then(Value::as_str);
140 Ok(json!({ "ended": self.registry.end(session_id, reason) }))
141 }
142 "window" => {
143 let req: WindowReport =
144 serde_json::from_value(payload).context("invalid `window` payload")?;
145 if req.key.trim().is_empty() {
146 bail!("`window` requires a non-empty `key`");
147 }
148 self.registry.report_window(req);
149 Ok(json!({ "ok": true }))
150 }
151 "window-unregister" => {
152 let key = require_str(&payload, "key", "window-unregister")?;
153 Ok(json!({ "removed": self.registry.unregister_window(key) }))
154 }
155 "list" => Ok(sessions_payload(&self.registry)),
156 other => bail!("unknown sessions op: {other}"),
157 }
158 }
159
160 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
161 if op != "subscribe" {
165 return None;
166 }
167 Some(Box::new(SessionsStream {
168 registry: self.registry.clone(),
169 changes: self.registry.subscribe_changes(),
172 }))
173 }
174
175 fn menu(&self) -> MenuSnapshot {
176 MenuSnapshot {
180 title: SUBMENU_TITLE.to_string(),
181 items: menu_items_for(&self.registry.list()),
182 }
183 }
184
185 async fn menu_action(&self, action_id: &str) -> Result<()> {
186 if let Some(session_id) = action_id.strip_prefix("focus:") {
187 let folder = self.registry.focus_folder(session_id).ok_or_else(|| {
188 anyhow!("session {session_id} is not open in a known VS Code window")
189 })?;
190 focus_window(&folder)?;
191 return Ok(());
192 }
193 bail!("unknown sessions menu action: {action_id}")
194 }
195
196 async fn status(&self) -> ServiceStatus {
197 let sessions = self.registry.list();
198 let summary = status_summary(&sessions);
199 ServiceStatus {
200 name: SERVICE_NAME.to_string(),
201 healthy: true,
202 summary,
203 detail: json!({ "sessions": sessions }),
204 }
205 }
206
207 async fn shutdown(&self) {
208 let task = self
212 .watcher
213 .lock()
214 .unwrap_or_else(PoisonError::into_inner)
215 .take();
216 if let Some(task) = task {
217 task.token.cancel();
218 let _ = task.handle.await;
219 }
220 }
221}
222
223fn sessions_payload(registry: &SessionsRegistry) -> Value {
227 json!({ "sessions": registry.list() })
228}
229
230struct SessionsStream {
238 registry: Arc<SessionsRegistry>,
240 changes: tokio::sync::watch::Receiver<u64>,
245}
246
247#[async_trait]
248impl ServiceStream for SessionsStream {
249 async fn changed(&mut self) {
250 if self.changes.changed().await.is_err() {
256 std::future::pending::<()>().await;
257 }
258 }
259
260 async fn snapshot(&self) -> Value {
261 sessions_payload(&self.registry)
262 }
263}
264
265fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
268 payload
269 .get(field)
270 .and_then(Value::as_str)
271 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
272}
273
274fn repo_name_for(cwd: &Path) -> Option<String> {
279 let repo = Repository::discover(cwd).ok()?;
280 main_repo_name(repo.commondir())
281}
282
283fn main_repo_name(commondir: &Path) -> Option<String> {
288 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
289 if file_name == ".git" {
290 commondir
291 .parent()
292 .and_then(Path::file_name)
293 .map(|n| n.to_string_lossy().into_owned())
294 } else {
295 Some(
296 file_name
297 .strip_suffix(".git")
298 .unwrap_or(&file_name)
299 .to_string(),
300 )
301 }
302}
303
304fn status_summary(sessions: &[SessionEntry]) -> String {
306 if sessions.is_empty() {
307 return "0 session(s)".to_string();
308 }
309 let mut working = 0;
310 let mut waiting = 0;
311 let mut idle = 0;
312 for s in sessions {
313 match s.state {
314 SessionState::Working | SessionState::Starting => working += 1,
315 SessionState::WaitingForInput | SessionState::WaitingForPermission => waiting += 1,
316 SessionState::Idle | SessionState::Ended => idle += 1,
317 }
318 }
319 format!(
320 "{} session(s): {working} working, {waiting} waiting, {idle} idle",
321 sessions.len()
322 )
323}
324
325fn state_glyph(state: SessionState) -> &'static str {
327 match state {
328 SessionState::Starting => "…",
329 SessionState::Working => "⚙",
330 SessionState::Idle => "◦",
331 SessionState::WaitingForInput => "?",
332 SessionState::WaitingForPermission => "!",
333 SessionState::Ended => "×",
334 }
335}
336
337fn display_name(entry: &SessionEntry) -> String {
340 if let Some(repo) = &entry.repo {
341 return repo.clone();
342 }
343 if let Some(cwd) = &entry.cwd {
344 if let Some(name) = cwd.file_name() {
345 return name.to_string_lossy().into_owned();
346 }
347 }
348 entry.session_id.chars().take(8).collect()
350}
351
352fn menu_items_for(sessions: &[SessionEntry]) -> Vec<MenuItem> {
358 use crate::sessions::Source;
359 if sessions.is_empty() {
360 return vec![MenuItem::Label("No active sessions".to_string())];
361 }
362 sessions
363 .iter()
364 .map(|entry| {
365 let label = format!(
366 "{} {} {}",
367 display_name(entry),
368 state_glyph(entry.state),
369 state_label(entry.state),
370 );
371 match &entry.source {
372 Source::VsCode { .. } => MenuItem::Action(MenuAction {
373 id: format!("focus:{}", entry.session_id),
374 label,
375 enabled: true,
376 }),
377 Source::Terminal => MenuItem::Label(label),
378 }
379 })
380 .collect()
381}
382
383fn state_label(state: SessionState) -> &'static str {
386 match state {
387 SessionState::Starting => "starting",
388 SessionState::Working => "working",
389 SessionState::Idle => "idle",
390 SessionState::WaitingForInput => "waiting",
391 SessionState::WaitingForPermission => "permission",
392 SessionState::Ended => "ended",
393 }
394}
395
396#[cfg(test)]
397#[allow(clippy::unwrap_used, clippy::expect_used)]
398mod tests {
399 use super::*;
400 use crate::sessions::{NotificationKind, SessionEvent, Source};
401 use std::path::PathBuf;
402
403 fn service() -> SessionsService {
404 SessionsService::new()
405 }
406
407 #[tokio::test]
408 async fn observe_then_list_round_trips() {
409 let svc = service();
410 let ok = svc
411 .handle(
412 "observe",
413 json!({ "session_id": "s1", "event": "session_start", "cwd": "/tmp/x" }),
414 )
415 .await
416 .unwrap();
417 assert_eq!(ok, json!({ "ok": true }));
418
419 let listed = svc.handle("list", Value::Null).await.unwrap();
420 let sessions = listed["sessions"].as_array().unwrap();
421 assert_eq!(sessions.len(), 1);
422 assert_eq!(sessions[0]["session_id"], "s1");
423 assert_eq!(sessions[0]["state"], "starting");
424 }
425
426 #[tokio::test]
427 async fn observe_rejects_blank_session_id() {
428 let svc = service();
429 let err = svc
430 .handle("observe", json!({ "session_id": " ", "event": "stop" }))
431 .await
432 .unwrap_err();
433 assert!(err.to_string().contains("session_id"), "{err}");
434 }
435
436 #[tokio::test]
437 async fn end_marks_ended() {
438 let svc = service();
439 svc.handle(
440 "observe",
441 json!({ "session_id": "s1", "event": "pre_tool_use" }),
442 )
443 .await
444 .unwrap();
445 let reply = svc
446 .handle("end", json!({ "session_id": "s1", "reason": "clear" }))
447 .await
448 .unwrap();
449 assert_eq!(reply, json!({ "ended": true }));
450 let reply = svc
452 .handle("end", json!({ "session_id": "ghost" }))
453 .await
454 .unwrap();
455 assert_eq!(reply, json!({ "ended": false }));
456 }
457
458 #[tokio::test]
459 async fn window_report_tags_source_and_unregister_removes() {
460 let svc = service();
461 svc.handle(
462 "observe",
463 json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
464 )
465 .await
466 .unwrap();
467 svc.handle(
468 "window",
469 json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
470 )
471 .await
472 .unwrap();
473 let listed = svc.handle("list", Value::Null).await.unwrap();
474 assert_eq!(listed["sessions"][0]["source"]["kind"], "vs_code");
475 assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
476
477 let removed = svc
478 .handle("window-unregister", json!({ "key": "w1" }))
479 .await
480 .unwrap();
481 assert_eq!(removed, json!({ "removed": true }));
482 let listed = svc.handle("list", Value::Null).await.unwrap();
483 assert_eq!(listed["sessions"][0]["source"]["kind"], "terminal");
484 }
485
486 #[tokio::test]
487 async fn unknown_op_errors() {
488 let svc = service();
489 let err = svc.handle("frobnicate", Value::Null).await.unwrap_err();
490 assert!(err.to_string().contains("unknown sessions op"), "{err}");
491 }
492
493 #[tokio::test]
494 async fn status_summarizes_states() {
495 let svc = service();
496 svc.registry().observe(ObserveRequest {
497 session_id: "w".to_string(),
498 cwd: None,
499 transcript_path: None,
500 event: SessionEvent::PreToolUse,
501 repo: None,
502 model: None,
503 });
504 svc.registry().observe(ObserveRequest {
505 session_id: "p".to_string(),
506 cwd: None,
507 transcript_path: None,
508 event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
509 repo: None,
510 model: None,
511 });
512 let status = svc.status().await;
513 assert!(status.healthy);
514 assert!(
515 status.summary.contains("2 session(s)"),
516 "{}",
517 status.summary
518 );
519 assert!(status.summary.contains("1 working"), "{}", status.summary);
520 assert!(status.summary.contains("1 waiting"), "{}", status.summary);
521 }
522
523 #[test]
524 fn menu_items_placeholder_when_empty() {
525 let items = menu_items_for(&[]);
526 assert_eq!(items.len(), 1);
527 assert!(matches!(&items[0], MenuItem::Label(l) if l.contains("No active")));
528 }
529
530 #[test]
531 fn menu_item_is_clickable_only_for_vscode_sessions() {
532 let now = chrono::Utc::now();
533 let base = |source: Source| SessionEntry {
534 session_id: "sid-12345678".to_string(),
535 cwd: Some(PathBuf::from("/p")),
536 transcript_path: None,
537 repo: Some("proj".to_string()),
538 model: None,
539 state: SessionState::Working,
540 source,
541 last_event: SessionEvent::PreToolUse,
542 started_at: now,
543 last_seen: now,
544 };
545 let terminal = menu_items_for(&[base(Source::Terminal)]);
547 assert!(
548 matches!(&terminal[0], MenuItem::Label(l) if l.contains("proj") && l.contains("working"))
549 );
550 let vscode = menu_items_for(&[base(Source::VsCode {
552 window_key: "w1".to_string(),
553 })]);
554 match &vscode[0] {
555 MenuItem::Action(a) => assert_eq!(a.id, "focus:sid-12345678"),
556 other => panic!("expected an action, got {other:?}"),
557 }
558 }
559
560 #[test]
561 fn repo_name_for_non_repo_is_none() {
562 assert_eq!(repo_name_for(Path::new("/nonexistent/xyz")), None);
564 }
565
566 #[test]
567 fn main_repo_name_handles_layouts() {
568 assert_eq!(
569 main_repo_name(Path::new("/home/me/proj/.git")).as_deref(),
570 Some("proj")
571 );
572 assert_eq!(
573 main_repo_name(Path::new("/home/me/bare.git")).as_deref(),
574 Some("bare")
575 );
576 }
577
578 fn entry(id: &str, state: SessionState, repo: Option<&str>, cwd: Option<&str>) -> SessionEntry {
580 let now = chrono::Utc::now();
581 SessionEntry {
582 session_id: id.to_string(),
583 cwd: cwd.map(PathBuf::from),
584 transcript_path: None,
585 repo: repo.map(str::to_string),
586 model: None,
587 state,
588 source: Source::Terminal,
589 last_event: SessionEvent::PreToolUse,
590 started_at: now,
591 last_seen: now,
592 }
593 }
594
595 #[test]
596 fn default_constructs_an_empty_service() {
597 let svc = SessionsService::default();
598 assert!(svc.registry().list().is_empty());
599 }
600
601 #[test]
602 fn start_watcher_is_a_noop_outside_a_runtime() {
603 let svc = SessionsService::new();
605 svc.start_watcher();
606 assert!(svc
607 .watcher
608 .lock()
609 .unwrap_or_else(PoisonError::into_inner)
610 .is_none());
611 }
612
613 #[tokio::test]
614 async fn start_watcher_is_idempotent_and_shutdown_stops_it() {
615 let svc = SessionsService::new();
616 svc.start_watcher();
617 svc.start_watcher();
619 assert!(svc
620 .watcher
621 .lock()
622 .unwrap_or_else(PoisonError::into_inner)
623 .is_some());
624 svc.shutdown().await;
626 assert!(svc
627 .watcher
628 .lock()
629 .unwrap_or_else(PoisonError::into_inner)
630 .is_none());
631 }
632
633 #[test]
634 fn menu_renders_every_state_and_name_fallback() {
635 let sessions = vec![
636 entry("s1", SessionState::Starting, Some("repo-a"), Some("/a")),
637 entry("s2", SessionState::Working, None, Some("/home/me/proj")),
638 entry("s3", SessionState::Idle, None, None),
639 entry("s4", SessionState::WaitingForInput, Some("r"), Some("/b")),
640 entry(
641 "s5",
642 SessionState::WaitingForPermission,
643 Some("r"),
644 Some("/c"),
645 ),
646 entry("s6", SessionState::Ended, Some("r"), Some("/d")),
647 ];
648 let items = menu_items_for(&sessions);
649 assert_eq!(items.len(), 6);
650 let labels: Vec<&str> = items
653 .iter()
654 .map(|i| match i {
655 MenuItem::Label(l) => l.as_str(),
656 _ => panic!("terminal sessions render as labels"),
657 })
658 .collect();
659 assert!(labels[0].contains("repo-a") && labels[0].contains("starting"));
660 assert!(labels[1].contains("proj") && labels[1].contains("working")); assert!(labels[2].contains("s3") && labels[2].contains("idle")); assert!(labels[3].contains("waiting"));
663 assert!(labels[4].contains("permission"));
664 assert!(labels[5].contains("ended"));
665 }
666
667 #[test]
668 fn menu_serves_the_snapshot_title() {
669 let svc = SessionsService::new();
670 svc.registry()
671 .observe(observe_req("s1", SessionEvent::Stop, None));
672 let snapshot = svc.menu();
673 assert_eq!(snapshot.title, SUBMENU_TITLE);
674 assert_eq!(snapshot.items.len(), 1);
675 }
676
677 fn observe_req(id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
679 ObserveRequest {
680 session_id: id.to_string(),
681 cwd: cwd.map(PathBuf::from),
682 transcript_path: None,
683 event,
684 repo: None,
685 model: None,
686 }
687 }
688
689 #[tokio::test]
690 async fn menu_action_errors_on_unknown_and_missing_window() {
691 let svc = SessionsService::new();
692 let err = svc.menu_action("frobnicate").await.unwrap_err();
694 assert!(
695 err.to_string().contains("unknown sessions menu action"),
696 "{err}"
697 );
698 let err = svc.menu_action("focus:nope").await.unwrap_err();
700 assert!(
701 err.to_string()
702 .contains("not open in a known VS Code window"),
703 "{err}"
704 );
705 }
706
707 #[test]
708 fn repo_name_for_resolves_a_real_repo() {
709 let tmp = tempfile::tempdir().unwrap();
710 let repo_dir = tmp.path().join("myrepo");
711 std::fs::create_dir(&repo_dir).unwrap();
712 git2::Repository::init(&repo_dir).unwrap();
713 assert_eq!(repo_name_for(&repo_dir).as_deref(), Some("myrepo"));
715 }
716
717 #[tokio::test]
718 async fn status_summary_counts_idle_and_ended() {
719 let svc = SessionsService::new();
720 svc.registry()
721 .observe(observe_req("i", SessionEvent::Stop, None)); svc.registry().end("i2", None); svc.registry()
724 .observe(observe_req("i2", SessionEvent::PreToolUse, None));
725 svc.registry().end("i2", Some("done")); let status = svc.status().await;
727 assert!(status.summary.contains("2 idle"), "{}", status.summary);
729 }
730
731 #[tokio::test]
734 async fn subscribe_streams_only_for_the_subscribe_op() {
735 let svc = service();
736 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
739 assert!(svc.subscribe("list", &Value::Null).is_none());
740 assert!(svc.subscribe("observe", &Value::Null).is_none());
741 assert!(svc.subscribe("window", &Value::Null).is_none());
742 assert!(svc.subscribe("bogus", &Value::Null).is_none());
743 }
744
745 #[tokio::test]
746 async fn subscribe_snapshot_matches_the_list_op() {
747 let svc = service();
748 let stream = svc
749 .subscribe("subscribe", &Value::Null)
750 .expect("subscribe stream");
751 assert_eq!(stream.snapshot().await, json!({ "sessions": [] }));
753
754 svc.handle(
755 "observe",
756 json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/tmp/x" }),
757 )
758 .await
759 .unwrap();
760 let snap = stream.snapshot().await;
763 let listed = svc.handle("list", Value::Null).await.unwrap();
764 assert_eq!(snap, listed);
765 assert_eq!(snap["sessions"][0]["state"], json!("working"));
766 }
767
768 #[tokio::test]
769 async fn subscribe_changed_wakes_on_a_visible_change() {
770 let svc = service();
771 let mut stream = svc
772 .subscribe("subscribe", &Value::Null)
773 .expect("subscribe stream");
774 tokio::select! {
776 () = stream.changed() => panic!("changed resolved with no registry change"),
777 () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
778 }
779 svc.handle(
781 "observe",
782 json!({ "session_id": "s1", "event": "session_start" }),
783 )
784 .await
785 .unwrap();
786 tokio::time::timeout(std::time::Duration::from_secs(2), stream.changed())
787 .await
788 .expect("changed should resolve after a visible change");
789 }
790
791 #[tokio::test]
792 async fn changed_parks_when_every_sender_is_gone() {
793 let (tx, rx) = tokio::sync::watch::channel(0u64);
798 let mut stream = SessionsStream {
799 registry: Arc::new(SessionsRegistry::new()),
800 changes: rx,
801 };
802 drop(tx);
803 tokio::select! {
804 () = stream.changed() => panic!("changed resolved after the sender was dropped"),
805 () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
806 }
807 }
808}