1use crate::model::{Harness, HostStats, ProcKind, ProcNode};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::time::{SystemTime, UNIX_EPOCH};
13use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
14
15#[derive(Debug, Clone)]
16pub struct RawProc {
17 pub pid: u32,
18 pub ppid: Option<u32>,
19 pub name: String,
20 pub exe: Option<PathBuf>,
21 pub cmd: Vec<String>,
22 pub cwd: Option<PathBuf>,
23 pub cpu_percent: f32,
24 pub rss_bytes: u64,
25 pub start_time: u64,
27 pub run_time: u64,
28}
29
30impl RawProc {
31 pub fn cmdline(&self) -> String {
32 if self.cmd.is_empty() { self.name.clone() } else { self.cmd.join(" ") }
33 }
34
35 fn program(&self) -> String {
37 let from_cmd = self.cmd.first().map(|c| basename(c));
38 let from_exe = self.exe.as_ref().and_then(|e| e.file_name()).map(|f| f.to_string_lossy().into_owned());
39 from_cmd.or(from_exe).unwrap_or_else(|| self.name.clone())
40 }
41}
42
43fn basename(s: &str) -> String {
44 s.rsplit('/').next().unwrap_or(s).to_string()
45}
46
47pub struct ProcessScanner {
48 sys: System,
49 self_pid: Option<u32>,
50}
51
52impl Default for ProcessScanner {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58impl ProcessScanner {
59 pub fn new() -> Self {
60 let mut sys = System::new();
61 sys.refresh_memory();
62 sys.refresh_cpu_usage();
63 sys.refresh_processes_specifics(ProcessesToUpdate::All, true, Self::refresh_kind());
64 let self_pid = sysinfo::get_current_pid().ok().map(|p| p.as_u32());
65 ProcessScanner { sys, self_pid }
66 }
67
68 pub fn refresh(&mut self) {
69 self.sys.refresh_memory();
70 self.sys.refresh_cpu_usage();
71 self.sys.refresh_processes_specifics(ProcessesToUpdate::All, true, Self::refresh_kind());
72 }
73
74 fn refresh_kind() -> ProcessRefreshKind {
81 ProcessRefreshKind::nothing()
82 .with_memory()
83 .with_cpu()
84 .with_exe(UpdateKind::OnlyIfNotSet)
85 .with_cmd(UpdateKind::OnlyIfNotSet)
86 .with_cwd(UpdateKind::OnlyIfNotSet)
87 }
88
89 pub fn host(&self) -> HostStats {
90 HostStats {
91 hostname: System::host_name(),
92 cpu_percent: self.sys.global_cpu_usage(),
93 cpu_count: self.sys.cpus().len(),
94 mem_used_bytes: self.sys.used_memory(),
95 mem_total_bytes: self.sys.total_memory(),
96 }
97 }
98
99 pub fn processes(&self) -> Vec<RawProc> {
100 self.sys
101 .processes()
102 .iter()
103 .filter(|(pid, _)| Some(pid.as_u32()) != self.self_pid)
104 .map(|(pid, p)| RawProc {
105 pid: pid.as_u32(),
106 ppid: p.parent().map(|x| x.as_u32()),
107 name: p.name().to_string_lossy().into_owned(),
108 exe: p.exe().map(|e| e.to_path_buf()),
109 cmd: p.cmd().iter().map(|c| c.to_string_lossy().into_owned()).collect(),
110 cwd: p.cwd().map(|c| c.to_path_buf()),
111 cpu_percent: p.cpu_usage(),
112 rss_bytes: p.memory(),
113 start_time: p.start_time(),
114 run_time: p.run_time(),
115 })
116 .collect()
117 }
118}
119
120pub fn classify_agent(p: &RawProc) -> Option<Harness> {
122 let prog = p.program();
123 let prog = prog.strip_suffix(".exe").unwrap_or(&prog).to_ascii_lowercase();
124 let joined = p.cmd.join(" ");
125
126 let script = p.cmd.get(1).map(|s| s.to_ascii_lowercase()).unwrap_or_default();
128
129 if prog == "claude" || script.contains("@anthropic-ai/claude-code") || script.ends_with("/claude") {
130 return Some(Harness::Claude);
131 }
132 if prog == "codex" || script.contains("@openai/codex") {
133 return Some(Harness::Codex);
134 }
135 if prog == "gemini" || script.contains("@google/gemini-cli") {
136 return Some(Harness::Gemini);
137 }
138 if prog == "opencode" {
139 return Some(Harness::OpenCode);
140 }
141 if prog == "aider" || joined.contains("aider/main.py") {
142 return Some(Harness::Aider);
143 }
144 if prog == "copilot" || script.contains("@github/copilot") {
145 return Some(Harness::Copilot);
146 }
147 if prog == "cursor-agent" {
148 return Some(Harness::Cursor);
149 }
150 None
151}
152
153pub fn classify_child(p: &RawProc) -> ProcKind {
155 let prog = p.program().to_ascii_lowercase();
156 let joined = p.cmdline().to_ascii_lowercase();
157 if matches!(prog.as_str(), "zsh" | "bash" | "sh" | "fish" | "dash" | "pwsh" | "cmd") {
158 return ProcKind::Shell;
159 }
160 if looks_like_mcp(&prog, &joined) {
161 return ProcKind::Mcp;
162 }
163 ProcKind::Tool
164}
165
166pub fn looks_like_mcp(prog: &str, joined: &str) -> bool {
170 prog.contains("mcp")
171 || joined.contains("modelcontextprotocol")
172 || joined.contains("mcp-server")
173 || joined.contains("mcp_server")
174 || joined.contains("-mcp ")
175 || joined.ends_with("-mcp")
176 || joined.contains("mcp-")
177 || joined.contains("/mcp/")
178 || joined.contains(" mcp ")
179}
180
181fn now_secs() -> u64 {
182 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
183}
184
185pub fn build_forest(procs: &[RawProc]) -> (Vec<ProcNode>, Vec<ProcNode>) {
191 let by_pid: HashMap<u32, &RawProc> = procs.iter().map(|p| (p.pid, p)).collect();
192 let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
193 for p in procs {
194 if let Some(pp) = p.ppid {
195 children.entry(pp).or_default().push(p.pid);
196 }
197 }
198 let harness_of: HashMap<u32, Harness> = procs.iter().filter_map(|p| classify_agent(p).map(|h| (p.pid, h))).collect();
199
200 let has_agent_ancestor = |mut pid: u32| -> bool {
201 let mut hops = 0;
202 while let Some(p) = by_pid.get(&pid) {
203 match p.ppid {
204 Some(pp) if pp != pid && hops < 64 => {
205 if harness_of.contains_key(&pp) {
206 return true;
207 }
208 pid = pp;
209 hops += 1;
210 }
211 _ => return false,
212 }
213 }
214 false
215 };
216
217 let now = now_secs();
218 fn build(
219 pid: u32,
220 kind: ProcKind,
221 by_pid: &HashMap<u32, &RawProc>,
222 children: &HashMap<u32, Vec<u32>>,
223 harness_of: &HashMap<u32, Harness>,
224 now: u64,
225 depth: usize,
226 ) -> ProcNode {
227 let p = by_pid[&pid];
228 let mut kids = Vec::new();
229 if depth < 32
230 && let Some(cs) = children.get(&pid)
231 {
232 let mut cs = cs.clone();
233 cs.sort_unstable();
234 for c in cs {
235 if c == pid {
236 continue;
237 }
238 let k = if harness_of.contains_key(&c) { ProcKind::Subagent } else { classify_child(by_pid[&c]) };
239 kids.push(build(c, k, by_pid, children, harness_of, now, depth + 1));
240 }
241 }
242 ProcNode {
243 pid,
244 ppid: p.ppid,
245 name: p.program(),
246 cmdline: p.cmdline(),
247 kind,
248 harness: harness_of.get(&pid).copied(),
249 cpu_percent: p.cpu_percent,
250 rss_bytes: p.rss_bytes,
251 age_secs: if p.run_time > 0 { p.run_time } else { now.saturating_sub(p.start_time) },
252 cwd: p.cwd.clone(),
253 children: kids,
254 }
255 }
256
257 let mut roots: Vec<ProcNode> = harness_of
258 .keys()
259 .filter(|pid| !has_agent_ancestor(**pid))
260 .map(|pid| build(*pid, ProcKind::Agent, &by_pid, &children, &harness_of, now, 0))
261 .collect();
262 roots.sort_by_key(|r| r.pid);
263
264 let mut orphans: Vec<ProcNode> = procs
266 .iter()
267 .filter(|p| !harness_of.contains_key(&p.pid))
268 .filter(|p| classify_child(p) == ProcKind::Mcp)
269 .filter(|p| !has_agent_ancestor(p.pid))
270 .map(|p| ProcNode {
271 pid: p.pid,
272 ppid: p.ppid,
273 name: p.program(),
274 cmdline: p.cmdline(),
275 kind: ProcKind::Mcp,
276 harness: None,
277 cpu_percent: p.cpu_percent,
278 rss_bytes: p.rss_bytes,
279 age_secs: if p.run_time > 0 { p.run_time } else { now.saturating_sub(p.start_time) },
280 cwd: p.cwd.clone(),
281 children: Vec::new(),
282 })
283 .collect();
284 let orphan_pids: std::collections::HashSet<u32> = orphans.iter().map(|o| o.pid).collect();
286 orphans.retain(|o| {
287 let mut pid = o.pid;
288 let mut hops = 0;
289 while let Some(p) = by_pid.get(&pid) {
290 match p.ppid {
291 Some(pp) if pp != pid && hops < 64 => {
292 if orphan_pids.contains(&pp) {
293 return false;
294 }
295 pid = pp;
296 hops += 1;
297 }
298 _ => break,
299 }
300 }
301 true
302 });
303 orphans.sort_by_key(|o| std::cmp::Reverse(o.age_secs));
304 (roots, orphans)
305}
306
307pub fn session_id_from_args(cmd: &[String]) -> Option<String> {
309 let mut it = cmd.iter();
310 while let Some(a) = it.next() {
311 if a == "--resume" || a == "-r" || a == "resume" {
312 if let Some(v) = it.next()
313 && looks_like_uuid(v)
314 {
315 return Some(v.clone());
316 }
317 } else if let Some(v) = a.strip_prefix("--resume=")
318 && looks_like_uuid(v)
319 {
320 return Some(v.to_string());
321 }
322 }
323 None
324}
325
326fn looks_like_uuid(s: &str) -> bool {
327 s.len() == 36 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn proc(pid: u32, ppid: Option<u32>, cmd: &[&str]) -> RawProc {
335 RawProc {
336 pid,
337 ppid,
338 name: basename(cmd[0]),
339 exe: None,
340 cmd: cmd.iter().map(|s| s.to_string()).collect(),
341 cwd: None,
342 cpu_percent: 0.0,
343 rss_bytes: 0,
344 start_time: 0,
345 run_time: 1,
346 }
347 }
348
349 #[test]
350 fn classifies_roots_and_children() {
351 let procs = vec![
352 proc(1, None, &["/sbin/launchd"]),
353 proc(10, Some(1), &["claude", "--resume", "a29e19c3-2856-4510-87a0-80ce170ad830"]),
354 proc(11, Some(10), &["/bin/zsh", "-c", "cargo test"]),
355 proc(12, Some(10), &["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"]),
356 proc(13, Some(10), &["claude", "-p", "summarise"]),
357 proc(20, Some(1), &["uvx", "mcp-server-git"]),
358 proc(
359 30,
360 Some(1),
361 &["/Applications/ChatGPT.app/Contents/Frameworks/Codex Framework.framework/Helpers/browser_crashpad_handler"],
362 ),
363 ];
364 let (roots, orphans) = build_forest(&procs);
365 assert_eq!(roots.len(), 1);
366 let root = &roots[0];
367 assert_eq!(root.harness, Some(Harness::Claude));
368 let kinds: Vec<ProcKind> = root.children.iter().map(|c| c.kind).collect();
369 assert_eq!(kinds, vec![ProcKind::Shell, ProcKind::Mcp, ProcKind::Subagent]);
370 assert_eq!(orphans.len(), 1);
371 assert_eq!(orphans[0].pid, 20);
372 assert_eq!(session_id_from_args(&procs[1].cmd).as_deref(), Some("a29e19c3-2856-4510-87a0-80ce170ad830"));
373 }
374}