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