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(not(unix))]
413pub(crate) fn process_is_live(pid: u32) -> bool {
414    // Claude Code's peer messaging socket is a Unix socket; the registry is
415    // not addressable on Windows in the first place.
416    let _ = pid;
417    false
418}
419
420/// Why a message could not be delivered into a live session.
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum ClaudePeerRefusal {
423    /// No live process is running this session right now.
424    NotLive,
425    /// The registry name no longer resolves to the requested session.
426    IdentityMismatch,
427    /// The relay did not report the message as sent.
428    DeliveryFailed,
429}
430
431impl ClaudePeerRefusal {
432    /// Stable wire spelling.
433    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/// A refusal paired with the detail that names it.
443#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
444#[error("{message}")]
445pub struct ClaudePeerRefusalError {
446    /// Machine-readable reason.
447    pub reason: ClaudePeerRefusal,
448    /// Human-readable detail.
449    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
461/// Resolve `session_id` to the live Claude session running it.
462///
463/// The registry is re-read here rather than trusted from a discovery result,
464/// and the resolved name is checked back against the requested session id: a
465/// name that has moved to another live session must refuse, not deliver a
466/// message to the wrong reader.
467pub fn resolve_live_session(
468    homes: &HarnessHomes,
469    session_id: &str,
470) -> Result<ClaudePeerSession, ClaudePeerRefusalError> {
471    let registry = read_registry(&registry_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    /// A pid that is certainly gone: a process we started and reaped.
521    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        // A record from a version that publishes no socket is not addressable.
629        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        // Same derived name, different session: delivering here would put the
702        // message in front of the wrong reader.
703        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}