Skip to main content

tmx/agents/
proc.rs

1//! Process tree walker. Given a tmux pane PID, find the most recently
2//! spawned `claude` or `codex` descendant so we can read its cwd and
3//! report it as the agent's working directory.
4//!
5//! `sysinfo` abstracts /proc on Linux and libproc on macOS, so the same
6//! code path works on both. We refresh with a minimal `ProcessRefreshKind`
7//! (only the fields we read) to keep the cost low — refreshing all
8//! processes every tick is sub-millisecond on typical workstations.
9
10use std::path::PathBuf;
11
12use sysinfo::{Pid, Process, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
13
14pub struct ProcSnapshot {
15    sys: System,
16}
17
18impl ProcSnapshot {
19    pub fn new() -> Self {
20        let mut sys = System::new();
21        sys.refresh_processes_specifics(ProcessesToUpdate::All, true, Self::refresh_kind());
22        Self { sys }
23    }
24
25    pub fn refresh(&mut self) {
26        self.sys
27            .refresh_processes_specifics(ProcessesToUpdate::All, true, Self::refresh_kind());
28    }
29
30    fn refresh_kind() -> ProcessRefreshKind {
31        ProcessRefreshKind::nothing()
32            .with_exe(UpdateKind::OnlyIfNotSet)
33            .with_cmd(UpdateKind::OnlyIfNotSet)
34            .with_cwd(UpdateKind::Always)
35    }
36
37    /// True if `pid` exists in this snapshot's process table. Used to
38    /// distinguish a freshly-spawned codex worker (alive) from a state
39    /// file whose `pid` field is days old (zombie).
40    pub fn pid_alive(&self, pid: u32) -> bool {
41        self.sys.process(Pid::from_u32(pid)).is_some()
42    }
43
44    /// Walk descendants of `root_pid` (BFS) and return the most recently
45    /// started one whose exe basename matches any of `targets`.
46    pub fn find_descendant(&self, root_pid: u32, targets: &[&str]) -> Option<ProcInfo> {
47        let mut best: Option<&Process> = None;
48        let mut stack = vec![Pid::from_u32(root_pid)];
49
50        // BFS over children. We don't pre-index parent→children, so we
51        // iterate all processes once per visited PID. With <1000 procs
52        // this is still well under a millisecond per call.
53        while let Some(pid) = stack.pop() {
54            for (other_pid, proc) in self.sys.processes() {
55                if proc.parent() != Some(pid) {
56                    continue;
57                }
58                if let Some(name) = exe_basename(proc)
59                    && targets.contains(&name)
60                {
61                    best = match best {
62                        None => Some(proc),
63                        Some(prev) if proc.start_time() > prev.start_time() => Some(proc),
64                        Some(_) => best,
65                    };
66                }
67                stack.push(*other_pid);
68            }
69        }
70
71        best.map(|p| ProcInfo {
72            pid: p.pid().as_u32(),
73            name: exe_basename(p).unwrap_or_default().to_string(),
74            cwd: p.cwd().map(PathBuf::from),
75        })
76    }
77}
78
79impl Default for ProcSnapshot {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85#[derive(Debug, Clone)]
86pub struct ProcInfo {
87    pub pid: u32,
88    pub name: String,
89    pub cwd: Option<PathBuf>,
90}
91
92fn exe_basename(proc: &Process) -> Option<&str> {
93    // Prefer the kernel-set comm name (`/proc/<pid>/comm` on Linux,
94    // libproc on macOS): it's what the binary calls itself and is
95    // stable across versions. Falling back to the exe path's basename
96    // is brittle — Claude Code's exe path is the versioned install
97    // directory (`.../versions/2.1.143`), so its file_name comes out
98    // as `2.1.143`, not `claude`.
99    proc.name().to_str().or_else(|| {
100        proc.exe()
101            .and_then(|p| p.file_name())
102            .and_then(|n| n.to_str())
103    })
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn snapshot_finds_self_as_no_target() {
112        // Smoke test: refresh succeeds and a known-bogus target returns None.
113        let snap = ProcSnapshot::new();
114        let me = std::process::id();
115        assert!(
116            snap.find_descendant(me, &["this_binary_does_not_exist"])
117                .is_none()
118        );
119    }
120}