use std::ffi::OsString;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelaunchSentinel {
pub session_id: String,
pub target_profile: String,
pub cwd: String,
pub handoff: String,
pub hop: i64,
pub born: i64,
}
#[cfg_attr(windows, allow(dead_code))]
pub const MAX_HOPS: i64 = 1;
#[cfg_attr(windows, allow(dead_code))]
pub fn read_relaunch(path: &Path) -> anyhow::Result<Option<RelaunchSentinel>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(serde_json::from_str(&s)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn write_relaunch(path: &Path, sentinel: &RelaunchSentinel) -> anyhow::Result<()> {
let tmp = path.with_extension("relaunch.tmp");
let json = serde_json::to_string(sentinel)?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})?;
Ok(())
}
#[cfg(not(windows))]
pub fn run_relaunch_loop(
launcher: &dyn crate::platform::launcher::Launcher,
spec: &LaunchSpec,
) -> anyhow::Result<()> {
relaunch_loop(launcher, spec)
}
#[cfg(windows)]
pub fn run_relaunch_loop(
launcher: &dyn crate::platform::launcher::Launcher,
spec: &LaunchSpec,
) -> anyhow::Result<()> {
run_once(launcher, spec)
}
#[cfg(windows)]
fn run_once(
launcher: &dyn crate::platform::launcher::Launcher,
spec: &LaunchSpec,
) -> anyhow::Result<()> {
use std::collections::HashMap;
use crate::paths;
let sid = spec.session_id.clone();
let pid_path = paths::pid_file(&sid);
let mut env: HashMap<OsString, OsString> = HashMap::new();
env.insert(
OsString::from("CLAUDE_CONFIG_DIR"),
spec.profile_dir.clone().into_os_string(),
);
let (status, _handle) = launcher.run_foreground(&sid, &spec.cli, &env)?;
let _ = std::fs::remove_file(&pid_path);
exit_with(status)
}
#[cfg(not(windows))]
fn relaunch_loop(
launcher: &dyn crate::platform::launcher::Launcher,
spec: &LaunchSpec,
) -> anyhow::Result<()> {
use std::collections::HashMap;
use crate::paths;
use crate::platform::pid;
let sid = spec.session_id.clone();
let relaunch_path = paths::relaunch(&sid);
let pid_path = paths::pid_file(&sid);
let mut cli: Vec<OsString> = spec.cli.clone();
let mut profile_dir = spec.profile_dir.clone();
loop {
if let Ok(Some((other_pid, _born))) = pid::read_pid_file(&pid_path) {
use crate::platform::proc_check::ProcCheck;
if other_pid != 0
&& crate::platform::PlatformProcCheck::is_live_claude_or_node(other_pid)
{
anyhow::bail!(
"session {sid} is already managed by a live process (pid {other_pid})"
);
}
}
let _ = std::fs::remove_file(&relaunch_path);
let mut env: HashMap<OsString, OsString> = HashMap::new();
env.insert(
OsString::from("CLAUDE_CONFIG_DIR"),
profile_dir.clone().into_os_string(),
);
let (status, handle) = launcher.run_foreground(&sid, &cli, &env)?;
let sentinel = match read_relaunch(&relaunch_path) {
Ok(Some(s)) => s,
Ok(None) => {
let _ = std::fs::remove_file(&pid_path);
return exit_with(status);
}
Err(e) => {
let _ = std::fs::remove_file(&pid_path);
return Err(e);
}
};
if sentinel.born < handle.born {
let _ = std::fs::remove_file(&relaunch_path);
let _ = std::fs::remove_file(&pid_path);
return exit_with(status);
}
if std::fs::remove_file(&relaunch_path).is_err() {
let _ = std::fs::remove_file(&pid_path);
return exit_with(status);
}
if sentinel.hop > MAX_HOPS {
eprintln!("csm: limit-switch hop cap ({MAX_HOPS}) reached — not relaunching again");
let _ = std::fs::remove_file(&pid_path);
return exit_with(status);
}
let profiles = crate::account::profiles::ProfileMap::load().unwrap_or_default();
let next_dir = match profiles.get(&sentinel.target_profile) {
Some(d) => std::path::PathBuf::from(d),
None => {
eprintln!(
"csm: relaunch target profile '{}' is unknown — aborting relaunch",
sentinel.target_profile
);
let _ = std::fs::remove_file(&pid_path);
return exit_with(status);
}
};
profile_dir = next_dir;
cli = build_next_cli(&sid, &sentinel);
}
}
#[cfg(not(windows))]
fn build_next_cli(sid: &str, sentinel: &RelaunchSentinel) -> Vec<OsString> {
let mut cli: Vec<OsString> = Vec::new();
cli.push(OsString::from("--resume"));
cli.push(OsString::from(sid));
if !sentinel.handoff.is_empty() {
cli.push(OsString::from(&sentinel.handoff));
}
cli
}
fn exit_with(status: std::process::ExitStatus) -> anyhow::Result<()> {
if let Some(code) = status.code() {
if code != 0 {
std::process::exit(code);
}
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
std::process::exit(128 + sig);
}
}
Ok(())
}
pub struct LaunchSpec {
pub session_id: String,
pub profile_dir: std::path::PathBuf,
#[allow(dead_code)]
pub cwd: std::path::PathBuf,
pub cli: Vec<OsString>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_sentinel() {
let sentinel = RelaunchSentinel {
session_id: "abc123".to_string(),
target_profile: "home".to_string(),
cwd: "/home/you/projects".to_string(),
handoff: "resume".to_string(),
hop: 1,
born: 1_718_000_000,
};
let json = serde_json::to_string(&sentinel).unwrap();
let back: RelaunchSentinel = serde_json::from_str(&json).unwrap();
assert_eq!(back.session_id, sentinel.session_id);
assert_eq!(back.target_profile, sentinel.target_profile);
assert_eq!(back.hop, sentinel.hop);
assert_eq!(back.born, sentinel.born);
}
#[test]
fn field_names_match_legacy_zsh() {
let json = r#"{
"session_id": "sid-1",
"target_profile": "work",
"cwd": "/tmp",
"handoff": "resume",
"hop": 0,
"born": 1700000000
}"#;
let s: RelaunchSentinel = serde_json::from_str(json).unwrap();
assert_eq!(s.session_id, "sid-1");
assert_eq!(s.target_profile, "work");
assert_eq!(s.hop, 0_i64);
assert_eq!(s.born, 1_700_000_000_i64);
}
#[test]
fn read_compat_unknown_future_field_is_ignored_not_fatal() {
let json = r#"{
"session_id":"sid-9","target_profile":"work","cwd":"/tmp",
"handoff":"resume","hop":1,"born":1700000000,
"futureField":"ignored","another":{"x":1}
}"#;
let s: RelaunchSentinel = serde_json::from_str(json).expect("unknown field must not abort");
assert_eq!(s.session_id, "sid-9");
assert_eq!(s.target_profile, "work");
assert_eq!(s.hop, 1_i64);
}
#[test]
fn read_compat_corrupt_relaunch_errs_without_panic() {
let tmp_dir = tempfile::tempdir().unwrap();
let path = tmp_dir.path().join("corrupt.relaunch");
std::fs::write(&path, "not json at all{{{").unwrap();
let result = read_relaunch(&path);
assert!(
result.is_err(),
"corrupt .relaunch must be Err, got {result:?}"
);
}
#[test]
fn hop_is_number_not_string() {
let json =
r#"{"session_id":"x","target_profile":"p","cwd":"/","handoff":"","hop":1,"born":0}"#;
let s: RelaunchSentinel = serde_json::from_str(json).unwrap();
assert_eq!(s.hop, 1_i64);
}
#[test]
fn read_absent_returns_none() {
let tmp_dir = tempfile::tempdir().unwrap();
let path = tmp_dir.path().join("nonexistent.relaunch");
let result = read_relaunch(&path).unwrap();
assert!(result.is_none());
}
#[test]
fn write_then_read_roundtrip() {
let tmp_dir = tempfile::tempdir().unwrap();
let path = tmp_dir.path().join("test.relaunch");
let sentinel = RelaunchSentinel {
session_id: "test-sid".to_string(),
target_profile: "home".to_string(),
cwd: "/tmp".to_string(),
handoff: "resume".to_string(),
hop: 0,
born: 1_718_100_000,
};
write_relaunch(&path, &sentinel).unwrap();
let back = read_relaunch(&path)
.unwrap()
.expect("should exist after write");
assert_eq!(back.session_id, sentinel.session_id);
assert_eq!(back.born, sentinel.born);
assert_eq!(back.hop, sentinel.hop);
}
#[test]
fn stale_sentinel_born_check() {
let launch_born: i64 = 1_718_200_000;
let stale_born: i64 = 1_718_100_000; assert!(
stale_born < launch_born,
"stale sentinel should have born < launch_born"
);
let fresh_born: i64 = 1_718_200_001;
assert!(fresh_born >= launch_born);
}
#[test]
fn hop_cap_constant() {
assert_eq!(MAX_HOPS, 1, "MAX_HOPS must match legacy zsh MAX_HOPS=1");
}
}