1use crate::hook_state::{HookSessionStatus, LiveSessionFile};
10use anyhow::{Context, Result};
11use chrono::{DateTime, Local, TimeZone};
12use std::process::Command;
13
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
16pub enum SessionType {
17 #[default]
19 Cli,
20 VsCode,
22 Subagent,
24}
25
26impl SessionType {
27 pub fn label(&self) -> &'static str {
28 match self {
29 SessionType::Cli => "CLI",
30 SessionType::VsCode => "IDE",
31 SessionType::Subagent => "Agent",
32 }
33 }
34}
35
36struct ParsedFlags {
38 session_type: SessionType,
39 model: Option<String>,
40 resume_id: Option<String>,
41}
42
43fn extract_flag_value(command: &str, flag: &str) -> Option<String> {
46 let tokens: Vec<&str> = command.split_whitespace().collect();
47 for i in 0..tokens.len().saturating_sub(1) {
48 if tokens[i] == flag {
49 return Some(tokens[i + 1].to_string());
50 }
51 }
52 None
53}
54
55fn parse_claude_flags(command: &str) -> ParsedFlags {
57 let has_stream_json = command.contains("stream-json");
58 let has_stdio_tool = command.contains("permission-prompt-tool") && command.contains("stdio");
59
60 let session_type = if has_stream_json && has_stdio_tool {
61 SessionType::VsCode
62 } else if has_stream_json {
63 SessionType::Subagent
64 } else {
65 SessionType::Cli
66 };
67
68 let model = extract_flag_value(command, "--model");
69 let resume_id = extract_flag_value(command, "--resume");
70
71 ParsedFlags {
72 session_type,
73 model,
74 resume_id,
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct LiveSession {
81 pub pid: u32,
83 pub start_time: DateTime<Local>,
85 pub working_directory: Option<String>,
87 pub command: String,
89 pub cpu_percent: f64,
91 pub memory_mb: u64,
93 pub tokens: Option<u64>,
95 pub session_id: Option<String>,
97 pub session_name: Option<String>,
99 pub session_type: SessionType,
101 pub model: Option<String>,
103 pub resume_id: Option<String>,
105}
106
107pub fn detect_live_sessions() -> Result<Vec<LiveSession>> {
117 #[cfg(unix)]
118 {
119 detect_live_sessions_unix()
120 }
121
122 #[cfg(windows)]
123 {
124 detect_live_sessions_windows()
125 }
126}
127
128#[cfg(unix)]
129fn detect_live_sessions_unix() -> Result<Vec<LiveSession>> {
130 let output = Command::new("ps")
132 .args(["aux"])
133 .output()
134 .context("Failed to run ps command")?;
135
136 if !output.status.success() {
137 return Ok(vec![]);
138 }
139
140 let stdout = String::from_utf8_lossy(&output.stdout);
141 let sessions: Vec<LiveSession> = stdout
142 .lines()
143 .filter(|line| is_claude_process_line(line))
144 .filter_map(parse_ps_line)
145 .collect();
146
147 Ok(sessions)
148}
149
150#[cfg(unix)]
155fn is_claude_process_line(line: &str) -> bool {
156 if line.contains("grep") || line.contains("ccboard") {
157 return false;
158 }
159 let mut fields = line.split_whitespace();
162 for _ in 0..10 {
163 if fields.next().is_none() {
164 return false;
165 }
166 }
167 let binary = fields.next().unwrap_or("");
169 let base = binary.rsplit('/').next().unwrap_or(binary);
170 base == "claude" || base == "claude-code"
171}
172
173#[cfg(unix)]
174fn parse_ps_line(line: &str) -> Option<LiveSession> {
175 let parts: Vec<&str> = line.split_whitespace().collect();
179 if parts.len() < 11 {
180 return None;
181 }
182
183 let pid = parts[1].parse::<u32>().ok()?;
184 let cpu_percent = parts[2].parse::<f64>().unwrap_or(0.0);
185 let memory_mb = parts[5].parse::<u64>().unwrap_or(0) / 1024; let start_str = parts[8]; let command = parts[10..].join(" ");
188
189 let flags = parse_claude_flags(&command);
191
192 let start_time = parse_start_time(start_str).unwrap_or_else(Local::now);
194
195 let working_directory = get_cwd_for_pid(pid);
197
198 let session_metadata = get_session_metadata(&working_directory);
200
201 Some(LiveSession {
202 pid,
203 start_time,
204 working_directory,
205 command,
206 cpu_percent,
207 memory_mb,
208 tokens: session_metadata.as_ref().and_then(|m| m.tokens),
209 session_id: session_metadata.as_ref().and_then(|m| m.session_id.clone()),
210 session_name: session_metadata
211 .as_ref()
212 .and_then(|m| m.session_name.clone()),
213 session_type: flags.session_type,
214 model: flags.model,
215 resume_id: flags.resume_id,
216 })
217}
218
219#[cfg(unix)]
220fn parse_start_time(start_str: &str) -> Option<DateTime<Local>> {
221 if start_str.contains(':') {
229 let parts: Vec<&str> = start_str.split(':').collect();
231 if parts.len() == 2 {
232 if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
233 let now = Local::now();
234 return now
235 .date_naive()
236 .and_hms_opt(hour, minute, 0)
237 .and_then(|dt| Local.from_local_datetime(&dt).single());
238 }
239 }
240 }
241
242 None
244}
245
246#[cfg(unix)]
247fn get_cwd_for_pid(pid: u32) -> Option<String> {
248 #[cfg(target_os = "linux")]
250 {
251 std::fs::read_link(format!("/proc/{}/cwd", pid))
253 .ok()
254 .and_then(|p| p.to_str().map(String::from))
255 }
256
257 #[cfg(target_os = "macos")]
258 {
259 let output = Command::new("lsof")
261 .args(["-p", &pid.to_string(), "-a", "-d", "cwd", "-Fn"])
262 .output()
263 .ok()?;
264
265 let stdout = String::from_utf8_lossy(&output.stdout);
266 stdout
268 .lines()
269 .find(|line| line.starts_with('n'))
270 .and_then(|line| line.strip_prefix('n'))
271 .map(String::from)
272 }
273
274 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
275 {
276 None
278 }
279}
280
281#[cfg(windows)]
282fn detect_live_sessions_windows() -> Result<Vec<LiveSession>> {
283 let output = Command::new("tasklist")
285 .args(&["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"])
286 .output()
287 .context("Failed to run tasklist command")?;
288
289 if !output.status.success() {
290 return Ok(vec![]);
291 }
292
293 let stdout = String::from_utf8_lossy(&output.stdout);
294 let sessions: Vec<LiveSession> = stdout
295 .lines()
296 .filter(|line| !line.is_empty())
297 .filter_map(|line| parse_tasklist_csv(line))
298 .collect();
299
300 Ok(sessions)
301}
302
303#[cfg(windows)]
304fn parse_tasklist_csv(line: &str) -> Option<LiveSession> {
305 let parts: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
308 if parts.len() < 2 {
309 return None;
310 }
311
312 let pid = parts[1].parse::<u32>().ok()?;
313 let command = parts[0].to_string();
314
315 let start_time = Local::now();
318 let working_directory = None; Some(LiveSession {
321 pid,
322 start_time,
323 working_directory,
324 command,
325 cpu_percent: 0.0,
326 memory_mb: 0,
327 tokens: None,
328 session_id: None,
329 session_name: None,
330 session_type: SessionType::Cli,
331 model: None,
332 resume_id: None,
333 })
334}
335
336struct LiveSessionMetadata {
338 tokens: Option<u64>,
339 session_id: Option<String>,
340 session_name: Option<String>,
341}
342
343fn get_session_metadata(working_directory: &Option<String>) -> Option<LiveSessionMetadata> {
356 let cwd = working_directory.as_ref()?;
357
358 let encoded = cwd.replace('/', "-");
361
362 let home = dirs::home_dir()?;
364 let sessions_dir = home.join(".claude").join("projects").join(&encoded);
365
366 if !sessions_dir.exists() {
367 return None;
368 }
369
370 let mut entries: Vec<_> = std::fs::read_dir(&sessions_dir)
372 .ok()?
373 .filter_map(|e| e.ok())
374 .filter(|e| {
375 e.path()
376 .extension()
377 .and_then(|s| s.to_str())
378 .map(|s| s == "jsonl")
379 .unwrap_or(false)
380 })
381 .collect();
382
383 entries.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
384 let latest = entries.last()?.path();
385
386 let session_id = latest
388 .file_stem()
389 .and_then(|s| s.to_str())
390 .map(String::from);
391
392 let file = std::fs::File::open(latest).ok()?;
394 let reader = std::io::BufReader::new(file);
395 let mut total_tokens = 0u64;
396 let mut session_name: Option<String> = None;
397
398 for line in std::io::BufRead::lines(reader) {
399 let Ok(line) = line else { continue };
401
402 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
403 if session_name.is_none() {
405 if let Some(event_type) = json.get("type").and_then(|v| v.as_str()) {
406 if event_type == "session_start" {
407 session_name = json.get("name").and_then(|v| v.as_str()).map(String::from);
408 }
409 }
410 }
411
412 if let Some(message) = json.get("message") {
414 if let Some(usage) = message.get("usage") {
415 if let Some(input) = usage.get("input_tokens").and_then(|v| v.as_u64()) {
417 total_tokens += input;
418 }
419 if let Some(output) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
420 total_tokens += output;
421 }
422 if let Some(cache_write) = usage
426 .get("cache_creation_input_tokens")
427 .and_then(|v| v.as_u64())
428 {
429 total_tokens += cache_write;
430 }
431 if let Some(cache_read) = usage
432 .get("cache_read_input_tokens")
433 .and_then(|v| v.as_u64())
434 {
435 total_tokens += cache_read;
436 }
437 }
438 }
439 }
440 }
441
442 Some(LiveSessionMetadata {
443 tokens: if total_tokens > 0 {
444 Some(total_tokens)
445 } else {
446 None
447 },
448 session_id,
449 session_name,
450 })
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum LiveSessionDisplayStatus {
460 Running,
462 WaitingInput,
464 Stopped,
466 ProcessOnly,
468 Unknown,
470}
471
472impl LiveSessionDisplayStatus {
473 pub fn icon(&self) -> &'static str {
475 match self {
476 LiveSessionDisplayStatus::Running => "●",
477 LiveSessionDisplayStatus::WaitingInput => "◐",
478 LiveSessionDisplayStatus::Stopped => "✓",
479 LiveSessionDisplayStatus::ProcessOnly => "🟢",
480 LiveSessionDisplayStatus::Unknown => "?",
481 }
482 }
483}
484
485#[derive(Debug, Clone)]
487pub struct MergedLiveSession {
488 pub session_id: Option<String>,
490 pub cwd: String,
492 pub tty: Option<String>,
494 pub hook_status: Option<HookSessionStatus>,
496 pub process: Option<LiveSession>,
498 pub last_event_at: Option<DateTime<Local>>,
500 pub last_event: Option<String>,
502}
503
504impl MergedLiveSession {
505 pub fn effective_status(&self) -> LiveSessionDisplayStatus {
507 match self.hook_status {
508 Some(HookSessionStatus::Running) => LiveSessionDisplayStatus::Running,
509 Some(HookSessionStatus::WaitingInput) => LiveSessionDisplayStatus::WaitingInput,
510 Some(HookSessionStatus::Stopped) => LiveSessionDisplayStatus::Stopped,
511 Some(HookSessionStatus::Unknown) | None => {
512 if self.process.is_some() {
513 LiveSessionDisplayStatus::ProcessOnly
514 } else {
515 LiveSessionDisplayStatus::Unknown
516 }
517 }
518 }
519 }
520
521 pub fn project_name(&self) -> &str {
523 std::path::Path::new(&self.cwd)
524 .file_name()
525 .and_then(|n| n.to_str())
526 .unwrap_or(&self.cwd)
527 }
528}
529
530pub fn merge_live_sessions(
540 hook_file: &LiveSessionFile,
541 ps_sessions: &[LiveSession],
542) -> Vec<MergedLiveSession> {
543 let mut result: Vec<MergedLiveSession> = Vec::new();
544 let mut matched_ps: Vec<bool> = vec![false; ps_sessions.len()];
545
546 for hook_session in hook_file.sessions.values() {
548 let mut matched_ps_idx: Option<usize> = None;
549
550 if matched_ps_idx.is_none() {
552 for (i, ps) in ps_sessions.iter().enumerate() {
553 if matched_ps[i] {
554 continue;
555 }
556 if ps
557 .session_id
558 .as_deref()
559 .map(|id| id == hook_session.session_id)
560 .unwrap_or(false)
561 {
562 matched_ps_idx = Some(i);
563 break;
564 }
565 }
566 }
567
568 if matched_ps_idx.is_none() && hook_session.tty != "unknown" {
570 let hook_tty_base = std::path::Path::new(&hook_session.tty)
571 .file_name()
572 .and_then(|n| n.to_str())
573 .unwrap_or(&hook_session.tty);
574
575 for (i, ps) in ps_sessions.iter().enumerate() {
576 if matched_ps[i] {
577 continue;
578 }
579
580 if ps
582 .working_directory
583 .as_deref()
584 .map(|wd| wd == hook_session.cwd)
585 .unwrap_or(false)
586 {
587 matched_ps_idx = Some(i);
588 break;
589 }
590
591 if ps.command.contains(hook_tty_base) {
593 matched_ps_idx = Some(i);
594 break;
595 }
596 }
597 }
598
599 if let Some(idx) = matched_ps_idx {
600 matched_ps[idx] = true;
601 let ps = &ps_sessions[idx];
602 result.push(MergedLiveSession {
603 session_id: Some(hook_session.session_id.clone()),
604 cwd: hook_session.cwd.clone(),
605 tty: Some(hook_session.tty.clone()),
606 hook_status: Some(hook_session.status),
607 process: Some(ps.clone()),
608 last_event_at: Some(hook_session.updated_at.with_timezone(&Local)),
609 last_event: Some(hook_session.last_event.clone()),
610 });
611 } else {
612 result.push(MergedLiveSession {
614 session_id: Some(hook_session.session_id.clone()),
615 cwd: hook_session.cwd.clone(),
616 tty: Some(hook_session.tty.clone()),
617 hook_status: Some(hook_session.status),
618 process: None,
619 last_event_at: Some(hook_session.updated_at.with_timezone(&Local)),
620 last_event: Some(hook_session.last_event.clone()),
621 });
622 }
623 }
624
625 for (i, ps) in ps_sessions.iter().enumerate() {
627 if !matched_ps[i] {
628 result.push(MergedLiveSession {
629 session_id: ps.session_id.clone(),
630 cwd: ps
631 .working_directory
632 .clone()
633 .unwrap_or_else(|| "unknown".to_string()),
634 tty: None,
635 hook_status: None,
636 process: Some(ps.clone()),
637 last_event_at: None,
638 last_event: None,
639 });
640 }
641 }
642
643 result
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 #[cfg(unix)]
652 fn test_parse_ps_line() {
653 let line = "user 12345 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 /usr/local/bin/claude --session foo";
654 let session = parse_ps_line(line).expect("Failed to parse valid ps line");
655 assert_eq!(session.pid, 12345);
656 assert!(session.command.contains("claude"));
657 }
658
659 #[test]
660 #[cfg(unix)]
661 fn test_parse_ps_line_invalid() {
662 let line = "user invalid 0.0 0.1";
663 assert!(parse_ps_line(line).is_none());
664 }
665
666 #[test]
667 fn test_detect_live_sessions_no_panic() {
668 let result = detect_live_sessions();
671 assert!(result.is_ok());
672 }
673
674 #[test]
675 #[cfg(unix)]
676 fn test_parse_start_time_today() {
677 let result = parse_start_time("14:30");
678 assert!(result.is_some());
679 }
680
681 #[test]
682 #[cfg(unix)]
683 fn test_parse_start_time_fallback() {
684 let result = parse_start_time("Feb 04");
685 assert!(result.is_none());
687 }
688
689 #[test]
690 #[cfg(unix)]
691 fn test_is_claude_process_line_match() {
692 let line = "user 12345 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 /usr/local/bin/claude --resume abc";
693 assert!(is_claude_process_line(line));
694 }
695
696 #[test]
697 #[cfg(unix)]
698 fn test_is_claude_process_line_bare_claude() {
699 let line = "user 12345 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 claude";
700 assert!(is_claude_process_line(line));
701 }
702
703 #[test]
704 #[cfg(unix)]
705 fn test_is_claude_process_line_rejects_desktop() {
706 let line = "user 99999 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 /Applications/Claude.app/claude-desktop";
708 assert!(!is_claude_process_line(line));
709 }
710
711 #[test]
712 #[cfg(unix)]
713 fn test_is_claude_process_line_rejects_grep() {
714 let line =
715 "user 99999 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 grep claude";
716 assert!(!is_claude_process_line(line));
717 }
718
719 #[test]
720 #[cfg(unix)]
721 fn test_is_claude_process_line_rejects_ccboard() {
722 let line = "user 99999 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 ccboard hook PreToolUse";
723 assert!(!is_claude_process_line(line));
724 }
725
726 #[test]
727 #[cfg(unix)]
728 fn test_is_claude_process_line_rejects_script_with_claude_in_name() {
729 let line = "user 88888 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 python3 claude_runner.py";
730 assert!(!is_claude_process_line(line));
731 }
732
733 #[test]
734 #[cfg(unix)]
735 fn test_parse_claude_flags_cli() {
736 let flags = parse_claude_flags("/usr/local/bin/claude --resume abc");
737 assert_eq!(flags.session_type, SessionType::Cli);
738 assert_eq!(flags.resume_id.as_deref(), Some("abc"));
739 assert!(flags.model.is_none());
740 }
741
742 #[test]
743 #[cfg(unix)]
744 fn test_parse_claude_flags_vscode() {
745 let flags =
746 parse_claude_flags("claude --output-format stream-json --permission-prompt-tool stdio");
747 assert_eq!(flags.session_type, SessionType::VsCode);
748 }
749
750 #[test]
751 #[cfg(unix)]
752 fn test_parse_claude_flags_subagent() {
753 let flags = parse_claude_flags("claude --output-format stream-json --model claude-opus-4");
754 assert_eq!(flags.session_type, SessionType::Subagent);
755 assert_eq!(flags.model.as_deref(), Some("claude-opus-4"));
756 }
757
758 #[test]
759 #[cfg(unix)]
760 fn test_parse_claude_flags_no_flags() {
761 let flags = parse_claude_flags("claude");
762 assert_eq!(flags.session_type, SessionType::Cli);
763 assert!(flags.model.is_none());
764 assert!(flags.resume_id.is_none());
765 }
766}