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 sha2::{Digest, Sha256};
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36/// Root directory under which all session WIRE_HOMEs live.
37///
38/// Honors `WIRE_HOME` for testing (sessions root becomes
39/// `$WIRE_HOME/sessions/`); otherwise lives at
40/// `$XDG_STATE_HOME/wire/sessions/`.
41pub fn sessions_root() -> Result<PathBuf> {
42    if let Ok(home) = std::env::var("WIRE_HOME") {
43        return Ok(PathBuf::from(home).join("sessions"));
44    }
45    let state = dirs::state_dir()
46        .ok_or_else(|| anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?;
47    Ok(state.join("wire").join("sessions"))
48}
49
50/// Full filesystem path for the named session's WIRE_HOME root.
51/// Inside this dir the standard wire layout applies: `config/wire/...`
52/// and `state/wire/...`.
53pub fn session_dir(name: &str) -> Result<PathBuf> {
54    Ok(sessions_root()?.join(sanitize_name(name)))
55}
56
57/// Registry tracks `cwd → session_name` so repeated `wire session new`
58/// from the same project reuses the same identity instead of creating
59/// a fresh one each time. Lives at `<sessions_root>/registry.json`.
60pub fn registry_path() -> Result<PathBuf> {
61    Ok(sessions_root()?.join("registry.json"))
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct SessionRegistry {
66    /// `cwd_absolute_path → session_name`. Absent if cwd has not been
67    /// associated with a session yet.
68    #[serde(default)]
69    pub by_cwd: HashMap<String, String>,
70}
71
72pub fn read_registry() -> Result<SessionRegistry> {
73    let path = registry_path()?;
74    if !path.exists() {
75        return Ok(SessionRegistry::default());
76    }
77    let bytes = std::fs::read(&path)
78        .with_context(|| format!("reading session registry {path:?}"))?;
79    serde_json::from_slice(&bytes)
80        .with_context(|| format!("parsing session registry {path:?}"))
81}
82
83pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
84    let path = registry_path()?;
85    if let Some(parent) = path.parent() {
86        std::fs::create_dir_all(parent)
87            .with_context(|| format!("creating {parent:?}"))?;
88    }
89    let body = serde_json::to_vec_pretty(reg)?;
90    std::fs::write(&path, body)
91        .with_context(|| format!("writing session registry {path:?}"))?;
92    Ok(())
93}
94
95/// Sanitize an arbitrary string to a session-name-safe form: lowercase
96/// ASCII alphanumeric + `-` + `_`, replace other chars with `-`,
97/// dedupe consecutive dashes, trim leading/trailing dashes, max 32 chars.
98pub fn sanitize_name(raw: &str) -> String {
99    let mut out = String::with_capacity(raw.len());
100    let mut prev_dash = false;
101    for c in raw.chars() {
102        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
103        let ch = if ok { c.to_ascii_lowercase() } else { '-' };
104        if ch == '-' {
105            if !prev_dash && !out.is_empty() {
106                out.push('-');
107            }
108            prev_dash = true;
109        } else {
110            out.push(ch);
111            prev_dash = false;
112        }
113    }
114    let trimmed = out.trim_matches('-').to_string();
115    if trimmed.is_empty() {
116        return "wire-session".to_string();
117    }
118    if trimmed.len() > 32 {
119        return trimmed[..32].trim_end_matches('-').to_string();
120    }
121    trimmed
122}
123
124/// Short hash suffix derived from the full absolute path of the cwd.
125/// Used to disambiguate two different projects whose basenames collide
126/// (e.g. `~/Source/wire` and `~/Archive/wire`).
127fn path_hash_suffix(cwd: &Path) -> String {
128    let bytes = cwd.as_os_str().to_string_lossy().into_owned();
129    let mut h = Sha256::new();
130    h.update(bytes.as_bytes());
131    let digest = h.finalize();
132    hex::encode(&digest[..2]) // 4 hex chars
133}
134
135/// Derive a stable session name for the given cwd. Resolution order:
136///
137/// 1. If the registry already maps this cwd → name, return that name.
138/// 2. Else: candidate = sanitize(basename(cwd)). If the candidate is
139///    already mapped to a DIFFERENT cwd in the registry, append a
140///    4-char path-hash suffix to avoid collision.
141/// 3. If still a collision: append a numeric suffix `-2`, `-3`, ...
142///    until unique.
143pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
144    let cwd_key = cwd.to_string_lossy().into_owned();
145    if let Some(existing) = registry.by_cwd.get(&cwd_key) {
146        return existing.clone();
147    }
148    let base = cwd
149        .file_name()
150        .and_then(|s| s.to_str())
151        .map(sanitize_name)
152        .unwrap_or_else(|| "wire-session".to_string());
153    let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
154    if !occupied.contains(&base) {
155        return base;
156    }
157    let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
158    if !occupied.contains(&with_hash) {
159        return with_hash;
160    }
161    // Highly unlikely (would require a SHA-256 prefix collision plus an
162    // existing entry to claim it). Numeric tiebreaker as final fallback.
163    for n in 2..1000 {
164        let candidate = format!("{base}-{n}");
165        if !occupied.contains(&candidate) {
166            return candidate;
167        }
168    }
169    // Pathological fallback — every numbered slot is taken.
170    format!("{base}-{}-overflow", path_hash_suffix(cwd))
171}
172
173/// Summary of one on-disk session for `wire session list`.
174#[derive(Debug, Clone, Serialize)]
175pub struct SessionInfo {
176    pub name: String,
177    /// First cwd associated with this session in the registry. `None`
178    /// if the session was created without registry tracking (manual
179    /// `wire session new <name>`).
180    pub cwd: Option<String>,
181    pub home_dir: PathBuf,
182    pub did: Option<String>,
183    pub handle: Option<String>,
184    /// True if a `daemon.pid` file exists AND the recorded PID is
185    /// actually a live process (best-effort, not POSIX-portable but
186    /// matches the existing `wire status` / `wire doctor` checks).
187    pub daemon_running: bool,
188}
189
190/// Enumerate every on-disk session by reading `sessions_root()`. Cross-
191/// references the registry so each entry's `cwd` is filled in when known.
192pub fn list_sessions() -> Result<Vec<SessionInfo>> {
193    let root = sessions_root()?;
194    if !root.exists() {
195        return Ok(Vec::new());
196    }
197    let registry = read_registry().unwrap_or_default();
198    // Reverse lookup: name → cwd. Used to annotate each SessionInfo.
199    let mut name_to_cwd: HashMap<String, String> = HashMap::new();
200    for (cwd, name) in &registry.by_cwd {
201        name_to_cwd.insert(name.clone(), cwd.clone());
202    }
203
204    let mut out = Vec::new();
205    for entry in std::fs::read_dir(&root)?.flatten() {
206        let path = entry.path();
207        if !path.is_dir() {
208            continue;
209        }
210        let name = match path.file_name().and_then(|s| s.to_str()) {
211            Some(s) => s.to_string(),
212            None => continue,
213        };
214        // Skip the registry sidecar.
215        if name == "registry.json" {
216            continue;
217        }
218        let card_path = path.join("config").join("wire").join("agent-card.json");
219        let (did, handle) = read_card_identity(&card_path);
220        let daemon_running = check_daemon_live(&path);
221        out.push(SessionInfo {
222            name: name.clone(),
223            cwd: name_to_cwd.get(&name).cloned(),
224            home_dir: path,
225            did,
226            handle,
227            daemon_running,
228        });
229    }
230    out.sort_by(|a, b| a.name.cmp(&b.name));
231    Ok(out)
232}
233
234fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
235    let bytes = match std::fs::read(card_path) {
236        Ok(b) => b,
237        Err(_) => return (None, None),
238    };
239    let v: serde_json::Value = match serde_json::from_slice(&bytes) {
240        Ok(v) => v,
241        Err(_) => return (None, None),
242    };
243    let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
244    let handle = v
245        .get("handle")
246        .and_then(|x| x.as_str())
247        .map(str::to_string)
248        .or_else(|| {
249            did.as_ref().map(|d| {
250                crate::agent_card::display_handle_from_did(d).to_string()
251            })
252        });
253    (did, handle)
254}
255
256fn check_daemon_live(session_home: &Path) -> bool {
257    // Pidfile lives at <session_home>/state/wire/daemon.pid. Use the
258    // existing ensure_up reader by temporarily pointing at the path; we
259    // can't change env mid-process race-free, so re-implement the pid
260    // extraction directly here from the JSON structure.
261    let pidfile = session_home
262        .join("state")
263        .join("wire")
264        .join("daemon.pid");
265    let bytes = match std::fs::read(&pidfile) {
266        Ok(b) => b,
267        Err(_) => return false,
268    };
269    // Try the structured form first.
270    let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
271        v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
272    } else {
273        // Legacy integer form.
274        String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
275    };
276    let pid = match pid_opt {
277        Some(p) => p,
278        None => return false,
279    };
280    is_process_live(pid)
281}
282
283fn is_process_live(pid: u32) -> bool {
284    #[cfg(target_os = "linux")]
285    {
286        std::path::Path::new(&format!("/proc/{pid}")).exists()
287    }
288    #[cfg(not(target_os = "linux"))]
289    {
290        std::process::Command::new("kill")
291            .args(["-0", &pid.to_string()])
292            .output()
293            .map(|o| o.status.success())
294            .unwrap_or(false)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn sanitize_handles_unicode_and_long_names() {
304        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
305        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
306        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
307        assert_eq!(sanitize_name(""), "wire-session");
308        assert_eq!(sanitize_name("---"), "wire-session");
309        let long: String = "a".repeat(100);
310        assert_eq!(sanitize_name(&long).len(), 32);
311    }
312
313    #[test]
314    fn derive_name_returns_basename_when_no_collision() {
315        let reg = SessionRegistry::default();
316        assert_eq!(
317            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
318            "wire"
319        );
320        assert_eq!(
321            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
322            "slancha-mesh"
323        );
324    }
325
326    #[test]
327    fn derive_name_returns_stored_name_when_cwd_already_registered() {
328        let mut reg = SessionRegistry::default();
329        reg.by_cwd.insert(
330            "/Users/paul/Source/wire".to_string(),
331            "wire-special".to_string(),
332        );
333        assert_eq!(
334            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
335            "wire-special"
336        );
337    }
338
339    #[test]
340    fn derive_name_appends_path_hash_when_basename_collides() {
341        let mut reg = SessionRegistry::default();
342        reg.by_cwd.insert(
343            "/Users/paul/Source/wire".to_string(),
344            "wire".to_string(),
345        );
346        // Different cwd, same basename → must get a hash suffix.
347        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
348        assert!(name.starts_with("wire-"));
349        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
350        assert_ne!(name, "wire");
351    }
352}