1#[cfg(unix)]
11use psutil::process::Process;
12
13#[cfg(windows)]
14use parking_lot::RwLock;
15#[cfg(windows)]
16use std::cell::RefCell;
17#[cfg(windows)]
18use std::collections::HashMap;
19#[cfg(windows)]
20use std::time::{Duration, Instant};
21#[cfg(windows)]
22use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
23
24use crate::core::models::{AiCliProcessInfo, ProcessTreeInfo};
25use crate::error::{AgenticResult, AgenticWardenError};
26use std::path::PathBuf;
27use std::sync::OnceLock;
28use thiserror::Error;
29
30static ROOT_PARENT_PID_CACHE: OnceLock<u32> = OnceLock::new();
32#[cfg(windows)]
33static PROCESS_INFO_CACHE: OnceLock<RwLock<HashMap<u32, CacheEntry>>> = OnceLock::new();
34#[cfg(windows)]
35const PROCESS_CACHE_TTL: Duration = Duration::from_millis(750);
36#[cfg(windows)]
37thread_local! {
38 static THREAD_SYSINFO: RefCell<SysinfoState> = RefCell::new(SysinfoState::new());
39}
40
41#[derive(Error, Debug)]
42pub enum ProcessTreeError {
43 #[cfg(unix)]
44 #[error("Failed to get process information: {0}")]
45 ProcessInfo(#[from] psutil::Error),
46 #[cfg(windows)]
47 #[allow(dead_code)]
48 #[error("Failed to get process information: {0}")]
49 ProcessInfo(String),
50 #[allow(dead_code)]
51 #[error("Process not found: {0}")]
52 ProcessNotFound(u32),
53 #[allow(dead_code)]
54 #[error("Permission denied accessing process: {0}")]
55 PermissionDenied(u32),
56 #[allow(dead_code)]
57 #[error("Unsupported platform")]
58 UnsupportedPlatform,
59 #[error("Process tree validation failed: {0}")]
60 Validation(String),
61}
62
63#[cfg(unix)]
65impl From<psutil::process::ProcessError> for ProcessTreeError {
66 fn from(err: psutil::process::ProcessError) -> Self {
67 use std::io;
69 ProcessTreeError::ProcessInfo(psutil::Error::from(io::Error::other(format!("{:?}", err))))
70 }
71}
72
73#[cfg(windows)]
74#[derive(Clone, Debug)]
75struct ProcessInfo {
76 parent: Option<u32>,
77 name: Option<String>,
78 cmdline: Option<Vec<String>>,
79 executable_path: Option<PathBuf>,
80}
81
82#[cfg(windows)]
83#[derive(Clone, Debug)]
84struct CacheEntry {
85 info: ProcessInfo,
86 expires_at: Instant,
87}
88
89#[cfg(windows)]
90#[derive(Debug)]
91struct SysinfoState {
92 system: System,
93}
94
95#[cfg(windows)]
96impl SysinfoState {
97 fn new() -> Self {
98 let mut system = System::new();
99 system.refresh_processes(ProcessesToUpdate::All, true);
100 Self { system }
101 }
102
103 fn snapshot(&mut self, pid: u32, include_cmdline: bool) -> Option<ProcessInfo> {
104 let sys_pid = Pid::from_u32(pid);
105 let pid_list = [sys_pid];
106 let refresh_kind = if include_cmdline {
107 ProcessRefreshKind::everything()
108 } else {
109 ProcessRefreshKind::new()
110 };
111 let _ = self.system.refresh_processes_specifics(
112 ProcessesToUpdate::Some(&pid_list),
113 true,
114 refresh_kind,
115 );
116 if self.system.process(sys_pid).is_none() {
117 self.system.refresh_processes(ProcessesToUpdate::All, true);
118 }
119 self.system.process(sys_pid).map(|process| {
120 let parent = process.parent().map(|p| p.as_u32());
121 let name = Some(process.name().to_string_lossy().into_owned());
122 let cmdline = if include_cmdline {
123 let args: Vec<String> = process
124 .cmd()
125 .iter()
126 .map(|arg| arg.to_string_lossy().into_owned())
127 .collect();
128 if args.is_empty() {
129 None
130 } else {
131 Some(args)
132 }
133 } else {
134 None
135 };
136 let executable_path = process.exe().map(|path| path.to_path_buf());
137 ProcessInfo {
138 parent,
139 name,
140 cmdline,
141 executable_path,
142 }
143 })
144 }
145}
146
147#[cfg(windows)]
148fn process_info_cache() -> &'static RwLock<HashMap<u32, CacheEntry>> {
149 PROCESS_INFO_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
150}
151
152#[cfg(windows)]
153fn read_process_info_windows(
154 pid: u32,
155 require_cmdline: bool,
156) -> Result<ProcessInfo, ProcessTreeError> {
157 if pid == 0 {
158 return Ok(ProcessInfo {
159 parent: None,
160 name: Some("System Idle Process".to_string()),
161 cmdline: None,
162 executable_path: None,
163 });
164 }
165
166 let now = Instant::now();
167 {
168 let cache_guard = process_info_cache().read();
169 if let Some(entry) = cache_guard.get(&pid) {
170 if entry.expires_at > now && (!require_cmdline || entry.info.cmdline.is_some()) {
171 return Ok(entry.info.clone());
172 }
173 }
174 }
175
176 let snapshot = THREAD_SYSINFO
177 .with(|state| state.borrow_mut().snapshot(pid, require_cmdline))
178 .ok_or(ProcessTreeError::ProcessNotFound(pid))?;
179
180 {
181 let mut cache_guard = process_info_cache().write();
182 cache_guard.insert(
183 pid,
184 CacheEntry {
185 info: snapshot.clone(),
186 expires_at: now + PROCESS_CACHE_TTL,
187 },
188 );
189 }
190
191 Ok(snapshot)
192}
193
194impl ProcessTreeInfo {
195 pub fn current() -> AgenticResult<Self> {
197 get_process_tree(std::process::id())
198 }
199}
200
201pub fn get_root_parent_pid_cached() -> AgenticResult<u32> {
205 let current_pid = std::process::id();
206
207 if let Some(&cached_pid) = ROOT_PARENT_PID_CACHE.get() {
209 Ok(cached_pid)
210 } else {
211 let ai_root_pid = find_ai_cli_root_parent(current_pid)?;
212 let _ = ROOT_PARENT_PID_CACHE.set(ai_root_pid);
214 Ok(ai_root_pid)
215 }
216}
217
218pub fn find_ai_cli_root_parent(pid: u32) -> AgenticResult<u32> {
221 let process_tree = get_process_tree(pid)?;
222
223 process_tree
224 .get_ai_cli_root()
225 .ok_or_else(|| ProcessTreeError::ProcessNotFound(pid).into())
226}
227
228fn get_ai_cli_type(pid: u32, process_name: &str) -> Option<String> {
231 let name_lower = process_name.to_lowercase();
232
233 let clean_name = if cfg!(windows) && name_lower.ends_with(".exe") {
235 &name_lower[..name_lower.len() - 4]
236 } else {
237 &name_lower
238 };
239
240 match clean_name {
243 "claude" | "claude-cli" | "anthropic-claude" | "claude-code" => {
244 return Some("claude".to_string())
245 }
246 "codex" | "codex-cli" | "openai-codex" => return Some("codex".to_string()),
247 "gemini" | "gemini-cli" | "google-gemini" => return Some("gemini".to_string()),
248 _ => {}
249 }
250
251 if (clean_name.contains("claude") || clean_name.contains("claude-code"))
254 && !clean_name.contains("claude-desktop")
255 {
256 return Some("claude".to_string());
257 }
258 if clean_name.contains("codex") {
259 return Some("codex".to_string());
260 }
261 if clean_name.contains("gemini") {
262 return Some("gemini".to_string());
263 }
264
265 if clean_name == "node" {
267 if let Some(ai_type) = detect_npm_ai_cli_type(pid) {
268 return Some(ai_type);
269 }
270 }
271
272 None
273}
274
275pub fn detect_npm_ai_cli_type(pid: u32) -> Option<String> {
277 #[cfg(unix)]
278 {
279 detect_npm_ai_cli_type_unix(pid)
280 }
281
282 #[cfg(windows)]
283 {
284 detect_npm_ai_cli_type_windows(pid)
285 }
286}
287
288#[cfg(unix)]
289fn detect_npm_ai_cli_type_unix(pid: u32) -> Option<String> {
290 get_command_line(pid)
291 .and_then(|cmd| analyze_cmdline_for_ai_cli(&cmd))
292 .or_else(|| Some("node".to_string()))
293}
294
295fn build_ai_cli_process_info(pid: u32) -> Option<AiCliProcessInfo> {
296 let process_name = get_process_name(pid)?;
297 let ai_type = get_ai_cli_type(pid, &process_name)?;
298 let mut info = AiCliProcessInfo::new(pid, ai_type).with_process_name(process_name);
299
300 if let Some(cmdline) = get_command_line(pid) {
301 let npm_flag = is_npm_command_line(&cmdline);
302 info = info
303 .with_command_line(cmdline)
304 .with_is_npm_package(npm_flag);
305 }
306
307 if let Some(path) = get_executable_path(pid) {
308 info = info.with_executable_path(Some(path));
309 }
310
311 info.validate().ok()?;
312 Some(info)
313}
314
315fn is_npm_command_line(cmdline: &str) -> bool {
316 let cmd = cmdline.to_lowercase();
317 cmd.contains("npm exec") || cmd.contains("npx ")
318}
319
320#[cfg(unix)]
321fn get_command_line(pid: u32) -> Option<String> {
322 use std::fs::File;
323 use std::io::Read;
324
325 let cmdline_path = format!("/proc/{pid}/cmdline");
326 let mut file = File::open(cmdline_path).ok()?;
327 let mut raw = String::new();
328 file.read_to_string(&mut raw).ok()?;
329 let cleaned = raw.replace('\0', " ").trim().to_string();
330 if cleaned.is_empty() {
331 None
332 } else {
333 Some(cleaned)
334 }
335}
336
337#[cfg(windows)]
338fn get_command_line(pid: u32) -> Option<String> {
339 read_process_info_windows(pid, true)
340 .ok()
341 .and_then(|info| info.cmdline)
342 .map(|cmd| cmd.join(" "))
343 .filter(|cmd| !cmd.trim().is_empty())
344}
345
346#[cfg(unix)]
347fn get_executable_path(pid: u32) -> Option<PathBuf> {
348 std::fs::read_link(format!("/proc/{pid}/exe")).ok()
349}
350
351#[cfg(windows)]
352fn get_executable_path(pid: u32) -> Option<PathBuf> {
353 read_process_info_windows(pid, false)
354 .ok()
355 .and_then(|info| info.executable_path)
356}
357
358#[cfg(windows)]
359fn detect_npm_ai_cli_type_windows(pid: u32) -> Option<String> {
360 get_command_line(pid)
361 .and_then(|cmd| analyze_cmdline_for_ai_cli(&cmd))
362 .or_else(|| Some("node".to_string()))
363}
364
365#[allow(dead_code)]
367fn analyze_cmdline_for_ai_cli(cmdline: &str) -> Option<String> {
368 let cmdline_lower = cmdline.to_lowercase();
369
370 if cmdline_lower.contains("claude-cli") || cmdline_lower.contains("@anthropic-ai/claude-cli") {
372 return Some("claude".to_string());
373 }
374 if cmdline_lower.contains("claude-code") {
375 return Some("claude".to_string());
376 }
377 if cmdline_lower.contains("codex-cli") {
378 return Some("codex".to_string());
379 }
380 if cmdline_lower.contains("gemini-cli") || cmdline_lower.contains("@google/generative-ai-cli") {
381 return Some("gemini".to_string());
382 }
383
384 if cmdline_lower.contains("npm exec") {
386 if cmdline_lower.contains("@anthropic-ai/claude") {
387 return Some("claude".to_string());
388 }
389 if cmdline_lower.contains("codex") {
390 return Some("codex".to_string());
391 }
392 if cmdline_lower.contains("gemini") {
393 return Some("gemini".to_string());
394 }
395 }
396
397 if cmdline_lower.contains("npx") {
399 if cmdline_lower.contains("@anthropic-ai/claude") {
400 return Some("claude".to_string());
401 }
402 if cmdline_lower.contains("codex") {
403 return Some("codex".to_string());
404 }
405 if cmdline_lower.contains("gemini") {
406 return Some("gemini".to_string());
407 }
408 }
409
410 if cmdline_lower.contains("node_modules") {
412 if cmdline_lower.contains("claude-cli") || cmdline_lower.contains("claude-code") {
413 return Some("claude".to_string());
414 }
415 if cmdline_lower.contains("codex-cli") {
416 return Some("codex".to_string());
417 }
418 if cmdline_lower.contains("gemini-cli") {
419 return Some("gemini".to_string());
420 }
421 }
422
423 Some("node".to_string())
425}
426
427fn get_process_tree_internal(pid: u32) -> Result<ProcessTreeInfo, ProcessTreeError> {
429 let mut chain = Vec::new();
430
431 let mut current_pid = pid;
433 chain.push(current_pid);
434 let mut ai_cli_info: Option<AiCliProcessInfo> = None;
435
436 for _ in 0..50 {
438 match get_parent_pid(current_pid)? {
439 Some(parent_pid) => {
440 if parent_pid == current_pid || parent_pid == 0 {
441 break;
443 }
444
445 chain.push(parent_pid);
446 if ai_cli_info.is_none() {
447 ai_cli_info = build_ai_cli_process_info(parent_pid);
448 }
449 current_pid = parent_pid;
450
451 if is_root_process(parent_pid) {
453 break;
454 }
455 }
456 None => {
457 break;
458 }
459 }
460 }
461
462 let info = ProcessTreeInfo::new(chain).with_ai_cli_process(ai_cli_info);
463 info.validate()
464 .map_err(|err| ProcessTreeError::Validation(err.to_string()))?;
465 Ok(info)
466}
467
468pub fn get_process_tree(pid: u32) -> AgenticResult<ProcessTreeInfo> {
469 get_process_tree_internal(pid).map_err(AgenticWardenError::from)
470}
471
472fn get_parent_pid(pid: u32) -> Result<Option<u32>, ProcessTreeError> {
474 #[cfg(windows)]
475 {
476 get_parent_pid_windows(pid)
477 }
478
479 #[cfg(unix)]
480 {
481 get_parent_pid_unix(pid)
482 }
483}
484
485#[cfg(windows)]
487fn get_parent_pid_windows(pid: u32) -> Result<Option<u32>, ProcessTreeError> {
488 if pid == 0 {
489 return Ok(None);
490 }
491 match read_process_info_windows(pid, false) {
492 Ok(info) => Ok(info.parent.filter(|parent| *parent != pid)),
493 Err(ProcessTreeError::ProcessNotFound(_)) => Ok(None),
494 Err(err) => Err(err),
495 }
496}
497
498#[cfg(unix)]
500fn get_parent_pid_unix(pid: u32) -> Result<Option<u32>, ProcessTreeError> {
501 let process = Process::new(pid.into())?;
502 match process.ppid() {
503 Ok(parent_pid_opt) => {
504 Ok(parent_pid_opt)
506 }
507 Err(err) => Err(err.into()),
508 }
509}
510
511fn is_root_process(pid: u32) -> bool {
513 #[cfg(windows)]
514 {
515 pid == 0 || pid == 4 || pid == 1
517 }
518
519 #[cfg(unix)]
520 {
521 pid == 1 || pid == 0
523 }
524}
525
526#[allow(dead_code)]
528pub fn get_process_name(pid: u32) -> Option<String> {
529 #[cfg(windows)]
530 {
531 get_process_name_windows(pid)
532 }
533
534 #[cfg(unix)]
535 {
536 get_process_name_unix(pid)
537 }
538}
539
540#[cfg(windows)]
542#[allow(dead_code)]
543fn get_process_name_windows(pid: u32) -> Option<String> {
544 read_process_info_windows(pid, false)
545 .ok()
546 .and_then(|info| info.name)
547}
548
549#[cfg(unix)]
551fn get_process_name_unix(pid: u32) -> Option<String> {
552 match Process::new(pid.into()) {
553 Ok(process) => match process.name() {
554 Ok(name) => Some(name),
555 Err(_) => None,
556 },
557 Err(_) => None,
558 }
559}
560
561#[allow(dead_code)]
563pub fn same_root_parent(pid1: u32, pid2: u32) -> AgenticResult<bool> {
564 let tree1 = get_process_tree(pid1)?;
565 let tree2 = get_process_tree(pid2)?;
566
567 match (tree1.root_parent_pid, tree2.root_parent_pid) {
568 (Some(root1), Some(root2)) => Ok(root1 == root2),
569 _ => Ok(false),
570 }
571}
572
573#[allow(dead_code)]
575pub fn get_direct_parent_pid_fallback() -> Option<u32> {
576 get_parent_pid(std::process::id()).ok().flatten()
577}
578
579fn process_tree_issue(operation: &str, message: impl Into<String>) -> AgenticWardenError {
580 AgenticWardenError::Process {
581 message: message.into(),
582 command: format!("process_tree::{operation}"),
583 source: None,
584 }
585}
586
587#[allow(dead_code)]
588fn process_tree_issue_with_source(
589 operation: &str,
590 message: impl Into<String>,
591 source: impl std::error::Error + Send + Sync + 'static,
592) -> AgenticWardenError {
593 AgenticWardenError::Process {
594 message: message.into(),
595 command: format!("process_tree::{operation}"),
596 source: Some(Box::new(source)),
597 }
598}
599
600impl From<ProcessTreeError> for AgenticWardenError {
601 fn from(err: ProcessTreeError) -> Self {
602 match err {
603 #[cfg(unix)]
604 ProcessTreeError::ProcessInfo(source) => {
605 process_tree_issue_with_source("info", "Failed to get process information", source)
606 }
607 #[cfg(windows)]
608 ProcessTreeError::ProcessInfo(message) => process_tree_issue(
609 "info",
610 format!("Failed to get process information: {message}"),
611 ),
612 ProcessTreeError::ProcessNotFound(pid) => {
613 process_tree_issue("lookup", format!("Process {pid} not found"))
614 }
615 ProcessTreeError::PermissionDenied(pid) => process_tree_issue(
616 "permission",
617 format!("Permission denied accessing process {pid}"),
618 ),
619 ProcessTreeError::UnsupportedPlatform => process_tree_issue(
620 "platform",
621 "Unsupported platform for process tree inspection",
622 ),
623 ProcessTreeError::Validation(message) => process_tree_issue("validate", message),
624 }
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[cfg(windows)]
633 use sysinfo::System;
634
635 #[test]
636 fn test_current_process_tree() {
637 let result = ProcessTreeInfo::current();
638 assert!(result.is_ok());
639
640 let tree = result.unwrap();
641 assert!(!tree.process_chain.is_empty());
642 assert!(tree.depth >= 1);
643 assert_eq!(tree.process_chain[0], std::process::id());
644 }
645
646 #[test]
647 fn test_same_root_parent_current() {
648 let current_pid = std::process::id();
649 let result = same_root_parent(current_pid, current_pid);
650 assert!(result.is_ok());
651 assert!(result.unwrap());
652 }
653
654 #[test]
655 fn test_direct_parent_fallback() {
656 let parent_pid = get_direct_parent_pid_fallback();
657 assert!(parent_pid.is_some(), "Should be able to get parent PID");
658
659 let parent = parent_pid.unwrap();
660 assert!(parent > 0, "Parent PID should be positive");
661 }
662
663 #[test]
664 fn test_root_process_detection() {
665 #[cfg(windows)]
666 {
667 assert!(is_root_process(0), "PID 0 should be root on Windows");
668 assert!(is_root_process(4), "PID 4 should be root on Windows");
669 }
670
671 #[cfg(unix)]
672 {
673 assert!(is_root_process(1), "PID 1 should be root on Unix");
674 }
675 }
676
677 #[test]
678 fn test_process_chain_validity() {
679 let tree = ProcessTreeInfo::current().expect("Failed to get process tree");
680
681 for pid in &tree.process_chain {
683 assert!(*pid > 0, "All PIDs in process chain should be positive");
684 }
685
686 let mut seen = std::collections::HashSet::new();
688 for pid in &tree.process_chain {
689 assert!(
690 !seen.contains(pid),
691 "Process chain should not contain duplicate PIDs"
692 );
693 seen.insert(*pid);
694 }
695 }
696
697 #[test]
698 fn test_process_name_retrieval() {
699 let current_pid = std::process::id();
700 let process_name = get_process_name(current_pid);
701
702 if let Some(name) = process_name {
704 println!("Current process name: {}", name);
705 assert!(!name.is_empty(), "Process name should not be empty");
706 }
707 }
708
709 #[test]
710 fn test_analyze_cmdline_detects_specific_cli() {
711 let claude_cmd = "node ./node_modules/@anthropic-ai/claude-cli/bin/run.js ask";
712 assert_eq!(
713 analyze_cmdline_for_ai_cli(claude_cmd),
714 Some("claude".to_string())
715 );
716
717 let codex_cmd = "npx codex-cli chat --model gpt-4";
718 assert_eq!(
719 analyze_cmdline_for_ai_cli(codex_cmd),
720 Some("codex".to_string())
721 );
722
723 let gemini_cmd = "npm exec @google/generative-ai-cli -- text";
724 assert_eq!(
725 analyze_cmdline_for_ai_cli(gemini_cmd),
726 Some("gemini".to_string())
727 );
728 }
729
730 #[test]
731 fn test_analyze_cmdline_defaults_to_node() {
732 let generic_cmd = "node ./scripts/custom-runner.js";
733 assert_eq!(
734 analyze_cmdline_for_ai_cli(generic_cmd),
735 Some("node".to_string())
736 );
737 }
738
739 #[cfg(unix)]
740 #[test]
741 fn test_unix_psutil_integration() {
742 let current_pid = std::process::id();
744 let process = Process::new(current_pid.into());
745 assert!(
746 process.is_ok(),
747 "Should be able to access current process via psutil"
748 );
749
750 if let Ok(proc) = process {
751 let ppid_result = proc.ppid();
753 assert!(
754 ppid_result.is_ok(),
755 "Should be able to get parent PID via psutil"
756 );
757
758 let name_result = proc.name();
760 println!("Process name via psutil: {:?}", name_result);
761 }
762 }
763
764 #[cfg(windows)]
765 #[test]
766 fn test_windows_sysinfo_integration() {
767 let mut system = System::new();
769 system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
770
771 let current_pid = std::process::id();
772 let found_process = system
773 .processes()
774 .values()
775 .find(|p| p.pid().as_u32() == current_pid);
776
777 assert!(
778 found_process.is_some(),
779 "Should be able to find current process via sysinfo"
780 );
781
782 if let Some(process) = found_process {
783 println!(
784 "Current process found: {} (PID: {})",
785 process.name().to_string_lossy(),
786 process.pid().as_u32()
787 );
788 assert!(
789 !process.name().is_empty(),
790 "Process name should not be empty"
791 );
792 }
793 }
794
795 #[cfg(windows)]
796 #[test]
797 fn test_windows_process_info_cache_roundtrip() {
798 let pid = std::process::id();
799 let info_a =
800 read_process_info_windows(pid, false).expect("Process info should be available");
801 assert!(info_a.name.is_some());
802
803 let info_b = read_process_info_windows(pid, false)
804 .expect("Process info should be cached and still available");
805 assert!(info_b.name.is_some());
806 }
807}