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) = std::env::var("WIRE_HOME") {
52        return Ok(PathBuf::from(home).join("sessions"));
53    }
54    let state = dirs::state_dir()
55        .or_else(dirs::data_local_dir)
56        .ok_or_else(|| {
57            anyhow!(
58                "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
59                 set WIRE_HOME or run on a platform with `dirs` support"
60            )
61        })?;
62    Ok(state.join("wire").join("sessions"))
63}
64
65/// Full filesystem path for the named session's WIRE_HOME root.
66/// Inside this dir the standard wire layout applies: `config/wire/...`
67/// and `state/wire/...`.
68pub fn session_dir(name: &str) -> Result<PathBuf> {
69    Ok(sessions_root()?.join(sanitize_name(name)))
70}
71
72/// Registry tracks `cwd → session_name` so repeated `wire session new`
73/// from the same project reuses the same identity instead of creating
74/// a fresh one each time. Lives at `<sessions_root>/registry.json`.
75pub fn registry_path() -> Result<PathBuf> {
76    Ok(sessions_root()?.join("registry.json"))
77}
78
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct SessionRegistry {
81    /// `cwd_absolute_path → session_name`. Absent if cwd has not been
82    /// associated with a session yet.
83    #[serde(default)]
84    pub by_cwd: HashMap<String, String>,
85}
86
87pub fn read_registry() -> Result<SessionRegistry> {
88    let path = registry_path()?;
89    if !path.exists() {
90        return Ok(SessionRegistry::default());
91    }
92    let bytes = std::fs::read(&path)
93        .with_context(|| format!("reading session registry {path:?}"))?;
94    serde_json::from_slice(&bytes)
95        .with_context(|| format!("parsing session registry {path:?}"))
96}
97
98pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
99    let path = registry_path()?;
100    if let Some(parent) = path.parent() {
101        std::fs::create_dir_all(parent)
102            .with_context(|| format!("creating {parent:?}"))?;
103    }
104    let body = serde_json::to_vec_pretty(reg)?;
105    std::fs::write(&path, body)
106        .with_context(|| format!("writing session registry {path:?}"))?;
107    Ok(())
108}
109
110/// Sanitize an arbitrary string to a session-name-safe form: lowercase
111/// ASCII alphanumeric + `-` + `_`, replace other chars with `-`,
112/// dedupe consecutive dashes, trim leading/trailing dashes, max 32 chars.
113pub fn sanitize_name(raw: &str) -> String {
114    let mut out = String::with_capacity(raw.len());
115    let mut prev_dash = false;
116    for c in raw.chars() {
117        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
118        let ch = if ok { c.to_ascii_lowercase() } else { '-' };
119        if ch == '-' {
120            if !prev_dash && !out.is_empty() {
121                out.push('-');
122            }
123            prev_dash = true;
124        } else {
125            out.push(ch);
126            prev_dash = false;
127        }
128    }
129    let trimmed = out.trim_matches('-').to_string();
130    if trimmed.is_empty() {
131        return "wire-session".to_string();
132    }
133    if trimmed.len() > 32 {
134        return trimmed[..32].trim_end_matches('-').to_string();
135    }
136    trimmed
137}
138
139/// Short hash suffix derived from the full absolute path of the cwd.
140/// Used to disambiguate two different projects whose basenames collide
141/// (e.g. `~/Source/wire` and `~/Archive/wire`).
142fn path_hash_suffix(cwd: &Path) -> String {
143    let bytes = cwd.as_os_str().to_string_lossy().into_owned();
144    let mut h = Sha256::new();
145    h.update(bytes.as_bytes());
146    let digest = h.finalize();
147    hex::encode(&digest[..2]) // 4 hex chars
148}
149
150/// Derive a stable session name for the given cwd. Resolution order:
151///
152/// 1. If the registry already maps this cwd → name, return that name.
153/// 2. Else: candidate = sanitize(basename(cwd)). If the candidate is
154///    already mapped to a DIFFERENT cwd in the registry, append a
155///    4-char path-hash suffix to avoid collision.
156/// 3. If still a collision: append a numeric suffix `-2`, `-3`, ...
157///    until unique.
158pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
159    let cwd_key = cwd.to_string_lossy().into_owned();
160    if let Some(existing) = registry.by_cwd.get(&cwd_key) {
161        return existing.clone();
162    }
163    let base = cwd
164        .file_name()
165        .and_then(|s| s.to_str())
166        .map(sanitize_name)
167        .unwrap_or_else(|| "wire-session".to_string());
168    let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
169    if !occupied.contains(&base) {
170        return base;
171    }
172    let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
173    if !occupied.contains(&with_hash) {
174        return with_hash;
175    }
176    // Highly unlikely (would require a SHA-256 prefix collision plus an
177    // existing entry to claim it). Numeric tiebreaker as final fallback.
178    for n in 2..1000 {
179        let candidate = format!("{base}-{n}");
180        if !occupied.contains(&candidate) {
181            return candidate;
182        }
183    }
184    // Pathological fallback — every numbered slot is taken.
185    format!("{base}-{}-overflow", path_hash_suffix(cwd))
186}
187
188/// Summary of one on-disk session for `wire session list`.
189#[derive(Debug, Clone, Serialize)]
190pub struct SessionInfo {
191    pub name: String,
192    /// First cwd associated with this session in the registry. `None`
193    /// if the session was created without registry tracking (manual
194    /// `wire session new <name>`).
195    pub cwd: Option<String>,
196    pub home_dir: PathBuf,
197    pub did: Option<String>,
198    pub handle: Option<String>,
199    /// True if a `daemon.pid` file exists AND the recorded PID is
200    /// actually a live process (best-effort, not POSIX-portable but
201    /// matches the existing `wire status` / `wire doctor` checks).
202    pub daemon_running: bool,
203}
204
205/// Enumerate every on-disk session by reading `sessions_root()`. Cross-
206/// references the registry so each entry's `cwd` is filled in when known.
207pub fn list_sessions() -> Result<Vec<SessionInfo>> {
208    let root = sessions_root()?;
209    if !root.exists() {
210        return Ok(Vec::new());
211    }
212    let registry = read_registry().unwrap_or_default();
213    // Reverse lookup: name → cwd. Used to annotate each SessionInfo.
214    let mut name_to_cwd: HashMap<String, String> = HashMap::new();
215    for (cwd, name) in &registry.by_cwd {
216        name_to_cwd.insert(name.clone(), cwd.clone());
217    }
218
219    let mut out = Vec::new();
220    for entry in std::fs::read_dir(&root)?.flatten() {
221        let path = entry.path();
222        if !path.is_dir() {
223            continue;
224        }
225        let name = match path.file_name().and_then(|s| s.to_str()) {
226            Some(s) => s.to_string(),
227            None => continue,
228        };
229        // Skip the registry sidecar.
230        if name == "registry.json" {
231            continue;
232        }
233        let card_path = path.join("config").join("wire").join("agent-card.json");
234        let (did, handle) = read_card_identity(&card_path);
235        let daemon_running = check_daemon_live(&path);
236        out.push(SessionInfo {
237            name: name.clone(),
238            cwd: name_to_cwd.get(&name).cloned(),
239            home_dir: path,
240            did,
241            handle,
242            daemon_running,
243        });
244    }
245    out.sort_by(|a, b| a.name.cmp(&b.name));
246    Ok(out)
247}
248
249fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
250    let bytes = match std::fs::read(card_path) {
251        Ok(b) => b,
252        Err(_) => return (None, None),
253    };
254    let v: serde_json::Value = match serde_json::from_slice(&bytes) {
255        Ok(v) => v,
256        Err(_) => return (None, None),
257    };
258    let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
259    let handle = v
260        .get("handle")
261        .and_then(|x| x.as_str())
262        .map(str::to_string)
263        .or_else(|| {
264            did.as_ref().map(|d| {
265                crate::agent_card::display_handle_from_did(d).to_string()
266            })
267        });
268    (did, handle)
269}
270
271fn check_daemon_live(session_home: &Path) -> bool {
272    // Pidfile lives at <session_home>/state/wire/daemon.pid. Use the
273    // existing ensure_up reader by temporarily pointing at the path; we
274    // can't change env mid-process race-free, so re-implement the pid
275    // extraction directly here from the JSON structure.
276    let pidfile = session_home
277        .join("state")
278        .join("wire")
279        .join("daemon.pid");
280    let bytes = match std::fs::read(&pidfile) {
281        Ok(b) => b,
282        Err(_) => return false,
283    };
284    // Try the structured form first.
285    let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
286        v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
287    } else {
288        // Legacy integer form.
289        String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
290    };
291    let pid = match pid_opt {
292        Some(p) => p,
293        None => return false,
294    };
295    is_process_live(pid)
296}
297
298fn is_process_live(pid: u32) -> bool {
299    #[cfg(target_os = "linux")]
300    {
301        std::path::Path::new(&format!("/proc/{pid}")).exists()
302    }
303    #[cfg(not(target_os = "linux"))]
304    {
305        std::process::Command::new("kill")
306            .args(["-0", &pid.to_string()])
307            .output()
308            .map(|o| o.status.success())
309            .unwrap_or(false)
310    }
311}
312
313/// Read a session's `relay-state.json` and return its `self.endpoints[]`
314/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
315/// is a best-effort discovery helper, not a verification tool. A pre-
316/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
317/// promotes those to a federation-only Endpoint, so the result is
318/// still meaningful for legacy sessions.
319pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
320    let path = session_home
321        .join("config")
322        .join("wire")
323        .join("relay-state.json");
324    let bytes = match std::fs::read(&path) {
325        Ok(b) => b,
326        Err(_) => return Vec::new(),
327    };
328    let val: Value = match serde_json::from_slice(&bytes) {
329        Ok(v) => v,
330        Err(_) => return Vec::new(),
331    };
332    self_endpoints(&val)
333}
334
335/// Stripped view of a Local endpoint for tooling output. Drops
336/// `slot_token` because it is a bearer credential — exposing it
337/// through `wire session list-local --json` would risk accidental
338/// leak via logs, screenshots, or piped output. Routing code uses
339/// the full `Endpoint` from `relay-state.json` directly; this type
340/// is for human/JSON observation only.
341#[derive(Debug, Clone, Serialize)]
342pub struct LocalEndpointView {
343    pub relay_url: String,
344    pub slot_id: String,
345}
346
347/// One row of `wire session list-local` output: a session that has a
348/// Local-scope endpoint plus metadata to render it.
349#[derive(Debug, Clone, Serialize)]
350pub struct LocalSessionView {
351    pub name: String,
352    pub handle: Option<String>,
353    pub did: Option<String>,
354    pub cwd: Option<String>,
355    pub home_dir: PathBuf,
356    pub daemon_running: bool,
357    /// All Local-scope endpoints this session advertises (token redacted).
358    /// Most sessions have exactly one; multiple is permitted for multi-
359    /// relay setups.
360    pub local_endpoints: Vec<LocalEndpointView>,
361}
362
363/// Sessions with no Local endpoint — shown separately so the operator
364/// knows they exist but are federation-only.
365#[derive(Debug, Clone, Serialize)]
366pub struct FederationOnlySessionView {
367    pub name: String,
368    pub handle: Option<String>,
369    pub cwd: Option<String>,
370}
371
372/// Result shape for `wire session list-local`. `local` is grouped by
373/// the local-relay URL so output can render each cluster of mutually-
374/// reachable sister sessions together. `federation_only` lists the rest.
375#[derive(Debug, Clone, Serialize)]
376pub struct LocalSessionListing {
377    pub local: HashMap<String, Vec<LocalSessionView>>,
378    pub federation_only: Vec<FederationOnlySessionView>,
379}
380
381/// Build the listing for `wire session list-local` from current on-disk
382/// state. Read-only; no daemon contact, no relay probe.
383pub fn list_local_sessions() -> Result<LocalSessionListing> {
384    let sessions = list_sessions()?;
385    let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
386    let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
387
388    for s in sessions {
389        let endpoints = read_session_endpoints(&s.home_dir);
390        let local_eps: Vec<Endpoint> = endpoints
391            .into_iter()
392            .filter(|e| matches!(e.scope, EndpointScope::Local))
393            .collect();
394        if local_eps.is_empty() {
395            federation_only.push(FederationOnlySessionView {
396                name: s.name.clone(),
397                handle: s.handle.clone(),
398                cwd: s.cwd.clone(),
399            });
400            continue;
401        }
402        // Redacted view: drop slot_token before exposing through CLI.
403        let redacted: Vec<LocalEndpointView> = local_eps
404            .iter()
405            .map(|e| LocalEndpointView {
406                relay_url: e.relay_url.clone(),
407                slot_id: e.slot_id.clone(),
408            })
409            .collect();
410        // Group by relay_url. A session with two Local endpoints (rare —
411        // would mean two loopback relays) appears under each.
412        for ep in &local_eps {
413            local
414                .entry(ep.relay_url.clone())
415                .or_default()
416                .push(LocalSessionView {
417                    name: s.name.clone(),
418                    handle: s.handle.clone(),
419                    did: s.did.clone(),
420                    cwd: s.cwd.clone(),
421                    home_dir: s.home_dir.clone(),
422                    daemon_running: s.daemon_running,
423                    local_endpoints: redacted.clone(),
424                });
425        }
426    }
427    // Sort each group by session name so output is deterministic.
428    for group in local.values_mut() {
429        group.sort_by(|a, b| a.name.cmp(&b.name));
430    }
431    federation_only.sort_by(|a, b| a.name.cmp(&b.name));
432    Ok(LocalSessionListing {
433        local,
434        federation_only,
435    })
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn sanitize_handles_unicode_and_long_names() {
444        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
445        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
446        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
447        assert_eq!(sanitize_name(""), "wire-session");
448        assert_eq!(sanitize_name("---"), "wire-session");
449        let long: String = "a".repeat(100);
450        assert_eq!(sanitize_name(&long).len(), 32);
451    }
452
453    #[test]
454    fn derive_name_returns_basename_when_no_collision() {
455        let reg = SessionRegistry::default();
456        assert_eq!(
457            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
458            "wire"
459        );
460        assert_eq!(
461            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
462            "slancha-mesh"
463        );
464    }
465
466    #[test]
467    fn derive_name_returns_stored_name_when_cwd_already_registered() {
468        let mut reg = SessionRegistry::default();
469        reg.by_cwd.insert(
470            "/Users/paul/Source/wire".to_string(),
471            "wire-special".to_string(),
472        );
473        assert_eq!(
474            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
475            "wire-special"
476        );
477    }
478
479    #[test]
480    fn read_session_endpoints_handles_missing_relay_state() {
481        let tmp = tempfile::tempdir().unwrap();
482        // No relay-state.json under <home>/config/wire/ — should yield empty.
483        let endpoints = read_session_endpoints(tmp.path());
484        assert!(endpoints.is_empty());
485    }
486
487    #[test]
488    fn read_session_endpoints_parses_dual_slot_form() {
489        let tmp = tempfile::tempdir().unwrap();
490        let cfg = tmp.path().join("config").join("wire");
491        std::fs::create_dir_all(&cfg).unwrap();
492        let body = serde_json::json!({
493            "self": {
494                "relay_url": "https://wireup.net",
495                "slot_id": "fed-slot",
496                "slot_token": "fed-tok",
497                "endpoints": [
498                    {
499                        "relay_url": "https://wireup.net",
500                        "slot_id": "fed-slot",
501                        "slot_token": "fed-tok",
502                        "scope": "federation"
503                    },
504                    {
505                        "relay_url": "http://127.0.0.1:8771",
506                        "slot_id": "loop-slot",
507                        "slot_token": "loop-tok",
508                        "scope": "local"
509                    }
510                ]
511            }
512        });
513        std::fs::write(cfg.join("relay-state.json"), serde_json::to_vec(&body).unwrap())
514            .unwrap();
515        let endpoints = read_session_endpoints(tmp.path());
516        assert_eq!(endpoints.len(), 2);
517        let local_count = endpoints
518            .iter()
519            .filter(|e| matches!(e.scope, EndpointScope::Local))
520            .count();
521        assert_eq!(local_count, 1);
522        let local = endpoints
523            .iter()
524            .find(|e| matches!(e.scope, EndpointScope::Local))
525            .unwrap();
526        assert_eq!(local.relay_url, "http://127.0.0.1:8771");
527        assert_eq!(local.slot_id, "loop-slot");
528    }
529
530    // NOTE: list_local_sessions is integration-tested via tests/cli.rs
531    // using a subprocess that sets WIRE_HOME per-process. We do not test
532    // it in-module because env mutation races other parallel unit tests
533    // (Rust 2024 marks std::env::set_var unsafe for that reason). The
534    // grouping logic is straightforward enough that the integration
535    // test plus the read_session_endpoints unit tests above provide
536    // adequate coverage.
537
538    #[test]
539    fn derive_name_appends_path_hash_when_basename_collides() {
540        let mut reg = SessionRegistry::default();
541        reg.by_cwd.insert(
542            "/Users/paul/Source/wire".to_string(),
543            "wire".to_string(),
544        );
545        // Different cwd, same basename → must get a hash suffix.
546        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
547        assert!(name.starts_with("wire-"));
548        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
549        assert_ne!(name, "wire");
550    }
551}