Skip to main content

supercode_harness/
claude_peer.rs

1//! Live Claude Code peer sessions: registry discovery and inbound policy.
2//!
3//! Claude Code is the one supported harness whose *running* interactive
4//! sessions are addressable. Each live process registers
5//! `~/.claude/sessions/<pid>.json` and binds the Unix socket named in it. The
6//! catalog ([`supercode_interchange::catalog`]) is deliberately about persisted state only, so
7//! nothing there may claim liveness; this module is the separate, explicitly
8//! process-checking half; it feeds session activity and the mail router's
9//! `native` door, which reach clients as a discovered descriptor's `activity`
10//! and `delivery`.
11//!
12//! Two rules earn their place here:
13//!
14//! 1. **A registry file is not a live session.** These files survive a crash,
15//!    so every read re-checks the recorded pid with `kill(pid, 0)` and drops
16//!    the record when the process is gone.
17//! 2. **Nothing here writes the socket.** The socket path is documented, but
18//!    its wire frame is not, and a foreign process authenticating to it is not
19//!    a supported case. Messages reach a live session through a Claude relay
20//!    ([`crate::claude_relay`]), a headless Claude that sends with Claude's own
21//!    `SendMessage`.
22
23use std::path::{Path, PathBuf};
24use std::{fs::OpenOptions, io::Write};
25
26use serde::{Deserialize, Serialize};
27
28use crate::HarnessHomes;
29
30/// Activity a live Claude Code session reports for itself.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ClaudePeerStatus {
34    /// A turn is running.
35    Busy,
36    /// The turn ended; the session waits for its user's next prompt.
37    Idle,
38    /// A turn is blocked on its user: a permission prompt or a question.
39    Waiting,
40}
41
42impl ClaudePeerStatus {
43    /// Stable wire spelling.
44    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    /// Interpret Claude Code's registry spelling without making discovery
53    /// brittle to a newer status value. `shell` is published after a turn has
54    /// ended while a background shell the session started still runs: the
55    /// transcript's last record is the turn's `turn_duration`, and the session
56    /// waits at its prompt, so it is idle to a messenger.
57    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/// One live Claude Code session: a registry record whose pid answered
68/// `kill(pid, 0)` during the read that produced this value.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ClaudePeerSession {
71    /// Process holding the session.
72    pub pid: u32,
73    /// Claude-native session id, joinable to a discovered transcript.
74    pub session_id: String,
75    /// Working directory the session was started in.
76    pub cwd: Option<PathBuf>,
77    /// Registry display name; this is also the cross-session address.
78    pub name: String,
79    /// Unix socket the session binds for peer messaging.
80    pub socket_path: PathBuf,
81    /// Reported activity. Absent on sessions that never published one.
82    pub status: Option<ClaudePeerStatus>,
83    /// Registry update time in epoch milliseconds, when recorded.
84    pub updated_at_ms: Option<u64>,
85    /// Claude Code version that wrote the record.
86    pub version: Option<String>,
87    /// The tmux session and pane it runs in (`<session>:@<window>.%<pane>`),
88    /// when it runs in tmux.
89    pub tmux: Option<String>,
90}
91
92/// Directory holding the live-session registry for the configured Claude home.
93///
94/// [`HarnessHomes::claude_code`] points at `<claude home>/projects`, so the
95/// registry is that directory's sibling. Deriving it keeps one configuration
96/// knob (`CLAUDE_CONFIG_DIR`, through [`HarnessHomes`]) rather than adding a
97/// second that could disagree with it.
98pub fn registry_dir(homes: &HarnessHomes) -> PathBuf {
99    homes
100        .claude_code
101        .parent()
102        .unwrap_or(Path::new("."))
103        .join("sessions")
104}
105
106/// User-level policy Claude Code applies to messages from other sessions.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum ClaudeCrossSessionInbound {
110    /// Deliver messages without a separate inbound approval.
111    Accept,
112    /// Queue messages for an explicit approval.
113    Hold,
114    /// Drop messages without delivering them.
115    Refuse,
116}
117
118impl ClaudeCrossSessionInbound {
119    /// Stable Claude settings spelling.
120    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/// The user-settings portion Supercode can inspect without pretending to know
130/// a target process's complete managed/project/CLI precedence stack.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
132pub struct ClaudePeerSettings {
133    /// Exact user settings file read or written.
134    pub path: PathBuf,
135    /// Hash of the exact native bytes observed. Configure calls may use this
136    /// as an optimistic concurrency guard.
137    pub revision: String,
138    /// Explicit user value. `None` means Claude's permission-class default
139    /// remains in effect and can hold a message.
140    pub cross_session_inbound: Option<ClaudeCrossSessionInbound>,
141}
142
143impl ClaudePeerSettings {
144    /// True only when this user setting explicitly opts into automatic
145    /// delivery. A higher-precedence managed/project/CLI setting can still
146    /// override it, so callers must label this as user-level evidence.
147    pub fn user_allows_automatic_delivery(&self) -> bool {
148        self.cross_session_inbound == Some(ClaudeCrossSessionInbound::Accept)
149    }
150}
151
152/// Failure to read or safely update Claude Code's user settings.
153#[derive(Debug, thiserror::Error)]
154pub enum ClaudePeerSettingsError {
155    /// Filesystem access failed.
156    #[error("Claude Code settings I/O failed: {0}")]
157    Io(#[from] std::io::Error),
158    /// The existing settings file is not valid JSON.
159    #[error("Claude Code settings JSON is invalid: {0}")]
160    Json(#[from] serde_json::Error),
161    /// The document shape or setting value is not one Supercode can preserve.
162    #[error("{0}")]
163    Invalid(String),
164    /// Another process edited the file during Supercode's read-modify-write.
165    #[error("Claude Code settings changed while Volter Harness was updating them; retry the explicit configuration action")]
166    ChangedDuringWrite,
167}
168
169/// Claude Code's user settings file for the configured Claude home.
170pub 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
178/// Inspect only the user-level inbound setting. The report deliberately does
179/// not claim to be Claude's effective value because managed, project, and
180/// command-line settings can have higher precedence in a particular target.
181pub 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
226/// Explicitly update Claude Code's user-level inbound policy while preserving
227/// every unrelated setting. The write is atomic, refuses symlinks, and aborts
228/// when it observes an edit between its initial read and commit.
229pub 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
236/// Set or reset Claude Code's user-level inbound policy. `expected_revision`
237/// prevents an explicit UI action from overwriting settings inspected before
238/// another process changed the file.
239pub 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    // Keep the vendor-owned value as text here. Deserializing it directly as
349    // our closed enum made one newly introduced status discard the ENTIRE
350    // live peer record, including its safe endpoint and process evidence.
351    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
360/// Read every LIVE session from a Claude registry directory.
361///
362/// Records are skipped, never fatal, when the file is malformed, when it names
363/// no messaging socket, or when its pid is gone — a stale file left by a
364/// crashed session is exactly the case that must not be reported as live.
365pub 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    // SAFETY: signal 0 performs only a liveness/permission check.
409    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/// Why a message could not be delivered into a live session.
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum ClaudePeerRefusal {
440    /// No live process is running this session right now.
441    NotLive,
442    /// The registry name no longer resolves to the requested session.
443    IdentityMismatch,
444    /// The relay did not report the message as sent.
445    DeliveryFailed,
446}
447
448impl ClaudePeerRefusal {
449    /// Stable wire spelling.
450    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/// A refusal paired with the detail that names it.
460#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
461#[error("{message}")]
462pub struct ClaudePeerRefusalError {
463    /// Machine-readable reason.
464    pub reason: ClaudePeerRefusal,
465    /// Human-readable detail.
466    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
478/// Resolve `session_id` to the live Claude session running it.
479///
480/// The registry is re-read here rather than trusted from a discovery result,
481/// and the resolved name is checked back against the requested session id: a
482/// name that has moved to another live session must refuse, not deliver a
483/// message to the wrong reader.
484pub fn resolve_live_session(
485    homes: &HarnessHomes,
486    session_id: &str,
487) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
488    let registry = read_registry(&registry_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}