pub fn running_process_names() -> Vec<String> {
#[cfg(target_os = "linux")]
{
linux_comms()
}
#[cfg(not(target_os = "linux"))]
{
ps_comms()
}
}
#[cfg(target_os = "linux")]
fn linux_comms() -> Vec<String> {
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir("/proc") {
for e in rd.flatten() {
let is_pid = e
.file_name()
.to_str()
.map(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
.unwrap_or(false);
if is_pid {
if let Ok(comm) = std::fs::read_to_string(e.path().join("comm")) {
out.push(comm.trim().to_string());
}
}
}
}
out
}
#[cfg(not(target_os = "linux"))]
fn ps_comms() -> Vec<String> {
match std::process::Command::new("ps")
.args(["-Ao", "comm="])
.output()
{
Ok(o) => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().rsplit('/').next().unwrap_or("").to_string())
.filter(|s| !s.is_empty())
.collect(),
Err(_) => Vec::new(),
}
}
#[derive(Debug, Clone)]
pub struct ClaudeProc {
pub securestorage_dir: Option<String>,
pub config_dir: Option<String>,
pub env_read: bool,
}
fn slot_key(securestorage: Option<&str>, config: Option<&str>) -> Option<String> {
match securestorage {
Some(s) if s.trim().is_empty() => None,
Some(s) => Some(s.trim().to_string()),
None => match config {
Some(c) if !c.trim().is_empty() => Some(c.trim().to_string()),
_ => None,
},
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum GuardVerdict {
Clear,
SameSlot,
Unknown,
}
pub fn claude_switch_guard(
my_securestorage: Option<&str>,
my_config: Option<&str>,
running: &[ClaudeProc],
) -> GuardVerdict {
let mine = slot_key(my_securestorage, my_config);
let mut unknown = false;
for p in running {
if !p.env_read {
unknown = true;
continue;
}
if slot_key(p.securestorage_dir.as_deref(), p.config_dir.as_deref()) == mine {
return GuardVerdict::SameSlot;
}
}
if unknown {
GuardVerdict::Unknown
} else {
GuardVerdict::Clear
}
}
pub fn running_claude_procs() -> Vec<ClaudeProc> {
#[cfg(target_os = "linux")]
{
linux_claude_procs()
}
#[cfg(not(target_os = "linux"))]
{
macos_claude_procs()
}
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn config_dirs_from_environ(bytes: &[u8]) -> (Option<String>, Option<String>) {
let (mut ss, mut cfg) = (None, None);
for kv in bytes.split(|&b| b == 0) {
let Ok(s) = std::str::from_utf8(kv) else {
continue;
};
if let Some(v) = s.strip_prefix("CLAUDE_SECURESTORAGE_CONFIG_DIR=") {
ss = Some(v.to_string());
} else if let Some(v) = s.strip_prefix("CLAUDE_CONFIG_DIR=") {
cfg = Some(v.to_string());
}
}
(ss, cfg)
}
#[cfg(target_os = "linux")]
fn linux_claude_procs() -> Vec<ClaudeProc> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir("/proc") else {
return out;
};
for e in rd.flatten() {
let is_pid = e
.file_name()
.to_str()
.map(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
.unwrap_or(false);
if !is_pid {
continue;
}
let comm = std::fs::read_to_string(e.path().join("comm")).unwrap_or_default();
if comm.trim() != "claude" {
continue;
}
match std::fs::read(e.path().join("environ")) {
Ok(bytes) => {
let (securestorage_dir, config_dir) = config_dirs_from_environ(&bytes);
out.push(ClaudeProc {
securestorage_dir,
config_dir,
env_read: true,
});
}
Err(_) => out.push(ClaudeProc {
securestorage_dir: None,
config_dir: None,
env_read: false,
}),
}
}
out
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
fn config_dirs_from_ps(line: &str) -> (Option<String>, Option<String>, bool) {
let mut ss = None;
let mut cfg = None;
let mut env_seen = false;
for tok in line.split_ascii_whitespace() {
if tok.starts_with("PATH=") || tok.starts_with("HOME=") || tok.starts_with("USER=") {
env_seen = true;
}
if let Some(v) = tok.strip_prefix("CLAUDE_SECURESTORAGE_CONFIG_DIR=") {
ss = Some(v.to_string());
env_seen = true;
} else if let Some(v) = tok.strip_prefix("CLAUDE_CONFIG_DIR=") {
cfg = Some(v.to_string());
env_seen = true;
}
}
(ss, cfg, env_seen)
}
#[cfg(not(target_os = "linux"))]
fn macos_claude_procs() -> Vec<ClaudeProc> {
let listing = match std::process::Command::new("ps")
.args(["-Ao", "pid=,comm="])
.output()
{
Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
Err(_) => return Vec::new(),
};
let pids: Vec<String> = listing
.lines()
.filter_map(|l| {
let l = l.trim();
let (pid, comm) = l.split_once(char::is_whitespace)?;
let name = comm.trim().rsplit('/').next().unwrap_or("");
(name == "claude").then(|| pid.trim().to_string())
})
.collect();
let mut out = Vec::new();
for pid in pids {
match std::process::Command::new("ps")
.args(["eww", "-o", "command=", "-p", &pid])
.output()
{
Ok(o) if o.status.success() => {
let line = String::from_utf8_lossy(&o.stdout);
let (securestorage_dir, config_dir, env_seen) = config_dirs_from_ps(&line);
out.push(ClaudeProc {
securestorage_dir,
config_dir,
env_read: env_seen,
});
}
_ => out.push(ClaudeProc {
securestorage_dir: None,
config_dir: None,
env_read: false,
}),
}
}
out
}
pub fn tool_running(tool: &str, comms: &[String]) -> bool {
let want = match tool {
"claude-code" => "claude",
"codex" => "codex",
"gemini" => "gemini",
"antigravity" => "agy",
_ => return false,
};
comms.iter().any(|c| c == want)
}
pub fn config_dir_in_use(dir: &std::path::Path) -> bool {
let want = dir.to_string_lossy();
if let Ok(rd) = std::fs::read_dir("/proc") {
for e in rd.flatten() {
let Ok(bytes) = std::fs::read(e.path().join("environ")) else {
continue;
};
if homes_in(&String::from_utf8_lossy(&bytes), '\0')
.iter()
.any(|v| *v == want)
{
return true;
}
}
return false;
}
let Ok(out) = std::process::Command::new("ps")
.args(["-E", "-ww", "-o", "command="])
.output()
else {
return false;
};
homes_in(&String::from_utf8_lossy(&out.stdout), ' ')
.iter()
.any(|v| *v == want)
}
fn homes_in(text: &str, sep: char) -> Vec<String> {
text.split([sep, '\n'])
.filter_map(|f| f.strip_prefix("CLAUDE_CONFIG_DIR="))
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_home_is_found_per_process_not_per_tool() {
let ps = "/usr/bin/claude PATH=/usr/bin CLAUDE_CONFIG_DIR=/home/me/.claude-work TERM=xterm\n/usr/bin/other SHELL=/bin/zsh\n";
assert_eq!(homes_in(ps, ' '), vec!["/home/me/.claude-work"]);
let environ = "PATH=/usr/bin\0CLAUDE_CONFIG_DIR=/home/me/.claude\0TERM=xterm\0";
assert_eq!(homes_in(environ, '\0'), vec!["/home/me/.claude"]);
assert!(homes_in("/usr/bin/claude TERM=xterm", ' ').is_empty());
}
#[test]
fn matches_exact_binary_name_only() {
let comms = vec![
"codex".to_string(),
"bash".to_string(),
"some-claude-helper".to_string(), ];
assert!(tool_running("codex", &comms));
assert!(!tool_running("claude-code", &comms), "no exact 'claude'");
assert!(!tool_running("unknown", &comms));
}
#[test]
fn matches_claude_when_present() {
let comms = vec!["claude".to_string(), "node".to_string()];
assert!(tool_running("claude-code", &comms));
assert!(!tool_running("codex", &comms));
}
fn proc(config: Option<&str>) -> ClaudeProc {
ClaudeProc {
securestorage_dir: None,
config_dir: config.map(str::to_string),
env_read: true,
}
}
#[test]
fn slot_key_derivation_matches_keychain_rules() {
assert_eq!(slot_key(None, None), None);
assert_eq!(slot_key(None, Some("")), None);
assert_eq!(slot_key(Some(""), Some("/x")), None); assert_eq!(
slot_key(None, Some("/home/u/.claude-company")).as_deref(),
Some("/home/u/.claude-company")
);
assert_eq!(slot_key(Some("/ss"), Some("/cfg")).as_deref(), Some("/ss"));
}
#[test]
fn guard_flags_only_a_session_on_the_same_slot() {
let company = proc(Some("/home/u/.claude-company"));
assert_eq!(
claude_switch_guard(None, None, std::slice::from_ref(&company)),
GuardVerdict::Clear
);
let bare = proc(None);
assert_eq!(
claude_switch_guard(None, None, &[company.clone(), bare]),
GuardVerdict::SameSlot
);
assert_eq!(
claude_switch_guard(None, Some("/home/u/.claude-company"), &[proc(None)]),
GuardVerdict::Clear
);
assert_eq!(
claude_switch_guard(None, Some("/home/u/.claude-company"), &[company]),
GuardVerdict::SameSlot
);
}
#[test]
fn environ_blob_yields_the_config_dirs() {
let blob = b"PATH=/usr/bin\0CLAUDE_CONFIG_DIR=/home/u/.claude-company\0TERM=xterm\0";
let (ss, cfg) = config_dirs_from_environ(blob);
assert_eq!(ss, None);
assert_eq!(cfg.as_deref(), Some("/home/u/.claude-company"));
let (_, cfg2) = config_dirs_from_environ(b"PATH=/usr/bin\0HOME=/home/u\0");
assert_eq!(cfg2, None);
let (ss3, _) = config_dirs_from_environ(
b"CLAUDE_SECURESTORAGE_CONFIG_DIR=/ss\0CLAUDE_CONFIG_DIR=/c\0",
);
assert_eq!(ss3.as_deref(), Some("/ss"));
}
#[test]
fn ps_line_parses_env_and_flags_visibility() {
let line = "/opt/homebrew/bin/node claude PATH=/usr/bin CLAUDE_CONFIG_DIR=/home/u/.claude-company TERM=xterm";
let (ss, cfg, seen) = config_dirs_from_ps(line);
assert_eq!(ss, None);
assert_eq!(cfg.as_deref(), Some("/home/u/.claude-company"));
assert!(seen, "PATH= present means the environment was included");
let (_, cfg2, seen2) = config_dirs_from_ps("/opt/homebrew/bin/node claude");
assert_eq!(cfg2, None);
assert!(!seen2, "no env visible -> caller must fail closed");
}
#[cfg(target_os = "linux")]
#[test]
fn running_claude_procs_reads_a_live_sessions_config_dir() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("swapdex_proc_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("claude");
std::fs::copy("/bin/sleep", &bin)
.or_else(|_| std::fs::copy("/usr/bin/sleep", &bin))
.unwrap();
let mut perms = std::fs::metadata(&bin).unwrap().permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
std::fs::set_permissions(&bin, perms).unwrap();
let mut child = std::process::Command::new(&bin)
.arg("30")
.env("CLAUDE_CONFIG_DIR", "/home/tester/.claude-company")
.spawn()
.unwrap();
let mut mine = None;
let mut last = Vec::new();
for _ in 0..150 {
last = running_claude_procs();
if let Some(p) = last
.iter()
.find(|p| p.config_dir.as_deref() == Some("/home/tester/.claude-company"))
{
mine = Some(p.clone());
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&dir);
let mine = mine.unwrap_or_else(|| {
panic!(
"the spawned claude session's CLAUDE_CONFIG_DIR should be detected; got {last:?}"
)
});
assert!(mine.env_read, "env was readable for our own process");
let _ = std::io::stdout().flush();
}
#[test]
fn guard_fails_closed_on_unreadable_env() {
let unknown = ClaudeProc {
securestorage_dir: None,
config_dir: None,
env_read: false,
};
assert_eq!(
claude_switch_guard(None, None, std::slice::from_ref(&unknown)),
GuardVerdict::Unknown
);
assert_eq!(claude_switch_guard(None, None, &[]), GuardVerdict::Clear);
assert_eq!(
claude_switch_guard(None, None, &[unknown, proc(None)]),
GuardVerdict::SameSlot
);
}
}