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        // The check is tight on purpose: only short-circuit when the
66        // immediate parent dir is named `sessions`. Anything else (a
67        // plain test WIRE_HOME, a custom location) keeps the v0.6.3
68        // behavior of returning `<WIRE_HOME>/sessions/` so the caller
69        // can populate it.
70        if let Some(parent) = home.parent()
71            && parent.file_name().and_then(|s| s.to_str()) == Some("sessions")
72        {
73            return Ok(parent.to_path_buf());
74        }
75        return Ok(direct);
76    }
77    let state = dirs::state_dir()
78        .or_else(dirs::data_local_dir)
79        .ok_or_else(|| {
80            anyhow!(
81                "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
82                 set WIRE_HOME or run on a platform with `dirs` support"
83            )
84        })?;
85    Ok(state.join("wire").join("sessions"))
86}
87
88/// Full filesystem path for the named session's WIRE_HOME root.
89/// Inside this dir the standard wire layout applies: `config/wire/...`
90/// and `state/wire/...`.
91pub fn session_dir(name: &str) -> Result<PathBuf> {
92    Ok(sessions_root()?.join(sanitize_name(name)))
93}
94
95/// Registry tracks `cwd → session_name` so repeated `wire session new`
96/// from the same project reuses the same identity instead of creating
97/// a fresh one each time. Lives at `<sessions_root>/registry.json`.
98pub fn registry_path() -> Result<PathBuf> {
99    Ok(sessions_root()?.join("registry.json"))
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct SessionRegistry {
104    /// `cwd_absolute_path → session_name`. Absent if cwd has not been
105    /// associated with a session yet.
106    #[serde(default)]
107    pub by_cwd: HashMap<String, String>,
108}
109
110pub fn read_registry() -> Result<SessionRegistry> {
111    let path = registry_path()?;
112    if !path.exists() {
113        return Ok(SessionRegistry::default());
114    }
115    let bytes =
116        std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
117    serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
118}
119
120pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
121    let path = registry_path()?;
122    if let Some(parent) = path.parent() {
123        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
124    }
125    let body = serde_json::to_vec_pretty(reg)?;
126    // v0.7.0-alpha.8 (review-fix #7): atomic write via tmp+rename so
127    // concurrent unflocked readers (detect_session_wire_home,
128    // list_sessions, cmd_peers) never observe a 0-byte / truncated
129    // registry mid-write. Pre-alpha.8 used std::fs::write which
130    // truncates first — race window where readers saw empty JSON and
131    // fell back to default identity for the write duration.
132    let tmp = path.with_extension("json.tmp");
133    std::fs::write(&tmp, body).with_context(|| format!("writing tmp session registry {tmp:?}"))?;
134    std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
135    Ok(())
136}
137
138/// v0.7.0-alpha.3: flock'd read-modify-write of the session registry.
139///
140/// `write_registry` alone is not safe under concurrency — multiple MCP
141/// processes auto-initing in parallel each read an old snapshot, mutate
142/// their copy, and write back, losing N-1 updates. This helper acquires
143/// an exclusive flock on a sibling lockfile, re-reads inside the lock,
144/// applies the caller's modifier, writes atomically, and releases.
145///
146/// Modeled on `config::update_relay_state`. Lock contention is bounded:
147/// modifications are pure HashMap operations, write is whole-file at
148/// roughly the registry size (KBs, not MBs).
149pub fn update_registry<F>(modifier: F) -> Result<()>
150where
151    F: FnOnce(&mut SessionRegistry) -> Result<()>,
152{
153    use fs2::FileExt;
154    let path = registry_path()?;
155    if let Some(parent) = path.parent() {
156        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
157    }
158    let lock_path = path.with_extension("lock");
159    let lock_file = std::fs::OpenOptions::new()
160        .create(true)
161        .truncate(false)
162        .read(true)
163        .write(true)
164        .open(&lock_path)
165        .with_context(|| format!("opening {lock_path:?}"))?;
166    lock_file
167        .lock_exclusive()
168        .with_context(|| format!("flock {lock_path:?}"))?;
169    // Re-read INSIDE the lock — any prior snapshot would race.
170    let mut reg = read_registry().unwrap_or_default();
171    let result = modifier(&mut reg);
172    let write_result = if result.is_ok() {
173        write_registry(&reg)
174    } else {
175        Ok(())
176    };
177    let _ = fs2::FileExt::unlock(&lock_file);
178    result?;
179    write_result?;
180    Ok(())
181}
182
183/// Sanitize an arbitrary string to a session-name-safe form: lowercase
184/// ASCII alphanumeric + `-` + `_`, replace other chars with `-`,
185/// dedupe consecutive dashes, trim leading/trailing dashes, max 32 chars.
186pub fn sanitize_name(raw: &str) -> String {
187    let mut out = String::with_capacity(raw.len());
188    let mut prev_dash = false;
189    for c in raw.chars() {
190        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
191        let ch = if ok { c.to_ascii_lowercase() } else { '-' };
192        if ch == '-' {
193            if !prev_dash && !out.is_empty() {
194                out.push('-');
195            }
196            prev_dash = true;
197        } else {
198            out.push(ch);
199            prev_dash = false;
200        }
201    }
202    let trimmed = out.trim_matches('-').to_string();
203    if trimmed.is_empty() {
204        return "wire-session".to_string();
205    }
206    if trimmed.len() > 32 {
207        return trimmed[..32].trim_end_matches('-').to_string();
208    }
209    trimmed
210}
211
212/// Short hash suffix derived from the full absolute path of the cwd.
213/// Used to disambiguate two different projects whose basenames collide
214/// (e.g. `~/Source/wire` and `~/Archive/wire`).
215fn path_hash_suffix(cwd: &Path) -> String {
216    let bytes = cwd.as_os_str().to_string_lossy().into_owned();
217    let mut h = Sha256::new();
218    h.update(bytes.as_bytes());
219    let digest = h.finalize();
220    hex::encode(&digest[..2]) // 4 hex chars
221}
222
223/// Derive a stable session name for the given cwd. Resolution order:
224///
225/// 1. If the registry already maps this cwd → name, return that name.
226/// 2. Else: candidate = sanitize(basename(cwd)). If the candidate is
227///    already mapped to a DIFFERENT cwd in the registry, append a
228///    4-char path-hash suffix to avoid collision.
229/// 3. If still a collision: append a numeric suffix `-2`, `-3`, ...
230///    until unique.
231pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
232    let cwd_key = cwd.to_string_lossy().into_owned();
233    if let Some(existing) = registry.by_cwd.get(&cwd_key) {
234        return existing.clone();
235    }
236    let base = cwd
237        .file_name()
238        .and_then(|s| s.to_str())
239        .map(sanitize_name)
240        .unwrap_or_else(|| "wire-session".to_string());
241    let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
242    if !occupied.contains(&base) {
243        return base;
244    }
245    let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
246    if !occupied.contains(&with_hash) {
247        return with_hash;
248    }
249    // Highly unlikely (would require a SHA-256 prefix collision plus an
250    // existing entry to claim it). Numeric tiebreaker as final fallback.
251    for n in 2..1000 {
252        let candidate = format!("{base}-{n}");
253        if !occupied.contains(&candidate) {
254            return candidate;
255        }
256    }
257    // Pathological fallback — every numbered slot is taken.
258    format!("{base}-{}-overflow", path_hash_suffix(cwd))
259}
260
261/// Summary of one on-disk session for `wire session list`.
262#[derive(Debug, Clone, Serialize)]
263pub struct SessionInfo {
264    pub name: String,
265    /// First cwd associated with this session in the registry. `None`
266    /// if the session was created without registry tracking (manual
267    /// `wire session new <name>`).
268    pub cwd: Option<String>,
269    pub home_dir: PathBuf,
270    pub did: Option<String>,
271    pub handle: Option<String>,
272    /// True if a `daemon.pid` file exists AND the recorded PID is
273    /// actually a live process (best-effort, not POSIX-portable but
274    /// matches the existing `wire status` / `wire doctor` checks).
275    pub daemon_running: bool,
276    /// Display character (nickname + emoji + color palette) derived from
277    /// the session's DID. `None` when the session has no agent-card yet
278    /// (pre-init). Lazy-computed at read time; never persisted to disk.
279    pub character: Option<crate::character::Character>,
280}
281
282/// Enumerate every on-disk session by reading `sessions_root()`. Cross-
283/// references the registry so each entry's `cwd` is filled in when known.
284/// v0.7.4: true iff the URL targets a loopback host (127.0.0.0/8 or
285/// [::1] or `localhost`). Used to detect "this Federation-scope slot
286/// is actually on a loopback relay" — those sessions are local-mesh
287/// candidates even though they're not tagged `local`.
288///
289/// Best-effort string match; we don't need full URL parsing for this
290/// because the relay URL is wire-controlled and follows a predictable
291/// shape (`http://<host>[:<port>][/path]`).
292fn url_is_loopback(url: &str) -> bool {
293    let lower = url.to_ascii_lowercase();
294    let after_scheme = match lower.split_once("://") {
295        Some((_, rest)) => rest,
296        None => lower.as_str(),
297    };
298    // Bracketed IPv6 literal: `[::1]:8771` keeps brackets in host slice.
299    if let Some(rest) = after_scheme.strip_prefix('[') {
300        return rest
301            .split_once(']')
302            .map(|(host, _)| host == "::1")
303            .unwrap_or(false);
304    }
305    let host = after_scheme.split(['/', ':']).next().unwrap_or("");
306    host == "localhost" || host == "127.0.0.1" || host.starts_with("127.")
307}
308
309/// v0.7.4: resolve an operator-typed name to a local sister session.
310/// Input may be the session NAME (e.g. `slancha-api`), the card
311/// HANDLE (usually equal to the name), or the character NICKNAME
312/// (e.g. `noble-slate`). Returns the session NAME suitable for the
313/// `--local-sister` add path. Case-insensitive. None on no match.
314///
315/// Designed for `wire add <input>` ergonomics — the operator should
316/// be able to type whatever face wire put on the peer (statusline
317/// nickname, session list emoji+name) and have wire find it.
318pub fn resolve_local_sister(input: &str) -> Option<String> {
319    let needle = input.trim();
320    if needle.is_empty() {
321        return None;
322    }
323    let sessions = list_sessions().ok()?;
324    for s in &sessions {
325        if s.name.eq_ignore_ascii_case(needle) {
326            return Some(s.name.clone());
327        }
328        if let Some(h) = &s.handle
329            && h.eq_ignore_ascii_case(needle)
330        {
331            return Some(s.name.clone());
332        }
333        if let Some(ch) = &s.character
334            && ch.nickname.eq_ignore_ascii_case(needle)
335        {
336            return Some(s.name.clone());
337        }
338    }
339    None
340}
341
342pub fn list_sessions() -> Result<Vec<SessionInfo>> {
343    let root = sessions_root()?;
344    if !root.exists() {
345        return Ok(Vec::new());
346    }
347    let registry = read_registry().unwrap_or_default();
348    // Reverse lookup: name → cwd. Used to annotate each SessionInfo.
349    let mut name_to_cwd: HashMap<String, String> = HashMap::new();
350    for (cwd, name) in &registry.by_cwd {
351        name_to_cwd.insert(name.clone(), cwd.clone());
352    }
353
354    let mut out = Vec::new();
355    for entry in std::fs::read_dir(&root)?.flatten() {
356        let path = entry.path();
357        if !path.is_dir() {
358            continue;
359        }
360        let name = match path.file_name().and_then(|s| s.to_str()) {
361            Some(s) => s.to_string(),
362            None => continue,
363        };
364        // Skip the registry sidecar.
365        if name == "registry.json" {
366            continue;
367        }
368        let card_path = path.join("config").join("wire").join("agent-card.json");
369        let (did, handle) = read_card_identity(&card_path);
370        let daemon_running = check_daemon_live(&path);
371        // v0.7.0-alpha.3: read this session's display.json for any
372        // operator-chosen nickname/emoji overrides.
373        let display_overrides_path = path.join("config").join("wire").join("display.json");
374        let overrides =
375            crate::config::read_display_overrides_at(&display_overrides_path).unwrap_or_default();
376        let character = did.as_deref().map(|d| {
377            crate::character::Character::from_did_with_override(
378                d,
379                overrides.nickname.as_deref(),
380                overrides.emoji.as_deref(),
381            )
382        });
383        out.push(SessionInfo {
384            name: name.clone(),
385            cwd: name_to_cwd.get(&name).cloned(),
386            home_dir: path,
387            did,
388            handle,
389            daemon_running,
390            character,
391        });
392    }
393    out.sort_by(|a, b| a.name.cmp(&b.name));
394    Ok(out)
395}
396
397fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
398    let bytes = match std::fs::read(card_path) {
399        Ok(b) => b,
400        Err(_) => return (None, None),
401    };
402    let v: serde_json::Value = match serde_json::from_slice(&bytes) {
403        Ok(v) => v,
404        Err(_) => return (None, None),
405    };
406    let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
407    let handle = v
408        .get("handle")
409        .and_then(|x| x.as_str())
410        .map(str::to_string)
411        .or_else(|| {
412            did.as_ref()
413                .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
414        });
415    (did, handle)
416}
417
418fn check_daemon_live(session_home: &Path) -> bool {
419    // Pidfile lives at <session_home>/state/wire/daemon.pid. Use the
420    // existing ensure_up reader by temporarily pointing at the path; we
421    // can't change env mid-process race-free, so re-implement the pid
422    // extraction directly here from the JSON structure.
423    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
424    let bytes = match std::fs::read(&pidfile) {
425        Ok(b) => b,
426        Err(_) => return false,
427    };
428    // Try the structured form first.
429    let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
430        v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
431    } else {
432        // Legacy integer form.
433        String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
434    };
435    let pid = match pid_opt {
436        Some(p) => p,
437        None => return false,
438    };
439    is_process_live(pid)
440}
441
442fn is_process_live(pid: u32) -> bool {
443    // v0.7.3: delegate to the shared platform helper. The previous
444    // implementation shelled out to `kill -0` on non-Linux, which
445    // unconditionally failed on Windows (no `kill` binary) and made
446    // `wire session list` report every daemon as `down` regardless of
447    // actual liveness.
448    crate::platform::process_alive(pid)
449}
450
451/// Read a session's `relay.json` and return its `self.endpoints[]`
452/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
453/// is a best-effort discovery helper, not a verification tool. A pre-
454/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
455/// promotes those to a federation-only Endpoint, so the result is
456/// still meaningful for legacy sessions.
457///
458/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
459/// not the canonical filename (`config::relay_state_path` returns
460/// `relay.json`). The mis-named read silently no-op'd and
461/// `list-local` always returned an empty `local` map as a result.
462/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
463/// in the same release — that helper had the symmetric write-side
464/// bug, so the local endpoint never got persisted in the first place.
465pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
466    let path = session_home.join("config").join("wire").join("relay.json");
467    let bytes = match std::fs::read(&path) {
468        Ok(b) => b,
469        Err(_) => return Vec::new(),
470    };
471    let val: Value = match serde_json::from_slice(&bytes) {
472        Ok(v) => v,
473        Err(_) => return Vec::new(),
474    };
475    self_endpoints(&val)
476}
477
478/// Stripped view of a Local endpoint for tooling output. Drops
479/// `slot_token` because it is a bearer credential — exposing it
480/// through `wire session list-local --json` would risk accidental
481/// leak via logs, screenshots, or piped output. Routing code uses
482/// the full `Endpoint` from `relay.json` directly; this type
483/// is for human/JSON observation only.
484#[derive(Debug, Clone, Serialize)]
485pub struct LocalEndpointView {
486    pub relay_url: String,
487    pub slot_id: String,
488}
489
490/// One row of `wire session list-local` output: a session that has a
491/// Local-scope endpoint plus metadata to render it.
492#[derive(Debug, Clone, Serialize)]
493pub struct LocalSessionView {
494    pub name: String,
495    pub handle: Option<String>,
496    pub did: Option<String>,
497    pub cwd: Option<String>,
498    pub home_dir: PathBuf,
499    pub daemon_running: bool,
500    /// All Local-scope endpoints this session advertises (token redacted).
501    /// Most sessions have exactly one; multiple is permitted for multi-
502    /// relay setups.
503    pub local_endpoints: Vec<LocalEndpointView>,
504}
505
506/// Sessions with no Local endpoint — shown separately so the operator
507/// knows they exist but are federation-only.
508#[derive(Debug, Clone, Serialize)]
509pub struct FederationOnlySessionView {
510    pub name: String,
511    pub handle: Option<String>,
512    pub cwd: Option<String>,
513}
514
515/// Result shape for `wire session list-local`. `local` is grouped by
516/// the local-relay URL so output can render each cluster of mutually-
517/// reachable sister sessions together. `federation_only` lists the rest.
518#[derive(Debug, Clone, Serialize)]
519pub struct LocalSessionListing {
520    pub local: HashMap<String, Vec<LocalSessionView>>,
521    pub federation_only: Vec<FederationOnlySessionView>,
522}
523
524/// Build the listing for `wire session list-local` from current on-disk
525/// state. Read-only; no daemon contact, no relay probe.
526pub fn list_local_sessions() -> Result<LocalSessionListing> {
527    let sessions = list_sessions()?;
528    let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
529    let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
530
531    for s in sessions {
532        let endpoints = read_session_endpoints(&s.home_dir);
533        let local_eps: Vec<Endpoint> = endpoints
534            .into_iter()
535            .filter(|e| {
536                // v0.7.4: include any session whose endpoint URL is a
537                // loopback address even if it's tagged Federation, not
538                // Local. This catches the legitimate-but-misshapen case
539                // where `wire init --relay http://127.0.0.1:8771` was run
540                // without `--with-local`, leaving the session with a
541                // loopback federation slot that's effectively local-mesh-
542                // reachable. Pre-v0.7.4 the strict scope-only filter
543                // silently excluded those sessions from `pair-all-local`,
544                // making nickname-based pairing fail for no operator-
545                // visible reason.
546                matches!(e.scope, EndpointScope::Local)
547                    || (matches!(e.scope, EndpointScope::Federation)
548                        && url_is_loopback(&e.relay_url))
549            })
550            .collect();
551        if local_eps.is_empty() {
552            federation_only.push(FederationOnlySessionView {
553                name: s.name.clone(),
554                handle: s.handle.clone(),
555                cwd: s.cwd.clone(),
556            });
557            continue;
558        }
559        // Redacted view: drop slot_token before exposing through CLI.
560        let redacted: Vec<LocalEndpointView> = local_eps
561            .iter()
562            .map(|e| LocalEndpointView {
563                relay_url: e.relay_url.clone(),
564                slot_id: e.slot_id.clone(),
565            })
566            .collect();
567        // Group by relay_url. A session with two Local endpoints (rare —
568        // would mean two loopback relays) appears under each.
569        for ep in &local_eps {
570            local
571                .entry(ep.relay_url.clone())
572                .or_default()
573                .push(LocalSessionView {
574                    name: s.name.clone(),
575                    handle: s.handle.clone(),
576                    did: s.did.clone(),
577                    cwd: s.cwd.clone(),
578                    home_dir: s.home_dir.clone(),
579                    daemon_running: s.daemon_running,
580                    local_endpoints: redacted.clone(),
581                });
582        }
583    }
584    // Sort each group by session name so output is deterministic.
585    for group in local.values_mut() {
586        group.sort_by(|a, b| a.name.cmp(&b.name));
587    }
588    federation_only.sort_by(|a, b| a.name.cmp(&b.name));
589    Ok(LocalSessionListing {
590        local,
591        federation_only,
592    })
593}
594
595/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
596///
597/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
598/// registry. If a session is registered for this cwd AND its home
599/// directory still exists, return that home dir; otherwise None.
600///
601/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
602/// so a `wire whoami` / `wire monitor` invocation from a project cwd
603/// adopts that project's session identity automatically, instead of
604/// silently falling back to the machine default. The CLI parity is
605/// load-bearing: without it, the user-visible identity diverges
606/// between MCP and the terminal, and monitors pull machine-wide
607/// inboxes when the operator expected a per-session view.
608pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
609    let registry = read_registry().ok()?;
610    // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
611    // inherit their parent's wire identity (e.g.
612    // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
613    // Without this, subdirs all fell back to the machine-wide default
614    // identity, which silently collapsed multiple Claude sessions onto the
615    // same DID + character.
616    let mut probe: Option<&std::path::Path> = Some(cwd);
617    while let Some(path) = probe {
618        let path_str = path.to_string_lossy().into_owned();
619        if let Some(session_name) = registry.by_cwd.get(&path_str) {
620            let session_home = session_dir(session_name).ok()?;
621            if session_home.exists() {
622                return Some(session_home);
623            }
624        }
625        probe = path.parent();
626    }
627    None
628}
629
630/// v0.6.10: warn at MCP/CLI startup if another `wire mcp` process is
631/// already running with the same effective `WIRE_HOME`. Closes the
632/// "two Claudes in same cwd silently share an identity" failure mode
633/// that wasted hours of operator debugging time: today the collision
634/// is invisible (both Claudes resolve to the same wire session via
635/// v0.6.7 auto-detect, race the inbox cursor, "look identical" from
636/// the operator's view). This surfaces it explicitly with a clear
637/// remediation path.
638///
639/// Best-effort: any subprocess / env-read failure is silent (the
640/// collision check should never block startup). Cross-platform via
641/// `ps -E -p <pid>` on macOS, `/proc/<pid>/environ` on Linux. Windows
642/// returns empty (no collision detected).
643pub fn warn_on_identity_collision(self_pid: u32) {
644    let our_wire_home = match std::env::var("WIRE_HOME") {
645        Ok(h) => h,
646        Err(_) => return,
647    };
648
649    let pgrep_out = match std::process::Command::new("pgrep")
650        .args(["-f", "wire mcp"])
651        .output()
652    {
653        Ok(o) if o.status.success() => o,
654        _ => return,
655    };
656
657    let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
658        .split_whitespace()
659        .filter_map(|s| s.parse::<u32>().ok())
660        .filter(|&p| p != self_pid)
661        .collect();
662
663    let mut colliders: Vec<u32> = Vec::new();
664    for pid in &other_pids {
665        if let Some(their_home) = read_wire_home_from_pid(*pid)
666            && their_home == our_wire_home
667        {
668            colliders.push(*pid);
669        }
670    }
671
672    if colliders.is_empty() {
673        return;
674    }
675
676    eprintln!(
677        "wire mcp: WARNING — {} other wire mcp process(es) already using WIRE_HOME=`{}` (pid {})",
678        colliders.len(),
679        our_wire_home,
680        colliders
681            .iter()
682            .map(|p| p.to_string())
683            .collect::<Vec<_>>()
684            .join(", ")
685    );
686    eprintln!(
687        "  Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
688    );
689    eprintln!("  To use a separate identity:");
690    eprintln!("    1. Close the other agent(s), OR");
691    eprintln!("    2. `wire session new <name> --local-only` to create a fresh identity, then");
692    eprintln!(
693        "    3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
694    );
695}
696
697/// Best-effort cross-platform read of another process's `WIRE_HOME`.
698/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
699/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
700/// Windows / other: returns `None` (collision detection no-ops).
701fn read_wire_home_from_pid(pid: u32) -> Option<String> {
702    #[cfg(target_os = "linux")]
703    {
704        let path = format!("/proc/{pid}/environ");
705        let bytes = std::fs::read(&path).ok()?;
706        for entry in bytes.split(|&b| b == 0) {
707            let s = match std::str::from_utf8(entry) {
708                Ok(s) => s,
709                Err(_) => continue,
710            };
711            if let Some(val) = s.strip_prefix("WIRE_HOME=") {
712                return Some(val.to_string());
713            }
714        }
715        None
716    }
717
718    #[cfg(target_os = "macos")]
719    {
720        let output = std::process::Command::new("ps")
721            .args(["-E", "-p", &pid.to_string(), "-o", "command="])
722            .output()
723            .ok()?;
724        let s = String::from_utf8_lossy(&output.stdout);
725        for tok in s.split_whitespace() {
726            if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
727                return Some(val.to_string());
728            }
729        }
730        None
731    }
732
733    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
734    {
735        let _ = pid;
736        None
737    }
738}
739
740/// v0.6.7: apply `detect_session_wire_home` for the current process.
741///
742/// If `WIRE_HOME` is unset and the current cwd maps to an existing
743/// session, set `WIRE_HOME` for the rest of this process and emit a
744/// one-liner to stderr so the operator knows which identity is in
745/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
746///
747/// `label` distinguishes the caller in the stderr line (`mcp` vs
748/// `cli`). Set `WIRE_QUIET_AUTOSESSION=1` to suppress the stderr line
749/// while keeping the env-var application active.
750///
751/// MUST be called BEFORE any worker thread or async task spawns —
752/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
753/// guarantees, and our use is safe only at process entry.
754pub fn maybe_adopt_session_wire_home(label: &str) {
755    if std::env::var("WIRE_HOME").is_ok() {
756        return;
757    }
758    let cwd = match std::env::current_dir() {
759        Ok(c) => c,
760        Err(_) => return,
761    };
762    let home = match detect_session_wire_home(&cwd) {
763        Some(h) => h,
764        None => return,
765    };
766    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
767        eprintln!(
768            "wire {label}: auto-detected session for cwd `{}` → WIRE_HOME=`{}`",
769            cwd.display(),
770            home.display()
771        );
772    }
773    // SAFETY: caller contract is "before any thread spawn." All
774    // production sites (cli::run, mcp::run) call this as the first
775    // step in their respective entry points.
776    unsafe {
777        std::env::set_var("WIRE_HOME", &home);
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    #[test]
786    fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
787        assert!(url_is_loopback("http://127.0.0.1:8771"));
788        assert!(url_is_loopback("http://127.1.2.3"));
789        assert!(url_is_loopback("http://localhost:9000"));
790        assert!(url_is_loopback("https://localhost/v1"));
791        assert!(url_is_loopback("http://[::1]:8771"));
792        // Case-insensitive.
793        assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
794        // Non-loopback negatives — must NOT be flagged.
795        assert!(!url_is_loopback("https://wireup.net"));
796        assert!(!url_is_loopback("http://192.168.1.50:8771"));
797        assert!(!url_is_loopback("http://10.0.0.5"));
798        assert!(!url_is_loopback("https://relay.example.com"));
799    }
800
801    #[test]
802    fn sanitize_handles_unicode_and_long_names() {
803        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
804        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
805        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
806        assert_eq!(sanitize_name(""), "wire-session");
807        assert_eq!(sanitize_name("---"), "wire-session");
808        let long: String = "a".repeat(100);
809        assert_eq!(sanitize_name(&long).len(), 32);
810    }
811
812    #[test]
813    fn derive_name_returns_basename_when_no_collision() {
814        let reg = SessionRegistry::default();
815        assert_eq!(
816            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
817            "wire"
818        );
819        assert_eq!(
820            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
821            "slancha-mesh"
822        );
823    }
824
825    #[test]
826    fn derive_name_returns_stored_name_when_cwd_already_registered() {
827        let mut reg = SessionRegistry::default();
828        reg.by_cwd.insert(
829            "/Users/paul/Source/wire".to_string(),
830            "wire-special".to_string(),
831        );
832        assert_eq!(
833            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
834            "wire-special"
835        );
836    }
837
838    #[test]
839    fn read_session_endpoints_handles_missing_relay_state() {
840        let tmp = tempfile::tempdir().unwrap();
841        // No relay.json under <home>/config/wire/ — should yield empty.
842        let endpoints = read_session_endpoints(tmp.path());
843        assert!(endpoints.is_empty());
844    }
845
846    #[test]
847    fn read_session_endpoints_parses_dual_slot_form() {
848        let tmp = tempfile::tempdir().unwrap();
849        let cfg = tmp.path().join("config").join("wire");
850        std::fs::create_dir_all(&cfg).unwrap();
851        let body = serde_json::json!({
852            "self": {
853                "relay_url": "https://wireup.net",
854                "slot_id": "fed-slot",
855                "slot_token": "fed-tok",
856                "endpoints": [
857                    {
858                        "relay_url": "https://wireup.net",
859                        "slot_id": "fed-slot",
860                        "slot_token": "fed-tok",
861                        "scope": "federation"
862                    },
863                    {
864                        "relay_url": "http://127.0.0.1:8771",
865                        "slot_id": "loop-slot",
866                        "slot_token": "loop-tok",
867                        "scope": "local"
868                    }
869                ]
870            }
871        });
872        std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
873        let endpoints = read_session_endpoints(tmp.path());
874        assert_eq!(endpoints.len(), 2);
875        let local_count = endpoints
876            .iter()
877            .filter(|e| matches!(e.scope, EndpointScope::Local))
878            .count();
879        assert_eq!(local_count, 1);
880        let local = endpoints
881            .iter()
882            .find(|e| matches!(e.scope, EndpointScope::Local))
883            .unwrap();
884        assert_eq!(local.relay_url, "http://127.0.0.1:8771");
885        assert_eq!(local.slot_id, "loop-slot");
886    }
887
888    // NOTE: list_local_sessions is integration-tested via tests/cli.rs
889    // using a subprocess that sets WIRE_HOME per-process. We do not test
890    // it in-module because env mutation races other parallel unit tests
891    // (Rust 2024 marks std::env::set_var unsafe for that reason). The
892    // grouping logic is straightforward enough that the integration
893    // test plus the read_session_endpoints unit tests above provide
894    // adequate coverage.
895
896    #[test]
897    fn derive_name_appends_path_hash_when_basename_collides() {
898        let mut reg = SessionRegistry::default();
899        reg.by_cwd
900            .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
901        // Different cwd, same basename → must get a hash suffix.
902        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
903        assert!(name.starts_with("wire-"));
904        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
905        assert_ne!(name, "wire");
906    }
907}