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" => return Some("claude".to_string()),
244 "codex" | "codex-cli" | "openai-codex" => return Some("codex".to_string()),
245 "gemini" | "gemini-cli" | "google-gemini" => return Some("gemini".to_string()),
246 _ => {}
247 }
248
249 if (clean_name.contains("claude") || clean_name.contains("claude-code")) && !clean_name.contains("claude-desktop") {
252 return Some("claude".to_string());
253 }
254 if clean_name.contains("codex") {
255 return Some("codex".to_string());
256 }
257 if clean_name.contains("gemini") {
258 return Some("gemini".to_string());
259 }
260
261 if clean_name == "node" {
263 if let Some(ai_type) = detect_npm_ai_cli_type(pid) {
264 return Some(ai_type);
265 }
266 }
267
268 None
269}
270
271pub fn detect_npm_ai_cli_type(pid: u32) -> Option<String> {
273 #[cfg(unix)]
274 {
275 detect_npm_ai_cli_type_unix(pid)
276 }
277
278 #[cfg(windows)]
279 {
280 detect_npm_ai_cli_type_windows(pid)
281 }
282}
283
284#[cfg(unix)]
285fn detect_npm_ai_cli_type_unix(pid: u32) -> Option<String> {
286 get_command_line(pid)
287 .and_then(|cmd| analyze_cmdline_for_ai_cli(&cmd))
288 .or_else(|| Some("node".to_string()))
289}
290
291fn build_ai_cli_process_info(pid: u32) -> Option<AiCliProcessInfo> {
292 let process_name = get_process_name(pid)?;
293 let ai_type = get_ai_cli_type(pid, &process_name)?;
294 let mut info = AiCliProcessInfo::new(pid, ai_type).with_process_name(process_name);
295
296 if let Some(cmdline) = get_command_line(pid) {
297 let npm_flag = is_npm_command_line(&cmdline);
298 info = info
299 .with_command_line(cmdline)
300 .with_is_npm_package(npm_flag);
301 }
302
303 if let Some(path) = get_executable_path(pid) {
304 info = info.with_executable_path(Some(path));
305 }
306
307 info.validate().ok()?;
308 Some(info)
309}
310
311fn is_npm_command_line(cmdline: &str) -> bool {
312 let cmd = cmdline.to_lowercase();
313 cmd.contains("npm exec") || cmd.contains("npx ")
314}
315
316#[cfg(unix)]
317fn get_command_line(pid: u32) -> Option<String> {
318 use std::fs::File;
319 use std::io::Read;
320
321 let cmdline_path = format!("/proc/{pid}/cmdline");
322 let mut file = File::open(cmdline_path).ok()?;
323 let mut raw = String::new();
324 file.read_to_string(&mut raw).ok()?;
325 let cleaned = raw.replace('\0', " ").trim().to_string();
326 if cleaned.is_empty() {
327 None
328 } else {
329 Some(cleaned)
330 }
331}
332
333#[cfg(windows)]
334fn get_command_line(pid: u32) -> Option<String> {
335 read_process_info_windows(pid, true)
336 .ok()
337 .and_then(|info| info.cmdline)
338 .map(|cmd| cmd.join(" "))
339 .filter(|cmd| !cmd.trim().is_empty())
340}
341
342#[cfg(unix)]
343fn get_executable_path(pid: u32) -> Option<PathBuf> {
344 std::fs::read_link(format!("/proc/{pid}/exe")).ok()
345}
346
347#[cfg(windows)]
348fn get_executable_path(pid: u32) -> Option<PathBuf> {
349 read_process_info_windows(pid, false)
350 .ok()
351 .and_then(|info| info.executable_path)
352}
353
354#[cfg(windows)]
355fn detect_npm_ai_cli_type_windows(pid: u32) -> Option<String> {
356 get_command_line(pid)
357 .and_then(|cmd| analyze_cmdline_for_ai_cli(&cmd))
358 .or_else(|| Some("node".to_string()))
359}
360
361#[allow(dead_code)]
363fn analyze_cmdline_for_ai_cli(cmdline: &str) -> Option<String> {
364 let cmdline_lower = cmdline.to_lowercase();
365
366 if cmdline_lower.contains("claude-cli") || cmdline_lower.contains("@anthropic-ai/claude-cli") {
368 return Some("claude".to_string());
369 }
370 if cmdline_lower.contains("claude-code") {
371 return Some("claude".to_string());
372 }
373 if cmdline_lower.contains("codex-cli") {
374 return Some("codex".to_string());
375 }
376 if cmdline_lower.contains("gemini-cli") || cmdline_lower.contains("@google/generative-ai-cli") {
377 return Some("gemini".to_string());
378 }
379
380 if cmdline_lower.contains("npm exec") {
382 if cmdline_lower.contains("@anthropic-ai/claude") {
383 return Some("claude".to_string());
384 }
385 if cmdline_lower.contains("codex") {
386 return Some("codex".to_string());
387 }
388 if cmdline_lower.contains("gemini") {
389 return Some("gemini".to_string());
390 }
391 }
392
393 if cmdline_lower.contains("npx") {
395 if cmdline_lower.contains("@anthropic-ai/claude") {
396 return Some("claude".to_string());
397 }
398 if cmdline_lower.contains("codex") {
399 return Some("codex".to_string());
400 }
401 if cmdline_lower.contains("gemini") {
402 return Some("gemini".to_string());
403 }
404 }
405
406 if cmdline_lower.contains("node_modules") {
408 if cmdline_lower.contains("claude-cli") || cmdline_lower.contains("claude-code") {
409 return Some("claude".to_string());
410 }
411 if cmdline_lower.contains("codex-cli") {
412 return Some("codex".to_string());
413 }
414 if cmdline_lower.contains("gemini-cli") {
415 return Some("gemini".to_string());
416 }
417 }
418
419 Some("node".to_string())
421}
422
423fn get_process_tree_internal(pid: u32) -> Result<ProcessTreeInfo, ProcessTreeError> {
425 let mut chain = Vec::new();
426
427 let mut current_pid = pid;
429 chain.push(current_pid);
430 let mut ai_cli_info: Option<AiCliProcessInfo> = None;
431
432 for _ in 0..50 {
434 match get_parent_pid(current_pid)? {
435 Some(parent_pid) => {
436 if parent_pid == current_pid || parent_pid == 0 {
437 break;
439 }
440
441 chain.push(parent_pid);
442 if ai_cli_info.is_none() {
443 ai_cli_info = build_ai_cli_process_info(parent_pid);
444 }
445 current_pid = parent_pid;
446
447 if is_root_process(parent_pid) {
449 break;
450 }
451 }
452 None => {
453 break;
454 }
455 }
456 }
457
458 let info = ProcessTreeInfo::new(chain).with_ai_cli_process(ai_cli_info);
459 info.validate()
460 .map_err(|err| ProcessTreeError::Validation(err.to_string()))?;
461 Ok(info)
462}
463
464pub fn get_process_tree(pid: u32) -> AgenticResult<ProcessTreeInfo> {
465 get_process_tree_internal(pid).map_err(AgenticWardenError::from)
466}
467
468fn get_parent_pid(pid: u32) -> Result<Option<u32>, ProcessTreeError> {
470 #[cfg(windows)]
471 {
472 get_parent_pid_windows(pid)
473 }
474
475 #[cfg(unix)]
476 {
477 get_parent_pid_unix(pid)
478 }
479}
480
481#[cfg(windows)]
483fn get_parent_pid_windows(pid: u32) -> Result<Option<u32>, ProcessTreeError> {
484 if pid == 0 {
485 return Ok(None);
486 }
487 match read_process_info_windows(pid, false) {
488 Ok(info) => Ok(info.parent.filter(|parent| *parent != pid)),
489 Err(ProcessTreeError::ProcessNotFound(_)) => Ok(None),
490 Err(err) => Err(err),
491 }
492}
493
494#[cfg(unix)]
496fn get_parent_pid_unix(pid: u32) -> Result<Option<u32>, ProcessTreeError> {
497 let process = Process::new(pid.into())?;
498 match process.ppid() {
499 Ok(parent_pid_opt) => {
500 Ok(parent_pid_opt)
502 }
503 Err(err) => Err(err.into()),
504 }
505}
506
507fn is_root_process(pid: u32) -> bool {
509 #[cfg(windows)]
510 {
511 pid == 0 || pid == 4 || pid == 1
513 }
514
515 #[cfg(unix)]
516 {
517 pid == 1 || pid == 0
519 }
520}
521
522#[allow(dead_code)]
524pub fn get_process_name(pid: u32) -> Option<String> {
525 #[cfg(windows)]
526 {
527 get_process_name_windows(pid)
528 }
529
530 #[cfg(unix)]
531 {
532 get_process_name_unix(pid)
533 }
534}
535
536#[cfg(windows)]
538#[allow(dead_code)]
539fn get_process_name_windows(pid: u32) -> Option<String> {
540 read_process_info_windows(pid, false)
541 .ok()
542 .and_then(|info| info.name)
543}
544
545#[cfg(unix)]
547fn get_process_name_unix(pid: u32) -> Option<String> {
548 match Process::new(pid.into()) {
549 Ok(process) => match process.name() {
550 Ok(name) => Some(name),
551 Err(_) => None,
552 },
553 Err(_) => None,
554 }
555}
556
557#[allow(dead_code)]
559pub fn same_root_parent(pid1: u32, pid2: u32) -> AgenticResult<bool> {
560 let tree1 = get_process_tree(pid1)?;
561 let tree2 = get_process_tree(pid2)?;
562
563 match (tree1.root_parent_pid, tree2.root_parent_pid) {
564 (Some(root1), Some(root2)) => Ok(root1 == root2),
565 _ => Ok(false),
566 }
567}
568
569#[allow(dead_code)]
571pub fn get_direct_parent_pid_fallback() -> Option<u32> {
572 get_parent_pid(std::process::id()).ok().flatten()
573}
574
575fn process_tree_issue(operation: &str, message: impl Into<String>) -> AgenticWardenError {
576 AgenticWardenError::Process {
577 message: message.into(),
578 command: format!("process_tree::{operation}"),
579 source: None,
580 }
581}
582
583#[allow(dead_code)]
584fn process_tree_issue_with_source(
585 operation: &str,
586 message: impl Into<String>,
587 source: impl std::error::Error + Send + Sync + 'static,
588) -> AgenticWardenError {
589 AgenticWardenError::Process {
590 message: message.into(),
591 command: format!("process_tree::{operation}"),
592 source: Some(Box::new(source)),
593 }
594}
595
596impl From<ProcessTreeError> for AgenticWardenError {
597 fn from(err: ProcessTreeError) -> Self {
598 match err {
599 #[cfg(unix)]
600 ProcessTreeError::ProcessInfo(source) => {
601 process_tree_issue_with_source("info", "Failed to get process information", source)
602 }
603 #[cfg(windows)]
604 ProcessTreeError::ProcessInfo(message) => process_tree_issue(
605 "info",
606 format!("Failed to get process information: {message}"),
607 ),
608 ProcessTreeError::ProcessNotFound(pid) => {
609 process_tree_issue("lookup", format!("Process {pid} not found"))
610 }
611 ProcessTreeError::PermissionDenied(pid) => process_tree_issue(
612 "permission",
613 format!("Permission denied accessing process {pid}"),
614 ),
615 ProcessTreeError::UnsupportedPlatform => process_tree_issue(
616 "platform",
617 "Unsupported platform for process tree inspection",
618 ),
619 ProcessTreeError::Validation(message) => process_tree_issue("validate", message),
620 }
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[cfg(windows)]
629 use sysinfo::System;
630
631 #[test]
632 fn test_current_process_tree() {
633 let result = ProcessTreeInfo::current();
634 assert!(result.is_ok());
635
636 let tree = result.unwrap();
637 assert!(!tree.process_chain.is_empty());
638 assert!(tree.depth >= 1);
639 assert_eq!(tree.process_chain[0], std::process::id());
640 }
641
642 #[test]
643 fn test_same_root_parent_current() {
644 let current_pid = std::process::id();
645 let result = same_root_parent(current_pid, current_pid);
646 assert!(result.is_ok());
647 assert!(result.unwrap());
648 }
649
650 #[test]
651 fn test_direct_parent_fallback() {
652 let parent_pid = get_direct_parent_pid_fallback();
653 assert!(parent_pid.is_some(), "Should be able to get parent PID");
654
655 let parent = parent_pid.unwrap();
656 assert!(parent > 0, "Parent PID should be positive");
657 }
658
659 #[test]
660 fn test_root_process_detection() {
661 #[cfg(windows)]
662 {
663 assert!(is_root_process(0), "PID 0 should be root on Windows");
664 assert!(is_root_process(4), "PID 4 should be root on Windows");
665 }
666
667 #[cfg(unix)]
668 {
669 assert!(is_root_process(1), "PID 1 should be root on Unix");
670 }
671 }
672
673 #[test]
674 fn test_process_chain_validity() {
675 let tree = ProcessTreeInfo::current().expect("Failed to get process tree");
676
677 for pid in &tree.process_chain {
679 assert!(*pid > 0, "All PIDs in process chain should be positive");
680 }
681
682 let mut seen = std::collections::HashSet::new();
684 for pid in &tree.process_chain {
685 assert!(
686 !seen.contains(pid),
687 "Process chain should not contain duplicate PIDs"
688 );
689 seen.insert(*pid);
690 }
691 }
692
693 #[test]
694 fn test_process_name_retrieval() {
695 let current_pid = std::process::id();
696 let process_name = get_process_name(current_pid);
697
698 if let Some(name) = process_name {
700 println!("Current process name: {}", name);
701 assert!(!name.is_empty(), "Process name should not be empty");
702 }
703 }
704
705 #[test]
706 fn test_analyze_cmdline_detects_specific_cli() {
707 let claude_cmd = "node ./node_modules/@anthropic-ai/claude-cli/bin/run.js ask";
708 assert_eq!(
709 analyze_cmdline_for_ai_cli(claude_cmd),
710 Some("claude".to_string())
711 );
712
713 let codex_cmd = "npx codex-cli chat --model gpt-4";
714 assert_eq!(
715 analyze_cmdline_for_ai_cli(codex_cmd),
716 Some("codex".to_string())
717 );
718
719 let gemini_cmd = "npm exec @google/generative-ai-cli -- text";
720 assert_eq!(
721 analyze_cmdline_for_ai_cli(gemini_cmd),
722 Some("gemini".to_string())
723 );
724 }
725
726 #[test]
727 fn test_analyze_cmdline_defaults_to_node() {
728 let generic_cmd = "node ./scripts/custom-runner.js";
729 assert_eq!(
730 analyze_cmdline_for_ai_cli(generic_cmd),
731 Some("node".to_string())
732 );
733 }
734
735 #[cfg(unix)]
736 #[test]
737 fn test_unix_psutil_integration() {
738 let current_pid = std::process::id();
740 let process = Process::new(current_pid.into());
741 assert!(
742 process.is_ok(),
743 "Should be able to access current process via psutil"
744 );
745
746 if let Ok(proc) = process {
747 let ppid_result = proc.ppid();
749 assert!(
750 ppid_result.is_ok(),
751 "Should be able to get parent PID via psutil"
752 );
753
754 let name_result = proc.name();
756 println!("Process name via psutil: {:?}", name_result);
757 }
758 }
759
760 #[cfg(windows)]
761 #[test]
762 fn test_windows_sysinfo_integration() {
763 let mut system = System::new();
765 system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
766
767 let current_pid = std::process::id();
768 let found_process = system
769 .processes()
770 .values()
771 .find(|p| p.pid().as_u32() == current_pid);
772
773 assert!(
774 found_process.is_some(),
775 "Should be able to find current process via sysinfo"
776 );
777
778 if let Some(process) = found_process {
779 println!(
780 "Current process found: {} (PID: {})",
781 process.name().to_string_lossy(),
782 process.pid().as_u32()
783 );
784 assert!(
785 !process.name().is_empty(),
786 "Process name should not be empty"
787 );
788 }
789 }
790
791 #[cfg(windows)]
792 #[test]
793 fn test_windows_process_info_cache_roundtrip() {
794 let pid = std::process::id();
795 let info_a =
796 read_process_info_windows(pid, false).expect("Process info should be available");
797 assert!(info_a.name.is_some());
798
799 let info_b = read_process_info_windows(pid, false)
800 .expect("Process info should be cached and still available");
801 assert!(info_b.name.is_some());
802 }
803}