Skip to main content

lean_ctx/core/value/
host.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Which agent process a lean-ctx session serves.
3//!
4//! Several agents can run lean-ctx in one project at once (two Claude windows,
5//! a Codex task). Each starts its own MCP server, and the project snapshot only
6//! names whichever wrote last. The server records its parent, the agent host;
7//! a status line or hook the same host started finds that pid among its own
8//! ancestors and so reads its own session, not a neighbour's.
9
10/// The process that started this one: for an MCP server, the agent host.
11/// `None` when orphaned (re-parented to init) or the platform cannot tell.
12pub fn host_pid() -> Option<u32> {
13    #[cfg(unix)]
14    {
15        // SAFETY: getppid() takes no arguments, cannot fail and has no side effects.
16        let ppid = unsafe { libc::getppid() } as u32;
17        (ppid > 1).then_some(ppid)
18    }
19    #[cfg(windows)]
20    {
21        parent_table()
22            .get(&std::process::id())
23            .copied()
24            .filter(|&ppid| ppid > 1)
25    }
26    #[cfg(not(any(unix, windows)))]
27    {
28        None
29    }
30}
31
32/// How far up the tree a caller looks: agent → shell → status-line command
33/// is two hops; the slack covers wrappers (`sh -c`, `env`, `npx`).
34const MAX_DEPTH: usize = 8;
35
36/// This process's ancestors, nearest first, stopping before init.
37pub fn ancestors() -> Vec<u32> {
38    let parents = parent_table();
39    let mut chain = Vec::new();
40    let mut pid = std::process::id();
41    while let Some(&ppid) = parents.get(&pid) {
42        if ppid <= 1 || chain.contains(&ppid) || chain.len() >= MAX_DEPTH {
43            break;
44        }
45        chain.push(ppid);
46        pid = ppid;
47    }
48    chain
49}
50
51/// pid → ppid for the processes the walk may visit.
52#[cfg(target_os = "linux")]
53fn parent_table() -> std::collections::HashMap<u32, u32> {
54    let mut table = std::collections::HashMap::new();
55    let mut pid = std::process::id();
56    for _ in 0..=MAX_DEPTH {
57        let Some(ppid) = std::fs::read_to_string(format!("/proc/{pid}/stat"))
58            .ok()
59            .and_then(|stat| parse_proc_stat_ppid(&stat))
60        else {
61            break;
62        };
63        table.insert(pid, ppid);
64        if ppid <= 1 {
65            break;
66        }
67        pid = ppid;
68    }
69    table
70}
71
72#[cfg(all(unix, not(target_os = "linux")))]
73fn parent_table() -> std::collections::HashMap<u32, u32> {
74    std::process::Command::new("ps")
75        .args(["-A", "-o", "pid=,ppid="])
76        .stderr(std::process::Stdio::null())
77        .output()
78        .ok()
79        .filter(|out| out.status.success())
80        .map(|out| parse_ps_table(&String::from_utf8_lossy(&out.stdout)))
81        .unwrap_or_default()
82}
83
84/// Windows has no `ps`/`/proc`: one ToolHelp snapshot lists every process with
85/// its parent (#2077).
86#[cfg(windows)]
87fn parent_table() -> std::collections::HashMap<u32, u32> {
88    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
89    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
90        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
91        TH32CS_SNAPPROCESS,
92    };
93
94    let mut table = std::collections::HashMap::new();
95    // SAFETY: the snapshot handle is closed below on every path that opened it.
96    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
97    if snapshot == INVALID_HANDLE_VALUE {
98        return table;
99    }
100    let mut entry = PROCESSENTRY32W {
101        dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
102        ..PROCESSENTRY32W::default()
103    };
104    // SAFETY: snapshot and entry are valid for ToolHelp process enumeration.
105    let mut has_entry = unsafe { Process32FirstW(snapshot, &raw mut entry) } != 0;
106    while has_entry {
107        table.insert(entry.th32ProcessID, entry.th32ParentProcessID);
108        // SAFETY: snapshot and entry stay valid until CloseHandle below.
109        has_entry = unsafe { Process32NextW(snapshot, &raw mut entry) } != 0;
110    }
111    // SAFETY: this function owns the snapshot handle.
112    unsafe { CloseHandle(snapshot) };
113    table
114}
115
116#[cfg(not(any(unix, windows)))]
117fn parent_table() -> std::collections::HashMap<u32, u32> {
118    std::collections::HashMap::new()
119}
120
121/// `ps -o pid=,ppid=` rows: two numbers per line.
122#[cfg(any(test, all(unix, not(target_os = "linux"))))]
123fn parse_ps_table(text: &str) -> std::collections::HashMap<u32, u32> {
124    text.lines()
125        .filter_map(|line| {
126            let mut cols = line.split_whitespace();
127            Some((cols.next()?.parse().ok()?, cols.next()?.parse().ok()?))
128        })
129        .collect()
130}
131
132/// The ppid in `/proc/<pid>/stat`: the field after the `(comm)` that may
133/// itself contain spaces and parentheses, so parse from the last `)`.
134#[cfg(any(test, target_os = "linux"))]
135fn parse_proc_stat_ppid(stat: &str) -> Option<u32> {
136    let rest = &stat[stat.rfind(')')? + 1..];
137    rest.split_whitespace().nth(1)?.parse().ok()
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn ps_rows_parse_and_junk_is_skipped() {
146        let table = parse_ps_table("  101   1\n  202 101\nPID PPID\n\n");
147        assert_eq!(table.get(&202), Some(&101));
148        assert_eq!(table.get(&101), Some(&1));
149        assert_eq!(table.len(), 2);
150    }
151
152    #[test]
153    fn proc_stat_ppid_survives_odd_command_names() {
154        assert_eq!(parse_proc_stat_ppid("42 (a) b (c) S 7 42 42"), Some(7));
155        assert_eq!(parse_proc_stat_ppid("no parens"), None);
156    }
157
158    #[cfg(any(unix, windows))]
159    #[test]
160    fn ancestors_start_at_the_parent() {
161        let chain = ancestors();
162        assert_eq!(chain.first().copied(), host_pid());
163    }
164}