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::{ProcessesToUpdate, System};
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(ProcessesToUpdate::All, true);
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(ProcessesToUpdate::All, true);
72    }
73
74    pub fn host(&self) -> HostStats {
75        HostStats {
76            hostname: System::host_name(),
77            cpu_percent: self.sys.global_cpu_usage(),
78            cpu_count: self.sys.cpus().len(),
79            mem_used_bytes: self.sys.used_memory(),
80            mem_total_bytes: self.sys.total_memory(),
81        }
82    }
83
84    pub fn processes(&self) -> Vec<RawProc> {
85        self.sys
86            .processes()
87            .iter()
88            .filter(|(pid, _)| Some(pid.as_u32()) != self.self_pid)
89            .map(|(pid, p)| RawProc {
90                pid: pid.as_u32(),
91                ppid: p.parent().map(|x| x.as_u32()),
92                name: p.name().to_string_lossy().into_owned(),
93                exe: p.exe().map(|e| e.to_path_buf()),
94                cmd: p.cmd().iter().map(|c| c.to_string_lossy().into_owned()).collect(),
95                cwd: p.cwd().map(|c| c.to_path_buf()),
96                cpu_percent: p.cpu_usage(),
97                rss_bytes: p.memory(),
98                start_time: p.start_time(),
99                run_time: p.run_time(),
100            })
101            .collect()
102    }
103}
104
105/// Is this process the root of a coding agent? Which harness?
106pub fn classify_agent(p: &RawProc) -> Option<Harness> {
107    let prog = p.program();
108    let prog = prog.strip_suffix(".exe").unwrap_or(&prog).to_ascii_lowercase();
109    let joined = p.cmd.join(" ");
110
111    // Node-hosted CLIs show up as `node <path>/cli.js`; look at the script path too.
112    let script = p.cmd.get(1).map(|s| s.to_ascii_lowercase()).unwrap_or_default();
113
114    if prog == "claude" || script.contains("@anthropic-ai/claude-code") || script.ends_with("/claude") {
115        return Some(Harness::Claude);
116    }
117    if prog == "codex" || script.contains("@openai/codex") {
118        return Some(Harness::Codex);
119    }
120    if prog == "gemini" || script.contains("@google/gemini-cli") {
121        return Some(Harness::Gemini);
122    }
123    if prog == "opencode" {
124        return Some(Harness::OpenCode);
125    }
126    if prog == "aider" || joined.contains("aider/main.py") {
127        return Some(Harness::Aider);
128    }
129    if prog == "copilot" || script.contains("@github/copilot") {
130        return Some(Harness::Copilot);
131    }
132    if prog == "cursor-agent" {
133        return Some(Harness::Cursor);
134    }
135    None
136}
137
138/// Classify a non-root process by what it looks like.
139pub fn classify_child(p: &RawProc) -> ProcKind {
140    let prog = p.program().to_ascii_lowercase();
141    let joined = p.cmdline().to_ascii_lowercase();
142    if matches!(prog.as_str(), "zsh" | "bash" | "sh" | "fish" | "dash" | "pwsh" | "cmd") {
143        return ProcKind::Shell;
144    }
145    if looks_like_mcp(&prog, &joined) {
146        return ProcKind::Mcp;
147    }
148    ProcKind::Tool
149}
150
151/// MCP servers have no wire-level marker visible from the process table, so
152/// this is purely a naming heuristic. False negatives are expected; ADR-002
153/// lists the known ones and RFC-102 proposes a registry-based replacement.
154pub fn looks_like_mcp(prog: &str, joined: &str) -> bool {
155    prog.contains("mcp")
156        || joined.contains("modelcontextprotocol")
157        || joined.contains("mcp-server")
158        || joined.contains("mcp_server")
159        || joined.contains("-mcp ")
160        || joined.ends_with("-mcp")
161        || joined.contains("mcp-")
162        || joined.contains("/mcp/")
163        || joined.contains(" mcp ")
164}
165
166fn now_secs() -> u64 {
167    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
168}
169
170/// Fold the flat table into a forest of agent trees plus the orphaned MCP list.
171///
172/// An agent root is a process that classifies as a harness and has no
173/// harness ancestor. Harness processes nested under a root become
174/// `Subagent` nodes of that root's tree.
175pub fn build_forest(procs: &[RawProc]) -> (Vec<ProcNode>, Vec<ProcNode>) {
176    let by_pid: HashMap<u32, &RawProc> = procs.iter().map(|p| (p.pid, p)).collect();
177    let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
178    for p in procs {
179        if let Some(pp) = p.ppid {
180            children.entry(pp).or_default().push(p.pid);
181        }
182    }
183    let harness_of: HashMap<u32, Harness> = procs.iter().filter_map(|p| classify_agent(p).map(|h| (p.pid, h))).collect();
184
185    let has_agent_ancestor = |mut pid: u32| -> bool {
186        let mut hops = 0;
187        while let Some(p) = by_pid.get(&pid) {
188            match p.ppid {
189                Some(pp) if pp != pid && hops < 64 => {
190                    if harness_of.contains_key(&pp) {
191                        return true;
192                    }
193                    pid = pp;
194                    hops += 1;
195                }
196                _ => return false,
197            }
198        }
199        false
200    };
201
202    let now = now_secs();
203    fn build(
204        pid: u32,
205        kind: ProcKind,
206        by_pid: &HashMap<u32, &RawProc>,
207        children: &HashMap<u32, Vec<u32>>,
208        harness_of: &HashMap<u32, Harness>,
209        now: u64,
210        depth: usize,
211    ) -> ProcNode {
212        let p = by_pid[&pid];
213        let mut kids = Vec::new();
214        if depth < 32
215            && let Some(cs) = children.get(&pid)
216        {
217            let mut cs = cs.clone();
218            cs.sort_unstable();
219            for c in cs {
220                if c == pid {
221                    continue;
222                }
223                let k = if harness_of.contains_key(&c) { ProcKind::Subagent } else { classify_child(by_pid[&c]) };
224                kids.push(build(c, k, by_pid, children, harness_of, now, depth + 1));
225            }
226        }
227        ProcNode {
228            pid,
229            ppid: p.ppid,
230            name: p.program(),
231            cmdline: p.cmdline(),
232            kind,
233            harness: harness_of.get(&pid).copied(),
234            cpu_percent: p.cpu_percent,
235            rss_bytes: p.rss_bytes,
236            age_secs: if p.run_time > 0 { p.run_time } else { now.saturating_sub(p.start_time) },
237            cwd: p.cwd.clone(),
238            children: kids,
239        }
240    }
241
242    let mut roots: Vec<ProcNode> = harness_of
243        .keys()
244        .filter(|pid| !has_agent_ancestor(**pid))
245        .map(|pid| build(*pid, ProcKind::Agent, &by_pid, &children, &harness_of, now, 0))
246        .collect();
247    roots.sort_by_key(|r| r.pid);
248
249    // Orphans: MCP-looking processes with no live agent anywhere above them.
250    let mut orphans: Vec<ProcNode> = procs
251        .iter()
252        .filter(|p| !harness_of.contains_key(&p.pid))
253        .filter(|p| classify_child(p) == ProcKind::Mcp)
254        .filter(|p| !has_agent_ancestor(p.pid))
255        .map(|p| ProcNode {
256            pid: p.pid,
257            ppid: p.ppid,
258            name: p.program(),
259            cmdline: p.cmdline(),
260            kind: ProcKind::Mcp,
261            harness: None,
262            cpu_percent: p.cpu_percent,
263            rss_bytes: p.rss_bytes,
264            age_secs: if p.run_time > 0 { p.run_time } else { now.saturating_sub(p.start_time) },
265            cwd: p.cwd.clone(),
266            children: Vec::new(),
267        })
268        .collect();
269    // Only report the top of each orphaned subtree, not every descendant.
270    let orphan_pids: std::collections::HashSet<u32> = orphans.iter().map(|o| o.pid).collect();
271    orphans.retain(|o| {
272        let mut pid = o.pid;
273        let mut hops = 0;
274        while let Some(p) = by_pid.get(&pid) {
275            match p.ppid {
276                Some(pp) if pp != pid && hops < 64 => {
277                    if orphan_pids.contains(&pp) {
278                        return false;
279                    }
280                    pid = pp;
281                    hops += 1;
282                }
283                _ => break,
284            }
285        }
286        true
287    });
288    orphans.sort_by_key(|o| std::cmp::Reverse(o.age_secs));
289    (roots, orphans)
290}
291
292/// Extract `--resume <id>` / `-r <id>` style session ids from a command line.
293pub fn session_id_from_args(cmd: &[String]) -> Option<String> {
294    let mut it = cmd.iter();
295    while let Some(a) = it.next() {
296        if a == "--resume" || a == "-r" || a == "resume" {
297            if let Some(v) = it.next()
298                && looks_like_uuid(v)
299            {
300                return Some(v.clone());
301            }
302        } else if let Some(v) = a.strip_prefix("--resume=")
303            && looks_like_uuid(v)
304        {
305            return Some(v.to_string());
306        }
307    }
308    None
309}
310
311fn looks_like_uuid(s: &str) -> bool {
312    s.len() == 36 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn proc(pid: u32, ppid: Option<u32>, cmd: &[&str]) -> RawProc {
320        RawProc {
321            pid,
322            ppid,
323            name: basename(cmd[0]),
324            exe: None,
325            cmd: cmd.iter().map(|s| s.to_string()).collect(),
326            cwd: None,
327            cpu_percent: 0.0,
328            rss_bytes: 0,
329            start_time: 0,
330            run_time: 1,
331        }
332    }
333
334    #[test]
335    fn classifies_roots_and_children() {
336        let procs = vec![
337            proc(1, None, &["/sbin/launchd"]),
338            proc(10, Some(1), &["claude", "--resume", "a29e19c3-2856-4510-87a0-80ce170ad830"]),
339            proc(11, Some(10), &["/bin/zsh", "-c", "cargo test"]),
340            proc(12, Some(10), &["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"]),
341            proc(13, Some(10), &["claude", "-p", "summarise"]),
342            proc(20, Some(1), &["uvx", "mcp-server-git"]),
343            proc(
344                30,
345                Some(1),
346                &["/Applications/ChatGPT.app/Contents/Frameworks/Codex Framework.framework/Helpers/browser_crashpad_handler"],
347            ),
348        ];
349        let (roots, orphans) = build_forest(&procs);
350        assert_eq!(roots.len(), 1);
351        let root = &roots[0];
352        assert_eq!(root.harness, Some(Harness::Claude));
353        let kinds: Vec<ProcKind> = root.children.iter().map(|c| c.kind).collect();
354        assert_eq!(kinds, vec![ProcKind::Shell, ProcKind::Mcp, ProcKind::Subagent]);
355        assert_eq!(orphans.len(), 1);
356        assert_eq!(orphans[0].pid, 20);
357        assert_eq!(session_id_from_args(&procs[1].cmd).as_deref(), Some("a29e19c3-2856-4510-87a0-80ce170ad830"));
358    }
359}