Skip to main content

wire/
session.rs

1//! Multi-session wire on one machine (v0.5.16).
2//!
3//! Problem: multiple Claude Code (or any agent harness) sessions on the
4//! same machine share a single `WIRE_HOME`, which means they share the
5//! same DID, same relay slot, same inbox JSONL, and same daemon. Peers
6//! have no way to address a specific session, and the operator can't
7//! tell which session sent what.
8//!
9//! Solution: a `wire session` subcommand that bootstraps **isolated**
10//! per-session `WIRE_HOME` trees. Each session gets its own identity,
11//! handle, relay slot, daemon, and inbox/outbox. Sessions pair with each
12//! other through the public relay (`wireup.net`) like any other peer —
13//! no protocol changes. The bilateral-pair gate from v0.5.14 still
14//! applies in both directions.
15//!
16//! Storage layout:
17//!
18//! ```text
19//! ~/.local/state/wire/sessions/
20//!   registry.json                — cwd → session_name map
21//!   <session-name>/               — full WIRE_HOME tree per session
22//!     config/wire/...
23//!     state/wire/...
24//! ```
25//!
26//! Naming: derived from `basename(cwd)` so re-opening the same project
27//! reuses the same session identity. Collisions across two different
28//! paths with the same basename get a 4-char SHA-256 path-hash suffix.
29
30use anyhow::{Context, Result, anyhow};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use sha2::{Digest, Sha256};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36
37use crate::endpoints::{Endpoint, EndpointScope, self_endpoints};
38
39/// Root directory under which all session WIRE_HOMEs live.
40///
41/// Honors `WIRE_HOME` for testing (sessions root becomes
42/// `$WIRE_HOME/sessions/`); otherwise:
43///   - Linux: `$XDG_STATE_HOME/wire/sessions/` (typically
44///     `~/.local/state/wire/sessions/`).
45///   - macOS / other Unix without XDG: falls back to
46///     `dirs::data_local_dir() / wire / sessions /`, which on macOS is
47///     `~/Library/Application Support/wire/sessions/`. This mirrors
48///     `config::state_dir`'s fallback so the two surfaces resolve to
49///     compatible roots on every platform.
50pub fn sessions_root() -> Result<PathBuf> {
51    if let Ok(home_str) = std::env::var("WIRE_HOME") {
52        let home = PathBuf::from(&home_str);
53        let direct = home.join("sessions");
54        if direct.exists() {
55            return Ok(direct);
56        }
57        // v0.6.4: inside-session fallback. When WIRE_HOME is set by the
58        // MCP auto-detect or `wire session env`, it points at one
59        // session's home (`<root>/sessions/<name>`) — *not* the root
60        // holding every session. Without this fallback, `wire mesh
61        // status` / `mesh role list` / `mesh broadcast` invoked from
62        // inside a session see zero sister sessions even though the
63        // operator can plainly see them with `wire session list`.
64        //
65        // Walk up to the nearest ancestor named `sessions` and return it.
66        // Handles BOTH the legacy `sessions/<name>` layout (parent named
67        // `sessions`) and the v0.13 `sessions/by-key/<hash>` layout (parent
68        // `by-key`, grandparent `sessions`). The old one-level parent check
69        // matched only the legacy layout, so an inside-session WIRE_HOME on
70        // v0.13 made sessions_root() point at a nonexistent nested dir —
71        // list-local / mesh / pair-all-local then saw zero sisters even
72        // though they were on disk. A WIRE_HOME with no `sessions` ancestor
73        // (plain test dir, custom location) falls through to the v0.6.3
74        // `<WIRE_HOME>/sessions/` behavior.
75        let mut anc = Some(home.as_path());
76        while let Some(p) = anc {
77            if p.file_name().and_then(|s| s.to_str()) == Some("sessions") {
78                return Ok(p.to_path_buf());
79            }
80            anc = p.parent();
81        }
82        return Ok(direct);
83    }
84    let state = dirs::state_dir()
85        .or_else(dirs::data_local_dir)
86        .ok_or_else(|| {
87            anyhow!(
88                "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
89                 set WIRE_HOME or run on a platform with `dirs` support"
90            )
91        })?;
92    Ok(state.join("wire").join("sessions"))
93}
94
95/// Full filesystem path for the named session's WIRE_HOME root.
96/// Inside this dir the standard wire layout applies: `config/wire/...`
97/// and `state/wire/...`.
98pub fn session_dir(name: &str) -> Result<PathBuf> {
99    Ok(sessions_root()?.join(sanitize_name(name)))
100}
101
102/// Registry tracks `cwd → session_name` so repeated `wire session new`
103/// from the same project reuses the same identity instead of creating
104/// a fresh one each time. Lives at `<sessions_root>/registry.json`.
105pub fn registry_path() -> Result<PathBuf> {
106    Ok(sessions_root()?.join("registry.json"))
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct SessionRegistry {
111    /// `cwd_absolute_path → session_name`. Absent if cwd has not been
112    /// associated with a session yet.
113    #[serde(default)]
114    pub by_cwd: HashMap<String, String>,
115}
116
117pub fn read_registry() -> Result<SessionRegistry> {
118    let path = registry_path()?;
119    if !path.exists() {
120        return Ok(SessionRegistry::default());
121    }
122    let bytes =
123        std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
124    serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
125}
126
127pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
128    let path = registry_path()?;
129    if let Some(parent) = path.parent() {
130        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
131    }
132    let body = serde_json::to_vec_pretty(reg)?;
133    // v0.7.0-alpha.8 (review-fix #7): atomic write via tmp+rename so
134    // concurrent unflocked readers (detect_session_wire_home,
135    // list_sessions, cmd_peers) never observe a 0-byte / truncated
136    // registry mid-write. Pre-alpha.8 used std::fs::write which
137    // truncates first — race window where readers saw empty JSON and
138    // fell back to default identity for the write duration.
139    let tmp = path.with_extension("json.tmp");
140    std::fs::write(&tmp, body).with_context(|| format!("writing tmp session registry {tmp:?}"))?;
141    std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
142    Ok(())
143}
144
145/// v0.7.0-alpha.3: flock'd read-modify-write of the session registry.
146///
147/// `write_registry` alone is not safe under concurrency — multiple MCP
148/// processes auto-initing in parallel each read an old snapshot, mutate
149/// their copy, and write back, losing N-1 updates. This helper acquires
150/// an exclusive flock on a sibling lockfile, re-reads inside the lock,
151/// applies the caller's modifier, writes atomically, and releases.
152///
153/// Modeled on `config::update_relay_state`. Lock contention is bounded:
154/// modifications are pure HashMap operations, write is whole-file at
155/// roughly the registry size (KBs, not MBs).
156pub fn update_registry<F>(modifier: F) -> Result<()>
157where
158    F: FnOnce(&mut SessionRegistry) -> Result<()>,
159{
160    use fs2::FileExt;
161    let path = registry_path()?;
162    if let Some(parent) = path.parent() {
163        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
164    }
165    let lock_path = path.with_extension("lock");
166    let lock_file = std::fs::OpenOptions::new()
167        .create(true)
168        .truncate(false)
169        .read(true)
170        .write(true)
171        .open(&lock_path)
172        .with_context(|| format!("opening {lock_path:?}"))?;
173    lock_file
174        .lock_exclusive()
175        .with_context(|| format!("flock {lock_path:?}"))?;
176    // Re-read INSIDE the lock — any prior snapshot would race.
177    let mut reg = read_registry().unwrap_or_default();
178    let result = modifier(&mut reg);
179    let write_result = if result.is_ok() {
180        write_registry(&reg)
181    } else {
182        Ok(())
183    };
184    let _ = fs2::FileExt::unlock(&lock_file);
185    result?;
186    write_result?;
187    Ok(())
188}
189
190/// Sanitize an arbitrary string to a session-name-safe form: lowercase
191/// ASCII alphanumeric + `-` + `_`, replace other chars with `-`,
192/// dedupe consecutive dashes, trim leading/trailing dashes, max 32 chars.
193pub fn sanitize_name(raw: &str) -> String {
194    let mut out = String::with_capacity(raw.len());
195    let mut prev_dash = false;
196    for c in raw.chars() {
197        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
198        let ch = if ok { c.to_ascii_lowercase() } else { '-' };
199        if ch == '-' {
200            if !prev_dash && !out.is_empty() {
201                out.push('-');
202            }
203            prev_dash = true;
204        } else {
205            out.push(ch);
206            prev_dash = false;
207        }
208    }
209    let trimmed = out.trim_matches('-').to_string();
210    if trimmed.is_empty() {
211        return "wire-session".to_string();
212    }
213    if trimmed.len() > 32 {
214        return trimmed[..32].trim_end_matches('-').to_string();
215    }
216    trimmed
217}
218
219/// Short hash suffix derived from the full absolute path of the cwd.
220/// Used to disambiguate two different projects whose basenames collide
221/// (e.g. `~/Source/wire` and `~/Archive/wire`).
222fn path_hash_suffix(cwd: &Path) -> String {
223    let bytes = cwd.as_os_str().to_string_lossy().into_owned();
224    let mut h = Sha256::new();
225    h.update(bytes.as_bytes());
226    let digest = h.finalize();
227    hex::encode(&digest[..2]) // 4 hex chars
228}
229
230/// Derive a stable session name for the given cwd. Resolution order:
231///
232/// 1. If the registry already maps this cwd → name, return that name.
233/// 2. Else: candidate = sanitize(basename(cwd)). If the candidate is
234///    already mapped to a DIFFERENT cwd in the registry, append a
235///    4-char path-hash suffix to avoid collision.
236/// 3. If still a collision: append a numeric suffix `-2`, `-3`, ...
237///    until unique.
238pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
239    let cwd_key = cwd.to_string_lossy().into_owned();
240    if let Some(existing) = registry.by_cwd.get(&cwd_key) {
241        return existing.clone();
242    }
243    let base = cwd
244        .file_name()
245        .and_then(|s| s.to_str())
246        .map(sanitize_name)
247        .unwrap_or_else(|| "wire-session".to_string());
248    let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
249    if !occupied.contains(&base) {
250        return base;
251    }
252    let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
253    if !occupied.contains(&with_hash) {
254        return with_hash;
255    }
256    // Highly unlikely (would require a SHA-256 prefix collision plus an
257    // existing entry to claim it). Numeric tiebreaker as final fallback.
258    for n in 2..1000 {
259        let candidate = format!("{base}-{n}");
260        if !occupied.contains(&candidate) {
261            return candidate;
262        }
263    }
264    // Pathological fallback — every numbered slot is taken.
265    format!("{base}-{}-overflow", path_hash_suffix(cwd))
266}
267
268/// Summary of one on-disk session for `wire session list`.
269#[derive(Debug, Clone, Serialize)]
270pub struct SessionInfo {
271    pub name: String,
272    /// First cwd associated with this session in the registry. `None`
273    /// if the session was created without registry tracking (manual
274    /// `wire session new <name>`).
275    pub cwd: Option<String>,
276    pub home_dir: PathBuf,
277    pub did: Option<String>,
278    pub handle: Option<String>,
279    /// True if a `daemon.pid` file exists AND the recorded PID is
280    /// actually a live process (best-effort, not POSIX-portable but
281    /// matches the existing `wire status` / `wire doctor` checks).
282    pub daemon_running: bool,
283    /// Display character (nickname + emoji + color palette) derived from
284    /// the session's DID. `None` when the session has no agent-card yet
285    /// (pre-init). Lazy-computed at read time; never persisted to disk.
286    pub character: Option<crate::character::Character>,
287}
288
289/// Enumerate every on-disk session by reading `sessions_root()`. Cross-
290/// references the registry so each entry's `cwd` is filled in when known.
291/// v0.7.4: true iff the URL targets a loopback host (127.0.0.0/8 or
292/// [::1] or `localhost`). Used to detect "this Federation-scope slot
293/// is actually on a loopback relay" — those sessions are local-mesh
294/// candidates even though they're not tagged `local`.
295///
296/// Best-effort string match; we don't need full URL parsing for this
297/// because the relay URL is wire-controlled and follows a predictable
298/// shape (`http://<host>[:<port>][/path]`).
299fn url_is_loopback(url: &str) -> bool {
300    let lower = url.to_ascii_lowercase();
301    let after_scheme = match lower.split_once("://") {
302        Some((_, rest)) => rest,
303        None => lower.as_str(),
304    };
305    // Bracketed IPv6 literal: `[::1]:8771` keeps brackets in host slice.
306    if let Some(rest) = after_scheme.strip_prefix('[') {
307        return rest
308            .split_once(']')
309            .map(|(host, _)| host == "::1")
310            .unwrap_or(false);
311    }
312    let host = after_scheme.split(['/', ':']).next().unwrap_or("");
313    host == "localhost" || host == "127.0.0.1" || host.starts_with("127.")
314}
315
316/// v0.7.4: resolve an operator-typed name to a local sister session.
317/// Input may be the session NAME (e.g. `slancha-api`), the card
318/// HANDLE (usually equal to the name), or the character NICKNAME
319/// (e.g. `noble-slate`). Returns the session NAME suitable for the
320/// `--local-sister` add path. Case-insensitive. None on no match.
321///
322/// Designed for `wire add <input>` ergonomics — the operator should
323/// be able to type whatever face wire put on the peer (statusline
324/// nickname, session list emoji+name) and have wire find it.
325pub fn resolve_local_sister(input: &str) -> Option<String> {
326    let needle = input.trim();
327    if needle.is_empty() {
328        return None;
329    }
330    let sessions = list_sessions().ok()?;
331    for s in &sessions {
332        if s.name.eq_ignore_ascii_case(needle) {
333            return Some(s.name.clone());
334        }
335        if let Some(h) = &s.handle
336            && h.eq_ignore_ascii_case(needle)
337        {
338            return Some(s.name.clone());
339        }
340        if let Some(ch) = &s.character
341            && ch.nickname.eq_ignore_ascii_case(needle)
342        {
343            return Some(s.name.clone());
344        }
345    }
346    None
347}
348
349pub fn list_sessions() -> Result<Vec<SessionInfo>> {
350    let root = sessions_root()?;
351    if !root.exists() {
352        return Ok(Vec::new());
353    }
354    let registry = read_registry().unwrap_or_default();
355    // Reverse lookup: name → cwd. Used to annotate each SessionInfo.
356    let mut name_to_cwd: HashMap<String, String> = HashMap::new();
357    for (cwd, name) in &registry.by_cwd {
358        name_to_cwd.insert(name.clone(), cwd.clone());
359    }
360
361    // Build a SessionInfo from a home dir, labeled `name`. v0.11: character
362    // is purely DID-derived (local display.json overrides removed).
363    let mk = |path: PathBuf, name: String| -> SessionInfo {
364        let card_path = path.join("config").join("wire").join("agent-card.json");
365        let (did, handle) = read_card_identity(&card_path);
366        let daemon_running = check_daemon_live(&path);
367        let character = did.as_deref().map(crate::character::Character::from_did);
368        SessionInfo {
369            cwd: name_to_cwd.get(&name).cloned(),
370            name,
371            home_dir: path,
372            did,
373            handle,
374            daemon_running,
375            character,
376        }
377    };
378
379    let mut out = Vec::new();
380    for entry in std::fs::read_dir(&root)?.flatten() {
381        let path = entry.path();
382        if !path.is_dir() {
383            continue;
384        }
385        let name = match path.file_name().and_then(|s| s.to_str()) {
386            Some(s) => s.to_string(),
387            None => continue,
388        };
389        // Skip the registry sidecar.
390        if name == "registry.json" {
391            continue;
392        }
393        // v0.13: session homes live under `by-key/<hash>`, not at the top
394        // level. Descend one level so same-box discovery (`list-local` /
395        // `pair-all-local`) sees them — the `by-key` dir itself is a
396        // container, not a session. Without this, EVERY v0.13 session was
397        // invisible to the local mesh, silently forcing same-box sisters
398        // onto federation instead of fast loopback routing.
399        if name == "by-key" {
400            for sub in std::fs::read_dir(&path)?.flatten() {
401                let sub_path = sub.path();
402                if !sub_path.is_dir() {
403                    continue;
404                }
405                let hash = sub_path
406                    .file_name()
407                    .and_then(|s| s.to_str())
408                    .unwrap_or("?")
409                    .to_string();
410                let mut info = mk(sub_path, hash);
411                // E8 (v0.13.2): skip uninitialized by-key homes. maybe_adopt_
412                // session_wire_home creates the home dir on first resolution —
413                // before any identity exists — so transient/probe session keys
414                // that never `wire up` leave empty or agent-card-less homes.
415                // Without this filter they surfaced as phantom "?"-handle
416                // sisters in list-local, degrading the very discovery rc3
417                // fixed. No DID == no identity == not a session.
418                if info.did.is_none() {
419                    continue;
420                }
421                // Prefer the persona handle as the display name when the home
422                // is initialized; fall back to the by-key hash otherwise.
423                if let Some(h) = info.handle.clone() {
424                    info.name = h;
425                }
426                out.push(info);
427            }
428            continue;
429        }
430        out.push(mk(path, name));
431    }
432    out.sort_by(|a, b| a.name.cmp(&b.name));
433    Ok(out)
434}
435
436fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
437    let bytes = match std::fs::read(card_path) {
438        Ok(b) => b,
439        Err(_) => return (None, None),
440    };
441    let v: serde_json::Value = match serde_json::from_slice(&bytes) {
442        Ok(v) => v,
443        Err(_) => return (None, None),
444    };
445    let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
446    let handle = v
447        .get("handle")
448        .and_then(|x| x.as_str())
449        .map(str::to_string)
450        .or_else(|| {
451            did.as_ref()
452                .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
453        });
454    (did, handle)
455}
456
457/// Read a session home's daemon pid from `<home>/state/wire/daemon.pid`
458/// (path-based; does NOT consult WIRE_HOME). None if absent/corrupt. Used to
459/// enumerate which daemon pids legitimately belong to a session so orphan
460/// detection doesn't flag a sibling session's daemon (A2).
461pub fn session_daemon_pid(session_home: &Path) -> Option<u32> {
462    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
463    let bytes = std::fs::read(&pidfile).ok()?;
464    // Structured form first, then legacy integer.
465    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
466        v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
467    } else {
468        String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
469    }
470}
471
472fn check_daemon_live(session_home: &Path) -> bool {
473    session_daemon_pid(session_home)
474        .map(is_process_live)
475        .unwrap_or(false)
476}
477
478fn is_process_live(pid: u32) -> bool {
479    // v0.7.3: delegate to the shared platform helper. The previous
480    // implementation shelled out to `kill -0` on non-Linux, which
481    // unconditionally failed on Windows (no `kill` binary) and made
482    // `wire session list` report every daemon as `down` regardless of
483    // actual liveness.
484    crate::platform::process_alive(pid)
485}
486
487/// Read a session's `relay.json` and return its `self.endpoints[]`
488/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
489/// is a best-effort discovery helper, not a verification tool. A pre-
490/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
491/// promotes those to a federation-only Endpoint, so the result is
492/// still meaningful for legacy sessions.
493///
494/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
495/// not the canonical filename (`config::relay_state_path` returns
496/// `relay.json`). The mis-named read silently no-op'd and
497/// `list-local` always returned an empty `local` map as a result.
498/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
499/// in the same release — that helper had the symmetric write-side
500/// bug, so the local endpoint never got persisted in the first place.
501pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
502    let path = session_home.join("config").join("wire").join("relay.json");
503    let bytes = match std::fs::read(&path) {
504        Ok(b) => b,
505        Err(_) => return Vec::new(),
506    };
507    let val: Value = match serde_json::from_slice(&bytes) {
508        Ok(v) => v,
509        Err(_) => return Vec::new(),
510    };
511    self_endpoints(&val)
512}
513
514/// Stripped view of a Local endpoint for tooling output. Drops
515/// `slot_token` because it is a bearer credential — exposing it
516/// through `wire session list-local --json` would risk accidental
517/// leak via logs, screenshots, or piped output. Routing code uses
518/// the full `Endpoint` from `relay.json` directly; this type
519/// is for human/JSON observation only.
520#[derive(Debug, Clone, Serialize)]
521pub struct LocalEndpointView {
522    pub relay_url: String,
523    pub slot_id: String,
524}
525
526/// One row of `wire session list-local` output: a session that has a
527/// Local-scope endpoint plus metadata to render it.
528#[derive(Debug, Clone, Serialize)]
529pub struct LocalSessionView {
530    pub name: String,
531    pub handle: Option<String>,
532    pub did: Option<String>,
533    pub cwd: Option<String>,
534    pub home_dir: PathBuf,
535    pub daemon_running: bool,
536    /// All Local-scope endpoints this session advertises (token redacted).
537    /// Most sessions have exactly one; multiple is permitted for multi-
538    /// relay setups.
539    pub local_endpoints: Vec<LocalEndpointView>,
540}
541
542/// Sessions with no Local endpoint — shown separately so the operator
543/// knows they exist but are federation-only.
544#[derive(Debug, Clone, Serialize)]
545pub struct FederationOnlySessionView {
546    pub name: String,
547    pub handle: Option<String>,
548    pub cwd: Option<String>,
549}
550
551/// Result shape for `wire session list-local`. `local` is grouped by
552/// the local-relay URL so output can render each cluster of mutually-
553/// reachable sister sessions together. `federation_only` lists the rest.
554#[derive(Debug, Clone, Serialize)]
555pub struct LocalSessionListing {
556    pub local: HashMap<String, Vec<LocalSessionView>>,
557    pub federation_only: Vec<FederationOnlySessionView>,
558}
559
560/// Build the listing for `wire session list-local` from current on-disk
561/// state. Read-only; no daemon contact, no relay probe.
562pub fn list_local_sessions() -> Result<LocalSessionListing> {
563    let sessions = list_sessions()?;
564    let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
565    let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
566
567    for s in sessions {
568        let endpoints = read_session_endpoints(&s.home_dir);
569        let local_eps: Vec<Endpoint> = endpoints
570            .into_iter()
571            .filter(|e| {
572                // v0.7.4: include any session whose endpoint URL is a
573                // loopback address even if it's tagged Federation, not
574                // Local. This catches the legitimate-but-misshapen case
575                // where `wire init --relay http://127.0.0.1:8771` was run
576                // without `--with-local`, leaving the session with a
577                // loopback federation slot that's effectively local-mesh-
578                // reachable. Pre-v0.7.4 the strict scope-only filter
579                // silently excluded those sessions from `pair-all-local`,
580                // making nickname-based pairing fail for no operator-
581                // visible reason.
582                matches!(e.scope, EndpointScope::Local)
583                    || (matches!(e.scope, EndpointScope::Federation)
584                        && url_is_loopback(&e.relay_url))
585            })
586            .collect();
587        if local_eps.is_empty() {
588            federation_only.push(FederationOnlySessionView {
589                name: s.name.clone(),
590                handle: s.handle.clone(),
591                cwd: s.cwd.clone(),
592            });
593            continue;
594        }
595        // Redacted view: drop slot_token before exposing through CLI.
596        let redacted: Vec<LocalEndpointView> = local_eps
597            .iter()
598            .map(|e| LocalEndpointView {
599                relay_url: e.relay_url.clone(),
600                slot_id: e.slot_id.clone(),
601            })
602            .collect();
603        // Group by relay_url. A session with two Local endpoints (rare —
604        // would mean two loopback relays) appears under each.
605        for ep in &local_eps {
606            local
607                .entry(ep.relay_url.clone())
608                .or_default()
609                .push(LocalSessionView {
610                    name: s.name.clone(),
611                    handle: s.handle.clone(),
612                    did: s.did.clone(),
613                    cwd: s.cwd.clone(),
614                    home_dir: s.home_dir.clone(),
615                    daemon_running: s.daemon_running,
616                    local_endpoints: redacted.clone(),
617                });
618        }
619    }
620    // Sort each group by session name so output is deterministic.
621    for group in local.values_mut() {
622        group.sort_by(|a, b| a.name.cmp(&b.name));
623    }
624    federation_only.sort_by(|a, b| a.name.cmp(&b.name));
625    Ok(LocalSessionListing {
626        local,
627        federation_only,
628    })
629}
630
631/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
632///
633/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
634/// registry. If a session is registered for this cwd AND its home
635/// directory still exists, return that home dir; otherwise None.
636///
637/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
638/// so a `wire whoami` / `wire monitor` invocation from a project cwd
639/// adopts that project's session identity automatically, instead of
640/// silently falling back to the machine default. The CLI parity is
641/// load-bearing: without it, the user-visible identity diverges
642/// between MCP and the terminal, and monitors pull machine-wide
643/// inboxes when the operator expected a per-session view.
644pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
645    let registry = read_registry().ok()?;
646    // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
647    // inherit their parent's wire identity (e.g.
648    // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
649    // Without this, subdirs all fell back to the machine-wide default
650    // identity, which silently collapsed multiple Claude sessions onto the
651    // same DID + character.
652    let mut probe: Option<&std::path::Path> = Some(cwd);
653    while let Some(path) = probe {
654        let path_str = path.to_string_lossy().into_owned();
655        if let Some(session_name) = registry.by_cwd.get(&path_str) {
656            let session_home = session_dir(session_name).ok()?;
657            if session_home.exists() {
658                return Some(session_home);
659            }
660        }
661        probe = path.parent();
662    }
663    None
664}
665
666/// v0.13: resolve a stable per-session key — host-agnostic, with a Claude
667/// Code adapter and the path left open for other hosts. Order:
668///   1. `WIRE_SESSION_ID` — explicit universal override (any harness).
669///   2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per
670///      conversation; the same id the auto-memory system keys off).
671///   3. `None` — caller falls back to legacy cwd-detect (bare CLI /
672///      pre-v0.13 hosts). Future host adapters slot in before this.
673///
674/// Returns `(key, source-label)`.
675pub fn resolve_session_key() -> Option<(String, &'static str)> {
676    for (var, source) in [
677        ("WIRE_SESSION_ID", "override"),
678        ("CLAUDE_CODE_SESSION_ID", "claude-code"),
679    ] {
680        if let Ok(v) = std::env::var(var) {
681            let v = v.trim();
682            if !v.is_empty() {
683                return Some((v.to_string(), source));
684            }
685        }
686    }
687    None
688}
689
690/// v0.13: the WIRE_HOME for a resolved session key —
691/// `<sessions_root>/by-key/<hash>` where `hash` is the first 16 hex of
692/// SHA-256(key). Deterministic and cwd-independent, so two sessions never
693/// collide and there is no path-string to mis-normalize (the Windows bug
694/// cannot occur). 64 bits is collision-safe at this scale.
695pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
696    let mut h = Sha256::new();
697    h.update(key.as_bytes());
698    let digest = h.finalize();
699    let hash = hex::encode(&digest[..8]); // 16 hex chars / 64 bits
700    Ok(sessions_root()?.join("by-key").join(hash))
701}
702
703/// v0.6.10: warn at MCP/CLI startup if another `wire mcp` process is
704/// already running with the same effective `WIRE_HOME`. Closes the
705/// "two Claudes in same cwd silently share an identity" failure mode
706/// that wasted hours of operator debugging time: today the collision
707/// is invisible (both Claudes resolve to the same wire session via
708/// v0.6.7 auto-detect, race the inbox cursor, "look identical" from
709/// the operator's view). This surfaces it explicitly with a clear
710/// remediation path.
711///
712/// Best-effort: any subprocess / env-read failure is silent (the
713/// collision check should never block startup). Cross-platform via
714/// `ps -E -p <pid>` on macOS, `/proc/<pid>/environ` on Linux. Windows
715/// returns empty (no collision detected).
716pub fn warn_on_identity_collision(self_pid: u32) {
717    let our_wire_home = match std::env::var("WIRE_HOME") {
718        Ok(h) => h,
719        Err(_) => return,
720    };
721
722    let pgrep_out = match std::process::Command::new("pgrep")
723        .args(["-f", "wire mcp"])
724        .output()
725    {
726        Ok(o) if o.status.success() => o,
727        _ => return,
728    };
729
730    let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
731        .split_whitespace()
732        .filter_map(|s| s.parse::<u32>().ok())
733        .filter(|&p| p != self_pid)
734        .collect();
735
736    let mut colliders: Vec<u32> = Vec::new();
737    for pid in &other_pids {
738        if let Some(their_home) = read_wire_home_from_pid(*pid)
739            && their_home == our_wire_home
740        {
741            colliders.push(*pid);
742        }
743    }
744
745    if colliders.is_empty() {
746        return;
747    }
748
749    eprintln!(
750        "wire mcp: WARNING — {} other wire mcp process(es) already using WIRE_HOME=`{}` (pid {})",
751        colliders.len(),
752        our_wire_home,
753        colliders
754            .iter()
755            .map(|p| p.to_string())
756            .collect::<Vec<_>>()
757            .join(", ")
758    );
759    eprintln!(
760        "  Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
761    );
762    eprintln!("  To use a separate identity:");
763    eprintln!("    1. Close the other agent(s), OR");
764    eprintln!("    2. `wire session new <name> --local-only` to create a fresh identity, then");
765    eprintln!(
766        "    3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
767    );
768}
769
770/// Best-effort cross-platform read of another process's `WIRE_HOME`.
771/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
772/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
773/// Windows / other: returns `None` (collision detection no-ops).
774fn read_wire_home_from_pid(pid: u32) -> Option<String> {
775    #[cfg(target_os = "linux")]
776    {
777        let path = format!("/proc/{pid}/environ");
778        let bytes = std::fs::read(&path).ok()?;
779        for entry in bytes.split(|&b| b == 0) {
780            let s = match std::str::from_utf8(entry) {
781                Ok(s) => s,
782                Err(_) => continue,
783            };
784            if let Some(val) = s.strip_prefix("WIRE_HOME=") {
785                return Some(val.to_string());
786            }
787        }
788        None
789    }
790
791    #[cfg(target_os = "macos")]
792    {
793        let output = std::process::Command::new("ps")
794            .args(["-E", "-p", &pid.to_string(), "-o", "command="])
795            .output()
796            .ok()?;
797        let s = String::from_utf8_lossy(&output.stdout);
798        for tok in s.split_whitespace() {
799            if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
800                return Some(val.to_string());
801            }
802        }
803        None
804    }
805
806    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
807    {
808        let _ = pid;
809        None
810    }
811}
812
813/// v0.6.7: apply `detect_session_wire_home` for the current process.
814///
815/// If `WIRE_HOME` is unset and the current cwd maps to an existing
816/// session, set `WIRE_HOME` for the rest of this process and emit a
817/// one-liner to stderr so the operator knows which identity is in
818/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
819///
820/// `label` distinguishes the caller in the stderr line (`mcp` vs
821/// `cli`). Set `WIRE_QUIET_AUTOSESSION=1` to suppress the stderr line
822/// while keeping the env-var application active.
823///
824/// MUST be called BEFORE any worker thread or async task spawns —
825/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
826/// guarantees, and our use is safe only at process entry.
827pub fn maybe_adopt_session_wire_home(label: &str) {
828    if std::env::var("WIRE_HOME").is_ok() {
829        return;
830    }
831    // v0.13: prefer the host-agnostic session key (WIRE_SESSION_ID >
832    // CLAUDE_CODE_SESSION_ID). Each session gets its own WIRE_HOME under
833    // `by-key/<hash>` — no cwd lookup, no shared default, no Windows path
834    // collapse. Falls back to legacy cwd-detect only when no session key is
835    // present (bare CLI / pre-v0.13 hosts).
836    let (home, why) = if let Some((key, source)) = resolve_session_key() {
837        match session_home_for_key(&key) {
838            Ok(h) => {
839                // v0.13.2 (E8): do NOT create the home here. Creating it
840                // unconditionally on every resolution — before any identity
841                // exists — left a permanent empty home for every transient /
842                // probe session key that never `wire up`d, accumulating
843                // forever and surfacing as phantom "?" sisters in list-local.
844                // The home is created lazily by `ensure_dirs` on the first
845                // real write (init / claim / send), so an uninitialized
846                // session leaves no trace on disk. (Write paths already
847                // tolerate a non-existent WIRE_HOME — the test harness runs
848                // every test against one.)
849                (h, format!("session key ({source})"))
850            }
851            Err(_) => return,
852        }
853    } else if label == "mcp" {
854        // v0.13.4 (operator directive: per-session ONLY, never cwd). The MCP
855        // server must NEVER cwd-resolve — that fallback is what collapsed every
856        // Claude session sharing a launch dir (`~/Source`, `C:\Users\<user>`)
857        // onto a single persona. A stdio MCP server is one process per Claude
858        // session, so when no session id reached us (the
859        // `${CLAUDE_CODE_SESSION_ID}` env-forward is missing or didn't expand)
860        // we MINT a per-process key: distinct per session, never a shared cwd
861        // identity. With the env-forward in place this branch isn't reached —
862        // the session id resolves above.
863        let minted = format!(
864            "mcp-proc-{:016x}{:016x}",
865            rand::random::<u64>(),
866            rand::random::<u64>()
867        );
868        match session_home_for_key(&minted) {
869            Ok(h) => {
870                // Pin it for the process so every later resolve is consistent.
871                unsafe {
872                    std::env::set_var("WIRE_SESSION_ID", &minted);
873                }
874                (
875                    h,
876                    "minted per-process key (no session id; cwd disabled for MCP)".to_string(),
877                )
878            }
879            Err(_) => return,
880        }
881    } else {
882        // CLI with no session id. Per the per-session-only directive we do NOT
883        // cwd-resolve here either — cwd identity is the collision trap (agents
884        // shell out to the CLI, and any cwd-derived identity risks the wrong /
885        // shared persona). Under Claude Code the CLI always carries
886        // CLAUDE_CODE_SESSION_ID (resolved above), so this only hits a bare
887        // terminal outside an agent host — which gets the stable machine-default
888        // identity (set WIRE_SESSION_ID / WIRE_HOME for an explicit one). No cwd.
889        return;
890    };
891    // v0.9.1: emit the chatter ONLY when stderr is an interactive TTY.
892    // When wire is invoked from a non-interactive parent (Claude Code's
893    // Bash tool, scripts, daemons), the auto-detect line is captured
894    // alongside command output and pollutes both — wasting agent
895    // context tokens and breaking JSON parsers that read combined
896    // streams. WIRE_VERBOSE=1 forces the line on; WIRE_QUIET_AUTOSESSION
897    // still forces it off for back-compat with v0.9 scripts.
898    use std::io::IsTerminal;
899    let quiet_env = std::env::var("WIRE_QUIET_AUTOSESSION").is_ok();
900    let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
901    let interactive = std::io::stderr().is_terminal();
902    if !quiet_env && (interactive || verbose_env) {
903        eprintln!(
904            "wire {label}: adopted {why} → WIRE_HOME=`{}`",
905            home.display()
906        );
907    }
908    // SAFETY: caller contract is "before any thread spawn." All
909    // production sites (cli::run, mcp::run) call this as the first
910    // step in their respective entry points.
911    unsafe {
912        std::env::set_var("WIRE_HOME", &home);
913    }
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    #[test]
921    fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
922        // Regression (v0.13.2): v0.13 moved session homes under
923        // `sessions/by-key/<hash>`, but (1) list_sessions only scanned the
924        // top level so by-key homes were invisible, and (2) sessions_root()'s
925        // inside-session fallback only walked ONE level up (expecting parent
926        // `sessions`), so an inside-session WIRE_HOME resolved to a bogus
927        // nested dir. Together they made same-box discovery (list-local /
928        // pair-all-local) return zero sisters under v0.13.
929        let _guard = crate::config::test_support::ENV_LOCK
930            .lock()
931            .unwrap_or_else(|p| p.into_inner());
932        let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
933        let _ = std::fs::remove_dir_all(&tmp);
934        let root = tmp.join("sessions");
935        let home = root.join("by-key").join("abc123def4567890");
936        let cfg = home.join("config").join("wire");
937        std::fs::create_dir_all(&cfg).unwrap();
938        std::fs::write(
939            cfg.join("agent-card.json"),
940            r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
941        )
942        .unwrap();
943
944        // (1) sessions_root() must find the real root even when WIRE_HOME
945        //     points INSIDE the by-key home.
946        // SAFETY: ENV_LOCK is held, serializing all env access.
947        unsafe { std::env::set_var("WIRE_HOME", &home) };
948        assert_eq!(
949            sessions_root().unwrap(),
950            root,
951            "sessions_root must resolve the root from inside a by-key home"
952        );
953
954        // (2) list_sessions() must enumerate the by-key home, labeled by handle.
955        let sessions = list_sessions().unwrap();
956        let found = sessions
957            .iter()
958            .any(|s| s.handle.as_deref() == Some("test-persona"));
959        unsafe { std::env::remove_var("WIRE_HOME") };
960        let _ = std::fs::remove_dir_all(&tmp);
961        assert!(
962            found,
963            "by-key home must be enumerated: {:?}",
964            sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
965        );
966    }
967
968    #[test]
969    fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
970        // session_home_for_key reads WIRE_HOME (via sessions_root); hold the
971        // shared env lock so a parallel env-mutating test can't change it
972        // between calls and make a1 != a2 (flaky race).
973        let _guard = crate::config::test_support::ENV_LOCK
974            .lock()
975            .unwrap_or_else(|p| p.into_inner());
976        let a1 = session_home_for_key("sess-aaa").unwrap();
977        let a2 = session_home_for_key("sess-aaa").unwrap();
978        let b = session_home_for_key("sess-bbb").unwrap();
979        assert_eq!(a1, a2, "same key -> same home (resume stability)");
980        assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
981        let leaf = a1.file_name().unwrap().to_str().unwrap();
982        assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
983        assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
984        assert_eq!(
985            a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
986            "by-key"
987        );
988    }
989
990    #[test]
991    fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
992        assert!(url_is_loopback("http://127.0.0.1:8771"));
993        assert!(url_is_loopback("http://127.1.2.3"));
994        assert!(url_is_loopback("http://localhost:9000"));
995        assert!(url_is_loopback("https://localhost/v1"));
996        assert!(url_is_loopback("http://[::1]:8771"));
997        // Case-insensitive.
998        assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
999        // Non-loopback negatives — must NOT be flagged.
1000        assert!(!url_is_loopback("https://wireup.net"));
1001        assert!(!url_is_loopback("http://192.168.1.50:8771"));
1002        assert!(!url_is_loopback("http://10.0.0.5"));
1003        assert!(!url_is_loopback("https://relay.example.com"));
1004    }
1005
1006    #[test]
1007    fn sanitize_handles_unicode_and_long_names() {
1008        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
1009        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
1010        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
1011        assert_eq!(sanitize_name(""), "wire-session");
1012        assert_eq!(sanitize_name("---"), "wire-session");
1013        let long: String = "a".repeat(100);
1014        assert_eq!(sanitize_name(&long).len(), 32);
1015    }
1016
1017    #[test]
1018    fn derive_name_returns_basename_when_no_collision() {
1019        let reg = SessionRegistry::default();
1020        assert_eq!(
1021            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
1022            "wire"
1023        );
1024        assert_eq!(
1025            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
1026            "slancha-mesh"
1027        );
1028    }
1029
1030    #[test]
1031    fn derive_name_returns_stored_name_when_cwd_already_registered() {
1032        let mut reg = SessionRegistry::default();
1033        reg.by_cwd.insert(
1034            "/Users/paul/Source/wire".to_string(),
1035            "wire-special".to_string(),
1036        );
1037        assert_eq!(
1038            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
1039            "wire-special"
1040        );
1041    }
1042
1043    #[test]
1044    fn read_session_endpoints_handles_missing_relay_state() {
1045        let tmp = tempfile::tempdir().unwrap();
1046        // No relay.json under <home>/config/wire/ — should yield empty.
1047        let endpoints = read_session_endpoints(tmp.path());
1048        assert!(endpoints.is_empty());
1049    }
1050
1051    #[test]
1052    fn read_session_endpoints_parses_dual_slot_form() {
1053        let tmp = tempfile::tempdir().unwrap();
1054        let cfg = tmp.path().join("config").join("wire");
1055        std::fs::create_dir_all(&cfg).unwrap();
1056        let body = serde_json::json!({
1057            "self": {
1058                "relay_url": "https://wireup.net",
1059                "slot_id": "fed-slot",
1060                "slot_token": "fed-tok",
1061                "endpoints": [
1062                    {
1063                        "relay_url": "https://wireup.net",
1064                        "slot_id": "fed-slot",
1065                        "slot_token": "fed-tok",
1066                        "scope": "federation"
1067                    },
1068                    {
1069                        "relay_url": "http://127.0.0.1:8771",
1070                        "slot_id": "loop-slot",
1071                        "slot_token": "loop-tok",
1072                        "scope": "local"
1073                    }
1074                ]
1075            }
1076        });
1077        std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
1078        let endpoints = read_session_endpoints(tmp.path());
1079        assert_eq!(endpoints.len(), 2);
1080        let local_count = endpoints
1081            .iter()
1082            .filter(|e| matches!(e.scope, EndpointScope::Local))
1083            .count();
1084        assert_eq!(local_count, 1);
1085        let local = endpoints
1086            .iter()
1087            .find(|e| matches!(e.scope, EndpointScope::Local))
1088            .unwrap();
1089        assert_eq!(local.relay_url, "http://127.0.0.1:8771");
1090        assert_eq!(local.slot_id, "loop-slot");
1091    }
1092
1093    // NOTE: list_local_sessions is integration-tested via tests/cli.rs
1094    // using a subprocess that sets WIRE_HOME per-process. We do not test
1095    // it in-module because env mutation races other parallel unit tests
1096    // (Rust 2024 marks std::env::set_var unsafe for that reason). The
1097    // grouping logic is straightforward enough that the integration
1098    // test plus the read_session_endpoints unit tests above provide
1099    // adequate coverage.
1100
1101    #[test]
1102    fn derive_name_appends_path_hash_when_basename_collides() {
1103        let mut reg = SessionRegistry::default();
1104        reg.by_cwd
1105            .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
1106        // Different cwd, same basename → must get a hash suffix.
1107        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
1108        assert!(name.starts_with("wire-"));
1109        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
1110        assert_ne!(name, "wire");
1111    }
1112}