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> {
58 match value {
59 "busy" => Some(Self::Busy),
60 "idle" | "shell" => Some(Self::Idle),
61 "waiting" => Some(Self::Waiting),
62 _ => None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ClaudePeerSession {
71 pub pid: u32,
73 pub session_id: String,
75 pub cwd: Option<PathBuf>,
77 pub name: String,
79 pub socket_path: PathBuf,
81 pub status: Option<ClaudePeerStatus>,
83 pub updated_at_ms: Option<u64>,
85 pub version: Option<String>,
87 pub tmux: Option<String>,
90}
91
92pub fn registry_dir(homes: &HarnessHomes) -> PathBuf {
99 homes
100 .claude_code
101 .parent()
102 .unwrap_or(Path::new("."))
103 .join("sessions")
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum ClaudeCrossSessionInbound {
110 Accept,
112 Hold,
114 Refuse,
116}
117
118impl ClaudeCrossSessionInbound {
119 pub const fn as_str(self) -> &'static str {
121 match self {
122 Self::Accept => "accept",
123 Self::Hold => "hold",
124 Self::Refuse => "refuse",
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
132pub struct ClaudePeerSettings {
133 pub path: PathBuf,
135 pub revision: String,
138 pub cross_session_inbound: Option<ClaudeCrossSessionInbound>,
141}
142
143impl ClaudePeerSettings {
144 pub fn user_allows_automatic_delivery(&self) -> bool {
148 self.cross_session_inbound == Some(ClaudeCrossSessionInbound::Accept)
149 }
150}
151
152#[derive(Debug, thiserror::Error)]
154pub enum ClaudePeerSettingsError {
155 #[error("Claude Code settings I/O failed: {0}")]
157 Io(#[from] std::io::Error),
158 #[error("Claude Code settings JSON is invalid: {0}")]
160 Json(#[from] serde_json::Error),
161 #[error("{0}")]
163 Invalid(String),
164 #[error("Claude Code settings changed while Volter Harness was updating them; retry the explicit configuration action")]
166 ChangedDuringWrite,
167}
168
169pub fn user_settings_path(homes: &HarnessHomes) -> PathBuf {
171 homes
172 .claude_code
173 .parent()
174 .unwrap_or(Path::new("."))
175 .join("settings.json")
176}
177
178pub fn read_claude_peer_settings(
182 homes: &HarnessHomes,
183) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
184 let path = user_settings_path(homes);
185 let bytes = match std::fs::read(&path) {
186 Ok(bytes) => bytes,
187 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
188 Err(error) => return Err(error.into()),
189 };
190 let value = if bytes.is_empty() {
191 serde_json::Value::Object(serde_json::Map::new())
192 } else {
193 serde_json::from_slice(&bytes)?
194 };
195 let object = value.as_object().ok_or_else(|| {
196 ClaudePeerSettingsError::Invalid(format!(
197 "Claude Code settings at {} must be a JSON object",
198 path.display()
199 ))
200 })?;
201 let cross_session_inbound = match object.get("crossSessionInbound") {
202 None => None,
203 Some(serde_json::Value::String(value)) if value == "accept" => {
204 Some(ClaudeCrossSessionInbound::Accept)
205 }
206 Some(serde_json::Value::String(value)) if value == "hold" => {
207 Some(ClaudeCrossSessionInbound::Hold)
208 }
209 Some(serde_json::Value::String(value)) if value == "refuse" => {
210 Some(ClaudeCrossSessionInbound::Refuse)
211 }
212 Some(value) => {
213 return Err(ClaudePeerSettingsError::Invalid(format!(
214 "Claude Code setting `crossSessionInbound` at {} must be `accept`, `hold`, or `refuse`, not {value}",
215 path.display()
216 )))
217 }
218 };
219 Ok(ClaudePeerSettings {
220 path,
221 revision: blake3::hash(&bytes).to_hex().to_string(),
222 cross_session_inbound,
223 })
224}
225
226pub fn write_claude_peer_settings(
230 homes: &HarnessHomes,
231 cross_session_inbound: ClaudeCrossSessionInbound,
232) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
233 update_claude_peer_settings(homes, Some(cross_session_inbound), None)
234}
235
236pub fn update_claude_peer_settings(
240 homes: &HarnessHomes,
241 cross_session_inbound: Option<ClaudeCrossSessionInbound>,
242 expected_revision: Option<&str>,
243) -> Result<ClaudePeerSettings, ClaudePeerSettingsError> {
244 let path = user_settings_path(homes);
245 if std::fs::symlink_metadata(&path)
246 .map(|metadata| metadata.file_type().is_symlink())
247 .unwrap_or(false)
248 {
249 return Err(ClaudePeerSettingsError::Invalid(format!(
250 "refusing to replace symlinked Claude Code settings at {}",
251 path.display()
252 )));
253 }
254 let original = match std::fs::read(&path) {
255 Ok(bytes) => bytes,
256 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
257 Err(error) => return Err(error.into()),
258 };
259 let original_revision = blake3::hash(&original).to_hex().to_string();
260 if expected_revision.is_some_and(|expected| expected != original_revision) {
261 return Err(ClaudePeerSettingsError::ChangedDuringWrite);
262 }
263 let mut value = if original.is_empty() {
264 serde_json::Value::Object(serde_json::Map::new())
265 } else {
266 serde_json::from_slice(&original)?
267 };
268 let object = value.as_object_mut().ok_or_else(|| {
269 ClaudePeerSettingsError::Invalid(format!(
270 "Claude Code settings at {} must be a JSON object",
271 path.display()
272 ))
273 })?;
274 let changed = match cross_session_inbound {
275 Some(value) => {
276 object.insert(
277 "crossSessionInbound".into(),
278 serde_json::Value::String(value.as_str().into()),
279 ) != Some(serde_json::Value::String(value.as_str().into()))
280 }
281 None => object.remove("crossSessionInbound").is_some(),
282 };
283 if !changed {
284 return read_claude_peer_settings(homes);
285 }
286 let mut encoded = serde_json::to_vec_pretty(&value)?;
287 encoded.push(b'\n');
288
289 let parent = path.parent().unwrap_or(Path::new("."));
290 std::fs::create_dir_all(parent)?;
291 let nonce = std::time::SystemTime::now()
292 .duration_since(std::time::UNIX_EPOCH)
293 .unwrap_or_default()
294 .as_nanos();
295 let temporary = parent.join(format!(
296 ".settings.json.supercode-{}-{nonce}.tmp",
297 std::process::id()
298 ));
299 let write_result = (|| -> Result<(), ClaudePeerSettingsError> {
300 let mut options = OpenOptions::new();
301 options.write(true).create_new(true);
302 #[cfg(unix)]
303 {
304 use std::os::unix::fs::OpenOptionsExt;
305 options.mode(0o600);
306 }
307 let mut file = options.open(&temporary)?;
308 #[cfg(unix)]
309 {
310 use std::os::unix::fs::{MetadataExt, PermissionsExt};
311 let mode = std::fs::metadata(&path)
312 .map(|metadata| metadata.mode() & 0o777)
313 .unwrap_or(0o600);
314 file.set_permissions(std::fs::Permissions::from_mode(mode))?;
315 }
316 file.write_all(&encoded)?;
317 file.sync_all()?;
318 let current = match std::fs::read(&path) {
319 Ok(bytes) => bytes,
320 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
321 Err(error) => return Err(error.into()),
322 };
323 if current != original {
324 return Err(ClaudePeerSettingsError::ChangedDuringWrite);
325 }
326 std::fs::rename(&temporary, &path)?;
327 Ok(())
328 })();
329 if write_result.is_err() {
330 std::fs::remove_file(&temporary).ok();
331 }
332 write_result?;
333 read_claude_peer_settings(homes)
334}
335
336#[derive(Deserialize)]
337struct RegistryRecord {
338 pid: u32,
339 #[serde(rename = "sessionId")]
340 session_id: String,
341 #[serde(default)]
342 cwd: Option<PathBuf>,
343 #[serde(default)]
344 name: Option<String>,
345 #[serde(rename = "messagingSocketPath", default)]
346 messaging_socket_path: Option<PathBuf>,
347 #[serde(default)]
348 status: Option<String>,
352 #[serde(rename = "updatedAt", default)]
353 updated_at: Option<u64>,
354 #[serde(default)]
355 version: Option<String>,
356 #[serde(default)]
357 tmux: Option<String>,
358}
359
360pub fn read_registry(directory: &Path) -> Vec<ClaudePeerSession> {
366 let Ok(entries) = std::fs::read_dir(directory) else {
367 return Vec::new();
368 };
369 let mut sessions = Vec::new();
370 for entry in entries.flatten() {
371 let path = entry.path();
372 if path.extension().and_then(|value| value.to_str()) != Some("json") {
373 continue;
374 }
375 let Ok(bytes) = std::fs::read(&path) else {
376 continue;
377 };
378 let Ok(record) = serde_json::from_slice::<RegistryRecord>(&bytes) else {
379 continue;
380 };
381 let (Some(name), Some(socket_path)) = (record.name, record.messaging_socket_path) else {
382 continue;
383 };
384 if record.session_id.is_empty() || name.is_empty() || !process_is_live(record.pid) {
385 continue;
386 }
387 sessions.push(ClaudePeerSession {
388 pid: record.pid,
389 session_id: record.session_id,
390 cwd: record.cwd,
391 name,
392 socket_path,
393 status: record
394 .status
395 .as_deref()
396 .and_then(ClaudePeerStatus::from_registry),
397 updated_at_ms: record.updated_at,
398 version: record.version,
399 tmux: record.tmux,
400 });
401 }
402 sessions.sort_by_key(|session| session.pid);
403 sessions
404}
405
406#[cfg(unix)]
407pub(crate) fn process_is_live(pid: u32) -> bool {
408 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
410 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
411}
412
413#[cfg(windows)]
414pub(crate) fn process_is_live(pid: u32) -> bool {
415 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
416 use windows_sys::Win32::System::Threading::{
417 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
418 };
419
420 let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
421 if process.is_null() {
422 return false;
423 }
424 let mut code = 0u32;
425 let read = unsafe { GetExitCodeProcess(process, &mut code) } != 0;
426 unsafe {
427 CloseHandle(process);
428 }
429 read && code == STILL_ACTIVE as u32
430}
431
432#[cfg(not(any(unix, windows)))]
433pub(crate) fn process_is_live(_pid: u32) -> bool {
434 false
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum ClaudePeerRefusal {
440 NotLive,
442 IdentityMismatch,
444 DeliveryFailed,
446}
447
448impl ClaudePeerRefusal {
449 pub const fn as_str(self) -> &'static str {
451 match self {
452 Self::NotLive => "not_live",
453 Self::IdentityMismatch => "identity_mismatch",
454 Self::DeliveryFailed => "delivery_failed",
455 }
456 }
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
461#[error("{message}")]
462pub struct ClaudePeerRefusalError {
463 pub reason: ClaudePeerRefusal,
465 pub message: String,
467}
468
469impl ClaudePeerRefusalError {
470 fn new(reason: ClaudePeerRefusal, message: impl Into<String>) -> Self {
471 Self {
472 reason,
473 message: message.into(),
474 }
475 }
476}
477
478pub fn resolve_live_session(
485 homes: &HarnessHomes,
486 session_id: &str,
487) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
488 let registry = read_registry(®istry_dir(homes));
489 let target = registry
490 .iter()
491 .find(|session| session.session_id == session_id)
492 .cloned()
493 .ok_or_else(|| {
494 ClaudePeerRefusalError::new(
495 ClaudePeerRefusal::NotLive,
496 format!(
497 "no live Claude Code process is running session `{session_id}`; \
498 its transcript is persisted only"
499 ),
500 )
501 })?;
502 let by_name = registry
503 .iter()
504 .filter(|session| session.name == target.name)
505 .collect::<Vec<_>>();
506 if by_name.len() != 1 || by_name[0].session_id != target.session_id {
507 return Err(ClaudePeerRefusalError::new(
508 ClaudePeerRefusal::IdentityMismatch,
509 format!(
510 "the registry name `{}` no longer resolves to session `{session_id}` alone; \
511 refusing rather than delivering into another session",
512 target.name
513 ),
514 ));
515 }
516
517 Ok(target)
518}