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(®)
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 ®istry.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"), ®),
1022 "wire"
1023 );
1024 assert_eq!(
1025 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
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"), ®),
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"), ®);
1108 assert!(name.starts_with("wire-"));
1109 assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
1110 assert_ne!(name, "wire");
1111 }
1112}