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    /// The npm launcher forwards argv[2..] unchanged to the native runtime.
43    /// Verified against openai/codex rust-v0.154.0, codex-cli/bin/codex.js.
44    fn is_codex_launcher(&self) -> bool {
45        matches!(self.program().to_ascii_lowercase().as_str(), "node" | "node.exe" | "nodejs" | "bun" | "bun.exe")
46            && self.cmd.get(1).is_some_and(|s| s.replace('\\', "/").ends_with("/@openai/codex/bin/codex.js"))
47    }
48
49    fn codex_args(&self) -> Option<&[String]> {
50        if matches!(self.program().to_ascii_lowercase().as_str(), "codex" | "codex.exe") {
51            Some(self.cmd.get(1..).unwrap_or_default())
52        } else if self.is_codex_launcher() {
53            self.cmd.get(2..)
54        } else {
55            None
56        }
57    }
58
59    /// Whether this is the native runtime directly spawned by an npm launcher
60    /// for the same invocation, rather than a separately launched agent.
61    pub(crate) fn is_codex_runtime_of(&self, parent: &RawProc) -> bool {
62        self.ppid == Some(parent.pid)
63            && parent.is_codex_launcher()
64            && !self.is_codex_launcher()
65            && classify_agent(self) == Some(Harness::Codex)
66            && self.codex_args() == parent.codex_args()
67    }
68}
69
70fn basename(s: &str) -> String {
71    s.rsplit('/').next().unwrap_or(s).to_string()
72}
73
74pub struct ProcessScanner {
75    sys: System,
76    self_pid: Option<u32>,
77}
78
79impl Default for ProcessScanner {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl ProcessScanner {
86    pub fn new() -> Self {
87        let mut sys = System::new();
88        sys.refresh_memory();
89        sys.refresh_cpu_usage();
90        sys.refresh_processes_specifics(ProcessesToUpdate::All, true, Self::refresh_kind());
91        let self_pid = sysinfo::get_current_pid().ok().map(|p| p.as_u32());
92        ProcessScanner { sys, self_pid }
93    }
94
95    pub fn refresh(&mut self) {
96        self.sys.refresh_memory();
97        self.sys.refresh_cpu_usage();
98        self.sys.refresh_processes_specifics(ProcessesToUpdate::All, true, Self::refresh_kind());
99    }
100
101    /// What to read per process. `System::refresh_processes` reads memory, CPU
102    /// and the executable only; the command line and working directory, which
103    /// every classification and attribution heuristic here depends on, have
104    /// to be asked for. Each is read once per process (`OnlyIfNotSet`): a
105    /// command line never changes, and an agent's working directory does not
106    /// change in practice, so the per-tick cost stays at memory and CPU.
107    /// `nothing()` still includes Linux tasks by default. Exclude them: worker
108    /// threads share the process's command line and RSS, and process CPU already
109    /// includes their work. Treating them as children invents subagents and
110    /// counts the same resources again for every thread.
111    fn refresh_kind() -> ProcessRefreshKind {
112        ProcessRefreshKind::nothing()
113            .without_tasks()
114            .with_memory()
115            .with_cpu()
116            .with_exe(UpdateKind::OnlyIfNotSet)
117            .with_cmd(UpdateKind::OnlyIfNotSet)
118            .with_cwd(UpdateKind::OnlyIfNotSet)
119    }
120
121    pub fn host(&self) -> HostStats {
122        HostStats {
123            hostname: System::host_name(),
124            cpu_percent: self.sys.global_cpu_usage(),
125            cpu_count: self.sys.cpus().len(),
126            mem_used_bytes: self.sys.used_memory(),
127            mem_total_bytes: self.sys.total_memory(),
128        }
129    }
130
131    pub fn processes(&self) -> Vec<RawProc> {
132        self.sys
133            .processes()
134            .iter()
135            .filter(|(pid, _)| Some(pid.as_u32()) != self.self_pid)
136            .map(|(pid, p)| RawProc {
137                pid: pid.as_u32(),
138                ppid: p.parent().map(|x| x.as_u32()),
139                name: p.name().to_string_lossy().into_owned(),
140                exe: p.exe().map(|e| e.to_path_buf()),
141                cmd: p.cmd().iter().map(|c| c.to_string_lossy().into_owned()).collect(),
142                cwd: p.cwd().map(|c| c.to_path_buf()),
143                cpu_percent: p.cpu_usage(),
144                rss_bytes: p.memory(),
145                start_time: p.start_time(),
146                run_time: p.run_time(),
147            })
148            .collect()
149    }
150}
151
152/// Is this process the root of a coding agent? Which harness?
153pub fn classify_agent(p: &RawProc) -> Option<Harness> {
154    let prog = p.program();
155    let prog = prog.strip_suffix(".exe").unwrap_or(&prog).to_ascii_lowercase();
156    let joined = p.cmd.join(" ");
157
158    // Node-hosted CLIs show up as `node <path>/cli.js`; look at the script path too.
159    let script = p.cmd.get(1).map(|s| s.to_ascii_lowercase()).unwrap_or_default();
160
161    if prog == "claude" || script.contains("@anthropic-ai/claude-code") || script.ends_with("/claude") {
162        return Some(Harness::Claude);
163    }
164    if let Some(args) = p.codex_args() {
165        return (!codex_helper(args)).then_some(Harness::Codex);
166    }
167    if prog == "gemini" || script.contains("@google/gemini-cli") {
168        return Some(Harness::Gemini);
169    }
170    if prog == "opencode" {
171        return Some(Harness::OpenCode);
172    }
173    if prog == "kodelet" {
174        let args = kodelet_command(p.cmd.get(1..).unwrap_or_default());
175        return (args.first().map(String::as_str) == Some("serve")
176            || (args.first().map(String::as_str) == Some("runner") && args.get(1).map(String::as_str) == Some("start")))
177        .then_some(Harness::Kodelet);
178    }
179    if prog == "aider" || joined.contains("aider/main.py") {
180        return Some(Harness::Aider);
181    }
182    if prog == "copilot" || script.contains("@github/copilot") {
183        return Some(Harness::Copilot);
184    }
185    if prog == "cursor-agent" {
186        return Some(Harness::Cursor);
187    }
188    None
189}
190
191/// Classify a non-root process by what it looks like.
192pub fn classify_child(p: &RawProc) -> ProcKind {
193    let prog = p.program().to_ascii_lowercase();
194    let joined = p.cmdline().to_ascii_lowercase();
195    // In particular, `codex mcp list` manages servers; it is not itself one.
196    if p.codex_args().is_some_and(codex_helper) {
197        return ProcKind::Tool;
198    }
199    // Kodelet clients and extension workers are not MCP servers, even if a
200    // prompt, extension name or profile happens to contain "mcp".
201    if prog == "kodelet" || prog.starts_with("kodelet-extension-") || prog == "kodelet-subagent" {
202        return ProcKind::Tool;
203    }
204    if matches!(prog.as_str(), "zsh" | "bash" | "sh" | "fish" | "dash" | "pwsh" | "cmd") {
205        return ProcKind::Shell;
206    }
207    if looks_like_mcp(&prog, &joined) {
208        return ProcKind::Mcp;
209    }
210    ProcKind::Tool
211}
212
213/// Explicit helper invocations from Codex 0.154 and main 7a3c5a83e (2026-09-17)
214/// cli/arg0 dispatch. Match positions, never words inside prompts or commands.
215/// This is deliberately not a full Codex option parser; unrecognised forms
216/// retain the existing harness-name heuristic.
217fn codex_helper(args: &[String]) -> bool {
218    matches!(
219        args.first().map(String::as_str),
220        Some(
221            "--codex-run-as-apply-patch"
222                | "--codex-run-as-arg0-exec-helper"
223                | "--codex-run-as-fs-helper"
224                | "--run-as-windows-sandbox"
225                | "--__codex-windows-mxc"
226                | "mcp"
227                | "sandbox"
228                | "exec-server"
229                | "stdio-to-uds"
230                | "responses-api-proxy"
231        )
232    ) || (args.first().map(String::as_str) == Some("app-server") && args.get(1).map(String::as_str) == Some("daemon"))
233}
234
235/// Kodelet v0.6.17-beta executes conversations in `serve` or `runner start`.
236/// `run`, `chat` and `acp` are thin clients, not additional agent hosts.
237/// Skip only known persistent flags; never search prompt text for a command.
238pub(crate) fn kodelet_command(mut args: &[String]) -> &[String] {
239    while let Some(arg) = args.first() {
240        let flag = arg.split('=').next().unwrap_or(arg);
241        let takes_value = match flag {
242            "--provider"
243            | "--model"
244            | "--max-tokens"
245            | "--thinking-budget-tokens"
246            | "--weak-model"
247            | "--weak-model-max-tokens"
248            | "--reasoning-effort"
249            | "--log-level"
250            | "--log-format"
251            | "--allowed-commands"
252            | "--allowed-domains-file"
253            | "--sysprompt"
254            | "--sysprompt-arg"
255            | "--allowed-tools"
256            | "--tool-mode"
257            | "--anthropic-api-access"
258            | "--profile"
259            | "--context-patterns"
260            | "--compact-ratio" => true,
261            "--enable-openai-search" | "--no-skills" | "--enable-fs-search-tools" => false,
262            _ => break,
263        };
264        let count = if takes_value && !arg.contains('=') { 2 } else { 1 };
265        args = args.get(count..).unwrap_or_default();
266    }
267    args
268}
269
270/// MCP servers have no wire-level marker visible from the process table, so
271/// this is purely a naming heuristic. False negatives are expected; ADR-002
272/// lists the known ones and RFC-102 proposes a registry-based replacement.
273pub fn looks_like_mcp(prog: &str, joined: &str) -> bool {
274    prog.contains("mcp")
275        || joined.contains("modelcontextprotocol")
276        || joined.contains("mcp-server")
277        || joined.contains("mcp_server")
278        || joined.contains("-mcp ")
279        || joined.ends_with("-mcp")
280        || joined.contains("mcp-")
281        || joined.contains("/mcp/")
282        || joined.contains(" mcp ")
283}
284
285fn now_secs() -> u64 {
286    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
287}
288
289/// Fold the flat table into a forest of agent trees plus the orphaned MCP list.
290///
291/// An agent root is a process that classifies as a harness and has no
292/// harness ancestor. Nested harness processes are also `Agent` nodes; their
293/// position records OS ancestry, not a logical subagent relationship.
294pub fn build_forest(procs: &[RawProc]) -> (Vec<ProcNode>, Vec<ProcNode>) {
295    let by_pid: HashMap<u32, &RawProc> = procs.iter().map(|p| (p.pid, p)).collect();
296    let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
297    for p in procs {
298        if let Some(pp) = p.ppid {
299            children.entry(pp).or_default().push(p.pid);
300        }
301    }
302    let harness_of: HashMap<u32, Harness> = procs.iter().filter_map(|p| classify_agent(p).map(|h| (p.pid, h))).collect();
303
304    let has_agent_ancestor = |mut pid: u32| -> bool {
305        let mut hops = 0;
306        while let Some(p) = by_pid.get(&pid) {
307            match p.ppid {
308                Some(pp) if pp != pid && hops < 64 => {
309                    if harness_of.contains_key(&pp) {
310                        return true;
311                    }
312                    pid = pp;
313                    hops += 1;
314                }
315                _ => return false,
316            }
317        }
318        false
319    };
320
321    let now = now_secs();
322    fn build(
323        pid: u32,
324        kind: ProcKind,
325        by_pid: &HashMap<u32, &RawProc>,
326        children: &HashMap<u32, Vec<u32>>,
327        harness_of: &HashMap<u32, Harness>,
328        now: u64,
329        depth: usize,
330    ) -> ProcNode {
331        let p = by_pid[&pid];
332        let mut kids = Vec::new();
333        if depth < 32
334            && let Some(cs) = children.get(&pid)
335        {
336            let mut cs = cs.clone();
337            cs.sort_unstable();
338            for c in cs {
339                if c == pid {
340                    continue;
341                }
342                let k = if harness_of.contains_key(&c) { ProcKind::Agent } else { classify_child(by_pid[&c]) };
343                kids.push(build(c, k, by_pid, children, harness_of, now, depth + 1));
344            }
345        }
346        ProcNode {
347            pid,
348            ppid: p.ppid,
349            name: p.program(),
350            cmdline: p.cmdline(),
351            kind,
352            harness: harness_of.get(&pid).copied(),
353            cpu_percent: p.cpu_percent,
354            rss_bytes: p.rss_bytes,
355            age_secs: if p.run_time > 0 { p.run_time } else { now.saturating_sub(p.start_time) },
356            cwd: p.cwd.clone(),
357            children: kids,
358        }
359    }
360
361    let mut roots: Vec<ProcNode> = harness_of
362        .keys()
363        .filter(|pid| !has_agent_ancestor(**pid))
364        .map(|pid| build(*pid, ProcKind::Agent, &by_pid, &children, &harness_of, now, 0))
365        .collect();
366    roots.sort_by_key(|r| r.pid);
367
368    // Orphans: MCP-looking processes with no live agent anywhere above them.
369    let mut orphans: Vec<ProcNode> = procs
370        .iter()
371        .filter(|p| !harness_of.contains_key(&p.pid))
372        .filter(|p| classify_child(p) == ProcKind::Mcp)
373        .filter(|p| !has_agent_ancestor(p.pid))
374        .map(|p| ProcNode {
375            pid: p.pid,
376            ppid: p.ppid,
377            name: p.program(),
378            cmdline: p.cmdline(),
379            kind: ProcKind::Mcp,
380            harness: None,
381            cpu_percent: p.cpu_percent,
382            rss_bytes: p.rss_bytes,
383            age_secs: if p.run_time > 0 { p.run_time } else { now.saturating_sub(p.start_time) },
384            cwd: p.cwd.clone(),
385            children: Vec::new(),
386        })
387        .collect();
388    // Only report the top of each orphaned subtree, not every descendant.
389    let orphan_pids: std::collections::HashSet<u32> = orphans.iter().map(|o| o.pid).collect();
390    orphans.retain(|o| {
391        let mut pid = o.pid;
392        let mut hops = 0;
393        while let Some(p) = by_pid.get(&pid) {
394            match p.ppid {
395                Some(pp) if pp != pid && hops < 64 => {
396                    if orphan_pids.contains(&pp) {
397                        return false;
398                    }
399                    pid = pp;
400                    hops += 1;
401                }
402                _ => break,
403            }
404        }
405        true
406    });
407    orphans.sort_by_key(|o| std::cmp::Reverse(o.age_secs));
408    (roots, orphans)
409}
410
411/// Extract `--resume <id>` / `-r <id>` style session ids from a command line.
412pub fn session_id_from_args(cmd: &[String]) -> Option<String> {
413    let mut it = cmd.iter();
414    while let Some(a) = it.next() {
415        if a == "--resume" || a == "-r" || a == "resume" {
416            if let Some(v) = it.next()
417                && looks_like_uuid(v)
418            {
419                return Some(v.clone());
420            }
421        } else if let Some(v) = a.strip_prefix("--resume=")
422            && looks_like_uuid(v)
423        {
424            return Some(v.to_string());
425        }
426    }
427    None
428}
429
430fn looks_like_uuid(s: &str) -> bool {
431    s.len() == 36 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn proc(pid: u32, ppid: Option<u32>, cmd: &[&str]) -> RawProc {
439        RawProc {
440            pid,
441            ppid,
442            name: basename(cmd[0]),
443            exe: None,
444            cmd: cmd.iter().map(|s| s.to_string()).collect(),
445            cwd: None,
446            cpu_percent: 0.0,
447            rss_bytes: 0,
448            start_time: 0,
449            run_time: 1,
450        }
451    }
452
453    #[test]
454    fn kodelet_execution_hosts_are_agents_but_clients_and_helpers_are_not() {
455        for cmd in [
456            vec!["kodelet", "serve", "--managed"],
457            vec!["/usr/local/bin/kodelet", "serve", "--embedded-runner=false"],
458            vec!["kodelet", "runner", "start"],
459            vec!["kodelet", "--log-level", "debug", "--profile=coding", "serve"],
460            vec!["kodelet", "--no-skills", "runner", "start"],
461        ] {
462            assert_eq!(classify_agent(&proc(1, None, &cmd)), Some(Harness::Kodelet), "{cmd:?}");
463        }
464        for cmd in [
465            vec!["kodelet"],
466            vec!["kodelet", "run", "serve mcp"],
467            vec!["kodelet", "chat", "--resume", "20260919T090000-abcdef"],
468            vec!["kodelet", "acp"],
469            vec!["kodelet", "runner", "list"],
470            vec!["kodelet", "server", "logs"],
471            vec!["kodelet", "conversation", "show", "abc"],
472            vec!["kodelet", "--model", "serve"],
473            vec!["kodelet", "--", "serve"],
474            vec!["kodelet-extension-code-search"],
475            vec!["kodelet-extension-mcp"],
476            vec!["kodelet-subagent"],
477            vec!["cat", "/usr/local/bin/kodelet"],
478        ] {
479            let p = proc(1, None, &cmd);
480            assert_eq!(classify_agent(&p), None, "{cmd:?}");
481            assert_eq!(classify_child(&p), ProcKind::Tool, "{cmd:?}");
482        }
483    }
484
485    #[test]
486    fn refreshes_processes_without_tasks() {
487        let kind = ProcessScanner::refresh_kind();
488        assert!(!kind.tasks(), "Linux threads share their process's RSS and must not become tree nodes");
489        assert!(kind.memory());
490        assert!(kind.cpu());
491    }
492
493    #[cfg(target_os = "linux")]
494    #[test]
495    fn scanner_excludes_live_worker_threads() {
496        use std::sync::mpsc;
497
498        std::thread::scope(|scope| {
499            let (ready, tid) = mpsc::channel();
500            let (_release, wait) = mpsc::channel::<()>();
501            scope.spawn(move || {
502                let path = std::fs::read_link("/proc/thread-self").unwrap();
503                let tid: u32 = path.file_name().unwrap().to_str().unwrap().parse().unwrap();
504                ready.send(tid).unwrap();
505                // Stay alive during both scans; dropping the sender also releases
506                // the worker if an assertion panics.
507                let _ = wait.recv();
508            });
509            let tid = tid.recv().unwrap();
510            let pid = std::process::id();
511            assert_ne!(tid, pid);
512
513            let mut scanner = ProcessScanner::new();
514            // Include the test process so we can check that only its leader is
515            // kept, independently of agent-top's normal self-PID exclusion.
516            scanner.self_pid = None;
517            for _ in 0..2 {
518                let procs = scanner.processes();
519                assert!(procs.iter().any(|p| p.pid == pid), "the process itself must remain visible");
520                assert!(!procs.iter().any(|p| p.pid == tid), "a worker thread is not a child process");
521                scanner.refresh();
522            }
523        });
524    }
525
526    #[test]
527    fn nested_harnesses_are_agents_with_each_process_counted_once() {
528        let mut procs = vec![
529            proc(10, None, &["node", "/usr/lib/node_modules/@openai/codex/bin/codex.js", "--yolo"]),
530            proc(11, Some(10), &["/opt/codex/bin/codex", "--yolo"]),
531            proc(12, Some(11), &["bash", "-c", "codex --yolo"]),
532            proc(13, Some(12), &["codex", "--yolo"]),
533            proc(14, Some(10), &["codex", "exec", "review"]),
534            proc(15, Some(11), &["codex", "--yolo"]),
535        ];
536        for p in &mut procs {
537            p.rss_bytes = 100;
538            p.cpu_percent = 1.0;
539        }
540        let (roots, orphans) = build_forest(&procs);
541        assert!(orphans.is_empty());
542        assert_eq!(roots.len(), 1);
543        let root = &roots[0];
544        assert_eq!(root.pid, 10);
545        let runtime = &root.children[0];
546        assert_eq!(runtime.pid, 11);
547        assert_eq!(runtime.kind, ProcKind::Agent);
548        assert_eq!(runtime.children[0].kind, ProcKind::Shell);
549        assert_eq!(runtime.children[0].children[0].kind, ProcKind::Agent, "tool-launched harnesses are still agents");
550        assert_eq!(runtime.children[1].kind, ProcKind::Agent);
551        assert_eq!(root.children[1].kind, ProcKind::Agent);
552        assert_eq!(root.totals(), (6.0, 600, 6, 0), "each real PID contributes resources exactly once");
553    }
554
555    #[test]
556    fn codex_runtime_matches_only_its_launcher_and_forwarded_arguments() {
557        let launcher = proc(10, None, &["node", "/usr/lib/node_modules/@openai/codex/bin/codex.js", "exec", "review the diff"]);
558        let runtime = proc(11, Some(10), &["/opt/codex/bin/codex", "exec", "review the diff"]);
559        assert!(runtime.is_codex_runtime_of(&launcher));
560
561        for child in [
562            proc(12, Some(10), &["codex", "exec", "different task"]),
563            proc(12, Some(10), &["codex", "exec", "review", "the", "diff"]),
564            proc(12, Some(99), &["codex", "exec", "review the diff"]),
565            proc(12, Some(10), &["node", "/usr/lib/node_modules/@openai/codex/bin/codex.js", "exec", "review the diff"]),
566        ] {
567            assert!(!child.is_codex_runtime_of(&launcher), "not the forwarded runtime: {child:?}");
568        }
569        let nested = proc(12, Some(11), &["codex", "exec", "review the diff"]);
570        assert!(!nested.is_codex_runtime_of(&runtime), "a native agent is not a launcher");
571
572        let launcher = proc(10, None, &["node", "/usr/lib/node_modules/@openai/codex/bin/codex.js", "mcp", "list"]);
573        let helper = proc(11, Some(10), &["codex", "mcp", "list"]);
574        assert!(!helper.is_codex_runtime_of(&launcher), "a management helper does not own agent rollouts");
575    }
576
577    #[test]
578    fn legacy_process_subagent_kind_deserializes_as_agent() {
579        let kind: ProcKind = serde_json::from_str("\"subagent\"").unwrap();
580        assert_eq!(kind, ProcKind::Agent);
581        assert_eq!(kind.label(), "agent");
582        assert_eq!(serde_json::to_value(kind).unwrap(), "agent");
583    }
584
585    #[test]
586    fn codex_helpers_are_tools_not_agents_or_mcp_servers() {
587        let helpers: &[&[&str]] = &[
588            &["codex", "--codex-run-as-apply-patch", "patch"],
589            &["codex", "--codex-run-as-arg0-exec-helper"],
590            &["codex", "--codex-run-as-fs-helper"],
591            &["codex.exe", "--run-as-windows-sandbox"],
592            &["codex.exe", "--__codex-windows-mxc"],
593            &["codex", "mcp", "list"],
594            &["codex", "sandbox", "--", "sh"],
595            &["codex", "exec-server"],
596            &["codex", "stdio-to-uds", "/tmp/socket"],
597            &["codex", "responses-api-proxy"],
598            &["codex", "app-server", "daemon", "start"],
599            &["node", "/usr/lib/node_modules/@openai/codex/bin/codex.js", "mcp", "list"],
600            &["codex-linux-sandbox", "--", "sh"],
601            &["apply_patch", "patch"],
602            &["applypatch", "patch"],
603        ];
604        let mut procs = vec![proc(1, None, &["codex", "--yolo"])];
605        for (i, cmd) in helpers.iter().enumerate() {
606            let mut p = proc(i as u32 + 2, Some(1), cmd);
607            // argv[0] dispatch aliases may all resolve to the Codex executable.
608            p.exe = Some(PathBuf::from("/opt/codex/bin/codex"));
609            assert_eq!(classify_agent(&p), None, "{cmd:?}");
610            assert_eq!(classify_child(&p), ProcKind::Tool, "{cmd:?}");
611            procs.push(p);
612        }
613        let (roots, orphans) = build_forest(&procs);
614        assert_eq!(roots.len(), 1);
615        assert!(roots[0].children.iter().all(|p| p.kind == ProcKind::Tool));
616        assert!(orphans.is_empty());
617
618        for cmd in [
619            vec!["codex"],
620            vec!["codex", "app-server", "--listen", "stdio://"],
621            vec!["codex", "exec", "mcp"],
622            vec!["codex", "--", "sandbox"],
623            vec!["codex", "review"],
624            vec!["codex", "resume", "--last"],
625            // This became a subcommand after 0.154, but is a valid prompt in
626            // that release. Do not exclude it without knowing the version.
627            vec!["codex", "tcp-tunnel"],
628        ] {
629            assert_eq!(classify_agent(&proc(20, None, &cmd)), Some(Harness::Codex), "{cmd:?}");
630        }
631        assert_eq!(
632            classify_agent(&proc(20, None, &["cat", "/usr/lib/node_modules/@openai/codex/bin/codex.js"])),
633            None,
634            "mentioning the launcher path is not running it"
635        );
636    }
637
638    #[test]
639    fn classifies_roots_and_children() {
640        let procs = vec![
641            proc(1, None, &["/sbin/launchd"]),
642            proc(10, Some(1), &["claude", "--resume", "a29e19c3-2856-4510-87a0-80ce170ad830"]),
643            proc(11, Some(10), &["/bin/zsh", "-c", "cargo test"]),
644            proc(12, Some(10), &["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"]),
645            proc(13, Some(10), &["claude", "-p", "summarise"]),
646            proc(20, Some(1), &["uvx", "mcp-server-git"]),
647            proc(
648                30,
649                Some(1),
650                &["/Applications/ChatGPT.app/Contents/Frameworks/Codex Framework.framework/Helpers/browser_crashpad_handler"],
651            ),
652        ];
653        let (roots, orphans) = build_forest(&procs);
654        assert_eq!(roots.len(), 1);
655        let root = &roots[0];
656        assert_eq!(root.harness, Some(Harness::Claude));
657        let kinds: Vec<ProcKind> = root.children.iter().map(|c| c.kind).collect();
658        assert_eq!(kinds, vec![ProcKind::Shell, ProcKind::Mcp, ProcKind::Agent]);
659        assert_eq!(orphans.len(), 1);
660        assert_eq!(orphans[0].pid, 20);
661        assert_eq!(session_id_from_args(&procs[1].cmd).as_deref(), Some("a29e19c3-2856-4510-87a0-80ce170ad830"));
662    }
663}