Skip to main content

agent_top_core/
process.rs

1//! Process enumeration and classification.
2//!
3//! sysinfo gives us the flat process table; this module decides which
4//! processes are agent roots, which are MCP servers, and folds the table into
5//! per-agent trees. Everything here is heuristic and documented as such in
6//! ADR-002; the harness registry (see `harness::claude`) is preferred when it
7//! exists.
8
9use 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    /// Seconds since the Unix epoch.
26    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    /// Basename of argv[0] or the executable, whichever is more informative.
36    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    /// What to read per process. `System::refresh_processes` reads memory, CPU
75    /// and the executable only; the command line and working directory, which
76    /// every classification and attribution heuristic here depends on, have
77    /// to be asked for. Each is read once per process (`OnlyIfNotSet`): a
78    /// command line never changes, and an agent's working directory does not
79    /// change in practice, so the per-tick cost stays at memory and CPU.
80    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
120/// Is this process the root of a coding agent? Which harness?
121pub 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    // Node-hosted CLIs show up as `node <path>/cli.js`; look at the script path too.
127    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
153/// Classify a non-root process by what it looks like.
154pub 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
166/// MCP servers have no wire-level marker visible from the process table, so
167/// this is purely a naming heuristic. False negatives are expected; ADR-002
168/// lists the known ones and RFC-102 proposes a registry-based replacement.
169pub 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
185/// Fold the flat table into a forest of agent trees plus the orphaned MCP list.
186///
187/// An agent root is a process that classifies as a harness and has no
188/// harness ancestor. Harness processes nested under a root become
189/// `Subagent` nodes of that root's tree.
190pub 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    // Orphans: MCP-looking processes with no live agent anywhere above them.
265    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    // Only report the top of each orphaned subtree, not every descendant.
285    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
307/// Extract `--resume <id>` / `-r <id>` style session ids from a command line.
308pub 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}