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 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
138pub 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 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
183pub 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
212fn 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]) }
222
223pub 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 for n in 2..1000 {
252 let candidate = format!("{base}-{n}");
253 if !occupied.contains(&candidate) {
254 return candidate;
255 }
256 }
257 format!("{base}-{}-overflow", path_hash_suffix(cwd))
259}
260
261#[derive(Debug, Clone, Serialize)]
263pub struct SessionInfo {
264 pub name: String,
265 pub cwd: Option<String>,
269 pub home_dir: PathBuf,
270 pub did: Option<String>,
271 pub handle: Option<String>,
272 pub daemon_running: bool,
276 pub character: Option<crate::character::Character>,
280}
281
282pub fn list_sessions() -> Result<Vec<SessionInfo>> {
285 let root = sessions_root()?;
286 if !root.exists() {
287 return Ok(Vec::new());
288 }
289 let registry = read_registry().unwrap_or_default();
290 let mut name_to_cwd: HashMap<String, String> = HashMap::new();
292 for (cwd, name) in ®istry.by_cwd {
293 name_to_cwd.insert(name.clone(), cwd.clone());
294 }
295
296 let mut out = Vec::new();
297 for entry in std::fs::read_dir(&root)?.flatten() {
298 let path = entry.path();
299 if !path.is_dir() {
300 continue;
301 }
302 let name = match path.file_name().and_then(|s| s.to_str()) {
303 Some(s) => s.to_string(),
304 None => continue,
305 };
306 if name == "registry.json" {
308 continue;
309 }
310 let card_path = path.join("config").join("wire").join("agent-card.json");
311 let (did, handle) = read_card_identity(&card_path);
312 let daemon_running = check_daemon_live(&path);
313 let display_overrides_path = path.join("config").join("wire").join("display.json");
316 let overrides =
317 crate::config::read_display_overrides_at(&display_overrides_path).unwrap_or_default();
318 let character = did.as_deref().map(|d| {
319 crate::character::Character::from_did_with_override(
320 d,
321 overrides.nickname.as_deref(),
322 overrides.emoji.as_deref(),
323 )
324 });
325 out.push(SessionInfo {
326 name: name.clone(),
327 cwd: name_to_cwd.get(&name).cloned(),
328 home_dir: path,
329 did,
330 handle,
331 daemon_running,
332 character,
333 });
334 }
335 out.sort_by(|a, b| a.name.cmp(&b.name));
336 Ok(out)
337}
338
339fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
340 let bytes = match std::fs::read(card_path) {
341 Ok(b) => b,
342 Err(_) => return (None, None),
343 };
344 let v: serde_json::Value = match serde_json::from_slice(&bytes) {
345 Ok(v) => v,
346 Err(_) => return (None, None),
347 };
348 let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
349 let handle = v
350 .get("handle")
351 .and_then(|x| x.as_str())
352 .map(str::to_string)
353 .or_else(|| {
354 did.as_ref()
355 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
356 });
357 (did, handle)
358}
359
360fn check_daemon_live(session_home: &Path) -> bool {
361 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
366 let bytes = match std::fs::read(&pidfile) {
367 Ok(b) => b,
368 Err(_) => return false,
369 };
370 let pid_opt: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
372 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
373 } else {
374 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
376 };
377 let pid = match pid_opt {
378 Some(p) => p,
379 None => return false,
380 };
381 is_process_live(pid)
382}
383
384fn is_process_live(pid: u32) -> bool {
385 #[cfg(target_os = "linux")]
386 {
387 std::path::Path::new(&format!("/proc/{pid}")).exists()
388 }
389 #[cfg(not(target_os = "linux"))]
390 {
391 std::process::Command::new("kill")
392 .args(["-0", &pid.to_string()])
393 .output()
394 .map(|o| o.status.success())
395 .unwrap_or(false)
396 }
397}
398
399pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
414 let path = session_home.join("config").join("wire").join("relay.json");
415 let bytes = match std::fs::read(&path) {
416 Ok(b) => b,
417 Err(_) => return Vec::new(),
418 };
419 let val: Value = match serde_json::from_slice(&bytes) {
420 Ok(v) => v,
421 Err(_) => return Vec::new(),
422 };
423 self_endpoints(&val)
424}
425
426#[derive(Debug, Clone, Serialize)]
433pub struct LocalEndpointView {
434 pub relay_url: String,
435 pub slot_id: String,
436}
437
438#[derive(Debug, Clone, Serialize)]
441pub struct LocalSessionView {
442 pub name: String,
443 pub handle: Option<String>,
444 pub did: Option<String>,
445 pub cwd: Option<String>,
446 pub home_dir: PathBuf,
447 pub daemon_running: bool,
448 pub local_endpoints: Vec<LocalEndpointView>,
452}
453
454#[derive(Debug, Clone, Serialize)]
457pub struct FederationOnlySessionView {
458 pub name: String,
459 pub handle: Option<String>,
460 pub cwd: Option<String>,
461}
462
463#[derive(Debug, Clone, Serialize)]
467pub struct LocalSessionListing {
468 pub local: HashMap<String, Vec<LocalSessionView>>,
469 pub federation_only: Vec<FederationOnlySessionView>,
470}
471
472pub fn list_local_sessions() -> Result<LocalSessionListing> {
475 let sessions = list_sessions()?;
476 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
477 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
478
479 for s in sessions {
480 let endpoints = read_session_endpoints(&s.home_dir);
481 let local_eps: Vec<Endpoint> = endpoints
482 .into_iter()
483 .filter(|e| matches!(e.scope, EndpointScope::Local))
484 .collect();
485 if local_eps.is_empty() {
486 federation_only.push(FederationOnlySessionView {
487 name: s.name.clone(),
488 handle: s.handle.clone(),
489 cwd: s.cwd.clone(),
490 });
491 continue;
492 }
493 let redacted: Vec<LocalEndpointView> = local_eps
495 .iter()
496 .map(|e| LocalEndpointView {
497 relay_url: e.relay_url.clone(),
498 slot_id: e.slot_id.clone(),
499 })
500 .collect();
501 for ep in &local_eps {
504 local
505 .entry(ep.relay_url.clone())
506 .or_default()
507 .push(LocalSessionView {
508 name: s.name.clone(),
509 handle: s.handle.clone(),
510 did: s.did.clone(),
511 cwd: s.cwd.clone(),
512 home_dir: s.home_dir.clone(),
513 daemon_running: s.daemon_running,
514 local_endpoints: redacted.clone(),
515 });
516 }
517 }
518 for group in local.values_mut() {
520 group.sort_by(|a, b| a.name.cmp(&b.name));
521 }
522 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
523 Ok(LocalSessionListing {
524 local,
525 federation_only,
526 })
527}
528
529pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
543 let registry = read_registry().ok()?;
544 let mut probe: Option<&std::path::Path> = Some(cwd);
551 while let Some(path) = probe {
552 let path_str = path.to_string_lossy().into_owned();
553 if let Some(session_name) = registry.by_cwd.get(&path_str) {
554 let session_home = session_dir(session_name).ok()?;
555 if session_home.exists() {
556 return Some(session_home);
557 }
558 }
559 probe = path.parent();
560 }
561 None
562}
563
564pub fn warn_on_identity_collision(self_pid: u32) {
578 let our_wire_home = match std::env::var("WIRE_HOME") {
579 Ok(h) => h,
580 Err(_) => return,
581 };
582
583 let pgrep_out = match std::process::Command::new("pgrep")
584 .args(["-f", "wire mcp"])
585 .output()
586 {
587 Ok(o) if o.status.success() => o,
588 _ => return,
589 };
590
591 let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
592 .split_whitespace()
593 .filter_map(|s| s.parse::<u32>().ok())
594 .filter(|&p| p != self_pid)
595 .collect();
596
597 let mut colliders: Vec<u32> = Vec::new();
598 for pid in &other_pids {
599 if let Some(their_home) = read_wire_home_from_pid(*pid)
600 && their_home == our_wire_home
601 {
602 colliders.push(*pid);
603 }
604 }
605
606 if colliders.is_empty() {
607 return;
608 }
609
610 eprintln!(
611 "wire mcp: WARNING — {} other wire mcp process(es) already using WIRE_HOME=`{}` (pid {})",
612 colliders.len(),
613 our_wire_home,
614 colliders
615 .iter()
616 .map(|p| p.to_string())
617 .collect::<Vec<_>>()
618 .join(", ")
619 );
620 eprintln!(
621 " Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
622 );
623 eprintln!(" To use a separate identity:");
624 eprintln!(" 1. Close the other agent(s), OR");
625 eprintln!(" 2. `wire session new <name> --local-only` to create a fresh identity, then");
626 eprintln!(
627 " 3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
628 );
629}
630
631fn read_wire_home_from_pid(pid: u32) -> Option<String> {
636 #[cfg(target_os = "linux")]
637 {
638 let path = format!("/proc/{pid}/environ");
639 let bytes = std::fs::read(&path).ok()?;
640 for entry in bytes.split(|&b| b == 0) {
641 let s = match std::str::from_utf8(entry) {
642 Ok(s) => s,
643 Err(_) => continue,
644 };
645 if let Some(val) = s.strip_prefix("WIRE_HOME=") {
646 return Some(val.to_string());
647 }
648 }
649 None
650 }
651
652 #[cfg(target_os = "macos")]
653 {
654 let output = std::process::Command::new("ps")
655 .args(["-E", "-p", &pid.to_string(), "-o", "command="])
656 .output()
657 .ok()?;
658 let s = String::from_utf8_lossy(&output.stdout);
659 for tok in s.split_whitespace() {
660 if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
661 return Some(val.to_string());
662 }
663 }
664 None
665 }
666
667 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
668 {
669 let _ = pid;
670 None
671 }
672}
673
674pub fn maybe_adopt_session_wire_home(label: &str) {
689 if std::env::var("WIRE_HOME").is_ok() {
690 return;
691 }
692 let cwd = match std::env::current_dir() {
693 Ok(c) => c,
694 Err(_) => return,
695 };
696 let home = match detect_session_wire_home(&cwd) {
697 Some(h) => h,
698 None => return,
699 };
700 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
701 eprintln!(
702 "wire {label}: auto-detected session for cwd `{}` → WIRE_HOME=`{}`",
703 cwd.display(),
704 home.display()
705 );
706 }
707 unsafe {
711 std::env::set_var("WIRE_HOME", &home);
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 fn sanitize_handles_unicode_and_long_names() {
721 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
722 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
723 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
725 assert_eq!(sanitize_name("---"), "wire-session");
726 let long: String = "a".repeat(100);
727 assert_eq!(sanitize_name(&long).len(), 32);
728 }
729
730 #[test]
731 fn derive_name_returns_basename_when_no_collision() {
732 let reg = SessionRegistry::default();
733 assert_eq!(
734 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
735 "wire"
736 );
737 assert_eq!(
738 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
739 "slancha-mesh"
740 );
741 }
742
743 #[test]
744 fn derive_name_returns_stored_name_when_cwd_already_registered() {
745 let mut reg = SessionRegistry::default();
746 reg.by_cwd.insert(
747 "/Users/paul/Source/wire".to_string(),
748 "wire-special".to_string(),
749 );
750 assert_eq!(
751 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
752 "wire-special"
753 );
754 }
755
756 #[test]
757 fn read_session_endpoints_handles_missing_relay_state() {
758 let tmp = tempfile::tempdir().unwrap();
759 let endpoints = read_session_endpoints(tmp.path());
761 assert!(endpoints.is_empty());
762 }
763
764 #[test]
765 fn read_session_endpoints_parses_dual_slot_form() {
766 let tmp = tempfile::tempdir().unwrap();
767 let cfg = tmp.path().join("config").join("wire");
768 std::fs::create_dir_all(&cfg).unwrap();
769 let body = serde_json::json!({
770 "self": {
771 "relay_url": "https://wireup.net",
772 "slot_id": "fed-slot",
773 "slot_token": "fed-tok",
774 "endpoints": [
775 {
776 "relay_url": "https://wireup.net",
777 "slot_id": "fed-slot",
778 "slot_token": "fed-tok",
779 "scope": "federation"
780 },
781 {
782 "relay_url": "http://127.0.0.1:8771",
783 "slot_id": "loop-slot",
784 "slot_token": "loop-tok",
785 "scope": "local"
786 }
787 ]
788 }
789 });
790 std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
791 let endpoints = read_session_endpoints(tmp.path());
792 assert_eq!(endpoints.len(), 2);
793 let local_count = endpoints
794 .iter()
795 .filter(|e| matches!(e.scope, EndpointScope::Local))
796 .count();
797 assert_eq!(local_count, 1);
798 let local = endpoints
799 .iter()
800 .find(|e| matches!(e.scope, EndpointScope::Local))
801 .unwrap();
802 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
803 assert_eq!(local.slot_id, "loop-slot");
804 }
805
806 #[test]
815 fn derive_name_appends_path_hash_when_basename_collides() {
816 let mut reg = SessionRegistry::default();
817 reg.by_cwd
818 .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
819 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
821 assert!(name.starts_with("wire-"));
822 assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
824 }
825}