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