1use std::path::{Path, PathBuf};
24use std::{fs::OpenOptions, io::Write};
25
26use serde::{Deserialize, Serialize};
27
28use crate::HarnessHomes;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ClaudePeerStatus {
34 Busy,
36 Idle,
38 Waiting,
40}
41
42impl ClaudePeerStatus {
43 pub const fn as_str(self) -> &'static str {
45 match self {
46 Self::Busy => "busy",
47 Self::Idle => "idle",
48 Self::Waiting => "waiting",
49 }
50 }
51
52 fn from_registry(value: &str) -> Option<Self> {
57 match value {
58 "busy" | "shell" => Some(Self::Busy),
59 "idle" => Some(Self::Idle),
60 "waiting" => Some(Self::Waiting),
61 _ => None,
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ClaudePeerSession {
70 pub pid: u32,
72 pub session_id: String,
74 pub cwd: Option<PathBuf>,
76 pub name: String,
78 pub socket_path: PathBuf,
80 pub status: Option<ClaudePeerStatus>,
82 pub updated_at_ms: Option<u64>,
84 pub version: Option<String>,
86 pub tmux: Option<String>,
89}
90
91pub fn registry_dir(homes: &HarnessHomes) -> PathBuf {
98 homes
99 .claude_code
100 .parent()
101 .unwrap_or(Path::new("."))
102 .join("sessions")
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ClaudeCrossSessionInbound {
109 Accept,
111 Hold,
113 Refuse,
115}
116
117impl ClaudeCrossSessionInbound {
118 pub const fn as_str(self) -> &'static str {
120 match self {
121 Self::Accept => "accept",
122 Self::Hold => "hold",
123 Self::Refuse => "refuse",
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131pub struct ClaudePeerSettings {
132 pub path: PathBuf,
134 pub revision: String,
137 pub cross_session_inbound: Option<ClaudeCrossSessionInbound>,
140}
141
142impl ClaudePeerSettings {
143 pub fn user_allows_automatic_delivery(&self) -> bool {
147 self.cross_session_inbound == Some(ClaudeCrossSessionInbound::Accept)
148 }
149}
150
151#[derive(Debug, thiserror::Error)]
153pub enum ClaudePeerSettingsError {
154 #[error("Claude Code settings I/O failed: {0}")]
156 Io(#[from] std::io::Error),
157 #[error("Claude Code settings JSON is invalid: {0}")]
159 Json(#[from] serde_json::Error),
160 #[error("{0}")]
162 Invalid(String),
163 #[error("Claude Code settings changed while Volter Harness was updating them; retry the explicit configuration action")]
165 ChangedDuringWrite,
166}
167
168pub fn user_settings_path(homes: &HarnessHomes) -> PathBuf {
170 homes
171 .claude_code
172 .parent()
173 .unwrap_or(Path::new("."))
174 .join("settings.json")
175}
176
177pub fn read_claude_peer_settings(
181 homes: &HarnessHomes,
182) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
183 let path = user_settings_path(homes);
184 let bytes = match std::fs::read(&path) {
185 Ok(bytes) => bytes,
186 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
187 Err(error) => return Err(error.into()),
188 };
189 let value = if bytes.is_empty() {
190 serde_json::Value::Object(serde_json::Map::new())
191 } else {
192 serde_json::from_slice(&bytes)?
193 };
194 let object = value.as_object().ok_or_else(|| {
195 ClaudePeerSettingsError::Invalid(format!(
196 "Claude Code settings at {} must be a JSON object",
197 path.display()
198 ))
199 })?;
200 let cross_session_inbound = match object.get("crossSessionInbound") {
201 None => None,
202 Some(serde_json::Value::String(value)) if value == "accept" => {
203 Some(ClaudeCrossSessionInbound::Accept)
204 }
205 Some(serde_json::Value::String(value)) if value == "hold" => {
206 Some(ClaudeCrossSessionInbound::Hold)
207 }
208 Some(serde_json::Value::String(value)) if value == "refuse" => {
209 Some(ClaudeCrossSessionInbound::Refuse)
210 }
211 Some(value) => {
212 return Err(ClaudePeerSettingsError::Invalid(format!(
213 "Claude Code setting `crossSessionInbound` at {} must be `accept`, `hold`, or `refuse`, not {value}",
214 path.display()
215 )))
216 }
217 };
218 Ok(ClaudePeerSettings {
219 path,
220 revision: blake3::hash(&bytes).to_hex().to_string(),
221 cross_session_inbound,
222 })
223}
224
225pub fn write_claude_peer_settings(
229 homes: &HarnessHomes,
230 cross_session_inbound: ClaudeCrossSessionInbound,
231) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
232 update_claude_peer_settings(homes, Some(cross_session_inbound), None)
233}
234
235pub fn update_claude_peer_settings(
239 homes: &HarnessHomes,
240 cross_session_inbound: Option<ClaudeCrossSessionInbound>,
241 expected_revision: Option<&str>,
242) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
243 let path = user_settings_path(homes);
244 if std::fs::symlink_metadata(&path)
245 .map(|metadata| metadata.file_type().is_symlink())
246 .unwrap_or(false)
247 {
248 return Err(ClaudePeerSettingsError::Invalid(format!(
249 "refusing to replace symlinked Claude Code settings at {}",
250 path.display()
251 )));
252 }
253 let original = match std::fs::read(&path) {
254 Ok(bytes) => bytes,
255 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
256 Err(error) => return Err(error.into()),
257 };
258 let original_revision = blake3::hash(&original).to_hex().to_string();
259 if expected_revision.is_some_and(|expected| expected != original_revision) {
260 return Err(ClaudePeerSettingsError::ChangedDuringWrite);
261 }
262 let mut value = if original.is_empty() {
263 serde_json::Value::Object(serde_json::Map::new())
264 } else {
265 serde_json::from_slice(&original)?
266 };
267 let object = value.as_object_mut().ok_or_else(|| {
268 ClaudePeerSettingsError::Invalid(format!(
269 "Claude Code settings at {} must be a JSON object",
270 path.display()
271 ))
272 })?;
273 let changed = match cross_session_inbound {
274 Some(value) => {
275 object.insert(
276 "crossSessionInbound".into(),
277 serde_json::Value::String(value.as_str().into()),
278 ) != Some(serde_json::Value::String(value.as_str().into()))
279 }
280 None => object.remove("crossSessionInbound").is_some(),
281 };
282 if !changed {
283 return read_claude_peer_settings(homes);
284 }
285 let mut encoded = serde_json::to_vec_pretty(&value)?;
286 encoded.push(b'\n');
287
288 let parent = path.parent().unwrap_or(Path::new("."));
289 std::fs::create_dir_all(parent)?;
290 let nonce = std::time::SystemTime::now()
291 .duration_since(std::time::UNIX_EPOCH)
292 .unwrap_or_default()
293 .as_nanos();
294 let temporary = parent.join(format!(
295 ".settings.json.supercode-{}-{nonce}.tmp",
296 std::process::id()
297 ));
298 let write_result = (|| -> Result<(), ClaudePeerSettingsError> {
299 let mut options = OpenOptions::new();
300 options.write(true).create_new(true);
301 #[cfg(unix)]
302 {
303 use std::os::unix::fs::OpenOptionsExt;
304 options.mode(0o600);
305 }
306 let mut file = options.open(&temporary)?;
307 #[cfg(unix)]
308 {
309 use std::os::unix::fs::{MetadataExt, PermissionsExt};
310 let mode = std::fs::metadata(&path)
311 .map(|metadata| metadata.mode() & 0o777)
312 .unwrap_or(0o600);
313 file.set_permissions(std::fs::Permissions::from_mode(mode))?;
314 }
315 file.write_all(&encoded)?;
316 file.sync_all()?;
317 let current = match std::fs::read(&path) {
318 Ok(bytes) => bytes,
319 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
320 Err(error) => return Err(error.into()),
321 };
322 if current != original {
323 return Err(ClaudePeerSettingsError::ChangedDuringWrite);
324 }
325 std::fs::rename(&temporary, &path)?;
326 Ok(())
327 })();
328 if write_result.is_err() {
329 std::fs::remove_file(&temporary).ok();
330 }
331 write_result?;
332 read_claude_peer_settings(homes)
333}
334
335#[derive(Deserialize)]
336struct RegistryRecord {
337 pid: u32,
338 #[serde(rename = "sessionId")]
339 session_id: String,
340 #[serde(default)]
341 cwd: Option<PathBuf>,
342 #[serde(default)]
343 name: Option<String>,
344 #[serde(rename = "messagingSocketPath", default)]
345 messaging_socket_path: Option<PathBuf>,
346 #[serde(default)]
347 status: Option<String>,
351 #[serde(rename = "updatedAt", default)]
352 updated_at: Option<u64>,
353 #[serde(default)]
354 version: Option<String>,
355 #[serde(default)]
356 tmux: Option<String>,
357}
358
359pub fn read_registry(directory: &Path) -> Vec<ClaudePeerSession> {
365 let Ok(entries) = std::fs::read_dir(directory) else {
366 return Vec::new();
367 };
368 let mut sessions = Vec::new();
369 for entry in entries.flatten() {
370 let path = entry.path();
371 if path.extension().and_then(|value| value.to_str()) != Some("json") {
372 continue;
373 }
374 let Ok(bytes) = std::fs::read(&path) else {
375 continue;
376 };
377 let Ok(record) = serde_json::from_slice::<RegistryRecord>(&bytes) else {
378 continue;
379 };
380 let (Some(name), Some(socket_path)) = (record.name, record.messaging_socket_path) else {
381 continue;
382 };
383 if record.session_id.is_empty() || name.is_empty() || !process_is_live(record.pid) {
384 continue;
385 }
386 sessions.push(ClaudePeerSession {
387 pid: record.pid,
388 session_id: record.session_id,
389 cwd: record.cwd,
390 name,
391 socket_path,
392 status: record
393 .status
394 .as_deref()
395 .and_then(ClaudePeerStatus::from_registry),
396 updated_at_ms: record.updated_at,
397 version: record.version,
398 tmux: record.tmux,
399 });
400 }
401 sessions.sort_by_key(|session| session.pid);
402 sessions
403}
404
405#[cfg(unix)]
406pub(crate) fn process_is_live(pid: u32) -> bool {
407 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
409 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
410}
411
412#[cfg(windows)]
413pub(crate) fn process_is_live(pid: u32) -> bool {
414 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
415 use windows_sys::Win32::System::Threading::{
416 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
417 };
418
419 let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
420 if process.is_null() {
421 return false;
422 }
423 let mut code = 0u32;
424 let read = unsafe { GetExitCodeProcess(process, &mut code) } != 0;
425 unsafe {
426 CloseHandle(process);
427 }
428 read && code == STILL_ACTIVE as u32
429}
430
431#[cfg(not(any(unix, windows)))]
432pub(crate) fn process_is_live(_pid: u32) -> bool {
433 false
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum ClaudePeerRefusal {
439 NotLive,
441 IdentityMismatch,
443 DeliveryFailed,
445}
446
447impl ClaudePeerRefusal {
448 pub const fn as_str(self) -> &'static str {
450 match self {
451 Self::NotLive => "not_live",
452 Self::IdentityMismatch => "identity_mismatch",
453 Self::DeliveryFailed => "delivery_failed",
454 }
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
460#[error("{message}")]
461pub struct ClaudePeerRefusalError {
462 pub reason: ClaudePeerRefusal,
464 pub message: String,
466}
467
468impl ClaudePeerRefusalError {
469 fn new(reason: ClaudePeerRefusal, message: impl Into<String>) -> Self {
470 Self {
471 reason,
472 message: message.into(),
473 }
474 }
475}
476
477pub fn resolve_live_session(
484 homes: &HarnessHomes,
485 session_id: &str,
486) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
487 let registry = read_registry(®istry_dir(homes));
488 let target = registry
489 .iter()
490 .find(|session| session.session_id == session_id)
491 .cloned()
492 .ok_or_else(|| {
493 ClaudePeerRefusalError::new(
494 ClaudePeerRefusal::NotLive,
495 format!(
496 "no live Claude Code process is running session `{session_id}`; \
497 its transcript is persisted only"
498 ),
499 )
500 })?;
501 let by_name = registry
502 .iter()
503 .filter(|session| session.name == target.name)
504 .collect::<Vec<_>>();
505 if by_name.len() != 1 || by_name[0].session_id != target.session_id {
506 return Err(ClaudePeerRefusalError::new(
507 ClaudePeerRefusal::IdentityMismatch,
508 format!(
509 "the registry name `{}` no longer resolves to session `{session_id}` alone; \
510 refusing rather than delivering into another session",
511 target.name
512 ),
513 ));
514 }
515
516 Ok(target)
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 fn temp_dir(label: &str) -> PathBuf {
524 let path = std::env::temp_dir().join(format!(
525 "supercode-claude-peer-{label}-{}-{:?}",
526 std::process::id(),
527 std::time::SystemTime::now()
528 .duration_since(std::time::UNIX_EPOCH)
529 .unwrap()
530 .as_nanos()
531 ));
532 std::fs::create_dir_all(&path).unwrap();
533 path
534 }
535
536 fn dead_pid() -> u32 {
538 let mut child = std::process::Command::new("/usr/bin/true")
539 .spawn()
540 .or_else(|_| std::process::Command::new("true").spawn())
541 .unwrap();
542 let pid = child.id();
543 child.wait().unwrap();
544 pid
545 }
546
547 fn write_record(directory: &Path, pid: u32, session_id: &str, name: &str, status: &str) {
548 let status = if status.is_empty() {
549 String::new()
550 } else {
551 format!(",\"status\":\"{status}\",\"updatedAt\":1786907689006")
552 };
553 std::fs::write(
554 directory.join(format!("{pid}.json")),
555 format!(
556 "{{\"pid\":{pid},\"sessionId\":\"{session_id}\",\"cwd\":\"/tmp/project\",\
557 \"version\":\"2.1.224\",\"peerProtocol\":1,\"kind\":\"interactive\",\
558 \"entrypoint\":\"cli\",\"messagingSocketPath\":\"/tmp/cc-socks/{pid}.sock\",\
559 \"name\":\"{name}\",\"nameSource\":\"derived\"{status}}}"
560 ),
561 )
562 .unwrap();
563 }
564
565 fn homes_for(root: &Path) -> HarnessHomes {
566 HarnessHomes {
567 claude_code: root.join("projects"),
568 ..HarnessHomes::default()
569 }
570 }
571
572 #[test]
573 fn explicit_peer_policy_update_preserves_the_rest_of_claude_settings() {
574 let root = temp_dir("settings");
575 let settings_path = root.join("settings.json");
576 std::fs::write(
577 &settings_path,
578 r#"{"permissions":{"allow":["Bash(git status)"]},"theme":"dark"}"#,
579 )
580 .unwrap();
581
582 let homes = homes_for(&root);
583 let before = read_claude_peer_settings(&homes).unwrap();
584 let updated = update_claude_peer_settings(
585 &homes,
586 Some(ClaudeCrossSessionInbound::Accept),
587 Some(&before.revision),
588 )
589 .unwrap();
590 assert_eq!(
591 updated.cross_session_inbound,
592 Some(ClaudeCrossSessionInbound::Accept)
593 );
594 let document: serde_json::Value =
595 serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap();
596 assert_eq!(document["theme"], "dark");
597 assert_eq!(document["permissions"]["allow"][0], "Bash(git status)");
598 assert_eq!(document["crossSessionInbound"], "accept");
599
600 let stale = update_claude_peer_settings(
601 &homes,
602 Some(ClaudeCrossSessionInbound::Hold),
603 Some(&before.revision),
604 )
605 .unwrap_err();
606 assert!(matches!(stale, ClaudePeerSettingsError::ChangedDuringWrite));
607
608 let reset = update_claude_peer_settings(&homes, None, Some(&updated.revision)).unwrap();
609 assert_eq!(reset.cross_session_inbound, None);
610 let reset_document: serde_json::Value =
611 serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap();
612 assert_eq!(reset_document["theme"], "dark");
613 assert!(reset_document.get("crossSessionInbound").is_none());
614 std::fs::remove_dir_all(root).ok();
615 }
616
617 #[cfg(unix)]
618 #[test]
619 fn explicit_peer_policy_update_refuses_a_symlinked_settings_file() {
620 use std::os::unix::fs::symlink;
621
622 let root = temp_dir("settings-symlink");
623 let outside = root.join("outside.json");
624 std::fs::write(&outside, "{}\n").unwrap();
625 symlink(&outside, root.join("settings.json")).unwrap();
626
627 let error =
628 write_claude_peer_settings(&homes_for(&root), ClaudeCrossSessionInbound::Accept)
629 .unwrap_err();
630 assert!(matches!(error, ClaudePeerSettingsError::Invalid(_)));
631 assert_eq!(std::fs::read_to_string(outside).unwrap(), "{}\n");
632 std::fs::remove_dir_all(root).ok();
633 }
634
635 #[test]
636 fn registry_reports_live_records_and_drops_stale_ones() {
637 let root = temp_dir("registry");
638 let sessions = root.join("sessions");
639 std::fs::create_dir_all(&sessions).unwrap();
640 let live = std::process::id();
641 let dead = dead_pid();
642 write_record(&sessions, live, "live-session", "peer-live", "busy");
643 write_record(&sessions, dead, "dead-session", "peer-dead", "idle");
644 std::fs::write(
646 sessions.join("777.json"),
647 format!("{{\"pid\":{live},\"sessionId\":\"no-socket\",\"name\":\"peer-x\"}}"),
648 )
649 .unwrap();
650 std::fs::write(sessions.join("bad.json"), "{not json").unwrap();
651
652 let found = read_registry(&sessions);
653 assert_eq!(found.len(), 1, "{found:?}");
654 assert_eq!(found[0].session_id, "live-session");
655 assert_eq!(found[0].name, "peer-live");
656 assert_eq!(found[0].status, Some(ClaudePeerStatus::Busy));
657 assert_eq!(
658 found[0].socket_path,
659 PathBuf::from(format!("/tmp/cc-socks/{live}.sock"))
660 );
661 assert_eq!(registry_dir(&homes_for(&root)), sessions);
662 std::fs::remove_dir_all(root).ok();
663 }
664
665 #[test]
666 fn registry_keeps_live_peers_during_shell_tools_and_unknown_vendor_states() {
667 let root = temp_dir("registry-statuses");
668 let sessions = root.join("sessions");
669 std::fs::create_dir_all(&sessions).unwrap();
670 let live = std::process::id();
671 write_record(&sessions, live, "shell-session", "peer-shell", "shell");
672 let future = std::fs::read_to_string(sessions.join(format!("{live}.json")))
673 .unwrap()
674 .replace("shell-session", "future-session")
675 .replace("peer-shell", "peer-future")
676 .replace("\"status\":\"shell\"", "\"status\":\"future-status\"");
677 std::fs::write(sessions.join("future.json"), future).unwrap();
678
679 let found = read_registry(&sessions);
680 assert_eq!(found.len(), 2, "a vendor status must not erase a live peer");
681 let shell = found
682 .iter()
683 .find(|peer| peer.session_id == "shell-session")
684 .unwrap();
685 let future = found
686 .iter()
687 .find(|peer| peer.session_id == "future-session")
688 .unwrap();
689 assert_eq!(shell.status, Some(ClaudePeerStatus::Busy));
690 assert_eq!(future.status, None);
691 std::fs::remove_dir_all(root).ok();
692 }
693
694 #[test]
695 fn a_persisted_only_session_refuses_with_not_live() {
696 let root = temp_dir("not-live");
697 std::fs::create_dir_all(root.join("sessions")).unwrap();
698 write_record(
699 &root.join("sessions"),
700 dead_pid(),
701 "gone-session",
702 "peer-gone",
703 "idle",
704 );
705 let refusal = resolve_live_session(&homes_for(&root), "gone-session").unwrap_err();
706 assert_eq!(refusal.reason, ClaudePeerRefusal::NotLive);
707 std::fs::remove_dir_all(root).ok();
708 }
709
710 #[test]
711 fn a_name_shared_by_two_live_sessions_refuses_instead_of_guessing() {
712 let root = temp_dir("mismatch");
713 let sessions = root.join("sessions");
714 std::fs::create_dir_all(&sessions).unwrap();
715 let live = std::process::id();
716 write_record(&sessions, live, "wanted-session", "peer-shared", "idle");
717 std::fs::write(
720 sessions.join(format!("{}.json", live + 1)),
721 format!(
722 "{{\"pid\":{live},\"sessionId\":\"other-session\",\
723 \"messagingSocketPath\":\"/tmp/cc-socks/{live}.sock\",\"name\":\"peer-shared\"}}"
724 ),
725 )
726 .unwrap();
727
728 let refusal = resolve_live_session(&homes_for(&root), "wanted-session").unwrap_err();
729 assert_eq!(refusal.reason, ClaudePeerRefusal::IdentityMismatch);
730 assert!(refusal.message.contains("peer-shared"));
731 std::fs::remove_dir_all(root).ok();
732 }
733}