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
6use anyhow::{Context, Result};
7use chrono::{DateTime, Local, TimeZone};
8use std::process::Command;
9
10/// Represents a live Claude Code session (running process)
11#[derive(Debug, Clone)]
12pub struct LiveSession {
13    /// Process ID
14    pub pid: u32,
15    /// Time when the process started
16    pub start_time: DateTime<Local>,
17    /// Working directory of the process (if detectable)
18    pub working_directory: Option<String>,
19    /// Full command line
20    pub command: String,
21    /// CPU usage percentage
22    pub cpu_percent: f64,
23    /// Memory usage in MB
24    pub memory_mb: u64,
25    /// Total tokens in active session (if detectable)
26    pub tokens: Option<u64>,
27    /// Session ID (from JSONL filename)
28    pub session_id: Option<String>,
29    /// Session name/title (from session_start event)
30    pub session_name: Option<String>,
31}
32
33/// Detect all running Claude Code processes on the system
34///
35/// Uses platform-specific commands:
36/// - Unix (macOS/Linux): `ps aux` to list processes
37/// - Windows: `tasklist` with CSV output
38///
39/// # Returns
40/// Vector of LiveSession structs, one per detected Claude process.
41/// Returns empty vector on error or if no Claude processes are running.
42pub fn detect_live_sessions() -> Result<Vec<LiveSession>> {
43    #[cfg(unix)]
44    {
45        detect_live_sessions_unix()
46    }
47
48    #[cfg(windows)]
49    {
50        detect_live_sessions_windows()
51    }
52}
53
54#[cfg(unix)]
55fn detect_live_sessions_unix() -> Result<Vec<LiveSession>> {
56    // Run ps aux to get all processes
57    let output = Command::new("ps")
58        .args(["aux"])
59        .output()
60        .context("Failed to run ps command")?;
61
62    if !output.status.success() {
63        return Ok(vec![]);
64    }
65
66    let stdout = String::from_utf8_lossy(&output.stdout);
67    let sessions: Vec<LiveSession> = stdout
68        .lines()
69        .filter(|line| {
70            // Filter for lines containing "claude" but not "grep" (avoid self-detection)
71            line.contains("claude") && !line.contains("grep") && !line.contains("ccboard")
72        })
73        .filter_map(parse_ps_line)
74        .collect();
75
76    Ok(sessions)
77}
78
79#[cfg(unix)]
80fn parse_ps_line(line: &str) -> Option<LiveSession> {
81    // ps aux format:
82    // USER  PID  %CPU %MEM  VSZ   RSS  TTY  STAT START TIME COMMAND
83    // 0     1    2    3     4     5    6    7    8     9    10+
84    let parts: Vec<&str> = line.split_whitespace().collect();
85    if parts.len() < 11 {
86        return None;
87    }
88
89    let pid = parts[1].parse::<u32>().ok()?;
90    let cpu_percent = parts[2].parse::<f64>().unwrap_or(0.0);
91    let memory_mb = parts[5].parse::<u64>().unwrap_or(0) / 1024; // RSS in KB → MB
92    let start_str = parts[8]; // START column (HH:MM or MMM DD)
93    let command = parts[10..].join(" ");
94
95    // Parse start time (best effort - format varies by OS and process age)
96    let start_time = parse_start_time(start_str).unwrap_or_else(Local::now);
97
98    // Try to get working directory for this PID
99    let working_directory = get_cwd_for_pid(pid);
100
101    // Try to extract session metadata (tokens, ID, name)
102    let session_metadata = get_session_metadata(&working_directory);
103
104    Some(LiveSession {
105        pid,
106        start_time,
107        working_directory,
108        command,
109        cpu_percent,
110        memory_mb,
111        tokens: session_metadata.as_ref().and_then(|m| m.tokens),
112        session_id: session_metadata.as_ref().and_then(|m| m.session_id.clone()),
113        session_name: session_metadata
114            .as_ref()
115            .and_then(|m| m.session_name.clone()),
116    })
117}
118
119#[cfg(unix)]
120fn parse_start_time(start_str: &str) -> Option<DateTime<Local>> {
121    // ps START column format varies:
122    // - If process started today: "HH:MM" (e.g., "14:30")
123    // - If process started earlier: "MMM DD" (e.g., "Feb 04")
124    //
125    // For simplicity, if it contains ":", assume today's date with that time.
126    // Otherwise, fall back to current time (imprecise but acceptable).
127
128    if start_str.contains(':') {
129        // Format: "HH:MM" - assume today
130        let parts: Vec<&str> = start_str.split(':').collect();
131        if parts.len() == 2 {
132            if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
133                let now = Local::now();
134                return now
135                    .date_naive()
136                    .and_hms_opt(hour, minute, 0)
137                    .and_then(|dt| Local.from_local_datetime(&dt).single());
138            }
139        }
140    }
141
142    // Fallback: can't parse reliably, return None
143    None
144}
145
146#[cfg(unix)]
147fn get_cwd_for_pid(pid: u32) -> Option<String> {
148    // Platform-specific working directory detection
149    #[cfg(target_os = "linux")]
150    {
151        // On Linux: readlink /proc/PID/cwd
152        std::fs::read_link(format!("/proc/{}/cwd", pid))
153            .ok()
154            .and_then(|p| p.to_str().map(String::from))
155    }
156
157    #[cfg(target_os = "macos")]
158    {
159        // On macOS: lsof -p PID -Fn (returns file descriptors, including cwd)
160        let output = Command::new("lsof")
161            .args(["-p", &pid.to_string(), "-a", "-d", "cwd", "-Fn"])
162            .output()
163            .ok()?;
164
165        let stdout = String::from_utf8_lossy(&output.stdout);
166        // lsof -Fn output format: "n/path/to/cwd"
167        stdout
168            .lines()
169            .find(|line| line.starts_with('n'))
170            .and_then(|line| line.strip_prefix('n'))
171            .map(String::from)
172    }
173
174    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
175    {
176        // Other Unix systems: not implemented
177        None
178    }
179}
180
181#[cfg(windows)]
182fn detect_live_sessions_windows() -> Result<Vec<LiveSession>> {
183    // Run tasklist with CSV output for parsing
184    let output = Command::new("tasklist")
185        .args(&["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"])
186        .output()
187        .context("Failed to run tasklist command")?;
188
189    if !output.status.success() {
190        return Ok(vec![]);
191    }
192
193    let stdout = String::from_utf8_lossy(&output.stdout);
194    let sessions: Vec<LiveSession> = stdout
195        .lines()
196        .filter(|line| !line.is_empty())
197        .filter_map(|line| parse_tasklist_csv(line))
198        .collect();
199
200    Ok(sessions)
201}
202
203#[cfg(windows)]
204fn parse_tasklist_csv(line: &str) -> Option<LiveSession> {
205    // CSV format (no header): "ImageName","PID","SessionName","Session#","MemUsage"
206    // Example: "claude.exe","12345","Console","1","50,000 K"
207    let parts: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
208    if parts.len() < 2 {
209        return None;
210    }
211
212    let pid = parts[1].parse::<u32>().ok()?;
213    let command = parts[0].to_string();
214
215    // Windows tasklist doesn't provide start time or cwd easily
216    // Use current time as approximate start (limitation of Windows API via tasklist)
217    let start_time = Local::now();
218    let working_directory = None; // Not available via tasklist
219
220    Some(LiveSession {
221        pid,
222        start_time,
223        working_directory,
224        command,
225        cpu_percent: 0.0,
226        memory_mb: 0,
227        tokens: None,
228        session_id: None,
229        session_name: None,
230    })
231}
232
233/// Session metadata extracted from active JSONL file
234struct LiveSessionMetadata {
235    tokens: Option<u64>,
236    session_id: Option<String>,
237    session_name: Option<String>,
238}
239
240/// Extract session metadata from active session JSONL file
241///
242/// Given a working directory (e.g., /Users/foo/myproject), attempts to:
243/// 1. Encode the path to match ~/.claude/projects/<encoded>/ format
244/// 2. Find the most recent .jsonl file in that directory
245/// 3. Parse tokens, session ID, and session name
246///
247/// Returns None if:
248/// - Working directory is None
249/// - Session directory doesn't exist
250/// - No JSONL files found
251/// - Parse errors occur
252fn get_session_metadata(working_directory: &Option<String>) -> Option<LiveSessionMetadata> {
253    let cwd = working_directory.as_ref()?;
254
255    // Encode path: /Users/foo/myproject → -Users-foo-myproject
256    // The leading '/' becomes '-' when replaced, so no need for format!("-{}")
257    let encoded = cwd.replace('/', "-");
258
259    // Build sessions directory path
260    let home = std::env::var("HOME").ok()?;
261    let sessions_dir = std::path::Path::new(&home)
262        .join(".claude")
263        .join("projects")
264        .join(&encoded);
265
266    if !sessions_dir.exists() {
267        return None;
268    }
269
270    // Find most recent .jsonl file
271    let mut entries: Vec<_> = std::fs::read_dir(&sessions_dir)
272        .ok()?
273        .filter_map(|e| e.ok())
274        .filter(|e| {
275            e.path()
276                .extension()
277                .and_then(|s| s.to_str())
278                .map(|s| s == "jsonl")
279                .unwrap_or(false)
280        })
281        .collect();
282
283    entries.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
284    let latest = entries.last()?.path();
285
286    // Extract session ID from filename (e.g., "abc123.jsonl" -> "abc123")
287    let session_id = latest
288        .file_stem()
289        .and_then(|s| s.to_str())
290        .map(String::from);
291
292    // Parse JSONL and sum tokens + extract session name
293    let file = std::fs::File::open(latest).ok()?;
294    let reader = std::io::BufReader::new(file);
295    let mut total_tokens = 0u64;
296    let mut session_name: Option<String> = None;
297
298    for line in std::io::BufRead::lines(reader) {
299        // Skip lines that fail to read (don't fail the entire function)
300        let Ok(line) = line else { continue };
301
302        if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
303            // Extract session name from session_start event
304            if session_name.is_none() {
305                if let Some(event_type) = json.get("type").and_then(|v| v.as_str()) {
306                    if event_type == "session_start" {
307                        session_name = json.get("name").and_then(|v| v.as_str()).map(String::from);
308                    }
309                }
310            }
311
312            // Usage is nested in .message.usage, not at root level
313            if let Some(message) = json.get("message") {
314                if let Some(usage) = message.get("usage") {
315                    // Sum all token types
316                    if let Some(input) = usage.get("input_tokens").and_then(|v| v.as_u64()) {
317                        total_tokens += input;
318                    }
319                    if let Some(output) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
320                        total_tokens += output;
321                    }
322                    // Note: Field names differ from stats-cache.json:
323                    // - cache_creation_input_tokens (not cache_write_tokens)
324                    // - cache_read_input_tokens (not cache_read_tokens)
325                    if let Some(cache_write) = usage
326                        .get("cache_creation_input_tokens")
327                        .and_then(|v| v.as_u64())
328                    {
329                        total_tokens += cache_write;
330                    }
331                    if let Some(cache_read) = usage
332                        .get("cache_read_input_tokens")
333                        .and_then(|v| v.as_u64())
334                    {
335                        total_tokens += cache_read;
336                    }
337                }
338            }
339        }
340    }
341
342    Some(LiveSessionMetadata {
343        tokens: if total_tokens > 0 {
344            Some(total_tokens)
345        } else {
346            None
347        },
348        session_id,
349        session_name,
350    })
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    #[cfg(unix)]
359    fn test_parse_ps_line() {
360        let line = "user  12345  0.0  0.1  123456  78910  ttys001  S+   14:30   0:05.23  /usr/local/bin/claude --session foo";
361        let session = parse_ps_line(line).expect("Failed to parse valid ps line");
362        assert_eq!(session.pid, 12345);
363        assert!(session.command.contains("claude"));
364    }
365
366    #[test]
367    #[cfg(unix)]
368    fn test_parse_ps_line_invalid() {
369        let line = "user  invalid  0.0  0.1";
370        assert!(parse_ps_line(line).is_none());
371    }
372
373    #[test]
374    fn test_detect_live_sessions_no_panic() {
375        // This test just ensures the function doesn't panic
376        // It may return empty vec if no claude processes are running
377        let result = detect_live_sessions();
378        assert!(result.is_ok());
379    }
380
381    #[test]
382    #[cfg(unix)]
383    fn test_parse_start_time_today() {
384        let result = parse_start_time("14:30");
385        assert!(result.is_some());
386    }
387
388    #[test]
389    #[cfg(unix)]
390    fn test_parse_start_time_fallback() {
391        let result = parse_start_time("Feb 04");
392        // Should return None for non-HH:MM format (can't reliably parse month/day)
393        assert!(result.is_none());
394    }
395}