use std::path::{Path, PathBuf};
use std::{fs::OpenOptions, io::Write};
use serde::{Deserialize, Serialize};
use crate::HarnessHomes;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaudePeerStatus {
Busy,
Idle,
Waiting,
}
impl ClaudePeerStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Busy => "busy",
Self::Idle => "idle",
Self::Waiting => "waiting",
}
}
fn from_registry(value: &str) -> Option<Self> {
match value {
"busy" | "shell" => Some(Self::Busy),
"idle" => Some(Self::Idle),
"waiting" => Some(Self::Waiting),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaudePeerSession {
pub pid: u32,
pub session_id: String,
pub cwd: Option<PathBuf>,
pub name: String,
pub socket_path: PathBuf,
pub status: Option<ClaudePeerStatus>,
pub updated_at_ms: Option<u64>,
pub version: Option<String>,
pub tmux: Option<String>,
}
pub fn registry_dir(homes: &HarnessHomes) -> PathBuf {
homes
.claude_code
.parent()
.unwrap_or(Path::new("."))
.join("sessions")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaudeCrossSessionInbound {
Accept,
Hold,
Refuse,
}
impl ClaudeCrossSessionInbound {
pub const fn as_str(self) -> &'static str {
match self {
Self::Accept => "accept",
Self::Hold => "hold",
Self::Refuse => "refuse",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ClaudePeerSettings {
pub path: PathBuf,
pub revision: String,
pub cross_session_inbound: Option<ClaudeCrossSessionInbound>,
}
impl ClaudePeerSettings {
pub fn user_allows_automatic_delivery(&self) -> bool {
self.cross_session_inbound == Some(ClaudeCrossSessionInbound::Accept)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ClaudePeerSettingsError {
#[error("Claude Code settings I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("Claude Code settings JSON is invalid: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Invalid(String),
#[error("Claude Code settings changed while Volter Harness was updating them; retry the explicit configuration action")]
ChangedDuringWrite,
}
pub fn user_settings_path(homes: &HarnessHomes) -> PathBuf {
homes
.claude_code
.parent()
.unwrap_or(Path::new("."))
.join("settings.json")
}
pub fn read_claude_peer_settings(
homes: &HarnessHomes,
) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
let path = user_settings_path(homes);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
let value = if bytes.is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_slice(&bytes)?
};
let object = value.as_object().ok_or_else(|| {
ClaudePeerSettingsError::Invalid(format!(
"Claude Code settings at {} must be a JSON object",
path.display()
))
})?;
let cross_session_inbound = match object.get("crossSessionInbound") {
None => None,
Some(serde_json::Value::String(value)) if value == "accept" => {
Some(ClaudeCrossSessionInbound::Accept)
}
Some(serde_json::Value::String(value)) if value == "hold" => {
Some(ClaudeCrossSessionInbound::Hold)
}
Some(serde_json::Value::String(value)) if value == "refuse" => {
Some(ClaudeCrossSessionInbound::Refuse)
}
Some(value) => {
return Err(ClaudePeerSettingsError::Invalid(format!(
"Claude Code setting `crossSessionInbound` at {} must be `accept`, `hold`, or `refuse`, not {value}",
path.display()
)))
}
};
Ok(ClaudePeerSettings {
path,
revision: blake3::hash(&bytes).to_hex().to_string(),
cross_session_inbound,
})
}
pub fn write_claude_peer_settings(
homes: &HarnessHomes,
cross_session_inbound: ClaudeCrossSessionInbound,
) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
update_claude_peer_settings(homes, Some(cross_session_inbound), None)
}
pub fn update_claude_peer_settings(
homes: &HarnessHomes,
cross_session_inbound: Option<ClaudeCrossSessionInbound>,
expected_revision: Option<&str>,
) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
let path = user_settings_path(homes);
if std::fs::symlink_metadata(&path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return Err(ClaudePeerSettingsError::Invalid(format!(
"refusing to replace symlinked Claude Code settings at {}",
path.display()
)));
}
let original = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
let original_revision = blake3::hash(&original).to_hex().to_string();
if expected_revision.is_some_and(|expected| expected != original_revision) {
return Err(ClaudePeerSettingsError::ChangedDuringWrite);
}
let mut value = if original.is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_slice(&original)?
};
let object = value.as_object_mut().ok_or_else(|| {
ClaudePeerSettingsError::Invalid(format!(
"Claude Code settings at {} must be a JSON object",
path.display()
))
})?;
let changed = match cross_session_inbound {
Some(value) => {
object.insert(
"crossSessionInbound".into(),
serde_json::Value::String(value.as_str().into()),
) != Some(serde_json::Value::String(value.as_str().into()))
}
None => object.remove("crossSessionInbound").is_some(),
};
if !changed {
return read_claude_peer_settings(homes);
}
let mut encoded = serde_json::to_vec_pretty(&value)?;
encoded.push(b'\n');
let parent = path.parent().unwrap_or(Path::new("."));
std::fs::create_dir_all(parent)?;
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temporary = parent.join(format!(
".settings.json.supercode-{}-{nonce}.tmp",
std::process::id()
));
let write_result = (|| -> Result<(), ClaudePeerSettingsError> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&temporary)?;
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let mode = std::fs::metadata(&path)
.map(|metadata| metadata.mode() & 0o777)
.unwrap_or(0o600);
file.set_permissions(std::fs::Permissions::from_mode(mode))?;
}
file.write_all(&encoded)?;
file.sync_all()?;
let current = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
if current != original {
return Err(ClaudePeerSettingsError::ChangedDuringWrite);
}
std::fs::rename(&temporary, &path)?;
Ok(())
})();
if write_result.is_err() {
std::fs::remove_file(&temporary).ok();
}
write_result?;
read_claude_peer_settings(homes)
}
#[derive(Deserialize)]
struct RegistryRecord {
pid: u32,
#[serde(rename = "sessionId")]
session_id: String,
#[serde(default)]
cwd: Option<PathBuf>,
#[serde(default)]
name: Option<String>,
#[serde(rename = "messagingSocketPath", default)]
messaging_socket_path: Option<PathBuf>,
#[serde(default)]
status: Option<String>,
#[serde(rename = "updatedAt", default)]
updated_at: Option<u64>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
tmux: Option<String>,
}
pub fn read_registry(directory: &Path) -> Vec<ClaudePeerSession> {
let Ok(entries) = std::fs::read_dir(directory) else {
return Vec::new();
};
let mut sessions = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(record) = serde_json::from_slice::<RegistryRecord>(&bytes) else {
continue;
};
let (Some(name), Some(socket_path)) = (record.name, record.messaging_socket_path) else {
continue;
};
if record.session_id.is_empty() || name.is_empty() || !process_is_live(record.pid) {
continue;
}
sessions.push(ClaudePeerSession {
pid: record.pid,
session_id: record.session_id,
cwd: record.cwd,
name,
socket_path,
status: record
.status
.as_deref()
.and_then(ClaudePeerStatus::from_registry),
updated_at_ms: record.updated_at,
version: record.version,
tmux: record.tmux,
});
}
sessions.sort_by_key(|session| session.pid);
sessions
}
#[cfg(unix)]
pub(crate) fn process_is_live(pid: u32) -> bool {
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(windows)]
pub(crate) fn process_is_live(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return false;
}
let mut code = 0u32;
let read = unsafe { GetExitCodeProcess(process, &mut code) } != 0;
unsafe {
CloseHandle(process);
}
read && code == STILL_ACTIVE as u32
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn process_is_live(_pid: u32) -> bool {
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaudePeerRefusal {
NotLive,
IdentityMismatch,
DeliveryFailed,
}
impl ClaudePeerRefusal {
pub const fn as_str(self) -> &'static str {
match self {
Self::NotLive => "not_live",
Self::IdentityMismatch => "identity_mismatch",
Self::DeliveryFailed => "delivery_failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct ClaudePeerRefusalError {
pub reason: ClaudePeerRefusal,
pub message: String,
}
impl ClaudePeerRefusalError {
fn new(reason: ClaudePeerRefusal, message: impl Into<String>) -> Self {
Self {
reason,
message: message.into(),
}
}
}
pub fn resolve_live_session(
homes: &HarnessHomes,
session_id: &str,
) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
let registry = read_registry(®istry_dir(homes));
let target = registry
.iter()
.find(|session| session.session_id == session_id)
.cloned()
.ok_or_else(|| {
ClaudePeerRefusalError::new(
ClaudePeerRefusal::NotLive,
format!(
"no live Claude Code process is running session `{session_id}`; \
its transcript is persisted only"
),
)
})?;
let by_name = registry
.iter()
.filter(|session| session.name == target.name)
.collect::<Vec<_>>();
if by_name.len() != 1 || by_name[0].session_id != target.session_id {
return Err(ClaudePeerRefusalError::new(
ClaudePeerRefusal::IdentityMismatch,
format!(
"the registry name `{}` no longer resolves to session `{session_id}` alone; \
refusing rather than delivering into another session",
target.name
),
));
}
Ok(target)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(label: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"supercode-claude-peer-{label}-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&path).unwrap();
path
}
fn dead_pid() -> u32 {
let mut child = std::process::Command::new("/usr/bin/true")
.spawn()
.or_else(|_| std::process::Command::new("true").spawn())
.unwrap();
let pid = child.id();
child.wait().unwrap();
pid
}
fn write_record(directory: &Path, pid: u32, session_id: &str, name: &str, status: &str) {
let status = if status.is_empty() {
String::new()
} else {
format!(",\"status\":\"{status}\",\"updatedAt\":1786907689006")
};
std::fs::write(
directory.join(format!("{pid}.json")),
format!(
"{{\"pid\":{pid},\"sessionId\":\"{session_id}\",\"cwd\":\"/tmp/project\",\
\"version\":\"2.1.224\",\"peerProtocol\":1,\"kind\":\"interactive\",\
\"entrypoint\":\"cli\",\"messagingSocketPath\":\"/tmp/cc-socks/{pid}.sock\",\
\"name\":\"{name}\",\"nameSource\":\"derived\"{status}}}"
),
)
.unwrap();
}
fn homes_for(root: &Path) -> HarnessHomes {
HarnessHomes {
claude_code: root.join("projects"),
..HarnessHomes::default()
}
}
#[test]
fn explicit_peer_policy_update_preserves_the_rest_of_claude_settings() {
let root = temp_dir("settings");
let settings_path = root.join("settings.json");
std::fs::write(
&settings_path,
r#"{"permissions":{"allow":["Bash(git status)"]},"theme":"dark"}"#,
)
.unwrap();
let homes = homes_for(&root);
let before = read_claude_peer_settings(&homes).unwrap();
let updated = update_claude_peer_settings(
&homes,
Some(ClaudeCrossSessionInbound::Accept),
Some(&before.revision),
)
.unwrap();
assert_eq!(
updated.cross_session_inbound,
Some(ClaudeCrossSessionInbound::Accept)
);
let document: serde_json::Value =
serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap();
assert_eq!(document["theme"], "dark");
assert_eq!(document["permissions"]["allow"][0], "Bash(git status)");
assert_eq!(document["crossSessionInbound"], "accept");
let stale = update_claude_peer_settings(
&homes,
Some(ClaudeCrossSessionInbound::Hold),
Some(&before.revision),
)
.unwrap_err();
assert!(matches!(stale, ClaudePeerSettingsError::ChangedDuringWrite));
let reset = update_claude_peer_settings(&homes, None, Some(&updated.revision)).unwrap();
assert_eq!(reset.cross_session_inbound, None);
let reset_document: serde_json::Value =
serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap();
assert_eq!(reset_document["theme"], "dark");
assert!(reset_document.get("crossSessionInbound").is_none());
std::fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn explicit_peer_policy_update_refuses_a_symlinked_settings_file() {
use std::os::unix::fs::symlink;
let root = temp_dir("settings-symlink");
let outside = root.join("outside.json");
std::fs::write(&outside, "{}\n").unwrap();
symlink(&outside, root.join("settings.json")).unwrap();
let error =
write_claude_peer_settings(&homes_for(&root), ClaudeCrossSessionInbound::Accept)
.unwrap_err();
assert!(matches!(error, ClaudePeerSettingsError::Invalid(_)));
assert_eq!(std::fs::read_to_string(outside).unwrap(), "{}\n");
std::fs::remove_dir_all(root).ok();
}
#[test]
fn registry_reports_live_records_and_drops_stale_ones() {
let root = temp_dir("registry");
let sessions = root.join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
let live = std::process::id();
let dead = dead_pid();
write_record(&sessions, live, "live-session", "peer-live", "busy");
write_record(&sessions, dead, "dead-session", "peer-dead", "idle");
std::fs::write(
sessions.join("777.json"),
format!("{{\"pid\":{live},\"sessionId\":\"no-socket\",\"name\":\"peer-x\"}}"),
)
.unwrap();
std::fs::write(sessions.join("bad.json"), "{not json").unwrap();
let found = read_registry(&sessions);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].session_id, "live-session");
assert_eq!(found[0].name, "peer-live");
assert_eq!(found[0].status, Some(ClaudePeerStatus::Busy));
assert_eq!(
found[0].socket_path,
PathBuf::from(format!("/tmp/cc-socks/{live}.sock"))
);
assert_eq!(registry_dir(&homes_for(&root)), sessions);
std::fs::remove_dir_all(root).ok();
}
#[test]
fn registry_keeps_live_peers_during_shell_tools_and_unknown_vendor_states() {
let root = temp_dir("registry-statuses");
let sessions = root.join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
let live = std::process::id();
write_record(&sessions, live, "shell-session", "peer-shell", "shell");
let future = std::fs::read_to_string(sessions.join(format!("{live}.json")))
.unwrap()
.replace("shell-session", "future-session")
.replace("peer-shell", "peer-future")
.replace("\"status\":\"shell\"", "\"status\":\"future-status\"");
std::fs::write(sessions.join("future.json"), future).unwrap();
let found = read_registry(&sessions);
assert_eq!(found.len(), 2, "a vendor status must not erase a live peer");
let shell = found
.iter()
.find(|peer| peer.session_id == "shell-session")
.unwrap();
let future = found
.iter()
.find(|peer| peer.session_id == "future-session")
.unwrap();
assert_eq!(shell.status, Some(ClaudePeerStatus::Busy));
assert_eq!(future.status, None);
std::fs::remove_dir_all(root).ok();
}
#[test]
fn a_persisted_only_session_refuses_with_not_live() {
let root = temp_dir("not-live");
std::fs::create_dir_all(root.join("sessions")).unwrap();
write_record(
&root.join("sessions"),
dead_pid(),
"gone-session",
"peer-gone",
"idle",
);
let refusal = resolve_live_session(&homes_for(&root), "gone-session").unwrap_err();
assert_eq!(refusal.reason, ClaudePeerRefusal::NotLive);
std::fs::remove_dir_all(root).ok();
}
#[test]
fn a_name_shared_by_two_live_sessions_refuses_instead_of_guessing() {
let root = temp_dir("mismatch");
let sessions = root.join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
let live = std::process::id();
write_record(&sessions, live, "wanted-session", "peer-shared", "idle");
std::fs::write(
sessions.join(format!("{}.json", live + 1)),
format!(
"{{\"pid\":{live},\"sessionId\":\"other-session\",\
\"messagingSocketPath\":\"/tmp/cc-socks/{live}.sock\",\"name\":\"peer-shared\"}}"
),
)
.unwrap();
let refusal = resolve_live_session(&homes_for(&root), "wanted-session").unwrap_err();
assert_eq!(refusal.reason, ClaudePeerRefusal::IdentityMismatch);
assert!(refusal.message.contains("peer-shared"));
std::fs::remove_dir_all(root).ok();
}
}