Skip to main content

agentsight_capture/view/
process_select.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4use crate::sources::proc::{PidSeed, ProcInfo, ProcSnapshot};
5use std::collections::HashSet;
6
7pub fn live_root_pids(snapshot: &ProcSnapshot, pid: Option<u32>, comm: Option<&str>) -> Vec<u32> {
8    if let Some(pid) = pid {
9        return snapshot
10            .procs
11            .contains_key(&pid)
12            .then_some(vec![pid])
13            .unwrap_or_default();
14    }
15
16    if let Some(comm) = comm {
17        return root_pids_matching_comm(snapshot, comm);
18    }
19
20    root_pids_for_known_agents(snapshot)
21}
22
23pub fn seeds_for_comm(snapshot: &ProcSnapshot, comm: &str) -> Vec<PidSeed> {
24    seeds_for_roots(snapshot, root_pids_matching_comm(snapshot, comm))
25}
26
27pub fn process_seeds(
28    snapshot: &ProcSnapshot,
29    session_id: Option<u32>,
30    pid: Option<u32>,
31    comm: Option<&str>,
32    include_all: bool,
33) -> Vec<PidSeed> {
34    if let Some(session_id) = session_id {
35        snapshot.seeds_for_session(session_id)
36    } else if let Some(pid) = pid {
37        snapshot.seeds_for_pid_family(pid)
38    } else if let Some(comm) = comm {
39        seeds_for_comm(snapshot, comm)
40    } else if include_all {
41        snapshot.seeds_for_all()
42    } else {
43        Vec::new()
44    }
45}
46
47pub fn pids_matching_comm(snapshot: &ProcSnapshot, comm: &str) -> Vec<u32> {
48    snapshot
49        .procs
50        .values()
51        .filter(|proc_info| process_matches_comm(proc_info, comm))
52        .map(|proc_info| proc_info.pid)
53        .collect()
54}
55
56pub fn agent_label_from_command(comm: &str, command: &str) -> String {
57    known_agent_label(comm, command)
58        .map(str::to_string)
59        .unwrap_or_else(|| {
60            if !comm.is_empty() && comm != "unknown" {
61                comm.to_string()
62            } else {
63                command
64                    .split_whitespace()
65                    .next()
66                    .unwrap_or("agent")
67                    .to_string()
68            }
69        })
70}
71
72pub fn known_agent_label(comm: &str, command: &str) -> Option<&'static str> {
73    label_from_exec_token(comm).or_else(|| label_from_command_argv(command))
74}
75
76fn root_pids_matching_comm(snapshot: &ProcSnapshot, comm: &str) -> Vec<u32> {
77    sorted_root_pids(snapshot, |proc_info| process_matches_comm(proc_info, comm))
78}
79
80fn root_pids_for_known_agents(snapshot: &ProcSnapshot) -> Vec<u32> {
81    let mut roots = Vec::new();
82    for proc_info in snapshot.procs.values() {
83        let Some(label) = known_agent_label(&proc_info.comm, &proc_info.command) else {
84            continue;
85        };
86        let nested_codex_exec = label == "codex" && is_codex_exec_invocation(proc_info);
87        if has_matching_ancestor(snapshot, proc_info, |parent| {
88            known_agent_label(&parent.comm, &parent.command) == Some(label)
89                && (!nested_codex_exec || is_codex_exec_invocation(parent))
90        }) {
91            continue;
92        }
93        roots.push(proc_info.pid);
94    }
95    roots
96}
97
98fn sorted_root_pids(
99    snapshot: &ProcSnapshot,
100    matches: impl Fn(&ProcInfo) -> bool + Copy,
101) -> Vec<u32> {
102    let mut roots = Vec::new();
103    for proc_info in snapshot.procs.values() {
104        if !matches(proc_info) {
105            continue;
106        }
107        if has_matching_ancestor(snapshot, proc_info, matches) {
108            continue;
109        }
110        roots.push(proc_info.pid);
111    }
112    roots.sort_unstable();
113    roots
114}
115
116fn seeds_for_roots(snapshot: &ProcSnapshot, roots: Vec<u32>) -> Vec<PidSeed> {
117    let mut seen = HashSet::new();
118    let mut out = Vec::new();
119    for pid in roots {
120        for family_pid in snapshot.process_family(pid) {
121            if seen.insert(family_pid)
122                && let Some(proc_info) = snapshot.procs.get(&family_pid)
123            {
124                out.push(proc_info.seed());
125            }
126        }
127    }
128    out
129}
130
131fn has_matching_ancestor(
132    snapshot: &ProcSnapshot,
133    proc_info: &ProcInfo,
134    matches: impl Fn(&ProcInfo) -> bool,
135) -> bool {
136    let mut parent_pid = proc_info.ppid;
137    let mut seen = HashSet::new();
138    while parent_pid > 0 && seen.insert(parent_pid) {
139        let Some(parent) = snapshot.procs.get(&parent_pid) else {
140            break;
141        };
142        if matches(parent) {
143            return true;
144        }
145        parent_pid = parent.ppid;
146    }
147    false
148}
149
150fn process_matches_comm(proc_info: &ProcInfo, wanted: &str) -> bool {
151    let wanted = wanted.to_ascii_lowercase();
152    if proc_info.comm.to_ascii_lowercase().contains(&wanted) {
153        return true;
154    }
155    executable_tokens(&proc_info.command).any(|token| executable_token_matches(token, &wanted))
156}
157
158fn label_from_command_argv(command: &str) -> Option<&'static str> {
159    let mut args = command.split_whitespace();
160    let argv0 = args.next()?;
161    if let Some(label) = label_from_exec_token(argv0) {
162        return Some(label);
163    }
164
165    args.filter(|arg| looks_like_exec_path(arg))
166        .find_map(label_from_exec_token)
167}
168
169fn executable_tokens(command: &str) -> impl Iterator<Item = &str> {
170    let mut first = true;
171    command.split_whitespace().filter(move |arg| {
172        let keep = first || looks_like_exec_path(arg);
173        first = false;
174        keep
175    })
176}
177
178fn looks_like_exec_path(token: &str) -> bool {
179    let token = token.trim_matches(|ch| matches!(ch, '"' | '\''));
180    token.contains(['/', '\\'])
181}
182
183fn executable_token_matches(token: &str, wanted: &str) -> bool {
184    let token = token.trim_matches(|ch| matches!(ch, '"' | '\''));
185    if token.is_empty() {
186        return false;
187    }
188
189    let lower = token.to_ascii_lowercase();
190    if label_from_exec_token(&lower).is_some_and(|label| label.contains(wanted)) {
191        return true;
192    }
193    let basename = exec_basename(&lower);
194    !basename.contains('.') && basename.contains(wanted)
195}
196
197fn label_from_exec_token(token: &str) -> Option<&'static str> {
198    let token = token.trim_matches(|ch| matches!(ch, '"' | '\''));
199    if token.is_empty() {
200        return None;
201    }
202
203    let lower = token.to_ascii_lowercase();
204    let basename = exec_basename(&lower);
205    let executable = basename
206        .strip_suffix(".exe")
207        .or_else(|| basename.strip_suffix(".cmd"))
208        .or_else(|| basename.strip_suffix(".bat"))
209        .unwrap_or(basename);
210
211    label_from_exec_name(executable).or_else(|| label_from_known_package_path(&lower))
212}
213
214fn exec_basename(token: &str) -> &str {
215    token.rsplit(['/', '\\']).next().unwrap_or(token)
216}
217
218fn label_from_exec_name(name: &str) -> Option<&'static str> {
219    match name {
220        "claude" | "claude-code" => Some("claude"),
221        "codex" | "codex-cli" => Some("codex"),
222        "gemini" | "gemini-cli" => Some("gemini"),
223        "opencode" => Some("opencode"),
224        "aider" => Some("aider"),
225        "goose" => Some("goose"),
226        "openclaw" => Some("openclaw"),
227        name if name.starts_with("openclaw-") => Some("openclaw"),
228        _ => None,
229    }
230}
231
232fn label_from_known_package_path(path: &str) -> Option<&'static str> {
233    let path = path.replace('\\', "/");
234    if path.contains("@anthropic-ai/claude-code") || path.contains("/claude-code/") {
235        Some("claude")
236    } else if path.contains("@openai/codex") || path.contains("/codex-linux-") {
237        Some("codex")
238    } else if path.contains("@google/gemini-cli") || path.contains("/gemini-cli/") {
239        Some("gemini")
240    } else {
241        None
242    }
243}
244
245pub fn is_codex_exec_invocation(proc_info: &ProcInfo) -> bool {
246    known_agent_label(&proc_info.comm, &proc_info.command) == Some("codex")
247        && has_codex_exec_subcommand(&proc_info.command)
248}
249
250fn has_codex_exec_subcommand(command: &str) -> bool {
251    let mut previous = "";
252    for token in command.split_whitespace() {
253        if is_codex_executable_token(previous) && token == "exec" {
254            return true;
255        }
256        previous = token;
257    }
258    false
259}
260
261fn is_codex_executable_token(token: &str) -> bool {
262    let token = token.trim_matches(|ch| matches!(ch, '"' | '\''));
263    if token.is_empty() {
264        return false;
265    }
266    let lower = token.to_ascii_lowercase();
267    matches!(
268        exec_basename(&lower),
269        "codex" | "codex.exe" | "codex-cli" | "codex-cli.exe"
270    )
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn known_agent_label_uses_executable_not_model_argument() {
279        assert_eq!(
280            known_agent_label(
281                "agentsight",
282                "agentsight top -s tokens -v all -c claude --model claude-sonnet"
283            ),
284            None
285        );
286        assert_eq!(
287            known_agent_label(
288                "python",
289                "python benchmark_runner.py --model claude-sonnet-4-5-20250929"
290            ),
291            None
292        );
293        assert_eq!(
294            known_agent_label(
295                "docker",
296                "docker run image bash -c claude --model claude-sonnet-4"
297            ),
298            None
299        );
300        assert_eq!(
301            known_agent_label("node", "node /opt/npm/bin/codex --model gpt-5"),
302            Some("codex")
303        );
304        assert_eq!(
305            known_agent_label("node", "node /home/user/.local/bin/claude"),
306            Some("claude")
307        );
308        assert_eq!(known_agent_label("claude", "claude"), Some("claude"));
309        assert_eq!(
310            known_agent_label("claude.exe", r#"C:\Users\dev\bin\claude.exe"#),
311            Some("claude")
312        );
313        assert_eq!(
314            known_agent_label(
315                "codex.exe",
316                r#"C:\Program Files\WindowsApps\OpenAI.Codex\codex.exe app-server"#
317            ),
318            Some("codex")
319        );
320        assert_eq!(
321            known_agent_label(
322                "node.exe",
323                r#"node.exe C:\Users\dev\npm\node_modules\@openai\codex\bin\codex.js"#
324            ),
325            Some("codex")
326        );
327        assert_eq!(known_agent_label("openclaw-gatewa", ""), Some("openclaw"));
328    }
329
330    #[test]
331    fn codex_exec_detection_accepts_windows_executable_names() {
332        let proc_info = ProcInfo {
333            comm: "codex.exe".to_string(),
334            command: r#"C:\Users\dev\bin\codex.exe exec --skip-git-repo-check"#.to_string(),
335            ..Default::default()
336        };
337        assert!(is_codex_exec_invocation(&proc_info));
338    }
339
340    #[test]
341    fn process_comm_matching_uses_comm_and_executable_tokens_only() {
342        let proc_info = ProcInfo {
343            comm: "agentsight".to_string(),
344            command: "agentsight top -c claude --model claude-sonnet".to_string(),
345            ..Default::default()
346        };
347        assert!(!process_matches_comm(&proc_info, "claude"));
348        assert!(process_matches_comm(&proc_info, "agentsight"));
349    }
350
351    #[test]
352    fn process_comm_matching_ignores_agent_names_in_data_paths_and_shell_args() {
353        let proc_info = ProcInfo {
354            comm: "docker".to_string(),
355            command: "docker run image bash -c claude --settings /root/config/claude/settings.json"
356                .to_string(),
357            ..Default::default()
358        };
359
360        assert!(!process_matches_comm(&proc_info, "claude"));
361        assert!(process_matches_comm(&proc_info, "docker"));
362    }
363
364    #[test]
365    fn live_roots_suppress_known_agent_children_with_same_label() {
366        let procs = [
367            ProcInfo {
368                pid: 1,
369                comm: "node".to_string(),
370                command: "node /opt/npm/bin/codex".to_string(),
371                ..Default::default()
372            },
373            ProcInfo {
374                pid: 2,
375                ppid: 1,
376                comm: "codex".to_string(),
377                ..Default::default()
378            },
379            ProcInfo {
380                pid: 3,
381                comm: "claude".to_string(),
382                ..Default::default()
383            },
384        ];
385        let snapshot = ProcSnapshot {
386            procs: procs
387                .into_iter()
388                .map(|proc_info| (proc_info.pid, proc_info))
389                .collect(),
390            ..Default::default()
391        };
392
393        assert_eq!(live_root_pids(&snapshot, None, None), vec![1, 3]);
394    }
395
396    #[test]
397    fn live_roots_keep_nested_codex_exec_under_app_server() {
398        let procs = [
399            ProcInfo {
400                pid: 1,
401                comm: "node".to_string(),
402                command: "node /opt/node/bin/codex app-server --listen sock".to_string(),
403                ..Default::default()
404            },
405            ProcInfo {
406                pid: 2,
407                ppid: 1,
408                comm: "node".to_string(),
409                command: "node /opt/node/bin/codex exec -C /work hello".to_string(),
410                ..Default::default()
411            },
412            ProcInfo {
413                pid: 3,
414                ppid: 2,
415                comm: "codex".to_string(),
416                command: "/opt/node_modules/@openai/codex-linux-x64/bin/codex exec -C /work hello"
417                    .to_string(),
418                ..Default::default()
419            },
420        ];
421        let snapshot = ProcSnapshot {
422            procs: procs
423                .into_iter()
424                .map(|proc_info| (proc_info.pid, proc_info))
425                .collect(),
426            ..Default::default()
427        };
428
429        assert_eq!(live_root_pids(&snapshot, None, None), vec![1, 2]);
430    }
431
432    #[test]
433    fn comm_seeds_use_the_same_root_selection_as_live_roots() {
434        let procs = [
435            ProcInfo {
436                pid: 1,
437                comm: "node".to_string(),
438                command: "node /opt/npm/bin/codex".to_string(),
439                ..Default::default()
440            },
441            ProcInfo {
442                pid: 2,
443                ppid: 1,
444                comm: "codex".to_string(),
445                ..Default::default()
446            },
447            ProcInfo {
448                pid: 3,
449                comm: "codex".to_string(),
450                ..Default::default()
451            },
452        ];
453        let snapshot = ProcSnapshot {
454            procs: procs
455                .into_iter()
456                .map(|proc_info| (proc_info.pid, proc_info))
457                .collect(),
458            ..Default::default()
459        };
460
461        let roots = live_root_pids(&snapshot, None, Some("codex"));
462        let seeds = seeds_for_comm(&snapshot, "codex");
463
464        assert_eq!(roots, vec![1, 3]);
465        assert_eq!(
466            seeds.into_iter().map(|seed| seed.pid).collect::<Vec<_>>(),
467            vec![1, 2, 3]
468        );
469    }
470}