use crate::platform::proc_check::is_claude_or_node_name;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcRow {
pub pid: u32,
pub ppid: Option<u32>,
pub pgid: Option<u32>,
pub start_time: u64,
pub exe_base: String,
pub cmd_snippet: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub sid: String,
pub claude_pid: u32,
pub born: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateKind {
Child,
LiveClaude,
}
impl CandidateKind {
pub fn tag(self) -> &'static str {
match self {
CandidateKind::Child => "child",
CandidateKind::LiveClaude => "live-claude",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
pub pid: u32,
pub kind: CandidateKind,
pub exe_base: String,
pub start_time: u64,
pub cmd_snippet: String,
}
impl Candidate {
fn child(p: &ProcRow) -> Self {
Candidate {
pid: p.pid,
kind: CandidateKind::Child,
exe_base: p.exe_base.clone(),
start_time: p.start_time,
cmd_snippet: p.cmd_snippet.clone(),
}
}
fn live_claude(p: &ProcRow) -> Self {
Candidate {
pid: p.pid,
kind: CandidateKind::LiveClaude,
exe_base: p.exe_base.clone(),
start_time: p.start_time,
cmd_snippet: p.cmd_snippet.clone(),
}
}
}
pub fn ppid_descendants(table: &[ProcRow], root: u32) -> std::collections::BTreeSet<u32> {
use std::collections::BTreeSet;
let mut reached: BTreeSet<u32> = BTreeSet::new();
let mut frontier = vec![root];
while let Some(parent) = frontier.pop() {
for p in table {
if p.ppid == Some(parent) && p.pid != root && reached.insert(p.pid) {
frontier.push(p.pid);
}
}
}
reached
}
pub fn session_claude_is_live(table: &[ProcRow], session: &Session) -> bool {
table.iter().any(|p| {
p.pid == session.claude_pid
&& p.start_time == session.born
&& is_claude_or_node_name(&p.exe_base)
})
}
pub fn select_candidates(
table: &[ProcRow],
session: &Session,
self_pid: u32,
include_live_claude: bool,
) -> Vec<Candidate> {
let c = session.claude_pid;
let born = session.born;
let descendants = ppid_descendants(table, c);
let mut out: Vec<Candidate> = Vec::new();
for p in table {
if p.pid == self_pid || p.pid == c {
continue;
}
if p.start_time < born {
continue; }
let pgid_match = p.pgid == Some(c);
if pgid_match || descendants.contains(&p.pid) {
out.push(Candidate::child(p));
}
}
if include_live_claude {
if let Some(cp) = table.iter().find(|p| p.pid == c) {
if cp.start_time == born && is_claude_or_node_name(&cp.exe_base) {
out.push(Candidate::live_claude(cp));
}
}
}
out.sort_by_key(|cand| cand.pid);
out.dedup_by_key(|cand| cand.pid);
out
}
pub fn format_age(now: u64, start_time: u64) -> String {
let secs = now.saturating_sub(start_time);
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{h}:{m:02}:{s:02}")
}
pub fn candidate_row(c: &Candidate, now: u64) -> String {
let age = format_age(now, c.start_time);
format!(
"{pid}\t{tag}\t{pid:>7} {exe} age={age} {cmd}",
pid = c.pid,
tag = c.kind.tag(),
exe = c.exe_base,
age = age,
cmd = c.cmd_snippet,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn row(pid: u32, ppid: Option<u32>, pgid: Option<u32>, start: u64, exe: &str) -> ProcRow {
ProcRow {
pid,
ppid,
pgid,
start_time: start,
exe_base: exe.to_string(),
cmd_snippet: format!("{exe} --flag"),
}
}
fn session(claude_pid: u32, born: u64) -> Session {
Session {
sid: "00000000-0000-0000-0000-000000000000".to_string(),
claude_pid,
born,
}
}
const CLAUDE: u32 = 100;
const BORN: u64 = 1000;
const SELF: u32 = 9;
#[test]
fn pgid_match_selects_child() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "node"),
row(200, Some(CLAUDE), Some(CLAUDE), BORN + 5, "node"), ];
let got = select_candidates(&table, &session(CLAUDE, BORN), SELF, false);
assert_eq!(got.len(), 1);
assert_eq!(got[0].pid, 200);
assert_eq!(got[0].kind, CandidateKind::Child);
}
#[test]
fn different_pgid_not_selected() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "node"),
row(300, Some(1), Some(300), BORN + 5, "firefox"),
];
let got = select_candidates(&table, &session(CLAUDE, BORN), SELF, false);
assert!(
got.is_empty(),
"unrelated proc must not be a candidate: {got:?}"
);
}
#[test]
fn born_filter_rejects_recycled_pid_even_with_matching_pgid() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "node"),
row(200, Some(CLAUDE), Some(CLAUDE), BORN - 1, "node"), ];
let got = select_candidates(&table, &session(CLAUDE, BORN), SELF, false);
assert!(got.is_empty(), "pre-born pid must be rejected: {got:?}");
}
#[test]
fn never_selects_self_or_claude() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "node"),
row(SELF, Some(1), Some(CLAUDE), BORN, "csm"),
];
let got = select_candidates(&table, &session(CLAUDE, BORN), SELF, false);
assert!(
got.iter().all(|c| c.pid != SELF && c.pid != CLAUDE),
"self/claude must never be child candidates: {got:?}"
);
}
#[test]
fn ppid_walk_catches_setsid_escaped_grandchild() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "node"),
row(200, Some(CLAUDE), Some(CLAUDE), BORN + 1, "bash"), row(201, Some(200), Some(201), BORN + 2, "python3"), ];
let got = select_candidates(&table, &session(CLAUDE, BORN), SELF, false);
let pids: Vec<u32> = got.iter().map(|c| c.pid).collect();
assert!(pids.contains(&200), "in-group child missing: {pids:?}");
assert!(
pids.contains(&201),
"setsid-escaped grandchild must be caught by ppid walk: {pids:?}"
);
}
#[test]
fn pgid_and_ppid_overlap_dedups_to_one() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "node"),
row(200, Some(CLAUDE), Some(CLAUDE), BORN + 1, "bash"), ];
let got = select_candidates(&table, &session(CLAUDE, BORN), SELF, false);
assert_eq!(got.len(), 1, "must dedup to a single candidate: {got:?}");
assert_eq!(got[0].pid, 200);
}
#[test]
fn none_pgid_only_reachable_via_ppid() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "node"),
row(200, Some(CLAUDE), None, BORN + 1, "helper"), row(300, Some(1), None, BORN + 1, "unrelated"), ];
let got = select_candidates(&table, &session(CLAUDE, BORN), SELF, false);
let pids: Vec<u32> = got.iter().map(|c| c.pid).collect();
assert_eq!(
pids,
vec![200],
"only the ppid-reachable None-pgid proc: {pids:?}"
);
}
#[test]
fn live_claude_included_only_when_requested_and_matching() {
let table = vec![row(CLAUDE, Some(1), Some(CLAUDE), BORN, "claude")];
let off = select_candidates(&table, &session(CLAUDE, BORN), SELF, false);
assert!(
off.is_empty(),
"claude must be absent when not requested: {off:?}"
);
let on = select_candidates(&table, &session(CLAUDE, BORN), SELF, true);
assert_eq!(on.len(), 1);
assert_eq!(on[0].pid, CLAUDE);
assert_eq!(on[0].kind, CandidateKind::LiveClaude);
}
#[test]
fn live_claude_rejected_on_born_mismatch_or_wrong_exe() {
let recycled = vec![row(CLAUDE, Some(1), Some(CLAUDE), BORN + 50, "claude")];
let got = select_candidates(&recycled, &session(CLAUDE, BORN), SELF, true);
assert!(
got.is_empty(),
"born mismatch must reject live-claude: {got:?}"
);
let impostor = vec![row(CLAUDE, Some(1), Some(CLAUDE), BORN, "python3")];
let got = select_candidates(&impostor, &session(CLAUDE, BORN), SELF, true);
assert!(
got.is_empty(),
"non-claude exe must reject live-claude: {got:?}"
);
}
#[test]
fn ppid_descendants_handles_cycles() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "node"),
row(200, Some(CLAUDE), Some(CLAUDE), BORN + 1, "a"),
row(201, Some(200), Some(CLAUDE), BORN + 1, "b"),
row(200, Some(201), Some(CLAUDE), BORN + 1, "a-dup"), ];
let reached = ppid_descendants(&table, CLAUDE);
assert!(reached.contains(&200) && reached.contains(&201));
}
#[test]
fn format_age_basic_and_clamps() {
assert_eq!(format_age(1000 + 3661, 1000), "1:01:01");
assert_eq!(format_age(1000, 1000), "0:00:00");
assert_eq!(format_age(900, 1000), "0:00:00");
}
#[test]
fn candidate_row_col1_is_recoverable_pid() {
let c = Candidate {
pid: 4242,
kind: CandidateKind::Child,
exe_base: "node".to_string(),
start_time: 1000,
cmd_snippet: "node mcp-server.js".to_string(),
};
let r = candidate_row(&c, 1000 + 65);
let col1 = r.split('\t').next().unwrap();
assert_eq!(col1, "4242", "col1 must be the recoverable pid");
let col2 = r.split('\t').nth(1).unwrap();
assert_eq!(col2, "child", "col2 must be the kind tag");
assert!(r.contains("age=0:01:05"), "row: {r}");
assert!(r.contains("node mcp-server.js"), "row: {r}");
}
#[test]
fn session_live_when_claude_present_born_matches_and_exe_is_claude() {
let table = vec![
row(CLAUDE, Some(1), Some(CLAUDE), BORN, "claude"),
row(200, Some(CLAUDE), Some(CLAUDE), BORN + 5, "node"),
];
assert!(
session_claude_is_live(&table, &session(CLAUDE, BORN)),
"a present claude with matching born + claude exe must read as live"
);
}
#[test]
fn session_not_live_when_claude_pid_absent() {
let table = vec![row(200, Some(1), Some(CLAUDE), BORN + 5, "node")];
assert!(
!session_claude_is_live(&table, &session(CLAUDE, BORN)),
"absent claude pid must read as not-live"
);
}
#[test]
fn session_not_live_on_born_mismatch_or_wrong_exe() {
let recycled = vec![row(CLAUDE, Some(1), Some(CLAUDE), BORN + 99, "claude")];
assert!(!session_claude_is_live(&recycled, &session(CLAUDE, BORN)));
let impostor = vec![row(CLAUDE, Some(1), Some(CLAUDE), BORN, "firefox")];
assert!(!session_claude_is_live(&impostor, &session(CLAUDE, BORN)));
}
}