Skip to main content

claudectl_core/
process.rs

1use crate::session::{ClaudeSession, SessionStatus};
2
3/// Check which PIDs are alive and fetch TTY, CPU%, MEM, command args — all via `ps`.
4/// No sysinfo dependency needed.
5pub fn fetch_and_enrich(sessions: &mut [ClaudeSession]) {
6    if sessions.is_empty() {
7        return;
8    }
9
10    let pids: Vec<String> = sessions.iter().map(|s| s.pid.to_string()).collect();
11    let pid_arg = pids.join(",");
12
13    let output = std::process::Command::new("ps")
14        .args(["-o", "pid=,tty=,%cpu=,rss=,command=", "-p", &pid_arg])
15        .env_clear()
16        .output();
17
18    let output = match output {
19        Ok(o) => o,
20        Err(e) => {
21            crate::logger::log("ERROR", &format!("ps command failed: {e}"));
22            // ps failed — mark all as Finished (will show tombstone for 30s)
23            for s in sessions.iter_mut() {
24                s.status = SessionStatus::Finished;
25                s.cpu_percent = 0.0;
26            }
27            return;
28        }
29    };
30
31    let stdout = String::from_utf8_lossy(&output.stdout);
32
33    // Collect alive PIDs from ps output
34    let mut alive_pids = std::collections::HashSet::new();
35
36    for line in stdout.lines() {
37        let trimmed = line.trim();
38        let fields: Vec<&str> = trimmed.split_whitespace().collect();
39        if fields.len() < 5 {
40            continue;
41        }
42        let Ok(pid) = fields[0].parse::<u32>() else {
43            continue;
44        };
45        let tty = fields[1].to_string();
46        let cpu = fields[2].parse::<f32>().unwrap_or(0.0);
47        let rss_kb = fields[3].parse::<f64>().unwrap_or(0.0);
48        let mem_mb = rss_kb / 1024.0;
49        let command = fields[4..].join(" ");
50
51        // Only count this PID as alive if it's actually a claude process.
52        // PIDs get reused on macOS — a dead claude session's PID may belong
53        // to an unrelated process now.
54        if !command.contains("claude") {
55            continue;
56        }
57
58        alive_pids.insert(pid);
59
60        for session in sessions.iter_mut() {
61            if session.pid == pid {
62                session.tty = tty.clone();
63                session.mem_mb = mem_mb;
64
65                // CPU smoothing: track last 3 readings, use average
66                session.cpu_history.push(cpu);
67                if session.cpu_history.len() > 3 {
68                    session.cpu_history.remove(0);
69                }
70                session.cpu_percent =
71                    session.cpu_history.iter().sum::<f32>() / session.cpu_history.len() as f32;
72
73                // Extract args (everything after "claude")
74                if let Some(idx) = command.find("claude") {
75                    let after_claude = &command[idx + 6..];
76                    session.command_args = after_claude.trim().to_string();
77                }
78
79                // Extract session name from --name or --resume
80                let cmd_parts: Vec<&str> = command.split_whitespace().collect();
81                extract_session_meta(&cmd_parts, session);
82
83                break;
84            }
85        }
86    }
87
88    // Mark dead PIDs as Finished instead of removing them immediately.
89    // They'll be displayed briefly so the user can see what exited.
90    //
91    // Liveness is re-decided from `ps` on every tick, in both directions. The
92    // clearing half matters because `Finished` is now sticky — `infer_status`
93    // will not overwrite it — so a session marked dead by a transient `ps`
94    // failure above would otherwise stay dead for the life of the process.
95    for session in sessions.iter_mut() {
96        if alive_pids.contains(&session.pid) {
97            if session.status == crate::session::SessionStatus::Finished {
98                session.status = crate::session::SessionStatus::Unknown;
99            }
100        } else {
101            session.status = crate::session::SessionStatus::Finished;
102            session.cpu_percent = 0.0;
103        }
104    }
105}
106
107fn extract_session_meta(cmd: &[&str], session: &mut ClaudeSession) {
108    let mut i = 0;
109    while i < cmd.len() {
110        match cmd[i] {
111            "--name" | "-n" if i + 1 < cmd.len() => {
112                session.session_name = cmd[i + 1].to_string();
113                i += 2;
114                continue;
115            }
116            "--resume" | "-r" if i + 1 < cmd.len() => {
117                let val = cmd[i + 1];
118                if !looks_like_uuid(val) {
119                    session.session_name = val.to_string();
120                }
121                i += 2;
122                continue;
123            }
124            _ => {}
125        }
126        i += 1;
127    }
128}
129
130fn looks_like_uuid(s: &str) -> bool {
131    s.len() == 36
132        && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
133        && s.matches('-').count() == 4
134}