use std::path::Path;
use anyhow::Context as _;
pub use crate::platform::relaunch::RelaunchSentinel;
pub fn commit_and_stop(
sid: &str,
target_profile: &str,
handoff: &str,
cwd: &str,
born: i64,
_owner_dir: &Path,
) -> anyhow::Result<()> {
use crate::paths;
let current_hop = read_sidecar_hop(sid);
let next_hop = current_hop + 1;
merge_sidecar_hop(sid, next_hop)?;
let actual_born = if born != 0 {
born
} else {
read_pid_born(sid).unwrap_or(0)
};
let sentinel = RelaunchSentinel {
session_id: sid.to_string(),
target_profile: target_profile.to_string(),
cwd: cwd.to_string(),
handoff: handoff.to_string(),
hop: next_hop,
born: actual_born,
};
crate::platform::relaunch::write_relaunch(&paths::relaunch(sid), &sentinel)?;
let switched_path = paths::switched(sid);
if !switched_path.exists() {
let epoch = now_epoch();
let _ = write_noclobber(&switched_path, &format!("{epoch}"));
}
let epoch = now_epoch();
std::fs::write(paths::last_switch(), format!("{epoch}"))
.context("failed to write .last-switch")?;
stop_managed_process(sid)?;
Ok(())
}
fn read_sidecar_hop(sid: &str) -> i64 {
crate::sidecar::read_sidecar(&crate::paths::sidecar(sid))
.map(|s| s.hop_int())
.unwrap_or(0)
}
fn merge_sidecar_hop(sid: &str, next_hop: i64) -> anyhow::Result<()> {
use crate::paths;
let path = paths::sidecar(sid);
let mut val: serde_json::Value = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or(serde_json::json!({})),
Err(_) => serde_json::json!({}),
};
if !val.is_object() {
val = serde_json::json!({});
}
val["hop"] = serde_json::Value::String(next_hop.to_string());
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_string(&val).context("failed to serialize sidecar")?;
std::fs::write(&tmp, &json).context("failed to write sidecar tmp")?;
std::fs::rename(&tmp, &path).context("failed to rename sidecar into place")?;
Ok(())
}
fn read_pid_born(sid: &str) -> Option<i64> {
use crate::paths;
let content = std::fs::read_to_string(paths::pid_file(sid)).ok()?;
let mut parts = content.split_whitespace();
let _pid: u32 = parts.next()?.parse().ok()?;
let born: i64 = parts.next()?.parse().ok()?;
Some(born)
}
fn read_pid(sid: &str) -> Option<u32> {
use crate::paths;
let content = std::fs::read_to_string(paths::pid_file(sid)).ok()?;
let pid: u32 = content.split_whitespace().next()?.parse().ok()?;
Some(pid)
}
fn stop_managed_process(sid: &str) -> anyhow::Result<()> {
let Some(pid) = read_pid(sid) else {
return Ok(());
};
if !is_live_claude_or_node(pid) {
return Ok(());
}
platform_stop(pid, sid)
}
pub fn check_is_live_claude_or_node(pid: u32) -> bool {
platform_is_live_claude_or_node(pid)
}
fn is_live_claude_or_node(pid: u32) -> bool {
platform_is_live_claude_or_node(pid)
}
pub fn is_claude_or_node_name(base: &str) -> bool {
let l = base.to_ascii_lowercase();
l.ends_with("claude") || l.ends_with("node")
}
#[cfg(unix)]
fn platform_is_live_claude_or_node(pid: u32) -> bool {
use std::process::Command;
let output = match Command::new("ps")
.args(["-o", "comm=", "-p", &pid.to_string()])
.output()
{
Ok(o) => o,
Err(_) => return false,
};
if !output.status.success() {
return false;
}
let comm = String::from_utf8_lossy(&output.stdout);
let base = comm.trim();
let basename = base.rsplit('/').next().unwrap_or(base);
is_claude_or_node_name(basename)
}
#[cfg(unix)]
fn platform_stop(pid: u32, _sid: &str) -> anyhow::Result<()> {
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid;
kill(Pid::from_raw(pid as i32), Signal::SIGTERM)
.with_context(|| format!("failed to SIGTERM pid {pid}"))?;
Ok(())
}
#[cfg(windows)]
fn platform_is_live_claude_or_node(pid: u32) -> bool {
use sysinfo::{Pid, ProcessRefreshKind, System};
let mut sys = System::new();
let sysinfo_pid = Pid::from_u32(pid);
sys.refresh_process_specifics(sysinfo_pid, ProcessRefreshKind::new());
let Some(proc) = sys.process(sysinfo_pid) else {
return false;
};
let Some(exe_name) = proc.exe().and_then(|p| p.file_name()) else {
return false;
};
let name = exe_name.to_string_lossy();
let base = name.strip_suffix(".exe").unwrap_or(&name);
is_claude_or_node_name(base)
}
#[cfg(windows)]
fn platform_stop(pid: u32, sid: &str) -> anyhow::Result<()> {
use crate::paths;
let stop_path = paths::stop_flag(sid);
std::fs::write(&stop_path, b"")
.with_context(|| format!("failed to write stop flag for pid {pid}"))?;
Ok(())
}
fn now_epoch() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn write_noclobber(path: &Path, content: &str) -> anyhow::Result<()> {
use std::fs::OpenOptions;
use std::io::Write as _;
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut f) => {
f.write_all(content.as_bytes())?;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
}
Err(e) => return Err(e.into()),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn name_check_basic() {
assert!(is_claude_or_node_name("claude"));
assert!(is_claude_or_node_name("node"));
}
#[test]
fn name_check_case_insensitive() {
assert!(is_claude_or_node_name("Claude"));
assert!(is_claude_or_node_name("NODE"));
assert!(is_claude_or_node_name("CLAUDE"));
}
#[test]
fn name_check_rejects_unrelated() {
assert!(!is_claude_or_node_name("bash"));
assert!(!is_claude_or_node_name("python3"));
assert!(!is_claude_or_node_name("csm"));
assert!(!is_claude_or_node_name(""));
}
#[test]
fn name_check_ends_with_tolerant() {
assert!(!is_claude_or_node_name("claude-3"));
assert!(is_claude_or_node_name("some-claude"));
}
#[test]
fn noclobber_first_write_wins() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("marker");
write_noclobber(&path, "first").unwrap();
write_noclobber(&path, "second").unwrap(); let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "first");
}
#[test]
fn relaunch_sentinel_hop_is_number() {
let sentinel = RelaunchSentinel {
session_id: "test-sid".to_string(),
target_profile: "work".to_string(),
cwd: "/tmp/cwd".to_string(),
handoff: "resume".to_string(),
hop: 1,
born: 1718000000,
};
let json = serde_json::to_string(&sentinel).unwrap();
assert!(
json.contains("\"hop\":1"),
"hop should be a JSON number in .relaunch: {json}"
);
let back: RelaunchSentinel = serde_json::from_str(&json).unwrap();
assert_eq!(back.session_id, "test-sid");
assert_eq!(back.hop, 1);
assert_eq!(back.born, 1718000000);
}
#[test]
fn sidecar_hop_serialized_as_string() {
let mut val = serde_json::json!({
"sessionId": "abc",
"permissionMode": "default"
});
val["hop"] = serde_json::Value::String(1_i64.to_string());
let json = serde_json::to_string(&val).unwrap();
assert!(
json.contains("\"hop\":\"1\""),
"hop should be a JSON string in sidecar: {json}"
);
}
#[test]
fn relaunch_sentinel_born_and_hop_types() {
let json = r#"{"session_id":"s","target_profile":"p","cwd":"/","handoff":"h","hop":2,"born":1234567890}"#;
let s: RelaunchSentinel = serde_json::from_str(json).unwrap();
assert_eq!(s.hop, 2i64);
assert_eq!(s.born, 1234567890i64);
}
}