use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::endpoints::{Endpoint, EndpointScope, self_endpoints};
pub fn sessions_root() -> Result<PathBuf> {
if let Ok(home_str) = std::env::var("WIRE_HOME") {
let home = PathBuf::from(&home_str);
let direct = home.join("sessions");
if direct.exists() {
return Ok(direct);
}
let mut anc = Some(home.as_path());
while let Some(p) = anc {
if p.file_name().and_then(|s| s.to_str()) == Some("sessions") {
return Ok(p.to_path_buf());
}
anc = p.parent();
}
return Ok(direct);
}
let state = dirs::state_dir()
.or_else(dirs::data_local_dir)
.ok_or_else(|| {
anyhow!(
"could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
set WIRE_HOME or run on a platform with `dirs` support"
)
})?;
Ok(state.join("wire").join("sessions"))
}
pub fn session_dir(name: &str) -> Result<PathBuf> {
Ok(sessions_root()?.join(sanitize_name(name)))
}
pub fn registry_path() -> Result<PathBuf> {
Ok(sessions_root()?.join("registry.json"))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionRegistry {
#[serde(default)]
pub by_cwd: HashMap<String, String>,
}
pub fn read_registry() -> Result<SessionRegistry> {
let path = registry_path()?;
if !path.exists() {
return Ok(SessionRegistry::default());
}
let bytes =
std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
}
pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
let path = registry_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
}
let body = serde_json::to_vec_pretty(reg)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, body).with_context(|| format!("writing tmp session registry {tmp:?}"))?;
std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
Ok(())
}
pub fn update_registry<F>(modifier: F) -> Result<()>
where
F: FnOnce(&mut SessionRegistry) -> Result<()>,
{
use fs2::FileExt;
let path = registry_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
}
let lock_path = path.with_extension("lock");
let lock_file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("opening {lock_path:?}"))?;
lock_file
.lock_exclusive()
.with_context(|| format!("flock {lock_path:?}"))?;
let mut reg = read_registry().unwrap_or_default();
let result = modifier(&mut reg);
let write_result = if result.is_ok() {
write_registry(®)
} else {
Ok(())
};
let _ = fs2::FileExt::unlock(&lock_file);
result?;
write_result?;
Ok(())
}
pub fn sanitize_name(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut prev_dash = false;
for c in raw.chars() {
let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
let ch = if ok { c.to_ascii_lowercase() } else { '-' };
if ch == '-' {
if !prev_dash && !out.is_empty() {
out.push('-');
}
prev_dash = true;
} else {
out.push(ch);
prev_dash = false;
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
return "wire-session".to_string();
}
if trimmed.len() > 32 {
return trimmed[..32].trim_end_matches('-').to_string();
}
trimmed
}
fn path_hash_suffix(cwd: &Path) -> String {
let bytes = cwd.as_os_str().to_string_lossy().into_owned();
let mut h = Sha256::new();
h.update(bytes.as_bytes());
let digest = h.finalize();
hex::encode(&digest[..2]) }
pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
let cwd_key = cwd.to_string_lossy().into_owned();
if let Some(existing) = registry.by_cwd.get(&cwd_key) {
return existing.clone();
}
let base = cwd
.file_name()
.and_then(|s| s.to_str())
.map(sanitize_name)
.unwrap_or_else(|| "wire-session".to_string());
let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
if !occupied.contains(&base) {
return base;
}
let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
if !occupied.contains(&with_hash) {
return with_hash;
}
for n in 2..1000 {
let candidate = format!("{base}-{n}");
if !occupied.contains(&candidate) {
return candidate;
}
}
format!("{base}-{}-overflow", path_hash_suffix(cwd))
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub name: String,
pub cwd: Option<String>,
pub home_dir: PathBuf,
pub did: Option<String>,
pub handle: Option<String>,
pub daemon_running: bool,
pub character: Option<crate::character::Character>,
}
fn url_is_loopback(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
let after_scheme = match lower.split_once("://") {
Some((_, rest)) => rest,
None => lower.as_str(),
};
if let Some(rest) = after_scheme.strip_prefix('[') {
return rest
.split_once(']')
.map(|(host, _)| host == "::1")
.unwrap_or(false);
}
let host = after_scheme.split(['/', ':']).next().unwrap_or("");
host == "localhost" || host == "127.0.0.1" || host.starts_with("127.")
}
pub fn resolve_local_sister(input: &str) -> Option<String> {
let needle = input.trim();
if needle.is_empty() {
return None;
}
let sessions = list_sessions().ok()?;
for s in &sessions {
if s.name.eq_ignore_ascii_case(needle) {
return Some(s.name.clone());
}
if let Some(h) = &s.handle
&& h.eq_ignore_ascii_case(needle)
{
return Some(s.name.clone());
}
if let Some(ch) = &s.character
&& ch.nickname.eq_ignore_ascii_case(needle)
{
return Some(s.name.clone());
}
}
None
}
pub fn list_sessions() -> Result<Vec<SessionInfo>> {
let root = sessions_root()?;
if !root.exists() {
return Ok(Vec::new());
}
let registry = read_registry().unwrap_or_default();
let mut name_to_cwd: HashMap<String, String> = HashMap::new();
for (cwd, name) in ®istry.by_cwd {
name_to_cwd.insert(name.clone(), cwd.clone());
}
let mk = |path: PathBuf, name: String| -> SessionInfo {
let card_path = path.join("config").join("wire").join("agent-card.json");
let (did, handle) = read_card_identity(&card_path);
let daemon_running = check_daemon_live(&path);
let character = did.as_deref().map(crate::character::Character::from_did);
SessionInfo {
cwd: name_to_cwd.get(&name).cloned(),
name,
home_dir: path,
did,
handle,
daemon_running,
character,
}
};
let mut out = Vec::new();
for entry in std::fs::read_dir(&root)?.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if name == "registry.json" {
continue;
}
if name == "by-key" {
for sub in std::fs::read_dir(&path)?.flatten() {
let sub_path = sub.path();
if !sub_path.is_dir() {
continue;
}
let hash = sub_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string();
let mut info = mk(sub_path, hash);
if info.did.is_none() {
continue;
}
if let Some(h) = info.handle.clone() {
info.name = h;
}
out.push(info);
}
continue;
}
out.push(mk(path, name));
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
let bytes = match std::fs::read(card_path) {
Ok(b) => b,
Err(_) => return (None, None),
};
let v: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return (None, None),
};
let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
let handle = v
.get("handle")
.and_then(|x| x.as_str())
.map(str::to_string)
.or_else(|| {
did.as_ref()
.map(|d| crate::agent_card::display_handle_from_did(d).to_string())
});
(did, handle)
}
pub fn session_daemon_pid(session_home: &Path) -> Option<u32> {
let pidfile = session_home.join("state").join("wire").join("daemon.pid");
let bytes = std::fs::read(&pidfile).ok()?;
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
} else {
String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
}
}
fn check_daemon_live(session_home: &Path) -> bool {
session_daemon_pid(session_home)
.map(is_process_live)
.unwrap_or(false)
}
fn is_process_live(pid: u32) -> bool {
crate::platform::process_alive(pid)
}
pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
let path = session_home.join("config").join("wire").join("relay.json");
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let val: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
self_endpoints(&val)
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalEndpointView {
pub relay_url: String,
pub slot_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalSessionView {
pub name: String,
pub handle: Option<String>,
pub did: Option<String>,
pub cwd: Option<String>,
pub home_dir: PathBuf,
pub daemon_running: bool,
pub local_endpoints: Vec<LocalEndpointView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FederationOnlySessionView {
pub name: String,
pub handle: Option<String>,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalSessionListing {
pub local: HashMap<String, Vec<LocalSessionView>>,
pub federation_only: Vec<FederationOnlySessionView>,
}
pub fn list_local_sessions() -> Result<LocalSessionListing> {
let sessions = list_sessions()?;
let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
for s in sessions {
let endpoints = read_session_endpoints(&s.home_dir);
let local_eps: Vec<Endpoint> = endpoints
.into_iter()
.filter(|e| {
matches!(e.scope, EndpointScope::Local)
|| (matches!(e.scope, EndpointScope::Federation)
&& url_is_loopback(&e.relay_url))
})
.collect();
if local_eps.is_empty() {
federation_only.push(FederationOnlySessionView {
name: s.name.clone(),
handle: s.handle.clone(),
cwd: s.cwd.clone(),
});
continue;
}
let redacted: Vec<LocalEndpointView> = local_eps
.iter()
.map(|e| LocalEndpointView {
relay_url: e.relay_url.clone(),
slot_id: e.slot_id.clone(),
})
.collect();
for ep in &local_eps {
local
.entry(ep.relay_url.clone())
.or_default()
.push(LocalSessionView {
name: s.name.clone(),
handle: s.handle.clone(),
did: s.did.clone(),
cwd: s.cwd.clone(),
home_dir: s.home_dir.clone(),
daemon_running: s.daemon_running,
local_endpoints: redacted.clone(),
});
}
}
for group in local.values_mut() {
group.sort_by(|a, b| a.name.cmp(&b.name));
}
federation_only.sort_by(|a, b| a.name.cmp(&b.name));
Ok(LocalSessionListing {
local,
federation_only,
})
}
pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
let registry = read_registry().ok()?;
let mut probe: Option<&std::path::Path> = Some(cwd);
while let Some(path) = probe {
let path_str = path.to_string_lossy().into_owned();
if let Some(session_name) = registry.by_cwd.get(&path_str) {
let session_home = session_dir(session_name).ok()?;
if session_home.exists() {
return Some(session_home);
}
}
probe = path.parent();
}
None
}
pub fn resolve_session_key() -> Option<(String, &'static str)> {
for (var, source) in [
("WIRE_SESSION_ID", "override"),
("CLAUDE_CODE_SESSION_ID", "claude-code"),
] {
if let Ok(v) = std::env::var(var) {
let v = v.trim();
if !v.is_empty() {
return Some((v.to_string(), source));
}
}
}
None
}
pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
let mut h = Sha256::new();
h.update(key.as_bytes());
let digest = h.finalize();
let hash = hex::encode(&digest[..8]); Ok(sessions_root()?.join("by-key").join(hash))
}
pub fn warn_on_identity_collision(self_pid: u32) {
let our_wire_home = match std::env::var("WIRE_HOME") {
Ok(h) => h,
Err(_) => return,
};
let pgrep_out = match std::process::Command::new("pgrep")
.args(["-f", "wire mcp"])
.output()
{
Ok(o) if o.status.success() => o,
_ => return,
};
let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
.split_whitespace()
.filter_map(|s| s.parse::<u32>().ok())
.filter(|&p| p != self_pid)
.collect();
let mut colliders: Vec<u32> = Vec::new();
for pid in &other_pids {
if let Some(their_home) = read_wire_home_from_pid(*pid)
&& their_home == our_wire_home
{
colliders.push(*pid);
}
}
if colliders.is_empty() {
return;
}
eprintln!(
"wire mcp: WARNING — {} other wire mcp process(es) already using WIRE_HOME=`{}` (pid {})",
colliders.len(),
our_wire_home,
colliders
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ")
);
eprintln!(
" Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
);
eprintln!(" To use a separate identity:");
eprintln!(" 1. Close the other agent(s), OR");
eprintln!(" 2. `wire session new <name> --local-only` to create a fresh identity, then");
eprintln!(
" 3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
);
}
fn read_wire_home_from_pid(pid: u32) -> Option<String> {
#[cfg(target_os = "linux")]
{
let path = format!("/proc/{pid}/environ");
let bytes = std::fs::read(&path).ok()?;
for entry in bytes.split(|&b| b == 0) {
let s = match std::str::from_utf8(entry) {
Ok(s) => s,
Err(_) => continue,
};
if let Some(val) = s.strip_prefix("WIRE_HOME=") {
return Some(val.to_string());
}
}
None
}
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("ps")
.args(["-E", "-p", &pid.to_string(), "-o", "command="])
.output()
.ok()?;
let s = String::from_utf8_lossy(&output.stdout);
for tok in s.split_whitespace() {
if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
return Some(val.to_string());
}
}
None
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = pid;
None
}
}
pub fn maybe_adopt_session_wire_home(label: &str) {
if std::env::var("WIRE_HOME").is_ok() {
return;
}
let (home, why) = if let Some((key, source)) = resolve_session_key() {
match session_home_for_key(&key) {
Ok(h) => {
(h, format!("session key ({source})"))
}
Err(_) => return,
}
} else if label == "mcp" {
let minted = format!(
"mcp-proc-{:016x}{:016x}",
rand::random::<u64>(),
rand::random::<u64>()
);
match session_home_for_key(&minted) {
Ok(h) => {
unsafe {
std::env::set_var("WIRE_SESSION_ID", &minted);
}
(
h,
"minted per-process key (no session id; cwd disabled for MCP)".to_string(),
)
}
Err(_) => return,
}
} else {
return;
};
use std::io::IsTerminal;
let quiet_env = std::env::var("WIRE_QUIET_AUTOSESSION").is_ok();
let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
let interactive = std::io::stderr().is_terminal();
if !quiet_env && (interactive || verbose_env) {
eprintln!(
"wire {label}: adopted {why} → WIRE_HOME=`{}`",
home.display()
);
}
unsafe {
std::env::set_var("WIRE_HOME", &home);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("sessions");
let home = root.join("by-key").join("abc123def4567890");
let cfg = home.join("config").join("wire");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(
cfg.join("agent-card.json"),
r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
)
.unwrap();
unsafe { std::env::set_var("WIRE_HOME", &home) };
assert_eq!(
sessions_root().unwrap(),
root,
"sessions_root must resolve the root from inside a by-key home"
);
let sessions = list_sessions().unwrap();
let found = sessions
.iter()
.any(|s| s.handle.as_deref() == Some("test-persona"));
unsafe { std::env::remove_var("WIRE_HOME") };
let _ = std::fs::remove_dir_all(&tmp);
assert!(
found,
"by-key home must be enumerated: {:?}",
sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
);
}
#[test]
fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let a1 = session_home_for_key("sess-aaa").unwrap();
let a2 = session_home_for_key("sess-aaa").unwrap();
let b = session_home_for_key("sess-bbb").unwrap();
assert_eq!(a1, a2, "same key -> same home (resume stability)");
assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
let leaf = a1.file_name().unwrap().to_str().unwrap();
assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
"by-key"
);
}
#[test]
fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
assert!(url_is_loopback("http://127.0.0.1:8771"));
assert!(url_is_loopback("http://127.1.2.3"));
assert!(url_is_loopback("http://localhost:9000"));
assert!(url_is_loopback("https://localhost/v1"));
assert!(url_is_loopback("http://[::1]:8771"));
assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
assert!(!url_is_loopback("https://wireup.net"));
assert!(!url_is_loopback("http://192.168.1.50:8771"));
assert!(!url_is_loopback("http://10.0.0.5"));
assert!(!url_is_loopback("https://relay.example.com"));
}
#[test]
fn sanitize_handles_unicode_and_long_names() {
assert_eq!(sanitize_name("paul-mac"), "paul-mac");
assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
assert_eq!(sanitize_name("---"), "wire-session");
let long: String = "a".repeat(100);
assert_eq!(sanitize_name(&long).len(), 32);
}
#[test]
fn derive_name_returns_basename_when_no_collision() {
let reg = SessionRegistry::default();
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
"wire"
);
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
"slancha-mesh"
);
}
#[test]
fn derive_name_returns_stored_name_when_cwd_already_registered() {
let mut reg = SessionRegistry::default();
reg.by_cwd.insert(
"/Users/paul/Source/wire".to_string(),
"wire-special".to_string(),
);
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
"wire-special"
);
}
#[test]
fn read_session_endpoints_handles_missing_relay_state() {
let tmp = tempfile::tempdir().unwrap();
let endpoints = read_session_endpoints(tmp.path());
assert!(endpoints.is_empty());
}
#[test]
fn read_session_endpoints_parses_dual_slot_form() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("config").join("wire");
std::fs::create_dir_all(&cfg).unwrap();
let body = serde_json::json!({
"self": {
"relay_url": "https://wireup.net",
"slot_id": "fed-slot",
"slot_token": "fed-tok",
"endpoints": [
{
"relay_url": "https://wireup.net",
"slot_id": "fed-slot",
"slot_token": "fed-tok",
"scope": "federation"
},
{
"relay_url": "http://127.0.0.1:8771",
"slot_id": "loop-slot",
"slot_token": "loop-tok",
"scope": "local"
}
]
}
});
std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
let endpoints = read_session_endpoints(tmp.path());
assert_eq!(endpoints.len(), 2);
let local_count = endpoints
.iter()
.filter(|e| matches!(e.scope, EndpointScope::Local))
.count();
assert_eq!(local_count, 1);
let local = endpoints
.iter()
.find(|e| matches!(e.scope, EndpointScope::Local))
.unwrap();
assert_eq!(local.relay_url, "http://127.0.0.1:8771");
assert_eq!(local.slot_id, "loop-slot");
}
#[test]
fn derive_name_appends_path_hash_when_basename_collides() {
let mut reg = SessionRegistry::default();
reg.by_cwd
.insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
assert!(name.starts_with("wire-"));
assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
}
}