use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
pub fn find_claude_pid_in_tmux(
session_name: &str,
max_attempts: u8,
delay: Duration,
) -> Option<u32> {
for attempt in 0..max_attempts.max(1) {
if attempt > 0 {
sleep(delay);
}
let Some(pane_pid) = tmux_pane_pid(session_name) else {
return None;
};
if let Some(pid) = claude_child_of(pane_pid) {
return Some(pid);
}
}
None
}
fn tmux_pane_pid(session_name: &str) -> Option<u32> {
let output = Command::new("tmux")
.args(["display-message", "-t", session_name, "-p", "#{pane_pid}"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
text.trim().parse::<u32>().ok()
}
fn claude_child_of(shell_pid: u32) -> Option<u32> {
let children = child_pids_of(shell_pid);
if let Some(pid) = children
.iter()
.copied()
.find(|&pid| process_name_contains_claude(pid))
{
return Some(pid);
}
children
.into_iter()
.filter(|&child| is_disclaim_wrapper(child))
.find_map(|wrapper| {
child_pids_of(wrapper)
.into_iter()
.find(|&pid| process_name_contains_claude(pid))
})
}
fn child_pids_of(pid: u32) -> Vec<u32> {
let Ok(output) = Command::new("pgrep")
.args(["-P", &pid.to_string()])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect()
}
fn is_disclaim_wrapper(pid: u32) -> bool {
process_args(pid)
.is_some_and(|args| args.contains(crate::core::spawn_disclaim::PANE_DISCLAIM_SUBCOMMAND))
}
fn process_args(pid: u32) -> Option<String> {
#[cfg(target_os = "linux")]
{
if let Ok(cmdline) = std::fs::read_to_string(format!("/proc/{pid}/cmdline")) {
return Some(cmdline.replace('\0', " "));
}
}
let output = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "args="])
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
}
fn process_name_contains_claude(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
if let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) {
return comm.to_ascii_lowercase().contains("claude");
}
}
let output = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output();
match output {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.to_ascii_lowercase()
.contains("claude"),
_ => false,
}
}
pub fn process_name_is_claude(pid: u32) -> bool {
process_name_contains_claude(pid)
}
pub fn is_process_alive(pid: u32) -> bool {
use nix::errno::Errno;
use nix::sys::signal::kill;
use nix::unistd::Pid;
let Ok(raw) = i32::try_from(pid) else {
return false;
};
if raw <= 0 {
return false;
}
match kill(Pid::from_raw(raw), None) {
Ok(()) => true,
Err(Errno::EPERM) => true,
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_claude_pid_returns_none_for_nonexistent_session() {
let pid = find_claude_pid_in_tmux(
"tmpm-definitely-not-a-real-session-xyz",
2,
Duration::from_millis(1),
);
assert_eq!(pid, None);
}
#[test]
fn is_process_alive_current_process() {
assert!(is_process_alive(std::process::id()));
}
#[test]
fn is_process_alive_dead_pid() {
assert!(!is_process_alive(u32::MAX));
}
#[test]
fn process_name_is_claude_for_dead_pid_is_false() {
assert!(!process_name_is_claude(u32::MAX));
}
#[test]
fn tree_helpers_see_a_real_child_and_reject_a_non_wrapper() {
use std::process::Stdio;
let mut child = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep");
let cpid = child.id();
assert!(
child_pids_of(std::process::id()).contains(&cpid),
"child_pids_of must include the spawned sleep pid {cpid}"
);
assert!(
!is_disclaim_wrapper(cpid),
"a bare sleep is not the wrapper"
);
assert!(
process_args(cpid).unwrap_or_default().contains("sleep"),
"process_args must read the child's argv"
);
let _ = child.kill();
let _ = child.wait();
}
#[cfg(target_os = "macos")]
#[test]
#[ignore]
fn claude_pid_resolves_through_disclaim_wrapper() {
if Command::new("tmux").arg("-V").output().is_err() {
eprintln!("tmux not available; skipping");
return;
}
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::os::unix::fs::symlink("/bin/sleep", dir.join("claude")).unwrap();
let wrap = dir.join("wrap.sh");
std::fs::write(
&wrap,
format!("#!/bin/sh\n\"{}/claude\" 60 &\nwait\n", dir.display()),
)
.unwrap();
let drv = dir.join("drv.sh");
std::fs::write(
&drv,
format!(
"#!/bin/sh\nsh \"{}\" {} &\nwait\n",
wrap.display(),
crate::core::spawn_disclaim::PANE_DISCLAIM_SUBCOMMAND
),
)
.unwrap();
let session = format!("tmpm-disclaim-pid-{}", std::process::id());
let tmux = |args: &[&str]| Command::new("tmux").args(args).status();
let _ = tmux(&["kill-session", "-t", &session]);
tmux(&[
"new-session",
"-d",
"-s",
&session,
&format!("sh {}", drv.display()),
])
.unwrap();
let pid = find_claude_pid_in_tmux(&session, 25, Duration::from_millis(200));
let named_claude = pid.map(process_name_is_claude);
let _ = tmux(&["kill-session", "-t", &session]);
let pid = pid.expect("claude pid must resolve through the disclaim wrapper");
assert_eq!(
named_claude,
Some(true),
"resolved pid {pid} must be the fake claude grandchild reached via the wrapper hop"
);
}
}