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(not(unix))]
413pub(crate) fn process_is_live(pid: u32) -> bool {
414 let _ = pid;
417 false
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum ClaudePeerRefusal {
423 NotLive,
425 IdentityMismatch,
427 DeliveryFailed,
429}
430
431impl ClaudePeerRefusal {
432 pub const fn as_str(self) -> &'static str {
434 match self {
435 Self::NotLive => "not_live",
436 Self::IdentityMismatch => "identity_mismatch",
437 Self::DeliveryFailed => "delivery_failed",
438 }
439 }
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
444#[error("{message}")]
445pub struct ClaudePeerRefusalError {
446 pub reason: ClaudePeerRefusal,
448 pub message: String,
450}
451
452impl ClaudePeerRefusalError {
453 fn new(reason: ClaudePeerRefusal, message: impl Into<String>) -> Self {
454 Self {
455 reason,
456 message: message.into(),
457 }
458 }
459}
460
461pub fn resolve_live_session(
468 homes: &HarnessHomes,
469 session_id: &str,
470) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
471 let registry = read_registry(®istry_dir(homes));
472 let target = registry
473 .iter()
474 .find(|session| session.session_id == session_id)
475 .cloned()
476 .ok_or_else(|| {
477 ClaudePeerRefusalError::new(
478 ClaudePeerRefusal::NotLive,
479 format!(
480 "no live Claude Code process is running session `{session_id}`; \
481 its transcript is persisted only"
482 ),
483 )
484 })?;
485 let by_name = registry
486 .iter()
487 .filter(|session| session.name == target.name)
488 .collect::<Vec<_>>();
489 if by_name.len() != 1 || by_name[0].session_id != target.session_id {
490 return Err(ClaudePeerRefusalError::new(
491 ClaudePeerRefusal::IdentityMismatch,
492 format!(
493 "the registry name `{}` no longer resolves to session `{session_id}` alone; \
494 refusing rather than delivering into another session",
495 target.name
496 ),
497 ));
498 }
499
500 Ok(target)
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 fn temp_dir(label: &str) -> PathBuf {
508 let path = std::env::temp_dir().join(format!(
509 "supercode-claude-peer-{label}-{}-{:?}",
510 std::process::id(),
511 std::time::SystemTime::now()
512 .duration_since(std::time::UNIX_EPOCH)
513 .unwrap()
514 .as_nanos()
515 ));
516 std::fs::create_dir_all(&path).unwrap();
517 path
518 }
519
520 fn dead_pid() -> u32 {
522 let mut child = std::process::Command::new("/usr/bin/true")
523 .spawn()
524 .or_else(|_| std::process::Command::new("true").spawn())
525 .unwrap();
526 let pid = child.id();
527 child.wait().unwrap();
528 pid
529 }
530
531 fn write_record(directory: &Path, pid: u32, session_id: &str, name: &str, status: &str) {
532 let status = if status.is_empty() {
533 String::new()
534 } else {
535 format!(",\"status\":\"{status}\",\"updatedAt\":1786907689006")
536 };
537 std::fs::write(
538 directory.join(format!("{pid}.json")),
539 format!(
540 "{{\"pid\":{pid},\"sessionId\":\"{session_id}\",\"cwd\":\"/tmp/project\",\
541 \"version\":\"2.1.224\",\"peerProtocol\":1,\"kind\":\"interactive\",\
542 \"entrypoint\":\"cli\",\"messagingSocketPath\":\"/tmp/cc-socks/{pid}.sock\",\
543 \"name\":\"{name}\",\"nameSource\":\"derived\"{status}}}"
544 ),
545 )
546 .unwrap();
547 }
548
549 fn homes_for(root: &Path) -> HarnessHomes {
550 HarnessHomes {
551 claude_code: root.join("projects"),
552 ..HarnessHomes::default()
553 }
554 }
555
556 #[test]
557 fn explicit_peer_policy_update_preserves_the_rest_of_claude_settings() {
558 let root = temp_dir("settings");
559 let settings_path = root.join("settings.json");
560 std::fs::write(
561 &settings_path,
562 r#"{"permissions":{"allow":["Bash(git status)"]},"theme":"dark"}"#,
563 )
564 .unwrap();
565
566 let homes = homes_for(&root);
567 let before = read_claude_peer_settings(&homes).unwrap();
568 let updated = update_claude_peer_settings(
569 &homes,
570 Some(ClaudeCrossSessionInbound::Accept),
571 Some(&before.revision),
572 )
573 .unwrap();
574 assert_eq!(
575 updated.cross_session_inbound,
576 Some(ClaudeCrossSessionInbound::Accept)
577 );
578 let document: serde_json::Value =
579 serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap();
580 assert_eq!(document["theme"], "dark");
581 assert_eq!(document["permissions"]["allow"][0], "Bash(git status)");
582 assert_eq!(document["crossSessionInbound"], "accept");
583
584 let stale = update_claude_peer_settings(
585 &homes,
586 Some(ClaudeCrossSessionInbound::Hold),
587 Some(&before.revision),
588 )
589 .unwrap_err();
590 assert!(matches!(stale, ClaudePeerSettingsError::ChangedDuringWrite));
591
592 let reset = update_claude_peer_settings(&homes, None, Some(&updated.revision)).unwrap();
593 assert_eq!(reset.cross_session_inbound, None);
594 let reset_document: serde_json::Value =
595 serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap();
596 assert_eq!(reset_document["theme"], "dark");
597 assert!(reset_document.get("crossSessionInbound").is_none());
598 std::fs::remove_dir_all(root).ok();
599 }
600
601 #[cfg(unix)]
602 #[test]
603 fn explicit_peer_policy_update_refuses_a_symlinked_settings_file() {
604 use std::os::unix::fs::symlink;
605
606 let root = temp_dir("settings-symlink");
607 let outside = root.join("outside.json");
608 std::fs::write(&outside, "{}\n").unwrap();
609 symlink(&outside, root.join("settings.json")).unwrap();
610
611 let error =
612 write_claude_peer_settings(&homes_for(&root), ClaudeCrossSessionInbound::Accept)
613 .unwrap_err();
614 assert!(matches!(error, ClaudePeerSettingsError::Invalid(_)));
615 assert_eq!(std::fs::read_to_string(outside).unwrap(), "{}\n");
616 std::fs::remove_dir_all(root).ok();
617 }
618
619 #[test]
620 fn registry_reports_live_records_and_drops_stale_ones() {
621 let root = temp_dir("registry");
622 let sessions = root.join("sessions");
623 std::fs::create_dir_all(&sessions).unwrap();
624 let live = std::process::id();
625 let dead = dead_pid();
626 write_record(&sessions, live, "live-session", "peer-live", "busy");
627 write_record(&sessions, dead, "dead-session", "peer-dead", "idle");
628 std::fs::write(
630 sessions.join("777.json"),
631 format!("{{\"pid\":{live},\"sessionId\":\"no-socket\",\"name\":\"peer-x\"}}"),
632 )
633 .unwrap();
634 std::fs::write(sessions.join("bad.json"), "{not json").unwrap();
635
636 let found = read_registry(&sessions);
637 assert_eq!(found.len(), 1, "{found:?}");
638 assert_eq!(found[0].session_id, "live-session");
639 assert_eq!(found[0].name, "peer-live");
640 assert_eq!(found[0].status, Some(ClaudePeerStatus::Busy));
641 assert_eq!(
642 found[0].socket_path,
643 PathBuf::from(format!("/tmp/cc-socks/{live}.sock"))
644 );
645 assert_eq!(registry_dir(&homes_for(&root)), sessions);
646 std::fs::remove_dir_all(root).ok();
647 }
648
649 #[test]
650 fn registry_keeps_live_peers_during_shell_tools_and_unknown_vendor_states() {
651 let root = temp_dir("registry-statuses");
652 let sessions = root.join("sessions");
653 std::fs::create_dir_all(&sessions).unwrap();
654 let live = std::process::id();
655 write_record(&sessions, live, "shell-session", "peer-shell", "shell");
656 let future = std::fs::read_to_string(sessions.join(format!("{live}.json")))
657 .unwrap()
658 .replace("shell-session", "future-session")
659 .replace("peer-shell", "peer-future")
660 .replace("\"status\":\"shell\"", "\"status\":\"future-status\"");
661 std::fs::write(sessions.join("future.json"), future).unwrap();
662
663 let found = read_registry(&sessions);
664 assert_eq!(found.len(), 2, "a vendor status must not erase a live peer");
665 let shell = found
666 .iter()
667 .find(|peer| peer.session_id == "shell-session")
668 .unwrap();
669 let future = found
670 .iter()
671 .find(|peer| peer.session_id == "future-session")
672 .unwrap();
673 assert_eq!(shell.status, Some(ClaudePeerStatus::Busy));
674 assert_eq!(future.status, None);
675 std::fs::remove_dir_all(root).ok();
676 }
677
678 #[test]
679 fn a_persisted_only_session_refuses_with_not_live() {
680 let root = temp_dir("not-live");
681 std::fs::create_dir_all(root.join("sessions")).unwrap();
682 write_record(
683 &root.join("sessions"),
684 dead_pid(),
685 "gone-session",
686 "peer-gone",
687 "idle",
688 );
689 let refusal = resolve_live_session(&homes_for(&root), "gone-session").unwrap_err();
690 assert_eq!(refusal.reason, ClaudePeerRefusal::NotLive);
691 std::fs::remove_dir_all(root).ok();
692 }
693
694 #[test]
695 fn a_name_shared_by_two_live_sessions_refuses_instead_of_guessing() {
696 let root = temp_dir("mismatch");
697 let sessions = root.join("sessions");
698 std::fs::create_dir_all(&sessions).unwrap();
699 let live = std::process::id();
700 write_record(&sessions, live, "wanted-session", "peer-shared", "idle");
701 std::fs::write(
704 sessions.join(format!("{}.json", live + 1)),
705 format!(
706 "{{\"pid\":{live},\"sessionId\":\"other-session\",\
707 \"messagingSocketPath\":\"/tmp/cc-socks/{live}.sock\",\"name\":\"peer-shared\"}}"
708 ),
709 )
710 .unwrap();
711
712 let refusal = resolve_live_session(&homes_for(&root), "wanted-session").unwrap_err();
713 assert_eq!(refusal.reason, ClaudePeerRefusal::IdentityMismatch);
714 assert!(refusal.message.contains("peer-shared"));
715 std::fs::remove_dir_all(root).ok();
716 }
717}