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//!    A live pid is still not the session's holder when Claude Code itself
18//!    says it is not: a window whose session was moved to the background keeps
19//!    its record with `parkedJobId` set and the status it had at that moment
20//!    (the background job registers its own record), and a pid the OS reused
21//!    after the session died fails the recorded `procStart`. Claude Code skips
22//!    both when it looks for a session's holder, and so do we.
23//! 2. **Nothing here writes the socket.** The socket path is documented, but
24//!    its wire frame is not, and a foreign process authenticating to it is not
25//!    a supported case. Messages reach a live session through a Claude relay
26//!    ([`crate::claude_relay`]), a headless Claude that sends with Claude's own
27//!    `SendMessage`.
28
29use std::path::{Path, PathBuf};
30use std::{fs::OpenOptions, io::Write};
31
32use serde::{Deserialize, Serialize};
33
34use crate::HarnessHomes;
35
36/// Activity a live Claude Code session reports for itself.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ClaudePeerStatus {
40    /// A turn is running.
41    Busy,
42    /// The turn ended; the session waits for its user's next prompt.
43    Idle,
44    /// A turn is blocked on its user: a permission prompt or a question.
45    Waiting,
46}
47
48impl ClaudePeerStatus {
49    /// Stable wire spelling.
50    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    /// Interpret Claude Code's registry spelling without making discovery
59    /// brittle to a newer status value. `shell` is published after a turn has
60    /// ended while a background shell the session started still runs: the
61    /// transcript's last record is the turn's `turn_duration`, and the session
62    /// waits at its prompt, so it is idle to a messenger.
63    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/// One live Claude Code session: a registry record whose pid answered
74/// `kill(pid, 0)` during the read that produced this value.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ClaudePeerSession {
77    /// Process holding the session.
78    pub pid: u32,
79    /// Claude-native session id, joinable to a discovered transcript.
80    pub session_id: String,
81    /// Working directory the session was started in.
82    pub cwd: Option<PathBuf>,
83    /// Registry display name; this is also the cross-session address.
84    pub name: String,
85    /// Unix socket the session binds for peer messaging.
86    pub socket_path: PathBuf,
87    /// Reported activity. Absent on sessions that never published one.
88    pub status: Option<ClaudePeerStatus>,
89    /// Registry update time in epoch milliseconds, when recorded.
90    pub updated_at_ms: Option<u64>,
91    /// Claude Code version that wrote the record.
92    pub version: Option<String>,
93    /// The tmux session and pane it runs in (`<session>:@<window>.%<pane>`),
94    /// when it runs in tmux.
95    pub tmux: Option<String>,
96}
97
98/// Directory holding the live-session registry for the configured Claude home.
99///
100/// [`HarnessHomes::claude_code`] points at `<claude home>/projects`, so the
101/// registry is that directory's sibling. Deriving it keeps one configuration
102/// knob (`CLAUDE_CONFIG_DIR`, through [`HarnessHomes`]) rather than adding a
103/// second that could disagree with it.
104pub fn registry_dir(homes: &HarnessHomes) -> PathBuf {
105    homes
106        .claude_code
107        .parent()
108        .unwrap_or(Path::new("."))
109        .join("sessions")
110}
111
112/// User-level policy Claude Code applies to messages from other sessions.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ClaudeCrossSessionInbound {
116    /// Deliver messages without a separate inbound approval.
117    Accept,
118    /// Queue messages for an explicit approval.
119    Hold,
120    /// Drop messages without delivering them.
121    Refuse,
122}
123
124impl ClaudeCrossSessionInbound {
125    /// Stable Claude settings spelling.
126    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/// The user-settings portion Supercode can inspect without pretending to know
136/// a target process's complete managed/project/CLI precedence stack.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138pub struct ClaudePeerSettings {
139    /// Exact user settings file read or written.
140    pub path: PathBuf,
141    /// Hash of the exact native bytes observed. Configure calls may use this
142    /// as an optimistic concurrency guard.
143    pub revision: String,
144    /// Explicit user value. `None` means Claude's permission-class default
145    /// remains in effect and can hold a message.
146    pub cross_session_inbound: Option<ClaudeCrossSessionInbound>,
147}
148
149impl ClaudePeerSettings {
150    /// True only when this user setting explicitly opts into automatic
151    /// delivery. A higher-precedence managed/project/CLI setting can still
152    /// override it, so callers must label this as user-level evidence.
153    pub fn user_allows_automatic_delivery(&self) -> bool {
154        self.cross_session_inbound == Some(ClaudeCrossSessionInbound::Accept)
155    }
156}
157
158/// Failure to read or safely update Claude Code's user settings.
159#[derive(Debug, thiserror::Error)]
160pub enum ClaudePeerSettingsError {
161    /// Filesystem access failed.
162    #[error("Claude Code settings I/O failed: {0}")]
163    Io(#[from] std::io::Error),
164    /// The existing settings file is not valid JSON.
165    #[error("Claude Code settings JSON is invalid: {0}")]
166    Json(#[from] serde_json::Error),
167    /// The document shape or setting value is not one Supercode can preserve.
168    #[error("{0}")]
169    Invalid(String),
170    /// Another process edited the file during Supercode's read-modify-write.
171    #[error("Claude Code settings changed while Volter Harness was updating them; retry the explicit configuration action")]
172    ChangedDuringWrite,
173}
174
175/// Claude Code's user settings file for the configured Claude home.
176pub 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
184/// Inspect only the user-level inbound setting. The report deliberately does
185/// not claim to be Claude's effective value because managed, project, and
186/// command-line settings can have higher precedence in a particular target.
187pub 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
232/// Explicitly update Claude Code's user-level inbound policy while preserving
233/// every unrelated setting. The write is atomic, refuses symlinks, and aborts
234/// when it observes an edit between its initial read and commit.
235pub 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
242/// Set or reset Claude Code's user-level inbound policy. `expected_revision`
243/// prevents an explicit UI action from overwriting settings inspected before
244/// another process changed the file.
245pub 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    // Keep the vendor-owned value as text here. Deserializing it directly as
355    // our closed enum made one newly introduced status discard the ENTIRE
356    // live peer record, including its safe endpoint and process evidence.
357    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
370/// Read every LIVE session from a Claude registry directory.
371///
372/// Records are skipped, never fatal, when the file is malformed, when it names
373/// no messaging socket, when its pid is gone or now belongs to another
374/// process, or when its window parked the session into a background job — a
375/// stale file left by a crashed session is exactly the case that must not be
376/// reported as live, and a parked window's status froze when it let go.
377pub 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/// Whether `pid` is still the process that wrote `recorded` as its
427/// `procStart`, in Claude Code's own spelling: `/proc/<pid>/stat`'s start
428/// time in clock ticks on Linux, `ps -o lstart=` under `TZ=UTC LC_ALL=C` on
429/// macOS. A start time this platform cannot read proves nothing and is not a
430/// mismatch.
431#[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    // `comm` may hold spaces and parentheses; fields resume after the last `)`.
437    // `starttime` is field 22, the 20th after `comm`.
438    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    // SAFETY: the buffer is exactly one `proc_bsdinfo`, the flavor's layout.
456    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    // SAFETY: `proc_pidinfo` filled all `size` bytes.
469    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    // SAFETY: `gmtime_r` writes one `tm`; `strftime` writes at most
476    // `text.len()` bytes into `text` from a NUL-terminated format.
477    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    // `ps` pads a one-digit day with a space; compare words, not spacing.
493    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    // SAFETY: signal 0 performs only a liveness/permission check.
504    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/// Why a message could not be delivered into a live session.
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum ClaudePeerRefusal {
535    /// No live process is running this session right now.
536    NotLive,
537    /// The registry name no longer resolves to the requested session.
538    IdentityMismatch,
539    /// The relay did not report the message as sent.
540    DeliveryFailed,
541}
542
543impl ClaudePeerRefusal {
544    /// Stable wire spelling.
545    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/// A refusal paired with the detail that names it.
555#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
556#[error("{message}")]
557pub struct ClaudePeerRefusalError {
558    /// Machine-readable reason.
559    pub reason: ClaudePeerRefusal,
560    /// Human-readable detail.
561    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
573/// Resolve `session_id` to the live Claude session running it.
574///
575/// The registry is re-read here rather than trusted from a discovery result,
576/// and the resolved name is checked back against the requested session id: a
577/// name that has moved to another live session must refuse, not deliver a
578/// message to the wrong reader.
579pub fn resolve_live_session(
580    homes: &HarnessHomes,
581    session_id: &str,
582) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
583    let registry = read_registry(&registry_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}