Skip to main content

ccboard_core/
live_monitor.rs

1//! Live Claude Code session detection
2//!
3//! Detects running Claude Code processes on the system and provides metadata
4//! about active sessions (PID, working directory, duration since start).
5//!
6//! Also provides `MergedLiveSession` which combines hook-based status data
7//! (from ~/.ccboard/live-sessions.json) with ps-based process data.
8
9use crate::hook_state::{HookSessionStatus, LiveSessionFile};
10use anyhow::{Context, Result};
11use chrono::{DateTime, Local, TimeZone};
12use std::process::Command;
13
14/// Type of Claude Code session, detected from CLI flags
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
16pub enum SessionType {
17    /// Standard `claude` CLI invocation
18    #[default]
19    Cli,
20    /// IDE integration (--output-format stream-json + --permission-prompt-tool stdio)
21    VsCode,
22    /// Sub-agent (--output-format stream-json, no --permission-prompt-tool)
23    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
36/// Parsed CLI flags from the claude command line
37struct ParsedFlags {
38    session_type: SessionType,
39    model: Option<String>,
40    resume_id: Option<String>,
41}
42
43/// Extract the value following a flag from a command string.
44/// E.g. `extract_flag_value("claude --model opus-4", "--model")` → `Some("opus-4")`
45fn 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
55/// Parse CLI flags from a claude command string to determine session type and metadata.
56fn 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/// Represents a live Claude Code session (running process)
79#[derive(Debug, Clone)]
80pub struct LiveSession {
81    /// Process ID
82    pub pid: u32,
83    /// Time when the process started
84    pub start_time: DateTime<Local>,
85    /// Working directory of the process (if detectable)
86    pub working_directory: Option<String>,
87    /// Full command line
88    pub command: String,
89    /// CPU usage percentage
90    pub cpu_percent: f64,
91    /// Memory usage in MB
92    pub memory_mb: u64,
93    /// Total tokens in active session (if detectable)
94    pub tokens: Option<u64>,
95    /// Session ID (from JSONL filename)
96    pub session_id: Option<String>,
97    /// Session name/title (from session_start event)
98    pub session_name: Option<String>,
99    /// Type of session (CLI / IDE / Agent), detected from CLI flags
100    pub session_type: SessionType,
101    /// Model in use (from --model flag, if present)
102    pub model: Option<String>,
103    /// Resume session ID (from --resume flag, may differ from session_id)
104    pub resume_id: Option<String>,
105}
106
107/// Detect all running Claude Code processes on the system
108///
109/// Uses platform-specific commands:
110/// - Unix (macOS/Linux): `ps aux` to list processes
111/// - Windows: `tasklist` with CSV output
112///
113/// # Returns
114/// Vector of LiveSession structs, one per detected Claude process.
115/// Returns empty vector on error or if no Claude processes are running.
116pub 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    // Run ps aux to get all processes
131    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/// Returns true if a `ps aux` line belongs to a Claude Code process.
151///
152/// Checks the COMMAND column (field 10, 0-indexed) basename is exactly `claude`
153/// or `claude-code`, avoiding false matches on `claude-desktop`, scripts, grep, etc.
154#[cfg(unix)]
155fn is_claude_process_line(line: &str) -> bool {
156    if line.contains("grep") || line.contains("ccboard") {
157        return false;
158    }
159    // ps aux columns: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND…
160    // split_whitespace() collapses multiple spaces — skip 10 columns to reach COMMAND
161    let mut fields = line.split_whitespace();
162    for _ in 0..10 {
163        if fields.next().is_none() {
164            return false;
165        }
166    }
167    // First token of COMMAND is the binary (possibly a full path)
168    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    // ps aux format:
176    // USER  PID  %CPU %MEM  VSZ   RSS  TTY  STAT START TIME COMMAND
177    // 0     1    2    3     4     5    6    7    8     9    10+
178    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; // RSS in KB → MB
186    let start_str = parts[8]; // START column (HH:MM or MMM DD)
187    let command = parts[10..].join(" ");
188
189    // Parse CLI flags (session type, model, resume ID)
190    let flags = parse_claude_flags(&command);
191
192    // Parse start time (best effort - format varies by OS and process age)
193    let start_time = parse_start_time(start_str).unwrap_or_else(Local::now);
194
195    // Try to get working directory for this PID
196    let working_directory = get_cwd_for_pid(pid);
197
198    // Try to extract session metadata (tokens, ID, name)
199    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    // ps START column format varies:
222    // - If process started today: "HH:MM" (e.g., "14:30")
223    // - If process started earlier: "MMM DD" (e.g., "Feb 04")
224    //
225    // For simplicity, if it contains ":", assume today's date with that time.
226    // Otherwise, fall back to current time (imprecise but acceptable).
227
228    if start_str.contains(':') {
229        // Format: "HH:MM" - assume today
230        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    // Fallback: can't parse reliably, return None
243    None
244}
245
246#[cfg(unix)]
247fn get_cwd_for_pid(pid: u32) -> Option<String> {
248    // Platform-specific working directory detection
249    #[cfg(target_os = "linux")]
250    {
251        // On Linux: readlink /proc/PID/cwd
252        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        // On macOS: lsof -p PID -Fn (returns file descriptors, including cwd)
260        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        // lsof -Fn output format: "n/path/to/cwd"
267        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        // Other Unix systems: not implemented
277        None
278    }
279}
280
281#[cfg(windows)]
282fn detect_live_sessions_windows() -> Result<Vec<LiveSession>> {
283    // Run tasklist with CSV output for parsing
284    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    // CSV format (no header): "ImageName","PID","SessionName","Session#","MemUsage"
306    // Example: "claude.exe","12345","Console","1","50,000 K"
307    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    // Windows tasklist doesn't provide start time or cwd easily
316    // Use current time as approximate start (limitation of Windows API via tasklist)
317    let start_time = Local::now();
318    let working_directory = None; // Not available via tasklist
319
320    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
336/// Session metadata extracted from active JSONL file
337struct LiveSessionMetadata {
338    tokens: Option<u64>,
339    session_id: Option<String>,
340    session_name: Option<String>,
341}
342
343/// Extract session metadata from active session JSONL file
344///
345/// Given a working directory (e.g., /Users/foo/myproject), attempts to:
346/// 1. Encode the path to match ~/.claude/projects/<encoded>/ format
347/// 2. Find the most recent .jsonl file in that directory
348/// 3. Parse tokens, session ID, and session name
349///
350/// Returns None if:
351/// - Working directory is None
352/// - Session directory doesn't exist
353/// - No JSONL files found
354/// - Parse errors occur
355fn get_session_metadata(working_directory: &Option<String>) -> Option<LiveSessionMetadata> {
356    let cwd = working_directory.as_ref()?;
357
358    // Encode path: /Users/foo/myproject → -Users-foo-myproject
359    // The leading '/' becomes '-' when replaced, so no need for format!("-{}")
360    let encoded = cwd.replace('/', "-");
361
362    // Build sessions directory path
363    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    // Find most recent .jsonl file
371    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    // Extract session ID from filename (e.g., "abc123.jsonl" -> "abc123")
387    let session_id = latest
388        .file_stem()
389        .and_then(|s| s.to_str())
390        .map(String::from);
391
392    // Parse JSONL and sum tokens + extract session name
393    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        // Skip lines that fail to read (don't fail the entire function)
400        let Ok(line) = line else { continue };
401
402        if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
403            // Extract session name from session_start event
404            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            // Usage is nested in .message.usage, not at root level
413            if let Some(message) = json.get("message") {
414                if let Some(usage) = message.get("usage") {
415                    // Sum all token types
416                    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                    // Note: Field names differ from stats-cache.json:
423                    // - cache_creation_input_tokens (not cache_write_tokens)
424                    // - cache_read_input_tokens (not cache_read_tokens)
425                    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// ─────────────────────────────────────────────────────────────────────────────
454// Merged live sessions (hook + ps)
455// ─────────────────────────────────────────────────────────────────────────────
456
457/// Display status computed from hook data and ps-based fallback
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum LiveSessionDisplayStatus {
460    /// Hook says Running
461    Running,
462    /// Hook says WaitingInput (permission prompt)
463    WaitingInput,
464    /// Hook says Stopped
465    Stopped,
466    /// No hooks — detected via ps only
467    ProcessOnly,
468    /// Unknown (hooks present but status unclear)
469    Unknown,
470}
471
472impl LiveSessionDisplayStatus {
473    /// Short icon for TUI display
474    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/// A merged view of hook data + ps-based process data for one Claude session
486#[derive(Debug, Clone)]
487pub struct MergedLiveSession {
488    /// Session ID (from hook data or ps metadata)
489    pub session_id: Option<String>,
490    /// Working directory
491    pub cwd: String,
492    /// TTY device (from hook data)
493    pub tty: Option<String>,
494    /// Status from hook events (None = no hooks for this session)
495    pub hook_status: Option<HookSessionStatus>,
496    /// Underlying ps-detected process (None = hook-only, ps couldn't find it yet)
497    pub process: Option<LiveSession>,
498    /// When the last hook event was received
499    pub last_event_at: Option<DateTime<Local>>,
500    /// Name of the last hook event
501    pub last_event: Option<String>,
502}
503
504impl MergedLiveSession {
505    /// Compute the effective display status
506    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    /// Project name (basename of cwd)
522    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
530/// Merge hook file data with ps-based session list.
531///
532/// Priority order:
533/// 1. session_id match (if both have session_id)
534/// 2. TTY match
535/// 3. cwd basename match (fallback)
536///
537/// Sessions present only in ps get `hook_status = None` (ProcessOnly).
538/// Sessions present only in hooks (not yet visible to ps) are included.
539pub 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 each hook session, try to find a matching ps session
547    for hook_session in hook_file.sessions.values() {
548        let mut matched_ps_idx: Option<usize> = None;
549
550        // Match by session_id
551        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        // Match by TTY (ps TTY column vs hook tty)
569        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                // Fallback: match by cwd
581                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                // Try hook_tty_base against session command
592                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            // Hook-only (ps hasn't picked it up or process ended)
613            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    // Remaining unmatched ps sessions → ProcessOnly
626    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        // This test just ensures the function doesn't panic
669        // It may return empty vec if no claude processes are running
670        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        // Should return None for non-HH:MM format (can't reliably parse month/day)
686        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        // claude-desktop should NOT match
707        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}