use crate::hook_state::{HookSessionStatus, LiveSessionFile};
use anyhow::{Context, Result};
use chrono::{DateTime, Local, TimeZone};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SessionType {
#[default]
Cli,
VsCode,
Subagent,
}
impl SessionType {
pub fn label(&self) -> &'static str {
match self {
SessionType::Cli => "CLI",
SessionType::VsCode => "IDE",
SessionType::Subagent => "Agent",
}
}
}
struct ParsedFlags {
session_type: SessionType,
model: Option<String>,
resume_id: Option<String>,
}
fn extract_flag_value(command: &str, flag: &str) -> Option<String> {
let tokens: Vec<&str> = command.split_whitespace().collect();
for i in 0..tokens.len().saturating_sub(1) {
if tokens[i] == flag {
return Some(tokens[i + 1].to_string());
}
}
None
}
fn parse_claude_flags(command: &str) -> ParsedFlags {
let has_stream_json = command.contains("stream-json");
let has_stdio_tool = command.contains("permission-prompt-tool") && command.contains("stdio");
let session_type = if has_stream_json && has_stdio_tool {
SessionType::VsCode
} else if has_stream_json {
SessionType::Subagent
} else {
SessionType::Cli
};
let model = extract_flag_value(command, "--model");
let resume_id = extract_flag_value(command, "--resume");
ParsedFlags {
session_type,
model,
resume_id,
}
}
#[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 session_type: SessionType,
pub model: Option<String>,
pub resume_id: 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| is_claude_process_line(line))
.filter_map(parse_ps_line)
.collect();
Ok(sessions)
}
#[cfg(unix)]
fn is_claude_process_line(line: &str) -> bool {
if line.contains("grep") || line.contains("ccboard") {
return false;
}
let mut fields = line.split_whitespace();
for _ in 0..10 {
if fields.next().is_none() {
return false;
}
}
let binary = fields.next().unwrap_or("");
let base = binary.rsplit('/').next().unwrap_or(binary);
base == "claude" || base == "claude-code"
}
#[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 flags = parse_claude_flags(&command);
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()),
session_type: flags.session_type,
model: flags.model,
resume_id: flags.resume_id,
})
}
#[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,
session_type: SessionType::Cli,
model: None,
resume_id: 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 = dirs::home_dir()?;
let sessions_dir = 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,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiveSessionDisplayStatus {
Running,
WaitingInput,
Stopped,
ProcessOnly,
Unknown,
}
impl LiveSessionDisplayStatus {
pub fn icon(&self) -> &'static str {
match self {
LiveSessionDisplayStatus::Running => "●",
LiveSessionDisplayStatus::WaitingInput => "◐",
LiveSessionDisplayStatus::Stopped => "✓",
LiveSessionDisplayStatus::ProcessOnly => "🟢",
LiveSessionDisplayStatus::Unknown => "?",
}
}
}
#[derive(Debug, Clone)]
pub struct MergedLiveSession {
pub session_id: Option<String>,
pub cwd: String,
pub tty: Option<String>,
pub hook_status: Option<HookSessionStatus>,
pub process: Option<LiveSession>,
pub last_event_at: Option<DateTime<Local>>,
pub last_event: Option<String>,
}
impl MergedLiveSession {
pub fn effective_status(&self) -> LiveSessionDisplayStatus {
match self.hook_status {
Some(HookSessionStatus::Running) => LiveSessionDisplayStatus::Running,
Some(HookSessionStatus::WaitingInput) => LiveSessionDisplayStatus::WaitingInput,
Some(HookSessionStatus::Stopped) => LiveSessionDisplayStatus::Stopped,
Some(HookSessionStatus::Unknown) | None => {
if self.process.is_some() {
LiveSessionDisplayStatus::ProcessOnly
} else {
LiveSessionDisplayStatus::Unknown
}
}
}
}
pub fn project_name(&self) -> &str {
std::path::Path::new(&self.cwd)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&self.cwd)
}
}
pub fn merge_live_sessions(
hook_file: &LiveSessionFile,
ps_sessions: &[LiveSession],
) -> Vec<MergedLiveSession> {
let mut result: Vec<MergedLiveSession> = Vec::new();
let mut matched_ps: Vec<bool> = vec![false; ps_sessions.len()];
for hook_session in hook_file.sessions.values() {
let mut matched_ps_idx: Option<usize> = None;
if matched_ps_idx.is_none() {
for (i, ps) in ps_sessions.iter().enumerate() {
if matched_ps[i] {
continue;
}
if ps
.session_id
.as_deref()
.map(|id| id == hook_session.session_id)
.unwrap_or(false)
{
matched_ps_idx = Some(i);
break;
}
}
}
if matched_ps_idx.is_none() && hook_session.tty != "unknown" {
let hook_tty_base = std::path::Path::new(&hook_session.tty)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&hook_session.tty);
for (i, ps) in ps_sessions.iter().enumerate() {
if matched_ps[i] {
continue;
}
if ps
.working_directory
.as_deref()
.map(|wd| wd == hook_session.cwd)
.unwrap_or(false)
{
matched_ps_idx = Some(i);
break;
}
if ps.command.contains(hook_tty_base) {
matched_ps_idx = Some(i);
break;
}
}
}
if let Some(idx) = matched_ps_idx {
matched_ps[idx] = true;
let ps = &ps_sessions[idx];
result.push(MergedLiveSession {
session_id: Some(hook_session.session_id.clone()),
cwd: hook_session.cwd.clone(),
tty: Some(hook_session.tty.clone()),
hook_status: Some(hook_session.status),
process: Some(ps.clone()),
last_event_at: Some(hook_session.updated_at.with_timezone(&Local)),
last_event: Some(hook_session.last_event.clone()),
});
} else {
result.push(MergedLiveSession {
session_id: Some(hook_session.session_id.clone()),
cwd: hook_session.cwd.clone(),
tty: Some(hook_session.tty.clone()),
hook_status: Some(hook_session.status),
process: None,
last_event_at: Some(hook_session.updated_at.with_timezone(&Local)),
last_event: Some(hook_session.last_event.clone()),
});
}
}
for (i, ps) in ps_sessions.iter().enumerate() {
if !matched_ps[i] {
result.push(MergedLiveSession {
session_id: ps.session_id.clone(),
cwd: ps
.working_directory
.clone()
.unwrap_or_else(|| "unknown".to_string()),
tty: None,
hook_status: None,
process: Some(ps.clone()),
last_event_at: None,
last_event: None,
});
}
}
result
}
#[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());
}
#[test]
#[cfg(unix)]
fn test_is_claude_process_line_match() {
let line = "user 12345 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 /usr/local/bin/claude --resume abc";
assert!(is_claude_process_line(line));
}
#[test]
#[cfg(unix)]
fn test_is_claude_process_line_bare_claude() {
let line = "user 12345 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 claude";
assert!(is_claude_process_line(line));
}
#[test]
#[cfg(unix)]
fn test_is_claude_process_line_rejects_desktop() {
let line = "user 99999 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 /Applications/Claude.app/claude-desktop";
assert!(!is_claude_process_line(line));
}
#[test]
#[cfg(unix)]
fn test_is_claude_process_line_rejects_grep() {
let line =
"user 99999 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 grep claude";
assert!(!is_claude_process_line(line));
}
#[test]
#[cfg(unix)]
fn test_is_claude_process_line_rejects_ccboard() {
let line = "user 99999 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 ccboard hook PreToolUse";
assert!(!is_claude_process_line(line));
}
#[test]
#[cfg(unix)]
fn test_is_claude_process_line_rejects_script_with_claude_in_name() {
let line = "user 88888 0.0 0.1 123456 78910 ttys001 S+ 14:30 0:05.23 python3 claude_runner.py";
assert!(!is_claude_process_line(line));
}
#[test]
#[cfg(unix)]
fn test_parse_claude_flags_cli() {
let flags = parse_claude_flags("/usr/local/bin/claude --resume abc");
assert_eq!(flags.session_type, SessionType::Cli);
assert_eq!(flags.resume_id.as_deref(), Some("abc"));
assert!(flags.model.is_none());
}
#[test]
#[cfg(unix)]
fn test_parse_claude_flags_vscode() {
let flags =
parse_claude_flags("claude --output-format stream-json --permission-prompt-tool stdio");
assert_eq!(flags.session_type, SessionType::VsCode);
}
#[test]
#[cfg(unix)]
fn test_parse_claude_flags_subagent() {
let flags = parse_claude_flags("claude --output-format stream-json --model claude-opus-4");
assert_eq!(flags.session_type, SessionType::Subagent);
assert_eq!(flags.model.as_deref(), Some("claude-opus-4"));
}
#[test]
#[cfg(unix)]
fn test_parse_claude_flags_no_flags() {
let flags = parse_claude_flags("claude");
assert_eq!(flags.session_type, SessionType::Cli);
assert!(flags.model.is_none());
assert!(flags.resume_id.is_none());
}
}