Skip to main content

wire/
session.rs

1//! Multi-session wire on one machine (v0.5.16).
2//!
3//! Problem: multiple Claude Code (or any agent harness) sessions on the
4//! same machine share a single `WIRE_HOME`, which means they share the
5//! same DID, same relay slot, same inbox JSONL, and same daemon. Peers
6//! have no way to address a specific session, and the operator can't
7//! tell which session sent what.
8//!
9//! Solution: a `wire session` subcommand that bootstraps **isolated**
10//! per-session `WIRE_HOME` trees. Each session gets its own identity,
11//! handle, relay slot, daemon, and inbox/outbox. Sessions pair with each
12//! other through the public relay (`wireup.net`) like any other peer —
13//! no protocol changes. The bilateral-pair gate from v0.5.14 still
14//! applies in both directions.
15//!
16//! Storage layout:
17//!
18//! ```text
19//! ~/.local/state/wire/sessions/
20//!   registry.json                — cwd → session_name map
21//!   <session-name>/               — full WIRE_HOME tree per session
22//!     config/wire/...
23//!     state/wire/...
24//! ```
25//!
26//! Naming: derived from `basename(cwd)` so re-opening the same project
27//! reuses the same session identity. Collisions across two different
28//! paths with the same basename get a 4-char SHA-256 path-hash suffix.
29
30use anyhow::{Context, Result, anyhow};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use sha2::{Digest, Sha256};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36
37use crate::endpoints::{Endpoint, EndpointScope, self_endpoints};
38
39/// Root directory under which all session WIRE_HOMEs live.
40///
41/// Honors `WIRE_HOME` for testing (sessions root becomes
42/// `$WIRE_HOME/sessions/`); otherwise:
43///   - Linux: `$XDG_STATE_HOME/wire/sessions/` (typically
44///     `~/.local/state/wire/sessions/`).
45///   - macOS / other Unix without XDG: falls back to
46///     `dirs::data_local_dir() / wire / sessions /`, which on macOS is
47///     `~/Library/Application Support/wire/sessions/`. This mirrors
48///     `config::state_dir`'s fallback so the two surfaces resolve to
49///     compatible roots on every platform.
50pub fn sessions_root() -> Result<PathBuf> {
51    if let Ok(home_str) = std::env::var("WIRE_HOME") {
52        let home = PathBuf::from(&home_str);
53        let direct = home.join("sessions");
54        if direct.exists() {
55            return Ok(direct);
56        }
57        // v0.6.4: inside-session fallback. When WIRE_HOME is set by the
58        // MCP auto-detect or `wire session env`, it points at one
59        // session's home (`<root>/sessions/<name>`) — *not* the root
60        // holding every session. Without this fallback, `wire mesh
61        // status` / `mesh role list` / `mesh broadcast` invoked from
62        // inside a session see zero sister sessions even though the
63        // operator can plainly see them with `wire session list`.
64        //
65        // Walk up to the nearest ancestor named `sessions` and return it.
66        // Handles BOTH the legacy `sessions/<name>` layout (parent named
67        // `sessions`) and the v0.13 `sessions/by-key/<hash>` layout (parent
68        // `by-key`, grandparent `sessions`). The old one-level parent check
69        // matched only the legacy layout, so an inside-session WIRE_HOME on
70        // v0.13 made sessions_root() point at a nonexistent nested dir —
71        // list-local / mesh / pair-all-local then saw zero sisters even
72        // though they were on disk. A WIRE_HOME with no `sessions` ancestor
73        // (plain test dir, custom location) falls through to the v0.6.3
74        // `<WIRE_HOME>/sessions/` behavior.
75        let mut anc = Some(home.as_path());
76        while let Some(p) = anc {
77            if p.file_name().and_then(|s| s.to_str()) == Some("sessions") {
78                return Ok(p.to_path_buf());
79            }
80            anc = p.parent();
81        }
82        return Ok(direct);
83    }
84    default_sessions_root()
85}
86
87/// The machine's DEFAULT sessions root — `sessions_root()` with the
88/// `WIRE_HOME` override deliberately ignored. This is where the real
89/// operator install lives even when the calling process runs under a
90/// temp/test `WIRE_HOME`. Used by `wire nuke`'s host guard, whose whole
91/// point is to see past the caller's env to what the machine-global
92/// teardown would actually hit.
93pub fn default_sessions_root() -> Result<PathBuf> {
94    let state = dirs::state_dir()
95        .or_else(dirs::data_local_dir)
96        .ok_or_else(|| {
97            anyhow!(
98                "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
99                 set WIRE_HOME or run on a platform with `dirs` support"
100            )
101        })?;
102    Ok(state.join("wire").join("sessions"))
103}
104
105/// Full filesystem path for a *named* session's WIRE_HOME root —
106/// `<sessions_root>/by-key/<hash>` where the by-key hash is derived
107/// from the (sanitized) operator-typed name. Inside this dir the
108/// standard wire layout applies: `config/wire/...` and `state/wire/...`.
109///
110/// RFC-006 Part A (1.0 format freeze): there is now exactly ONE physical
111/// session layout — `by-key/<hash>`. A named session's key is its name;
112/// an agent session's key is its session id. Both hash into the same
113/// store, so every reader (`list_sessions`, `find_session_home_by_name`,
114/// the supervisor) handles one shape instead of straddling the old
115/// `sessions/<name>/` top-level layout AND `by-key/<hash>/`.
116///
117/// Operator-facing CLI paths that accept a user-typed name should still
118/// prefer [`find_session_home_by_name`]: it resolves both a named
119/// session (key == name) AND an agent session typed by its DID-derived
120/// persona handle (key == session id, name on disk is the hash).
121pub fn session_dir(name: &str) -> Result<PathBuf> {
122    session_home_for_key(&sanitize_name(name))
123}
124
125/// Operator-facing session-name → home_dir resolver. RFC-006 Part A
126/// (1.0 format freeze) collapsed the two historical disk layouts into a
127/// single `by-key/<hash>` store, so this resolver now keys two *naming*
128/// conventions onto one physical layout:
129///
130/// 1. **Named session**: the operator typed the session NAME (e.g.
131///    `slancha-api`). A named session's by-key hash is derived from the
132///    name itself, so [`session_dir`] computes the home directly — no
133///    enumeration.
134/// 2. **Agent persona**: the operator typed the DID-derived persona
135///    HANDLE (`coral-weasel`, `agate-nimbus`) of an agent session whose
136///    key is its session id, not its name. The handle does not hash to
137///    the home, so we fall back to a [`list_sessions`] walk, which
138///    surfaces each by-key entry with `SessionInfo.name = handle`.
139///
140/// Order: compute the named-key home first (fast, no enumeration), then
141/// fall back to the handle walk. Returns `Ok(None)` when neither matches
142/// — the caller decides whether to error or no-op.
143///
144/// v0.14.2 (#170 follow-up from #174's PR body): operators running
145/// `wire daemon --session foo` from a tmux pane hit `session 'foo' not
146/// found` when the resolver only knew the literal top-level path. Post
147/// Part A both naming conventions resolve through the one by-key store.
148pub fn find_session_home_by_name(name: &str) -> Result<Option<PathBuf>> {
149    // 1. Named session: key == name → deterministic by-key home.
150    let direct = session_dir(name)?;
151    if direct.exists() {
152        return Ok(Some(direct));
153    }
154    // 2. Agent persona: typed name is the card-derived handle, not the
155    // session key. list_sessions overrides SessionInfo.name to the
156    // handle when the card is present; match against either the
157    // overridden name or the raw by-key hash.
158    let sanitized = sanitize_name(name);
159    for info in list_sessions().unwrap_or_default() {
160        if info.name == name
161            || info.name == sanitized
162            || info
163                .home_dir
164                .file_name()
165                .and_then(|s| s.to_str())
166                .map(|f| f == name)
167                .unwrap_or(false)
168        {
169            return Ok(Some(info.home_dir));
170        }
171    }
172    Ok(None)
173}
174
175/// Registry tracks `cwd → session_name` so repeated `wire session new`
176/// from the same project reuses the same identity instead of creating
177/// a fresh one each time. Lives at `<sessions_root>/registry.json`.
178pub fn registry_path() -> Result<PathBuf> {
179    Ok(sessions_root()?.join("registry.json"))
180}
181
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183pub struct SessionRegistry {
184    /// `cwd_absolute_path → session_name`. Absent if cwd has not been
185    /// associated with a session yet.
186    #[serde(default)]
187    pub by_cwd: HashMap<String, String>,
188}
189
190pub fn read_registry() -> Result<SessionRegistry> {
191    let path = registry_path()?;
192    if !path.exists() {
193        return Ok(SessionRegistry::default());
194    }
195    let bytes =
196        std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
197    serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
198}
199
200pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
201    let path = registry_path()?;
202    if let Some(parent) = path.parent() {
203        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
204    }
205    let body = serde_json::to_vec_pretty(reg)?;
206    // v0.7.0-alpha.8 (review-fix #7): atomic write via tmp+rename so
207    // concurrent unflocked readers (detect_session_wire_home,
208    // list_sessions, cmd_peers) never observe a 0-byte / truncated
209    // registry mid-write. Pre-alpha.8 used std::fs::write which
210    // truncates first — race window where readers saw empty JSON and
211    // fell back to default identity for the write duration.
212    let tmp = path.with_extension("json.tmp");
213    std::fs::write(&tmp, body).with_context(|| format!("writing tmp session registry {tmp:?}"))?;
214    std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
215    Ok(())
216}
217
218/// v0.7.0-alpha.3: flock'd read-modify-write of the session registry.
219///
220/// `write_registry` alone is not safe under concurrency — multiple MCP
221/// processes auto-initing in parallel each read an old snapshot, mutate
222/// their copy, and write back, losing N-1 updates. This helper acquires
223/// an exclusive flock on a sibling lockfile, re-reads inside the lock,
224/// applies the caller's modifier, writes atomically, and releases.
225///
226/// Modeled on `config::update_relay_state`. Lock contention is bounded:
227/// modifications are pure HashMap operations, write is whole-file at
228/// roughly the registry size (KBs, not MBs).
229pub fn update_registry<F>(modifier: F) -> Result<()>
230where
231    F: FnOnce(&mut SessionRegistry) -> Result<()>,
232{
233    use fs2::FileExt;
234    let path = registry_path()?;
235    if let Some(parent) = path.parent() {
236        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
237    }
238    let lock_path = path.with_extension("lock");
239    let lock_file = std::fs::OpenOptions::new()
240        .create(true)
241        .truncate(false)
242        .read(true)
243        .write(true)
244        .open(&lock_path)
245        .with_context(|| format!("opening {lock_path:?}"))?;
246    lock_file
247        .lock_exclusive()
248        .with_context(|| format!("flock {lock_path:?}"))?;
249    // Re-read INSIDE the lock — any prior snapshot would race.
250    let mut reg = read_registry().unwrap_or_default();
251    let result = modifier(&mut reg);
252    let write_result = if result.is_ok() {
253        write_registry(&reg)
254    } else {
255        Ok(())
256    };
257    let _ = fs2::FileExt::unlock(&lock_file);
258    result?;
259    write_result?;
260    Ok(())
261}
262
263/// Sanitize an arbitrary string to a session-name-safe form: lowercase
264/// ASCII alphanumeric + `-` + `_`, replace other chars with `-`,
265/// dedupe consecutive dashes, trim leading/trailing dashes, max 32 chars.
266pub fn sanitize_name(raw: &str) -> String {
267    let mut out = String::with_capacity(raw.len());
268    let mut prev_dash = false;
269    for c in raw.chars() {
270        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
271        let ch = if ok { c.to_ascii_lowercase() } else { '-' };
272        if ch == '-' {
273            if !prev_dash && !out.is_empty() {
274                out.push('-');
275            }
276            prev_dash = true;
277        } else {
278            out.push(ch);
279            prev_dash = false;
280        }
281    }
282    let trimmed = out.trim_matches('-').to_string();
283    if trimmed.is_empty() {
284        return "wire-session".to_string();
285    }
286    if trimmed.len() > 32 {
287        return trimmed[..32].trim_end_matches('-').to_string();
288    }
289    trimmed
290}
291
292/// Short hash suffix derived from the full absolute path of the cwd.
293/// Used to disambiguate two different projects whose basenames collide
294/// (e.g. `~/Source/wire` and `~/Archive/wire`).
295fn path_hash_suffix(cwd: &Path) -> String {
296    let bytes = cwd.as_os_str().to_string_lossy().into_owned();
297    let mut h = Sha256::new();
298    h.update(bytes.as_bytes());
299    let digest = h.finalize();
300    hex::encode(&digest[..2]) // 4 hex chars
301}
302
303/// v0.13.6: case-insensitive cwd-registry key on Windows.
304///
305/// Issue #30 (Willard repro): on Windows, two terminals in the "same"
306/// project under different drive/path casing (`C:\Foo\Bar` vs
307/// `C:\foo\bar`) hashed to DIFFERENT registry keys — the second
308/// terminal's `wire whoami` missed the registry lookup, derived a
309/// phantom name, and silently fell back to the legacy default identity
310/// (e.g. `did:wire:willard`). Both terminals collapsed onto one shared
311/// DID, every pairing attempt between them was a self-pair, and
312/// bilateral handshake could never complete.
313///
314/// Fix: on Windows, lowercase the cwd before reading from OR writing to
315/// the cwd→session map. Two paths that resolve to the same on-disk
316/// directory now produce the same registry key regardless of how the
317/// shell / launcher capitalized them.
318///
319/// On case-sensitive filesystems (Linux / macOS HFS+ / case-sensitive
320/// APFS / NTFS in case-sensitive mode) the path is returned as-is —
321/// distinct casings legitimately point at distinct directories.
322///
323/// Used at every read and write of `SessionRegistry.by_cwd` so old
324/// non-canonical entries written by v0.13.5 still resolve under v0.13.6+
325/// later, and new entries written under v0.13.6+ are immediately canonical.
326pub fn normalize_cwd_key(path: &Path) -> String {
327    let s = path.to_string_lossy().into_owned();
328    if cfg!(windows) { s.to_lowercase() } else { s }
329}
330
331/// Derive a stable session name for the given cwd. Resolution order:
332///
333/// 1. If the registry already maps this cwd → name, return that name.
334/// 2. Else: candidate = sanitize(basename(cwd)). If the candidate is
335///    already mapped to a DIFFERENT cwd in the registry, append a
336///    4-char path-hash suffix to avoid collision.
337/// 3. If still a collision: append a numeric suffix `-2`, `-3`, ...
338///    until unique.
339pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
340    let cwd_key = normalize_cwd_key(cwd);
341    // Backward compat: O(n) normalized scan on read-miss.
342    //
343    // Per @laulpogan / coral-weasel correction on #67: a verbatim fallback
344    // (try the raw lookup string if the normalized lookup misses) only
345    // handles consistent-casing upgraders — it can't recover a
346    // mixed-case stored key (`C:\Users\Willard\...`) from a different-
347    // case lookup (`c:\users\willard\...`) because both raw and
348    // normalized lookup strings derive from the LOOKUP path; the
349    // stored key's original casing is unrecoverable from the lookup
350    // alone.
351    //
352    // The O(n) scan handles both cases:
353    //   - Consistent casing: normalize(stored) == cwd_key on the FIRST
354    //     `.get` (no scan needed; happy path is O(1)).
355    //   - Cross casing: stored "C:\Users\Willard" normalizes to
356    //     "c:\users\willard" == cwd_key → the scan resolves it.
357    //
358    // O(n) is over the per-machine session count (typically <20),
359    // hit only on the rare upgrader-misses-normalized-lookup case.
360    // New writes are normalized (see cli.rs insert sites) so the
361    // scan-cost shrinks to zero as old entries get touched.
362    if let Some(existing) = registry.by_cwd.get(&cwd_key).or_else(|| {
363        registry
364            .by_cwd
365            .iter()
366            .find(|(k, _)| normalize_cwd_key(Path::new(k)) == cwd_key)
367            .map(|(_, v)| v)
368    }) {
369        return existing.clone();
370    }
371    let base = cwd
372        .file_name()
373        .and_then(|s| s.to_str())
374        .map(sanitize_name)
375        .unwrap_or_else(|| "wire-session".to_string());
376    let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
377    if !occupied.contains(&base) {
378        return base;
379    }
380    let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
381    if !occupied.contains(&with_hash) {
382        return with_hash;
383    }
384    // Highly unlikely (would require a SHA-256 prefix collision plus an
385    // existing entry to claim it). Numeric tiebreaker as final fallback.
386    for n in 2..1000 {
387        let candidate = format!("{base}-{n}");
388        if !occupied.contains(&candidate) {
389            return candidate;
390        }
391    }
392    // Pathological fallback — every numbered slot is taken.
393    format!("{base}-{}-overflow", path_hash_suffix(cwd))
394}
395
396/// Summary of one on-disk session for `wire session list`.
397#[derive(Debug, Clone, Serialize)]
398pub struct SessionInfo {
399    pub name: String,
400    /// First cwd associated with this session in the registry. `None`
401    /// if the session was created without registry tracking (manual
402    /// `wire session new <name>`).
403    pub cwd: Option<String>,
404    pub home_dir: PathBuf,
405    pub did: Option<String>,
406    pub handle: Option<String>,
407    /// True if a `daemon.pid` file exists AND the recorded PID is
408    /// actually a live process (best-effort, not POSIX-portable but
409    /// matches the existing `wire status` / `wire doctor` checks).
410    pub daemon_running: bool,
411    /// Display character (nickname + emoji + color palette) derived from
412    /// the session's DID. `None` when the session has no agent-card yet
413    /// (pre-init). Lazy-computed at read time; never persisted to disk.
414    pub character: Option<crate::character::Character>,
415}
416
417/// Enumerate every on-disk session by reading `sessions_root()`. Cross-
418/// references the registry so each entry's `cwd` is filled in when known.
419/// v0.7.4: true iff the URL targets a loopback host (127.0.0.0/8 or
420/// [::1] or `localhost`). Used to detect "this Federation-scope slot
421/// is actually on a loopback relay" — those sessions are local-mesh
422/// candidates even though they're not tagged `local`.
423///
424/// Best-effort string match; we don't need full URL parsing for this
425/// because the relay URL is wire-controlled and follows a predictable
426/// shape (`http://<host>[:<port>][/path]`).
427fn url_is_loopback(url: &str) -> bool {
428    let lower = url.to_ascii_lowercase();
429    let after_scheme = match lower.split_once("://") {
430        Some((_, rest)) => rest,
431        None => lower.as_str(),
432    };
433    // Bracketed IPv6 literal: `[::1]:8771` keeps brackets in host slice.
434    if let Some(rest) = after_scheme.strip_prefix('[') {
435        return rest
436            .split_once(']')
437            .map(|(host, _)| host == "::1")
438            .unwrap_or(false);
439    }
440    let host = after_scheme.split(['/', ':']).next().unwrap_or("");
441    host == "localhost" || host == "127.0.0.1" || host.starts_with("127.")
442}
443
444/// v0.7.4: resolve an operator-typed name to a local sister session.
445/// Input may be the session NAME (e.g. `slancha-api`), the card
446/// HANDLE (usually equal to the name), or the character NICKNAME
447/// (e.g. `noble-slate`). Returns the session NAME suitable for the
448/// `--local-sister` add path. Case-insensitive. None on no match.
449///
450/// Designed for `wire add <input>` ergonomics — the operator should
451/// be able to type whatever face wire put on the peer (statusline
452/// nickname, session list emoji+name) and have wire find it.
453pub fn resolve_local_sister(input: &str) -> Option<String> {
454    let needle = input.trim();
455    if needle.is_empty() {
456        return None;
457    }
458    let sessions = list_sessions().ok()?;
459    for s in &sessions {
460        if s.name.eq_ignore_ascii_case(needle) {
461            return Some(s.name.clone());
462        }
463        if let Some(h) = &s.handle
464            && h.eq_ignore_ascii_case(needle)
465        {
466            return Some(s.name.clone());
467        }
468        if let Some(ch) = &s.character
469            && ch.nickname.eq_ignore_ascii_case(needle)
470        {
471            return Some(s.name.clone());
472        }
473    }
474    None
475}
476
477pub fn list_sessions() -> Result<Vec<SessionInfo>> {
478    let root = sessions_root()?;
479    if !root.exists() {
480        return Ok(Vec::new());
481    }
482    let registry = read_registry().unwrap_or_default();
483    // Reverse lookup: name → cwd. Used to annotate each SessionInfo.
484    let mut name_to_cwd: HashMap<String, String> = HashMap::new();
485    for (cwd, name) in &registry.by_cwd {
486        name_to_cwd.insert(name.clone(), cwd.clone());
487    }
488
489    // Build a SessionInfo from a home dir, labeled `name`. v0.11: character
490    // is purely DID-derived (local display.json overrides removed).
491    let mk = |path: PathBuf, name: String| -> SessionInfo {
492        let card_path = path.join("config").join("wire").join("agent-card.json");
493        let (did, handle) = read_card_identity(&card_path);
494        let daemon_running = check_daemon_live(&path);
495        let character = did.as_deref().map(crate::character::Character::from_did);
496        SessionInfo {
497            cwd: name_to_cwd.get(&name).cloned(),
498            name,
499            home_dir: path,
500            did,
501            handle,
502            daemon_running,
503            character,
504        }
505    };
506
507    let mut out = Vec::new();
508    for entry in std::fs::read_dir(&root)?.flatten() {
509        let path = entry.path();
510        if !path.is_dir() {
511            continue;
512        }
513        let name = match path.file_name().and_then(|s| s.to_str()) {
514            Some(s) => s.to_string(),
515            None => continue,
516        };
517        // RFC-006 Part A (1.0 format freeze): session homes live ONLY
518        // under `by-key/<hash>` — never at the top level. The `by-key`
519        // dir is a container, not a session; every other top-level entry
520        // (the `registry.json` sidecar, its lock/tmp, stray dirs) is
521        // ignored. Descend one level so same-box discovery (`list-local`
522        // / `pair-all-local`) sees the real homes.
523        if name != "by-key" {
524            continue;
525        }
526        for sub in std::fs::read_dir(&path)?.flatten() {
527            let sub_path = sub.path();
528            if !sub_path.is_dir() {
529                continue;
530            }
531            let hash = sub_path
532                .file_name()
533                .and_then(|s| s.to_str())
534                .unwrap_or("?")
535                .to_string();
536            let mut info = mk(sub_path, hash);
537            // E8 (v0.13.2): skip uninitialized by-key homes. maybe_adopt_
538            // session_wire_home creates the home dir on first resolution —
539            // before any identity exists — so transient/probe session keys
540            // that never `wire up` leave empty or agent-card-less homes.
541            // Without this filter they surfaced as phantom "?"-handle
542            // sisters in list-local, degrading the very discovery rc3
543            // fixed. No DID == no identity == not a session.
544            if info.did.is_none() {
545                continue;
546            }
547            // Prefer the persona handle as the display name when the home
548            // is initialized; fall back to the by-key hash otherwise.
549            if let Some(h) = info.handle.clone() {
550                info.name = h;
551            }
552            out.push(info);
553        }
554    }
555    out.sort_by(|a, b| a.name.cmp(&b.name));
556    Ok(out)
557}
558
559fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
560    let bytes = match std::fs::read(card_path) {
561        Ok(b) => b,
562        Err(_) => return (None, None),
563    };
564    let v: serde_json::Value = match serde_json::from_slice(&bytes) {
565        Ok(v) => v,
566        Err(_) => return (None, None),
567    };
568    let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
569    let handle = v
570        .get("handle")
571        .and_then(|x| x.as_str())
572        .map(str::to_string)
573        .or_else(|| {
574            did.as_ref()
575                .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
576        });
577    (did, handle)
578}
579
580/// Read a session home's daemon pid from `<home>/state/wire/daemon.pid`
581/// (path-based; does NOT consult WIRE_HOME). None if absent/corrupt. Used to
582/// enumerate which daemon pids legitimately belong to a session so orphan
583/// detection doesn't flag a sibling session's daemon (A2).
584pub fn session_daemon_pid(session_home: &Path) -> Option<u32> {
585    session_role_pid(session_home, "daemon")
586}
587
588/// Read a session home's `<role>.pid` (path-based, no WIRE_HOME read).
589/// Same JSON shape as `daemon.pid`. None if absent/corrupt.
590///
591/// #247 finding 4: lets the Windows identity-collision adapter walk
592/// `list_sessions()` × every inbox-owning role and reverse-map a
593/// candidate PID back to its serving `WIRE_HOME` without needing to
594/// read the remote process's environment.
595pub fn session_role_pid(session_home: &Path, role: &str) -> Option<u32> {
596    let pidfile = session_home
597        .join("state")
598        .join("wire")
599        .join(format!("{role}.pid"));
600    let bytes = std::fs::read(&pidfile).ok()?;
601    serde_json::from_slice::<serde_json::Value>(&bytes)
602        .ok()
603        .and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
604        .map(|p| p as u32)
605}
606
607fn check_daemon_live(session_home: &Path) -> bool {
608    session_daemon_pid(session_home)
609        .map(is_process_live)
610        .unwrap_or(false)
611}
612
613/// Walk every initialized session and read its `daemon.pid`; return a
614/// map from `pid → session_name`. Used by `wire status`'s orphan-pid
615/// annotation (#173 follow-up) so a supervisor child's pid — which
616/// no longer carries `--session <name>` in its cmdline post-#174 — is
617/// still correctly attributed to the session whose home it serves.
618///
619/// Cost: one filesystem read per session per status invocation. On a
620/// 133-session box that's 133 small reads (a few ms total) — bounded
621/// + acceptable. The map is fresh per call; no caching, no staleness.
622pub fn pid_to_session_map() -> HashMap<u32, String> {
623    let mut out = HashMap::new();
624    let sessions = match list_sessions() {
625        Ok(v) => v,
626        Err(_) => return out,
627    };
628    for info in sessions {
629        if let Some(pid) = session_daemon_pid(&info.home_dir) {
630            out.insert(pid, info.name);
631        }
632    }
633    out
634}
635
636fn is_process_live(pid: u32) -> bool {
637    // v0.7.3: delegate to the shared platform helper. The previous
638    // implementation shelled out to `kill -0` on non-Linux, which
639    // unconditionally failed on Windows (no `kill` binary) and made
640    // `wire session list` report every daemon as `down` regardless of
641    // actual liveness.
642    crate::platform::process_alive(pid)
643}
644
645/// Read a session's `relay.json` and return its `self.endpoints[]`
646/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
647/// is a best-effort discovery helper, not a verification tool. A pre-
648/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
649/// promotes those to a federation-only Endpoint, so the result is
650/// still meaningful for legacy sessions.
651///
652/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
653/// not the canonical filename (`config::relay_state_path` returns
654/// `relay.json`). The mis-named read silently no-op'd and
655/// `list-local` always returned an empty `local` map as a result.
656/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
657/// in the same release — that helper had the symmetric write-side
658/// bug, so the local endpoint never got persisted in the first place.
659pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
660    let path = session_home.join("config").join("wire").join("relay.json");
661    let bytes = match std::fs::read(&path) {
662        Ok(b) => b,
663        Err(_) => return Vec::new(),
664    };
665    let val: Value = match serde_json::from_slice(&bytes) {
666        Ok(v) => v,
667        Err(_) => return Vec::new(),
668    };
669    self_endpoints(&val)
670}
671
672/// Stripped view of a Local endpoint for tooling output. Drops
673/// `slot_token` because it is a bearer credential — exposing it
674/// through `wire session list-local --json` would risk accidental
675/// leak via logs, screenshots, or piped output. Routing code uses
676/// the full `Endpoint` from `relay.json` directly; this type
677/// is for human/JSON observation only.
678#[derive(Debug, Clone, Serialize)]
679pub struct LocalEndpointView {
680    pub relay_url: String,
681    pub slot_id: String,
682}
683
684/// One row of `wire session list-local` output: a session that has a
685/// Local-scope endpoint plus metadata to render it.
686#[derive(Debug, Clone, Serialize)]
687pub struct LocalSessionView {
688    pub name: String,
689    pub handle: Option<String>,
690    pub did: Option<String>,
691    pub cwd: Option<String>,
692    pub home_dir: PathBuf,
693    pub daemon_running: bool,
694    /// All Local-scope endpoints this session advertises (token redacted).
695    /// Most sessions have exactly one; multiple is permitted for multi-
696    /// relay setups.
697    pub local_endpoints: Vec<LocalEndpointView>,
698}
699
700/// Sessions with no Local endpoint — shown separately so the operator
701/// knows they exist but are federation-only.
702#[derive(Debug, Clone, Serialize)]
703pub struct FederationOnlySessionView {
704    pub name: String,
705    pub handle: Option<String>,
706    pub cwd: Option<String>,
707}
708
709/// Result shape for `wire session list-local`. `local` is grouped by
710/// the local-relay URL so output can render each cluster of mutually-
711/// reachable sister sessions together. `federation_only` lists the rest.
712#[derive(Debug, Clone, Serialize)]
713pub struct LocalSessionListing {
714    pub local: HashMap<String, Vec<LocalSessionView>>,
715    pub federation_only: Vec<FederationOnlySessionView>,
716}
717
718/// Build the listing for `wire session list-local` from current on-disk
719/// state. Read-only; no daemon contact, no relay probe.
720pub fn list_local_sessions() -> Result<LocalSessionListing> {
721    let sessions = list_sessions()?;
722    let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
723    let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
724
725    for s in sessions {
726        let endpoints = read_session_endpoints(&s.home_dir);
727        let local_eps: Vec<Endpoint> = endpoints
728            .into_iter()
729            .filter(|e| {
730                // v0.7.4: include any session whose endpoint URL is a
731                // loopback address even if it's tagged Federation, not
732                // Local. This catches the legitimate-but-misshapen case
733                // where `wire init --relay http://127.0.0.1:8771` was run
734                // without `--with-local`, leaving the session with a
735                // loopback federation slot that's effectively local-mesh-
736                // reachable. Pre-v0.7.4 the strict scope-only filter
737                // silently excluded those sessions from `pair-all-local`,
738                // making nickname-based pairing fail for no operator-
739                // visible reason.
740                matches!(e.scope, EndpointScope::Local)
741                    || (matches!(e.scope, EndpointScope::Federation)
742                        && url_is_loopback(&e.relay_url))
743            })
744            .collect();
745        if local_eps.is_empty() {
746            federation_only.push(FederationOnlySessionView {
747                name: s.name.clone(),
748                handle: s.handle.clone(),
749                cwd: s.cwd.clone(),
750            });
751            continue;
752        }
753        // Redacted view: drop slot_token before exposing through CLI.
754        let redacted: Vec<LocalEndpointView> = local_eps
755            .iter()
756            .map(|e| LocalEndpointView {
757                relay_url: e.relay_url.clone(),
758                slot_id: e.slot_id.clone(),
759            })
760            .collect();
761        // Group by relay_url. A session with two Local endpoints (rare —
762        // would mean two loopback relays) appears under each.
763        for ep in &local_eps {
764            local
765                .entry(ep.relay_url.clone())
766                .or_default()
767                .push(LocalSessionView {
768                    name: s.name.clone(),
769                    handle: s.handle.clone(),
770                    did: s.did.clone(),
771                    cwd: s.cwd.clone(),
772                    home_dir: s.home_dir.clone(),
773                    daemon_running: s.daemon_running,
774                    local_endpoints: redacted.clone(),
775                });
776        }
777    }
778    // Sort each group by session name so output is deterministic.
779    for group in local.values_mut() {
780        group.sort_by(|a, b| a.name.cmp(&b.name));
781    }
782    federation_only.sort_by(|a, b| a.name.cmp(&b.name));
783    Ok(LocalSessionListing {
784        local,
785        federation_only,
786    })
787}
788
789/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
790///
791/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
792/// registry. If a session is registered for this cwd AND its home
793/// directory still exists, return that home dir; otherwise None.
794///
795/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
796/// so a `wire whoami` / `wire monitor` invocation from a project cwd
797/// adopts that project's session identity automatically, instead of
798/// silently falling back to the machine default. The CLI parity is
799/// load-bearing: without it, the user-visible identity diverges
800/// between MCP and the terminal, and monitors pull machine-wide
801/// inboxes when the operator expected a per-session view.
802pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
803    let registry = read_registry().ok()?;
804    // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
805    // inherit their parent's wire identity (e.g.
806    // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
807    // Without this, subdirs all fell back to the machine-wide default
808    // identity, which silently collapsed multiple Claude sessions onto the
809    // same DID + character.
810    let mut probe: Option<&std::path::Path> = Some(cwd);
811    while let Some(path) = probe {
812        // Same O(n) normalized scan as derive_name_from_cwd: handles both
813        // consistent-casing and cross-casing upgraders. See the comment
814        // on derive_name_from_cwd for the rationale.
815        let path_str = normalize_cwd_key(path);
816        if let Some(session_name) = registry.by_cwd.get(&path_str).or_else(|| {
817            registry
818                .by_cwd
819                .iter()
820                .find(|(k, _)| normalize_cwd_key(Path::new(k)) == path_str)
821                .map(|(_, v)| v)
822        }) {
823            let session_home = session_dir(session_name).ok()?;
824            if session_home.exists() {
825                return Some(session_home);
826            }
827        }
828        probe = path.parent();
829    }
830    None
831}
832
833/// v0.13: resolve a stable per-session key — host-agnostic, with a Claude
834/// Code adapter and the path left open for other hosts. Order:
835///   1. `WIRE_SESSION_ID` — explicit universal override (any harness).
836///   2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per
837///      conversation; the same id the auto-memory system keys off).
838///   3. `CODEX_SESSION_ID` — OpenAI Codex CLI adapter. Stable per Codex
839///      thread (the same UUIDv7 emitted in `thread.started` and used as
840///      the rollout-file suffix under `$CODEX_HOME/sessions/`). Codex
841///      does not yet forward this var to MCP children out of the box —
842///      operators must set it via `[mcp_servers.<name>.env]` in
843///      `~/.codex/config.toml` (or upstream Codex must add it to the
844///      MCP child env). Wiring the name in advance means once Codex
845///      ships the env, wire picks it up with zero further code change.
846///   4. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` /
847///      `copilot`) adapter. Set by the Copilot CLI host for every
848///      session; stable per conversation; UUID-shaped.
849///   5. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based
850///      identity (stable per workspace).
851///   6. `None` — caller falls back to legacy cwd-detect (bare CLI /
852///      pre-v0.13 hosts). Future host adapters slot in before this.
853///
854/// Returns `(key, source-label)`.
855pub fn resolve_session_key() -> Option<(String, &'static str)> {
856    for (var, source) in [
857        ("WIRE_SESSION_ID", "override"),
858        ("CLAUDE_CODE_SESSION_ID", "claude-code"),
859        ("CODEX_SESSION_ID", "codex-cli"),
860        ("COPILOT_AGENT_SESSION_ID", "copilot-cli"),
861        ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"),
862    ] {
863        if let Ok(v) = std::env::var(var)
864            && valid_session_key(&v)
865        {
866            return Some((v.trim().to_string(), source));
867        }
868    }
869    // Claude Code adapter (host-agnostic fallback). On some platforms the MCP
870    // server process does not inherit CLAUDE_CODE_SESSION_ID and the MCP
871    // `initialize` handshake carries no session id, so the env checks above
872    // miss. Claude Code, however, writes `~/.claude/sessions/<pid>.json`
873    // ({"sessionId":..., "cwd":...}) for each live session, named by the
874    // owning `claude` process PID. Walk our parent-process chain to that
875    // process and read its sessionId — deterministic, race-free, env-free.
876    if let Some(sid) = claude_code_session_from_pidfile() {
877        return Some((sid, "claude-code-pidfile"));
878    }
879
880    None
881}
882
883/// A session key from the environment is usable only if it is non-empty and is
884/// NOT an unexpanded `${...}` placeholder. A host that writes
885/// `"env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}` but doesn't expand
886/// it (Windows Claude Code passes the literal when the var is absent) would
887/// otherwise have wire hash the literal — collapsing every session onto one
888/// identity. Treat any `${...}` value as unset so resolution falls through to
889/// the PID-file adapter / per-process mint instead of a shared bogus persona.
890fn valid_session_key(v: &str) -> bool {
891    let v = v.trim();
892    !v.is_empty() && !v.contains("${")
893}
894
895/// A session-identity SPLIT: this process's OPERATIONAL identity (the home it is
896/// actually serving, via `config_dir`/`WIRE_HOME`) disagrees with the LIVE
897/// Claude session (resolved fresh from the PID-file). The "displayed name ≠
898/// operational name" bug — a long-lived wire process (usually the MCP server)
899/// frozen to a stale/minted identity while the live Claude session moved on
900/// (e.g. across a resume). Comparing the SERVED identity (not an env key) is
901/// what lets the frozen process itself detect the split: its env key may be
902/// unset (minted) or stale, but the home it serves is concrete — the earlier
903/// env-vs-pidfile check ran in a fresh CLI whose env was already live, so it
904/// never saw the frozen MCP's stale identity at all.
905#[derive(Debug, Clone)]
906pub struct IdentitySplit {
907    /// How this process resolved its session (`session_source()`).
908    pub source: &'static str,
909    /// The handle this process is actually operating (and sending mail) as.
910    pub operational_handle: Option<String>,
911    /// The handle the live Claude session resolves to right now.
912    pub live_handle: Option<String>,
913}
914
915/// Handle served by an initialized by-key home for `key` (DID-derived). `None`
916/// when that home has no agent-card yet (uninitialized session).
917///
918/// Hash the RAW key — homes are created via `session_home_for_key(&key)` on the
919/// raw resolved key (see `maybe_adopt_session_wire_home`). `sanitize_name`
920/// truncates to 32 chars, so sanitizing here would hash a DIFFERENT string than
921/// a 36-char session-id uuid's home and silently miss it (the split then never
922/// surfaces). Keep this in lock-step with home creation: raw key, no sanitize.
923fn handle_for_key(key: &str) -> Option<String> {
924    let home = session_home_for_key(key).ok()?;
925    let card = home.join("config").join("wire").join("agent-card.json");
926    read_card_identity(&card).1
927}
928
929/// The handle this process is CURRENTLY operating as — read from the agent-card
930/// in the home it resolved (`config_dir`, driven by `WIRE_HOME`). `None` when
931/// uninitialized. For a frozen MCP this is the stale identity it froze to,
932/// independent of any env key.
933fn current_operational_handle() -> Option<String> {
934    let card = crate::config::read_agent_card().ok()?;
935    let did = card.get("did").and_then(Value::as_str)?;
936    let handle = card
937        .get("handle")
938        .and_then(Value::as_str)
939        .map(str::to_string)
940        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(did).to_string());
941    Some(handle)
942}
943
944/// Pure split decision: `Some` iff both handles are known and differ. Extracted
945/// so the policy is unit-testable without process env / real homes.
946fn identity_split_between(
947    operational: Option<String>,
948    live: Option<String>,
949    source: &'static str,
950) -> Option<IdentitySplit> {
951    match (&operational, &live) {
952        (Some(op), Some(lv)) if op != lv => Some(IdentitySplit {
953            source,
954            operational_handle: operational,
955            live_handle: live,
956        }),
957        _ => None,
958    }
959}
960
961/// An explicit operator `WIRE_HOME` pin — the RFC-008 §C "deliberate fleet-
962/// share" contract, where several sessions intentionally present as ONE shared
963/// identity. In that mode the served identity legitimately differs from the
964/// per-session one, so a "split" there is BY DESIGN, not the frozen-process bug.
965/// (`SESSION_SOURCE` is set to these exact labels only when `WIRE_HOME` was
966/// already in the env at adopt time — never for wire's own `minted`/`claude-*`
967/// resolution, which is the case the split detector targets.)
968fn is_deliberate_home_pin(source: &str) -> bool {
969    source == "env:WIRE_HOME" || source == "env:WIRE_HOME_FORCE"
970}
971
972/// Detect a session-identity split (see [`IdentitySplit`]). `None` in the
973/// healthy case (served identity == live Claude session), when either side is
974/// unresolvable (bare CLI, uninitialized), or when the operator DELIBERATELY
975/// pinned `WIRE_HOME` to share one identity across sessions (not a bug). Run
976/// INSIDE a long-lived MCP/daemon this catches the frozen-identity case the old
977/// env-vs-pidfile check missed: the served home is stale, the PID-file is live,
978/// they differ. Reads the (cached) parent-chain PID-files — cheap on the
979/// session-start health check, not a per-message hot path.
980pub fn detect_identity_split() -> Option<IdentitySplit> {
981    let source = session_source();
982    // Deliberate fleet-share (operator WIRE_HOME pin) is intentional, not a
983    // split — suppress it, else every such session cries wolf forever.
984    if is_deliberate_home_pin(source) {
985        return None;
986    }
987    let operational = current_operational_handle();
988    let live = claude_code_session_from_pidfile().and_then(|k| handle_for_key(&k));
989    identity_split_between(operational, live, source)
990}
991
992#[cfg(test)]
993mod split_tests {
994    use super::*;
995    use std::io::Write;
996    use std::time::Duration;
997
998    #[test]
999    fn identity_split_only_when_both_known_and_differ() {
1000        // healthy: served identity == live session → no split
1001        assert!(
1002            identity_split_between(
1003                Some("verdant-palm".into()),
1004                Some("verdant-palm".into()),
1005                "claude-code",
1006            )
1007            .is_none()
1008        );
1009        // the real bug: served (frozen) != live
1010        let s = identity_split_between(
1011            Some("merry-spindle".into()),
1012            Some("daydream-gorge".into()),
1013            "minted",
1014        )
1015        .expect("differing handles ⇒ split");
1016        assert_eq!(s.operational_handle.as_deref(), Some("merry-spindle"));
1017        assert_eq!(s.live_handle.as_deref(), Some("daydream-gorge"));
1018        // either side unknown ⇒ never cry wolf
1019        assert!(identity_split_between(None, Some("x".into()), "s").is_none());
1020        assert!(identity_split_between(Some("x".into()), None, "s").is_none());
1021    }
1022
1023    #[test]
1024    fn ancestor_chain_is_bounded_and_stops_at_root() {
1025        let parents = |p: u32| match p {
1026            10 => Some(5),
1027            5 => Some(1),
1028            _ => None,
1029        };
1030        assert_eq!(ancestor_chain(10, 16, parents), vec![10, 5, 1]);
1031        // `max` caps the walk even when the chain continues.
1032        assert_eq!(ancestor_chain(10, 2, parents), vec![10, 5]);
1033    }
1034
1035    #[test]
1036    fn retry_recovers_a_pidfile_that_arrives_late() {
1037        // The startup-race case: the pidfile lands mid-retry, not before.
1038        let dir = tempfile::tempdir().unwrap();
1039        let d = dir.path().to_path_buf();
1040        let writer = std::thread::spawn(move || {
1041            std::thread::sleep(Duration::from_millis(40));
1042            let mut f = std::fs::File::create(d.join("4242.json")).unwrap();
1043            f.write_all(br#"{"sessionId":"late-arriving-id"}"#).unwrap();
1044        });
1045        let got = resolve_session_id_from_chain(dir.path(), &[4242], 30, Duration::from_millis(10));
1046        writer.join().unwrap();
1047        assert_eq!(got.as_deref(), Some("late-arriving-id"));
1048    }
1049
1050    #[test]
1051    fn resolve_returns_none_when_no_pidfile_ever() {
1052        let dir = tempfile::tempdir().unwrap();
1053        assert!(
1054            resolve_session_id_from_chain(dir.path(), &[1, 2, 3], 3, Duration::from_millis(1))
1055                .is_none()
1056        );
1057    }
1058
1059    #[test]
1060    fn read_session_id_parses_first_present_and_skips_empty() {
1061        let dir = tempfile::tempdir().unwrap();
1062        std::fs::write(dir.path().join("7.json"), br#"{"sessionId":"  sid-7  "}"#).unwrap();
1063        std::fs::write(dir.path().join("8.json"), br#"{"sessionId":""}"#).unwrap();
1064        assert_eq!(
1065            read_session_id_from_pidfile(dir.path(), 7).as_deref(),
1066            Some("sid-7")
1067        );
1068        assert!(read_session_id_from_pidfile(dir.path(), 8).is_none());
1069        assert!(read_session_id_from_pidfile(dir.path(), 9).is_none());
1070    }
1071
1072    #[test]
1073    fn handle_for_key_home_matches_raw_key_home() {
1074        // Regression (in-situ gate catch): a 36-char session-id uuid is longer
1075        // than sanitize_name's 32-char cap, so hashing the sanitized form yields
1076        // a DIFFERENT by-key home than creation (which hashes the raw key). If
1077        // handle_for_key sanitized, it would look in a home nothing wrote →
1078        // detect_identity_split silently returns None. Lock the invariant: the
1079        // home for a raw key is named by `by_key_dir_name(raw)`, and that differs
1080        // from the sanitized form for a >32-char key.
1081        let uuid = "7122130f-0a1b-431b-93d3-7818585770af";
1082        assert_eq!(uuid.len(), 36);
1083        assert_ne!(
1084            by_key_dir_name(uuid),
1085            by_key_dir_name(&sanitize_name(uuid)),
1086            "sanitize truncates a 36-char key; handle_for_key must hash raw"
1087        );
1088        let home = session_home_for_key(uuid).unwrap();
1089        assert_eq!(
1090            home.file_name().unwrap().to_str().unwrap(),
1091            by_key_dir_name(uuid),
1092            "home name must be by_key_dir_name(raw key)"
1093        );
1094    }
1095
1096    #[test]
1097    fn handle_for_key_reads_raw_key_home_not_truncated() {
1098        // The load-bearing regression guard: drive handle_for_key itself. A
1099        // 36-char session-id uuid exceeds sanitize_name's 32-char cap, so a
1100        // sanitized lookup lands on a DIFFERENT home than creation wrote. If
1101        // handle_for_key re-added sanitize_name, the raw-key assert below returns
1102        // None and this test fails — catching the exact bug the fix closed.
1103        let _guard = crate::config::test_support::ENV_LOCK
1104            .lock()
1105            .unwrap_or_else(|e| e.into_inner());
1106        let tmp = tempfile::tempdir().unwrap();
1107        // SAFETY: ENV_LOCK held — serializes all in-process env access.
1108        unsafe {
1109            std::env::set_var("WIRE_HOME", tmp.path());
1110        }
1111        // Make sessions_root() resolve under the tempdir (never real state).
1112        std::fs::create_dir_all(tmp.path().join("sessions")).unwrap();
1113        let uuid = "7122130f-0a1b-431b-93d3-7818585770af";
1114        let cfg = session_home_for_key(uuid)
1115            .unwrap()
1116            .join("config")
1117            .join("wire");
1118        std::fs::create_dir_all(&cfg).unwrap();
1119        std::fs::write(
1120            cfg.join("agent-card.json"),
1121            br#"{"did":"did:wire:merry-spindle-82f38897","handle":"merry-spindle"}"#,
1122        )
1123        .unwrap();
1124        // Raw-key lookup finds the card…
1125        assert_eq!(handle_for_key(uuid).as_deref(), Some("merry-spindle"));
1126        // …a sanitized (truncated) key lands on a different, empty home → None.
1127        assert_ne!(sanitize_name(uuid), uuid);
1128        assert!(handle_for_key(&sanitize_name(uuid)).is_none());
1129        // SAFETY: ENV_LOCK still held.
1130        unsafe {
1131            std::env::remove_var("WIRE_HOME");
1132        }
1133    }
1134
1135    #[test]
1136    fn deliberate_home_pin_suppresses_split() {
1137        // RFC-008 fleet-share (operator WIRE_HOME pin) is intentional, not a bug.
1138        assert!(is_deliberate_home_pin("env:WIRE_HOME"));
1139        assert!(is_deliberate_home_pin("env:WIRE_HOME_FORCE"));
1140        // wire's own resolution labels are NOT suppressed — that's the real bug.
1141        assert!(!is_deliberate_home_pin("minted"));
1142        assert!(!is_deliberate_home_pin("claude-code"));
1143        assert!(!is_deliberate_home_pin("claude-code-pidfile"));
1144    }
1145
1146    #[test]
1147    fn unexpanded_template_key_is_invalid() {
1148        // The ${...} literal that caused the historical collision stays rejected.
1149        assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
1150        assert!(valid_session_key("real-session-id"));
1151    }
1152}
1153
1154/// The by-key session-id read from a single `<dir>/<pid>.json` Claude PID-file.
1155/// `None` if absent / unparseable / empty. The shared atom of the one-shot and
1156/// retry readers below.
1157fn read_session_id_from_pidfile(dir: &Path, pid: u32) -> Option<String> {
1158    let txt = std::fs::read_to_string(dir.join(format!("{pid}.json"))).ok()?;
1159    let v: Value = serde_json::from_str(&txt).ok()?;
1160    let s = v.get("sessionId").and_then(Value::as_str)?.trim();
1161    (!s.is_empty()).then(|| s.to_string())
1162}
1163
1164/// The process ancestry `[start, parent, grandparent, …]`, up to `max` deep,
1165/// walked via the injected `parent_of` (so it is unit-testable without `ps`).
1166/// Stops early when `parent_of` returns `None`. Bounded — no `ps` re-fork after
1167/// this single walk (the retry reader below re-reads the cached pids only).
1168fn ancestor_chain(start_pid: u32, max: usize, parent_of: impl Fn(u32) -> Option<u32>) -> Vec<u32> {
1169    let mut chain = Vec::with_capacity(max.min(16));
1170    let mut pid = start_pid;
1171    for _ in 0..max {
1172        chain.push(pid);
1173        match parent_of(pid) {
1174            Some(p) => pid = p,
1175            None => break,
1176        }
1177    }
1178    chain
1179}
1180
1181/// Re-read the ancestor `chain`'s PID-files across a short backoff, returning
1182/// the first session id found. Pure over `dir`/`chain` so it is unit-testable
1183/// (tempdir + a late-arriving file). No `ps` calls here — the chain is walked
1184/// once by the caller; only the leaf file reads repeat (avoids the #353 Windows
1185/// per-hop perf trap).
1186fn resolve_session_id_from_chain(
1187    dir: &Path,
1188    chain: &[u32],
1189    attempts: u32,
1190    backoff: std::time::Duration,
1191) -> Option<String> {
1192    for i in 0..attempts.max(1) {
1193        if let Some(sid) = chain
1194            .iter()
1195            .find_map(|&pid| read_session_id_from_pidfile(dir, pid))
1196        {
1197            return Some(sid);
1198        }
1199        if i + 1 < attempts {
1200            std::thread::sleep(backoff);
1201        }
1202    }
1203    None
1204}
1205
1206/// The ancestor PID chain, walked ONCE per process and cached. A process's
1207/// parentage is immutable, so repeat identity checks (`detect_identity_split`
1208/// fires on every `wire_status`/`wire_whoami`) must re-read only the tiny
1209/// PID-files, never re-fork `ps`. This keeps the session-start hot path off the
1210/// per-call subprocess walk — the single bounded walk happens on first use
1211/// (typically already paid by startup resolution).
1212fn ancestor_chain_cached() -> &'static [u32] {
1213    static ANCESTOR_CHAIN: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
1214    ANCESTOR_CHAIN.get_or_init(|| ancestor_chain(std::process::id(), 16, parent_pid))
1215}
1216
1217/// Recover the Claude Code session id from the per-session PID-file when it
1218/// isn't available via the environment. Claude Code writes
1219/// `~/.claude/sessions/<pid>.json` = `{"sessionId": "...", "cwd": "...", ...}`
1220/// for each live session, keyed by the owning `claude` process PID. The MCP
1221/// server we run inside is a descendant of that process, so we walk our
1222/// parent chain (cached — no per-call `ps` re-fork) and return the `sessionId`
1223/// of the first ancestor that has a PID-file. Cross-platform.
1224fn claude_code_session_from_pidfile() -> Option<String> {
1225    let dir = dirs::home_dir()?.join(".claude").join("sessions");
1226    ancestor_chain_cached()
1227        .iter()
1228        .find_map(|&pid| read_session_id_from_pidfile(&dir, pid))
1229}
1230
1231/// Retry variant for the MCP-startup RACE: Claude may not have written its
1232/// session PID-file yet when the stdio MCP server boots, so a single read
1233/// misses and the caller would mint a throwaway identity (the "two names" bug).
1234/// Uses the cached ancestor chain (walked once), re-reading only those PID-files
1235/// across `attempts × backoff`. `None` if the live session is still
1236/// unrecoverable after the window.
1237fn claude_code_session_from_pidfile_retry(
1238    attempts: u32,
1239    backoff: std::time::Duration,
1240) -> Option<String> {
1241    let dir = dirs::home_dir()?.join(".claude").join("sessions");
1242    resolve_session_id_from_chain(&dir, ancestor_chain_cached(), attempts, backoff)
1243}
1244
1245/// MCP-startup race window: how many times / how long to re-poll the ancestor
1246/// PID-files before falling back to a minted per-process identity. Small: the
1247/// pidfile lands within a few ms in practice; ~100 ms worst case at boot only.
1248const MINT_PIDFILE_ATTEMPTS: u32 = 5;
1249const MINT_PIDFILE_BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
1250
1251/// Best-effort parent-PID lookup. Linux: `/proc/<pid>/status`. macOS: `ps`.
1252/// Windows: PowerShell CIM (no extra crate). Returns `None` on any failure,
1253/// which simply ends the walk.
1254#[cfg(target_os = "linux")]
1255fn parent_pid(pid: u32) -> Option<u32> {
1256    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
1257    for line in status.lines() {
1258        if let Some(rest) = line.strip_prefix("PPid:") {
1259            return rest.trim().parse().ok();
1260        }
1261    }
1262    None
1263}
1264
1265#[cfg(target_os = "macos")]
1266fn parent_pid(pid: u32) -> Option<u32> {
1267    let out = std::process::Command::new("ps")
1268        .args(["-o", "ppid=", "-p", &pid.to_string()])
1269        .output()
1270        .ok()?;
1271    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
1272}
1273
1274#[cfg(target_os = "windows")]
1275fn parent_pid(pid: u32) -> Option<u32> {
1276    use std::os::windows::process::CommandExt;
1277    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
1278    let out = std::process::Command::new("powershell")
1279        .args([
1280            "-NoProfile",
1281            "-NonInteractive",
1282            "-Command",
1283            &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').ParentProcessId"),
1284        ])
1285        .creation_flags(CREATE_NO_WINDOW)
1286        .output()
1287        .ok()?;
1288    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
1289}
1290
1291#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1292fn parent_pid(_pid: u32) -> Option<u32> {
1293    None
1294}
1295
1296/// v0.13: the WIRE_HOME for a resolved session key —
1297/// `<sessions_root>/by-key/<hash>` where `hash` is the first 16 hex of
1298/// SHA-256(key). Deterministic and cwd-independent, so two sessions never
1299/// collide and there is no path-string to mis-normalize (the Windows bug
1300/// cannot occur). 64 bits is collision-safe at this scale.
1301pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
1302    Ok(sessions_root()?.join("by-key").join(by_key_dir_name(key)))
1303}
1304
1305/// The by-key directory name (16 hex chars / 64 bits) for a session key —
1306/// the first 8 bytes of SHA-256(key). Public so test fixtures and external
1307/// tooling can locate a session home without replicating the hash:
1308/// `session_home_for_key(key) == sessions_root()/by-key/<by_key_dir_name(key)>`.
1309pub fn by_key_dir_name(key: &str) -> String {
1310    let mut h = Sha256::new();
1311    h.update(key.as_bytes());
1312    hex::encode(&h.finalize()[..8])
1313}
1314
1315/// Long-running `wire <subcommand>` invocations that own the inbox
1316/// cursor and therefore race each other under a shared `WIRE_HOME`.
1317/// Keep this list in sync with [`warn_on_identity_collision`]'s pgrep
1318/// predicate and the call-site list in `cli::run` / `mcp::run`.
1319///
1320/// Note: `pair-host` (and the rest of the SAS code-phrase flow) was removed
1321/// in RFC-005 follow-on, so it is naturally absent from this list.
1322///
1323/// Short-lived commands (`whoami`, `status`, `send`, `peers`, …) are
1324/// intentionally absent — they write atomically and don't race, and
1325/// warning on every one would spam any operator running scripts.
1326pub const INBOX_OWNING_SUBCOMMANDS: &[&str] = &["mcp", "daemon", "monitor", "notify"];
1327
1328/// v0.6.10: warn at MCP/CLI startup if another long-running `wire`
1329/// process is already running with the same effective `WIRE_HOME`.
1330/// Closes the "two Claudes in same cwd silently share an identity"
1331/// failure mode that wasted hours of operator debugging time: today
1332/// the collision is invisible (both Claudes resolve to the same wire
1333/// session via v0.6.7 auto-detect, race the inbox cursor, "look
1334/// identical" from the operator's view). This surfaces it explicitly
1335/// with a clear remediation path.
1336///
1337/// `role` is the calling subcommand label (`"mcp"`, `"daemon"`,
1338/// `"monitor"`, …) — used in the warning's leading tag so operators
1339/// can tell which surface is observing the collision. Detection
1340/// itself spans every inbox-owning role: a `wire daemon` colliding
1341/// with an existing `wire mcp` warns just the same as an mcp/mcp
1342/// pair.
1343///
1344/// Best-effort: any subprocess / env-read failure is silent (the
1345/// collision check should never block startup).
1346///
1347/// Cross-platform process enumeration via
1348/// [`crate::platform::find_processes_by_cmdline`] — the existing
1349/// PowerShell + CIM `Get-CimInstance Win32_Process` adapter for
1350/// Windows, `pgrep -f` for POSIX. The per-role mapping back to a
1351/// `WIRE_HOME` then uses [`read_wire_home_from_pid`], which (POSIX)
1352/// reads `/proc/<pid>/environ` or `ps -E` directly, and (Windows)
1353/// walks `list_sessions()` × `<role>.pid` files since Windows has no
1354/// portable cross-process env read. #247 finding 4: closes the
1355/// "Windows returns empty" gap that left two `wire mcp` servers
1356/// sharing one `WIRE_HOME` silently racing the inbox cursor on a
1357/// Windows host.
1358pub fn warn_on_identity_collision(self_pid: u32, role: &str) {
1359    let our_wire_home = match std::env::var("WIRE_HOME") {
1360        Ok(h) => h,
1361        Err(_) => return,
1362    };
1363
1364    // One enumeration per inbox-owning role; merge the results. Going
1365    // role-by-role rather than a single alternation predicate lets us
1366    // reuse `crate::platform::find_processes_by_cmdline` unchanged —
1367    // the Windows adapter there already matches the `wire.exe` image +
1368    // a single CommandLine substring, and chaining N small queries is
1369    // cheap (one PowerShell launch per role, ≤4 launches total).
1370    let mut other_pids: Vec<u32> = Vec::new();
1371    for sub in INBOX_OWNING_SUBCOMMANDS {
1372        for pid in crate::platform::find_processes_by_cmdline(&format!("wire {sub}")) {
1373            if pid != self_pid && !other_pids.contains(&pid) {
1374                other_pids.push(pid);
1375            }
1376        }
1377    }
1378
1379    let other_homes: Vec<(u32, Option<String>)> = other_pids
1380        .iter()
1381        .map(|p| (*p, read_wire_home_from_pid(*p)))
1382        .collect();
1383
1384    let colliders = find_colliders(&our_wire_home, &other_homes);
1385
1386    if colliders.is_empty() {
1387        return;
1388    }
1389
1390    emit_collision_warning(role, &our_wire_home, &colliders);
1391}
1392
1393/// Pure decision: from a snapshot of `(pid, their_wire_home)` for
1394/// every other wire process on the host, return the pids whose
1395/// `WIRE_HOME` exactly matches ours. Missing-home entries (process
1396/// died, env unreadable on this platform) are skipped, never counted.
1397pub(crate) fn find_colliders(
1398    our_wire_home: &str,
1399    other_homes: &[(u32, Option<String>)],
1400) -> Vec<u32> {
1401    other_homes
1402        .iter()
1403        .filter_map(|(pid, their_home)| match their_home {
1404            Some(h) if h == our_wire_home => Some(*pid),
1405            _ => None,
1406        })
1407        .collect()
1408}
1409
1410/// Render the collision warning. Extracted so the format is unit-
1411/// testable without mocking a real pgrep / cross-process env read.
1412pub(crate) fn emit_collision_warning(role: &str, our_wire_home: &str, colliders: &[u32]) {
1413    eprintln!(
1414        "wire {role}: WARNING — {} other wire process(es) already using WIRE_HOME=`{}` (pid {})",
1415        colliders.len(),
1416        our_wire_home,
1417        colliders
1418            .iter()
1419            .map(|p| p.to_string())
1420            .collect::<Vec<_>>()
1421            .join(", ")
1422    );
1423    eprintln!(
1424        "  Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
1425    );
1426    eprintln!("  To use a separate identity:");
1427    eprintln!("    1. Close the other agent(s), OR");
1428    eprintln!("    2. `wire session new <name> --local-only` to create a fresh identity, then");
1429    eprintln!(
1430        "    3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
1431    );
1432}
1433
1434/// Best-effort cross-platform read of another process's `WIRE_HOME`.
1435///
1436/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
1437/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
1438/// Windows: walks every known session (`list_sessions()`) × every
1439/// inbox-owning role (`INBOX_OWNING_SUBCOMMANDS`) and reads the
1440/// per-role pidfile (`<session_home>/state/wire/<role>.pid`) — if
1441/// any pidfile records `pid`, the session's `home_dir` is the
1442/// candidate's `WIRE_HOME`. Returns None if no pidfile matches.
1443///
1444/// The Windows path requires every inbox-owning role to actually
1445/// write its pidfile on startup — daemon has always done so via
1446/// `write_self_daemon_pid`; #247 finding 4 adds the equivalent for
1447/// `mcp` / `monitor` / `notify` via [`crate::ensure_up::write_self_role_pid`].
1448///
1449/// Why not read the remote process environment on Windows? `NtQuery
1450/// InformationProcess` + `ReadProcessMemory` would give 100% coverage
1451/// but adds an `ntdll` FFI dep + brittle PEB-layout assumptions across
1452/// Windows builds. The pidfile-lookup gives equivalent coverage for
1453/// every long-running wire role we care about, with the same on-disk
1454/// data structure POSIX uses today, and zero new platform deps.
1455///
1456/// Also used by `ensure_up::daemon_liveness` to scope the orphan-daemon
1457/// check to processes serving the same WIRE_HOME.
1458pub(crate) fn read_wire_home_from_pid(pid: u32) -> Option<String> {
1459    #[cfg(target_os = "linux")]
1460    {
1461        let path = format!("/proc/{pid}/environ");
1462        let bytes = std::fs::read(&path).ok()?;
1463        for entry in bytes.split(|&b| b == 0) {
1464            let s = match std::str::from_utf8(entry) {
1465                Ok(s) => s,
1466                Err(_) => continue,
1467            };
1468            if let Some(val) = s.strip_prefix("WIRE_HOME=") {
1469                return Some(val.to_string());
1470            }
1471        }
1472        None
1473    }
1474
1475    #[cfg(target_os = "macos")]
1476    {
1477        let output = std::process::Command::new("ps")
1478            .args(["-E", "-p", &pid.to_string(), "-o", "command="])
1479            .output()
1480            .ok()?;
1481        let s = String::from_utf8_lossy(&output.stdout);
1482        for tok in s.split_whitespace() {
1483            if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
1484                return Some(val.to_string());
1485            }
1486        }
1487        None
1488    }
1489
1490    #[cfg(windows)]
1491    {
1492        wire_home_from_pid_via_pidfile_scan(pid, &list_sessions().unwrap_or_default())
1493    }
1494
1495    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
1496    {
1497        let _ = pid;
1498        None
1499    }
1500}
1501
1502/// Pure-logic helper for the Windows pidfile-scan path: given a
1503/// candidate PID and a snapshot of `(home_dir, role-pid)` pairs from
1504/// every known session × inbox-owning role, return the home whose
1505/// pidfile records this PID. Extracted so the policy is unit-testable
1506/// without a real `list_sessions` filesystem snapshot.
1507#[cfg_attr(not(windows), allow(dead_code))]
1508pub(crate) fn find_home_for_pid(pid: u32, sessions_role_pids: &[(String, u32)]) -> Option<String> {
1509    sessions_role_pids
1510        .iter()
1511        .find_map(|(home, recorded)| (*recorded == pid).then(|| home.clone()))
1512}
1513
1514/// Windows-side bridge: from the SessionInfo list, expand to
1515/// `(home_str, pid)` for every inbox-owning role's pidfile, then ask
1516/// [`find_home_for_pid`].
1517#[cfg(windows)]
1518fn wire_home_from_pid_via_pidfile_scan(pid: u32, sessions: &[SessionInfo]) -> Option<String> {
1519    let mut pairs: Vec<(String, u32)> = Vec::new();
1520    for s in sessions {
1521        for role in INBOX_OWNING_SUBCOMMANDS {
1522            if let Some(p) = session_role_pid(&s.home_dir, role) {
1523                pairs.push((s.home_dir.to_string_lossy().to_string(), p));
1524            }
1525        }
1526    }
1527    find_home_for_pid(pid, &pairs)
1528}
1529
1530/// v0.6.7: apply `detect_session_wire_home` for the current process.
1531///
1532/// If `WIRE_HOME` is unset and the current cwd maps to an existing
1533/// session, set `WIRE_HOME` for the rest of this process and emit a
1534/// one-liner to stderr so the operator knows which identity is in
1535/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
1536///
1537/// `label` distinguishes the caller in the stderr line (`mcp` vs
1538/// `cli`). Output only appears on interactive TTYs; set `WIRE_VERBOSE=1`
1539/// to force it on in non-interactive contexts.
1540///
1541/// MUST be called BEFORE any worker thread or async task spawns —
1542/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
1543/// guarantees, and our use is safe only at process entry.
1544/// Process-global record of WHICH signal won session/home resolution,
1545/// captured at adoption time by [`maybe_adopt_session_wire_home`]. Read by
1546/// `wire whoami --json` (`session_source`) so an operator can see in one
1547/// command whether identity came from an explicit `WIRE_HOME`, a host
1548/// session-id adapter, the Claude-Code pidfile fallback, a minted
1549/// per-process key, or the machine default. Post-hoc re-derivation is
1550/// unreliable — minting sets `WIRE_SESSION_ID` and `WIRE_HOME` is always set
1551/// after adoption — so the winning source MUST be captured here, once.
1552static SESSION_SOURCE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
1553
1554/// The signal that won session/home resolution for this process. One of:
1555/// `env:WIRE_HOME`, `env:WIRE_HOME_FORCE` (RFC-008 §C legacy-shape force),
1556/// `override` (`WIRE_SESSION_ID`), `claude-code`, `claude-code-pidfile`,
1557/// `codex-cli`, `copilot-cli`, `vscode-workspace`, `minted`,
1558/// `machine-default`, or `unknown` if adoption never ran.
1559pub fn session_source() -> &'static str {
1560    SESSION_SOURCE.get().copied().unwrap_or("unknown")
1561}
1562
1563/// Sources that indicate the process did NOT inherit an explicit identity
1564/// signal from its launcher. `machine-default` means a bare CLI / no
1565/// session id at all; `minted` means an MCP server fell through to a
1566/// fresh per-process key. Either way, a long-running inbox-owning role
1567/// running under one of these sources is almost certainly NOT the
1568/// identity its launcher intended (#284.4: the operator-facing symptom
1569/// is a spawn that should have inherited `WIRE_HOME` but didn't, and
1570/// then silently writes to / blocks on the cwd-default home alongside a
1571/// sibling process serving the real session-key home).
1572pub fn is_unexpected_session_source(source: &str) -> bool {
1573    matches!(source, "machine-default" | "minted")
1574}
1575
1576/// #284.4: at startup, a long-running inbox-owning role (`daemon`, `mcp`,
1577/// `monitor`, `notify`) calls this to surface the "the launcher meant
1578/// to hand us an explicit identity but didn't" failure mode. Default
1579/// behavior is a loud, force-rendered stderr warning naming the
1580/// resolved source so operators see the silent-collision risk; setting
1581/// `WIRE_STRICT_SESSION=1` upgrades it to a hard exit (code 2) so a
1582/// script wrapper can fail-fast instead of waiting for the downstream
1583/// `init` / `bind` / `daemon` call to block on a shared relay lock.
1584///
1585/// Roles that aren't in the inbox-owning long-running set (`whoami`,
1586/// `send`, `peers`, …) deliberately skip this check — they're meant to
1587/// run from a bare CLI on the machine-default identity, and warning
1588/// every one of them would drown legitimate ad-hoc usage in noise.
1589pub fn warn_if_unexpected_session_source(role: &str) {
1590    let source = session_source();
1591    if !is_unexpected_session_source(source) {
1592        return;
1593    }
1594    let strict = std::env::var("WIRE_STRICT_SESSION").is_ok_and(|v| !v.is_empty() && v != "0");
1595    let message = format!(
1596        "wire {role}: session-source=`{source}` — the launcher did not pass a session-key \
1597         (WIRE_HOME / WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID), so this process is running \
1598         against the {kind} identity. If a sibling agent is serving the real session-key \
1599         home, they will race the inbox cursor. Pass an explicit `WIRE_SESSION_ID=<key>` or \
1600         `WIRE_HOME=<path>` to fix.",
1601        kind = match source {
1602            "machine-default" => "machine-default (bare CLI)",
1603            "minted" => "freshly minted per-process",
1604            _ => "unexpected fallback",
1605        },
1606    );
1607    if strict {
1608        eprintln!("wire {role}: ERROR (WIRE_STRICT_SESSION=1) — {message}");
1609        std::process::exit(2);
1610    }
1611    eprintln!("{message}");
1612}
1613
1614/// RFC-008 §C — does this `WIRE_HOME` path point at the modern operator-
1615/// explicit `sessions/by-key/<16-hex-hash>` shape, or at an older/foreign
1616/// path?
1617///
1618/// The precedence flip in `maybe_adopt_session_wire_home` uses this to keep
1619/// **explicit modern pins** winning (operator deliberately joining two CC
1620/// tabs to one fleet-shared home, IDE config pinning a by-key path on
1621/// purpose) while letting the **session-key chain** beat a stale pre-v0.13.5
1622/// shell-profile `WIRE_HOME` pointing at the cwd-derived legacy layout
1623/// (paul's RFC-005 Phase 4 deletes the LAYOUT reader, but a shell-set env
1624/// var pointing at the path lingers across upgrades — that's the #210
1625/// regression). A non-by-key-shape `WIRE_HOME` loses to a present
1626/// session-key env var unless the operator opts back into legacy
1627/// ordering via `WIRE_HOME_FORCE=1`.
1628///
1629/// Match rule: `/by-key/<16-hex>` substring anywhere in the path (anchored
1630/// to a `by-key` segment with a `/` or `\` separator). Cross-platform: works
1631/// for both `/` and `\` separators by checking both.
1632fn is_by_key_shape(path: &str) -> bool {
1633    for needle in ["/by-key/", "\\by-key\\"] {
1634        if let Some(pos) = path.find(needle) {
1635            let after = &path[pos + needle.len()..];
1636            // Hash is the first path segment after `by-key/`. Take up to
1637            // the next separator (or end of string).
1638            let hash = after.split(['/', '\\']).next().unwrap_or("");
1639            // Wire writes exactly 16 lowercase hex chars
1640            // (`session_home_for_key`); reject anything else as malformed
1641            // → treat as legacy for safety.
1642            // Lowercase-only: wire emits `hex::encode(...)` which is
1643            // lowercase. `is_ascii_hexdigit` accepts both cases, so guard
1644            // explicitly against uppercase to keep the test pin from
1645            // `is_by_key_shape_rejects_legacy_and_malformed` honest.
1646            if hash.len() == 16
1647                && hash
1648                    .chars()
1649                    .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
1650            {
1651                return true;
1652            }
1653        }
1654    }
1655    false
1656}
1657
1658pub fn maybe_adopt_session_wire_home(label: &str) {
1659    // RFC-008 §C — precedence flip for the agent-host case. Before §C:
1660    // presence of `WIRE_HOME` in env unconditionally short-circuited the
1661    // session-key chain, so a stale pre-v0.13.5 shell-profile pin (#210
1662    // reproducer) silently overrode CLAUDE_CODE_SESSION_ID. After §C:
1663    // WIRE_HOME still wins for the OPERATOR-EXPLICIT cases (by-key-shape
1664    // modern pin OR WIRE_HOME_FORCE=1 legacy-shape override). A
1665    // non-by-key-shape WIRE_HOME without WIRE_HOME_FORCE=1 LOSES to a
1666    // present session-key env var. Closes the silent-override path
1667    // without breaking the deliberate-fleet-share contract.
1668    if let Ok(pin) = std::env::var("WIRE_HOME") {
1669        let force = std::env::var("WIRE_HOME_FORCE").is_ok();
1670        let by_key = is_by_key_shape(&pin);
1671        if by_key {
1672            // Modern operator-explicit pin. Always wins.
1673            let _ = SESSION_SOURCE.set("env:WIRE_HOME");
1674            return;
1675        }
1676        if force {
1677            // Legacy-shape pin + explicit operator opt-back-in. Surface the
1678            // force so operators reading whoami can tell the override is
1679            // active (not silent).
1680            let _ = SESSION_SOURCE.set("env:WIRE_HOME_FORCE");
1681            return;
1682        }
1683        // Legacy-shape pin without WIRE_HOME_FORCE. Check if a session-key
1684        // env var is present; if so, it WINS (the §C flip). If no
1685        // session-key resolves either, fall through to honor the pin
1686        // (preserves the bare-pin path).
1687        if resolve_session_key().is_some() {
1688            // Session-key chain takes over. Clear WIRE_HOME from env so
1689            // downstream resolution writes the session-key by-key home
1690            // instead of layering on top of the stale pin.
1691            //
1692            // SAFETY: caller contract is "before any thread spawn." All
1693            // production sites (cli::run, mcp::run) call this fn as the
1694            // first step in their respective entry points.
1695            unsafe {
1696                std::env::remove_var("WIRE_HOME");
1697            }
1698            // Audible warning to stderr (gated on interactive TTY +
1699            // WIRE_VERBOSE, matching the existing autosession line below).
1700            // Suppress with WIRE_QUIET_AUTOSESSION=1 (same gate as the
1701            // autosession chatter).
1702            use std::io::IsTerminal;
1703            let quiet = std::env::var("WIRE_QUIET_AUTOSESSION").is_ok();
1704            let verbose = std::env::var("WIRE_VERBOSE").is_ok();
1705            let interactive = std::io::stderr().is_terminal();
1706            if !quiet && (interactive || verbose) {
1707                eprintln!(
1708                    "wire {label}: WIRE_HOME ({pin}) is legacy-shape and a session-key env var is present — the session-key resolution chain wins (RFC-008 §C precedence flip). Set WIRE_HOME_FORCE=1 to opt back into legacy ordering. See RFC-008 / #210."
1709                );
1710            }
1711            // Fall through to the session-key resolution block below; it
1712            // will set SESSION_SOURCE to its own adapter label via the
1713            // existing `let _ = SESSION_SOURCE.set(source);` call.
1714        } else {
1715            // No session-key. Fall back to honoring the pin (legacy shape
1716            // but only signal present). Same as pre-§C behavior for this
1717            // case.
1718            let _ = SESSION_SOURCE.set("env:WIRE_HOME");
1719            return;
1720        }
1721    }
1722    // v0.13: prefer the host-agnostic session key (WIRE_SESSION_ID >
1723    // CLAUDE_CODE_SESSION_ID). Each session gets its own WIRE_HOME under
1724    // `by-key/<hash>` — no cwd lookup, no shared default, no Windows path
1725    // collapse. Falls back to legacy cwd-detect only when no session key is
1726    // present (bare CLI / pre-v0.13 hosts).
1727    let (home, why) = if let Some((key, source)) = resolve_session_key() {
1728        match session_home_for_key(&key) {
1729            Ok(h) => {
1730                // v0.13.2 (E8): do NOT create the home here. Creating it
1731                // unconditionally on every resolution — before any identity
1732                // exists — left a permanent empty home for every transient /
1733                // probe session key that never `wire up`d, accumulating
1734                // forever and surfacing as phantom "?" sisters in list-local.
1735                // The home is created lazily by `ensure_dirs` on the first
1736                // real write (init / claim / send), so an uninitialized
1737                // session leaves no trace on disk. (Write paths already
1738                // tolerate a non-existent WIRE_HOME — the test harness runs
1739                // every test against one.)
1740                let _ = SESSION_SOURCE.set(source);
1741                (h, format!("session key ({source})"))
1742            }
1743            Err(_) => return,
1744        }
1745    } else if label == "mcp" {
1746        // v0.13.4 (operator directive: per-session ONLY, never cwd). The MCP
1747        // server must NEVER cwd-resolve — that fallback is what collapsed every
1748        // Claude session sharing a launch dir onto a single persona.
1749        //
1750        // We reach here only when `resolve_session_key()` above returned None —
1751        // no session-id env var AND a single PID-file read missed. The dominant
1752        // cause is a STARTUP RACE: Claude hadn't written its session PID-file yet
1753        // when this stdio MCP booted. So retry the PID-file (cached-chain, no
1754        // per-hop `ps` re-fork) before giving up — this recovers the LIVE
1755        // identity instead of freezing a throwaway one (the "two names" bug).
1756        if let Some(sid) =
1757            claude_code_session_from_pidfile_retry(MINT_PIDFILE_ATTEMPTS, MINT_PIDFILE_BACKOFF)
1758        {
1759            match session_home_for_key(&sid) {
1760                Ok(h) => {
1761                    let _ = SESSION_SOURCE.set("claude-code-pidfile");
1762                    (
1763                        h,
1764                        "session key (claude-code-pidfile, post-race-retry)".to_string(),
1765                    )
1766                }
1767                Err(_) => return,
1768            }
1769        } else {
1770            // Still no live session after the race window: mint a per-process
1771            // key — distinct per session, never a shared cwd identity.
1772            let minted = format!(
1773                "mcp-proc-{:016x}{:016x}",
1774                rand::random::<u64>(),
1775                rand::random::<u64>()
1776            );
1777            match session_home_for_key(&minted) {
1778                Ok(h) => {
1779                    // Do NOT pin the minted key into WIRE_SESSION_ID. That slot
1780                    // is the operator-override / live-session channel; a minted
1781                    // value parked there masqueraded as an operator override and
1782                    // beat the live session on any re-resolve — the root of the
1783                    // "two names" split. WIRE_HOME (set below) already pins the
1784                    // home for this process and is inherited by children, so
1785                    // consistency holds without overloading the public slot.
1786                    let _ = SESSION_SOURCE.set("minted");
1787                    (
1788                        h,
1789                        "minted per-process key (no live session after race retry; cwd disabled for MCP)"
1790                            .to_string(),
1791                    )
1792                }
1793                Err(_) => return,
1794            }
1795        }
1796    } else {
1797        // CLI with no session id. Per the per-session-only directive we do NOT
1798        // cwd-resolve here either — cwd identity is the collision trap (agents
1799        // shell out to the CLI, and any cwd-derived identity risks the wrong /
1800        // shared persona). Under Claude Code the CLI always carries
1801        // CLAUDE_CODE_SESSION_ID (resolved above), so this only hits a bare
1802        // terminal outside an agent host — which gets the stable machine-default
1803        // identity (set WIRE_SESSION_ID / WIRE_HOME for an explicit one). No cwd.
1804        let _ = SESSION_SOURCE.set("machine-default");
1805        return;
1806    };
1807    // v0.9.1: emit the chatter ONLY when stderr is an interactive TTY.
1808    // When wire is invoked from a non-interactive parent (Claude Code's
1809    // Bash tool, scripts, daemons), the auto-detect line is captured
1810    // alongside command output and pollutes both — wasting agent
1811    // context tokens and breaking JSON parsers that read combined
1812    // streams. WIRE_VERBOSE=1 forces the line on.
1813    use std::io::IsTerminal;
1814    let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
1815    let interactive = std::io::stderr().is_terminal();
1816    if interactive || verbose_env {
1817        eprintln!(
1818            "wire {label}: adopted {why} → WIRE_HOME=`{}`",
1819            home.display()
1820        );
1821    }
1822    // SAFETY: caller contract is "before any thread spawn." All
1823    // production sites (cli::run, mcp::run) call this as the first
1824    // step in their respective entry points.
1825    unsafe {
1826        std::env::set_var("WIRE_HOME", &home);
1827    }
1828}
1829
1830#[cfg(test)]
1831mod tests {
1832    use super::*;
1833
1834    #[test]
1835    fn valid_session_key_rejects_empty_and_unexpanded_placeholder() {
1836        assert!(valid_session_key("4129275d-cc5c-4d2a"));
1837        assert!(valid_session_key("mcp-proc-deadbeef"));
1838        assert!(!valid_session_key(""));
1839        assert!(!valid_session_key("   "));
1840        // The load-bearing guard: an unexpanded MCP-config placeholder must NOT
1841        // be hashed — that's the all-sessions-collapse (soft-spruce) bug.
1842        assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
1843        assert!(!valid_session_key("  ${CLAUDE_CODE_SESSION_ID}  "));
1844    }
1845
1846    #[test]
1847    fn resolve_session_key_vscode_adapter_and_placeholder_guard() {
1848        // Per-adapter test for the VS Code / GitHub Copilot path added in #59.
1849        // Holds two invariants the integration depends on:
1850        //
1851        //   (a) When VSCODE_GIT_REPOSITORY_ROOT is set to a real workspace
1852        //       path, that key wins resolution and two distinct workspace
1853        //       paths produce two distinct session homes — proves the
1854        //       per-workspace-identity contract documented in
1855        //       docs/integrations/GITHUB_COPILOT.md.
1856        //
1857        //   (b) When the env entry is the unexpanded literal "${workspaceFolder}"
1858        //       (host failed to substitute), the ${} guard rejects it and the
1859        //       fn falls through — proves the safe-degradation property
1860        //       (no-identity, NOT cross-workspace collision).
1861        //
1862        // Mirrors the WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID semantics so any
1863        // future adapter added to the env-check loop inherits the same gates.
1864        let _guard = crate::config::test_support::ENV_LOCK
1865            .lock()
1866            .unwrap_or_else(|p| p.into_inner());
1867
1868        // Snapshot + clear every env var resolve_session_key consults so this
1869        // test is hermetic regardless of the harness environment.
1870        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1871        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1872        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1873        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1874        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1875        // SAFETY: ENV_LOCK is held, serializing all env access.
1876        unsafe {
1877            std::env::remove_var("WIRE_SESSION_ID");
1878            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1879            std::env::remove_var("CODEX_SESSION_ID");
1880            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1881            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1882        }
1883
1884        // (a) Two distinct workspace paths -> two distinct, stable session homes.
1885        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1886        let r1 = resolve_session_key();
1887        assert!(
1888            matches!(&r1, Some((k, src)) if k == "/home/dev/frontend" && *src == "vscode-workspace"),
1889            "VSCODE_GIT_REPOSITORY_ROOT must win resolution and be labeled vscode-workspace; got {r1:?}"
1890        );
1891        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1892
1893        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/backend") };
1894        let r2 = resolve_session_key();
1895        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1896        assert_ne!(
1897            home_a, home_b,
1898            "distinct workspace roots must map to distinct session homes (no cross-workspace persona collision)"
1899        );
1900
1901        // Same path again -> same home (resume stability).
1902        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1903        let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1904        assert_eq!(
1905            home_a, home_a2,
1906            "same workspace root must yield the same home across calls"
1907        );
1908
1909        // (b) Unexpanded ${workspaceFolder} literal MUST NOT be accepted.
1910        //     With every other adapter still cleared, resolution must fall
1911        //     through to None (or the claude pidfile path, which is absent in
1912        //     this test env) — never hash the literal.
1913        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "${workspaceFolder}") };
1914        let r_guard = resolve_session_key();
1915        assert!(
1916            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1917            "unexpanded ${{workspaceFolder}} literal must be rejected by the ${{}} guard; got {r_guard:?}"
1918        );
1919        // Same guard for the other adapter slots.
1920        unsafe {
1921            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1922            std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}");
1923        }
1924        let r_guard2 = resolve_session_key();
1925        assert!(
1926            !matches!(&r_guard2, Some((k, _)) if k.contains("${")),
1927            "unexpanded ${{workspaceFolder}} in WIRE_SESSION_ID must also be rejected; got {r_guard2:?}"
1928        );
1929
1930        // Restore any env we displaced.
1931        // SAFETY: ENV_LOCK still held.
1932        unsafe {
1933            std::env::remove_var("WIRE_SESSION_ID");
1934            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1935            std::env::remove_var("CODEX_SESSION_ID");
1936            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1937            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1938            if let Some(v) = prev_override {
1939                std::env::set_var("WIRE_SESSION_ID", v);
1940            }
1941            if let Some(v) = prev_claude {
1942                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1943            }
1944            if let Some(v) = prev_codex {
1945                std::env::set_var("CODEX_SESSION_ID", v);
1946            }
1947            if let Some(v) = prev_copilot {
1948                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1949            }
1950            if let Some(v) = prev_vscode {
1951                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1952            }
1953        }
1954    }
1955
1956    #[test]
1957    fn resolve_session_key_copilot_cli_adapter_and_priority() {
1958        // Per-adapter test for the GitHub Copilot CLI path (Phase 2 of #59):
1959        // resolve_session_key reads COPILOT_AGENT_SESSION_ID (set by the
1960        // `gh copilot` / `copilot` CLI host on every session) as a TARGETED
1961        // env adapter — exactly like CLAUDE_CODE_SESSION_ID. Holds three
1962        // invariants:
1963        //
1964        //   (a) Set to a real id -> that key wins resolution and two distinct
1965        //       conversations map to two distinct session homes (per-
1966        //       conversation identity contract).
1967        //   (b) WIRE_SESSION_ID overrides COPILOT_AGENT_SESSION_ID (priority
1968        //       1 trumps priority 3).
1969        //   (c) Unexpanded ${...} literal is rejected by the ${} guard —
1970        //       falls through to the None path, never hashed (mirrors the
1971        //       guard inherited from CLAUDE_CODE_SESSION_ID / WIRE_SESSION_ID
1972        //       / VSCODE_GIT_REPOSITORY_ROOT).
1973        let _guard = crate::config::test_support::ENV_LOCK
1974            .lock()
1975            .unwrap_or_else(|p| p.into_inner());
1976
1977        // Snapshot every env var resolve_session_key consults so the test is
1978        // hermetic regardless of harness environment (this test literally
1979        // runs under Copilot CLI, where COPILOT_AGENT_SESSION_ID is set).
1980        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1981        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1982        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1983        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1984        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1985        // SAFETY: ENV_LOCK is held, serializing all env access.
1986        unsafe {
1987            std::env::remove_var("WIRE_SESSION_ID");
1988            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1989            std::env::remove_var("CODEX_SESSION_ID");
1990            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1991            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1992        }
1993
1994        // (a) COPILOT_AGENT_SESSION_ID set -> wins resolution; distinct ids
1995        //     map to distinct session homes.
1996        unsafe {
1997            std::env::set_var(
1998                "COPILOT_AGENT_SESSION_ID",
1999                "3869478a-33cc-4c33-82ee-b6403a24d734",
2000            )
2001        };
2002        let r1 = resolve_session_key();
2003        assert!(
2004            matches!(&r1, Some((k, src)) if k == "3869478a-33cc-4c33-82ee-b6403a24d734" && *src == "copilot-cli"),
2005            "COPILOT_AGENT_SESSION_ID must win resolution and be labeled copilot-cli; got {r1:?}"
2006        );
2007        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
2008
2009        unsafe {
2010            std::env::set_var(
2011                "COPILOT_AGENT_SESSION_ID",
2012                "deadbeef-0000-0000-0000-000000000000",
2013            )
2014        };
2015        let r2 = resolve_session_key();
2016        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
2017        assert_ne!(
2018            home_a, home_b,
2019            "distinct Copilot CLI session ids must map to distinct session homes"
2020        );
2021
2022        // (b) WIRE_SESSION_ID at priority 1 overrides COPILOT_AGENT_SESSION_ID
2023        //     at priority 3. Operator's explicit universal override always wins.
2024        unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
2025        let r_override = resolve_session_key();
2026        assert!(
2027            matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
2028            "WIRE_SESSION_ID must beat COPILOT_AGENT_SESSION_ID; got {r_override:?}"
2029        );
2030        unsafe { std::env::remove_var("WIRE_SESSION_ID") };
2031
2032        // (c) Unexpanded ${...} literal is rejected by the ${} guard.
2033        //     `gh copilot` shouldn't ship literal placeholders in
2034        //     COPILOT_AGENT_SESSION_ID, but if some future config-forwarding
2035        //     path does, the guard must reject it (same as for the other
2036        //     adapters) so we never hash the literal and collapse sessions.
2037        unsafe { std::env::set_var("COPILOT_AGENT_SESSION_ID", "${SOME_PLACEHOLDER}") };
2038        let r_guard = resolve_session_key();
2039        assert!(
2040            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
2041            "unexpanded ${{...}} in COPILOT_AGENT_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
2042        );
2043
2044        // Restore any env we displaced.
2045        // SAFETY: ENV_LOCK still held.
2046        unsafe {
2047            std::env::remove_var("WIRE_SESSION_ID");
2048            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
2049            std::env::remove_var("CODEX_SESSION_ID");
2050            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
2051            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
2052            if let Some(v) = prev_override {
2053                std::env::set_var("WIRE_SESSION_ID", v);
2054            }
2055            if let Some(v) = prev_claude {
2056                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
2057            }
2058            if let Some(v) = prev_codex {
2059                std::env::set_var("CODEX_SESSION_ID", v);
2060            }
2061            if let Some(v) = prev_copilot {
2062                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
2063            }
2064            if let Some(v) = prev_vscode {
2065                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
2066            }
2067        }
2068    }
2069
2070    #[test]
2071    fn resolve_session_key_codex_cli_adapter_and_priority() {
2072        // Per-adapter test for the OpenAI Codex CLI path (#__pr_codex__).
2073        // resolve_session_key reads CODEX_SESSION_ID as a TARGETED env adapter
2074        // — exactly like CLAUDE_CODE_SESSION_ID and COPILOT_AGENT_SESSION_ID.
2075        // Until Codex itself forwards the thread id to MCP child env, operators
2076        // wire it via `[mcp_servers.<name>.env]` in `~/.codex/config.toml`;
2077        // landing the adapter now means once Codex ships the env it works
2078        // with zero further code change. Holds three invariants:
2079        //
2080        //   (a) Set to a real thread id -> that key wins resolution and two
2081        //       distinct threads map to two distinct session homes
2082        //       (per-thread identity contract).
2083        //   (b) WIRE_SESSION_ID overrides CODEX_SESSION_ID (priority 1
2084        //       trumps priority 3); CLAUDE_CODE_SESSION_ID also outranks
2085        //       CODEX_SESSION_ID (priority 2 trumps priority 3) — the
2086        //       Codex adapter slots between Claude Code and Copilot.
2087        //   (c) Unexpanded ${...} literal is rejected by the ${} guard,
2088        //       falling through rather than collapsing all sessions
2089        //       (mirrors the guard inherited from every other adapter).
2090        let _guard = crate::config::test_support::ENV_LOCK
2091            .lock()
2092            .unwrap_or_else(|p| p.into_inner());
2093
2094        // Snapshot every env var resolve_session_key consults so the test is
2095        // hermetic regardless of harness environment.
2096        let prev_override = std::env::var_os("WIRE_SESSION_ID");
2097        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
2098        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
2099        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
2100        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
2101        // SAFETY: ENV_LOCK is held, serializing all env access.
2102        unsafe {
2103            std::env::remove_var("WIRE_SESSION_ID");
2104            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
2105            std::env::remove_var("CODEX_SESSION_ID");
2106            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
2107            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
2108        }
2109
2110        // (a) CODEX_SESSION_ID set -> wins resolution over the no-id baseline;
2111        //     distinct thread ids map to distinct session homes.
2112        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
2113        let r1 = resolve_session_key();
2114        assert!(
2115            matches!(&r1, Some((k, src)) if k == "019e66ad-277e-7be3-bdd9-b7708e069f3b" && *src == "codex-cli"),
2116            "CODEX_SESSION_ID must win resolution and be labeled codex-cli; got {r1:?}"
2117        );
2118        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
2119
2120        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66b6-14de-7142-b43a-1861fe59e945") };
2121        let r2 = resolve_session_key();
2122        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
2123        assert_ne!(
2124            home_a, home_b,
2125            "distinct Codex thread ids must map to distinct session homes"
2126        );
2127
2128        // Same id again -> same home (resume stability — same thread reconnects
2129        // to the same persona).
2130        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
2131        let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
2132        assert_eq!(
2133            home_a, home_a2,
2134            "same Codex thread id must yield the same home across calls"
2135        );
2136
2137        // (b) WIRE_SESSION_ID at priority 1 overrides CODEX_SESSION_ID at
2138        //     priority 3 (operator explicit override always wins).
2139        unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
2140        let r_override = resolve_session_key();
2141        assert!(
2142            matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
2143            "WIRE_SESSION_ID must beat CODEX_SESSION_ID; got {r_override:?}"
2144        );
2145        unsafe { std::env::remove_var("WIRE_SESSION_ID") };
2146
2147        // CLAUDE_CODE_SESSION_ID at priority 2 also beats CODEX_SESSION_ID at
2148        // priority 3. (Earlier adapters get to claim the host they were
2149        // designed for; Codex slots in after Claude Code.)
2150        unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-codex") };
2151        let r_claude_wins = resolve_session_key();
2152        assert!(
2153            matches!(&r_claude_wins, Some((k, src)) if k == "claude-wins-over-codex" && *src == "claude-code"),
2154            "CLAUDE_CODE_SESSION_ID must beat CODEX_SESSION_ID; got {r_claude_wins:?}"
2155        );
2156        unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") };
2157
2158        // (c) Unexpanded ${...} literal is rejected by the ${} guard.
2159        //     If a host's config-forwarding ever ships a literal placeholder,
2160        //     the guard rejects it (same as for every other adapter) so we
2161        //     never hash the literal and collapse sessions.
2162        unsafe { std::env::set_var("CODEX_SESSION_ID", "${SOME_PLACEHOLDER}") };
2163        let r_guard = resolve_session_key();
2164        assert!(
2165            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
2166            "unexpanded ${{...}} in CODEX_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
2167        );
2168
2169        // Restore any env we displaced.
2170        // SAFETY: ENV_LOCK still held.
2171        unsafe {
2172            std::env::remove_var("WIRE_SESSION_ID");
2173            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
2174            std::env::remove_var("CODEX_SESSION_ID");
2175            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
2176            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
2177            if let Some(v) = prev_override {
2178                std::env::set_var("WIRE_SESSION_ID", v);
2179            }
2180            if let Some(v) = prev_claude {
2181                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
2182            }
2183            if let Some(v) = prev_codex {
2184                std::env::set_var("CODEX_SESSION_ID", v);
2185            }
2186            if let Some(v) = prev_copilot {
2187                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
2188            }
2189            if let Some(v) = prev_vscode {
2190                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
2191            }
2192        }
2193    }
2194
2195    #[test]
2196    fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
2197        // Regression (v0.13.2): v0.13 moved session homes under
2198        // `sessions/by-key/<hash>`, but (1) list_sessions only scanned the
2199        // top level so by-key homes were invisible, and (2) sessions_root()'s
2200        // inside-session fallback only walked ONE level up (expecting parent
2201        // `sessions`), so an inside-session WIRE_HOME resolved to a bogus
2202        // nested dir. Together they made same-box discovery (list-local /
2203        // pair-all-local) return zero sisters under v0.13.
2204        let _guard = crate::config::test_support::ENV_LOCK
2205            .lock()
2206            .unwrap_or_else(|p| p.into_inner());
2207        let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
2208        let _ = std::fs::remove_dir_all(&tmp);
2209        let root = tmp.join("sessions");
2210        let home = root.join("by-key").join("abc123def4567890");
2211        let cfg = home.join("config").join("wire");
2212        std::fs::create_dir_all(&cfg).unwrap();
2213        std::fs::write(
2214            cfg.join("agent-card.json"),
2215            r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
2216        )
2217        .unwrap();
2218
2219        // (1) sessions_root() must find the real root even when WIRE_HOME
2220        //     points INSIDE the by-key home.
2221        // SAFETY: ENV_LOCK is held, serializing all env access.
2222        unsafe { std::env::set_var("WIRE_HOME", &home) };
2223        assert_eq!(
2224            sessions_root().unwrap(),
2225            root,
2226            "sessions_root must resolve the root from inside a by-key home"
2227        );
2228
2229        // (2) list_sessions() must enumerate the by-key home, labeled by handle.
2230        let sessions = list_sessions().unwrap();
2231        let found = sessions
2232            .iter()
2233            .any(|s| s.handle.as_deref() == Some("test-persona"));
2234        unsafe { std::env::remove_var("WIRE_HOME") };
2235        let _ = std::fs::remove_dir_all(&tmp);
2236        assert!(
2237            found,
2238            "by-key home must be enumerated: {:?}",
2239            sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
2240        );
2241    }
2242
2243    #[test]
2244    fn find_session_home_by_name_resolves_named_and_persona() {
2245        // RFC-006 Part A: a single by-key store, two naming conventions.
2246        // (1) A NAMED session's key is its name, so its home is
2247        //     `session_dir(name)` directly. (2) An AGENT session's key is
2248        //     its session id; the operator types the DID-derived persona
2249        //     HANDLE, which does not hash to the home, so the handle walk
2250        //     resolves it. Both must resolve via `find_session_home_by_name`.
2251        let _guard = crate::config::test_support::ENV_LOCK
2252            .lock()
2253            .unwrap_or_else(|p| p.into_inner());
2254        let tmp = std::env::temp_dir().join(format!("wire-find-{}", rand::random::<u32>()));
2255        let _ = std::fs::remove_dir_all(&tmp);
2256        let root = tmp.join("sessions");
2257        std::fs::create_dir_all(&root).unwrap();
2258
2259        // SAFETY: ENV_LOCK is held. Set early so `session_dir` resolves
2260        // the canonical by-key home under this test root.
2261        unsafe { std::env::set_var("WIRE_HOME", &root) };
2262
2263        // (1) Named session: key == name → `by-key/<hash(name)>`. The card
2264        // handle equals the name (named sessions claim their own name).
2265        let named_home = super::session_dir("named-pane").unwrap();
2266        let named_cfg = named_home.join("config").join("wire");
2267        std::fs::create_dir_all(&named_cfg).unwrap();
2268        std::fs::write(
2269            named_cfg.join("agent-card.json"),
2270            r#"{"did":"did:wire:named-pane-aaaa1111","handle":"named-pane","verify_keys":{}}"#,
2271        )
2272        .unwrap();
2273
2274        // (2) Agent session: dir is a session-key hash, card handle is the
2275        // DID-derived persona `coral-weasel` (≠ the hash).
2276        let bykey_home = root.join("by-key").join("3049827d92d4fbd5");
2277        let bykey_cfg = bykey_home.join("config").join("wire");
2278        std::fs::create_dir_all(&bykey_cfg).unwrap();
2279        std::fs::write(
2280            bykey_cfg.join("agent-card.json"),
2281            r#"{"did":"did:wire:coral-weasel-0616dc6c","handle":"coral-weasel","verify_keys":{}}"#,
2282        )
2283        .unwrap();
2284
2285        // Named lookup: operator types the session name; resolves via the
2286        // deterministic by-key home, no enumeration.
2287        let named = super::find_session_home_by_name("named-pane").unwrap();
2288        assert_eq!(
2289            named.as_deref(),
2290            Some(named_home.as_path()),
2291            "named session must resolve to its by-key/<hash(name)> home"
2292        );
2293
2294        // Persona lookup: operator types the persona handle, not the hash.
2295        let bykey = super::find_session_home_by_name("coral-weasel").unwrap();
2296        assert_eq!(
2297            bykey.as_deref(),
2298            Some(bykey_home.as_path()),
2299            "agent persona handle must resolve to its by-key/<hash> dir"
2300        );
2301
2302        // by-key lookup via the hash itself also works (some tooling
2303        // may pass the raw dir name).
2304        let by_hash = super::find_session_home_by_name("3049827d92d4fbd5").unwrap();
2305        assert_eq!(
2306            by_hash.as_deref(),
2307            Some(bykey_home.as_path()),
2308            "raw by-key hash dir name must also resolve"
2309        );
2310
2311        // Negative: an unknown name returns None, not an error.
2312        let missing = super::find_session_home_by_name("never-existed").unwrap();
2313        assert_eq!(missing, None, "unknown session must return None");
2314
2315        unsafe { std::env::remove_var("WIRE_HOME") };
2316        let _ = std::fs::remove_dir_all(&tmp);
2317    }
2318
2319    #[test]
2320    fn pid_to_session_map_builds_from_session_pidfiles() {
2321        // #173 follow-up (#174 hotfix removed --session arg from
2322        // supervisor children): wire status orphan annotation now
2323        // maps pid → session via per-session pidfiles. Walk should
2324        // find each session whose `<home>/state/wire/daemon.pid`
2325        // contains a valid pid, and IGNORE sessions whose pidfile
2326        // is absent or unreadable.
2327        let _guard = crate::config::test_support::ENV_LOCK
2328            .lock()
2329            .unwrap_or_else(|p| p.into_inner());
2330        let tmp = std::env::temp_dir().join(format!("wire-p2s-{}", rand::random::<u32>()));
2331        let _ = std::fs::remove_dir_all(&tmp);
2332        let root = tmp.join("sessions");
2333        // Three by-key sessions. Two have pidfiles, one doesn't.
2334        let mk_session = |key: &str, handle: &str| -> PathBuf {
2335            let home = root.join("by-key").join(key);
2336            let cfg = home.join("config").join("wire");
2337            std::fs::create_dir_all(&cfg).unwrap();
2338            std::fs::write(
2339                cfg.join("agent-card.json"),
2340                format!(
2341                    r#"{{"did":"did:wire:{handle}-6e301ab1","handle":"{handle}","verify_keys":{{}}}}"#
2342                ),
2343            )
2344            .unwrap();
2345            home
2346        };
2347        let h1 = mk_session("abc123def4567890", "alpha-aurora");
2348        let h2 = mk_session("def456abc7890123", "beta-blossom");
2349        let _h3 = mk_session("0000aaaabbbbcccc", "gamma-gorge");
2350        // h1 / h2 get JSON pidfiles; h3 gets none.
2351        let state1 = h1.join("state").join("wire");
2352        let state2 = h2.join("state").join("wire");
2353        std::fs::create_dir_all(&state1).unwrap();
2354        std::fs::create_dir_all(&state2).unwrap();
2355        std::fs::write(state1.join("daemon.pid"), r#"{"pid": 12345}"#).unwrap();
2356        std::fs::write(state2.join("daemon.pid"), r#"{"pid": 67890}"#).unwrap();
2357
2358        // SAFETY: ENV_LOCK is held, serializing all env access.
2359        unsafe { std::env::set_var("WIRE_HOME", &h1) };
2360        let map = super::pid_to_session_map();
2361        unsafe { std::env::remove_var("WIRE_HOME") };
2362        let _ = std::fs::remove_dir_all(&tmp);
2363
2364        // h1 / h2 present, h3 absent. SessionInfo.name is the handle
2365        // derived from the card when the home is initialized
2366        // (list_sessions's mk helper overrides name = handle in that
2367        // case; by-key hash is only the fallback for uninitialized
2368        // homes). That's exactly the production label `wire status`
2369        // already prints for sessions.
2370        assert_eq!(
2371            map.get(&12345).map(String::as_str),
2372            Some("alpha-aurora"),
2373            "pid 12345 should map to the handle for h1"
2374        );
2375        assert_eq!(
2376            map.get(&67890).map(String::as_str),
2377            Some("beta-blossom"),
2378            "pid 67890 should map (JSON pidfile form, handle for h2)"
2379        );
2380        // Sanity: no entry for an unrelated pid.
2381        assert!(
2382            !map.contains_key(&99999),
2383            "synthetic missing pid should not appear in the map"
2384        );
2385    }
2386
2387    #[test]
2388    fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
2389        // session_home_for_key reads WIRE_HOME (via sessions_root); hold the
2390        // shared env lock so a parallel env-mutating test can't change it
2391        // between calls and make a1 != a2 (flaky race).
2392        let _guard = crate::config::test_support::ENV_LOCK
2393            .lock()
2394            .unwrap_or_else(|p| p.into_inner());
2395        let a1 = session_home_for_key("sess-aaa").unwrap();
2396        let a2 = session_home_for_key("sess-aaa").unwrap();
2397        let b = session_home_for_key("sess-bbb").unwrap();
2398        assert_eq!(a1, a2, "same key -> same home (resume stability)");
2399        assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
2400        let leaf = a1.file_name().unwrap().to_str().unwrap();
2401        assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
2402        assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
2403        assert_eq!(
2404            a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
2405            "by-key"
2406        );
2407    }
2408
2409    #[test]
2410    fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
2411        assert!(url_is_loopback("http://127.0.0.1:8771"));
2412        assert!(url_is_loopback("http://127.1.2.3"));
2413        assert!(url_is_loopback("http://localhost:9000"));
2414        assert!(url_is_loopback("https://localhost/v1"));
2415        assert!(url_is_loopback("http://[::1]:8771"));
2416        // Case-insensitive.
2417        assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
2418        // Non-loopback negatives — must NOT be flagged.
2419        assert!(!url_is_loopback("https://wireup.net"));
2420        assert!(!url_is_loopback("http://192.168.1.50:8771"));
2421        assert!(!url_is_loopback("http://10.0.0.5"));
2422        assert!(!url_is_loopback("https://relay.example.com"));
2423    }
2424
2425    #[test]
2426    fn sanitize_handles_unicode_and_long_names() {
2427        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
2428        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
2429        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
2430        assert_eq!(sanitize_name(""), "wire-session");
2431        assert_eq!(sanitize_name("---"), "wire-session");
2432        let long: String = "a".repeat(100);
2433        assert_eq!(sanitize_name(&long).len(), 32);
2434    }
2435
2436    #[test]
2437    fn derive_name_returns_basename_when_no_collision() {
2438        let reg = SessionRegistry::default();
2439        assert_eq!(
2440            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
2441            "wire"
2442        );
2443        assert_eq!(
2444            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
2445            "slancha-mesh"
2446        );
2447    }
2448
2449    #[test]
2450    fn derive_name_returns_stored_name_when_cwd_already_registered() {
2451        let mut reg = SessionRegistry::default();
2452        reg.by_cwd.insert(
2453            "/Users/paul/Source/wire".to_string(),
2454            "wire-special".to_string(),
2455        );
2456        assert_eq!(
2457            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
2458            "wire-special"
2459        );
2460    }
2461
2462    #[test]
2463    fn normalize_cwd_key_case_handling_matches_platform_filesystem() {
2464        // Issue #30 Willard repro: on Windows, two terminals in the "same"
2465        // project under different casings of the same path
2466        // (`C:\Foo\Bar` vs `C:\foo\bar`) hashed to DIFFERENT registry keys
2467        // pre-fix → the second terminal missed the registry lookup, fell
2468        // back to the legacy default identity, and both terminals collapsed
2469        // onto a shared DID. Fix: normalize the cwd key case-insensitively
2470        // on Windows, case-sensitively elsewhere (so distinct-on-disk paths
2471        // on case-sensitive filesystems remain distinct).
2472        let upper = Path::new("/Users/paul/Source/WIRE");
2473        let lower = Path::new("/Users/paul/Source/wire");
2474        if cfg!(windows) {
2475            assert_eq!(
2476                normalize_cwd_key(upper),
2477                normalize_cwd_key(lower),
2478                "on Windows, distinct casings of the same path MUST normalize \
2479                 to the same key (NTFS is case-insensitive by default)"
2480            );
2481        } else {
2482            assert_ne!(
2483                normalize_cwd_key(upper),
2484                normalize_cwd_key(lower),
2485                "on case-sensitive filesystems, distinct casings ARE distinct \
2486                 directories and MUST stay distinct keys"
2487            );
2488        }
2489        // Trivial sanity: same input always produces same output.
2490        assert_eq!(normalize_cwd_key(lower), normalize_cwd_key(lower));
2491    }
2492
2493    #[test]
2494    fn derive_name_no_regression_exact_match_still_resolves() {
2495        // Cross-platform no-regression check for the v0.13.6 lookup
2496        // changes: an exact-match (same casing stored AND looked up)
2497        // entry MUST continue to resolve on the fast path — the new
2498        // O(n) normalized-scan fallback is only reached on the initial
2499        // .get miss.
2500        //
2501        // Honest scope (per coral-weasel's #67 review): this test does
2502        // NOT exercise the case-folding fallback on Linux/macOS — the
2503        // normalizer is a no-op there, so the first `.get` hits and
2504        // the scan never runs. The case-folding behavior is inherently
2505        // Windows-only; that path is covered by
2506        // derive_name_finds_registered_cwd_under_alternate_casing_on_windows
2507        // which executes on Windows CI.
2508        let mut reg = SessionRegistry::default();
2509        let stored = "/Users/Paul/Source/Wire-v0_13_5-Era";
2510        reg.by_cwd
2511            .insert(stored.to_string(), "wire-legacy".to_string());
2512
2513        // Lookup under the EXACT stored path: must resolve on the
2514        // fast `.get` path regardless of platform.
2515        assert_eq!(
2516            derive_name_from_cwd(Path::new(stored), &reg),
2517            "wire-legacy",
2518            "exact-match v0.13.5 entry MUST still resolve under v0.13.6+"
2519        );
2520    }
2521
2522    #[test]
2523    fn derive_name_scan_fallback_runs_when_initial_get_misses() {
2524        // Cross-platform proof that the O(n) normalized-scan fallback
2525        // engages on a .get miss. We can't trigger the *case-folding*
2526        // case on Linux/macOS (normalizer is a no-op), but we CAN
2527        // exercise the scan branch by storing under a key the
2528        // normalized lookup definitely won't hit, and verifying that
2529        // the .find()-based fallback resolves it.
2530        //
2531        // Setup: store under a key that's identical to the lookup
2532        // BUT with a trailing slash difference (so `.get` exact-match
2533        // misses, but our normalize_cwd_key — which preserves the
2534        // trailing slash — also misses; then we rely on the .find()
2535        // iterator). This is a contrived setup that proves the scan
2536        // branch is reachable; it does NOT test case-folding (Windows
2537        // only).
2538        //
2539        // A simpler way to exercise the same logic: store under one
2540        // path, look up under a different path that normalizes to the
2541        // SAME key. Without case-folding, the only way to do that is
2542        // to mutate normalize_cwd_key. Since we can't do that in a
2543        // test, this test instead pins the *no-false-positive* side:
2544        // a path with no matching stored entry must NOT resolve.
2545        let mut reg = SessionRegistry::default();
2546        reg.by_cwd.insert(
2547            "/Users/paul/Source/project-a".to_string(),
2548            "project-a".to_string(),
2549        );
2550
2551        // Distinct path → no match → falls through to basename
2552        // derivation. Proves the scan doesn't fabricate matches.
2553        let derived = derive_name_from_cwd(Path::new("/Users/paul/Source/project-b"), &reg);
2554        assert_eq!(
2555            derived, "project-b",
2556            "non-matching lookup must fall through to basename derivation, \
2557             NOT fabricate a match via the scan"
2558        );
2559    }
2560
2561    #[cfg(windows)]
2562    #[test]
2563    fn derive_name_finds_registered_cwd_under_alternate_casing_on_windows() {
2564        // Direct integration check for the Willard repro on Windows: an
2565        // existing registry entry written under one casing MUST resolve
2566        // when the lookup arrives under a different casing of the same
2567        // path.
2568        //
2569        // Trace through the v0.13.6 read-side O(n) normalized scan:
2570        //   - Stored key: "C:\Users\Willard\ComfyUI\claude-integration"
2571        //   - Lookup cwd: "c:\users\willard\comfyui\claude-integration"
2572        //   - cwd_key  = normalize(lookup) = "c:\users\..." (already lower)
2573        //   - .get(&cwd_key)  → MISS (stored has mixed casing)
2574        //   - .iter().find(normalize(stored) == cwd_key) → HIT
2575        //     (normalize("C:\Users\...") == "c:\users\..." == cwd_key)
2576        //   - Returns "claude-integration" ← the fix.
2577        //
2578        // Pre-fix this returned the basename → phantom hash-suffix → identity
2579        // collision (the original Willard report).
2580        let mut reg = SessionRegistry::default();
2581        reg.by_cwd.insert(
2582            r"C:\Users\Willard\ComfyUI\claude-integration".to_string(),
2583            "claude-integration".to_string(),
2584        );
2585        let from_lower_cwd = Path::new(r"c:\users\willard\comfyui\claude-integration");
2586        assert_eq!(
2587            derive_name_from_cwd(from_lower_cwd, &reg),
2588            "claude-integration",
2589            "Windows lookup MUST find the registered entry regardless of \
2590             how the shell capitalized the cwd, via the normalized scan"
2591        );
2592    }
2593
2594    #[test]
2595    fn read_session_endpoints_handles_missing_relay_state() {
2596        let tmp = tempfile::tempdir().unwrap();
2597        // No relay.json under <home>/config/wire/ — should yield empty.
2598        let endpoints = read_session_endpoints(tmp.path());
2599        assert!(endpoints.is_empty());
2600    }
2601
2602    #[test]
2603    fn read_session_endpoints_parses_dual_slot_form() {
2604        let tmp = tempfile::tempdir().unwrap();
2605        let cfg = tmp.path().join("config").join("wire");
2606        std::fs::create_dir_all(&cfg).unwrap();
2607        let body = serde_json::json!({
2608            "self": {
2609                "relay_url": "https://wireup.net",
2610                "slot_id": "fed-slot",
2611                "slot_token": "fed-tok",
2612                "endpoints": [
2613                    {
2614                        "relay_url": "https://wireup.net",
2615                        "slot_id": "fed-slot",
2616                        "slot_token": "fed-tok",
2617                        "scope": "federation"
2618                    },
2619                    {
2620                        "relay_url": "http://127.0.0.1:8771",
2621                        "slot_id": "loop-slot",
2622                        "slot_token": "loop-tok",
2623                        "scope": "local"
2624                    }
2625                ]
2626            }
2627        });
2628        std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
2629        let endpoints = read_session_endpoints(tmp.path());
2630        assert_eq!(endpoints.len(), 2);
2631        let local_count = endpoints
2632            .iter()
2633            .filter(|e| matches!(e.scope, EndpointScope::Local))
2634            .count();
2635        assert_eq!(local_count, 1);
2636        let local = endpoints
2637            .iter()
2638            .find(|e| matches!(e.scope, EndpointScope::Local))
2639            .unwrap();
2640        assert_eq!(local.relay_url, "http://127.0.0.1:8771");
2641        assert_eq!(local.slot_id, "loop-slot");
2642    }
2643
2644    // NOTE: list_local_sessions is integration-tested via tests/cli.rs
2645    // using a subprocess that sets WIRE_HOME per-process. We do not test
2646    // it in-module because env mutation races other parallel unit tests
2647    // (Rust 2024 marks std::env::set_var unsafe for that reason). The
2648    // grouping logic is straightforward enough that the integration
2649    // test plus the read_session_endpoints unit tests above provide
2650    // adequate coverage.
2651
2652    #[test]
2653    fn derive_name_appends_path_hash_when_basename_collides() {
2654        let mut reg = SessionRegistry::default();
2655        reg.by_cwd
2656            .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
2657        // Different cwd, same basename → must get a hash suffix.
2658        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
2659        assert!(name.starts_with("wire-"));
2660        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
2661        assert_ne!(name, "wire");
2662    }
2663
2664    // ---------- identity-collision warning (issue #29/#30 — broaden to
2665    // every inbox-cursor-owning subcommand, not just `wire mcp`). ----------
2666
2667    #[test]
2668    fn inbox_owning_subcommands_covers_each_runtime_role() {
2669        // Lock the role list down — any addition / removal here must
2670        // come with an updated call site (cli::cmd_daemon, cmd_monitor,
2671        // cmd_notify, mcp::run) and an updated rendezvous in the pgrep
2672        // predicate. The pgrep predicate is built from this list at
2673        // call time, so adding "watch" here automatically extends
2674        // detection — but the warning is only fired if a call site
2675        // also invokes warn_on_identity_collision with that role.
2676        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"mcp"));
2677        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"daemon"));
2678        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"monitor"));
2679        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"notify"));
2680        // pair-host (SAS code-phrase flow) was removed in RFC-005 follow-on
2681        // and must not appear in the list.
2682        assert!(!INBOX_OWNING_SUBCOMMANDS.contains(&"pair-host"));
2683    }
2684
2685    #[test]
2686    fn find_colliders_returns_only_same_home_pids() {
2687        let our_home = "/tmp/wire-home-A";
2688        let others = vec![
2689            (101, Some("/tmp/wire-home-A".to_string())), // collide
2690            (102, Some("/tmp/wire-home-B".to_string())), // distinct home
2691            (103, None),                                 // env-unreadable, skip
2692            (104, Some("/tmp/wire-home-A".to_string())), // collide
2693        ];
2694        let colliders = find_colliders(our_home, &others);
2695        assert_eq!(colliders, vec![101, 104]);
2696    }
2697
2698    #[test]
2699    fn find_colliders_no_match_returns_empty() {
2700        let our_home = "/tmp/wire-home-A";
2701        let others = vec![
2702            (101, Some("/tmp/wire-home-B".to_string())),
2703            (102, Some("/tmp/wire-home-C".to_string())),
2704            (103, None),
2705        ];
2706        assert!(find_colliders(our_home, &others).is_empty());
2707    }
2708
2709    #[test]
2710    fn find_colliders_empty_input_is_empty() {
2711        assert!(find_colliders("/tmp/anywhere", &[]).is_empty());
2712    }
2713
2714    #[test]
2715    fn find_colliders_ignores_substring_matches() {
2716        // `WIRE_HOME=/wire-A` must NOT collide with `WIRE_HOME=/wire-A/sub`.
2717        // Exact-match semantics protect against parent/child confusion.
2718        let our_home = "/tmp/wire-A";
2719        let others = vec![
2720            (201, Some("/tmp/wire-A/sub".to_string())),
2721            (202, Some("/wire-A".to_string())), // distinct path
2722            (203, Some("/tmp/wire-A".to_string())), // real collision
2723        ];
2724        assert_eq!(find_colliders(our_home, &others), vec![203]);
2725    }
2726
2727    #[test]
2728    fn collision_warning_format_includes_role_home_and_pids() {
2729        // Sanity-check the first warning line by reconstructing it
2730        // exactly the way `emit_collision_warning` does. If anyone
2731        // changes the format, this test must change with it — that's
2732        // the point: the format is a documented operator-facing
2733        // surface (Willard's #30 cited the older wording verbatim
2734        // when filing the bug).
2735        let role = "daemon";
2736        let home = "/tmp/by-key/abc123";
2737        let colliders = vec![4242u32, 4243u32];
2738        let expected_head = format!(
2739            "wire {role}: WARNING — {n} other wire process(es) already using WIRE_HOME=`{home}` (pid {pids})",
2740            n = colliders.len(),
2741            pids = colliders
2742                .iter()
2743                .map(u32::to_string)
2744                .collect::<Vec<_>>()
2745                .join(", "),
2746        );
2747        assert_eq!(
2748            expected_head,
2749            "wire daemon: WARNING — 2 other wire process(es) already using WIRE_HOME=`/tmp/by-key/abc123` (pid 4242, 4243)"
2750        );
2751        // Exercise the renderer so it can't bit-rot via dead-code
2752        // pruning. Output goes to stderr; under libtest it's captured.
2753        emit_collision_warning(role, home, &colliders);
2754    }
2755
2756    /// RFC-008 §C — `is_by_key_shape` must distinguish modern operator-
2757    /// explicit `sessions/by-key/<16-hex>` pins from every other path
2758    /// shape (legacy cwd-derived `sessions/<name>/config/wire`, foreign
2759    /// paths, malformed by-key dirs). Fast — used at every wire startup
2760    /// when WIRE_HOME is set — so this asserts cross-platform path-sep
2761    /// handling without allocating.
2762    #[test]
2763    fn is_by_key_shape_recognizes_modern_pin() {
2764        // Linux/macOS modern shape.
2765        assert!(is_by_key_shape(
2766            "/home/dev/.local/state/wire/sessions/by-key/0c38ce498aa9d955"
2767        ));
2768        // Windows modern shape (escaped backslashes).
2769        assert!(is_by_key_shape(
2770            "C:\\Users\\Willard\\AppData\\Local\\wire\\sessions\\by-key\\0c38ce498aa9d955"
2771        ));
2772        // With trailing path segments (e.g. /config/wire suffix).
2773        assert!(is_by_key_shape(
2774            "/home/dev/.local/state/wire/sessions/by-key/abcdef0123456789/config/wire"
2775        ));
2776        assert!(is_by_key_shape(
2777            "C:\\wire\\sessions\\by-key\\abcdef0123456789\\config\\wire"
2778        ));
2779    }
2780
2781    #[test]
2782    fn is_by_key_shape_rejects_legacy_and_malformed() {
2783        // Legacy cwd-derived shape (the #210 case).
2784        assert!(!is_by_key_shape(
2785            "C:\\Users\\Willard\\AppData\\Local\\wire\\sessions\\willard\\config\\wire"
2786        ));
2787        assert!(!is_by_key_shape(
2788            "/home/dev/.local/state/wire/sessions/projx/config/wire"
2789        ));
2790        // Foreign path, no `by-key/` at all.
2791        assert!(!is_by_key_shape("/tmp/some-fleet-shared-dir"));
2792        // by-key with wrong hash length (8 hex — too short).
2793        assert!(!is_by_key_shape("/wire/sessions/by-key/abcdef01"));
2794        // by-key with non-hex chars in hash.
2795        assert!(!is_by_key_shape("/wire/sessions/by-key/not-a-hex-hash"));
2796        // by-key with uppercase hex (wire writes lowercase only).
2797        assert!(!is_by_key_shape("/wire/sessions/by-key/0C38CE498AA9D955"));
2798        // by-key with hash too long (32 hex).
2799        assert!(!is_by_key_shape(
2800            "/wire/sessions/by-key/0c38ce498aa9d9550c38ce498aa9d955"
2801        ));
2802        // Empty path.
2803        assert!(!is_by_key_shape(""));
2804    }
2805
2806    /// RFC-008 §C — verify a path containing `by-key` as a partial
2807    /// substring (NOT as a path segment) is correctly rejected. Catches a
2808    /// regex-style regression where the matcher splits on the wrong
2809    /// delimiter.
2810    #[test]
2811    fn is_by_key_shape_substring_not_segment_rejected() {
2812        // `by-key` appears, but NOT as a path segment — should reject.
2813        assert!(!is_by_key_shape(
2814            "/wire/sessions/foo-by-key-bar/0c38ce498aa9d955"
2815        ));
2816        // by-key/ but no hash after (just the bare dir).
2817        assert!(!is_by_key_shape("/wire/sessions/by-key/"));
2818        assert!(!is_by_key_shape("/wire/sessions/by-key"));
2819    }
2820
2821    // ---------- #247 finding 4: Windows pidfile-scan path ----------
2822
2823    #[test]
2824    fn find_home_for_pid_matches_first_recorded_session() {
2825        let pairs = vec![
2826            ("/wire/sessions/by-key/aaaaaaaaaaaaaaaa".to_string(), 1001),
2827            ("/wire/sessions/by-key/bbbbbbbbbbbbbbbb".to_string(), 1002),
2828            ("/wire/sessions/by-key/cccccccccccccccc".to_string(), 1003),
2829        ];
2830        assert_eq!(
2831            find_home_for_pid(1002, &pairs).as_deref(),
2832            Some("/wire/sessions/by-key/bbbbbbbbbbbbbbbb")
2833        );
2834    }
2835
2836    #[test]
2837    fn find_home_for_pid_returns_none_when_no_session_owns_pid() {
2838        let pairs = vec![("/home-A".to_string(), 1001), ("/home-B".to_string(), 1002)];
2839        assert_eq!(find_home_for_pid(9999, &pairs), None);
2840    }
2841
2842    #[test]
2843    fn find_home_for_pid_empty_input_is_none() {
2844        assert_eq!(find_home_for_pid(1234, &[]), None);
2845    }
2846
2847    #[test]
2848    fn find_home_for_pid_handles_one_pid_in_multiple_roles() {
2849        // Realistic shape on Windows: a single wire process serves
2850        // multiple inbox-owning roles for one session (e.g. the same
2851        // pid in daemon.pid + mcp.pid, hypothetical). Pidfile-scan
2852        // emits one entry per (home, role) pair, so the same pid
2853        // appears multiple times under the same home — first match
2854        // wins, which is correct.
2855        let pairs = vec![("/home-A".to_string(), 4242), ("/home-A".to_string(), 4242)];
2856        assert_eq!(find_home_for_pid(4242, &pairs).as_deref(), Some("/home-A"));
2857    }
2858
2859    #[test]
2860    fn session_role_pid_reads_json_pid_from_role_pidfile() {
2861        use std::io::Write as _;
2862        let tmp = tempfile::tempdir().unwrap();
2863        let pid_dir = tmp.path().join("state").join("wire");
2864        std::fs::create_dir_all(&pid_dir).unwrap();
2865
2866        // Mimic the JSON shape ensure_up::write_pid_record produces.
2867        let pidfile = pid_dir.join("mcp.pid");
2868        let mut f = std::fs::File::create(&pidfile).unwrap();
2869        write!(&mut f, r#"{{"pid":7777,"version":"0.16.0"}}"#).unwrap();
2870
2871        assert_eq!(session_role_pid(tmp.path(), "mcp"), Some(7777));
2872        // A role with no pidfile reads as None.
2873        assert_eq!(session_role_pid(tmp.path(), "monitor"), None);
2874        // Corrupt body reads as None (no panic).
2875        std::fs::write(pid_dir.join("notify.pid"), b"this is not json").unwrap();
2876        assert_eq!(session_role_pid(tmp.path(), "notify"), None);
2877    }
2878
2879    // ---------- #284.4: is_unexpected_session_source predicate ----------
2880
2881    #[test]
2882    fn is_unexpected_session_source_flags_machine_default_and_minted() {
2883        assert!(is_unexpected_session_source("machine-default"));
2884        assert!(is_unexpected_session_source("minted"));
2885    }
2886
2887    #[test]
2888    fn is_unexpected_session_source_passes_explicit_sources() {
2889        // Every adapter that names an explicit launcher signal counts
2890        // as "the operator actually meant this identity."
2891        for ok in [
2892            "env:WIRE_HOME",
2893            "env:WIRE_HOME_FORCE",
2894            "override",
2895            "claude-code",
2896            "claude-code-pidfile",
2897            "codex-cli",
2898            "copilot-cli",
2899            "vscode-workspace",
2900        ] {
2901            assert!(
2902                !is_unexpected_session_source(ok),
2903                "explicit source `{ok}` must NOT be flagged"
2904            );
2905        }
2906    }
2907
2908    #[test]
2909    fn is_unexpected_session_source_handles_unknown_conservatively() {
2910        // `unknown` means adoption never ran — treat as a separate
2911        // failure mode, NOT as the same class as machine-default /
2912        // minted. Callers (warn_if_unexpected_session_source) can
2913        // decide what to do with it; this predicate stays narrow.
2914        assert!(!is_unexpected_session_source("unknown"));
2915        // Future adapter labels likewise default to "explicit"
2916        // until a maintainer opts them in.
2917        assert!(!is_unexpected_session_source("future-adapter-X"));
2918    }
2919}