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::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
34use crate::daemon::services::worktrees::focus_window;
35use crate::sessions::{ObserveRequest, SessionEntry, SessionState, SessionsRegistry, WindowReport};
36
37pub const SERVICE_NAME: &str = "sessions";
39
40const SUBMENU_TITLE: &str = "Claude Sessions";
42
43struct WatcherTask {
45 token: CancellationToken,
47 handle: JoinHandle<()>,
49}
50
51pub struct SessionsService {
53 registry: Arc<SessionsRegistry>,
56 watcher: Mutex<Option<WatcherTask>>,
59}
60
61impl SessionsService {
62 #[must_use]
66 pub fn new() -> Self {
67 Self {
68 registry: Arc::new(SessionsRegistry::new()),
69 watcher: Mutex::new(None),
70 }
71 }
72
73 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 #[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 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 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 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
206fn 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
215fn repo_name_for(cwd: &Path) -> Option<String> {
220 let repo = Repository::discover(cwd).ok()?;
221 main_repo_name(repo.commondir())
222}
223
224fn 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
245fn 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
266fn 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
278fn 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 entry.session_id.chars().take(8).collect()
291}
292
293fn 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
324fn 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 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 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 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 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 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 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 svc.start_watcher();
560 assert!(svc
561 .watcher
562 .lock()
563 .unwrap_or_else(PoisonError::into_inner)
564 .is_some());
565 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 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")); assert!(labels[2].contains("s3") && labels[2].contains("idle")); 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 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 let err = svc.menu_action("frobnicate").await.unwrap_err();
635 assert!(
636 err.to_string().contains("unknown sessions menu action"),
637 "{err}"
638 );
639 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 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)); svc.registry().end("i2", None); svc.registry()
665 .observe(observe_req("i2", SessionEvent::PreToolUse, None));
666 svc.registry().end("i2", Some("done")); let status = svc.status().await;
668 assert!(status.summary.contains("2 idle"), "{}", status.summary);
670 }
671}