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" => Some(Self::Busy),
"idle" | "shell" => 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>,
#[serde(rename = "parkedJobId", default)]
parked_job_id: Option<String>,
#[serde(rename = "procStart", default)]
proc_start: 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()
|| record.parked_job_id.is_some()
|| !process_is_live(record.pid)
|| record
.proc_start
.as_deref()
.is_some_and(|recorded| !same_process_start(record.pid, recorded))
{
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(target_os = "linux")]
fn same_process_start(pid: u32, recorded: &str) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return true;
};
let Some(start) = stat
.rsplit_once(')')
.and_then(|(_, rest)| rest.split_whitespace().nth(19))
else {
return true;
};
start == recorded.trim()
}
#[cfg(target_os = "macos")]
fn same_process_start(pid: u32, recorded: &str) -> bool {
use std::mem::{size_of, MaybeUninit};
let Ok(pid) = libc::c_int::try_from(pid) else {
return true;
};
let mut info = MaybeUninit::<libc::proc_bsdinfo>::zeroed();
let size = size_of::<libc::proc_bsdinfo>() as libc::c_int;
let read = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
info.as_mut_ptr().cast(),
size,
)
};
if read != size {
return true;
}
let seconds = unsafe { info.assume_init() }.pbi_start_tvsec;
let Ok(seconds) = libc::time_t::try_from(seconds) else {
return true;
};
let mut tm = MaybeUninit::<libc::tm>::zeroed();
let mut text = [0u8; 64];
let written = unsafe {
if libc::gmtime_r(&seconds, tm.as_mut_ptr()).is_null() {
return true;
}
libc::strftime(
text.as_mut_ptr().cast(),
text.len(),
c"%a %b %e %H:%M:%S %Y".as_ptr(),
tm.as_ptr(),
)
};
if written == 0 {
return true;
}
let actual = String::from_utf8_lossy(&text[..written]);
actual.split_whitespace().eq(recorded.split_whitespace())
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn same_process_start(_pid: u32, _recorded: &str) -> bool {
true
}
#[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)
}