Skip to main content

wire/
session.rs

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