use crate::session::Session;
use crate::util;
use std::collections::{HashMap, HashSet};
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, UpdateKind};
const PROC_LINGER_TICKS: u8 = 3;
#[derive(Debug, Clone)]
pub struct ProcEntry {
pub pid: u32,
pub cpu: f32,
pub memory: u64,
pub args: String,
pub is_root: bool,
pub ghost: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ProcInfo {
pub pids: usize,
pub cpu: f32,
pub memory: u64,
pub command: String,
pub process_list: Vec<ProcEntry>,
}
pub fn terminate(pid: u32) -> Result<(), String> {
let mut sys = System::new();
let process_pid = Pid::from_u32(pid);
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&[process_pid]),
true,
ProcessRefreshKind::nothing(),
);
let process = sys
.process(process_pid)
.ok_or_else(|| format!("process {pid} has already exited"))?;
match process.kill_with(Signal::Term) {
Some(true) => Ok(()),
Some(false) => Err(format!("permission denied stopping process {pid}")),
None => Err("graceful termination is not supported on this platform".into()),
}
}
#[derive(Debug, Clone)]
pub struct Orphan {
pub provider: crate::pricing::Provider,
pub cwd: String,
}
pub struct Collector {
sys: System,
ghosts: HashMap<String, HashMap<u32, (ProcEntry, u8)>>,
orphans: HashMap<String, Orphan>,
}
impl Default for Collector {
fn default() -> Self {
Self::new()
}
}
fn is_daemon(args: &str) -> bool {
args.split_whitespace().any(|tok| {
matches!(tok, "app-server" | "server" | "daemon")
|| tok.ends_with("/app-server")
|| tok.ends_with("/daemon")
})
}
fn is_app_bundle(args: &str) -> bool {
(args.contains(".app/") || args.contains(".app\\")) && !args.contains("/claude-code/")
}
fn basename(s: &str) -> &str {
s.rsplit(['/', '\\']).next().unwrap_or(s)
}
fn command_stem(s: &str) -> String {
let base = basename(s);
base.strip_suffix(".js")
.or_else(|| base.strip_suffix(".exe"))
.unwrap_or(base)
.to_ascii_lowercase()
}
fn is_node_hosted_codex(tokens: &[String]) -> bool {
is_node_hosted_agent(tokens, "codex")
}
fn is_codex_process(name: &str, tokens: &[String]) -> bool {
is_codex_binary(name)
|| is_sandboxed_codex_launcher(name, tokens)
|| (name == "codex-linux-sandbox" && tokens.iter().any(|t| t == "app-server"))
|| (name == "node" && is_node_hosted_codex(tokens))
}
fn is_sandboxed_codex_launcher(name: &str, tokens: &[String]) -> bool {
if !matches!(name, "bwrap" | "codex-linux-sandbox") {
return false;
}
let launches_codex = tokens
.iter()
.any(|token| is_codex_binary(&command_stem(token)));
launches_codex
&& (flag_value(tokens, "--command-cwd").is_some()
|| flag_value(tokens, "--sandbox-policy-cwd").is_some())
}
fn is_codex_binary(name: &str) -> bool {
name == "codex"
|| (name.starts_with("codex-")
&& (name.contains("-unknown-linux-")
|| name.ends_with("-apple-darwin")
|| name.ends_with("-pc-windows-msvc")))
}
fn exclude_agent_process(name: &str, tokens: &[String], args: &str) -> bool {
is_app_bundle(args) || (is_daemon(args) && !is_codex_process(name, tokens))
}
fn is_node_hosted_agent(tokens: &[String], agent: &str) -> bool {
tokens
.iter()
.skip(1)
.take(3)
.find(|t| !t.starts_with('-'))
.is_some_and(|script| command_stem(script) == agent)
}
fn is_claude_binary(name: &str, tokens: &[String]) -> bool {
if name == "claude" {
return true;
}
tokens
.first()
.is_some_and(|argv0| command_stem(argv0) == "claude")
|| tokens.first().is_some_and(|p| {
p.contains("/.claude/remote/ccd-cli/")
|| p.contains("\\.claude\\remote\\ccd-cli\\")
|| p.contains("/claude/versions/")
|| p.contains("\\claude\\versions\\")
})
}
fn resume_value(tokens: &[String]) -> Option<(&str, usize)> {
for (i, t) in tokens.iter().enumerate() {
if let Some(v) = t.strip_prefix("--resume=") {
return Some((v, i));
}
if t == "resume" || t == "--resume" {
return tokens.get(i + 1).map(|v| (v.as_str(), i + 1));
}
}
None
}
fn resume_uuid(tokens: &[String]) -> Option<&str> {
let (value, _) = resume_value(tokens)?;
crate::config::is_full_uuid(value).then_some(value)
}
fn session_value(tokens: &[String]) -> Option<&str> {
for (i, token) in tokens.iter().enumerate() {
if let Some(value) = token.strip_prefix("--session=") {
return (!value.is_empty()).then_some(value);
}
if let Some(value) = token.strip_prefix("-s=") {
return (!value.is_empty()).then_some(value);
}
if token == "--session" || token == "-s" {
return tokens.get(i + 1).map(String::as_str);
}
}
None
}
fn flag_value<'a>(tokens: &'a [String], flag: &str) -> Option<&'a str> {
for (i, token) in tokens.iter().enumerate() {
if let Some(value) = token.strip_prefix(&format!("{flag}=")) {
return (!value.is_empty()).then_some(value);
}
if token == flag {
return tokens.get(i + 1).map(String::as_str);
}
}
None
}
fn resume_title(tokens: &[String]) -> Option<String> {
let (value, idx) = resume_value(tokens)?;
if tokens.get(idx).is_some_and(|t| t.starts_with("--resume=")) {
return (!value.is_empty()).then(|| value.to_string());
}
let rest = tokens.get(idx..)?;
(!rest.is_empty()).then(|| rest.join(" ").trim().to_string())
}
impl Collector {
pub fn new() -> Self {
Collector {
sys: System::new(),
ghosts: HashMap::new(),
orphans: HashMap::new(),
}
}
pub fn orphans(&self) -> &HashMap<String, Orphan> {
&self.orphans
}
pub fn collect(&mut self, sessions: &[Session]) -> HashMap<String, ProcInfo> {
self.sys.refresh_processes_specifics(
ProcessesToUpdate::All,
true,
ProcessRefreshKind::nothing()
.with_cmd(UpdateKind::Always)
.with_cwd(UpdateKind::Always)
.with_exe(UpdateKind::Always)
.with_memory()
.with_cpu()
.without_tasks(),
);
self.orphans.clear();
struct Snap {
ppid: u32,
args: String,
tokens: Vec<String>,
name: String,
cwd: String,
cpu: f32,
memory: u64,
start_time: u64,
}
let snapshot: HashMap<u32, Snap> = self
.sys
.processes()
.iter()
.filter(|(_, p)| p.thread_kind().is_none())
.map(|(pid, p)| {
let tokens: Vec<String> = p
.cmd()
.iter()
.map(|s| s.to_string_lossy().into_owned())
.collect();
let args = tokens.join(" ");
let name = p
.exe()
.map(|e| command_stem(&e.to_string_lossy()))
.filter(|n| !n.is_empty())
.or_else(|| tokens.first().map(|t| command_stem(t)))
.unwrap_or_else(|| command_stem(&p.name().to_string_lossy()));
(
pid.as_u32(),
Snap {
ppid: p.parent().map(Pid::as_u32).unwrap_or(0),
args,
tokens,
name,
cwd: p
.cwd()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default(),
cpu: p.cpu_usage(),
memory: p.memory(),
start_time: p.start_time(),
},
)
})
.collect();
let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
for (pid, s) in &snapshot {
children.entry(s.ppid).or_default().push(*pid);
}
let mut current_ancestors = HashSet::new();
let mut current = Some(std::process::id());
while let Some(pid) = current {
if !current_ancestors.insert(pid) {
break;
}
current = snapshot
.get(&pid)
.and_then(|s| (s.ppid != 0).then_some(s.ppid));
}
let mut roots: HashMap<String, u32> = HashMap::new();
let mut unmatched: Vec<(u32, crate::pricing::Provider)> = Vec::new();
let mut cwd_index: HashMap<(crate::pricing::Provider, &str), Vec<&Session>> =
HashMap::new();
for s in sessions.iter().filter(|s| !s.label_source.is_empty()) {
cwd_index
.entry((s.provider, s.label_source.as_str()))
.or_default()
.push(s);
}
for candidates in cwd_index.values_mut() {
candidates.sort_by(|a, b| {
b.last_active
.cmp(&a.last_active)
.then_with(|| a.session_id.cmp(&b.session_id))
});
}
for (&pid, snap) in &snapshot {
if exclude_agent_process(&snap.name, &snap.tokens, &snap.args) {
continue;
}
let is_claude = is_claude_binary(&snap.name, &snap.tokens);
let is_codex = is_codex_process(&snap.name, &snap.tokens)
&& (snap.name != "bwrap" || current_ancestors.contains(&pid));
let is_opencode = matches!(snap.name.as_str(), "opencode" | "opencode-cli")
|| (snap.name == "node" && is_node_hosted_agent(&snap.tokens, "opencode"));
let is_pi = snap.name == "pi"
|| (snap.name == "node" && is_node_hosted_agent(&snap.tokens, "pi"));
if !is_claude && !is_codex && !is_opencode && !is_pi {
continue;
}
let provider = if is_opencode {
crate::pricing::Provider::OpenCode
} else if is_pi {
crate::pricing::Provider::Pi
} else if is_codex {
crate::pricing::Provider::Codex
} else {
crate::pricing::Provider::Claude
};
if matches!(
provider,
crate::pricing::Provider::OpenCode | crate::pricing::Provider::Pi
) && let Some(id) = session_value(&snap.tokens)
{
claim_root(&mut roots, format!("{}:{id}", provider.as_str()), pid);
continue;
}
if let Some(uuid) = resume_uuid(&snap.tokens) {
claim_root(&mut roots, format!("{}:{}", provider.as_str(), uuid), pid);
continue;
}
if is_claude
&& let Some(title) = resume_title(&snap.tokens)
&& let Some(session) = sessions
.iter()
.find(|s| s.surface.is_desktop() && s.title.as_deref() == Some(title.as_str()))
{
roots.entry(session.key()).or_insert(pid);
continue;
}
unmatched.push((pid, provider));
}
let mut by_cwd: HashMap<(crate::pricing::Provider, String), Vec<u32>> = HashMap::new();
for (pid, provider) in unmatched {
let mut current = Some(pid);
let mut seen = HashSet::new();
let cwd = loop {
let Some(current_pid) = current else { break "" };
if !seen.insert(current_pid) {
break "";
}
let Some(s) = snapshot.get(¤t_pid) else {
break "";
};
if !s.cwd.is_empty() {
break s.cwd.as_str();
}
if let Some(value) = flag_value(&s.tokens, "--command-cwd")
.or_else(|| flag_value(&s.tokens, "--sandbox-policy-cwd"))
{
break value;
}
current = (s.ppid != 0).then_some(s.ppid);
};
by_cwd
.entry((provider, cwd.to_string()))
.or_default()
.push(pid);
}
let mut groups: Vec<((crate::pricing::Provider, String), Vec<u32>)> =
by_cwd.into_iter().collect();
groups.sort_by(|a, b| (a.0.0.as_str(), &a.0.1).cmp(&(b.0.0.as_str(), &b.0.1)));
for ((provider, cwd), mut pids) in groups {
pids.sort_by_key(|pid| (snapshot.get(pid).map_or(0, |s| s.start_time), *pid));
let mut claimed = 0;
if !cwd.is_empty()
&& let Some(candidates) = cwd_index.get(&(provider, cwd.as_str()))
{
let live = live_sessions_for_group(candidates, pids.len(), &roots);
for (pid, session) in pids.iter().zip(&live) {
claim_root(&mut roots, session.key(), *pid);
claimed += 1;
}
}
for &pid in &pids[claimed..] {
if provider == crate::pricing::Provider::Codex {
continue;
}
let key = format!("{}:_pid_{}", provider.as_str(), pid);
claim_root(&mut roots, key.clone(), pid);
self.orphans.insert(
key,
Orphan {
provider,
cwd: cwd.clone(),
},
);
}
}
let mut result = HashMap::new();
for (key, root_pid) in roots {
let pids = descendants(root_pid, &children);
let mut info = ProcInfo {
command: snapshot
.get(&root_pid)
.map(|s| s.args.clone())
.unwrap_or_default(),
..Default::default()
};
for pid in &pids {
let Some(snap) = snapshot.get(pid) else {
continue;
};
info.cpu += snap.cpu;
info.memory += snap.memory;
info.process_list.push(ProcEntry {
pid: *pid,
cpu: snap.cpu,
memory: snap.memory,
args: snap.args.clone(),
is_root: *pid == root_pid,
ghost: false,
});
}
info.pids = pids.len();
info.cpu = (info.cpu * 10.0).round() / 10.0;
self.apply_linger(&key, &mut info);
result.insert(key, info);
}
self.ghosts.retain(|k, _| result.contains_key(k));
result
}
fn apply_linger(&mut self, key: &str, info: &mut ProcInfo) {
let cache = self.ghosts.entry(key.to_string()).or_default();
let live: HashSet<u32> = info.process_list.iter().map(|p| p.pid).collect();
for p in &info.process_list {
cache.insert(p.pid, (p.clone(), PROC_LINGER_TICKS));
}
cache.retain(|pid, (entry, remaining)| {
if live.contains(pid) {
return true;
}
if *remaining == 0 {
return false;
}
*remaining -= 1;
let mut ghost = entry.clone();
ghost.ghost = true;
ghost.cpu = 0.0;
info.process_list.push(ghost);
true
});
}
}
fn live_sessions_for_group<'a>(
candidates: &[&'a Session],
process_count: usize,
claimed: &HashMap<String, u32>,
) -> Vec<&'a Session> {
let mut live: Vec<&Session> = candidates
.iter()
.copied()
.filter(|s| !claimed.contains_key(&s.key()))
.take(process_count)
.collect();
live.sort_by_key(|s| util::parse_ts(&s.started_at));
live
}
fn claim_root(roots: &mut HashMap<String, u32>, key: String, pid: u32) {
roots
.entry(key)
.and_modify(|existing| {
if pid < *existing {
*existing = pid;
}
})
.or_insert(pid);
}
fn descendants(root: u32, children: &HashMap<u32, Vec<u32>>) -> Vec<u32> {
let mut seen = HashSet::from([root]);
let mut out = vec![root];
let mut queue = vec![root];
while let Some(pid) = queue.pop() {
let Some(kids) = children.get(&pid) else {
continue;
};
for &child in kids {
if seen.insert(child) {
out.push(child);
queue.push(child);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn toks(s: &str) -> Vec<String> {
s.split_whitespace().map(str::to_string).collect()
}
const UUID: &str = "7026d578-8cba-4880-b464-9700f1b77b71";
fn session_at(id: &str, started_at: &str, last_active: &str) -> Session {
let mut s = Session::new(crate::pricing::Provider::Claude, id.to_string());
s.started_at = started_at.to_string();
s.last_active = last_active.to_string();
s
}
#[test]
fn concurrent_sessions_in_one_directory_each_claim_a_process() {
let newest = session_at(
"newest",
"2026-08-05T12:00:00+00:00",
"2026-08-05T15:00:00+00:00",
);
let older = session_at(
"older",
"2026-08-05T09:00:00+00:00",
"2026-08-05T14:00:00+00:00",
);
let stale = session_at(
"stale",
"2026-01-01T00:00:00+00:00",
"2026-01-01T00:00:00+00:00",
);
let candidates = vec![&newest, &older, &stale];
let live = live_sessions_for_group(&candidates, 2, &HashMap::new());
let ids: Vec<&str> = live.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, vec!["older", "newest"]);
let single = live_sessions_for_group(&candidates, 1, &HashMap::new());
assert_eq!(
single
.iter()
.map(|s| s.session_id.as_str())
.collect::<Vec<_>>(),
vec!["newest"]
);
assert!(live_sessions_for_group(&candidates, 0, &HashMap::new()).is_empty());
}
#[test]
fn exact_matches_are_not_reclaimed_by_directory_matching() {
let newest = session_at(
"newest",
"2026-08-05T12:00:00+00:00",
"2026-08-05T15:00:00+00:00",
);
let older = session_at(
"older",
"2026-08-05T09:00:00+00:00",
"2026-08-05T14:00:00+00:00",
);
let candidates = vec![&newest, &older];
let mut claimed = HashMap::new();
claimed.insert(newest.key(), 4242);
let live = live_sessions_for_group(&candidates, 1, &claimed);
assert_eq!(
live.iter()
.map(|s| s.session_id.as_str())
.collect::<Vec<_>>(),
vec!["older"]
);
}
#[test]
fn resume_uuid_extraction() {
assert_eq!(
resume_uuid(&toks(&format!("claude --resume {UUID}"))),
Some(UUID)
);
assert_eq!(resume_uuid(&toks("claude --resume My Session")), None);
assert_eq!(resume_uuid(&toks("claude")), None);
}
#[test]
fn resume_uuid_accepts_equals_form() {
let t = toks(&format!(
"/home/f/.claude/remote/ccd-cli/2.1.221 --verbose --resume={UUID} -"
));
assert_eq!(resume_uuid(&t), Some(UUID));
}
#[test]
fn version_named_claude_binaries_are_recognised() {
assert!(is_claude_binary("2.1.222", &toks("claude")));
assert!(is_claude_binary("2.1.222", &toks("claude --resume")));
assert!(is_claude_binary(
"2.1.222",
&toks("/home/f/.local/share/claude/versions/2.1.222")
));
assert!(is_claude_binary(
"2.1.221",
&toks("/home/f/.claude/remote/ccd-cli/2.1.221 --verbose")
));
assert!(!is_claude_binary("2.1.222", &toks("/opt/other/2.1.222")));
assert!(!is_claude_binary("node", &toks("node server.js")));
}
#[test]
fn resume_title_joins_remaining_args() {
assert_eq!(
resume_title(&toks("claude --resume My Long Title")).as_deref(),
Some("My Long Title")
);
assert_eq!(
resume_title(&toks("claude --resume=Solo")).as_deref(),
Some("Solo")
);
}
#[test]
fn version_named_remote_binary_is_recognised() {
let t = toks("/home/flo/.claude/remote/ccd-cli/2.1.221 --verbose");
assert!(is_claude_binary(&command_stem(&t[0]), &t));
assert!(is_claude_binary("claude", &toks("claude")));
assert!(!is_claude_binary("node", &toks("node server.js")));
assert!(!is_claude_binary(
"server",
&toks("/home/flo/.claude/remote/srv/abc/server --serve")
));
}
#[test]
fn node_codex_requires_script_arg_not_stray_path() {
assert!(is_node_hosted_codex(&toks("node /usr/lib/codex.js run")));
assert!(!is_node_hosted_codex(&toks(
"node /home/f/app.js --config /home/f/.codex/config.toml"
)));
}
#[test]
fn platform_named_codex_binaries_are_agent_roots() {
assert!(is_codex_binary("codex"));
assert!(is_codex_binary("codex-x86_64-unknown-linux-musl"));
assert!(is_codex_binary("codex-aarch64-apple-darwin"));
assert!(is_codex_binary("codex-x86_64-pc-windows-msvc"));
assert!(!is_codex_binary("codex-linux-sandbox"));
}
#[test]
fn sandbox_wrapper_with_codex_executable_is_an_agent_root() {
let argv = toks("/home/f/.cursor-server/extensions/openai.chatgpt/bin/codex app-server");
assert!(is_codex_process("codex-linux-sandbox", &argv));
assert!(!is_codex_process(
"codex-linux-sandbox",
&toks("codex-linux-sandbox --helper")
));
}
#[test]
fn codex_app_server_reaches_cwd_session_matching() {
let native = toks("/opt/codex -c features.code_mode_host=true app-server");
assert!(is_daemon(&native.join(" ")));
assert!(!exclude_agent_process("codex", &native, &native.join(" ")));
let claude = toks("claude app-server");
assert!(exclude_agent_process("claude", &claude, &claude.join(" ")));
let helper = toks("codex-code-mode-host");
assert!(!is_codex_process("codex-code-mode-host", &helper));
}
#[test]
fn node_hosted_agents_are_identified_by_script_name() {
assert!(is_node_hosted_agent(
&toks("node /usr/lib/opencode.js --session ses_1"),
"opencode"
));
assert!(is_node_hosted_agent(&toks("node /usr/lib/pi.js -c"), "pi"));
assert!(!is_node_hosted_agent(
&toks("node app.js --config /home/f/.pi/settings.json"),
"pi"
));
}
#[test]
fn session_flag_supports_opencode_and_pi_forms() {
assert_eq!(
session_value(&toks("opencode --session ses_123")),
Some("ses_123")
);
assert_eq!(session_value(&toks("opencode -s=ses_123")), Some("ses_123"));
assert_eq!(session_value(&toks("pi --session=abc123")), Some("abc123"));
}
#[test]
fn sandbox_codex_app_server_uses_command_cwd() {
let tokens = toks("codex-linux-sandbox --command-cwd /home/f/cctop app-server");
assert!(is_codex_process("codex-linux-sandbox", &tokens));
assert_eq!(flag_value(&tokens, "--command-cwd"), Some("/home/f/cctop"));
}
#[test]
fn bwrap_codex_launcher_is_recognized_with_workspace_flags() {
let tokens = toks(
"codex-linux-sandbox -- /opt/codex --sandbox-policy-cwd /home/f/cctop --command-cwd /home/f/cctop app-server",
);
assert!(is_codex_process("codex-linux-sandbox", &tokens));
assert!(is_codex_process("bwrap", &tokens));
}
#[test]
fn daemons_and_bundles_excluded() {
assert!(is_daemon("codex app-server"));
assert!(!is_daemon("codex resume abc"));
assert!(is_app_bundle(
"/Applications/Claude.app/Contents/MacOS/Claude"
));
assert!(!is_app_bundle(
"/Users/x/claude-code/versions/1.2/claude.app/Contents/MacOS/claude"
));
}
#[test]
fn command_stem_strips_path_and_extension() {
assert_eq!(command_stem("/usr/local/bin/claude"), "claude");
assert_eq!(command_stem("C:\\bin\\codex.exe"), "codex");
assert_eq!(command_stem("codex.js"), "codex");
}
#[test]
fn root_claim_is_order_independent() {
let mut a = HashMap::new();
for pid in [900u32, 120, 4000] {
claim_root(&mut a, "claude:x".into(), pid);
}
let mut b = HashMap::new();
for pid in [4000u32, 900, 120] {
claim_root(&mut b, "claude:x".into(), pid);
}
assert_eq!(a["claude:x"], 120);
assert_eq!(a, b);
}
#[test]
fn descendants_walks_full_subtree() {
let children = HashMap::from([(1, vec![2, 3]), (2, vec![4]), (3, vec![]), (4, vec![5])]);
let mut got = descendants(1, &children);
got.sort();
assert_eq!(got, vec![1, 2, 3, 4, 5]);
}
#[test]
fn descendants_survives_cycles() {
let children = HashMap::from([(1, vec![2]), (2, vec![1])]);
let mut got = descendants(1, &children);
got.sort();
assert_eq!(got, vec![1, 2]);
}
}