1use 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}