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    let state = dirs::state_dir()
85        .or_else(dirs::data_local_dir)
86        .ok_or_else(|| {
87            anyhow!(
88                "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
89                 set WIRE_HOME or run on a platform with `dirs` support"
90            )
91        })?;
92    Ok(state.join("wire").join("sessions"))
93}
94
95/// Full filesystem path for the named session's WIRE_HOME root.
96/// Inside this dir the standard wire layout applies: `config/wire/...`
97/// and `state/wire/...`.
98///
99/// Resolves the *legacy v0.6 top-level* layout only — joins the
100/// session name directly onto `sessions_root`. Operator-facing CLI
101/// paths that accept a user-typed session name should use
102/// [`find_session_home_by_name`] instead, which also handles the
103/// v0.13 `by-key/<hash>` layout where the on-disk dir name is a hash
104/// and the user-facing name is the persona handle derived from the
105/// card.
106pub fn session_dir(name: &str) -> Result<PathBuf> {
107    Ok(sessions_root()?.join(sanitize_name(name)))
108}
109
110/// Operator-facing session-name → home_dir resolver. Handles BOTH
111/// layouts wire has shipped:
112///
113/// 1. **v0.6 top-level**: `sessions_root/<name>` — the user-typed
114///    name IS the directory name. [`session_dir`] is the direct
115///    primitive.
116/// 2. **v0.13 by-key/<hash>**: the on-disk dir is a 16-hex hash but
117///    operators type the persona handle (`coral-weasel`,
118///    `agate-nimbus`) — derived from the card's DID. [`list_sessions`]
119///    surfaces those entries with `SessionInfo.name = handle`, so we
120///    can walk it and match.
121///
122/// Order: try the literal top-level path first (fast, no enumeration),
123/// then fall back to a `list_sessions` walk for the by-key handle
124/// case. Returns `Ok(None)` when neither layout has a match — the
125/// caller decides whether to error or no-op.
126///
127/// v0.14.2 (#170 follow-up from #174's PR body): operators running
128/// `wire daemon --session foo` from a tmux pane on a v0.13 box hit
129/// `session 'foo' not found` because the literal path didn't exist.
130/// That's #174's exact failure mode (supervisor case, now fixed via
131/// env-pinned WIRE_HOME) reapplied to the operator-facing CLI path.
132pub fn find_session_home_by_name(name: &str) -> Result<Option<PathBuf>> {
133    // 1. Legacy literal lookup.
134    let direct = session_dir(name)?;
135    if direct.exists() {
136        return Ok(Some(direct));
137    }
138    // 2. v0.13 by-key walk: list_sessions overrides SessionInfo.name to
139    // the handle when the card is present; match against either the
140    // overridden name or the raw by-key hash.
141    let sanitized = sanitize_name(name);
142    for info in list_sessions().unwrap_or_default() {
143        if info.name == name
144            || info.name == sanitized
145            || info
146                .home_dir
147                .file_name()
148                .and_then(|s| s.to_str())
149                .map(|f| f == name)
150                .unwrap_or(false)
151        {
152            return Ok(Some(info.home_dir));
153        }
154    }
155    Ok(None)
156}
157
158/// Registry tracks `cwd → session_name` so repeated `wire session new`
159/// from the same project reuses the same identity instead of creating
160/// a fresh one each time. Lives at `<sessions_root>/registry.json`.
161pub fn registry_path() -> Result<PathBuf> {
162    Ok(sessions_root()?.join("registry.json"))
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
166pub struct SessionRegistry {
167    /// `cwd_absolute_path → session_name`. Absent if cwd has not been
168    /// associated with a session yet.
169    #[serde(default)]
170    pub by_cwd: HashMap<String, String>,
171}
172
173pub fn read_registry() -> Result<SessionRegistry> {
174    let path = registry_path()?;
175    if !path.exists() {
176        return Ok(SessionRegistry::default());
177    }
178    let bytes =
179        std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
180    serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
181}
182
183pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
184    let path = registry_path()?;
185    if let Some(parent) = path.parent() {
186        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
187    }
188    let body = serde_json::to_vec_pretty(reg)?;
189    // v0.7.0-alpha.8 (review-fix #7): atomic write via tmp+rename so
190    // concurrent unflocked readers (detect_session_wire_home,
191    // list_sessions, cmd_peers) never observe a 0-byte / truncated
192    // registry mid-write. Pre-alpha.8 used std::fs::write which
193    // truncates first — race window where readers saw empty JSON and
194    // fell back to default identity for the write duration.
195    let tmp = path.with_extension("json.tmp");
196    std::fs::write(&tmp, body).with_context(|| format!("writing tmp session registry {tmp:?}"))?;
197    std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
198    Ok(())
199}
200
201/// v0.7.0-alpha.3: flock'd read-modify-write of the session registry.
202///
203/// `write_registry` alone is not safe under concurrency — multiple MCP
204/// processes auto-initing in parallel each read an old snapshot, mutate
205/// their copy, and write back, losing N-1 updates. This helper acquires
206/// an exclusive flock on a sibling lockfile, re-reads inside the lock,
207/// applies the caller's modifier, writes atomically, and releases.
208///
209/// Modeled on `config::update_relay_state`. Lock contention is bounded:
210/// modifications are pure HashMap operations, write is whole-file at
211/// roughly the registry size (KBs, not MBs).
212pub fn update_registry<F>(modifier: F) -> Result<()>
213where
214    F: FnOnce(&mut SessionRegistry) -> Result<()>,
215{
216    use fs2::FileExt;
217    let path = registry_path()?;
218    if let Some(parent) = path.parent() {
219        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
220    }
221    let lock_path = path.with_extension("lock");
222    let lock_file = std::fs::OpenOptions::new()
223        .create(true)
224        .truncate(false)
225        .read(true)
226        .write(true)
227        .open(&lock_path)
228        .with_context(|| format!("opening {lock_path:?}"))?;
229    lock_file
230        .lock_exclusive()
231        .with_context(|| format!("flock {lock_path:?}"))?;
232    // Re-read INSIDE the lock — any prior snapshot would race.
233    let mut reg = read_registry().unwrap_or_default();
234    let result = modifier(&mut reg);
235    let write_result = if result.is_ok() {
236        write_registry(&reg)
237    } else {
238        Ok(())
239    };
240    let _ = fs2::FileExt::unlock(&lock_file);
241    result?;
242    write_result?;
243    Ok(())
244}
245
246/// Sanitize an arbitrary string to a session-name-safe form: lowercase
247/// ASCII alphanumeric + `-` + `_`, replace other chars with `-`,
248/// dedupe consecutive dashes, trim leading/trailing dashes, max 32 chars.
249pub fn sanitize_name(raw: &str) -> String {
250    let mut out = String::with_capacity(raw.len());
251    let mut prev_dash = false;
252    for c in raw.chars() {
253        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
254        let ch = if ok { c.to_ascii_lowercase() } else { '-' };
255        if ch == '-' {
256            if !prev_dash && !out.is_empty() {
257                out.push('-');
258            }
259            prev_dash = true;
260        } else {
261            out.push(ch);
262            prev_dash = false;
263        }
264    }
265    let trimmed = out.trim_matches('-').to_string();
266    if trimmed.is_empty() {
267        return "wire-session".to_string();
268    }
269    if trimmed.len() > 32 {
270        return trimmed[..32].trim_end_matches('-').to_string();
271    }
272    trimmed
273}
274
275/// Short hash suffix derived from the full absolute path of the cwd.
276/// Used to disambiguate two different projects whose basenames collide
277/// (e.g. `~/Source/wire` and `~/Archive/wire`).
278fn path_hash_suffix(cwd: &Path) -> String {
279    let bytes = cwd.as_os_str().to_string_lossy().into_owned();
280    let mut h = Sha256::new();
281    h.update(bytes.as_bytes());
282    let digest = h.finalize();
283    hex::encode(&digest[..2]) // 4 hex chars
284}
285
286/// v0.13.6: case-insensitive cwd-registry key on Windows.
287///
288/// Issue #30 (Willard repro): on Windows, two terminals in the "same"
289/// project under different drive/path casing (`C:\Foo\Bar` vs
290/// `C:\foo\bar`) hashed to DIFFERENT registry keys — the second
291/// terminal's `wire whoami` missed the registry lookup, derived a
292/// phantom name, and silently fell back to the legacy default identity
293/// (e.g. `did:wire:willard`). Both terminals collapsed onto one shared
294/// DID, every pairing attempt between them was a self-pair, and
295/// bilateral handshake could never complete.
296///
297/// Fix: on Windows, lowercase the cwd before reading from OR writing to
298/// the cwd→session map. Two paths that resolve to the same on-disk
299/// directory now produce the same registry key regardless of how the
300/// shell / launcher capitalized them.
301///
302/// On case-sensitive filesystems (Linux / macOS HFS+ / case-sensitive
303/// APFS / NTFS in case-sensitive mode) the path is returned as-is —
304/// distinct casings legitimately point at distinct directories.
305///
306/// Used at every read and write of `SessionRegistry.by_cwd` so old
307/// non-canonical entries written by v0.13.5 still resolve under v0.13.6+
308/// later, and new entries written under v0.13.6+ are immediately canonical.
309pub fn normalize_cwd_key(path: &Path) -> String {
310    let s = path.to_string_lossy().into_owned();
311    if cfg!(windows) { s.to_lowercase() } else { s }
312}
313
314/// Derive a stable session name for the given cwd. Resolution order:
315///
316/// 1. If the registry already maps this cwd → name, return that name.
317/// 2. Else: candidate = sanitize(basename(cwd)). If the candidate is
318///    already mapped to a DIFFERENT cwd in the registry, append a
319///    4-char path-hash suffix to avoid collision.
320/// 3. If still a collision: append a numeric suffix `-2`, `-3`, ...
321///    until unique.
322pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
323    let cwd_key = normalize_cwd_key(cwd);
324    // Backward compat: O(n) normalized scan on read-miss.
325    //
326    // Per @laulpogan / coral-weasel correction on #67: a verbatim fallback
327    // (try the raw lookup string if the normalized lookup misses) only
328    // handles consistent-casing upgraders — it can't recover a
329    // mixed-case stored key (`C:\Users\Willard\...`) from a different-
330    // case lookup (`c:\users\willard\...`) because both raw and
331    // normalized lookup strings derive from the LOOKUP path; the
332    // stored key's original casing is unrecoverable from the lookup
333    // alone.
334    //
335    // The O(n) scan handles both cases:
336    //   - Consistent casing: normalize(stored) == cwd_key on the FIRST
337    //     `.get` (no scan needed; happy path is O(1)).
338    //   - Cross casing: stored "C:\Users\Willard" normalizes to
339    //     "c:\users\willard" == cwd_key → the scan resolves it.
340    //
341    // O(n) is over the per-machine session count (typically <20),
342    // hit only on the rare upgrader-misses-normalized-lookup case.
343    // New writes are normalized (see cli.rs insert sites) so the
344    // scan-cost shrinks to zero as old entries get touched.
345    if let Some(existing) = registry.by_cwd.get(&cwd_key).or_else(|| {
346        registry
347            .by_cwd
348            .iter()
349            .find(|(k, _)| normalize_cwd_key(Path::new(k)) == cwd_key)
350            .map(|(_, v)| v)
351    }) {
352        return existing.clone();
353    }
354    let base = cwd
355        .file_name()
356        .and_then(|s| s.to_str())
357        .map(sanitize_name)
358        .unwrap_or_else(|| "wire-session".to_string());
359    let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
360    if !occupied.contains(&base) {
361        return base;
362    }
363    let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
364    if !occupied.contains(&with_hash) {
365        return with_hash;
366    }
367    // Highly unlikely (would require a SHA-256 prefix collision plus an
368    // existing entry to claim it). Numeric tiebreaker as final fallback.
369    for n in 2..1000 {
370        let candidate = format!("{base}-{n}");
371        if !occupied.contains(&candidate) {
372            return candidate;
373        }
374    }
375    // Pathological fallback — every numbered slot is taken.
376    format!("{base}-{}-overflow", path_hash_suffix(cwd))
377}
378
379/// Summary of one on-disk session for `wire session list`.
380#[derive(Debug, Clone, Serialize)]
381pub struct SessionInfo {
382    pub name: String,
383    /// First cwd associated with this session in the registry. `None`
384    /// if the session was created without registry tracking (manual
385    /// `wire session new <name>`).
386    pub cwd: Option<String>,
387    pub home_dir: PathBuf,
388    pub did: Option<String>,
389    pub handle: Option<String>,
390    /// True if a `daemon.pid` file exists AND the recorded PID is
391    /// actually a live process (best-effort, not POSIX-portable but
392    /// matches the existing `wire status` / `wire doctor` checks).
393    pub daemon_running: bool,
394    /// Display character (nickname + emoji + color palette) derived from
395    /// the session's DID. `None` when the session has no agent-card yet
396    /// (pre-init). Lazy-computed at read time; never persisted to disk.
397    pub character: Option<crate::character::Character>,
398}
399
400/// Enumerate every on-disk session by reading `sessions_root()`. Cross-
401/// references the registry so each entry's `cwd` is filled in when known.
402/// v0.7.4: true iff the URL targets a loopback host (127.0.0.0/8 or
403/// [::1] or `localhost`). Used to detect "this Federation-scope slot
404/// is actually on a loopback relay" — those sessions are local-mesh
405/// candidates even though they're not tagged `local`.
406///
407/// Best-effort string match; we don't need full URL parsing for this
408/// because the relay URL is wire-controlled and follows a predictable
409/// shape (`http://<host>[:<port>][/path]`).
410fn url_is_loopback(url: &str) -> bool {
411    let lower = url.to_ascii_lowercase();
412    let after_scheme = match lower.split_once("://") {
413        Some((_, rest)) => rest,
414        None => lower.as_str(),
415    };
416    // Bracketed IPv6 literal: `[::1]:8771` keeps brackets in host slice.
417    if let Some(rest) = after_scheme.strip_prefix('[') {
418        return rest
419            .split_once(']')
420            .map(|(host, _)| host == "::1")
421            .unwrap_or(false);
422    }
423    let host = after_scheme.split(['/', ':']).next().unwrap_or("");
424    host == "localhost" || host == "127.0.0.1" || host.starts_with("127.")
425}
426
427/// v0.7.4: resolve an operator-typed name to a local sister session.
428/// Input may be the session NAME (e.g. `slancha-api`), the card
429/// HANDLE (usually equal to the name), or the character NICKNAME
430/// (e.g. `noble-slate`). Returns the session NAME suitable for the
431/// `--local-sister` add path. Case-insensitive. None on no match.
432///
433/// Designed for `wire add <input>` ergonomics — the operator should
434/// be able to type whatever face wire put on the peer (statusline
435/// nickname, session list emoji+name) and have wire find it.
436pub fn resolve_local_sister(input: &str) -> Option<String> {
437    let needle = input.trim();
438    if needle.is_empty() {
439        return None;
440    }
441    let sessions = list_sessions().ok()?;
442    for s in &sessions {
443        if s.name.eq_ignore_ascii_case(needle) {
444            return Some(s.name.clone());
445        }
446        if let Some(h) = &s.handle
447            && h.eq_ignore_ascii_case(needle)
448        {
449            return Some(s.name.clone());
450        }
451        if let Some(ch) = &s.character
452            && ch.nickname.eq_ignore_ascii_case(needle)
453        {
454            return Some(s.name.clone());
455        }
456    }
457    None
458}
459
460pub fn list_sessions() -> Result<Vec<SessionInfo>> {
461    let root = sessions_root()?;
462    if !root.exists() {
463        return Ok(Vec::new());
464    }
465    let registry = read_registry().unwrap_or_default();
466    // Reverse lookup: name → cwd. Used to annotate each SessionInfo.
467    let mut name_to_cwd: HashMap<String, String> = HashMap::new();
468    for (cwd, name) in &registry.by_cwd {
469        name_to_cwd.insert(name.clone(), cwd.clone());
470    }
471
472    // Build a SessionInfo from a home dir, labeled `name`. v0.11: character
473    // is purely DID-derived (local display.json overrides removed).
474    let mk = |path: PathBuf, name: String| -> SessionInfo {
475        let card_path = path.join("config").join("wire").join("agent-card.json");
476        let (did, handle) = read_card_identity(&card_path);
477        let daemon_running = check_daemon_live(&path);
478        let character = did.as_deref().map(crate::character::Character::from_did);
479        SessionInfo {
480            cwd: name_to_cwd.get(&name).cloned(),
481            name,
482            home_dir: path,
483            did,
484            handle,
485            daemon_running,
486            character,
487        }
488    };
489
490    let mut out = Vec::new();
491    for entry in std::fs::read_dir(&root)?.flatten() {
492        let path = entry.path();
493        if !path.is_dir() {
494            continue;
495        }
496        let name = match path.file_name().and_then(|s| s.to_str()) {
497            Some(s) => s.to_string(),
498            None => continue,
499        };
500        // Skip the registry sidecar.
501        if name == "registry.json" {
502            continue;
503        }
504        // v0.13: session homes live under `by-key/<hash>`, not at the top
505        // level. Descend one level so same-box discovery (`list-local` /
506        // `pair-all-local`) sees them — the `by-key` dir itself is a
507        // container, not a session. Without this, EVERY v0.13 session was
508        // invisible to the local mesh, silently forcing same-box sisters
509        // onto federation instead of fast loopback routing.
510        if name == "by-key" {
511            for sub in std::fs::read_dir(&path)?.flatten() {
512                let sub_path = sub.path();
513                if !sub_path.is_dir() {
514                    continue;
515                }
516                let hash = sub_path
517                    .file_name()
518                    .and_then(|s| s.to_str())
519                    .unwrap_or("?")
520                    .to_string();
521                let mut info = mk(sub_path, hash);
522                // E8 (v0.13.2): skip uninitialized by-key homes. maybe_adopt_
523                // session_wire_home creates the home dir on first resolution —
524                // before any identity exists — so transient/probe session keys
525                // that never `wire up` leave empty or agent-card-less homes.
526                // Without this filter they surfaced as phantom "?"-handle
527                // sisters in list-local, degrading the very discovery rc3
528                // fixed. No DID == no identity == not a session.
529                if info.did.is_none() {
530                    continue;
531                }
532                // Prefer the persona handle as the display name when the home
533                // is initialized; fall back to the by-key hash otherwise.
534                if let Some(h) = info.handle.clone() {
535                    info.name = h;
536                }
537                out.push(info);
538            }
539            continue;
540        }
541        out.push(mk(path, name));
542    }
543    out.sort_by(|a, b| a.name.cmp(&b.name));
544    Ok(out)
545}
546
547fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
548    let bytes = match std::fs::read(card_path) {
549        Ok(b) => b,
550        Err(_) => return (None, None),
551    };
552    let v: serde_json::Value = match serde_json::from_slice(&bytes) {
553        Ok(v) => v,
554        Err(_) => return (None, None),
555    };
556    let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
557    let handle = v
558        .get("handle")
559        .and_then(|x| x.as_str())
560        .map(str::to_string)
561        .or_else(|| {
562            did.as_ref()
563                .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
564        });
565    (did, handle)
566}
567
568/// Read a session home's daemon pid from `<home>/state/wire/daemon.pid`
569/// (path-based; does NOT consult WIRE_HOME). None if absent/corrupt. Used to
570/// enumerate which daemon pids legitimately belong to a session so orphan
571/// detection doesn't flag a sibling session's daemon (A2).
572pub fn session_daemon_pid(session_home: &Path) -> Option<u32> {
573    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
574    let bytes = std::fs::read(&pidfile).ok()?;
575    // Pidfile is either JSON `{"pid": <n>, ...}` (v0.5.11+) or a bare
576    // integer (legacy). Try JSON-with-pid-field first; if that yields
577    // None (parse failed OR JSON parsed successfully as a bare Number
578    // with no `.pid` field), fall through to the bare-int path.
579    // Pre-fix: a legacy bare-integer pidfile silently returned None
580    // here because `serde_json::from_slice("67890")` succeeds as
581    // Value::Number, then `v.get("pid")` is None, and the else
582    // branch never ran. Caused legitimate pre-v0.5.11 sessions to
583    // read as "no daemon" in list-local and orphan-detection paths.
584    serde_json::from_slice::<serde_json::Value>(&bytes)
585        .ok()
586        .and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
587        .or_else(|| String::from_utf8_lossy(&bytes).trim().parse::<u64>().ok())
588        .map(|p| p as u32)
589}
590
591fn check_daemon_live(session_home: &Path) -> bool {
592    session_daemon_pid(session_home)
593        .map(is_process_live)
594        .unwrap_or(false)
595}
596
597/// Walk every initialized session and read its `daemon.pid`; return a
598/// map from `pid → session_name`. Used by `wire status`'s orphan-pid
599/// annotation (#173 follow-up) so a supervisor child's pid — which
600/// no longer carries `--session <name>` in its cmdline post-#174 — is
601/// still correctly attributed to the session whose home it serves.
602///
603/// Cost: one filesystem read per session per status invocation. On a
604/// 133-session box that's 133 small reads (a few ms total) — bounded
605/// + acceptable. The map is fresh per call; no caching, no staleness.
606pub fn pid_to_session_map() -> HashMap<u32, String> {
607    let mut out = HashMap::new();
608    let sessions = match list_sessions() {
609        Ok(v) => v,
610        Err(_) => return out,
611    };
612    for info in sessions {
613        if let Some(pid) = session_daemon_pid(&info.home_dir) {
614            out.insert(pid, info.name);
615        }
616    }
617    out
618}
619
620fn is_process_live(pid: u32) -> bool {
621    // v0.7.3: delegate to the shared platform helper. The previous
622    // implementation shelled out to `kill -0` on non-Linux, which
623    // unconditionally failed on Windows (no `kill` binary) and made
624    // `wire session list` report every daemon as `down` regardless of
625    // actual liveness.
626    crate::platform::process_alive(pid)
627}
628
629/// Read a session's `relay.json` and return its `self.endpoints[]`
630/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
631/// is a best-effort discovery helper, not a verification tool. A pre-
632/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
633/// promotes those to a federation-only Endpoint, so the result is
634/// still meaningful for legacy sessions.
635///
636/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
637/// not the canonical filename (`config::relay_state_path` returns
638/// `relay.json`). The mis-named read silently no-op'd and
639/// `list-local` always returned an empty `local` map as a result.
640/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
641/// in the same release — that helper had the symmetric write-side
642/// bug, so the local endpoint never got persisted in the first place.
643pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
644    let path = session_home.join("config").join("wire").join("relay.json");
645    let bytes = match std::fs::read(&path) {
646        Ok(b) => b,
647        Err(_) => return Vec::new(),
648    };
649    let val: Value = match serde_json::from_slice(&bytes) {
650        Ok(v) => v,
651        Err(_) => return Vec::new(),
652    };
653    self_endpoints(&val)
654}
655
656/// Stripped view of a Local endpoint for tooling output. Drops
657/// `slot_token` because it is a bearer credential — exposing it
658/// through `wire session list-local --json` would risk accidental
659/// leak via logs, screenshots, or piped output. Routing code uses
660/// the full `Endpoint` from `relay.json` directly; this type
661/// is for human/JSON observation only.
662#[derive(Debug, Clone, Serialize)]
663pub struct LocalEndpointView {
664    pub relay_url: String,
665    pub slot_id: String,
666}
667
668/// One row of `wire session list-local` output: a session that has a
669/// Local-scope endpoint plus metadata to render it.
670#[derive(Debug, Clone, Serialize)]
671pub struct LocalSessionView {
672    pub name: String,
673    pub handle: Option<String>,
674    pub did: Option<String>,
675    pub cwd: Option<String>,
676    pub home_dir: PathBuf,
677    pub daemon_running: bool,
678    /// All Local-scope endpoints this session advertises (token redacted).
679    /// Most sessions have exactly one; multiple is permitted for multi-
680    /// relay setups.
681    pub local_endpoints: Vec<LocalEndpointView>,
682}
683
684/// Sessions with no Local endpoint — shown separately so the operator
685/// knows they exist but are federation-only.
686#[derive(Debug, Clone, Serialize)]
687pub struct FederationOnlySessionView {
688    pub name: String,
689    pub handle: Option<String>,
690    pub cwd: Option<String>,
691}
692
693/// Result shape for `wire session list-local`. `local` is grouped by
694/// the local-relay URL so output can render each cluster of mutually-
695/// reachable sister sessions together. `federation_only` lists the rest.
696#[derive(Debug, Clone, Serialize)]
697pub struct LocalSessionListing {
698    pub local: HashMap<String, Vec<LocalSessionView>>,
699    pub federation_only: Vec<FederationOnlySessionView>,
700}
701
702/// Build the listing for `wire session list-local` from current on-disk
703/// state. Read-only; no daemon contact, no relay probe.
704pub fn list_local_sessions() -> Result<LocalSessionListing> {
705    let sessions = list_sessions()?;
706    let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
707    let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
708
709    for s in sessions {
710        let endpoints = read_session_endpoints(&s.home_dir);
711        let local_eps: Vec<Endpoint> = endpoints
712            .into_iter()
713            .filter(|e| {
714                // v0.7.4: include any session whose endpoint URL is a
715                // loopback address even if it's tagged Federation, not
716                // Local. This catches the legitimate-but-misshapen case
717                // where `wire init --relay http://127.0.0.1:8771` was run
718                // without `--with-local`, leaving the session with a
719                // loopback federation slot that's effectively local-mesh-
720                // reachable. Pre-v0.7.4 the strict scope-only filter
721                // silently excluded those sessions from `pair-all-local`,
722                // making nickname-based pairing fail for no operator-
723                // visible reason.
724                matches!(e.scope, EndpointScope::Local)
725                    || (matches!(e.scope, EndpointScope::Federation)
726                        && url_is_loopback(&e.relay_url))
727            })
728            .collect();
729        if local_eps.is_empty() {
730            federation_only.push(FederationOnlySessionView {
731                name: s.name.clone(),
732                handle: s.handle.clone(),
733                cwd: s.cwd.clone(),
734            });
735            continue;
736        }
737        // Redacted view: drop slot_token before exposing through CLI.
738        let redacted: Vec<LocalEndpointView> = local_eps
739            .iter()
740            .map(|e| LocalEndpointView {
741                relay_url: e.relay_url.clone(),
742                slot_id: e.slot_id.clone(),
743            })
744            .collect();
745        // Group by relay_url. A session with two Local endpoints (rare —
746        // would mean two loopback relays) appears under each.
747        for ep in &local_eps {
748            local
749                .entry(ep.relay_url.clone())
750                .or_default()
751                .push(LocalSessionView {
752                    name: s.name.clone(),
753                    handle: s.handle.clone(),
754                    did: s.did.clone(),
755                    cwd: s.cwd.clone(),
756                    home_dir: s.home_dir.clone(),
757                    daemon_running: s.daemon_running,
758                    local_endpoints: redacted.clone(),
759                });
760        }
761    }
762    // Sort each group by session name so output is deterministic.
763    for group in local.values_mut() {
764        group.sort_by(|a, b| a.name.cmp(&b.name));
765    }
766    federation_only.sort_by(|a, b| a.name.cmp(&b.name));
767    Ok(LocalSessionListing {
768        local,
769        federation_only,
770    })
771}
772
773/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
774///
775/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
776/// registry. If a session is registered for this cwd AND its home
777/// directory still exists, return that home dir; otherwise None.
778///
779/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
780/// so a `wire whoami` / `wire monitor` invocation from a project cwd
781/// adopts that project's session identity automatically, instead of
782/// silently falling back to the machine default. The CLI parity is
783/// load-bearing: without it, the user-visible identity diverges
784/// between MCP and the terminal, and monitors pull machine-wide
785/// inboxes when the operator expected a per-session view.
786pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
787    let registry = read_registry().ok()?;
788    // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
789    // inherit their parent's wire identity (e.g.
790    // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
791    // Without this, subdirs all fell back to the machine-wide default
792    // identity, which silently collapsed multiple Claude sessions onto the
793    // same DID + character.
794    let mut probe: Option<&std::path::Path> = Some(cwd);
795    while let Some(path) = probe {
796        // Same O(n) normalized scan as derive_name_from_cwd: handles both
797        // consistent-casing and cross-casing upgraders. See the comment
798        // on derive_name_from_cwd for the rationale.
799        let path_str = normalize_cwd_key(path);
800        if let Some(session_name) = registry.by_cwd.get(&path_str).or_else(|| {
801            registry
802                .by_cwd
803                .iter()
804                .find(|(k, _)| normalize_cwd_key(Path::new(k)) == path_str)
805                .map(|(_, v)| v)
806        }) {
807            let session_home = session_dir(session_name).ok()?;
808            if session_home.exists() {
809                return Some(session_home);
810            }
811        }
812        probe = path.parent();
813    }
814    None
815}
816
817/// v0.13: resolve a stable per-session key — host-agnostic, with a Claude
818/// Code adapter and the path left open for other hosts. Order:
819///   1. `WIRE_SESSION_ID` — explicit universal override (any harness).
820///   2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per
821///      conversation; the same id the auto-memory system keys off).
822///   3. `CODEX_SESSION_ID` — OpenAI Codex CLI adapter. Stable per Codex
823///      thread (the same UUIDv7 emitted in `thread.started` and used as
824///      the rollout-file suffix under `$CODEX_HOME/sessions/`). Codex
825///      does not yet forward this var to MCP children out of the box —
826///      operators must set it via `[mcp_servers.<name>.env]` in
827///      `~/.codex/config.toml` (or upstream Codex must add it to the
828///      MCP child env). Wiring the name in advance means once Codex
829///      ships the env, wire picks it up with zero further code change.
830///   4. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` /
831///      `copilot`) adapter. Set by the Copilot CLI host for every
832///      session; stable per conversation; UUID-shaped.
833///   5. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based
834///      identity (stable per workspace).
835///   6. `None` — caller falls back to legacy cwd-detect (bare CLI /
836///      pre-v0.13 hosts). Future host adapters slot in before this.
837///
838/// Returns `(key, source-label)`.
839pub fn resolve_session_key() -> Option<(String, &'static str)> {
840    for (var, source) in [
841        ("WIRE_SESSION_ID", "override"),
842        ("CLAUDE_CODE_SESSION_ID", "claude-code"),
843        ("CODEX_SESSION_ID", "codex-cli"),
844        ("COPILOT_AGENT_SESSION_ID", "copilot-cli"),
845        ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"),
846    ] {
847        if let Ok(v) = std::env::var(var)
848            && valid_session_key(&v)
849        {
850            return Some((v.trim().to_string(), source));
851        }
852    }
853    // Claude Code adapter (host-agnostic fallback). On some platforms the MCP
854    // server process does not inherit CLAUDE_CODE_SESSION_ID and the MCP
855    // `initialize` handshake carries no session id, so the env checks above
856    // miss. Claude Code, however, writes `~/.claude/sessions/<pid>.json`
857    // ({"sessionId":..., "cwd":...}) for each live session, named by the
858    // owning `claude` process PID. Walk our parent-process chain to that
859    // process and read its sessionId — deterministic, race-free, env-free.
860    if let Some(sid) = claude_code_session_from_pidfile() {
861        return Some((sid, "claude-code-pidfile"));
862    }
863
864    None
865}
866
867/// A session key from the environment is usable only if it is non-empty and is
868/// NOT an unexpanded `${...}` placeholder. A host that writes
869/// `"env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}` but doesn't expand
870/// it (Windows Claude Code passes the literal when the var is absent) would
871/// otherwise have wire hash the literal — collapsing every session onto one
872/// identity. Treat any `${...}` value as unset so resolution falls through to
873/// the PID-file adapter / per-process mint instead of a shared bogus persona.
874fn valid_session_key(v: &str) -> bool {
875    let v = v.trim();
876    !v.is_empty() && !v.contains("${")
877}
878
879/// Recover the Claude Code session id from the per-session PID-file when it
880/// isn't available via the environment. Claude Code writes
881/// `~/.claude/sessions/<pid>.json` = `{"sessionId": "...", "cwd": "...", ...}`
882/// for each live session, keyed by the owning `claude` process PID. The MCP
883/// server we run inside is a descendant of that process, so we walk our
884/// parent chain and return the `sessionId` of the first ancestor that has a
885/// PID-file. Cross-platform: the file exists on macOS/Linux/Windows alike.
886fn claude_code_session_from_pidfile() -> Option<String> {
887    let dir = dirs::home_dir()?.join(".claude").join("sessions");
888    let mut pid = std::process::id();
889    // Chains are shallow (MCP server -> launcher -> claude); 16 is generous.
890    for _ in 0..16 {
891        let f = dir.join(format!("{pid}.json"));
892        if let Ok(txt) = std::fs::read_to_string(&f)
893            && let Ok(v) = serde_json::from_str::<Value>(&txt)
894            && let Some(s) = v.get("sessionId").and_then(Value::as_str)
895        {
896            let s = s.trim();
897            if !s.is_empty() {
898                return Some(s.to_string());
899            }
900        }
901        pid = parent_pid(pid)?;
902    }
903    None
904}
905
906/// Best-effort parent-PID lookup. Linux: `/proc/<pid>/status`. macOS: `ps`.
907/// Windows: PowerShell CIM (no extra crate). Returns `None` on any failure,
908/// which simply ends the walk.
909#[cfg(target_os = "linux")]
910fn parent_pid(pid: u32) -> Option<u32> {
911    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
912    for line in status.lines() {
913        if let Some(rest) = line.strip_prefix("PPid:") {
914            return rest.trim().parse().ok();
915        }
916    }
917    None
918}
919
920#[cfg(target_os = "macos")]
921fn parent_pid(pid: u32) -> Option<u32> {
922    let out = std::process::Command::new("ps")
923        .args(["-o", "ppid=", "-p", &pid.to_string()])
924        .output()
925        .ok()?;
926    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
927}
928
929#[cfg(target_os = "windows")]
930fn parent_pid(pid: u32) -> Option<u32> {
931    use std::os::windows::process::CommandExt;
932    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
933    let out = std::process::Command::new("powershell")
934        .args([
935            "-NoProfile",
936            "-NonInteractive",
937            "-Command",
938            &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').ParentProcessId"),
939        ])
940        .creation_flags(CREATE_NO_WINDOW)
941        .output()
942        .ok()?;
943    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
944}
945
946#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
947fn parent_pid(_pid: u32) -> Option<u32> {
948    None
949}
950
951/// v0.13: the WIRE_HOME for a resolved session key —
952/// `<sessions_root>/by-key/<hash>` where `hash` is the first 16 hex of
953/// SHA-256(key). Deterministic and cwd-independent, so two sessions never
954/// collide and there is no path-string to mis-normalize (the Windows bug
955/// cannot occur). 64 bits is collision-safe at this scale.
956pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
957    let mut h = Sha256::new();
958    h.update(key.as_bytes());
959    let digest = h.finalize();
960    let hash = hex::encode(&digest[..8]); // 16 hex chars / 64 bits
961    Ok(sessions_root()?.join("by-key").join(hash))
962}
963
964/// Long-running `wire <subcommand>` invocations that own the inbox
965/// cursor and therefore race each other under a shared `WIRE_HOME`.
966/// Keep this list in sync with [`warn_on_identity_collision`]'s pgrep
967/// predicate and the call-site list in `cli::run` / `mcp::run`.
968///
969/// `pair-host*` is intentionally absent: it works against the
970/// per-pair relay slot, not the shared inbox cursor, and is meant
971/// to coexist with a `wire daemon` that advances the queued pair —
972/// warning there would be a false positive for the documented
973/// "queue + daemon" pattern.
974///
975/// Short-lived commands (`whoami`, `status`, `send`, `peers`, …) are
976/// intentionally absent — they write atomically and don't race, and
977/// warning on every one would spam any operator running scripts.
978pub const INBOX_OWNING_SUBCOMMANDS: &[&str] = &["mcp", "daemon", "monitor", "notify"];
979
980/// v0.6.10: warn at MCP/CLI startup if another long-running `wire`
981/// process is already running with the same effective `WIRE_HOME`.
982/// Closes the "two Claudes in same cwd silently share an identity"
983/// failure mode that wasted hours of operator debugging time: today
984/// the collision is invisible (both Claudes resolve to the same wire
985/// session via v0.6.7 auto-detect, race the inbox cursor, "look
986/// identical" from the operator's view). This surfaces it explicitly
987/// with a clear remediation path.
988///
989/// `role` is the calling subcommand label (`"mcp"`, `"daemon"`,
990/// `"monitor"`, …) — used in the warning's leading tag so operators
991/// can tell which surface is observing the collision. Detection
992/// itself spans every inbox-owning role: a `wire daemon` colliding
993/// with an existing `wire mcp` warns just the same as an mcp/mcp
994/// pair.
995///
996/// Best-effort: any subprocess / env-read failure is silent (the
997/// collision check should never block startup). Cross-platform via
998/// `ps -E -p <pid>` on macOS, `/proc/<pid>/environ` on Linux. Windows
999/// returns empty (no collision detected).
1000pub fn warn_on_identity_collision(self_pid: u32, role: &str) {
1001    let our_wire_home = match std::env::var("WIRE_HOME") {
1002        Ok(h) => h,
1003        Err(_) => return,
1004    };
1005
1006    // Single pgrep call with an alternation predicate. `pgrep -f`
1007    // matches against the full argv string, so `wire (mcp|daemon|…)`
1008    // catches every inbox-owning subcommand in one shot. Falls back to
1009    // silent no-op on platforms without pgrep (Windows) — the env-read
1010    // path below also returns None there, so detection is end-to-end
1011    // unsupported on Windows. Future: a powershell adapter for
1012    // identity collisions, tracked in #29 / #30.
1013    let predicate = format!("wire ({})", INBOX_OWNING_SUBCOMMANDS.join("|"));
1014    let pgrep_out = match std::process::Command::new("pgrep")
1015        .args(["-f", &predicate])
1016        .output()
1017    {
1018        Ok(o) if o.status.success() => o,
1019        _ => return,
1020    };
1021
1022    let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
1023        .split_whitespace()
1024        .filter_map(|s| s.parse::<u32>().ok())
1025        .filter(|&p| p != self_pid)
1026        .collect();
1027
1028    let other_homes: Vec<(u32, Option<String>)> = other_pids
1029        .iter()
1030        .map(|p| (*p, read_wire_home_from_pid(*p)))
1031        .collect();
1032
1033    let colliders = find_colliders(&our_wire_home, &other_homes);
1034
1035    if colliders.is_empty() {
1036        return;
1037    }
1038
1039    emit_collision_warning(role, &our_wire_home, &colliders);
1040}
1041
1042/// Pure decision: from a snapshot of `(pid, their_wire_home)` for
1043/// every other wire process on the host, return the pids whose
1044/// `WIRE_HOME` exactly matches ours. Missing-home entries (process
1045/// died, env unreadable on this platform) are skipped, never counted.
1046pub(crate) fn find_colliders(
1047    our_wire_home: &str,
1048    other_homes: &[(u32, Option<String>)],
1049) -> Vec<u32> {
1050    other_homes
1051        .iter()
1052        .filter_map(|(pid, their_home)| match their_home {
1053            Some(h) if h == our_wire_home => Some(*pid),
1054            _ => None,
1055        })
1056        .collect()
1057}
1058
1059/// Render the collision warning. Extracted so the format is unit-
1060/// testable without mocking a real pgrep / cross-process env read.
1061pub(crate) fn emit_collision_warning(role: &str, our_wire_home: &str, colliders: &[u32]) {
1062    eprintln!(
1063        "wire {role}: WARNING — {} other wire process(es) already using WIRE_HOME=`{}` (pid {})",
1064        colliders.len(),
1065        our_wire_home,
1066        colliders
1067            .iter()
1068            .map(|p| p.to_string())
1069            .collect::<Vec<_>>()
1070            .join(", ")
1071    );
1072    eprintln!(
1073        "  Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
1074    );
1075    eprintln!("  To use a separate identity:");
1076    eprintln!("    1. Close the other agent(s), OR");
1077    eprintln!("    2. `wire session new <name> --local-only` to create a fresh identity, then");
1078    eprintln!(
1079        "    3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
1080    );
1081}
1082
1083/// Best-effort cross-platform read of another process's `WIRE_HOME`.
1084/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
1085/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
1086/// Windows / other: returns `None` (collision detection no-ops).
1087fn read_wire_home_from_pid(pid: u32) -> Option<String> {
1088    #[cfg(target_os = "linux")]
1089    {
1090        let path = format!("/proc/{pid}/environ");
1091        let bytes = std::fs::read(&path).ok()?;
1092        for entry in bytes.split(|&b| b == 0) {
1093            let s = match std::str::from_utf8(entry) {
1094                Ok(s) => s,
1095                Err(_) => continue,
1096            };
1097            if let Some(val) = s.strip_prefix("WIRE_HOME=") {
1098                return Some(val.to_string());
1099            }
1100        }
1101        None
1102    }
1103
1104    #[cfg(target_os = "macos")]
1105    {
1106        let output = std::process::Command::new("ps")
1107            .args(["-E", "-p", &pid.to_string(), "-o", "command="])
1108            .output()
1109            .ok()?;
1110        let s = String::from_utf8_lossy(&output.stdout);
1111        for tok in s.split_whitespace() {
1112            if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
1113                return Some(val.to_string());
1114            }
1115        }
1116        None
1117    }
1118
1119    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1120    {
1121        let _ = pid;
1122        None
1123    }
1124}
1125
1126/// v0.6.7: apply `detect_session_wire_home` for the current process.
1127///
1128/// If `WIRE_HOME` is unset and the current cwd maps to an existing
1129/// session, set `WIRE_HOME` for the rest of this process and emit a
1130/// one-liner to stderr so the operator knows which identity is in
1131/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
1132///
1133/// `label` distinguishes the caller in the stderr line (`mcp` vs
1134/// `cli`). Set `WIRE_QUIET_AUTOSESSION=1` to suppress the stderr line
1135/// while keeping the env-var application active.
1136///
1137/// MUST be called BEFORE any worker thread or async task spawns —
1138/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
1139/// guarantees, and our use is safe only at process entry.
1140pub fn maybe_adopt_session_wire_home(label: &str) {
1141    if std::env::var("WIRE_HOME").is_ok() {
1142        return;
1143    }
1144    // v0.13: prefer the host-agnostic session key (WIRE_SESSION_ID >
1145    // CLAUDE_CODE_SESSION_ID). Each session gets its own WIRE_HOME under
1146    // `by-key/<hash>` — no cwd lookup, no shared default, no Windows path
1147    // collapse. Falls back to legacy cwd-detect only when no session key is
1148    // present (bare CLI / pre-v0.13 hosts).
1149    let (home, why) = if let Some((key, source)) = resolve_session_key() {
1150        match session_home_for_key(&key) {
1151            Ok(h) => {
1152                // v0.13.2 (E8): do NOT create the home here. Creating it
1153                // unconditionally on every resolution — before any identity
1154                // exists — left a permanent empty home for every transient /
1155                // probe session key that never `wire up`d, accumulating
1156                // forever and surfacing as phantom "?" sisters in list-local.
1157                // The home is created lazily by `ensure_dirs` on the first
1158                // real write (init / claim / send), so an uninitialized
1159                // session leaves no trace on disk. (Write paths already
1160                // tolerate a non-existent WIRE_HOME — the test harness runs
1161                // every test against one.)
1162                (h, format!("session key ({source})"))
1163            }
1164            Err(_) => return,
1165        }
1166    } else if label == "mcp" {
1167        // v0.13.4 (operator directive: per-session ONLY, never cwd). The MCP
1168        // server must NEVER cwd-resolve — that fallback is what collapsed every
1169        // Claude session sharing a launch dir (`~/Source`, `C:\Users\<user>`)
1170        // onto a single persona. A stdio MCP server is one process per Claude
1171        // session, so when no session id reached us (the
1172        // `${CLAUDE_CODE_SESSION_ID}` env-forward is missing or didn't expand)
1173        // we MINT a per-process key: distinct per session, never a shared cwd
1174        // identity. With the env-forward in place this branch isn't reached —
1175        // the session id resolves above.
1176        let minted = format!(
1177            "mcp-proc-{:016x}{:016x}",
1178            rand::random::<u64>(),
1179            rand::random::<u64>()
1180        );
1181        match session_home_for_key(&minted) {
1182            Ok(h) => {
1183                // Pin it for the process so every later resolve is consistent.
1184                unsafe {
1185                    std::env::set_var("WIRE_SESSION_ID", &minted);
1186                }
1187                (
1188                    h,
1189                    "minted per-process key (no session id; cwd disabled for MCP)".to_string(),
1190                )
1191            }
1192            Err(_) => return,
1193        }
1194    } else {
1195        // CLI with no session id. Per the per-session-only directive we do NOT
1196        // cwd-resolve here either — cwd identity is the collision trap (agents
1197        // shell out to the CLI, and any cwd-derived identity risks the wrong /
1198        // shared persona). Under Claude Code the CLI always carries
1199        // CLAUDE_CODE_SESSION_ID (resolved above), so this only hits a bare
1200        // terminal outside an agent host — which gets the stable machine-default
1201        // identity (set WIRE_SESSION_ID / WIRE_HOME for an explicit one). No cwd.
1202        return;
1203    };
1204    // v0.9.1: emit the chatter ONLY when stderr is an interactive TTY.
1205    // When wire is invoked from a non-interactive parent (Claude Code's
1206    // Bash tool, scripts, daemons), the auto-detect line is captured
1207    // alongside command output and pollutes both — wasting agent
1208    // context tokens and breaking JSON parsers that read combined
1209    // streams. WIRE_VERBOSE=1 forces the line on; WIRE_QUIET_AUTOSESSION
1210    // still forces it off for back-compat with v0.9 scripts.
1211    use std::io::IsTerminal;
1212    let quiet_env = std::env::var("WIRE_QUIET_AUTOSESSION").is_ok();
1213    let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
1214    let interactive = std::io::stderr().is_terminal();
1215    if !quiet_env && (interactive || verbose_env) {
1216        eprintln!(
1217            "wire {label}: adopted {why} → WIRE_HOME=`{}`",
1218            home.display()
1219        );
1220    }
1221    // SAFETY: caller contract is "before any thread spawn." All
1222    // production sites (cli::run, mcp::run) call this as the first
1223    // step in their respective entry points.
1224    unsafe {
1225        std::env::set_var("WIRE_HOME", &home);
1226    }
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232
1233    #[test]
1234    fn valid_session_key_rejects_empty_and_unexpanded_placeholder() {
1235        assert!(valid_session_key("4129275d-cc5c-4d2a"));
1236        assert!(valid_session_key("mcp-proc-deadbeef"));
1237        assert!(!valid_session_key(""));
1238        assert!(!valid_session_key("   "));
1239        // The load-bearing guard: an unexpanded MCP-config placeholder must NOT
1240        // be hashed — that's the all-sessions-collapse (soft-spruce) bug.
1241        assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
1242        assert!(!valid_session_key("  ${CLAUDE_CODE_SESSION_ID}  "));
1243    }
1244
1245    #[test]
1246    fn resolve_session_key_vscode_adapter_and_placeholder_guard() {
1247        // Per-adapter test for the VS Code / GitHub Copilot path added in #59.
1248        // Holds two invariants the integration depends on:
1249        //
1250        //   (a) When VSCODE_GIT_REPOSITORY_ROOT is set to a real workspace
1251        //       path, that key wins resolution and two distinct workspace
1252        //       paths produce two distinct session homes — proves the
1253        //       per-workspace-identity contract documented in
1254        //       docs/integrations/GITHUB_COPILOT.md.
1255        //
1256        //   (b) When the env entry is the unexpanded literal "${workspaceFolder}"
1257        //       (host failed to substitute), the ${} guard rejects it and the
1258        //       fn falls through — proves the safe-degradation property
1259        //       (no-identity, NOT cross-workspace collision).
1260        //
1261        // Mirrors the WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID semantics so any
1262        // future adapter added to the env-check loop inherits the same gates.
1263        let _guard = crate::config::test_support::ENV_LOCK
1264            .lock()
1265            .unwrap_or_else(|p| p.into_inner());
1266
1267        // Snapshot + clear every env var resolve_session_key consults so this
1268        // test is hermetic regardless of the harness environment.
1269        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1270        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1271        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1272        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1273        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1274        // SAFETY: ENV_LOCK is held, serializing all env access.
1275        unsafe {
1276            std::env::remove_var("WIRE_SESSION_ID");
1277            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1278            std::env::remove_var("CODEX_SESSION_ID");
1279            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1280            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1281        }
1282
1283        // (a) Two distinct workspace paths -> two distinct, stable session homes.
1284        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1285        let r1 = resolve_session_key();
1286        assert!(
1287            matches!(&r1, Some((k, src)) if k == "/home/dev/frontend" && *src == "vscode-workspace"),
1288            "VSCODE_GIT_REPOSITORY_ROOT must win resolution and be labeled vscode-workspace; got {r1:?}"
1289        );
1290        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1291
1292        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/backend") };
1293        let r2 = resolve_session_key();
1294        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1295        assert_ne!(
1296            home_a, home_b,
1297            "distinct workspace roots must map to distinct session homes (no cross-workspace persona collision)"
1298        );
1299
1300        // Same path again -> same home (resume stability).
1301        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1302        let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1303        assert_eq!(
1304            home_a, home_a2,
1305            "same workspace root must yield the same home across calls"
1306        );
1307
1308        // (b) Unexpanded ${workspaceFolder} literal MUST NOT be accepted.
1309        //     With every other adapter still cleared, resolution must fall
1310        //     through to None (or the claude pidfile path, which is absent in
1311        //     this test env) — never hash the literal.
1312        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "${workspaceFolder}") };
1313        let r_guard = resolve_session_key();
1314        assert!(
1315            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1316            "unexpanded ${{workspaceFolder}} literal must be rejected by the ${{}} guard; got {r_guard:?}"
1317        );
1318        // Same guard for the other adapter slots.
1319        unsafe {
1320            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1321            std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}");
1322        }
1323        let r_guard2 = resolve_session_key();
1324        assert!(
1325            !matches!(&r_guard2, Some((k, _)) if k.contains("${")),
1326            "unexpanded ${{workspaceFolder}} in WIRE_SESSION_ID must also be rejected; got {r_guard2:?}"
1327        );
1328
1329        // Restore any env we displaced.
1330        // SAFETY: ENV_LOCK still held.
1331        unsafe {
1332            std::env::remove_var("WIRE_SESSION_ID");
1333            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1334            std::env::remove_var("CODEX_SESSION_ID");
1335            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1336            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1337            if let Some(v) = prev_override {
1338                std::env::set_var("WIRE_SESSION_ID", v);
1339            }
1340            if let Some(v) = prev_claude {
1341                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1342            }
1343            if let Some(v) = prev_codex {
1344                std::env::set_var("CODEX_SESSION_ID", v);
1345            }
1346            if let Some(v) = prev_copilot {
1347                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1348            }
1349            if let Some(v) = prev_vscode {
1350                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1351            }
1352        }
1353    }
1354
1355    #[test]
1356    fn resolve_session_key_copilot_cli_adapter_and_priority() {
1357        // Per-adapter test for the GitHub Copilot CLI path (Phase 2 of #59):
1358        // resolve_session_key reads COPILOT_AGENT_SESSION_ID (set by the
1359        // `gh copilot` / `copilot` CLI host on every session) as a TARGETED
1360        // env adapter — exactly like CLAUDE_CODE_SESSION_ID. Holds three
1361        // invariants:
1362        //
1363        //   (a) Set to a real id -> that key wins resolution and two distinct
1364        //       conversations map to two distinct session homes (per-
1365        //       conversation identity contract).
1366        //   (b) WIRE_SESSION_ID overrides COPILOT_AGENT_SESSION_ID (priority
1367        //       1 trumps priority 3).
1368        //   (c) Unexpanded ${...} literal is rejected by the ${} guard —
1369        //       falls through to the None path, never hashed (mirrors the
1370        //       guard inherited from CLAUDE_CODE_SESSION_ID / WIRE_SESSION_ID
1371        //       / VSCODE_GIT_REPOSITORY_ROOT).
1372        let _guard = crate::config::test_support::ENV_LOCK
1373            .lock()
1374            .unwrap_or_else(|p| p.into_inner());
1375
1376        // Snapshot every env var resolve_session_key consults so the test is
1377        // hermetic regardless of harness environment (this test literally
1378        // runs under Copilot CLI, where COPILOT_AGENT_SESSION_ID is set).
1379        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1380        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1381        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1382        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1383        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1384        // SAFETY: ENV_LOCK is held, serializing all env access.
1385        unsafe {
1386            std::env::remove_var("WIRE_SESSION_ID");
1387            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1388            std::env::remove_var("CODEX_SESSION_ID");
1389            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1390            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1391        }
1392
1393        // (a) COPILOT_AGENT_SESSION_ID set -> wins resolution; distinct ids
1394        //     map to distinct session homes.
1395        unsafe {
1396            std::env::set_var(
1397                "COPILOT_AGENT_SESSION_ID",
1398                "3869478a-33cc-4c33-82ee-b6403a24d734",
1399            )
1400        };
1401        let r1 = resolve_session_key();
1402        assert!(
1403            matches!(&r1, Some((k, src)) if k == "3869478a-33cc-4c33-82ee-b6403a24d734" && *src == "copilot-cli"),
1404            "COPILOT_AGENT_SESSION_ID must win resolution and be labeled copilot-cli; got {r1:?}"
1405        );
1406        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1407
1408        unsafe {
1409            std::env::set_var(
1410                "COPILOT_AGENT_SESSION_ID",
1411                "deadbeef-0000-0000-0000-000000000000",
1412            )
1413        };
1414        let r2 = resolve_session_key();
1415        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1416        assert_ne!(
1417            home_a, home_b,
1418            "distinct Copilot CLI session ids must map to distinct session homes"
1419        );
1420
1421        // (b) WIRE_SESSION_ID at priority 1 overrides COPILOT_AGENT_SESSION_ID
1422        //     at priority 3. Operator's explicit universal override always wins.
1423        unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
1424        let r_override = resolve_session_key();
1425        assert!(
1426            matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
1427            "WIRE_SESSION_ID must beat COPILOT_AGENT_SESSION_ID; got {r_override:?}"
1428        );
1429        unsafe { std::env::remove_var("WIRE_SESSION_ID") };
1430
1431        // (c) Unexpanded ${...} literal is rejected by the ${} guard.
1432        //     `gh copilot` shouldn't ship literal placeholders in
1433        //     COPILOT_AGENT_SESSION_ID, but if some future config-forwarding
1434        //     path does, the guard must reject it (same as for the other
1435        //     adapters) so we never hash the literal and collapse sessions.
1436        unsafe { std::env::set_var("COPILOT_AGENT_SESSION_ID", "${SOME_PLACEHOLDER}") };
1437        let r_guard = resolve_session_key();
1438        assert!(
1439            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1440            "unexpanded ${{...}} in COPILOT_AGENT_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
1441        );
1442
1443        // Restore any env we displaced.
1444        // SAFETY: ENV_LOCK still held.
1445        unsafe {
1446            std::env::remove_var("WIRE_SESSION_ID");
1447            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1448            std::env::remove_var("CODEX_SESSION_ID");
1449            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1450            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1451            if let Some(v) = prev_override {
1452                std::env::set_var("WIRE_SESSION_ID", v);
1453            }
1454            if let Some(v) = prev_claude {
1455                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1456            }
1457            if let Some(v) = prev_codex {
1458                std::env::set_var("CODEX_SESSION_ID", v);
1459            }
1460            if let Some(v) = prev_copilot {
1461                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1462            }
1463            if let Some(v) = prev_vscode {
1464                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1465            }
1466        }
1467    }
1468
1469    #[test]
1470    fn resolve_session_key_codex_cli_adapter_and_priority() {
1471        // Per-adapter test for the OpenAI Codex CLI path (#__pr_codex__).
1472        // resolve_session_key reads CODEX_SESSION_ID as a TARGETED env adapter
1473        // — exactly like CLAUDE_CODE_SESSION_ID and COPILOT_AGENT_SESSION_ID.
1474        // Until Codex itself forwards the thread id to MCP child env, operators
1475        // wire it via `[mcp_servers.<name>.env]` in `~/.codex/config.toml`;
1476        // landing the adapter now means once Codex ships the env it works
1477        // with zero further code change. Holds three invariants:
1478        //
1479        //   (a) Set to a real thread id -> that key wins resolution and two
1480        //       distinct threads map to two distinct session homes
1481        //       (per-thread identity contract).
1482        //   (b) WIRE_SESSION_ID overrides CODEX_SESSION_ID (priority 1
1483        //       trumps priority 3); CLAUDE_CODE_SESSION_ID also outranks
1484        //       CODEX_SESSION_ID (priority 2 trumps priority 3) — the
1485        //       Codex adapter slots between Claude Code and Copilot.
1486        //   (c) Unexpanded ${...} literal is rejected by the ${} guard,
1487        //       falling through rather than collapsing all sessions
1488        //       (mirrors the guard inherited from every other adapter).
1489        let _guard = crate::config::test_support::ENV_LOCK
1490            .lock()
1491            .unwrap_or_else(|p| p.into_inner());
1492
1493        // Snapshot every env var resolve_session_key consults so the test is
1494        // hermetic regardless of harness environment.
1495        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1496        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1497        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1498        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1499        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1500        // SAFETY: ENV_LOCK is held, serializing all env access.
1501        unsafe {
1502            std::env::remove_var("WIRE_SESSION_ID");
1503            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1504            std::env::remove_var("CODEX_SESSION_ID");
1505            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1506            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1507        }
1508
1509        // (a) CODEX_SESSION_ID set -> wins resolution over the no-id baseline;
1510        //     distinct thread ids map to distinct session homes.
1511        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
1512        let r1 = resolve_session_key();
1513        assert!(
1514            matches!(&r1, Some((k, src)) if k == "019e66ad-277e-7be3-bdd9-b7708e069f3b" && *src == "codex-cli"),
1515            "CODEX_SESSION_ID must win resolution and be labeled codex-cli; got {r1:?}"
1516        );
1517        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1518
1519        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66b6-14de-7142-b43a-1861fe59e945") };
1520        let r2 = resolve_session_key();
1521        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1522        assert_ne!(
1523            home_a, home_b,
1524            "distinct Codex thread ids must map to distinct session homes"
1525        );
1526
1527        // Same id again -> same home (resume stability — same thread reconnects
1528        // to the same persona).
1529        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
1530        let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1531        assert_eq!(
1532            home_a, home_a2,
1533            "same Codex thread id must yield the same home across calls"
1534        );
1535
1536        // (b) WIRE_SESSION_ID at priority 1 overrides CODEX_SESSION_ID at
1537        //     priority 3 (operator explicit override always wins).
1538        unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
1539        let r_override = resolve_session_key();
1540        assert!(
1541            matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
1542            "WIRE_SESSION_ID must beat CODEX_SESSION_ID; got {r_override:?}"
1543        );
1544        unsafe { std::env::remove_var("WIRE_SESSION_ID") };
1545
1546        // CLAUDE_CODE_SESSION_ID at priority 2 also beats CODEX_SESSION_ID at
1547        // priority 3. (Earlier adapters get to claim the host they were
1548        // designed for; Codex slots in after Claude Code.)
1549        unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-codex") };
1550        let r_claude_wins = resolve_session_key();
1551        assert!(
1552            matches!(&r_claude_wins, Some((k, src)) if k == "claude-wins-over-codex" && *src == "claude-code"),
1553            "CLAUDE_CODE_SESSION_ID must beat CODEX_SESSION_ID; got {r_claude_wins:?}"
1554        );
1555        unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") };
1556
1557        // (c) Unexpanded ${...} literal is rejected by the ${} guard.
1558        //     If a host's config-forwarding ever ships a literal placeholder,
1559        //     the guard rejects it (same as for every other adapter) so we
1560        //     never hash the literal and collapse sessions.
1561        unsafe { std::env::set_var("CODEX_SESSION_ID", "${SOME_PLACEHOLDER}") };
1562        let r_guard = resolve_session_key();
1563        assert!(
1564            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1565            "unexpanded ${{...}} in CODEX_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
1566        );
1567
1568        // Restore any env we displaced.
1569        // SAFETY: ENV_LOCK still held.
1570        unsafe {
1571            std::env::remove_var("WIRE_SESSION_ID");
1572            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1573            std::env::remove_var("CODEX_SESSION_ID");
1574            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1575            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1576            if let Some(v) = prev_override {
1577                std::env::set_var("WIRE_SESSION_ID", v);
1578            }
1579            if let Some(v) = prev_claude {
1580                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1581            }
1582            if let Some(v) = prev_codex {
1583                std::env::set_var("CODEX_SESSION_ID", v);
1584            }
1585            if let Some(v) = prev_copilot {
1586                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1587            }
1588            if let Some(v) = prev_vscode {
1589                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1590            }
1591        }
1592    }
1593
1594    #[test]
1595    fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
1596        // Regression (v0.13.2): v0.13 moved session homes under
1597        // `sessions/by-key/<hash>`, but (1) list_sessions only scanned the
1598        // top level so by-key homes were invisible, and (2) sessions_root()'s
1599        // inside-session fallback only walked ONE level up (expecting parent
1600        // `sessions`), so an inside-session WIRE_HOME resolved to a bogus
1601        // nested dir. Together they made same-box discovery (list-local /
1602        // pair-all-local) return zero sisters under v0.13.
1603        let _guard = crate::config::test_support::ENV_LOCK
1604            .lock()
1605            .unwrap_or_else(|p| p.into_inner());
1606        let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
1607        let _ = std::fs::remove_dir_all(&tmp);
1608        let root = tmp.join("sessions");
1609        let home = root.join("by-key").join("abc123def4567890");
1610        let cfg = home.join("config").join("wire");
1611        std::fs::create_dir_all(&cfg).unwrap();
1612        std::fs::write(
1613            cfg.join("agent-card.json"),
1614            r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
1615        )
1616        .unwrap();
1617
1618        // (1) sessions_root() must find the real root even when WIRE_HOME
1619        //     points INSIDE the by-key home.
1620        // SAFETY: ENV_LOCK is held, serializing all env access.
1621        unsafe { std::env::set_var("WIRE_HOME", &home) };
1622        assert_eq!(
1623            sessions_root().unwrap(),
1624            root,
1625            "sessions_root must resolve the root from inside a by-key home"
1626        );
1627
1628        // (2) list_sessions() must enumerate the by-key home, labeled by handle.
1629        let sessions = list_sessions().unwrap();
1630        let found = sessions
1631            .iter()
1632            .any(|s| s.handle.as_deref() == Some("test-persona"));
1633        unsafe { std::env::remove_var("WIRE_HOME") };
1634        let _ = std::fs::remove_dir_all(&tmp);
1635        assert!(
1636            found,
1637            "by-key home must be enumerated: {:?}",
1638            sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
1639        );
1640    }
1641
1642    #[test]
1643    fn find_session_home_by_name_resolves_both_layouts() {
1644        // #44 / #170 follow-up: v0.6 top-level sessions (dir name ==
1645        // operator-typed name) and v0.13 by-key sessions (dir name is
1646        // a hash, operator types the persona handle from the card)
1647        // must BOTH resolve via `find_session_home_by_name`. Pre-fix
1648        // (`session_dir(name)` only) the v0.13 by-key case bailed
1649        // with "session not found" even though `wire session list`
1650        // showed it.
1651        let _guard = crate::config::test_support::ENV_LOCK
1652            .lock()
1653            .unwrap_or_else(|p| p.into_inner());
1654        let tmp = std::env::temp_dir().join(format!("wire-find-{}", rand::random::<u32>()));
1655        let _ = std::fs::remove_dir_all(&tmp);
1656        let root = tmp.join("sessions");
1657
1658        // Legacy v0.6 top-level: a dir named `legacy-pane` directly
1659        // under sessions_root.
1660        let legacy_home = root.join("legacy-pane");
1661        let legacy_cfg = legacy_home.join("config").join("wire");
1662        std::fs::create_dir_all(&legacy_cfg).unwrap();
1663        std::fs::write(
1664            legacy_cfg.join("agent-card.json"),
1665            r#"{"did":"did:wire:legacy-pane-aaaa1111","handle":"legacy-pane","verify_keys":{}}"#,
1666        )
1667        .unwrap();
1668
1669        // v0.13 by-key: dir name is a hash, card's handle is `coral-weasel`.
1670        let bykey_home = root.join("by-key").join("3049827d92d4fbd5");
1671        let bykey_cfg = bykey_home.join("config").join("wire");
1672        std::fs::create_dir_all(&bykey_cfg).unwrap();
1673        std::fs::write(
1674            bykey_cfg.join("agent-card.json"),
1675            r#"{"did":"did:wire:coral-weasel-0616dc6c","handle":"coral-weasel","verify_keys":{}}"#,
1676        )
1677        .unwrap();
1678
1679        // SAFETY: ENV_LOCK is held.
1680        unsafe { std::env::set_var("WIRE_HOME", &root) };
1681
1682        // Legacy lookup: operator types the literal dir name.
1683        let legacy = super::find_session_home_by_name("legacy-pane").unwrap();
1684        assert_eq!(
1685            legacy.as_deref(),
1686            Some(legacy_home.as_path()),
1687            "v0.6 top-level layout: legacy-pane must resolve to its top-level dir"
1688        );
1689
1690        // by-key lookup: operator types the persona handle, not the hash.
1691        let bykey = super::find_session_home_by_name("coral-weasel").unwrap();
1692        assert_eq!(
1693            bykey.as_deref(),
1694            Some(bykey_home.as_path()),
1695            "v0.13 by-key layout: coral-weasel must resolve to its by-key/<hash> dir"
1696        );
1697
1698        // by-key lookup via the hash itself also works (some tooling
1699        // may pass the raw dir name).
1700        let by_hash = super::find_session_home_by_name("3049827d92d4fbd5").unwrap();
1701        assert_eq!(
1702            by_hash.as_deref(),
1703            Some(bykey_home.as_path()),
1704            "v0.13 by-key layout: hash dir name must also resolve"
1705        );
1706
1707        // Negative: an unknown name returns None, not an error.
1708        let missing = super::find_session_home_by_name("never-existed").unwrap();
1709        assert_eq!(missing, None, "unknown session must return None");
1710
1711        unsafe { std::env::remove_var("WIRE_HOME") };
1712        let _ = std::fs::remove_dir_all(&tmp);
1713    }
1714
1715    #[test]
1716    fn pid_to_session_map_builds_from_session_pidfiles() {
1717        // #173 follow-up (#174 hotfix removed --session arg from
1718        // supervisor children): wire status orphan annotation now
1719        // maps pid → session via per-session pidfiles. Walk should
1720        // find each session whose `<home>/state/wire/daemon.pid`
1721        // contains a valid pid, and IGNORE sessions whose pidfile
1722        // is absent or unreadable.
1723        let _guard = crate::config::test_support::ENV_LOCK
1724            .lock()
1725            .unwrap_or_else(|p| p.into_inner());
1726        let tmp = std::env::temp_dir().join(format!("wire-p2s-{}", rand::random::<u32>()));
1727        let _ = std::fs::remove_dir_all(&tmp);
1728        let root = tmp.join("sessions");
1729        // Three by-key sessions. Two have pidfiles, one doesn't.
1730        let mk_session = |key: &str, handle: &str| -> PathBuf {
1731            let home = root.join("by-key").join(key);
1732            let cfg = home.join("config").join("wire");
1733            std::fs::create_dir_all(&cfg).unwrap();
1734            std::fs::write(
1735                cfg.join("agent-card.json"),
1736                format!(
1737                    r#"{{"did":"did:wire:{handle}-6e301ab1","handle":"{handle}","verify_keys":{{}}}}"#
1738                ),
1739            )
1740            .unwrap();
1741            home
1742        };
1743        let h1 = mk_session("abc123def4567890", "alpha-aurora");
1744        let h2 = mk_session("def456abc7890123", "beta-blossom");
1745        let _h3 = mk_session("0000aaaabbbbcccc", "gamma-gorge");
1746        // h1 / h2 get pidfiles (JSON form + legacy-int form for coverage);
1747        // h3 gets none.
1748        let state1 = h1.join("state").join("wire");
1749        let state2 = h2.join("state").join("wire");
1750        std::fs::create_dir_all(&state1).unwrap();
1751        std::fs::create_dir_all(&state2).unwrap();
1752        std::fs::write(state1.join("daemon.pid"), r#"{"pid": 12345}"#).unwrap();
1753        std::fs::write(state2.join("daemon.pid"), "67890").unwrap();
1754
1755        // SAFETY: ENV_LOCK is held, serializing all env access.
1756        unsafe { std::env::set_var("WIRE_HOME", &h1) };
1757        let map = super::pid_to_session_map();
1758        unsafe { std::env::remove_var("WIRE_HOME") };
1759        let _ = std::fs::remove_dir_all(&tmp);
1760
1761        // h1 / h2 present, h3 absent. SessionInfo.name is the handle
1762        // derived from the card when the home is initialized
1763        // (list_sessions's mk helper overrides name = handle in that
1764        // case; by-key hash is only the fallback for uninitialized
1765        // homes). That's exactly the production label `wire status`
1766        // already prints for sessions.
1767        assert_eq!(
1768            map.get(&12345).map(String::as_str),
1769            Some("alpha-aurora"),
1770            "pid 12345 should map to the handle for h1"
1771        );
1772        assert_eq!(
1773            map.get(&67890).map(String::as_str),
1774            Some("beta-blossom"),
1775            "pid 67890 should map (legacy-int pidfile form, handle for h2)"
1776        );
1777        // Sanity: no entry for an unrelated pid.
1778        assert!(
1779            !map.contains_key(&99999),
1780            "synthetic missing pid should not appear in the map"
1781        );
1782    }
1783
1784    #[test]
1785    fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
1786        // session_home_for_key reads WIRE_HOME (via sessions_root); hold the
1787        // shared env lock so a parallel env-mutating test can't change it
1788        // between calls and make a1 != a2 (flaky race).
1789        let _guard = crate::config::test_support::ENV_LOCK
1790            .lock()
1791            .unwrap_or_else(|p| p.into_inner());
1792        let a1 = session_home_for_key("sess-aaa").unwrap();
1793        let a2 = session_home_for_key("sess-aaa").unwrap();
1794        let b = session_home_for_key("sess-bbb").unwrap();
1795        assert_eq!(a1, a2, "same key -> same home (resume stability)");
1796        assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
1797        let leaf = a1.file_name().unwrap().to_str().unwrap();
1798        assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
1799        assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
1800        assert_eq!(
1801            a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
1802            "by-key"
1803        );
1804    }
1805
1806    #[test]
1807    fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
1808        assert!(url_is_loopback("http://127.0.0.1:8771"));
1809        assert!(url_is_loopback("http://127.1.2.3"));
1810        assert!(url_is_loopback("http://localhost:9000"));
1811        assert!(url_is_loopback("https://localhost/v1"));
1812        assert!(url_is_loopback("http://[::1]:8771"));
1813        // Case-insensitive.
1814        assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
1815        // Non-loopback negatives — must NOT be flagged.
1816        assert!(!url_is_loopback("https://wireup.net"));
1817        assert!(!url_is_loopback("http://192.168.1.50:8771"));
1818        assert!(!url_is_loopback("http://10.0.0.5"));
1819        assert!(!url_is_loopback("https://relay.example.com"));
1820    }
1821
1822    #[test]
1823    fn sanitize_handles_unicode_and_long_names() {
1824        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
1825        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
1826        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
1827        assert_eq!(sanitize_name(""), "wire-session");
1828        assert_eq!(sanitize_name("---"), "wire-session");
1829        let long: String = "a".repeat(100);
1830        assert_eq!(sanitize_name(&long).len(), 32);
1831    }
1832
1833    #[test]
1834    fn derive_name_returns_basename_when_no_collision() {
1835        let reg = SessionRegistry::default();
1836        assert_eq!(
1837            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
1838            "wire"
1839        );
1840        assert_eq!(
1841            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
1842            "slancha-mesh"
1843        );
1844    }
1845
1846    #[test]
1847    fn derive_name_returns_stored_name_when_cwd_already_registered() {
1848        let mut reg = SessionRegistry::default();
1849        reg.by_cwd.insert(
1850            "/Users/paul/Source/wire".to_string(),
1851            "wire-special".to_string(),
1852        );
1853        assert_eq!(
1854            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
1855            "wire-special"
1856        );
1857    }
1858
1859    #[test]
1860    fn normalize_cwd_key_case_handling_matches_platform_filesystem() {
1861        // Issue #30 Willard repro: on Windows, two terminals in the "same"
1862        // project under different casings of the same path
1863        // (`C:\Foo\Bar` vs `C:\foo\bar`) hashed to DIFFERENT registry keys
1864        // pre-fix → the second terminal missed the registry lookup, fell
1865        // back to the legacy default identity, and both terminals collapsed
1866        // onto a shared DID. Fix: normalize the cwd key case-insensitively
1867        // on Windows, case-sensitively elsewhere (so distinct-on-disk paths
1868        // on case-sensitive filesystems remain distinct).
1869        let upper = Path::new("/Users/paul/Source/WIRE");
1870        let lower = Path::new("/Users/paul/Source/wire");
1871        if cfg!(windows) {
1872            assert_eq!(
1873                normalize_cwd_key(upper),
1874                normalize_cwd_key(lower),
1875                "on Windows, distinct casings of the same path MUST normalize \
1876                 to the same key (NTFS is case-insensitive by default)"
1877            );
1878        } else {
1879            assert_ne!(
1880                normalize_cwd_key(upper),
1881                normalize_cwd_key(lower),
1882                "on case-sensitive filesystems, distinct casings ARE distinct \
1883                 directories and MUST stay distinct keys"
1884            );
1885        }
1886        // Trivial sanity: same input always produces same output.
1887        assert_eq!(normalize_cwd_key(lower), normalize_cwd_key(lower));
1888    }
1889
1890    #[test]
1891    fn derive_name_no_regression_exact_match_still_resolves() {
1892        // Cross-platform no-regression check for the v0.13.6 lookup
1893        // changes: an exact-match (same casing stored AND looked up)
1894        // entry MUST continue to resolve on the fast path — the new
1895        // O(n) normalized-scan fallback is only reached on the initial
1896        // .get miss.
1897        //
1898        // Honest scope (per coral-weasel's #67 review): this test does
1899        // NOT exercise the case-folding fallback on Linux/macOS — the
1900        // normalizer is a no-op there, so the first `.get` hits and
1901        // the scan never runs. The case-folding behavior is inherently
1902        // Windows-only; that path is covered by
1903        // derive_name_finds_registered_cwd_under_alternate_casing_on_windows
1904        // which executes on Windows CI.
1905        let mut reg = SessionRegistry::default();
1906        let stored = "/Users/Paul/Source/Wire-v0_13_5-Era";
1907        reg.by_cwd
1908            .insert(stored.to_string(), "wire-legacy".to_string());
1909
1910        // Lookup under the EXACT stored path: must resolve on the
1911        // fast `.get` path regardless of platform.
1912        assert_eq!(
1913            derive_name_from_cwd(Path::new(stored), &reg),
1914            "wire-legacy",
1915            "exact-match v0.13.5 entry MUST still resolve under v0.13.6+"
1916        );
1917    }
1918
1919    #[test]
1920    fn derive_name_scan_fallback_runs_when_initial_get_misses() {
1921        // Cross-platform proof that the O(n) normalized-scan fallback
1922        // engages on a .get miss. We can't trigger the *case-folding*
1923        // case on Linux/macOS (normalizer is a no-op), but we CAN
1924        // exercise the scan branch by storing under a key the
1925        // normalized lookup definitely won't hit, and verifying that
1926        // the .find()-based fallback resolves it.
1927        //
1928        // Setup: store under a key that's identical to the lookup
1929        // BUT with a trailing slash difference (so `.get` exact-match
1930        // misses, but our normalize_cwd_key — which preserves the
1931        // trailing slash — also misses; then we rely on the .find()
1932        // iterator). This is a contrived setup that proves the scan
1933        // branch is reachable; it does NOT test case-folding (Windows
1934        // only).
1935        //
1936        // A simpler way to exercise the same logic: store under one
1937        // path, look up under a different path that normalizes to the
1938        // SAME key. Without case-folding, the only way to do that is
1939        // to mutate normalize_cwd_key. Since we can't do that in a
1940        // test, this test instead pins the *no-false-positive* side:
1941        // a path with no matching stored entry must NOT resolve.
1942        let mut reg = SessionRegistry::default();
1943        reg.by_cwd.insert(
1944            "/Users/paul/Source/project-a".to_string(),
1945            "project-a".to_string(),
1946        );
1947
1948        // Distinct path → no match → falls through to basename
1949        // derivation. Proves the scan doesn't fabricate matches.
1950        let derived = derive_name_from_cwd(Path::new("/Users/paul/Source/project-b"), &reg);
1951        assert_eq!(
1952            derived, "project-b",
1953            "non-matching lookup must fall through to basename derivation, \
1954             NOT fabricate a match via the scan"
1955        );
1956    }
1957
1958    #[cfg(windows)]
1959    #[test]
1960    fn derive_name_finds_registered_cwd_under_alternate_casing_on_windows() {
1961        // Direct integration check for the Willard repro on Windows: an
1962        // existing registry entry written under one casing MUST resolve
1963        // when the lookup arrives under a different casing of the same
1964        // path.
1965        //
1966        // Trace through the v0.13.6 read-side O(n) normalized scan:
1967        //   - Stored key: "C:\Users\Willard\ComfyUI\claude-integration"
1968        //   - Lookup cwd: "c:\users\willard\comfyui\claude-integration"
1969        //   - cwd_key  = normalize(lookup) = "c:\users\..." (already lower)
1970        //   - .get(&cwd_key)  → MISS (stored has mixed casing)
1971        //   - .iter().find(normalize(stored) == cwd_key) → HIT
1972        //     (normalize("C:\Users\...") == "c:\users\..." == cwd_key)
1973        //   - Returns "claude-integration" ← the fix.
1974        //
1975        // Pre-fix this returned the basename → phantom hash-suffix → identity
1976        // collision (the original Willard report).
1977        let mut reg = SessionRegistry::default();
1978        reg.by_cwd.insert(
1979            r"C:\Users\Willard\ComfyUI\claude-integration".to_string(),
1980            "claude-integration".to_string(),
1981        );
1982        let from_lower_cwd = Path::new(r"c:\users\willard\comfyui\claude-integration");
1983        assert_eq!(
1984            derive_name_from_cwd(from_lower_cwd, &reg),
1985            "claude-integration",
1986            "Windows lookup MUST find the registered entry regardless of \
1987             how the shell capitalized the cwd, via the normalized scan"
1988        );
1989    }
1990
1991    #[test]
1992    fn read_session_endpoints_handles_missing_relay_state() {
1993        let tmp = tempfile::tempdir().unwrap();
1994        // No relay.json under <home>/config/wire/ — should yield empty.
1995        let endpoints = read_session_endpoints(tmp.path());
1996        assert!(endpoints.is_empty());
1997    }
1998
1999    #[test]
2000    fn read_session_endpoints_parses_dual_slot_form() {
2001        let tmp = tempfile::tempdir().unwrap();
2002        let cfg = tmp.path().join("config").join("wire");
2003        std::fs::create_dir_all(&cfg).unwrap();
2004        let body = serde_json::json!({
2005            "self": {
2006                "relay_url": "https://wireup.net",
2007                "slot_id": "fed-slot",
2008                "slot_token": "fed-tok",
2009                "endpoints": [
2010                    {
2011                        "relay_url": "https://wireup.net",
2012                        "slot_id": "fed-slot",
2013                        "slot_token": "fed-tok",
2014                        "scope": "federation"
2015                    },
2016                    {
2017                        "relay_url": "http://127.0.0.1:8771",
2018                        "slot_id": "loop-slot",
2019                        "slot_token": "loop-tok",
2020                        "scope": "local"
2021                    }
2022                ]
2023            }
2024        });
2025        std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
2026        let endpoints = read_session_endpoints(tmp.path());
2027        assert_eq!(endpoints.len(), 2);
2028        let local_count = endpoints
2029            .iter()
2030            .filter(|e| matches!(e.scope, EndpointScope::Local))
2031            .count();
2032        assert_eq!(local_count, 1);
2033        let local = endpoints
2034            .iter()
2035            .find(|e| matches!(e.scope, EndpointScope::Local))
2036            .unwrap();
2037        assert_eq!(local.relay_url, "http://127.0.0.1:8771");
2038        assert_eq!(local.slot_id, "loop-slot");
2039    }
2040
2041    // NOTE: list_local_sessions is integration-tested via tests/cli.rs
2042    // using a subprocess that sets WIRE_HOME per-process. We do not test
2043    // it in-module because env mutation races other parallel unit tests
2044    // (Rust 2024 marks std::env::set_var unsafe for that reason). The
2045    // grouping logic is straightforward enough that the integration
2046    // test plus the read_session_endpoints unit tests above provide
2047    // adequate coverage.
2048
2049    #[test]
2050    fn derive_name_appends_path_hash_when_basename_collides() {
2051        let mut reg = SessionRegistry::default();
2052        reg.by_cwd
2053            .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
2054        // Different cwd, same basename → must get a hash suffix.
2055        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
2056        assert!(name.starts_with("wire-"));
2057        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
2058        assert_ne!(name, "wire");
2059    }
2060
2061    // ---------- identity-collision warning (issue #29/#30 — broaden to
2062    // every inbox-cursor-owning subcommand, not just `wire mcp`). ----------
2063
2064    #[test]
2065    fn inbox_owning_subcommands_covers_each_runtime_role() {
2066        // Lock the role list down — any addition / removal here must
2067        // come with an updated call site (cli::cmd_daemon, cmd_monitor,
2068        // cmd_notify, mcp::run) and an updated rendezvous in the pgrep
2069        // predicate. The pgrep predicate is built from this list at
2070        // call time, so adding "watch" here automatically extends
2071        // detection — but the warning is only fired if a call site
2072        // also invokes warn_on_identity_collision with that role.
2073        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"mcp"));
2074        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"daemon"));
2075        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"monitor"));
2076        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"notify"));
2077        // Pair-host is documented-coexist with daemon — must NOT be in
2078        // the list or operators get a false positive on every queued
2079        // pair flow.
2080        assert!(!INBOX_OWNING_SUBCOMMANDS.contains(&"pair-host"));
2081    }
2082
2083    #[test]
2084    fn find_colliders_returns_only_same_home_pids() {
2085        let our_home = "/tmp/wire-home-A";
2086        let others = vec![
2087            (101, Some("/tmp/wire-home-A".to_string())), // collide
2088            (102, Some("/tmp/wire-home-B".to_string())), // distinct home
2089            (103, None),                                 // env-unreadable, skip
2090            (104, Some("/tmp/wire-home-A".to_string())), // collide
2091        ];
2092        let colliders = find_colliders(our_home, &others);
2093        assert_eq!(colliders, vec![101, 104]);
2094    }
2095
2096    #[test]
2097    fn find_colliders_no_match_returns_empty() {
2098        let our_home = "/tmp/wire-home-A";
2099        let others = vec![
2100            (101, Some("/tmp/wire-home-B".to_string())),
2101            (102, Some("/tmp/wire-home-C".to_string())),
2102            (103, None),
2103        ];
2104        assert!(find_colliders(our_home, &others).is_empty());
2105    }
2106
2107    #[test]
2108    fn find_colliders_empty_input_is_empty() {
2109        assert!(find_colliders("/tmp/anywhere", &[]).is_empty());
2110    }
2111
2112    #[test]
2113    fn find_colliders_ignores_substring_matches() {
2114        // `WIRE_HOME=/wire-A` must NOT collide with `WIRE_HOME=/wire-A/sub`.
2115        // Exact-match semantics protect against parent/child confusion.
2116        let our_home = "/tmp/wire-A";
2117        let others = vec![
2118            (201, Some("/tmp/wire-A/sub".to_string())),
2119            (202, Some("/wire-A".to_string())), // distinct path
2120            (203, Some("/tmp/wire-A".to_string())), // real collision
2121        ];
2122        assert_eq!(find_colliders(our_home, &others), vec![203]);
2123    }
2124
2125    #[test]
2126    fn collision_warning_format_includes_role_home_and_pids() {
2127        // Sanity-check the first warning line by reconstructing it
2128        // exactly the way `emit_collision_warning` does. If anyone
2129        // changes the format, this test must change with it — that's
2130        // the point: the format is a documented operator-facing
2131        // surface (Willard's #30 cited the older wording verbatim
2132        // when filing the bug).
2133        let role = "daemon";
2134        let home = "/tmp/by-key/abc123";
2135        let colliders = vec![4242u32, 4243u32];
2136        let expected_head = format!(
2137            "wire {role}: WARNING — {n} other wire process(es) already using WIRE_HOME=`{home}` (pid {pids})",
2138            n = colliders.len(),
2139            pids = colliders
2140                .iter()
2141                .map(u32::to_string)
2142                .collect::<Vec<_>>()
2143                .join(", "),
2144        );
2145        assert_eq!(
2146            expected_head,
2147            "wire daemon: WARNING — 2 other wire process(es) already using WIRE_HOME=`/tmp/by-key/abc123` (pid 4242, 4243)"
2148        );
2149        // Exercise the renderer so it can't bit-rot via dead-code
2150        // pruning. Output goes to stderr; under libtest it's captured.
2151        emit_collision_warning(role, home, &colliders);
2152    }
2153}