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 home: 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, Clone, Copy)]
struct SlotEnv<'a> {
securestorage: Option<&'a str>,
config: Option<&'a str>,
home: Option<&'a str>,
}
fn config_dir_of(e: &SlotEnv) -> Option<std::path::PathBuf> {
fn nonempty(v: Option<&str>) -> Option<&str> {
v.map(str::trim).filter(|x| !x.is_empty())
}
if let Some(c) = nonempty(e.config) {
return Some(std::path::PathBuf::from(c));
}
nonempty(e.home).map(|h| std::path::Path::new(h).join(".claude"))
}
fn same_login_slot(a: &SlotEnv, b: &SlotEnv, keychain_shared: bool) -> bool {
let raw_equal = slot_key(a.securestorage, a.config) == slot_key(b.securestorage, b.config);
if keychain_shared && raw_equal {
return true;
}
match (config_dir_of(a), config_dir_of(b)) {
(Some(x), Some(y)) => x == y,
_ => raw_equal,
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum GuardVerdict {
Clear,
SameSlot,
Unknown,
}
pub fn claude_switch_guard(
my_securestorage: Option<&str>,
my_config: Option<&str>,
my_home: Option<&str>,
running: &[ClaudeProc],
) -> GuardVerdict {
let mine = SlotEnv {
securestorage: my_securestorage,
config: my_config,
home: my_home,
};
let shared = cfg!(target_os = "macos");
let mut unknown = false;
for p in running {
if !p.env_read {
unknown = true;
continue;
}
let theirs = SlotEnv {
securestorage: p.securestorage_dir.as_deref(),
config: p.config_dir.as_deref(),
home: p.home.as_deref(),
};
if same_login_slot(&mine, &theirs, shared) {
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]) -> ClaudeProc {
let mut p = ClaudeProc {
securestorage_dir: None,
config_dir: None,
home: None,
env_read: true,
};
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=") {
p.securestorage_dir = Some(v.to_string());
} else if let Some(v) = s.strip_prefix("CLAUDE_CONFIG_DIR=") {
p.config_dir = Some(v.to_string());
} else if let Some(v) = s.strip_prefix("HOME=") {
p.home = Some(v.to_string());
}
}
p
}
#[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) => out.push(config_dirs_from_environ(&bytes)),
Err(_) => out.push(ClaudeProc {
securestorage_dir: None,
config_dir: None,
home: None,
env_read: false,
}),
}
}
out
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
fn config_dirs_from_ps(line: &str) -> ClaudeProc {
let mut p = ClaudeProc {
securestorage_dir: None,
config_dir: None,
home: None,
env_read: false,
};
for tok in line.split_ascii_whitespace() {
if tok.starts_with("PATH=") || tok.starts_with("USER=") {
p.env_read = true;
}
if let Some(v) = tok.strip_prefix("CLAUDE_SECURESTORAGE_CONFIG_DIR=") {
p.securestorage_dir = Some(v.to_string());
p.env_read = true;
} else if let Some(v) = tok.strip_prefix("CLAUDE_CONFIG_DIR=") {
p.config_dir = Some(v.to_string());
p.env_read = true;
} else if let Some(v) = tok.strip_prefix("HOME=") {
p.home = Some(v.to_string());
p.env_read = true;
}
}
p
}
#[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);
out.push(config_dirs_from_ps(&line));
}
_ => out.push(ClaudeProc {
securestorage_dir: None,
config_dir: None,
home: 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)
}
struct SlotVars {
comm: &'static str,
config: &'static str,
bare: &'static str,
}
const CLAUDE_SLOT: SlotVars = SlotVars {
comm: "claude",
config: "CLAUDE_CONFIG_DIR",
bare: ".claude",
};
const CODEX_SLOT: SlotVars = SlotVars {
comm: "codex",
config: "CODEX_HOME",
bare: ".codex",
};
fn slot_vars(tool: &str) -> Option<&'static SlotVars> {
match tool {
"claude-code" => Some(&CLAUDE_SLOT),
"codex" => Some(&CODEX_SLOT),
_ => None,
}
}
fn slot_dir_in(text: &str, sep: char, comm: &str, vars: &SlotVars) -> Option<std::path::PathBuf> {
let mut home = None;
for field in text.split([sep, '\n']) {
let Some((key, value)) = field.split_once('=') else {
continue;
};
if value.is_empty() {
continue;
}
if key == vars.config {
return Some(std::path::PathBuf::from(value));
}
if key == "HOME" {
home = Some(value);
}
}
if comm != vars.comm {
return None;
}
home.map(|h| std::path::Path::new(h).join(vars.bare))
}
fn ps_comm_by_pid() -> std::collections::HashMap<String, String> {
let Ok(out) = std::process::Command::new("ps")
.args(["-Ao", "pid=,comm="])
.output()
else {
return std::collections::HashMap::new();
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let (pid, comm) = l.trim().split_once(char::is_whitespace)?;
let name = comm.trim().rsplit('/').next()?.to_string();
Some((pid.trim().to_string(), name))
})
.collect()
}
pub fn running_config_dirs(tool: &str) -> Vec<std::path::PathBuf> {
let Some(vars) = slot_vars(tool) else {
return Vec::new();
};
let mut dirs = Vec::new();
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;
};
let comm = std::fs::read_to_string(e.path().join("comm")).unwrap_or_default();
let text = String::from_utf8_lossy(&bytes);
if let Some(dir) = slot_dir_in(&text, '\0', comm.trim(), vars) {
dirs.push(dir);
}
}
return dirs;
}
let Ok(out) = std::process::Command::new("ps")
.args(["-E", "-ww", "-o", "pid=,command="])
.output()
else {
return dirs;
};
let comms = ps_comm_by_pid();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let l = line;
let Some((pid, rest)) = l.trim().split_once(char::is_whitespace) else {
continue;
};
let comm = comms.get(pid).map(String::as_str).unwrap_or_default();
if let Some(dir) = slot_dir_in(rest, ' ', comm, vars) {
dirs.push(dir);
}
}
dirs
}
pub fn config_dir_in_use(dir: &std::path::Path, tool: &str) -> bool {
running_config_dirs(tool).into_iter().any(|d| d == dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_slot_is_found_per_process_not_per_tool() {
let ps = "/usr/bin/claude PATH=/usr/bin CLAUDE_CONFIG_DIR=/home/me/.claude-work TERM=xterm";
assert_eq!(
slot_dir_in(ps, ' ', "claude", &CLAUDE_SLOT),
Some("/home/me/.claude-work".into())
);
let environ = "PATH=/usr/bin\0CLAUDE_CONFIG_DIR=/home/me/.claude\0TERM=xterm\0";
assert_eq!(
slot_dir_in(environ, '\0', "claude", &CLAUDE_SLOT),
Some("/home/me/.claude".into())
);
assert_eq!(slot_dir_in(environ, '\0', "claude", &CODEX_SLOT), None);
assert_eq!(
slot_dir_in(
"CODEX_HOME=/home/me/.codex-alt\0",
'\0',
"codex",
&CODEX_SLOT
),
Some("/home/me/.codex-alt".into())
);
assert_eq!(
slot_dir_in("HOME=/home/me\0", '\0', "claude", &CLAUDE_SLOT),
Some("/home/me/.claude".into())
);
assert_eq!(
slot_dir_in("HOME=/home/me\0", '\0', "bash", &CLAUDE_SLOT),
None
);
assert_eq!(slot_dir_in("TERM=xterm", ' ', "claude", &CLAUDE_SLOT), None);
assert_eq!(
slot_dir_in(
"CLAUDE_CONFIG_DIR=/home/me/.claude-work\0",
'\0',
"node",
&CLAUDE_SLOT
),
Some("/home/me/.claude-work".into())
);
}
#[test]
fn only_the_renewable_tools_have_slot_vars() {
assert!(slot_vars("claude-code").is_some());
assert!(slot_vars("codex").is_some());
assert!(slot_vars("gemini").is_none());
assert!(slot_vars("antigravity").is_none());
}
#[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 {
proc_home(config, None)
}
fn proc_home(config: Option<&str>, home: Option<&str>) -> ClaudeProc {
ClaudeProc {
securestorage_dir: None,
config_dir: config.map(str::to_string),
home: home.map(str::to_string),
env_read: true,
}
}
fn env<'a>(config: Option<&'a str>, home: Option<&'a str>) -> SlotEnv<'a> {
SlotEnv {
securestorage: None,
config,
home,
}
}
#[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, None, std::slice::from_ref(&company)),
GuardVerdict::Clear
);
let bare = proc(None);
assert_eq!(
claude_switch_guard(None, None, None, &[company.clone(), bare]),
GuardVerdict::SameSlot
);
assert_eq!(
claude_switch_guard(None, Some("/home/u/.claude-company"), None, &[proc(None)]),
GuardVerdict::Clear
);
assert_eq!(
claude_switch_guard(None, Some("/home/u/.claude-company"), None, &[company]),
GuardVerdict::SameSlot
);
}
#[test]
fn guard_sees_the_bare_slot_spelled_out() {
assert_eq!(
claude_switch_guard(
None,
None,
Some("/home/u"),
&[proc_home(Some("/home/u/.claude"), None)]
),
GuardVerdict::SameSlot
);
assert_eq!(
claude_switch_guard(
None,
Some("/home/u/.claude"),
None,
&[proc_home(None, Some("/home/u"))]
),
GuardVerdict::SameSlot
);
}
#[test]
fn a_second_home_is_a_second_slot_only_where_the_files_are() {
let a = env(None, Some("/home/a"));
let b = env(None, Some("/home/b"));
assert!(!same_login_slot(&a, &b, false));
assert!(same_login_slot(&a, &b, true));
}
#[test]
fn guard_sees_through_two_spellings_of_one_dir() {
assert_eq!(
claude_switch_guard(
None,
Some("/home/u/.claude-company"),
None,
&[proc(Some("/home/u/.claude-company/"))]
),
GuardVerdict::SameSlot
);
}
#[test]
fn parsers_keep_the_home_a_bare_session_reads_from() {
let bare = config_dirs_from_environ(b"PATH=/usr/bin\0HOME=/home/u\0");
assert_eq!(bare.config_dir, None);
assert_eq!(bare.home.as_deref(), Some("/home/u"));
assert!(bare.env_read);
let bare_ps = config_dirs_from_ps("/opt/homebrew/bin/node claude HOME=/home/u");
assert_eq!(bare_ps.config_dir, None);
assert_eq!(bare_ps.home.as_deref(), Some("/home/u"));
assert!(bare_ps.env_read);
}
#[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 p = config_dirs_from_environ(blob);
assert_eq!(p.securestorage_dir, None);
assert_eq!(p.config_dir.as_deref(), Some("/home/u/.claude-company"));
let p2 = config_dirs_from_environ(b"PATH=/usr/bin\0HOME=/home/u\0");
assert_eq!(p2.config_dir, None);
let p3 = config_dirs_from_environ(
b"CLAUDE_SECURESTORAGE_CONFIG_DIR=/ss\0CLAUDE_CONFIG_DIR=/c\0",
);
assert_eq!(p3.securestorage_dir.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 p = config_dirs_from_ps(line);
assert_eq!(p.securestorage_dir, None);
assert_eq!(p.config_dir.as_deref(), Some("/home/u/.claude-company"));
assert!(
p.env_read,
"PATH= present means the environment was included"
);
let p2 = config_dirs_from_ps("/opt/homebrew/bin/node claude");
assert_eq!(p2.config_dir, None);
assert!(!p2.env_read, "no env visible -> caller must fail closed");
}
#[cfg(target_os = "linux")]
fn stub_binary(root: &std::path::Path, name: &str) -> std::path::PathBuf {
std::fs::create_dir_all(root).unwrap();
let bin = root.join(name);
let sleep = if std::path::Path::new("/bin/sleep").exists() {
"/bin/sleep"
} else {
"/usr/bin/sleep"
};
std::os::unix::fs::symlink(sleep, &bin).unwrap();
bin
}
#[cfg(target_os = "linux")]
#[test]
fn a_live_bare_session_reaches_the_guard() {
let dir = std::env::temp_dir().join(format!("swapdex_bare_test_{}", std::process::id()));
let bin = stub_binary(&dir, "claude");
let home = dir.join("home").to_string_lossy().into_owned();
let mut child = std::process::Command::new(&bin)
.arg("30")
.env("HOME", &home)
.env_remove("CLAUDE_CONFIG_DIR")
.env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR")
.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.home.as_deref() == Some(home.as_str()))
{
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!("a bare session's HOME should survive the /proc parse; got {last:?}")
});
assert_eq!(mine.config_dir, None, "the child set no CLAUDE_CONFIG_DIR");
assert_eq!(
claude_switch_guard(None, None, Some(&home), std::slice::from_ref(&mine)),
GuardVerdict::SameSlot
);
assert_eq!(
claude_switch_guard(None, None, Some("/home/somebody-else"), &[mine]),
GuardVerdict::Clear
);
}
#[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()));
let bin = stub_binary(&dir, "claude");
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,
home: None,
env_read: false,
};
assert_eq!(
claude_switch_guard(None, None, None, std::slice::from_ref(&unknown)),
GuardVerdict::Unknown
);
assert_eq!(
claude_switch_guard(None, None, None, &[]),
GuardVerdict::Clear
);
assert_eq!(
claude_switch_guard(None, None, None, &[unknown, proc(None)]),
GuardVerdict::SameSlot
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_live_codex_session_holds_its_slot() {
let root = std::env::temp_dir().join(format!("swapdex_cx_use_{}", std::process::id()));
let bin = stub_binary(&root, "codex");
let slot = root.join("slot");
let mut child = std::process::Command::new(&bin)
.arg("30")
.env("CODEX_HOME", &slot)
.spawn()
.unwrap();
let mut held = false;
for _ in 0..150 {
if config_dir_in_use(&slot, "codex") {
held = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let other = config_dir_in_use(&root.join("other-slot"), "codex");
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&root);
assert!(
held,
"a running session pins its slot with CODEX_HOME; renewing it \
retires the refresh token that session holds"
);
assert!(!other, "a slot no session is on is free to renew");
}
#[cfg(target_os = "linux")]
#[test]
fn a_bare_session_holds_its_default_slot() {
let root = std::env::temp_dir().join(format!("swapdex_bare_use_{}", std::process::id()));
let bin = stub_binary(&root, "claude");
let mut child = std::process::Command::new(&bin)
.arg("30")
.env("HOME", &root)
.env_remove("CLAUDE_CONFIG_DIR")
.env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR")
.spawn()
.unwrap();
let environ = format!("/proc/{}/environ", child.id());
for _ in 0..300 {
if std::fs::read(&environ).is_ok_and(|b| !b.is_empty()) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let held = config_dir_in_use(&root.join(".claude"), "claude-code");
let other = config_dir_in_use(&root.join(".claude-work"), "claude-code");
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&root);
assert!(held, "a session with no variable set is on <HOME>/.claude");
assert!(!other, "and only on that one");
}
#[cfg(target_os = "linux")]
#[test]
fn two_spellings_of_one_slot_are_one_slot() {
let root = std::env::temp_dir().join(format!("swapdex_spell_use_{}", std::process::id()));
let bin = stub_binary(&root, "claude");
let slot = root.join("slot");
let mut child = std::process::Command::new(&bin)
.arg("30")
.env("CLAUDE_CONFIG_DIR", format!("{}//", slot.display()))
.env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR")
.spawn()
.unwrap();
let environ = format!("/proc/{}/environ", child.id());
for _ in 0..300 {
if std::fs::read(&environ).is_ok_and(|b| !b.is_empty()) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let held = config_dir_in_use(&slot, "claude-code");
let other = config_dir_in_use(&root.join("slot2"), "claude-code");
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&root);
assert!(held, "`<slot>//` and `<slot>` are the same directory");
assert!(!other, "a neighbouring slot is still free");
}
}