1use 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
39pub 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 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
88pub fn session_dir(name: &str) -> Result<PathBuf> {
92 Ok(sessions_root()?.join(sanitize_name(name)))
93}
94
95pub 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 #[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 std::fs::write(&path, body).with_context(|| format!("writing session registry {path:?}"))?;
127 Ok(())
128}
129
130pub fn sanitize_name(raw: &str) -> String {
134 let mut out = String::with_capacity(raw.len());
135 let mut prev_dash = false;
136 for c in raw.chars() {
137 let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
138 let ch = if ok { c.to_ascii_lowercase() } else { '-' };
139 if ch == '-' {
140 if !prev_dash && !out.is_empty() {
141 out.push('-');
142 }
143 prev_dash = true;
144 } else {
145 out.push(ch);
146 prev_dash = false;
147 }
148 }
149 let trimmed = out.trim_matches('-').to_string();
150 if trimmed.is_empty() {
151 return "wire-session".to_string();
152 }
153 if trimmed.len() > 32 {
154 return trimmed[..32].trim_end_matches('-').to_string();
155 }
156 trimmed
157}
158
159fn path_hash_suffix(cwd: &Path) -> String {
163 let bytes = cwd.as_os_str().to_string_lossy().into_owned();
164 let mut h = Sha256::new();
165 h.update(bytes.as_bytes());
166 let digest = h.finalize();
167 hex::encode(&digest[..2]) }
169
170pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
179 let cwd_key = cwd.to_string_lossy().into_owned();
180 if let Some(existing) = registry.by_cwd.get(&cwd_key) {
181 return existing.clone();
182 }
183 let base = cwd
184 .file_name()
185 .and_then(|s| s.to_str())
186 .map(sanitize_name)
187 .unwrap_or_else(|| "wire-session".to_string());
188 let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
189 if !occupied.contains(&base) {
190 return base;
191 }
192 let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
193 if !occupied.contains(&with_hash) {
194 return with_hash;
195 }
196 for n in 2..1000 {
199 let candidate = format!("{base}-{n}");
200 if !occupied.contains(&candidate) {
201 return candidate;
202 }
203 }
204 format!("{base}-{}-overflow", path_hash_suffix(cwd))
206}
207
208#[derive(Debug, Clone, Serialize)]
210pub struct SessionInfo {
211 pub name: String,
212 pub cwd: Option<String>,
216 pub home_dir: PathBuf,
217 pub did: Option<String>,
218 pub handle: Option<String>,
219 pub daemon_running: bool,
223}
224
225pub fn list_sessions() -> Result<Vec<SessionInfo>> {
228 let root = sessions_root()?;
229 if !root.exists() {
230 return Ok(Vec::new());
231 }
232 let registry = read_registry().unwrap_or_default();
233 let mut name_to_cwd: HashMap<String, String> = HashMap::new();
235 for (cwd, name) in ®istry.by_cwd {
236 name_to_cwd.insert(name.clone(), cwd.clone());
237 }
238
239 let mut out = Vec::new();
240 for entry in std::fs::read_dir(&root)?.flatten() {
241 let path = entry.path();
242 if !path.is_dir() {
243 continue;
244 }
245 let name = match path.file_name().and_then(|s| s.to_str()) {
246 Some(s) => s.to_string(),
247 None => continue,
248 };
249 if name == "registry.json" {
251 continue;
252 }
253 let card_path = path.join("config").join("wire").join("agent-card.json");
254 let (did, handle) = read_card_identity(&card_path);
255 let daemon_running = check_daemon_live(&path);
256 out.push(SessionInfo {
257 name: name.clone(),
258 cwd: name_to_cwd.get(&name).cloned(),
259 home_dir: path,
260 did,
261 handle,
262 daemon_running,
263 });
264 }
265 out.sort_by(|a, b| a.name.cmp(&b.name));
266 Ok(out)
267}
268
269fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
270 let bytes = match std::fs::read(card_path) {
271 Ok(b) => b,
272 Err(_) => return (None, None),
273 };
274 let v: serde_json::Value = match serde_json::from_slice(&bytes) {
275 Ok(v) => v,
276 Err(_) => return (None, None),
277 };
278 let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
279 let handle = v
280 .get("handle")
281 .and_then(|x| x.as_str())
282 .map(str::to_string)
283 .or_else(|| {
284 did.as_ref()
285 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
286 });
287 (did, handle)
288}
289
290fn check_daemon_live(session_home: &Path) -> bool {
291 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
296 let bytes = match std::fs::read(&pidfile) {
297 Ok(b) => b,
298 Err(_) => return false,
299 };
300 let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
302 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
303 } else {
304 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
306 };
307 let pid = match pid_opt {
308 Some(p) => p,
309 None => return false,
310 };
311 is_process_live(pid)
312}
313
314fn is_process_live(pid: u32) -> bool {
315 #[cfg(target_os = "linux")]
316 {
317 std::path::Path::new(&format!("/proc/{pid}")).exists()
318 }
319 #[cfg(not(target_os = "linux"))]
320 {
321 std::process::Command::new("kill")
322 .args(["-0", &pid.to_string()])
323 .output()
324 .map(|o| o.status.success())
325 .unwrap_or(false)
326 }
327}
328
329pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
344 let path = session_home.join("config").join("wire").join("relay.json");
345 let bytes = match std::fs::read(&path) {
346 Ok(b) => b,
347 Err(_) => return Vec::new(),
348 };
349 let val: Value = match serde_json::from_slice(&bytes) {
350 Ok(v) => v,
351 Err(_) => return Vec::new(),
352 };
353 self_endpoints(&val)
354}
355
356#[derive(Debug, Clone, Serialize)]
363pub struct LocalEndpointView {
364 pub relay_url: String,
365 pub slot_id: String,
366}
367
368#[derive(Debug, Clone, Serialize)]
371pub struct LocalSessionView {
372 pub name: String,
373 pub handle: Option<String>,
374 pub did: Option<String>,
375 pub cwd: Option<String>,
376 pub home_dir: PathBuf,
377 pub daemon_running: bool,
378 pub local_endpoints: Vec<LocalEndpointView>,
382}
383
384#[derive(Debug, Clone, Serialize)]
387pub struct FederationOnlySessionView {
388 pub name: String,
389 pub handle: Option<String>,
390 pub cwd: Option<String>,
391}
392
393#[derive(Debug, Clone, Serialize)]
397pub struct LocalSessionListing {
398 pub local: HashMap<String, Vec<LocalSessionView>>,
399 pub federation_only: Vec<FederationOnlySessionView>,
400}
401
402pub fn list_local_sessions() -> Result<LocalSessionListing> {
405 let sessions = list_sessions()?;
406 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
407 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
408
409 for s in sessions {
410 let endpoints = read_session_endpoints(&s.home_dir);
411 let local_eps: Vec<Endpoint> = endpoints
412 .into_iter()
413 .filter(|e| matches!(e.scope, EndpointScope::Local))
414 .collect();
415 if local_eps.is_empty() {
416 federation_only.push(FederationOnlySessionView {
417 name: s.name.clone(),
418 handle: s.handle.clone(),
419 cwd: s.cwd.clone(),
420 });
421 continue;
422 }
423 let redacted: Vec<LocalEndpointView> = local_eps
425 .iter()
426 .map(|e| LocalEndpointView {
427 relay_url: e.relay_url.clone(),
428 slot_id: e.slot_id.clone(),
429 })
430 .collect();
431 for ep in &local_eps {
434 local
435 .entry(ep.relay_url.clone())
436 .or_default()
437 .push(LocalSessionView {
438 name: s.name.clone(),
439 handle: s.handle.clone(),
440 did: s.did.clone(),
441 cwd: s.cwd.clone(),
442 home_dir: s.home_dir.clone(),
443 daemon_running: s.daemon_running,
444 local_endpoints: redacted.clone(),
445 });
446 }
447 }
448 for group in local.values_mut() {
450 group.sort_by(|a, b| a.name.cmp(&b.name));
451 }
452 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
453 Ok(LocalSessionListing {
454 local,
455 federation_only,
456 })
457}
458
459pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
473 let registry = read_registry().ok()?;
474 let cwd_str = cwd.to_string_lossy().into_owned();
475 let session_name = registry.by_cwd.get(&cwd_str)?;
476 let session_home = session_dir(session_name).ok()?;
477 if !session_home.exists() {
478 return None;
479 }
480 Some(session_home)
481}
482
483pub fn warn_on_identity_collision(self_pid: u32) {
497 let our_wire_home = match std::env::var("WIRE_HOME") {
498 Ok(h) => h,
499 Err(_) => return,
500 };
501
502 let pgrep_out = match std::process::Command::new("pgrep")
503 .args(["-f", "wire mcp"])
504 .output()
505 {
506 Ok(o) if o.status.success() => o,
507 _ => return,
508 };
509
510 let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
511 .split_whitespace()
512 .filter_map(|s| s.parse::<u32>().ok())
513 .filter(|&p| p != self_pid)
514 .collect();
515
516 let mut colliders: Vec<u32> = Vec::new();
517 for pid in &other_pids {
518 if let Some(their_home) = read_wire_home_from_pid(*pid)
519 && their_home == our_wire_home
520 {
521 colliders.push(*pid);
522 }
523 }
524
525 if colliders.is_empty() {
526 return;
527 }
528
529 eprintln!(
530 "wire mcp: WARNING — {} other wire mcp process(es) already using WIRE_HOME=`{}` (pid {})",
531 colliders.len(),
532 our_wire_home,
533 colliders
534 .iter()
535 .map(|p| p.to_string())
536 .collect::<Vec<_>>()
537 .join(", ")
538 );
539 eprintln!(
540 " Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
541 );
542 eprintln!(" To use a separate identity:");
543 eprintln!(" 1. Close the other agent(s), OR");
544 eprintln!(" 2. `wire session new <name> --local-only` to create a fresh identity, then");
545 eprintln!(
546 " 3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
547 );
548}
549
550fn read_wire_home_from_pid(pid: u32) -> Option<String> {
555 #[cfg(target_os = "linux")]
556 {
557 let path = format!("/proc/{pid}/environ");
558 let bytes = std::fs::read(&path).ok()?;
559 for entry in bytes.split(|&b| b == 0) {
560 let s = match std::str::from_utf8(entry) {
561 Ok(s) => s,
562 Err(_) => continue,
563 };
564 if let Some(val) = s.strip_prefix("WIRE_HOME=") {
565 return Some(val.to_string());
566 }
567 }
568 None
569 }
570
571 #[cfg(target_os = "macos")]
572 {
573 let output = std::process::Command::new("ps")
574 .args(["-E", "-p", &pid.to_string(), "-o", "command="])
575 .output()
576 .ok()?;
577 let s = String::from_utf8_lossy(&output.stdout);
578 for tok in s.split_whitespace() {
579 if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
580 return Some(val.to_string());
581 }
582 }
583 None
584 }
585
586 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
587 {
588 let _ = pid;
589 None
590 }
591}
592
593pub fn maybe_adopt_session_wire_home(label: &str) {
608 if std::env::var("WIRE_HOME").is_ok() {
609 return;
610 }
611 let cwd = match std::env::current_dir() {
612 Ok(c) => c,
613 Err(_) => return,
614 };
615 let home = match detect_session_wire_home(&cwd) {
616 Some(h) => h,
617 None => return,
618 };
619 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
620 eprintln!(
621 "wire {label}: auto-detected session for cwd `{}` → WIRE_HOME=`{}`",
622 cwd.display(),
623 home.display()
624 );
625 }
626 unsafe {
630 std::env::set_var("WIRE_HOME", &home);
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn sanitize_handles_unicode_and_long_names() {
640 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
641 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
642 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
644 assert_eq!(sanitize_name("---"), "wire-session");
645 let long: String = "a".repeat(100);
646 assert_eq!(sanitize_name(&long).len(), 32);
647 }
648
649 #[test]
650 fn derive_name_returns_basename_when_no_collision() {
651 let reg = SessionRegistry::default();
652 assert_eq!(
653 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
654 "wire"
655 );
656 assert_eq!(
657 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
658 "slancha-mesh"
659 );
660 }
661
662 #[test]
663 fn derive_name_returns_stored_name_when_cwd_already_registered() {
664 let mut reg = SessionRegistry::default();
665 reg.by_cwd.insert(
666 "/Users/paul/Source/wire".to_string(),
667 "wire-special".to_string(),
668 );
669 assert_eq!(
670 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
671 "wire-special"
672 );
673 }
674
675 #[test]
676 fn read_session_endpoints_handles_missing_relay_state() {
677 let tmp = tempfile::tempdir().unwrap();
678 let endpoints = read_session_endpoints(tmp.path());
680 assert!(endpoints.is_empty());
681 }
682
683 #[test]
684 fn read_session_endpoints_parses_dual_slot_form() {
685 let tmp = tempfile::tempdir().unwrap();
686 let cfg = tmp.path().join("config").join("wire");
687 std::fs::create_dir_all(&cfg).unwrap();
688 let body = serde_json::json!({
689 "self": {
690 "relay_url": "https://wireup.net",
691 "slot_id": "fed-slot",
692 "slot_token": "fed-tok",
693 "endpoints": [
694 {
695 "relay_url": "https://wireup.net",
696 "slot_id": "fed-slot",
697 "slot_token": "fed-tok",
698 "scope": "federation"
699 },
700 {
701 "relay_url": "http://127.0.0.1:8771",
702 "slot_id": "loop-slot",
703 "slot_token": "loop-tok",
704 "scope": "local"
705 }
706 ]
707 }
708 });
709 std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
710 let endpoints = read_session_endpoints(tmp.path());
711 assert_eq!(endpoints.len(), 2);
712 let local_count = endpoints
713 .iter()
714 .filter(|e| matches!(e.scope, EndpointScope::Local))
715 .count();
716 assert_eq!(local_count, 1);
717 let local = endpoints
718 .iter()
719 .find(|e| matches!(e.scope, EndpointScope::Local))
720 .unwrap();
721 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
722 assert_eq!(local.slot_id, "loop-slot");
723 }
724
725 #[test]
734 fn derive_name_appends_path_hash_when_basename_collides() {
735 let mut reg = SessionRegistry::default();
736 reg.by_cwd
737 .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
738 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
740 assert!(name.starts_with("wire-"));
741 assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
743 }
744}