1use std::path::{Path, PathBuf};
30use std::{fs::OpenOptions, io::Write};
31
32use serde::{Deserialize, Serialize};
33
34use crate::HarnessHomes;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ClaudePeerStatus {
40 Busy,
42 Idle,
44 Waiting,
46}
47
48impl ClaudePeerStatus {
49 pub const fn as_str(self) -> &'static str {
51 match self {
52 Self::Busy => "busy",
53 Self::Idle => "idle",
54 Self::Waiting => "waiting",
55 }
56 }
57
58 fn from_registry(value: &str) -> Option<Self> {
64 match value {
65 "busy" => Some(Self::Busy),
66 "idle" | "shell" => Some(Self::Idle),
67 "waiting" => Some(Self::Waiting),
68 _ => None,
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ClaudePeerSession {
77 pub pid: u32,
79 pub session_id: String,
81 pub cwd: Option<PathBuf>,
83 pub name: String,
85 pub socket_path: PathBuf,
87 pub status: Option<ClaudePeerStatus>,
89 pub updated_at_ms: Option<u64>,
91 pub version: Option<String>,
93 pub tmux: Option<String>,
96}
97
98pub fn registry_dir(homes: &HarnessHomes) -> PathBuf {
105 homes
106 .claude_code
107 .parent()
108 .unwrap_or(Path::new("."))
109 .join("sessions")
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ClaudeCrossSessionInbound {
116 Accept,
118 Hold,
120 Refuse,
122}
123
124impl ClaudeCrossSessionInbound {
125 pub const fn as_str(self) -> &'static str {
127 match self {
128 Self::Accept => "accept",
129 Self::Hold => "hold",
130 Self::Refuse => "refuse",
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138pub struct ClaudePeerSettings {
139 pub path: PathBuf,
141 pub revision: String,
144 pub cross_session_inbound: Option<ClaudeCrossSessionInbound>,
147}
148
149impl ClaudePeerSettings {
150 pub fn user_allows_automatic_delivery(&self) -> bool {
154 self.cross_session_inbound == Some(ClaudeCrossSessionInbound::Accept)
155 }
156}
157
158#[derive(Debug, thiserror::Error)]
160pub enum ClaudePeerSettingsError {
161 #[error("Claude Code settings I/O failed: {0}")]
163 Io(#[from] std::io::Error),
164 #[error("Claude Code settings JSON is invalid: {0}")]
166 Json(#[from] serde_json::Error),
167 #[error("{0}")]
169 Invalid(String),
170 #[error("Claude Code settings changed while Volter Harness was updating them; retry the explicit configuration action")]
172 ChangedDuringWrite,
173}
174
175pub fn user_settings_path(homes: &HarnessHomes) -> PathBuf {
177 homes
178 .claude_code
179 .parent()
180 .unwrap_or(Path::new("."))
181 .join("settings.json")
182}
183
184pub fn read_claude_peer_settings(
188 homes: &HarnessHomes,
189) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
190 let path = user_settings_path(homes);
191 let bytes = match std::fs::read(&path) {
192 Ok(bytes) => bytes,
193 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
194 Err(error) => return Err(error.into()),
195 };
196 let value = if bytes.is_empty() {
197 serde_json::Value::Object(serde_json::Map::new())
198 } else {
199 serde_json::from_slice(&bytes)?
200 };
201 let object = value.as_object().ok_or_else(|| {
202 ClaudePeerSettingsError::Invalid(format!(
203 "Claude Code settings at {} must be a JSON object",
204 path.display()
205 ))
206 })?;
207 let cross_session_inbound = match object.get("crossSessionInbound") {
208 None => None,
209 Some(serde_json::Value::String(value)) if value == "accept" => {
210 Some(ClaudeCrossSessionInbound::Accept)
211 }
212 Some(serde_json::Value::String(value)) if value == "hold" => {
213 Some(ClaudeCrossSessionInbound::Hold)
214 }
215 Some(serde_json::Value::String(value)) if value == "refuse" => {
216 Some(ClaudeCrossSessionInbound::Refuse)
217 }
218 Some(value) => {
219 return Err(ClaudePeerSettingsError::Invalid(format!(
220 "Claude Code setting `crossSessionInbound` at {} must be `accept`, `hold`, or `refuse`, not {value}",
221 path.display()
222 )))
223 }
224 };
225 Ok(ClaudePeerSettings {
226 path,
227 revision: blake3::hash(&bytes).to_hex().to_string(),
228 cross_session_inbound,
229 })
230}
231
232pub fn write_claude_peer_settings(
236 homes: &HarnessHomes,
237 cross_session_inbound: ClaudeCrossSessionInbound,
238) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
239 update_claude_peer_settings(homes, Some(cross_session_inbound), None)
240}
241
242pub fn update_claude_peer_settings(
246 homes: &HarnessHomes,
247 cross_session_inbound: Option<ClaudeCrossSessionInbound>,
248 expected_revision: Option<&str>,
249) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
250 let path = user_settings_path(homes);
251 if std::fs::symlink_metadata(&path)
252 .map(|metadata| metadata.file_type().is_symlink())
253 .unwrap_or(false)
254 {
255 return Err(ClaudePeerSettingsError::Invalid(format!(
256 "refusing to replace symlinked Claude Code settings at {}",
257 path.display()
258 )));
259 }
260 let original = match std::fs::read(&path) {
261 Ok(bytes) => bytes,
262 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
263 Err(error) => return Err(error.into()),
264 };
265 let original_revision = blake3::hash(&original).to_hex().to_string();
266 if expected_revision.is_some_and(|expected| expected != original_revision) {
267 return Err(ClaudePeerSettingsError::ChangedDuringWrite);
268 }
269 let mut value = if original.is_empty() {
270 serde_json::Value::Object(serde_json::Map::new())
271 } else {
272 serde_json::from_slice(&original)?
273 };
274 let object = value.as_object_mut().ok_or_else(|| {
275 ClaudePeerSettingsError::Invalid(format!(
276 "Claude Code settings at {} must be a JSON object",
277 path.display()
278 ))
279 })?;
280 let changed = match cross_session_inbound {
281 Some(value) => {
282 object.insert(
283 "crossSessionInbound".into(),
284 serde_json::Value::String(value.as_str().into()),
285 ) != Some(serde_json::Value::String(value.as_str().into()))
286 }
287 None => object.remove("crossSessionInbound").is_some(),
288 };
289 if !changed {
290 return read_claude_peer_settings(homes);
291 }
292 let mut encoded = serde_json::to_vec_pretty(&value)?;
293 encoded.push(b'\n');
294
295 let parent = path.parent().unwrap_or(Path::new("."));
296 std::fs::create_dir_all(parent)?;
297 let nonce = std::time::SystemTime::now()
298 .duration_since(std::time::UNIX_EPOCH)
299 .unwrap_or_default()
300 .as_nanos();
301 let temporary = parent.join(format!(
302 ".settings.json.supercode-{}-{nonce}.tmp",
303 std::process::id()
304 ));
305 let write_result = (|| -> Result<(), ClaudePeerSettingsError> {
306 let mut options = OpenOptions::new();
307 options.write(true).create_new(true);
308 #[cfg(unix)]
309 {
310 use std::os::unix::fs::OpenOptionsExt;
311 options.mode(0o600);
312 }
313 let mut file = options.open(&temporary)?;
314 #[cfg(unix)]
315 {
316 use std::os::unix::fs::{MetadataExt, PermissionsExt};
317 let mode = std::fs::metadata(&path)
318 .map(|metadata| metadata.mode() & 0o777)
319 .unwrap_or(0o600);
320 file.set_permissions(std::fs::Permissions::from_mode(mode))?;
321 }
322 file.write_all(&encoded)?;
323 file.sync_all()?;
324 let current = match std::fs::read(&path) {
325 Ok(bytes) => bytes,
326 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
327 Err(error) => return Err(error.into()),
328 };
329 if current != original {
330 return Err(ClaudePeerSettingsError::ChangedDuringWrite);
331 }
332 std::fs::rename(&temporary, &path)?;
333 Ok(())
334 })();
335 if write_result.is_err() {
336 std::fs::remove_file(&temporary).ok();
337 }
338 write_result?;
339 read_claude_peer_settings(homes)
340}
341
342#[derive(Deserialize)]
343struct RegistryRecord {
344 pid: u32,
345 #[serde(rename = "sessionId")]
346 session_id: String,
347 #[serde(default)]
348 cwd: Option<PathBuf>,
349 #[serde(default)]
350 name: Option<String>,
351 #[serde(rename = "messagingSocketPath", default)]
352 messaging_socket_path: Option<PathBuf>,
353 #[serde(default)]
354 status: Option<String>,
358 #[serde(rename = "updatedAt", default)]
359 updated_at: Option<u64>,
360 #[serde(default)]
361 version: Option<String>,
362 #[serde(default)]
363 tmux: Option<String>,
364 #[serde(rename = "parkedJobId", default)]
365 parked_job_id: Option<String>,
366 #[serde(rename = "procStart", default)]
367 proc_start: Option<String>,
368}
369
370pub fn read_registry(directory: &Path) -> Vec<ClaudePeerSession> {
378 let Ok(entries) = std::fs::read_dir(directory) else {
379 return Vec::new();
380 };
381 let mut sessions = Vec::new();
382 for entry in entries.flatten() {
383 let path = entry.path();
384 if path.extension().and_then(|value| value.to_str()) != Some("json") {
385 continue;
386 }
387 let Ok(bytes) = std::fs::read(&path) else {
388 continue;
389 };
390 let Ok(record) = serde_json::from_slice::<RegistryRecord>(&bytes) else {
391 continue;
392 };
393 let (Some(name), Some(socket_path)) = (record.name, record.messaging_socket_path) else {
394 continue;
395 };
396 if record.session_id.is_empty()
397 || name.is_empty()
398 || record.parked_job_id.is_some()
399 || !process_is_live(record.pid)
400 || record
401 .proc_start
402 .as_deref()
403 .is_some_and(|recorded| !same_process_start(record.pid, recorded))
404 {
405 continue;
406 }
407 sessions.push(ClaudePeerSession {
408 pid: record.pid,
409 session_id: record.session_id,
410 cwd: record.cwd,
411 name,
412 socket_path,
413 status: record
414 .status
415 .as_deref()
416 .and_then(ClaudePeerStatus::from_registry),
417 updated_at_ms: record.updated_at,
418 version: record.version,
419 tmux: record.tmux,
420 });
421 }
422 sessions.sort_by_key(|session| session.pid);
423 sessions
424}
425
426#[cfg(target_os = "linux")]
432fn same_process_start(pid: u32, recorded: &str) -> bool {
433 let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
434 return true;
435 };
436 let Some(start) = stat
439 .rsplit_once(')')
440 .and_then(|(_, rest)| rest.split_whitespace().nth(19))
441 else {
442 return true;
443 };
444 start == recorded.trim()
445}
446
447#[cfg(target_os = "macos")]
448fn same_process_start(pid: u32, recorded: &str) -> bool {
449 use std::mem::{size_of, MaybeUninit};
450 let Ok(pid) = libc::c_int::try_from(pid) else {
451 return true;
452 };
453 let mut info = MaybeUninit::<libc::proc_bsdinfo>::zeroed();
454 let size = size_of::<libc::proc_bsdinfo>() as libc::c_int;
455 let read = unsafe {
457 libc::proc_pidinfo(
458 pid,
459 libc::PROC_PIDTBSDINFO,
460 0,
461 info.as_mut_ptr().cast(),
462 size,
463 )
464 };
465 if read != size {
466 return true;
467 }
468 let seconds = unsafe { info.assume_init() }.pbi_start_tvsec;
470 let Ok(seconds) = libc::time_t::try_from(seconds) else {
471 return true;
472 };
473 let mut tm = MaybeUninit::<libc::tm>::zeroed();
474 let mut text = [0u8; 64];
475 let written = unsafe {
478 if libc::gmtime_r(&seconds, tm.as_mut_ptr()).is_null() {
479 return true;
480 }
481 libc::strftime(
482 text.as_mut_ptr().cast(),
483 text.len(),
484 c"%a %b %e %H:%M:%S %Y".as_ptr(),
485 tm.as_ptr(),
486 )
487 };
488 if written == 0 {
489 return true;
490 }
491 let actual = String::from_utf8_lossy(&text[..written]);
492 actual.split_whitespace().eq(recorded.split_whitespace())
494}
495
496#[cfg(not(any(target_os = "linux", target_os = "macos")))]
497fn same_process_start(_pid: u32, _recorded: &str) -> bool {
498 true
499}
500
501#[cfg(unix)]
502pub(crate) fn process_is_live(pid: u32) -> bool {
503 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
505 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
506}
507
508#[cfg(windows)]
509pub(crate) fn process_is_live(pid: u32) -> bool {
510 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
511 use windows_sys::Win32::System::Threading::{
512 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
513 };
514
515 let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
516 if process.is_null() {
517 return false;
518 }
519 let mut code = 0u32;
520 let read = unsafe { GetExitCodeProcess(process, &mut code) } != 0;
521 unsafe {
522 CloseHandle(process);
523 }
524 read && code == STILL_ACTIVE as u32
525}
526
527#[cfg(not(any(unix, windows)))]
528pub(crate) fn process_is_live(_pid: u32) -> bool {
529 false
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum ClaudePeerRefusal {
535 NotLive,
537 IdentityMismatch,
539 DeliveryFailed,
541}
542
543impl ClaudePeerRefusal {
544 pub const fn as_str(self) -> &'static str {
546 match self {
547 Self::NotLive => "not_live",
548 Self::IdentityMismatch => "identity_mismatch",
549 Self::DeliveryFailed => "delivery_failed",
550 }
551 }
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
556#[error("{message}")]
557pub struct ClaudePeerRefusalError {
558 pub reason: ClaudePeerRefusal,
560 pub message: String,
562}
563
564impl ClaudePeerRefusalError {
565 fn new(reason: ClaudePeerRefusal, message: impl Into<String>) -> Self {
566 Self {
567 reason,
568 message: message.into(),
569 }
570 }
571}
572
573pub fn resolve_live_session(
580 homes: &HarnessHomes,
581 session_id: &str,
582) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
583 let registry = read_registry(®istry_dir(homes));
584 let target = registry
585 .iter()
586 .find(|session| session.session_id == session_id)
587 .cloned()
588 .ok_or_else(|| {
589 ClaudePeerRefusalError::new(
590 ClaudePeerRefusal::NotLive,
591 format!(
592 "no live Claude Code process is running session `{session_id}`; \
593 its transcript is persisted only"
594 ),
595 )
596 })?;
597 let by_name = registry
598 .iter()
599 .filter(|session| session.name == target.name)
600 .collect::<Vec<_>>();
601 if by_name.len() != 1 || by_name[0].session_id != target.session_id {
602 return Err(ClaudePeerRefusalError::new(
603 ClaudePeerRefusal::IdentityMismatch,
604 format!(
605 "the registry name `{}` no longer resolves to session `{session_id}` alone; \
606 refusing rather than delivering into another session",
607 target.name
608 ),
609 ));
610 }
611
612 Ok(target)
613}