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 while Claude is
54    /// executing a shell tool, so it is active work from a messenger's point
55    /// of view just like `busy`.
56    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/// One live Claude Code session: a registry record whose pid answered
67/// `kill(pid, 0)` during the read that produced this value.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ClaudePeerSession {
70    /// Process holding the session.
71    pub pid: u32,
72    /// Claude-native session id, joinable to a discovered transcript.
73    pub session_id: String,
74    /// Working directory the session was started in.
75    pub cwd: Option<PathBuf>,
76    /// Registry display name; this is also the cross-session address.
77    pub name: String,
78    /// Unix socket the session binds for peer messaging.
79    pub socket_path: PathBuf,
80    /// Reported activity. Absent on sessions that never published one.
81    pub status: Option<ClaudePeerStatus>,
82    /// Registry update time in epoch milliseconds, when recorded.
83    pub updated_at_ms: Option<u64>,
84    /// Claude Code version that wrote the record.
85    pub version: Option<String>,
86    /// The tmux session and pane it runs in (`<session>:@<window>.%<pane>`),
87    /// when it runs in tmux.
88    pub tmux: Option<String>,
89}
90
91/// Directory holding the live-session registry for the configured Claude home.
92///
93/// [`HarnessHomes::claude_code`] points at `<claude home>/projects`, so the
94/// registry is that directory's sibling. Deriving it keeps one configuration
95/// knob (`CLAUDE_CONFIG_DIR`, through [`HarnessHomes`]) rather than adding a
96/// second that could disagree with it.
97pub fn registry_dir(homes: &HarnessHomes) -> PathBuf {
98    homes
99        .claude_code
100        .parent()
101        .unwrap_or(Path::new("."))
102        .join("sessions")
103}
104
105/// User-level policy Claude Code applies to messages from other sessions.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ClaudeCrossSessionInbound {
109    /// Deliver messages without a separate inbound approval.
110    Accept,
111    /// Queue messages for an explicit approval.
112    Hold,
113    /// Drop messages without delivering them.
114    Refuse,
115}
116
117impl ClaudeCrossSessionInbound {
118    /// Stable Claude settings spelling.
119    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/// The user-settings portion Supercode can inspect without pretending to know
129/// a target process's complete managed/project/CLI precedence stack.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131pub struct ClaudePeerSettings {
132    /// Exact user settings file read or written.
133    pub path: PathBuf,
134    /// Hash of the exact native bytes observed. Configure calls may use this
135    /// as an optimistic concurrency guard.
136    pub revision: String,
137    /// Explicit user value. `None` means Claude's permission-class default
138    /// remains in effect and can hold a message.
139    pub cross_session_inbound: Option<ClaudeCrossSessionInbound>,
140}
141
142impl ClaudePeerSettings {
143    /// True only when this user setting explicitly opts into automatic
144    /// delivery. A higher-precedence managed/project/CLI setting can still
145    /// override it, so callers must label this as user-level evidence.
146    pub fn user_allows_automatic_delivery(&self) -> bool {
147        self.cross_session_inbound == Some(ClaudeCrossSessionInbound::Accept)
148    }
149}
150
151/// Failure to read or safely update Claude Code's user settings.
152#[derive(Debug, thiserror::Error)]
153pub enum ClaudePeerSettingsError {
154    /// Filesystem access failed.
155    #[error("Claude Code settings I/O failed: {0}")]
156    Io(#[from] std::io::Error),
157    /// The existing settings file is not valid JSON.
158    #[error("Claude Code settings JSON is invalid: {0}")]
159    Json(#[from] serde_json::Error),
160    /// The document shape or setting value is not one Supercode can preserve.
161    #[error("{0}")]
162    Invalid(String),
163    /// Another process edited the file during Supercode's read-modify-write.
164    #[error("Claude Code settings changed while Volter Harness was updating them; retry the explicit configuration action")]
165    ChangedDuringWrite,
166}
167
168/// Claude Code's user settings file for the configured Claude home.
169pub 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
177/// Inspect only the user-level inbound setting. The report deliberately does
178/// not claim to be Claude's effective value because managed, project, and
179/// command-line settings can have higher precedence in a particular target.
180pub 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
225/// Explicitly update Claude Code's user-level inbound policy while preserving
226/// every unrelated setting. The write is atomic, refuses symlinks, and aborts
227/// when it observes an edit between its initial read and commit.
228pub 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
235/// Set or reset Claude Code's user-level inbound policy. `expected_revision`
236/// prevents an explicit UI action from overwriting settings inspected before
237/// another process changed the file.
238pub 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    // Keep the vendor-owned value as text here. Deserializing it directly as
348    // our closed enum made one newly introduced status discard the ENTIRE
349    // live peer record, including its safe endpoint and process evidence.
350    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
359/// Read every LIVE session from a Claude registry directory.
360///
361/// Records are skipped, never fatal, when the file is malformed, when it names
362/// no messaging socket, or when its pid is gone — a stale file left by a
363/// crashed session is exactly the case that must not be reported as live.
364pub 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    // SAFETY: signal 0 performs only a liveness/permission check.
408    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/// Why a message could not be delivered into a live session.
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum ClaudePeerRefusal {
439    /// No live process is running this session right now.
440    NotLive,
441    /// The registry name no longer resolves to the requested session.
442    IdentityMismatch,
443    /// The relay did not report the message as sent.
444    DeliveryFailed,
445}
446
447impl ClaudePeerRefusal {
448    /// Stable wire spelling.
449    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/// A refusal paired with the detail that names it.
459#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
460#[error("{message}")]
461pub struct ClaudePeerRefusalError {
462    /// Machine-readable reason.
463    pub reason: ClaudePeerRefusal,
464    /// Human-readable detail.
465    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
477/// Resolve `session_id` to the live Claude session running it.
478///
479/// The registry is re-read here rather than trusted from a discovery result,
480/// and the resolved name is checked back against the requested session id: a
481/// name that has moved to another live session must refuse, not deliver a
482/// message to the wrong reader.
483pub fn resolve_live_session(
484    homes: &HarnessHomes,
485    session_id: &str,
486) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
487    let registry = read_registry(&registry_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    /// A pid that is certainly gone: a process we started and reaped.
537    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        // A record from a version that publishes no socket is not addressable.
645        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        // Same derived name, different session: delivering here would put the
718        // message in front of the wrong reader.
719        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}