ccboard_core/
live_monitor.rs1use anyhow::{Context, Result};
7use chrono::{DateTime, Local, TimeZone};
8use std::process::Command;
9
10#[derive(Debug, Clone)]
12pub struct LiveSession {
13 pub pid: u32,
15 pub start_time: DateTime<Local>,
17 pub working_directory: Option<String>,
19 pub command: String,
21 pub cpu_percent: f64,
23 pub memory_mb: u64,
25 pub tokens: Option<u64>,
27 pub session_id: Option<String>,
29 pub session_name: Option<String>,
31}
32
33pub 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 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 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 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; let start_str = parts[8]; let command = parts[10..].join(" ");
94
95 let start_time = parse_start_time(start_str).unwrap_or_else(Local::now);
97
98 let working_directory = get_cwd_for_pid(pid);
100
101 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 if start_str.contains(':') {
129 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 None
144}
145
146#[cfg(unix)]
147fn get_cwd_for_pid(pid: u32) -> Option<String> {
148 #[cfg(target_os = "linux")]
150 {
151 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 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 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 None
178 }
179}
180
181#[cfg(windows)]
182fn detect_live_sessions_windows() -> Result<Vec<LiveSession>> {
183 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 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 let start_time = Local::now();
218 let working_directory = None; 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
233struct LiveSessionMetadata {
235 tokens: Option<u64>,
236 session_id: Option<String>,
237 session_name: Option<String>,
238}
239
240fn get_session_metadata(working_directory: &Option<String>) -> Option<LiveSessionMetadata> {
253 let cwd = working_directory.as_ref()?;
254
255 let encoded = cwd.replace('/', "-");
258
259 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 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 let session_id = latest
288 .file_stem()
289 .and_then(|s| s.to_str())
290 .map(String::from);
291
292 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 let Ok(line) = line else { continue };
301
302 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
303 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 if let Some(message) = json.get("message") {
314 if let Some(usage) = message.get("usage") {
315 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 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 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 assert!(result.is_none());
394 }
395}