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 the JSON `{"pid": <n>, ...}` form (v0.5.11+). Anything
576    // else reads as "no daemon".
577    serde_json::from_slice::<serde_json::Value>(&bytes)
578        .ok()
579        .and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
580        .map(|p| p as u32)
581}
582
583fn check_daemon_live(session_home: &Path) -> bool {
584    session_daemon_pid(session_home)
585        .map(is_process_live)
586        .unwrap_or(false)
587}
588
589/// Walk every initialized session and read its `daemon.pid`; return a
590/// map from `pid → session_name`. Used by `wire status`'s orphan-pid
591/// annotation (#173 follow-up) so a supervisor child's pid — which
592/// no longer carries `--session <name>` in its cmdline post-#174 — is
593/// still correctly attributed to the session whose home it serves.
594///
595/// Cost: one filesystem read per session per status invocation. On a
596/// 133-session box that's 133 small reads (a few ms total) — bounded
597/// + acceptable. The map is fresh per call; no caching, no staleness.
598pub fn pid_to_session_map() -> HashMap<u32, String> {
599    let mut out = HashMap::new();
600    let sessions = match list_sessions() {
601        Ok(v) => v,
602        Err(_) => return out,
603    };
604    for info in sessions {
605        if let Some(pid) = session_daemon_pid(&info.home_dir) {
606            out.insert(pid, info.name);
607        }
608    }
609    out
610}
611
612fn is_process_live(pid: u32) -> bool {
613    // v0.7.3: delegate to the shared platform helper. The previous
614    // implementation shelled out to `kill -0` on non-Linux, which
615    // unconditionally failed on Windows (no `kill` binary) and made
616    // `wire session list` report every daemon as `down` regardless of
617    // actual liveness.
618    crate::platform::process_alive(pid)
619}
620
621/// Read a session's `relay.json` and return its `self.endpoints[]`
622/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
623/// is a best-effort discovery helper, not a verification tool. A pre-
624/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
625/// promotes those to a federation-only Endpoint, so the result is
626/// still meaningful for legacy sessions.
627///
628/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
629/// not the canonical filename (`config::relay_state_path` returns
630/// `relay.json`). The mis-named read silently no-op'd and
631/// `list-local` always returned an empty `local` map as a result.
632/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
633/// in the same release — that helper had the symmetric write-side
634/// bug, so the local endpoint never got persisted in the first place.
635pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
636    let path = session_home.join("config").join("wire").join("relay.json");
637    let bytes = match std::fs::read(&path) {
638        Ok(b) => b,
639        Err(_) => return Vec::new(),
640    };
641    let val: Value = match serde_json::from_slice(&bytes) {
642        Ok(v) => v,
643        Err(_) => return Vec::new(),
644    };
645    self_endpoints(&val)
646}
647
648/// Stripped view of a Local endpoint for tooling output. Drops
649/// `slot_token` because it is a bearer credential — exposing it
650/// through `wire session list-local --json` would risk accidental
651/// leak via logs, screenshots, or piped output. Routing code uses
652/// the full `Endpoint` from `relay.json` directly; this type
653/// is for human/JSON observation only.
654#[derive(Debug, Clone, Serialize)]
655pub struct LocalEndpointView {
656    pub relay_url: String,
657    pub slot_id: String,
658}
659
660/// One row of `wire session list-local` output: a session that has a
661/// Local-scope endpoint plus metadata to render it.
662#[derive(Debug, Clone, Serialize)]
663pub struct LocalSessionView {
664    pub name: String,
665    pub handle: Option<String>,
666    pub did: Option<String>,
667    pub cwd: Option<String>,
668    pub home_dir: PathBuf,
669    pub daemon_running: bool,
670    /// All Local-scope endpoints this session advertises (token redacted).
671    /// Most sessions have exactly one; multiple is permitted for multi-
672    /// relay setups.
673    pub local_endpoints: Vec<LocalEndpointView>,
674}
675
676/// Sessions with no Local endpoint — shown separately so the operator
677/// knows they exist but are federation-only.
678#[derive(Debug, Clone, Serialize)]
679pub struct FederationOnlySessionView {
680    pub name: String,
681    pub handle: Option<String>,
682    pub cwd: Option<String>,
683}
684
685/// Result shape for `wire session list-local`. `local` is grouped by
686/// the local-relay URL so output can render each cluster of mutually-
687/// reachable sister sessions together. `federation_only` lists the rest.
688#[derive(Debug, Clone, Serialize)]
689pub struct LocalSessionListing {
690    pub local: HashMap<String, Vec<LocalSessionView>>,
691    pub federation_only: Vec<FederationOnlySessionView>,
692}
693
694/// Build the listing for `wire session list-local` from current on-disk
695/// state. Read-only; no daemon contact, no relay probe.
696pub fn list_local_sessions() -> Result<LocalSessionListing> {
697    let sessions = list_sessions()?;
698    let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
699    let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
700
701    for s in sessions {
702        let endpoints = read_session_endpoints(&s.home_dir);
703        let local_eps: Vec<Endpoint> = endpoints
704            .into_iter()
705            .filter(|e| {
706                // v0.7.4: include any session whose endpoint URL is a
707                // loopback address even if it's tagged Federation, not
708                // Local. This catches the legitimate-but-misshapen case
709                // where `wire init --relay http://127.0.0.1:8771` was run
710                // without `--with-local`, leaving the session with a
711                // loopback federation slot that's effectively local-mesh-
712                // reachable. Pre-v0.7.4 the strict scope-only filter
713                // silently excluded those sessions from `pair-all-local`,
714                // making nickname-based pairing fail for no operator-
715                // visible reason.
716                matches!(e.scope, EndpointScope::Local)
717                    || (matches!(e.scope, EndpointScope::Federation)
718                        && url_is_loopback(&e.relay_url))
719            })
720            .collect();
721        if local_eps.is_empty() {
722            federation_only.push(FederationOnlySessionView {
723                name: s.name.clone(),
724                handle: s.handle.clone(),
725                cwd: s.cwd.clone(),
726            });
727            continue;
728        }
729        // Redacted view: drop slot_token before exposing through CLI.
730        let redacted: Vec<LocalEndpointView> = local_eps
731            .iter()
732            .map(|e| LocalEndpointView {
733                relay_url: e.relay_url.clone(),
734                slot_id: e.slot_id.clone(),
735            })
736            .collect();
737        // Group by relay_url. A session with two Local endpoints (rare —
738        // would mean two loopback relays) appears under each.
739        for ep in &local_eps {
740            local
741                .entry(ep.relay_url.clone())
742                .or_default()
743                .push(LocalSessionView {
744                    name: s.name.clone(),
745                    handle: s.handle.clone(),
746                    did: s.did.clone(),
747                    cwd: s.cwd.clone(),
748                    home_dir: s.home_dir.clone(),
749                    daemon_running: s.daemon_running,
750                    local_endpoints: redacted.clone(),
751                });
752        }
753    }
754    // Sort each group by session name so output is deterministic.
755    for group in local.values_mut() {
756        group.sort_by(|a, b| a.name.cmp(&b.name));
757    }
758    federation_only.sort_by(|a, b| a.name.cmp(&b.name));
759    Ok(LocalSessionListing {
760        local,
761        federation_only,
762    })
763}
764
765/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
766///
767/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
768/// registry. If a session is registered for this cwd AND its home
769/// directory still exists, return that home dir; otherwise None.
770///
771/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
772/// so a `wire whoami` / `wire monitor` invocation from a project cwd
773/// adopts that project's session identity automatically, instead of
774/// silently falling back to the machine default. The CLI parity is
775/// load-bearing: without it, the user-visible identity diverges
776/// between MCP and the terminal, and monitors pull machine-wide
777/// inboxes when the operator expected a per-session view.
778pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
779    let registry = read_registry().ok()?;
780    // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
781    // inherit their parent's wire identity (e.g.
782    // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
783    // Without this, subdirs all fell back to the machine-wide default
784    // identity, which silently collapsed multiple Claude sessions onto the
785    // same DID + character.
786    let mut probe: Option<&std::path::Path> = Some(cwd);
787    while let Some(path) = probe {
788        // Same O(n) normalized scan as derive_name_from_cwd: handles both
789        // consistent-casing and cross-casing upgraders. See the comment
790        // on derive_name_from_cwd for the rationale.
791        let path_str = normalize_cwd_key(path);
792        if let Some(session_name) = registry.by_cwd.get(&path_str).or_else(|| {
793            registry
794                .by_cwd
795                .iter()
796                .find(|(k, _)| normalize_cwd_key(Path::new(k)) == path_str)
797                .map(|(_, v)| v)
798        }) {
799            let session_home = session_dir(session_name).ok()?;
800            if session_home.exists() {
801                return Some(session_home);
802            }
803        }
804        probe = path.parent();
805    }
806    None
807}
808
809/// v0.13: resolve a stable per-session key — host-agnostic, with a Claude
810/// Code adapter and the path left open for other hosts. Order:
811///   1. `WIRE_SESSION_ID` — explicit universal override (any harness).
812///   2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per
813///      conversation; the same id the auto-memory system keys off).
814///   3. `CODEX_SESSION_ID` — OpenAI Codex CLI adapter. Stable per Codex
815///      thread (the same UUIDv7 emitted in `thread.started` and used as
816///      the rollout-file suffix under `$CODEX_HOME/sessions/`). Codex
817///      does not yet forward this var to MCP children out of the box —
818///      operators must set it via `[mcp_servers.<name>.env]` in
819///      `~/.codex/config.toml` (or upstream Codex must add it to the
820///      MCP child env). Wiring the name in advance means once Codex
821///      ships the env, wire picks it up with zero further code change.
822///   4. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` /
823///      `copilot`) adapter. Set by the Copilot CLI host for every
824///      session; stable per conversation; UUID-shaped.
825///   5. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based
826///      identity (stable per workspace).
827///   6. `None` — caller falls back to legacy cwd-detect (bare CLI /
828///      pre-v0.13 hosts). Future host adapters slot in before this.
829///
830/// Returns `(key, source-label)`.
831pub fn resolve_session_key() -> Option<(String, &'static str)> {
832    for (var, source) in [
833        ("WIRE_SESSION_ID", "override"),
834        ("CLAUDE_CODE_SESSION_ID", "claude-code"),
835        ("CODEX_SESSION_ID", "codex-cli"),
836        ("COPILOT_AGENT_SESSION_ID", "copilot-cli"),
837        ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"),
838    ] {
839        if let Ok(v) = std::env::var(var)
840            && valid_session_key(&v)
841        {
842            return Some((v.trim().to_string(), source));
843        }
844    }
845    // Claude Code adapter (host-agnostic fallback). On some platforms the MCP
846    // server process does not inherit CLAUDE_CODE_SESSION_ID and the MCP
847    // `initialize` handshake carries no session id, so the env checks above
848    // miss. Claude Code, however, writes `~/.claude/sessions/<pid>.json`
849    // ({"sessionId":..., "cwd":...}) for each live session, named by the
850    // owning `claude` process PID. Walk our parent-process chain to that
851    // process and read its sessionId — deterministic, race-free, env-free.
852    if let Some(sid) = claude_code_session_from_pidfile() {
853        return Some((sid, "claude-code-pidfile"));
854    }
855
856    None
857}
858
859/// A session key from the environment is usable only if it is non-empty and is
860/// NOT an unexpanded `${...}` placeholder. A host that writes
861/// `"env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}` but doesn't expand
862/// it (Windows Claude Code passes the literal when the var is absent) would
863/// otherwise have wire hash the literal — collapsing every session onto one
864/// identity. Treat any `${...}` value as unset so resolution falls through to
865/// the PID-file adapter / per-process mint instead of a shared bogus persona.
866fn valid_session_key(v: &str) -> bool {
867    let v = v.trim();
868    !v.is_empty() && !v.contains("${")
869}
870
871/// Recover the Claude Code session id from the per-session PID-file when it
872/// isn't available via the environment. Claude Code writes
873/// `~/.claude/sessions/<pid>.json` = `{"sessionId": "...", "cwd": "...", ...}`
874/// for each live session, keyed by the owning `claude` process PID. The MCP
875/// server we run inside is a descendant of that process, so we walk our
876/// parent chain and return the `sessionId` of the first ancestor that has a
877/// PID-file. Cross-platform: the file exists on macOS/Linux/Windows alike.
878fn claude_code_session_from_pidfile() -> Option<String> {
879    let dir = dirs::home_dir()?.join(".claude").join("sessions");
880    let mut pid = std::process::id();
881    // Chains are shallow (MCP server -> launcher -> claude); 16 is generous.
882    for _ in 0..16 {
883        let f = dir.join(format!("{pid}.json"));
884        if let Ok(txt) = std::fs::read_to_string(&f)
885            && let Ok(v) = serde_json::from_str::<Value>(&txt)
886            && let Some(s) = v.get("sessionId").and_then(Value::as_str)
887        {
888            let s = s.trim();
889            if !s.is_empty() {
890                return Some(s.to_string());
891            }
892        }
893        pid = parent_pid(pid)?;
894    }
895    None
896}
897
898/// Best-effort parent-PID lookup. Linux: `/proc/<pid>/status`. macOS: `ps`.
899/// Windows: PowerShell CIM (no extra crate). Returns `None` on any failure,
900/// which simply ends the walk.
901#[cfg(target_os = "linux")]
902fn parent_pid(pid: u32) -> Option<u32> {
903    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
904    for line in status.lines() {
905        if let Some(rest) = line.strip_prefix("PPid:") {
906            return rest.trim().parse().ok();
907        }
908    }
909    None
910}
911
912#[cfg(target_os = "macos")]
913fn parent_pid(pid: u32) -> Option<u32> {
914    let out = std::process::Command::new("ps")
915        .args(["-o", "ppid=", "-p", &pid.to_string()])
916        .output()
917        .ok()?;
918    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
919}
920
921#[cfg(target_os = "windows")]
922fn parent_pid(pid: u32) -> Option<u32> {
923    use std::os::windows::process::CommandExt;
924    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
925    let out = std::process::Command::new("powershell")
926        .args([
927            "-NoProfile",
928            "-NonInteractive",
929            "-Command",
930            &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').ParentProcessId"),
931        ])
932        .creation_flags(CREATE_NO_WINDOW)
933        .output()
934        .ok()?;
935    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
936}
937
938#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
939fn parent_pid(_pid: u32) -> Option<u32> {
940    None
941}
942
943/// v0.13: the WIRE_HOME for a resolved session key —
944/// `<sessions_root>/by-key/<hash>` where `hash` is the first 16 hex of
945/// SHA-256(key). Deterministic and cwd-independent, so two sessions never
946/// collide and there is no path-string to mis-normalize (the Windows bug
947/// cannot occur). 64 bits is collision-safe at this scale.
948pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
949    let mut h = Sha256::new();
950    h.update(key.as_bytes());
951    let digest = h.finalize();
952    let hash = hex::encode(&digest[..8]); // 16 hex chars / 64 bits
953    Ok(sessions_root()?.join("by-key").join(hash))
954}
955
956/// Long-running `wire <subcommand>` invocations that own the inbox
957/// cursor and therefore race each other under a shared `WIRE_HOME`.
958/// Keep this list in sync with [`warn_on_identity_collision`]'s pgrep
959/// predicate and the call-site list in `cli::run` / `mcp::run`.
960///
961/// Note: `pair-host` (and the rest of the SAS code-phrase flow) was removed
962/// in RFC-005 follow-on, so it is naturally absent from this list.
963///
964/// Short-lived commands (`whoami`, `status`, `send`, `peers`, …) are
965/// intentionally absent — they write atomically and don't race, and
966/// warning on every one would spam any operator running scripts.
967pub const INBOX_OWNING_SUBCOMMANDS: &[&str] = &["mcp", "daemon", "monitor", "notify"];
968
969/// v0.6.10: warn at MCP/CLI startup if another long-running `wire`
970/// process is already running with the same effective `WIRE_HOME`.
971/// Closes the "two Claudes in same cwd silently share an identity"
972/// failure mode that wasted hours of operator debugging time: today
973/// the collision is invisible (both Claudes resolve to the same wire
974/// session via v0.6.7 auto-detect, race the inbox cursor, "look
975/// identical" from the operator's view). This surfaces it explicitly
976/// with a clear remediation path.
977///
978/// `role` is the calling subcommand label (`"mcp"`, `"daemon"`,
979/// `"monitor"`, …) — used in the warning's leading tag so operators
980/// can tell which surface is observing the collision. Detection
981/// itself spans every inbox-owning role: a `wire daemon` colliding
982/// with an existing `wire mcp` warns just the same as an mcp/mcp
983/// pair.
984///
985/// Best-effort: any subprocess / env-read failure is silent (the
986/// collision check should never block startup). Cross-platform via
987/// `ps -E -p <pid>` on macOS, `/proc/<pid>/environ` on Linux. Windows
988/// returns empty (no collision detected).
989pub fn warn_on_identity_collision(self_pid: u32, role: &str) {
990    let our_wire_home = match std::env::var("WIRE_HOME") {
991        Ok(h) => h,
992        Err(_) => return,
993    };
994
995    // Single pgrep call with an alternation predicate. `pgrep -f`
996    // matches against the full argv string, so `wire (mcp|daemon|…)`
997    // catches every inbox-owning subcommand in one shot. Falls back to
998    // silent no-op on platforms without pgrep (Windows) — the env-read
999    // path below also returns None there, so detection is end-to-end
1000    // unsupported on Windows. Future: a powershell adapter for
1001    // identity collisions, tracked in #29 / #30.
1002    let predicate = format!("wire ({})", INBOX_OWNING_SUBCOMMANDS.join("|"));
1003    let pgrep_out = match std::process::Command::new("pgrep")
1004        .args(["-f", &predicate])
1005        .output()
1006    {
1007        Ok(o) if o.status.success() => o,
1008        _ => return,
1009    };
1010
1011    let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
1012        .split_whitespace()
1013        .filter_map(|s| s.parse::<u32>().ok())
1014        .filter(|&p| p != self_pid)
1015        .collect();
1016
1017    let other_homes: Vec<(u32, Option<String>)> = other_pids
1018        .iter()
1019        .map(|p| (*p, read_wire_home_from_pid(*p)))
1020        .collect();
1021
1022    let colliders = find_colliders(&our_wire_home, &other_homes);
1023
1024    if colliders.is_empty() {
1025        return;
1026    }
1027
1028    emit_collision_warning(role, &our_wire_home, &colliders);
1029}
1030
1031/// Pure decision: from a snapshot of `(pid, their_wire_home)` for
1032/// every other wire process on the host, return the pids whose
1033/// `WIRE_HOME` exactly matches ours. Missing-home entries (process
1034/// died, env unreadable on this platform) are skipped, never counted.
1035pub(crate) fn find_colliders(
1036    our_wire_home: &str,
1037    other_homes: &[(u32, Option<String>)],
1038) -> Vec<u32> {
1039    other_homes
1040        .iter()
1041        .filter_map(|(pid, their_home)| match their_home {
1042            Some(h) if h == our_wire_home => Some(*pid),
1043            _ => None,
1044        })
1045        .collect()
1046}
1047
1048/// Render the collision warning. Extracted so the format is unit-
1049/// testable without mocking a real pgrep / cross-process env read.
1050pub(crate) fn emit_collision_warning(role: &str, our_wire_home: &str, colliders: &[u32]) {
1051    eprintln!(
1052        "wire {role}: WARNING — {} other wire process(es) already using WIRE_HOME=`{}` (pid {})",
1053        colliders.len(),
1054        our_wire_home,
1055        colliders
1056            .iter()
1057            .map(|p| p.to_string())
1058            .collect::<Vec<_>>()
1059            .join(", ")
1060    );
1061    eprintln!(
1062        "  Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
1063    );
1064    eprintln!("  To use a separate identity:");
1065    eprintln!("    1. Close the other agent(s), OR");
1066    eprintln!("    2. `wire session new <name> --local-only` to create a fresh identity, then");
1067    eprintln!(
1068        "    3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
1069    );
1070}
1071
1072/// Best-effort cross-platform read of another process's `WIRE_HOME`.
1073/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
1074/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
1075/// Windows / other: returns `None` (collision detection no-ops).
1076fn read_wire_home_from_pid(pid: u32) -> Option<String> {
1077    #[cfg(target_os = "linux")]
1078    {
1079        let path = format!("/proc/{pid}/environ");
1080        let bytes = std::fs::read(&path).ok()?;
1081        for entry in bytes.split(|&b| b == 0) {
1082            let s = match std::str::from_utf8(entry) {
1083                Ok(s) => s,
1084                Err(_) => continue,
1085            };
1086            if let Some(val) = s.strip_prefix("WIRE_HOME=") {
1087                return Some(val.to_string());
1088            }
1089        }
1090        None
1091    }
1092
1093    #[cfg(target_os = "macos")]
1094    {
1095        let output = std::process::Command::new("ps")
1096            .args(["-E", "-p", &pid.to_string(), "-o", "command="])
1097            .output()
1098            .ok()?;
1099        let s = String::from_utf8_lossy(&output.stdout);
1100        for tok in s.split_whitespace() {
1101            if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
1102                return Some(val.to_string());
1103            }
1104        }
1105        None
1106    }
1107
1108    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1109    {
1110        let _ = pid;
1111        None
1112    }
1113}
1114
1115/// v0.6.7: apply `detect_session_wire_home` for the current process.
1116///
1117/// If `WIRE_HOME` is unset and the current cwd maps to an existing
1118/// session, set `WIRE_HOME` for the rest of this process and emit a
1119/// one-liner to stderr so the operator knows which identity is in
1120/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
1121///
1122/// `label` distinguishes the caller in the stderr line (`mcp` vs
1123/// `cli`). Output only appears on interactive TTYs; set `WIRE_VERBOSE=1`
1124/// to force it on in non-interactive contexts.
1125///
1126/// MUST be called BEFORE any worker thread or async task spawns —
1127/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
1128/// guarantees, and our use is safe only at process entry.
1129/// Process-global record of WHICH signal won session/home resolution,
1130/// captured at adoption time by [`maybe_adopt_session_wire_home`]. Read by
1131/// `wire whoami --json` (`session_source`) so an operator can see in one
1132/// command whether identity came from an explicit `WIRE_HOME`, a host
1133/// session-id adapter, the Claude-Code pidfile fallback, a minted
1134/// per-process key, or the machine default. Post-hoc re-derivation is
1135/// unreliable — minting sets `WIRE_SESSION_ID` and `WIRE_HOME` is always set
1136/// after adoption — so the winning source MUST be captured here, once.
1137static SESSION_SOURCE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
1138
1139/// The signal that won session/home resolution for this process. One of:
1140/// `env:WIRE_HOME`, `override` (`WIRE_SESSION_ID`), `claude-code`,
1141/// `claude-code-pidfile`, `codex-cli`, `copilot-cli`, `vscode-workspace`,
1142/// `minted`, `machine-default`, or `unknown` if adoption never ran.
1143pub fn session_source() -> &'static str {
1144    SESSION_SOURCE.get().copied().unwrap_or("unknown")
1145}
1146
1147pub fn maybe_adopt_session_wire_home(label: &str) {
1148    if std::env::var("WIRE_HOME").is_ok() {
1149        let _ = SESSION_SOURCE.set("env:WIRE_HOME");
1150        return;
1151    }
1152    // v0.13: prefer the host-agnostic session key (WIRE_SESSION_ID >
1153    // CLAUDE_CODE_SESSION_ID). Each session gets its own WIRE_HOME under
1154    // `by-key/<hash>` — no cwd lookup, no shared default, no Windows path
1155    // collapse. Falls back to legacy cwd-detect only when no session key is
1156    // present (bare CLI / pre-v0.13 hosts).
1157    let (home, why) = if let Some((key, source)) = resolve_session_key() {
1158        match session_home_for_key(&key) {
1159            Ok(h) => {
1160                // v0.13.2 (E8): do NOT create the home here. Creating it
1161                // unconditionally on every resolution — before any identity
1162                // exists — left a permanent empty home for every transient /
1163                // probe session key that never `wire up`d, accumulating
1164                // forever and surfacing as phantom "?" sisters in list-local.
1165                // The home is created lazily by `ensure_dirs` on the first
1166                // real write (init / claim / send), so an uninitialized
1167                // session leaves no trace on disk. (Write paths already
1168                // tolerate a non-existent WIRE_HOME — the test harness runs
1169                // every test against one.)
1170                let _ = SESSION_SOURCE.set(source);
1171                (h, format!("session key ({source})"))
1172            }
1173            Err(_) => return,
1174        }
1175    } else if label == "mcp" {
1176        // v0.13.4 (operator directive: per-session ONLY, never cwd). The MCP
1177        // server must NEVER cwd-resolve — that fallback is what collapsed every
1178        // Claude session sharing a launch dir (`~/Source`, `C:\Users\<user>`)
1179        // onto a single persona. A stdio MCP server is one process per Claude
1180        // session, so when no session id reached us (the
1181        // `${CLAUDE_CODE_SESSION_ID}` env-forward is missing or didn't expand)
1182        // we MINT a per-process key: distinct per session, never a shared cwd
1183        // identity. With the env-forward in place this branch isn't reached —
1184        // the session id resolves above.
1185        let minted = format!(
1186            "mcp-proc-{:016x}{:016x}",
1187            rand::random::<u64>(),
1188            rand::random::<u64>()
1189        );
1190        match session_home_for_key(&minted) {
1191            Ok(h) => {
1192                // Pin it for the process so every later resolve is consistent.
1193                unsafe {
1194                    std::env::set_var("WIRE_SESSION_ID", &minted);
1195                }
1196                let _ = SESSION_SOURCE.set("minted");
1197                (
1198                    h,
1199                    "minted per-process key (no session id; cwd disabled for MCP)".to_string(),
1200                )
1201            }
1202            Err(_) => return,
1203        }
1204    } else {
1205        // CLI with no session id. Per the per-session-only directive we do NOT
1206        // cwd-resolve here either — cwd identity is the collision trap (agents
1207        // shell out to the CLI, and any cwd-derived identity risks the wrong /
1208        // shared persona). Under Claude Code the CLI always carries
1209        // CLAUDE_CODE_SESSION_ID (resolved above), so this only hits a bare
1210        // terminal outside an agent host — which gets the stable machine-default
1211        // identity (set WIRE_SESSION_ID / WIRE_HOME for an explicit one). No cwd.
1212        let _ = SESSION_SOURCE.set("machine-default");
1213        return;
1214    };
1215    // v0.9.1: emit the chatter ONLY when stderr is an interactive TTY.
1216    // When wire is invoked from a non-interactive parent (Claude Code's
1217    // Bash tool, scripts, daemons), the auto-detect line is captured
1218    // alongside command output and pollutes both — wasting agent
1219    // context tokens and breaking JSON parsers that read combined
1220    // streams. WIRE_VERBOSE=1 forces the line on.
1221    use std::io::IsTerminal;
1222    let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
1223    let interactive = std::io::stderr().is_terminal();
1224    if interactive || verbose_env {
1225        eprintln!(
1226            "wire {label}: adopted {why} → WIRE_HOME=`{}`",
1227            home.display()
1228        );
1229    }
1230    // SAFETY: caller contract is "before any thread spawn." All
1231    // production sites (cli::run, mcp::run) call this as the first
1232    // step in their respective entry points.
1233    unsafe {
1234        std::env::set_var("WIRE_HOME", &home);
1235    }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241
1242    #[test]
1243    fn valid_session_key_rejects_empty_and_unexpanded_placeholder() {
1244        assert!(valid_session_key("4129275d-cc5c-4d2a"));
1245        assert!(valid_session_key("mcp-proc-deadbeef"));
1246        assert!(!valid_session_key(""));
1247        assert!(!valid_session_key("   "));
1248        // The load-bearing guard: an unexpanded MCP-config placeholder must NOT
1249        // be hashed — that's the all-sessions-collapse (soft-spruce) bug.
1250        assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
1251        assert!(!valid_session_key("  ${CLAUDE_CODE_SESSION_ID}  "));
1252    }
1253
1254    #[test]
1255    fn resolve_session_key_vscode_adapter_and_placeholder_guard() {
1256        // Per-adapter test for the VS Code / GitHub Copilot path added in #59.
1257        // Holds two invariants the integration depends on:
1258        //
1259        //   (a) When VSCODE_GIT_REPOSITORY_ROOT is set to a real workspace
1260        //       path, that key wins resolution and two distinct workspace
1261        //       paths produce two distinct session homes — proves the
1262        //       per-workspace-identity contract documented in
1263        //       docs/integrations/GITHUB_COPILOT.md.
1264        //
1265        //   (b) When the env entry is the unexpanded literal "${workspaceFolder}"
1266        //       (host failed to substitute), the ${} guard rejects it and the
1267        //       fn falls through — proves the safe-degradation property
1268        //       (no-identity, NOT cross-workspace collision).
1269        //
1270        // Mirrors the WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID semantics so any
1271        // future adapter added to the env-check loop inherits the same gates.
1272        let _guard = crate::config::test_support::ENV_LOCK
1273            .lock()
1274            .unwrap_or_else(|p| p.into_inner());
1275
1276        // Snapshot + clear every env var resolve_session_key consults so this
1277        // test is hermetic regardless of the harness environment.
1278        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1279        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1280        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1281        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1282        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1283        // SAFETY: ENV_LOCK is held, serializing all env access.
1284        unsafe {
1285            std::env::remove_var("WIRE_SESSION_ID");
1286            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1287            std::env::remove_var("CODEX_SESSION_ID");
1288            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1289            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1290        }
1291
1292        // (a) Two distinct workspace paths -> two distinct, stable session homes.
1293        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1294        let r1 = resolve_session_key();
1295        assert!(
1296            matches!(&r1, Some((k, src)) if k == "/home/dev/frontend" && *src == "vscode-workspace"),
1297            "VSCODE_GIT_REPOSITORY_ROOT must win resolution and be labeled vscode-workspace; got {r1:?}"
1298        );
1299        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1300
1301        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/backend") };
1302        let r2 = resolve_session_key();
1303        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1304        assert_ne!(
1305            home_a, home_b,
1306            "distinct workspace roots must map to distinct session homes (no cross-workspace persona collision)"
1307        );
1308
1309        // Same path again -> same home (resume stability).
1310        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1311        let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1312        assert_eq!(
1313            home_a, home_a2,
1314            "same workspace root must yield the same home across calls"
1315        );
1316
1317        // (b) Unexpanded ${workspaceFolder} literal MUST NOT be accepted.
1318        //     With every other adapter still cleared, resolution must fall
1319        //     through to None (or the claude pidfile path, which is absent in
1320        //     this test env) — never hash the literal.
1321        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "${workspaceFolder}") };
1322        let r_guard = resolve_session_key();
1323        assert!(
1324            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1325            "unexpanded ${{workspaceFolder}} literal must be rejected by the ${{}} guard; got {r_guard:?}"
1326        );
1327        // Same guard for the other adapter slots.
1328        unsafe {
1329            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1330            std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}");
1331        }
1332        let r_guard2 = resolve_session_key();
1333        assert!(
1334            !matches!(&r_guard2, Some((k, _)) if k.contains("${")),
1335            "unexpanded ${{workspaceFolder}} in WIRE_SESSION_ID must also be rejected; got {r_guard2:?}"
1336        );
1337
1338        // Restore any env we displaced.
1339        // SAFETY: ENV_LOCK still held.
1340        unsafe {
1341            std::env::remove_var("WIRE_SESSION_ID");
1342            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1343            std::env::remove_var("CODEX_SESSION_ID");
1344            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1345            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1346            if let Some(v) = prev_override {
1347                std::env::set_var("WIRE_SESSION_ID", v);
1348            }
1349            if let Some(v) = prev_claude {
1350                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1351            }
1352            if let Some(v) = prev_codex {
1353                std::env::set_var("CODEX_SESSION_ID", v);
1354            }
1355            if let Some(v) = prev_copilot {
1356                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1357            }
1358            if let Some(v) = prev_vscode {
1359                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1360            }
1361        }
1362    }
1363
1364    #[test]
1365    fn resolve_session_key_copilot_cli_adapter_and_priority() {
1366        // Per-adapter test for the GitHub Copilot CLI path (Phase 2 of #59):
1367        // resolve_session_key reads COPILOT_AGENT_SESSION_ID (set by the
1368        // `gh copilot` / `copilot` CLI host on every session) as a TARGETED
1369        // env adapter — exactly like CLAUDE_CODE_SESSION_ID. Holds three
1370        // invariants:
1371        //
1372        //   (a) Set to a real id -> that key wins resolution and two distinct
1373        //       conversations map to two distinct session homes (per-
1374        //       conversation identity contract).
1375        //   (b) WIRE_SESSION_ID overrides COPILOT_AGENT_SESSION_ID (priority
1376        //       1 trumps priority 3).
1377        //   (c) Unexpanded ${...} literal is rejected by the ${} guard —
1378        //       falls through to the None path, never hashed (mirrors the
1379        //       guard inherited from CLAUDE_CODE_SESSION_ID / WIRE_SESSION_ID
1380        //       / VSCODE_GIT_REPOSITORY_ROOT).
1381        let _guard = crate::config::test_support::ENV_LOCK
1382            .lock()
1383            .unwrap_or_else(|p| p.into_inner());
1384
1385        // Snapshot every env var resolve_session_key consults so the test is
1386        // hermetic regardless of harness environment (this test literally
1387        // runs under Copilot CLI, where COPILOT_AGENT_SESSION_ID is set).
1388        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1389        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1390        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1391        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1392        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1393        // SAFETY: ENV_LOCK is held, serializing all env access.
1394        unsafe {
1395            std::env::remove_var("WIRE_SESSION_ID");
1396            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1397            std::env::remove_var("CODEX_SESSION_ID");
1398            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1399            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1400        }
1401
1402        // (a) COPILOT_AGENT_SESSION_ID set -> wins resolution; distinct ids
1403        //     map to distinct session homes.
1404        unsafe {
1405            std::env::set_var(
1406                "COPILOT_AGENT_SESSION_ID",
1407                "3869478a-33cc-4c33-82ee-b6403a24d734",
1408            )
1409        };
1410        let r1 = resolve_session_key();
1411        assert!(
1412            matches!(&r1, Some((k, src)) if k == "3869478a-33cc-4c33-82ee-b6403a24d734" && *src == "copilot-cli"),
1413            "COPILOT_AGENT_SESSION_ID must win resolution and be labeled copilot-cli; got {r1:?}"
1414        );
1415        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1416
1417        unsafe {
1418            std::env::set_var(
1419                "COPILOT_AGENT_SESSION_ID",
1420                "deadbeef-0000-0000-0000-000000000000",
1421            )
1422        };
1423        let r2 = resolve_session_key();
1424        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1425        assert_ne!(
1426            home_a, home_b,
1427            "distinct Copilot CLI session ids must map to distinct session homes"
1428        );
1429
1430        // (b) WIRE_SESSION_ID at priority 1 overrides COPILOT_AGENT_SESSION_ID
1431        //     at priority 3. Operator's explicit universal override always wins.
1432        unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
1433        let r_override = resolve_session_key();
1434        assert!(
1435            matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
1436            "WIRE_SESSION_ID must beat COPILOT_AGENT_SESSION_ID; got {r_override:?}"
1437        );
1438        unsafe { std::env::remove_var("WIRE_SESSION_ID") };
1439
1440        // (c) Unexpanded ${...} literal is rejected by the ${} guard.
1441        //     `gh copilot` shouldn't ship literal placeholders in
1442        //     COPILOT_AGENT_SESSION_ID, but if some future config-forwarding
1443        //     path does, the guard must reject it (same as for the other
1444        //     adapters) so we never hash the literal and collapse sessions.
1445        unsafe { std::env::set_var("COPILOT_AGENT_SESSION_ID", "${SOME_PLACEHOLDER}") };
1446        let r_guard = resolve_session_key();
1447        assert!(
1448            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1449            "unexpanded ${{...}} in COPILOT_AGENT_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
1450        );
1451
1452        // Restore any env we displaced.
1453        // SAFETY: ENV_LOCK still held.
1454        unsafe {
1455            std::env::remove_var("WIRE_SESSION_ID");
1456            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1457            std::env::remove_var("CODEX_SESSION_ID");
1458            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1459            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1460            if let Some(v) = prev_override {
1461                std::env::set_var("WIRE_SESSION_ID", v);
1462            }
1463            if let Some(v) = prev_claude {
1464                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1465            }
1466            if let Some(v) = prev_codex {
1467                std::env::set_var("CODEX_SESSION_ID", v);
1468            }
1469            if let Some(v) = prev_copilot {
1470                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1471            }
1472            if let Some(v) = prev_vscode {
1473                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1474            }
1475        }
1476    }
1477
1478    #[test]
1479    fn resolve_session_key_codex_cli_adapter_and_priority() {
1480        // Per-adapter test for the OpenAI Codex CLI path (#__pr_codex__).
1481        // resolve_session_key reads CODEX_SESSION_ID as a TARGETED env adapter
1482        // — exactly like CLAUDE_CODE_SESSION_ID and COPILOT_AGENT_SESSION_ID.
1483        // Until Codex itself forwards the thread id to MCP child env, operators
1484        // wire it via `[mcp_servers.<name>.env]` in `~/.codex/config.toml`;
1485        // landing the adapter now means once Codex ships the env it works
1486        // with zero further code change. Holds three invariants:
1487        //
1488        //   (a) Set to a real thread id -> that key wins resolution and two
1489        //       distinct threads map to two distinct session homes
1490        //       (per-thread identity contract).
1491        //   (b) WIRE_SESSION_ID overrides CODEX_SESSION_ID (priority 1
1492        //       trumps priority 3); CLAUDE_CODE_SESSION_ID also outranks
1493        //       CODEX_SESSION_ID (priority 2 trumps priority 3) — the
1494        //       Codex adapter slots between Claude Code and Copilot.
1495        //   (c) Unexpanded ${...} literal is rejected by the ${} guard,
1496        //       falling through rather than collapsing all sessions
1497        //       (mirrors the guard inherited from every other adapter).
1498        let _guard = crate::config::test_support::ENV_LOCK
1499            .lock()
1500            .unwrap_or_else(|p| p.into_inner());
1501
1502        // Snapshot every env var resolve_session_key consults so the test is
1503        // hermetic regardless of harness environment.
1504        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1505        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1506        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1507        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1508        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1509        // SAFETY: ENV_LOCK is held, serializing all env access.
1510        unsafe {
1511            std::env::remove_var("WIRE_SESSION_ID");
1512            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1513            std::env::remove_var("CODEX_SESSION_ID");
1514            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1515            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1516        }
1517
1518        // (a) CODEX_SESSION_ID set -> wins resolution over the no-id baseline;
1519        //     distinct thread ids map to distinct session homes.
1520        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
1521        let r1 = resolve_session_key();
1522        assert!(
1523            matches!(&r1, Some((k, src)) if k == "019e66ad-277e-7be3-bdd9-b7708e069f3b" && *src == "codex-cli"),
1524            "CODEX_SESSION_ID must win resolution and be labeled codex-cli; got {r1:?}"
1525        );
1526        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1527
1528        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66b6-14de-7142-b43a-1861fe59e945") };
1529        let r2 = resolve_session_key();
1530        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1531        assert_ne!(
1532            home_a, home_b,
1533            "distinct Codex thread ids must map to distinct session homes"
1534        );
1535
1536        // Same id again -> same home (resume stability — same thread reconnects
1537        // to the same persona).
1538        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
1539        let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1540        assert_eq!(
1541            home_a, home_a2,
1542            "same Codex thread id must yield the same home across calls"
1543        );
1544
1545        // (b) WIRE_SESSION_ID at priority 1 overrides CODEX_SESSION_ID at
1546        //     priority 3 (operator explicit override always wins).
1547        unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
1548        let r_override = resolve_session_key();
1549        assert!(
1550            matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
1551            "WIRE_SESSION_ID must beat CODEX_SESSION_ID; got {r_override:?}"
1552        );
1553        unsafe { std::env::remove_var("WIRE_SESSION_ID") };
1554
1555        // CLAUDE_CODE_SESSION_ID at priority 2 also beats CODEX_SESSION_ID at
1556        // priority 3. (Earlier adapters get to claim the host they were
1557        // designed for; Codex slots in after Claude Code.)
1558        unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-codex") };
1559        let r_claude_wins = resolve_session_key();
1560        assert!(
1561            matches!(&r_claude_wins, Some((k, src)) if k == "claude-wins-over-codex" && *src == "claude-code"),
1562            "CLAUDE_CODE_SESSION_ID must beat CODEX_SESSION_ID; got {r_claude_wins:?}"
1563        );
1564        unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") };
1565
1566        // (c) Unexpanded ${...} literal is rejected by the ${} guard.
1567        //     If a host's config-forwarding ever ships a literal placeholder,
1568        //     the guard rejects it (same as for every other adapter) so we
1569        //     never hash the literal and collapse sessions.
1570        unsafe { std::env::set_var("CODEX_SESSION_ID", "${SOME_PLACEHOLDER}") };
1571        let r_guard = resolve_session_key();
1572        assert!(
1573            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1574            "unexpanded ${{...}} in CODEX_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
1575        );
1576
1577        // Restore any env we displaced.
1578        // SAFETY: ENV_LOCK still held.
1579        unsafe {
1580            std::env::remove_var("WIRE_SESSION_ID");
1581            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1582            std::env::remove_var("CODEX_SESSION_ID");
1583            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1584            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1585            if let Some(v) = prev_override {
1586                std::env::set_var("WIRE_SESSION_ID", v);
1587            }
1588            if let Some(v) = prev_claude {
1589                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1590            }
1591            if let Some(v) = prev_codex {
1592                std::env::set_var("CODEX_SESSION_ID", v);
1593            }
1594            if let Some(v) = prev_copilot {
1595                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1596            }
1597            if let Some(v) = prev_vscode {
1598                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1599            }
1600        }
1601    }
1602
1603    #[test]
1604    fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
1605        // Regression (v0.13.2): v0.13 moved session homes under
1606        // `sessions/by-key/<hash>`, but (1) list_sessions only scanned the
1607        // top level so by-key homes were invisible, and (2) sessions_root()'s
1608        // inside-session fallback only walked ONE level up (expecting parent
1609        // `sessions`), so an inside-session WIRE_HOME resolved to a bogus
1610        // nested dir. Together they made same-box discovery (list-local /
1611        // pair-all-local) return zero sisters under v0.13.
1612        let _guard = crate::config::test_support::ENV_LOCK
1613            .lock()
1614            .unwrap_or_else(|p| p.into_inner());
1615        let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
1616        let _ = std::fs::remove_dir_all(&tmp);
1617        let root = tmp.join("sessions");
1618        let home = root.join("by-key").join("abc123def4567890");
1619        let cfg = home.join("config").join("wire");
1620        std::fs::create_dir_all(&cfg).unwrap();
1621        std::fs::write(
1622            cfg.join("agent-card.json"),
1623            r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
1624        )
1625        .unwrap();
1626
1627        // (1) sessions_root() must find the real root even when WIRE_HOME
1628        //     points INSIDE the by-key home.
1629        // SAFETY: ENV_LOCK is held, serializing all env access.
1630        unsafe { std::env::set_var("WIRE_HOME", &home) };
1631        assert_eq!(
1632            sessions_root().unwrap(),
1633            root,
1634            "sessions_root must resolve the root from inside a by-key home"
1635        );
1636
1637        // (2) list_sessions() must enumerate the by-key home, labeled by handle.
1638        let sessions = list_sessions().unwrap();
1639        let found = sessions
1640            .iter()
1641            .any(|s| s.handle.as_deref() == Some("test-persona"));
1642        unsafe { std::env::remove_var("WIRE_HOME") };
1643        let _ = std::fs::remove_dir_all(&tmp);
1644        assert!(
1645            found,
1646            "by-key home must be enumerated: {:?}",
1647            sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
1648        );
1649    }
1650
1651    #[test]
1652    fn find_session_home_by_name_resolves_both_layouts() {
1653        // #44 / #170 follow-up: v0.6 top-level sessions (dir name ==
1654        // operator-typed name) and v0.13 by-key sessions (dir name is
1655        // a hash, operator types the persona handle from the card)
1656        // must BOTH resolve via `find_session_home_by_name`. Pre-fix
1657        // (`session_dir(name)` only) the v0.13 by-key case bailed
1658        // with "session not found" even though `wire session list`
1659        // showed it.
1660        let _guard = crate::config::test_support::ENV_LOCK
1661            .lock()
1662            .unwrap_or_else(|p| p.into_inner());
1663        let tmp = std::env::temp_dir().join(format!("wire-find-{}", rand::random::<u32>()));
1664        let _ = std::fs::remove_dir_all(&tmp);
1665        let root = tmp.join("sessions");
1666
1667        // Legacy v0.6 top-level: a dir named `legacy-pane` directly
1668        // under sessions_root.
1669        let legacy_home = root.join("legacy-pane");
1670        let legacy_cfg = legacy_home.join("config").join("wire");
1671        std::fs::create_dir_all(&legacy_cfg).unwrap();
1672        std::fs::write(
1673            legacy_cfg.join("agent-card.json"),
1674            r#"{"did":"did:wire:legacy-pane-aaaa1111","handle":"legacy-pane","verify_keys":{}}"#,
1675        )
1676        .unwrap();
1677
1678        // v0.13 by-key: dir name is a hash, card's handle is `coral-weasel`.
1679        let bykey_home = root.join("by-key").join("3049827d92d4fbd5");
1680        let bykey_cfg = bykey_home.join("config").join("wire");
1681        std::fs::create_dir_all(&bykey_cfg).unwrap();
1682        std::fs::write(
1683            bykey_cfg.join("agent-card.json"),
1684            r#"{"did":"did:wire:coral-weasel-0616dc6c","handle":"coral-weasel","verify_keys":{}}"#,
1685        )
1686        .unwrap();
1687
1688        // SAFETY: ENV_LOCK is held.
1689        unsafe { std::env::set_var("WIRE_HOME", &root) };
1690
1691        // Legacy lookup: operator types the literal dir name.
1692        let legacy = super::find_session_home_by_name("legacy-pane").unwrap();
1693        assert_eq!(
1694            legacy.as_deref(),
1695            Some(legacy_home.as_path()),
1696            "v0.6 top-level layout: legacy-pane must resolve to its top-level dir"
1697        );
1698
1699        // by-key lookup: operator types the persona handle, not the hash.
1700        let bykey = super::find_session_home_by_name("coral-weasel").unwrap();
1701        assert_eq!(
1702            bykey.as_deref(),
1703            Some(bykey_home.as_path()),
1704            "v0.13 by-key layout: coral-weasel must resolve to its by-key/<hash> dir"
1705        );
1706
1707        // by-key lookup via the hash itself also works (some tooling
1708        // may pass the raw dir name).
1709        let by_hash = super::find_session_home_by_name("3049827d92d4fbd5").unwrap();
1710        assert_eq!(
1711            by_hash.as_deref(),
1712            Some(bykey_home.as_path()),
1713            "v0.13 by-key layout: hash dir name must also resolve"
1714        );
1715
1716        // Negative: an unknown name returns None, not an error.
1717        let missing = super::find_session_home_by_name("never-existed").unwrap();
1718        assert_eq!(missing, None, "unknown session must return None");
1719
1720        unsafe { std::env::remove_var("WIRE_HOME") };
1721        let _ = std::fs::remove_dir_all(&tmp);
1722    }
1723
1724    #[test]
1725    fn pid_to_session_map_builds_from_session_pidfiles() {
1726        // #173 follow-up (#174 hotfix removed --session arg from
1727        // supervisor children): wire status orphan annotation now
1728        // maps pid → session via per-session pidfiles. Walk should
1729        // find each session whose `<home>/state/wire/daemon.pid`
1730        // contains a valid pid, and IGNORE sessions whose pidfile
1731        // is absent or unreadable.
1732        let _guard = crate::config::test_support::ENV_LOCK
1733            .lock()
1734            .unwrap_or_else(|p| p.into_inner());
1735        let tmp = std::env::temp_dir().join(format!("wire-p2s-{}", rand::random::<u32>()));
1736        let _ = std::fs::remove_dir_all(&tmp);
1737        let root = tmp.join("sessions");
1738        // Three by-key sessions. Two have pidfiles, one doesn't.
1739        let mk_session = |key: &str, handle: &str| -> PathBuf {
1740            let home = root.join("by-key").join(key);
1741            let cfg = home.join("config").join("wire");
1742            std::fs::create_dir_all(&cfg).unwrap();
1743            std::fs::write(
1744                cfg.join("agent-card.json"),
1745                format!(
1746                    r#"{{"did":"did:wire:{handle}-6e301ab1","handle":"{handle}","verify_keys":{{}}}}"#
1747                ),
1748            )
1749            .unwrap();
1750            home
1751        };
1752        let h1 = mk_session("abc123def4567890", "alpha-aurora");
1753        let h2 = mk_session("def456abc7890123", "beta-blossom");
1754        let _h3 = mk_session("0000aaaabbbbcccc", "gamma-gorge");
1755        // h1 / h2 get JSON pidfiles; h3 gets none.
1756        let state1 = h1.join("state").join("wire");
1757        let state2 = h2.join("state").join("wire");
1758        std::fs::create_dir_all(&state1).unwrap();
1759        std::fs::create_dir_all(&state2).unwrap();
1760        std::fs::write(state1.join("daemon.pid"), r#"{"pid": 12345}"#).unwrap();
1761        std::fs::write(state2.join("daemon.pid"), r#"{"pid": 67890}"#).unwrap();
1762
1763        // SAFETY: ENV_LOCK is held, serializing all env access.
1764        unsafe { std::env::set_var("WIRE_HOME", &h1) };
1765        let map = super::pid_to_session_map();
1766        unsafe { std::env::remove_var("WIRE_HOME") };
1767        let _ = std::fs::remove_dir_all(&tmp);
1768
1769        // h1 / h2 present, h3 absent. SessionInfo.name is the handle
1770        // derived from the card when the home is initialized
1771        // (list_sessions's mk helper overrides name = handle in that
1772        // case; by-key hash is only the fallback for uninitialized
1773        // homes). That's exactly the production label `wire status`
1774        // already prints for sessions.
1775        assert_eq!(
1776            map.get(&12345).map(String::as_str),
1777            Some("alpha-aurora"),
1778            "pid 12345 should map to the handle for h1"
1779        );
1780        assert_eq!(
1781            map.get(&67890).map(String::as_str),
1782            Some("beta-blossom"),
1783            "pid 67890 should map (JSON pidfile form, handle for h2)"
1784        );
1785        // Sanity: no entry for an unrelated pid.
1786        assert!(
1787            !map.contains_key(&99999),
1788            "synthetic missing pid should not appear in the map"
1789        );
1790    }
1791
1792    #[test]
1793    fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
1794        // session_home_for_key reads WIRE_HOME (via sessions_root); hold the
1795        // shared env lock so a parallel env-mutating test can't change it
1796        // between calls and make a1 != a2 (flaky race).
1797        let _guard = crate::config::test_support::ENV_LOCK
1798            .lock()
1799            .unwrap_or_else(|p| p.into_inner());
1800        let a1 = session_home_for_key("sess-aaa").unwrap();
1801        let a2 = session_home_for_key("sess-aaa").unwrap();
1802        let b = session_home_for_key("sess-bbb").unwrap();
1803        assert_eq!(a1, a2, "same key -> same home (resume stability)");
1804        assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
1805        let leaf = a1.file_name().unwrap().to_str().unwrap();
1806        assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
1807        assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
1808        assert_eq!(
1809            a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
1810            "by-key"
1811        );
1812    }
1813
1814    #[test]
1815    fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
1816        assert!(url_is_loopback("http://127.0.0.1:8771"));
1817        assert!(url_is_loopback("http://127.1.2.3"));
1818        assert!(url_is_loopback("http://localhost:9000"));
1819        assert!(url_is_loopback("https://localhost/v1"));
1820        assert!(url_is_loopback("http://[::1]:8771"));
1821        // Case-insensitive.
1822        assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
1823        // Non-loopback negatives — must NOT be flagged.
1824        assert!(!url_is_loopback("https://wireup.net"));
1825        assert!(!url_is_loopback("http://192.168.1.50:8771"));
1826        assert!(!url_is_loopback("http://10.0.0.5"));
1827        assert!(!url_is_loopback("https://relay.example.com"));
1828    }
1829
1830    #[test]
1831    fn sanitize_handles_unicode_and_long_names() {
1832        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
1833        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
1834        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
1835        assert_eq!(sanitize_name(""), "wire-session");
1836        assert_eq!(sanitize_name("---"), "wire-session");
1837        let long: String = "a".repeat(100);
1838        assert_eq!(sanitize_name(&long).len(), 32);
1839    }
1840
1841    #[test]
1842    fn derive_name_returns_basename_when_no_collision() {
1843        let reg = SessionRegistry::default();
1844        assert_eq!(
1845            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
1846            "wire"
1847        );
1848        assert_eq!(
1849            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
1850            "slancha-mesh"
1851        );
1852    }
1853
1854    #[test]
1855    fn derive_name_returns_stored_name_when_cwd_already_registered() {
1856        let mut reg = SessionRegistry::default();
1857        reg.by_cwd.insert(
1858            "/Users/paul/Source/wire".to_string(),
1859            "wire-special".to_string(),
1860        );
1861        assert_eq!(
1862            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
1863            "wire-special"
1864        );
1865    }
1866
1867    #[test]
1868    fn normalize_cwd_key_case_handling_matches_platform_filesystem() {
1869        // Issue #30 Willard repro: on Windows, two terminals in the "same"
1870        // project under different casings of the same path
1871        // (`C:\Foo\Bar` vs `C:\foo\bar`) hashed to DIFFERENT registry keys
1872        // pre-fix → the second terminal missed the registry lookup, fell
1873        // back to the legacy default identity, and both terminals collapsed
1874        // onto a shared DID. Fix: normalize the cwd key case-insensitively
1875        // on Windows, case-sensitively elsewhere (so distinct-on-disk paths
1876        // on case-sensitive filesystems remain distinct).
1877        let upper = Path::new("/Users/paul/Source/WIRE");
1878        let lower = Path::new("/Users/paul/Source/wire");
1879        if cfg!(windows) {
1880            assert_eq!(
1881                normalize_cwd_key(upper),
1882                normalize_cwd_key(lower),
1883                "on Windows, distinct casings of the same path MUST normalize \
1884                 to the same key (NTFS is case-insensitive by default)"
1885            );
1886        } else {
1887            assert_ne!(
1888                normalize_cwd_key(upper),
1889                normalize_cwd_key(lower),
1890                "on case-sensitive filesystems, distinct casings ARE distinct \
1891                 directories and MUST stay distinct keys"
1892            );
1893        }
1894        // Trivial sanity: same input always produces same output.
1895        assert_eq!(normalize_cwd_key(lower), normalize_cwd_key(lower));
1896    }
1897
1898    #[test]
1899    fn derive_name_no_regression_exact_match_still_resolves() {
1900        // Cross-platform no-regression check for the v0.13.6 lookup
1901        // changes: an exact-match (same casing stored AND looked up)
1902        // entry MUST continue to resolve on the fast path — the new
1903        // O(n) normalized-scan fallback is only reached on the initial
1904        // .get miss.
1905        //
1906        // Honest scope (per coral-weasel's #67 review): this test does
1907        // NOT exercise the case-folding fallback on Linux/macOS — the
1908        // normalizer is a no-op there, so the first `.get` hits and
1909        // the scan never runs. The case-folding behavior is inherently
1910        // Windows-only; that path is covered by
1911        // derive_name_finds_registered_cwd_under_alternate_casing_on_windows
1912        // which executes on Windows CI.
1913        let mut reg = SessionRegistry::default();
1914        let stored = "/Users/Paul/Source/Wire-v0_13_5-Era";
1915        reg.by_cwd
1916            .insert(stored.to_string(), "wire-legacy".to_string());
1917
1918        // Lookup under the EXACT stored path: must resolve on the
1919        // fast `.get` path regardless of platform.
1920        assert_eq!(
1921            derive_name_from_cwd(Path::new(stored), &reg),
1922            "wire-legacy",
1923            "exact-match v0.13.5 entry MUST still resolve under v0.13.6+"
1924        );
1925    }
1926
1927    #[test]
1928    fn derive_name_scan_fallback_runs_when_initial_get_misses() {
1929        // Cross-platform proof that the O(n) normalized-scan fallback
1930        // engages on a .get miss. We can't trigger the *case-folding*
1931        // case on Linux/macOS (normalizer is a no-op), but we CAN
1932        // exercise the scan branch by storing under a key the
1933        // normalized lookup definitely won't hit, and verifying that
1934        // the .find()-based fallback resolves it.
1935        //
1936        // Setup: store under a key that's identical to the lookup
1937        // BUT with a trailing slash difference (so `.get` exact-match
1938        // misses, but our normalize_cwd_key — which preserves the
1939        // trailing slash — also misses; then we rely on the .find()
1940        // iterator). This is a contrived setup that proves the scan
1941        // branch is reachable; it does NOT test case-folding (Windows
1942        // only).
1943        //
1944        // A simpler way to exercise the same logic: store under one
1945        // path, look up under a different path that normalizes to the
1946        // SAME key. Without case-folding, the only way to do that is
1947        // to mutate normalize_cwd_key. Since we can't do that in a
1948        // test, this test instead pins the *no-false-positive* side:
1949        // a path with no matching stored entry must NOT resolve.
1950        let mut reg = SessionRegistry::default();
1951        reg.by_cwd.insert(
1952            "/Users/paul/Source/project-a".to_string(),
1953            "project-a".to_string(),
1954        );
1955
1956        // Distinct path → no match → falls through to basename
1957        // derivation. Proves the scan doesn't fabricate matches.
1958        let derived = derive_name_from_cwd(Path::new("/Users/paul/Source/project-b"), &reg);
1959        assert_eq!(
1960            derived, "project-b",
1961            "non-matching lookup must fall through to basename derivation, \
1962             NOT fabricate a match via the scan"
1963        );
1964    }
1965
1966    #[cfg(windows)]
1967    #[test]
1968    fn derive_name_finds_registered_cwd_under_alternate_casing_on_windows() {
1969        // Direct integration check for the Willard repro on Windows: an
1970        // existing registry entry written under one casing MUST resolve
1971        // when the lookup arrives under a different casing of the same
1972        // path.
1973        //
1974        // Trace through the v0.13.6 read-side O(n) normalized scan:
1975        //   - Stored key: "C:\Users\Willard\ComfyUI\claude-integration"
1976        //   - Lookup cwd: "c:\users\willard\comfyui\claude-integration"
1977        //   - cwd_key  = normalize(lookup) = "c:\users\..." (already lower)
1978        //   - .get(&cwd_key)  → MISS (stored has mixed casing)
1979        //   - .iter().find(normalize(stored) == cwd_key) → HIT
1980        //     (normalize("C:\Users\...") == "c:\users\..." == cwd_key)
1981        //   - Returns "claude-integration" ← the fix.
1982        //
1983        // Pre-fix this returned the basename → phantom hash-suffix → identity
1984        // collision (the original Willard report).
1985        let mut reg = SessionRegistry::default();
1986        reg.by_cwd.insert(
1987            r"C:\Users\Willard\ComfyUI\claude-integration".to_string(),
1988            "claude-integration".to_string(),
1989        );
1990        let from_lower_cwd = Path::new(r"c:\users\willard\comfyui\claude-integration");
1991        assert_eq!(
1992            derive_name_from_cwd(from_lower_cwd, &reg),
1993            "claude-integration",
1994            "Windows lookup MUST find the registered entry regardless of \
1995             how the shell capitalized the cwd, via the normalized scan"
1996        );
1997    }
1998
1999    #[test]
2000    fn read_session_endpoints_handles_missing_relay_state() {
2001        let tmp = tempfile::tempdir().unwrap();
2002        // No relay.json under <home>/config/wire/ — should yield empty.
2003        let endpoints = read_session_endpoints(tmp.path());
2004        assert!(endpoints.is_empty());
2005    }
2006
2007    #[test]
2008    fn read_session_endpoints_parses_dual_slot_form() {
2009        let tmp = tempfile::tempdir().unwrap();
2010        let cfg = tmp.path().join("config").join("wire");
2011        std::fs::create_dir_all(&cfg).unwrap();
2012        let body = serde_json::json!({
2013            "self": {
2014                "relay_url": "https://wireup.net",
2015                "slot_id": "fed-slot",
2016                "slot_token": "fed-tok",
2017                "endpoints": [
2018                    {
2019                        "relay_url": "https://wireup.net",
2020                        "slot_id": "fed-slot",
2021                        "slot_token": "fed-tok",
2022                        "scope": "federation"
2023                    },
2024                    {
2025                        "relay_url": "http://127.0.0.1:8771",
2026                        "slot_id": "loop-slot",
2027                        "slot_token": "loop-tok",
2028                        "scope": "local"
2029                    }
2030                ]
2031            }
2032        });
2033        std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
2034        let endpoints = read_session_endpoints(tmp.path());
2035        assert_eq!(endpoints.len(), 2);
2036        let local_count = endpoints
2037            .iter()
2038            .filter(|e| matches!(e.scope, EndpointScope::Local))
2039            .count();
2040        assert_eq!(local_count, 1);
2041        let local = endpoints
2042            .iter()
2043            .find(|e| matches!(e.scope, EndpointScope::Local))
2044            .unwrap();
2045        assert_eq!(local.relay_url, "http://127.0.0.1:8771");
2046        assert_eq!(local.slot_id, "loop-slot");
2047    }
2048
2049    // NOTE: list_local_sessions is integration-tested via tests/cli.rs
2050    // using a subprocess that sets WIRE_HOME per-process. We do not test
2051    // it in-module because env mutation races other parallel unit tests
2052    // (Rust 2024 marks std::env::set_var unsafe for that reason). The
2053    // grouping logic is straightforward enough that the integration
2054    // test plus the read_session_endpoints unit tests above provide
2055    // adequate coverage.
2056
2057    #[test]
2058    fn derive_name_appends_path_hash_when_basename_collides() {
2059        let mut reg = SessionRegistry::default();
2060        reg.by_cwd
2061            .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
2062        // Different cwd, same basename → must get a hash suffix.
2063        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
2064        assert!(name.starts_with("wire-"));
2065        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
2066        assert_ne!(name, "wire");
2067    }
2068
2069    // ---------- identity-collision warning (issue #29/#30 — broaden to
2070    // every inbox-cursor-owning subcommand, not just `wire mcp`). ----------
2071
2072    #[test]
2073    fn inbox_owning_subcommands_covers_each_runtime_role() {
2074        // Lock the role list down — any addition / removal here must
2075        // come with an updated call site (cli::cmd_daemon, cmd_monitor,
2076        // cmd_notify, mcp::run) and an updated rendezvous in the pgrep
2077        // predicate. The pgrep predicate is built from this list at
2078        // call time, so adding "watch" here automatically extends
2079        // detection — but the warning is only fired if a call site
2080        // also invokes warn_on_identity_collision with that role.
2081        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"mcp"));
2082        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"daemon"));
2083        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"monitor"));
2084        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"notify"));
2085        // pair-host (SAS code-phrase flow) was removed in RFC-005 follow-on
2086        // and must not appear in the list.
2087        assert!(!INBOX_OWNING_SUBCOMMANDS.contains(&"pair-host"));
2088    }
2089
2090    #[test]
2091    fn find_colliders_returns_only_same_home_pids() {
2092        let our_home = "/tmp/wire-home-A";
2093        let others = vec![
2094            (101, Some("/tmp/wire-home-A".to_string())), // collide
2095            (102, Some("/tmp/wire-home-B".to_string())), // distinct home
2096            (103, None),                                 // env-unreadable, skip
2097            (104, Some("/tmp/wire-home-A".to_string())), // collide
2098        ];
2099        let colliders = find_colliders(our_home, &others);
2100        assert_eq!(colliders, vec![101, 104]);
2101    }
2102
2103    #[test]
2104    fn find_colliders_no_match_returns_empty() {
2105        let our_home = "/tmp/wire-home-A";
2106        let others = vec![
2107            (101, Some("/tmp/wire-home-B".to_string())),
2108            (102, Some("/tmp/wire-home-C".to_string())),
2109            (103, None),
2110        ];
2111        assert!(find_colliders(our_home, &others).is_empty());
2112    }
2113
2114    #[test]
2115    fn find_colliders_empty_input_is_empty() {
2116        assert!(find_colliders("/tmp/anywhere", &[]).is_empty());
2117    }
2118
2119    #[test]
2120    fn find_colliders_ignores_substring_matches() {
2121        // `WIRE_HOME=/wire-A` must NOT collide with `WIRE_HOME=/wire-A/sub`.
2122        // Exact-match semantics protect against parent/child confusion.
2123        let our_home = "/tmp/wire-A";
2124        let others = vec![
2125            (201, Some("/tmp/wire-A/sub".to_string())),
2126            (202, Some("/wire-A".to_string())), // distinct path
2127            (203, Some("/tmp/wire-A".to_string())), // real collision
2128        ];
2129        assert_eq!(find_colliders(our_home, &others), vec![203]);
2130    }
2131
2132    #[test]
2133    fn collision_warning_format_includes_role_home_and_pids() {
2134        // Sanity-check the first warning line by reconstructing it
2135        // exactly the way `emit_collision_warning` does. If anyone
2136        // changes the format, this test must change with it — that's
2137        // the point: the format is a documented operator-facing
2138        // surface (Willard's #30 cited the older wording verbatim
2139        // when filing the bug).
2140        let role = "daemon";
2141        let home = "/tmp/by-key/abc123";
2142        let colliders = vec![4242u32, 4243u32];
2143        let expected_head = format!(
2144            "wire {role}: WARNING — {n} other wire process(es) already using WIRE_HOME=`{home}` (pid {pids})",
2145            n = colliders.len(),
2146            pids = colliders
2147                .iter()
2148                .map(u32::to_string)
2149                .collect::<Vec<_>>()
2150                .join(", "),
2151        );
2152        assert_eq!(
2153            expected_head,
2154            "wire daemon: WARNING — 2 other wire process(es) already using WIRE_HOME=`/tmp/by-key/abc123` (pid 4242, 4243)"
2155        );
2156        // Exercise the renderer so it can't bit-rot via dead-code
2157        // pruning. Output goes to stderr; under libtest it's captured.
2158        emit_collision_warning(role, home, &colliders);
2159    }
2160}