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(®)
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 ®istry.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.11: character is purely DID-derived across every session.
372 // display.json reads removed — rename verb is gone and the
373 // one-name rule says the character must be findable, which a
374 // local-display override never was.
375 let character = did.as_deref().map(crate::character::Character::from_did);
376 out.push(SessionInfo {
377 name: name.clone(),
378 cwd: name_to_cwd.get(&name).cloned(),
379 home_dir: path,
380 did,
381 handle,
382 daemon_running,
383 character,
384 });
385 }
386 out.sort_by(|a, b| a.name.cmp(&b.name));
387 Ok(out)
388}
389
390fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
391 let bytes = match std::fs::read(card_path) {
392 Ok(b) => b,
393 Err(_) => return (None, None),
394 };
395 let v: serde_json::Value = match serde_json::from_slice(&bytes) {
396 Ok(v) => v,
397 Err(_) => return (None, None),
398 };
399 let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
400 let handle = v
401 .get("handle")
402 .and_then(|x| x.as_str())
403 .map(str::to_string)
404 .or_else(|| {
405 did.as_ref()
406 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
407 });
408 (did, handle)
409}
410
411fn check_daemon_live(session_home: &Path) -> bool {
412 // Pidfile lives at <session_home>/state/wire/daemon.pid. Use the
413 // existing ensure_up reader by temporarily pointing at the path; we
414 // can't change env mid-process race-free, so re-implement the pid
415 // extraction directly here from the JSON structure.
416 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
417 let bytes = match std::fs::read(&pidfile) {
418 Ok(b) => b,
419 Err(_) => return false,
420 };
421 // Try the structured form first.
422 let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
423 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
424 } else {
425 // Legacy integer form.
426 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
427 };
428 let pid = match pid_opt {
429 Some(p) => p,
430 None => return false,
431 };
432 is_process_live(pid)
433}
434
435fn is_process_live(pid: u32) -> bool {
436 // v0.7.3: delegate to the shared platform helper. The previous
437 // implementation shelled out to `kill -0` on non-Linux, which
438 // unconditionally failed on Windows (no `kill` binary) and made
439 // `wire session list` report every daemon as `down` regardless of
440 // actual liveness.
441 crate::platform::process_alive(pid)
442}
443
444/// Read a session's `relay.json` and return its `self.endpoints[]`
445/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
446/// is a best-effort discovery helper, not a verification tool. A pre-
447/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
448/// promotes those to a federation-only Endpoint, so the result is
449/// still meaningful for legacy sessions.
450///
451/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
452/// not the canonical filename (`config::relay_state_path` returns
453/// `relay.json`). The mis-named read silently no-op'd and
454/// `list-local` always returned an empty `local` map as a result.
455/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
456/// in the same release — that helper had the symmetric write-side
457/// bug, so the local endpoint never got persisted in the first place.
458pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
459 let path = session_home.join("config").join("wire").join("relay.json");
460 let bytes = match std::fs::read(&path) {
461 Ok(b) => b,
462 Err(_) => return Vec::new(),
463 };
464 let val: Value = match serde_json::from_slice(&bytes) {
465 Ok(v) => v,
466 Err(_) => return Vec::new(),
467 };
468 self_endpoints(&val)
469}
470
471/// Stripped view of a Local endpoint for tooling output. Drops
472/// `slot_token` because it is a bearer credential — exposing it
473/// through `wire session list-local --json` would risk accidental
474/// leak via logs, screenshots, or piped output. Routing code uses
475/// the full `Endpoint` from `relay.json` directly; this type
476/// is for human/JSON observation only.
477#[derive(Debug, Clone, Serialize)]
478pub struct LocalEndpointView {
479 pub relay_url: String,
480 pub slot_id: String,
481}
482
483/// One row of `wire session list-local` output: a session that has a
484/// Local-scope endpoint plus metadata to render it.
485#[derive(Debug, Clone, Serialize)]
486pub struct LocalSessionView {
487 pub name: String,
488 pub handle: Option<String>,
489 pub did: Option<String>,
490 pub cwd: Option<String>,
491 pub home_dir: PathBuf,
492 pub daemon_running: bool,
493 /// All Local-scope endpoints this session advertises (token redacted).
494 /// Most sessions have exactly one; multiple is permitted for multi-
495 /// relay setups.
496 pub local_endpoints: Vec<LocalEndpointView>,
497}
498
499/// Sessions with no Local endpoint — shown separately so the operator
500/// knows they exist but are federation-only.
501#[derive(Debug, Clone, Serialize)]
502pub struct FederationOnlySessionView {
503 pub name: String,
504 pub handle: Option<String>,
505 pub cwd: Option<String>,
506}
507
508/// Result shape for `wire session list-local`. `local` is grouped by
509/// the local-relay URL so output can render each cluster of mutually-
510/// reachable sister sessions together. `federation_only` lists the rest.
511#[derive(Debug, Clone, Serialize)]
512pub struct LocalSessionListing {
513 pub local: HashMap<String, Vec<LocalSessionView>>,
514 pub federation_only: Vec<FederationOnlySessionView>,
515}
516
517/// Build the listing for `wire session list-local` from current on-disk
518/// state. Read-only; no daemon contact, no relay probe.
519pub fn list_local_sessions() -> Result<LocalSessionListing> {
520 let sessions = list_sessions()?;
521 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
522 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
523
524 for s in sessions {
525 let endpoints = read_session_endpoints(&s.home_dir);
526 let local_eps: Vec<Endpoint> = endpoints
527 .into_iter()
528 .filter(|e| {
529 // v0.7.4: include any session whose endpoint URL is a
530 // loopback address even if it's tagged Federation, not
531 // Local. This catches the legitimate-but-misshapen case
532 // where `wire init --relay http://127.0.0.1:8771` was run
533 // without `--with-local`, leaving the session with a
534 // loopback federation slot that's effectively local-mesh-
535 // reachable. Pre-v0.7.4 the strict scope-only filter
536 // silently excluded those sessions from `pair-all-local`,
537 // making nickname-based pairing fail for no operator-
538 // visible reason.
539 matches!(e.scope, EndpointScope::Local)
540 || (matches!(e.scope, EndpointScope::Federation)
541 && url_is_loopback(&e.relay_url))
542 })
543 .collect();
544 if local_eps.is_empty() {
545 federation_only.push(FederationOnlySessionView {
546 name: s.name.clone(),
547 handle: s.handle.clone(),
548 cwd: s.cwd.clone(),
549 });
550 continue;
551 }
552 // Redacted view: drop slot_token before exposing through CLI.
553 let redacted: Vec<LocalEndpointView> = local_eps
554 .iter()
555 .map(|e| LocalEndpointView {
556 relay_url: e.relay_url.clone(),
557 slot_id: e.slot_id.clone(),
558 })
559 .collect();
560 // Group by relay_url. A session with two Local endpoints (rare —
561 // would mean two loopback relays) appears under each.
562 for ep in &local_eps {
563 local
564 .entry(ep.relay_url.clone())
565 .or_default()
566 .push(LocalSessionView {
567 name: s.name.clone(),
568 handle: s.handle.clone(),
569 did: s.did.clone(),
570 cwd: s.cwd.clone(),
571 home_dir: s.home_dir.clone(),
572 daemon_running: s.daemon_running,
573 local_endpoints: redacted.clone(),
574 });
575 }
576 }
577 // Sort each group by session name so output is deterministic.
578 for group in local.values_mut() {
579 group.sort_by(|a, b| a.name.cmp(&b.name));
580 }
581 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
582 Ok(LocalSessionListing {
583 local,
584 federation_only,
585 })
586}
587
588/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
589///
590/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
591/// registry. If a session is registered for this cwd AND its home
592/// directory still exists, return that home dir; otherwise None.
593///
594/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
595/// so a `wire whoami` / `wire monitor` invocation from a project cwd
596/// adopts that project's session identity automatically, instead of
597/// silently falling back to the machine default. The CLI parity is
598/// load-bearing: without it, the user-visible identity diverges
599/// between MCP and the terminal, and monitors pull machine-wide
600/// inboxes when the operator expected a per-session view.
601pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
602 let registry = read_registry().ok()?;
603 // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
604 // inherit their parent's wire identity (e.g.
605 // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
606 // Without this, subdirs all fell back to the machine-wide default
607 // identity, which silently collapsed multiple Claude sessions onto the
608 // same DID + character.
609 let mut probe: Option<&std::path::Path> = Some(cwd);
610 while let Some(path) = probe {
611 let path_str = path.to_string_lossy().into_owned();
612 if let Some(session_name) = registry.by_cwd.get(&path_str) {
613 let session_home = session_dir(session_name).ok()?;
614 if session_home.exists() {
615 return Some(session_home);
616 }
617 }
618 probe = path.parent();
619 }
620 None
621}
622
623/// v0.13: resolve a stable per-session key — host-agnostic, with a Claude
624/// Code adapter and the path left open for other hosts. Order:
625/// 1. `WIRE_SESSION_ID` — explicit universal override (any harness).
626/// 2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per
627/// conversation; the same id the auto-memory system keys off).
628/// 3. `None` — caller falls back to legacy cwd-detect (bare CLI /
629/// pre-v0.13 hosts). Future host adapters slot in before this.
630///
631/// Returns `(key, source-label)`.
632pub fn resolve_session_key() -> Option<(String, &'static str)> {
633 for (var, source) in [
634 ("WIRE_SESSION_ID", "override"),
635 ("CLAUDE_CODE_SESSION_ID", "claude-code"),
636 ] {
637 if let Ok(v) = std::env::var(var) {
638 let v = v.trim();
639 if !v.is_empty() {
640 return Some((v.to_string(), source));
641 }
642 }
643 }
644 None
645}
646
647/// v0.13: the WIRE_HOME for a resolved session key —
648/// `<sessions_root>/by-key/<hash>` where `hash` is the first 16 hex of
649/// SHA-256(key). Deterministic and cwd-independent, so two sessions never
650/// collide and there is no path-string to mis-normalize (the Windows bug
651/// cannot occur). 64 bits is collision-safe at this scale.
652pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
653 let mut h = Sha256::new();
654 h.update(key.as_bytes());
655 let digest = h.finalize();
656 let hash = hex::encode(&digest[..8]); // 16 hex chars / 64 bits
657 Ok(sessions_root()?.join("by-key").join(hash))
658}
659
660/// v0.6.10: warn at MCP/CLI startup if another `wire mcp` process is
661/// already running with the same effective `WIRE_HOME`. Closes the
662/// "two Claudes in same cwd silently share an identity" failure mode
663/// that wasted hours of operator debugging time: today the collision
664/// is invisible (both Claudes resolve to the same wire session via
665/// v0.6.7 auto-detect, race the inbox cursor, "look identical" from
666/// the operator's view). This surfaces it explicitly with a clear
667/// remediation path.
668///
669/// Best-effort: any subprocess / env-read failure is silent (the
670/// collision check should never block startup). Cross-platform via
671/// `ps -E -p <pid>` on macOS, `/proc/<pid>/environ` on Linux. Windows
672/// returns empty (no collision detected).
673pub fn warn_on_identity_collision(self_pid: u32) {
674 let our_wire_home = match std::env::var("WIRE_HOME") {
675 Ok(h) => h,
676 Err(_) => return,
677 };
678
679 let pgrep_out = match std::process::Command::new("pgrep")
680 .args(["-f", "wire mcp"])
681 .output()
682 {
683 Ok(o) if o.status.success() => o,
684 _ => return,
685 };
686
687 let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
688 .split_whitespace()
689 .filter_map(|s| s.parse::<u32>().ok())
690 .filter(|&p| p != self_pid)
691 .collect();
692
693 let mut colliders: Vec<u32> = Vec::new();
694 for pid in &other_pids {
695 if let Some(their_home) = read_wire_home_from_pid(*pid)
696 && their_home == our_wire_home
697 {
698 colliders.push(*pid);
699 }
700 }
701
702 if colliders.is_empty() {
703 return;
704 }
705
706 eprintln!(
707 "wire mcp: WARNING — {} other wire mcp process(es) already using WIRE_HOME=`{}` (pid {})",
708 colliders.len(),
709 our_wire_home,
710 colliders
711 .iter()
712 .map(|p| p.to_string())
713 .collect::<Vec<_>>()
714 .join(", ")
715 );
716 eprintln!(
717 " Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
718 );
719 eprintln!(" To use a separate identity:");
720 eprintln!(" 1. Close the other agent(s), OR");
721 eprintln!(" 2. `wire session new <name> --local-only` to create a fresh identity, then");
722 eprintln!(
723 " 3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
724 );
725}
726
727/// Best-effort cross-platform read of another process's `WIRE_HOME`.
728/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
729/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
730/// Windows / other: returns `None` (collision detection no-ops).
731fn read_wire_home_from_pid(pid: u32) -> Option<String> {
732 #[cfg(target_os = "linux")]
733 {
734 let path = format!("/proc/{pid}/environ");
735 let bytes = std::fs::read(&path).ok()?;
736 for entry in bytes.split(|&b| b == 0) {
737 let s = match std::str::from_utf8(entry) {
738 Ok(s) => s,
739 Err(_) => continue,
740 };
741 if let Some(val) = s.strip_prefix("WIRE_HOME=") {
742 return Some(val.to_string());
743 }
744 }
745 None
746 }
747
748 #[cfg(target_os = "macos")]
749 {
750 let output = std::process::Command::new("ps")
751 .args(["-E", "-p", &pid.to_string(), "-o", "command="])
752 .output()
753 .ok()?;
754 let s = String::from_utf8_lossy(&output.stdout);
755 for tok in s.split_whitespace() {
756 if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
757 return Some(val.to_string());
758 }
759 }
760 None
761 }
762
763 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
764 {
765 let _ = pid;
766 None
767 }
768}
769
770/// v0.6.7: apply `detect_session_wire_home` for the current process.
771///
772/// If `WIRE_HOME` is unset and the current cwd maps to an existing
773/// session, set `WIRE_HOME` for the rest of this process and emit a
774/// one-liner to stderr so the operator knows which identity is in
775/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
776///
777/// `label` distinguishes the caller in the stderr line (`mcp` vs
778/// `cli`). Set `WIRE_QUIET_AUTOSESSION=1` to suppress the stderr line
779/// while keeping the env-var application active.
780///
781/// MUST be called BEFORE any worker thread or async task spawns —
782/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
783/// guarantees, and our use is safe only at process entry.
784pub fn maybe_adopt_session_wire_home(label: &str) {
785 if std::env::var("WIRE_HOME").is_ok() {
786 return;
787 }
788 // v0.13: prefer the host-agnostic session key (WIRE_SESSION_ID >
789 // CLAUDE_CODE_SESSION_ID). Each session gets its own WIRE_HOME under
790 // `by-key/<hash>` — no cwd lookup, no shared default, no Windows path
791 // collapse. Falls back to legacy cwd-detect only when no session key is
792 // present (bare CLI / pre-v0.13 hosts).
793 let (home, why) = if let Some((key, source)) = resolve_session_key() {
794 match session_home_for_key(&key) {
795 Ok(h) => {
796 // by-key homes are created lazily on first resolution.
797 if let Err(e) = std::fs::create_dir_all(&h) {
798 eprintln!(
799 "wire {label}: could not create session home {}: {e}",
800 h.display()
801 );
802 return;
803 }
804 (h, format!("session key ({source})"))
805 }
806 Err(_) => return,
807 }
808 } else {
809 let cwd = match std::env::current_dir() {
810 Ok(c) => c,
811 Err(_) => return,
812 };
813 match detect_session_wire_home(&cwd) {
814 Some(h) => (h, format!("cwd `{}`", cwd.display())),
815 None => return,
816 }
817 };
818 // v0.9.1: emit the chatter ONLY when stderr is an interactive TTY.
819 // When wire is invoked from a non-interactive parent (Claude Code's
820 // Bash tool, scripts, daemons), the auto-detect line is captured
821 // alongside command output and pollutes both — wasting agent
822 // context tokens and breaking JSON parsers that read combined
823 // streams. WIRE_VERBOSE=1 forces the line on; WIRE_QUIET_AUTOSESSION
824 // still forces it off for back-compat with v0.9 scripts.
825 use std::io::IsTerminal;
826 let quiet_env = std::env::var("WIRE_QUIET_AUTOSESSION").is_ok();
827 let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
828 let interactive = std::io::stderr().is_terminal();
829 if !quiet_env && (interactive || verbose_env) {
830 eprintln!(
831 "wire {label}: adopted {why} → WIRE_HOME=`{}`",
832 home.display()
833 );
834 }
835 // SAFETY: caller contract is "before any thread spawn." All
836 // production sites (cli::run, mcp::run) call this as the first
837 // step in their respective entry points.
838 unsafe {
839 std::env::set_var("WIRE_HOME", &home);
840 }
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 #[test]
848 fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
849 let a1 = session_home_for_key("sess-aaa").unwrap();
850 let a2 = session_home_for_key("sess-aaa").unwrap();
851 let b = session_home_for_key("sess-bbb").unwrap();
852 assert_eq!(a1, a2, "same key -> same home (resume stability)");
853 assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
854 let leaf = a1.file_name().unwrap().to_str().unwrap();
855 assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
856 assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
857 assert_eq!(
858 a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
859 "by-key"
860 );
861 }
862
863 #[test]
864 fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
865 assert!(url_is_loopback("http://127.0.0.1:8771"));
866 assert!(url_is_loopback("http://127.1.2.3"));
867 assert!(url_is_loopback("http://localhost:9000"));
868 assert!(url_is_loopback("https://localhost/v1"));
869 assert!(url_is_loopback("http://[::1]:8771"));
870 // Case-insensitive.
871 assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
872 // Non-loopback negatives — must NOT be flagged.
873 assert!(!url_is_loopback("https://wireup.net"));
874 assert!(!url_is_loopback("http://192.168.1.50:8771"));
875 assert!(!url_is_loopback("http://10.0.0.5"));
876 assert!(!url_is_loopback("https://relay.example.com"));
877 }
878
879 #[test]
880 fn sanitize_handles_unicode_and_long_names() {
881 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
882 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
883 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
884 assert_eq!(sanitize_name(""), "wire-session");
885 assert_eq!(sanitize_name("---"), "wire-session");
886 let long: String = "a".repeat(100);
887 assert_eq!(sanitize_name(&long).len(), 32);
888 }
889
890 #[test]
891 fn derive_name_returns_basename_when_no_collision() {
892 let reg = SessionRegistry::default();
893 assert_eq!(
894 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
895 "wire"
896 );
897 assert_eq!(
898 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
899 "slancha-mesh"
900 );
901 }
902
903 #[test]
904 fn derive_name_returns_stored_name_when_cwd_already_registered() {
905 let mut reg = SessionRegistry::default();
906 reg.by_cwd.insert(
907 "/Users/paul/Source/wire".to_string(),
908 "wire-special".to_string(),
909 );
910 assert_eq!(
911 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
912 "wire-special"
913 );
914 }
915
916 #[test]
917 fn read_session_endpoints_handles_missing_relay_state() {
918 let tmp = tempfile::tempdir().unwrap();
919 // No relay.json under <home>/config/wire/ — should yield empty.
920 let endpoints = read_session_endpoints(tmp.path());
921 assert!(endpoints.is_empty());
922 }
923
924 #[test]
925 fn read_session_endpoints_parses_dual_slot_form() {
926 let tmp = tempfile::tempdir().unwrap();
927 let cfg = tmp.path().join("config").join("wire");
928 std::fs::create_dir_all(&cfg).unwrap();
929 let body = serde_json::json!({
930 "self": {
931 "relay_url": "https://wireup.net",
932 "slot_id": "fed-slot",
933 "slot_token": "fed-tok",
934 "endpoints": [
935 {
936 "relay_url": "https://wireup.net",
937 "slot_id": "fed-slot",
938 "slot_token": "fed-tok",
939 "scope": "federation"
940 },
941 {
942 "relay_url": "http://127.0.0.1:8771",
943 "slot_id": "loop-slot",
944 "slot_token": "loop-tok",
945 "scope": "local"
946 }
947 ]
948 }
949 });
950 std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
951 let endpoints = read_session_endpoints(tmp.path());
952 assert_eq!(endpoints.len(), 2);
953 let local_count = endpoints
954 .iter()
955 .filter(|e| matches!(e.scope, EndpointScope::Local))
956 .count();
957 assert_eq!(local_count, 1);
958 let local = endpoints
959 .iter()
960 .find(|e| matches!(e.scope, EndpointScope::Local))
961 .unwrap();
962 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
963 assert_eq!(local.slot_id, "loop-slot");
964 }
965
966 // NOTE: list_local_sessions is integration-tested via tests/cli.rs
967 // using a subprocess that sets WIRE_HOME per-process. We do not test
968 // it in-module because env mutation races other parallel unit tests
969 // (Rust 2024 marks std::env::set_var unsafe for that reason). The
970 // grouping logic is straightforward enough that the integration
971 // test plus the read_session_endpoints unit tests above provide
972 // adequate coverage.
973
974 #[test]
975 fn derive_name_appends_path_hash_when_basename_collides() {
976 let mut reg = SessionRegistry::default();
977 reg.by_cwd
978 .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
979 // Different cwd, same basename → must get a hash suffix.
980 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
981 assert!(name.starts_with("wire-"));
982 assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
983 assert_ne!(name, "wire");
984 }
985}