use anyhow::{Context, Result};
use chrono::{DateTime, Local, TimeZone};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct LiveSession {
pub pid: u32,
pub start_time: DateTime<Local>,
pub working_directory: Option<String>,
pub command: String,
pub cpu_percent: f64,
pub memory_mb: u64,
pub tokens: Option<u64>,
pub session_id: Option<String>,
pub session_name: Option<String>,
}
pub fn detect_live_sessions() -> Result<Vec<LiveSession>> {
#[cfg(unix)]
{
detect_live_sessions_unix()
}
#[cfg(windows)]
{
detect_live_sessions_windows()
}
}
#[cfg(unix)]
fn detect_live_sessions_unix() -> Result<Vec<LiveSession>> {
let output = Command::new("ps")
.args(["aux"])
.output()
.context("Failed to run ps command")?;
if !output.status.success() {
return Ok(vec![]);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let sessions: Vec<LiveSession> = stdout
.lines()
.filter(|line| {
line.contains("claude") && !line.contains("grep") && !line.contains("ccboard")
})
.filter_map(parse_ps_line)
.collect();
Ok(sessions)
}
#[cfg(unix)]
fn parse_ps_line(line: &str) -> Option<LiveSession> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 11 {
return None;
}
let pid = parts[1].parse::<u32>().ok()?;
let cpu_percent = parts[2].parse::<f64>().unwrap_or(0.0);
let memory_mb = parts[5].parse::<u64>().unwrap_or(0) / 1024; let start_str = parts[8]; let command = parts[10..].join(" ");
let start_time = parse_start_time(start_str).unwrap_or_else(Local::now);
let working_directory = get_cwd_for_pid(pid);
let session_metadata = get_session_metadata(&working_directory);
Some(LiveSession {
pid,
start_time,
working_directory,
command,
cpu_percent,
memory_mb,
tokens: session_metadata.as_ref().and_then(|m| m.tokens),
session_id: session_metadata.as_ref().and_then(|m| m.session_id.clone()),
session_name: session_metadata
.as_ref()
.and_then(|m| m.session_name.clone()),
})
}
#[cfg(unix)]
fn parse_start_time(start_str: &str) -> Option<DateTime<Local>> {
if start_str.contains(':') {
let parts: Vec<&str> = start_str.split(':').collect();
if parts.len() == 2 {
if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
let now = Local::now();
return now
.date_naive()
.and_hms_opt(hour, minute, 0)
.and_then(|dt| Local.from_local_datetime(&dt).single());
}
}
}
None
}
#[cfg(unix)]
fn get_cwd_for_pid(pid: u32) -> Option<String> {
#[cfg(target_os = "linux")]
{
std::fs::read_link(format!("/proc/{}/cwd", pid))
.ok()
.and_then(|p| p.to_str().map(String::from))
}
#[cfg(target_os = "macos")]
{
let output = Command::new("lsof")
.args(["-p", &pid.to_string(), "-a", "-d", "cwd", "-Fn"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.find(|line| line.starts_with('n'))
.and_then(|line| line.strip_prefix('n'))
.map(String::from)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
#[cfg(windows)]
fn detect_live_sessions_windows() -> Result<Vec<LiveSession>> {
let output = Command::new("tasklist")
.args(&["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"])
.output()
.context("Failed to run tasklist command")?;
if !output.status.success() {
return Ok(vec![]);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let sessions: Vec<LiveSession> = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| parse_tasklist_csv(line))
.collect();
Ok(sessions)
}
#[cfg(windows)]
fn parse_tasklist_csv(line: &str) -> Option<LiveSession> {
let parts: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
if parts.len() < 2 {
return None;
}
let pid = parts[1].parse::<u32>().ok()?;
let command = parts[0].to_string();
let start_time = Local::now();
let working_directory = None;
Some(LiveSession {
pid,
start_time,
working_directory,
command,
cpu_percent: 0.0,
memory_mb: 0,
tokens: None,
session_id: None,
session_name: None,
})
}
struct LiveSessionMetadata {
tokens: Option<u64>,
session_id: Option<String>,
session_name: Option<String>,
}
fn get_session_metadata(working_directory: &Option<String>) -> Option<LiveSessionMetadata> {
let cwd = working_directory.as_ref()?;
let encoded = cwd.replace('/', "-");
let home = std::env::var("HOME").ok()?;
let sessions_dir = std::path::Path::new(&home)
.join(".claude")
.join("projects")
.join(&encoded);
if !sessions_dir.exists() {
return None;
}
let mut entries: Vec<_> = std::fs::read_dir(&sessions_dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|s| s.to_str())
.map(|s| s == "jsonl")
.unwrap_or(false)
})
.collect();
entries.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
let latest = entries.last()?.path();
let session_id = latest
.file_stem()
.and_then(|s| s.to_str())
.map(String::from);
let file = std::fs::File::open(latest).ok()?;
let reader = std::io::BufReader::new(file);
let mut total_tokens = 0u64;
let mut session_name: Option<String> = None;
for line in std::io::BufRead::lines(reader) {
let Ok(line) = line else { continue };
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
if session_name.is_none() {
if let Some(event_type) = json.get("type").and_then(|v| v.as_str()) {
if event_type == "session_start" {
session_name = json.get("name").and_then(|v| v.as_str()).map(String::from);
}
}
}
if let Some(message) = json.get("message") {
if let Some(usage) = message.get("usage") {
if let Some(input) = usage.get("input_tokens").and_then(|v| v.as_u64()) {
total_tokens += input;
}
if let Some(output) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
total_tokens += output;
}
if let Some(cache_write) = usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
{
total_tokens += cache_write;
}
if let Some(cache_read) = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
{
total_tokens += cache_read;
}
}
}
}
}
Some(LiveSessionMetadata {
tokens: if total_tokens > 0 {
Some(total_tokens)
} else {
None
},
session_id,
session_name,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(unix)]
fn test_parse_ps_line() {
let line = "user 12345 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 /usr/local/bin/claude --session foo";
let session = parse_ps_line(line).expect("Failed to parse valid ps line");
assert_eq!(session.pid, 12345);
assert!(session.command.contains("claude"));
}
#[test]
#[cfg(unix)]
fn test_parse_ps_line_invalid() {
let line = "user invalid 0.0 0.1";
assert!(parse_ps_line(line).is_none());
}
#[test]
fn test_detect_live_sessions_no_panic() {
let result = detect_live_sessions();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn test_parse_start_time_today() {
let result = parse_start_time("14:30");
assert!(result.is_some());
}
#[test]
#[cfg(unix)]
fn test_parse_start_time_fallback() {
let result = parse_start_time("Feb 04");
assert!(result.is_none());
}
}