1use anyhow::{Context, Result, anyhow, bail};
17use clap::{Parser, Subcommand};
18use serde_json::{Value, json};
19
20use crate::{
21 agent_card::{build_agent_card, sign_agent_card},
22 config,
23 signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
24 trust::{add_self_to_trust, empty_trust},
25};
26
27#[derive(Parser, Debug)]
29#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
30pub struct Cli {
31 #[command(subcommand)]
32 pub command: Command,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Command {
37 Init {
39 handle: String,
41 #[arg(long)]
43 name: Option<String>,
44 #[arg(long)]
47 relay: Option<String>,
48 #[arg(long)]
50 json: bool,
51 },
52 Whoami {
56 #[arg(long)]
57 json: bool,
58 #[arg(long, conflicts_with = "json")]
61 short: bool,
62 #[arg(long, conflicts_with_all = ["json", "short"])]
65 colored: bool,
66 },
67 Peers {
69 #[arg(long)]
70 json: bool,
71 },
72 Send {
80 peer: String,
82 kind_or_body: String,
87 body: Option<String>,
91 #[arg(long)]
93 deadline: Option<String>,
94 #[arg(long)]
96 json: bool,
97 },
98 Tail {
100 peer: Option<String>,
102 #[arg(long)]
104 json: bool,
105 #[arg(long, default_value_t = 0)]
107 limit: usize,
108 },
109 Monitor {
120 #[arg(long)]
122 peer: Option<String>,
123 #[arg(long)]
125 json: bool,
126 #[arg(long)]
129 include_handshake: bool,
130 #[arg(long, default_value_t = 500)]
132 interval_ms: u64,
133 #[arg(long, default_value_t = 0)]
135 replay: usize,
136 },
137 Verify {
139 path: String,
141 #[arg(long)]
143 json: bool,
144 },
145 Mcp,
149 RelayServer {
151 #[arg(long, default_value = "127.0.0.1:8770")]
153 bind: String,
154 #[arg(long)]
162 local_only: bool,
163 #[arg(long)]
169 uds: Option<std::path::PathBuf>,
170 },
171 BindRelay {
180 url: String,
182 #[arg(long)]
187 migrate_pinned: bool,
188 #[arg(long)]
189 json: bool,
190 },
191 AddPeerSlot {
194 handle: String,
196 url: String,
198 slot_id: String,
200 slot_token: String,
202 #[arg(long)]
203 json: bool,
204 },
205 Push {
207 peer: Option<String>,
209 #[arg(long)]
210 json: bool,
211 },
212 Pull {
214 #[arg(long)]
215 json: bool,
216 },
217 Status {
220 #[arg(long)]
222 peer: Option<String>,
223 #[arg(long)]
224 json: bool,
225 },
226 Responder {
228 #[command(subcommand)]
229 command: ResponderCommand,
230 },
231 Pin {
234 card_file: String,
236 #[arg(long)]
237 json: bool,
238 },
239 RotateSlot {
250 #[arg(long)]
253 no_announce: bool,
254 #[arg(long)]
255 json: bool,
256 },
257 ForgetPeer {
261 handle: String,
263 #[arg(long)]
265 purge: bool,
266 #[arg(long)]
267 json: bool,
268 },
269 Daemon {
273 #[arg(long, default_value_t = 5)]
275 interval: u64,
276 #[arg(long)]
278 once: bool,
279 #[arg(long)]
280 json: bool,
281 },
282 PairHost {
287 #[arg(long)]
289 relay: String,
290 #[arg(long)]
294 yes: bool,
295 #[arg(long, default_value_t = 300)]
297 timeout: u64,
298 #[arg(long)]
304 detach: bool,
305 #[arg(long)]
307 json: bool,
308 },
309 #[command(alias = "join")]
313 PairJoin {
314 code_phrase: String,
316 #[arg(long)]
318 relay: String,
319 #[arg(long)]
320 yes: bool,
321 #[arg(long, default_value_t = 300)]
322 timeout: u64,
323 #[arg(long)]
325 detach: bool,
326 #[arg(long)]
328 json: bool,
329 },
330 PairConfirm {
334 code_phrase: String,
336 digits: String,
338 #[arg(long)]
340 json: bool,
341 },
342 PairList {
344 #[arg(long)]
346 json: bool,
347 #[arg(long)]
351 watch: bool,
352 #[arg(long, default_value_t = 1)]
354 watch_interval: u64,
355 },
356 PairCancel {
358 code_phrase: String,
359 #[arg(long)]
360 json: bool,
361 },
362 PairWatch {
372 code_phrase: String,
373 #[arg(long, default_value = "sas_ready")]
375 status: String,
376 #[arg(long, default_value_t = 300)]
378 timeout: u64,
379 #[arg(long)]
381 json: bool,
382 },
383 Pair {
392 handle: String,
395 #[arg(long)]
398 code: Option<String>,
399 #[arg(long, default_value = "https://wireup.net")]
401 relay: String,
402 #[arg(long)]
404 yes: bool,
405 #[arg(long, default_value_t = 300)]
407 timeout: u64,
408 #[arg(long)]
411 no_setup: bool,
412 #[arg(long)]
417 detach: bool,
418 },
419 PairAbandon {
425 code_phrase: String,
427 #[arg(long, default_value = "https://wireup.net")]
429 relay: String,
430 },
431 PairAccept {
437 peer: String,
439 #[arg(long)]
441 json: bool,
442 },
443 PairReject {
450 peer: String,
452 #[arg(long)]
454 json: bool,
455 },
456 PairListInbound {
462 #[arg(long)]
464 json: bool,
465 },
466 #[command(subcommand)]
476 Session(SessionCommand),
477 Identity {
482 #[command(subcommand)]
483 cmd: IdentityCommand,
484 },
485 #[command(subcommand)]
490 Mesh(MeshCommand),
491 Setup {
496 #[arg(long)]
498 apply: bool,
499 },
500 Whois {
504 handle: Option<String>,
506 #[arg(long)]
507 json: bool,
508 #[arg(long)]
511 relay: Option<String>,
512 },
513 Add {
519 handle: String,
522 #[arg(long)]
524 relay: Option<String>,
525 #[arg(long)]
533 local_sister: bool,
534 #[arg(long)]
535 json: bool,
536 },
537 Up {
547 handle: String,
550 #[arg(long)]
552 name: Option<String>,
553 #[arg(long)]
554 json: bool,
555 },
556 Doctor {
563 #[arg(long)]
565 json: bool,
566 #[arg(long, default_value_t = 5)]
568 recent_rejections: usize,
569 },
570 Upgrade {
575 #[arg(long)]
578 check: bool,
579 #[arg(long)]
580 json: bool,
581 },
582 Service {
587 #[command(subcommand)]
588 action: ServiceAction,
589 },
590 Diag {
595 #[command(subcommand)]
596 action: DiagAction,
597 },
598 Claim {
602 nick: String,
603 #[arg(long)]
605 relay: Option<String>,
606 #[arg(long)]
608 public_url: Option<String>,
609 #[arg(long)]
617 hidden: bool,
618 #[arg(long)]
619 json: bool,
620 },
621 Profile {
631 #[command(subcommand)]
632 action: ProfileAction,
633 },
634 Invite {
638 #[arg(long, default_value = "https://wireup.net")]
640 relay: String,
641 #[arg(long, default_value_t = 86_400)]
643 ttl: u64,
644 #[arg(long, default_value_t = 1)]
647 uses: u32,
648 #[arg(long)]
652 share: bool,
653 #[arg(long)]
655 json: bool,
656 },
657 Accept {
660 url: String,
662 #[arg(long)]
664 json: bool,
665 },
666 Reactor {
672 #[arg(long)]
674 on_event: String,
675 #[arg(long)]
677 peer: Option<String>,
678 #[arg(long)]
680 kind: Option<String>,
681 #[arg(long, default_value_t = true)]
683 verified_only: bool,
684 #[arg(long, default_value_t = 2)]
686 interval: u64,
687 #[arg(long)]
689 once: bool,
690 #[arg(long)]
692 dry_run: bool,
693 #[arg(long, default_value_t = 6)]
697 max_per_minute: u32,
698 #[arg(long, default_value_t = 1)]
702 max_chain_depth: u32,
703 },
704 Notify {
709 #[arg(long, default_value_t = 2)]
711 interval: u64,
712 #[arg(long)]
714 peer: Option<String>,
715 #[arg(long)]
717 once: bool,
718 #[arg(long)]
722 json: bool,
723 },
724}
725
726#[derive(Subcommand, Debug)]
727pub enum DiagAction {
728 Tail {
730 #[arg(long, default_value_t = 20)]
731 limit: usize,
732 #[arg(long)]
733 json: bool,
734 },
735 Enable,
738 Disable,
740 Status {
742 #[arg(long)]
743 json: bool,
744 },
745}
746
747#[derive(Subcommand, Debug)]
748pub enum IdentityCommand {
749 Rename {
759 #[arg(long)]
763 name: Option<String>,
764 #[arg(long)]
767 emoji: Option<String>,
768 #[arg(long, conflicts_with_all = ["name", "emoji"])]
771 clear: bool,
772 #[arg(long, conflicts_with_all = ["name", "emoji", "clear"])]
776 random: bool,
777 #[arg(long)]
778 json: bool,
779 },
780 Show {
783 #[arg(long)]
784 json: bool,
785 },
786 List {
791 #[arg(long)]
792 json: bool,
793 },
794 Publish {
800 nick: String,
802 #[arg(long)]
805 relay: Option<String>,
806 #[arg(long, alias = "public")]
809 public_url: Option<String>,
810 #[arg(long)]
814 hidden: bool,
815 #[arg(long)]
816 json: bool,
817 },
818 Destroy {
822 name: String,
824 #[arg(long)]
826 force: bool,
827 #[arg(long)]
828 json: bool,
829 },
830 Create {
842 #[arg(long)]
845 name: Option<String>,
846 #[arg(long, conflicts_with = "local")]
849 anonymous: bool,
850 #[arg(long)]
853 local: bool,
854 #[arg(long)]
855 json: bool,
856 },
857 Persist {
862 name: String,
864 #[arg(long = "as", value_name = "NEW_NAME")]
866 as_name: Option<String>,
867 #[arg(long)]
868 json: bool,
869 },
870 Demote {
880 name: String,
882 #[arg(long)]
883 json: bool,
884 },
885}
886
887#[derive(Subcommand, Debug)]
888pub enum SessionCommand {
889 New {
897 name: Option<String>,
899 #[arg(long, default_value = "https://wireup.net")]
901 relay: String,
902 #[arg(long)]
909 with_local: bool,
910 #[arg(long, default_value = "http://127.0.0.1:8771")]
914 local_relay: String,
915 #[arg(long)]
922 with_lan: bool,
923 #[arg(long)]
927 lan_relay: Option<String>,
928 #[arg(long)]
935 with_uds: bool,
936 #[arg(long)]
940 uds_socket: Option<std::path::PathBuf>,
941 #[arg(long)]
944 no_daemon: bool,
945 #[arg(long)]
953 local_only: bool,
954 #[arg(long)]
956 json: bool,
957 },
958 List {
961 #[arg(long)]
962 json: bool,
963 },
964 ListLocal {
970 #[arg(long)]
971 json: bool,
972 },
973 PairAllLocal {
989 #[arg(long, default_value_t = 1)]
994 settle_secs: u64,
995 #[arg(long, default_value = "https://wireup.net")]
1000 federation_relay: String,
1001 #[arg(long)]
1002 json: bool,
1003 },
1004 MeshStatus {
1018 #[arg(long, default_value_t = 300)]
1023 stale_secs: u64,
1024 #[arg(long)]
1025 json: bool,
1026 },
1027 Env {
1031 name: Option<String>,
1033 #[arg(long)]
1034 json: bool,
1035 },
1036 Current {
1040 #[arg(long)]
1041 json: bool,
1042 },
1043 Bind {
1051 name: Option<String>,
1055 #[arg(long)]
1056 json: bool,
1057 },
1058 Destroy {
1062 name: String,
1063 #[arg(long)]
1065 force: bool,
1066 #[arg(long)]
1067 json: bool,
1068 },
1069}
1070
1071#[derive(Subcommand, Debug)]
1076pub enum MeshCommand {
1077 Status {
1080 #[arg(long, default_value_t = 300)]
1082 stale_secs: u64,
1083 #[arg(long)]
1084 json: bool,
1085 },
1086 Broadcast {
1105 #[arg(long, default_value = "claim")]
1108 kind: String,
1109 #[arg(long, default_value = "local")]
1111 scope: String,
1112 #[arg(long)]
1114 exclude: Vec<String>,
1115 #[arg(long)]
1119 noreply: bool,
1120 body: String,
1122 #[arg(long)]
1123 json: bool,
1124 },
1125 Role {
1134 #[command(subcommand)]
1135 action: MeshRoleAction,
1136 },
1137 Route {
1153 role: String,
1155 #[arg(long, default_value = "round-robin")]
1157 strategy: String,
1158 #[arg(long)]
1160 exclude: Vec<String>,
1161 #[arg(long, default_value = "claim")]
1164 kind: String,
1165 body: String,
1167 #[arg(long)]
1168 json: bool,
1169 },
1170}
1171
1172#[derive(Subcommand, Debug)]
1174pub enum MeshRoleAction {
1175 Set {
1180 role: String,
1181 #[arg(long)]
1182 json: bool,
1183 },
1184 Get {
1187 peer: Option<String>,
1188 #[arg(long)]
1189 json: bool,
1190 },
1191 List {
1194 #[arg(long)]
1195 json: bool,
1196 },
1197 Clear {
1200 #[arg(long)]
1201 json: bool,
1202 },
1203}
1204
1205#[derive(Subcommand, Debug)]
1206pub enum ServiceAction {
1207 Install {
1217 #[arg(long)]
1219 local_relay: bool,
1220 #[arg(long)]
1221 json: bool,
1222 },
1223 Uninstall {
1227 #[arg(long)]
1229 local_relay: bool,
1230 #[arg(long)]
1231 json: bool,
1232 },
1233 Status {
1235 #[arg(long)]
1237 local_relay: bool,
1238 #[arg(long)]
1239 json: bool,
1240 },
1241}
1242
1243#[derive(Subcommand, Debug)]
1244pub enum ResponderCommand {
1245 Set {
1247 status: String,
1249 #[arg(long)]
1251 reason: Option<String>,
1252 #[arg(long)]
1254 json: bool,
1255 },
1256 Get {
1258 peer: Option<String>,
1260 #[arg(long)]
1262 json: bool,
1263 },
1264}
1265
1266#[derive(Subcommand, Debug)]
1267pub enum ProfileAction {
1268 Set {
1272 field: String,
1273 value: String,
1274 #[arg(long)]
1275 json: bool,
1276 },
1277 Get {
1279 #[arg(long)]
1280 json: bool,
1281 },
1282 Clear {
1284 field: String,
1285 #[arg(long)]
1286 json: bool,
1287 },
1288}
1289
1290pub fn run() -> Result<()> {
1292 crate::session::maybe_adopt_session_wire_home("cli");
1303 let cli = Cli::parse();
1304 match cli.command {
1305 Command::Init {
1306 handle,
1307 name,
1308 relay,
1309 json,
1310 } => cmd_init(&handle, name.as_deref(), relay.as_deref(), json),
1311 Command::Status { peer, json } => {
1312 if let Some(peer) = peer {
1313 cmd_status_peer(&peer, json)
1314 } else {
1315 cmd_status(json)
1316 }
1317 }
1318 Command::Whoami {
1319 json,
1320 short,
1321 colored,
1322 } => cmd_whoami(json, short, colored),
1323 Command::Peers { json } => cmd_peers(json),
1324 Command::Send {
1325 peer,
1326 kind_or_body,
1327 body,
1328 deadline,
1329 json,
1330 } => {
1331 let (kind, body) = match body {
1334 Some(real_body) => (kind_or_body, real_body),
1335 None => ("claim".to_string(), kind_or_body),
1336 };
1337 cmd_send(&peer, &kind, &body, deadline.as_deref(), json)
1338 }
1339 Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1340 Command::Monitor {
1341 peer,
1342 json,
1343 include_handshake,
1344 interval_ms,
1345 replay,
1346 } => cmd_monitor(
1347 peer.as_deref(),
1348 json,
1349 include_handshake,
1350 interval_ms,
1351 replay,
1352 ),
1353 Command::Verify { path, json } => cmd_verify(&path, json),
1354 Command::Responder { command } => match command {
1355 ResponderCommand::Set {
1356 status,
1357 reason,
1358 json,
1359 } => cmd_responder_set(&status, reason.as_deref(), json),
1360 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1361 },
1362 Command::Mcp => cmd_mcp(),
1363 Command::RelayServer {
1364 bind,
1365 local_only,
1366 uds,
1367 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1368 Command::BindRelay {
1369 url,
1370 migrate_pinned,
1371 json,
1372 } => cmd_bind_relay(&url, migrate_pinned, json),
1373 Command::AddPeerSlot {
1374 handle,
1375 url,
1376 slot_id,
1377 slot_token,
1378 json,
1379 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1380 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1381 Command::Pull { json } => cmd_pull(json),
1382 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1383 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1384 Command::ForgetPeer {
1385 handle,
1386 purge,
1387 json,
1388 } => cmd_forget_peer(&handle, purge, json),
1389 Command::Daemon {
1390 interval,
1391 once,
1392 json,
1393 } => cmd_daemon(interval, once, json),
1394 Command::PairHost {
1395 relay,
1396 yes,
1397 timeout,
1398 detach,
1399 json,
1400 } => {
1401 if detach {
1402 cmd_pair_host_detach(&relay, json)
1403 } else {
1404 cmd_pair_host(&relay, yes, timeout)
1405 }
1406 }
1407 Command::PairJoin {
1408 code_phrase,
1409 relay,
1410 yes,
1411 timeout,
1412 detach,
1413 json,
1414 } => {
1415 if detach {
1416 cmd_pair_join_detach(&code_phrase, &relay, json)
1417 } else {
1418 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1419 }
1420 }
1421 Command::PairConfirm {
1422 code_phrase,
1423 digits,
1424 json,
1425 } => cmd_pair_confirm(&code_phrase, &digits, json),
1426 Command::PairList {
1427 json,
1428 watch,
1429 watch_interval,
1430 } => cmd_pair_list(json, watch, watch_interval),
1431 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1432 Command::PairWatch {
1433 code_phrase,
1434 status,
1435 timeout,
1436 json,
1437 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1438 Command::Pair {
1439 handle,
1440 code,
1441 relay,
1442 yes,
1443 timeout,
1444 no_setup,
1445 detach,
1446 } => {
1447 if handle.contains('@') && code.is_none() {
1454 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1455 } else if detach {
1456 cmd_pair_detach(&handle, code.as_deref(), &relay)
1457 } else {
1458 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1459 }
1460 }
1461 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1462 Command::PairAccept { peer, json } => cmd_pair_accept(&peer, json),
1463 Command::PairReject { peer, json } => cmd_pair_reject(&peer, json),
1464 Command::PairListInbound { json } => cmd_pair_list_inbound(json),
1465 Command::Session(cmd) => cmd_session(cmd),
1466 Command::Identity { cmd } => cmd_identity(cmd),
1467 Command::Mesh(cmd) => cmd_mesh(cmd),
1468 Command::Invite {
1469 relay,
1470 ttl,
1471 uses,
1472 share,
1473 json,
1474 } => cmd_invite(&relay, ttl, uses, share, json),
1475 Command::Accept { url, json } => cmd_accept(&url, json),
1476 Command::Whois {
1477 handle,
1478 json,
1479 relay,
1480 } => cmd_whois(handle.as_deref(), json, relay.as_deref()),
1481 Command::Add {
1482 handle,
1483 relay,
1484 local_sister,
1485 json,
1486 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1487 Command::Up { handle, name, json } => cmd_up(&handle, name.as_deref(), json),
1488 Command::Doctor {
1489 json,
1490 recent_rejections,
1491 } => cmd_doctor(json, recent_rejections),
1492 Command::Upgrade { check, json } => cmd_upgrade(check, json),
1493 Command::Service { action } => cmd_service(action),
1494 Command::Diag { action } => cmd_diag(action),
1495 Command::Claim {
1496 nick,
1497 relay,
1498 public_url,
1499 hidden,
1500 json,
1501 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1502 Command::Profile { action } => cmd_profile(action),
1503 Command::Setup { apply } => cmd_setup(apply),
1504 Command::Reactor {
1505 on_event,
1506 peer,
1507 kind,
1508 verified_only,
1509 interval,
1510 once,
1511 dry_run,
1512 max_per_minute,
1513 max_chain_depth,
1514 } => cmd_reactor(
1515 &on_event,
1516 peer.as_deref(),
1517 kind.as_deref(),
1518 verified_only,
1519 interval,
1520 once,
1521 dry_run,
1522 max_per_minute,
1523 max_chain_depth,
1524 ),
1525 Command::Notify {
1526 interval,
1527 peer,
1528 once,
1529 json,
1530 } => cmd_notify(interval, peer.as_deref(), once, json),
1531 }
1532}
1533
1534fn cmd_init(handle: &str, name: Option<&str>, relay: Option<&str>, as_json: bool) -> Result<()> {
1537 if !handle
1538 .chars()
1539 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1540 {
1541 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
1542 }
1543 if config::is_initialized()? {
1544 bail!(
1545 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1546 config::config_dir()?
1547 );
1548 }
1549
1550 config::ensure_dirs()?;
1551 let (sk_seed, pk_bytes) = generate_keypair();
1552 config::write_private_key(&sk_seed)?;
1553
1554 let card = build_agent_card(handle, &pk_bytes, name, None, None);
1555 let signed = sign_agent_card(&card, &sk_seed);
1556 config::write_agent_card(&signed)?;
1557
1558 let mut trust = empty_trust();
1559 add_self_to_trust(&mut trust, handle, &pk_bytes);
1560 config::write_trust(&trust)?;
1561
1562 let fp = fingerprint(&pk_bytes);
1563 let key_id = make_key_id(handle, &pk_bytes);
1564
1565 let mut relay_info: Option<(String, String)> = None;
1567 if let Some(url) = relay {
1568 let normalized = url.trim_end_matches('/');
1569 let client = crate::relay_client::RelayClient::new(normalized);
1570 client.check_healthz()?;
1571 let alloc = client.allocate_slot(Some(handle))?;
1572 let mut state = config::read_relay_state()?;
1573 state["self"] = json!({
1574 "relay_url": normalized,
1575 "slot_id": alloc.slot_id.clone(),
1576 "slot_token": alloc.slot_token,
1577 });
1578 config::write_relay_state(&state)?;
1579 relay_info = Some((normalized.to_string(), alloc.slot_id));
1580 }
1581
1582 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1583 if as_json {
1584 let mut out = json!({
1585 "did": did_str.clone(),
1586 "fingerprint": fp,
1587 "key_id": key_id,
1588 "config_dir": config::config_dir()?.to_string_lossy(),
1589 });
1590 if let Some((url, slot_id)) = &relay_info {
1591 out["relay_url"] = json!(url);
1592 out["slot_id"] = json!(slot_id);
1593 }
1594 println!("{}", serde_json::to_string(&out)?);
1595 } else {
1596 println!("generated {did_str} (ed25519:{key_id})");
1597 println!(
1598 "config written to {}",
1599 config::config_dir()?.to_string_lossy()
1600 );
1601 if let Some((url, slot_id)) = &relay_info {
1602 println!("bound to relay {url} (slot {slot_id})");
1603 println!();
1604 println!(
1605 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1606 );
1607 } else {
1608 println!();
1609 println!(
1610 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1611 );
1612 }
1613 }
1614 Ok(())
1615}
1616
1617fn cmd_status(as_json: bool) -> Result<()> {
1620 let initialized = config::is_initialized()?;
1621
1622 let mut summary = json!({
1623 "initialized": initialized,
1624 });
1625
1626 if initialized {
1627 let card = config::read_agent_card()?;
1628 let did = card
1629 .get("did")
1630 .and_then(Value::as_str)
1631 .unwrap_or("")
1632 .to_string();
1633 let handle = card
1637 .get("handle")
1638 .and_then(Value::as_str)
1639 .map(str::to_string)
1640 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1641 let pk_b64 = card
1642 .get("verify_keys")
1643 .and_then(Value::as_object)
1644 .and_then(|m| m.values().next())
1645 .and_then(|v| v.get("key"))
1646 .and_then(Value::as_str)
1647 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1648 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1649 summary["did"] = json!(did);
1650 summary["handle"] = json!(handle);
1651 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1652 summary["capabilities"] = card
1653 .get("capabilities")
1654 .cloned()
1655 .unwrap_or_else(|| json!([]));
1656
1657 let trust = config::read_trust()?;
1658 let relay_state_for_tier =
1659 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1660 let mut peers = Vec::new();
1661 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1662 for (peer_handle, _agent) in agents {
1663 if peer_handle == &handle {
1664 continue; }
1666 peers.push(json!({
1671 "handle": peer_handle,
1672 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
1673 }));
1674 }
1675 }
1676 summary["peers"] = json!(peers);
1677
1678 let relay_state = config::read_relay_state()?;
1679 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
1680 if !summary["self_relay"].is_null() {
1681 if let Some(obj) = summary["self_relay"].as_object_mut() {
1683 obj.remove("slot_token");
1684 }
1685 }
1686 summary["peer_slots_count"] = json!(
1687 relay_state
1688 .get("peers")
1689 .and_then(Value::as_object)
1690 .map(|m| m.len())
1691 .unwrap_or(0)
1692 );
1693
1694 let outbox = config::outbox_dir()?;
1696 let inbox = config::inbox_dir()?;
1697 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
1698 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
1699
1700 let snap = crate::ensure_up::daemon_liveness();
1706 let mut daemon = json!({
1707 "running": snap.pidfile_alive,
1708 "pid": snap.pidfile_pid,
1709 "all_running_pids": snap.pgrep_pids,
1710 "orphans": snap.orphan_pids,
1711 });
1712 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1713 daemon["version"] = json!(d.version);
1714 daemon["bin_path"] = json!(d.bin_path);
1715 daemon["did"] = json!(d.did);
1716 daemon["relay_url"] = json!(d.relay_url);
1717 daemon["started_at"] = json!(d.started_at);
1718 daemon["schema"] = json!(d.schema);
1719 if d.version != env!("CARGO_PKG_VERSION") {
1720 daemon["version_mismatch"] = json!({
1721 "daemon": d.version.clone(),
1722 "cli": env!("CARGO_PKG_VERSION"),
1723 });
1724 }
1725 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
1726 daemon["pidfile_form"] = json!("legacy-int");
1727 daemon["version_mismatch"] = json!({
1728 "daemon": "<pre-0.5.11>",
1729 "cli": env!("CARGO_PKG_VERSION"),
1730 });
1731 }
1732 summary["daemon"] = daemon;
1733
1734 let pending = crate::pending_pair::list_pending().unwrap_or_default();
1736 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
1737 for p in &pending {
1738 *counts.entry(p.status.clone()).or_default() += 1;
1739 }
1740 let pending_inbound =
1742 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
1743 let inbound_handles: Vec<&str> = pending_inbound
1744 .iter()
1745 .map(|p| p.peer_handle.as_str())
1746 .collect();
1747 summary["pending_pairs"] = json!({
1748 "total": pending.len(),
1749 "by_status": counts,
1750 "inbound_count": pending_inbound.len(),
1751 "inbound_handles": inbound_handles,
1752 });
1753 }
1754
1755 if as_json {
1756 println!("{}", serde_json::to_string(&summary)?);
1757 } else if !initialized {
1758 println!("not initialized — run `wire init <handle>` first");
1759 } else {
1760 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
1761 println!(
1762 "fingerprint: {}",
1763 summary["fingerprint"].as_str().unwrap_or("?")
1764 );
1765 println!("capabilities: {}", summary["capabilities"]);
1766 if !summary["self_relay"].is_null() {
1767 println!(
1768 "self relay: {} (slot {})",
1769 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
1770 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
1771 );
1772 } else {
1773 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
1774 }
1775 println!(
1776 "peers: {}",
1777 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
1778 );
1779 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
1780 println!(
1781 " - {:<20} tier={}",
1782 p["handle"].as_str().unwrap_or(""),
1783 p["tier"].as_str().unwrap_or("?")
1784 );
1785 }
1786 println!(
1787 "outbox: {} file(s), {} event(s) queued",
1788 summary["outbox"]["files"].as_u64().unwrap_or(0),
1789 summary["outbox"]["events"].as_u64().unwrap_or(0)
1790 );
1791 println!(
1792 "inbox: {} file(s), {} event(s) received",
1793 summary["inbox"]["files"].as_u64().unwrap_or(0),
1794 summary["inbox"]["events"].as_u64().unwrap_or(0)
1795 );
1796 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
1797 let daemon_pid = summary["daemon"]["pid"]
1798 .as_u64()
1799 .map(|p| p.to_string())
1800 .unwrap_or_else(|| "—".to_string());
1801 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
1802 let version_suffix = if !daemon_version.is_empty() {
1803 format!(" v{daemon_version}")
1804 } else {
1805 String::new()
1806 };
1807 println!(
1808 "daemon: {} (pid {}{})",
1809 if daemon_running { "running" } else { "DOWN" },
1810 daemon_pid,
1811 version_suffix,
1812 );
1813 if let Some(mm) = summary["daemon"].get("version_mismatch") {
1815 println!(
1816 " !! version mismatch: daemon={} CLI={}. \
1817 run `wire upgrade` to swap atomically.",
1818 mm["daemon"].as_str().unwrap_or("?"),
1819 mm["cli"].as_str().unwrap_or("?"),
1820 );
1821 }
1822 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
1823 && !orphans.is_empty()
1824 {
1825 let pids: Vec<String> = orphans
1826 .iter()
1827 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
1828 .collect();
1829 println!(
1830 " !! orphan daemon process(es): pids {}. \
1831 pgrep saw them but pidfile didn't — likely stale process from \
1832 prior install. Multiple daemons race the relay cursor.",
1833 pids.join(", ")
1834 );
1835 }
1836 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
1837 let inbound_count = summary["pending_pairs"]["inbound_count"]
1838 .as_u64()
1839 .unwrap_or(0);
1840 if pending_total > 0 {
1841 print!("pending pairs: {pending_total}");
1842 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
1843 let parts: Vec<String> = obj
1844 .iter()
1845 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
1846 .collect();
1847 if !parts.is_empty() {
1848 print!(" ({})", parts.join(", "));
1849 }
1850 }
1851 println!();
1852 } else if inbound_count == 0 {
1853 println!("pending pairs: none");
1854 }
1855 if inbound_count > 0 {
1859 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
1860 .as_array()
1861 .map(|a| {
1862 a.iter()
1863 .filter_map(|v| v.as_str().map(str::to_string))
1864 .collect()
1865 })
1866 .unwrap_or_default();
1867 println!(
1868 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
1869 handles.join(", "),
1870 );
1871 }
1872 }
1873 Ok(())
1874}
1875
1876fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
1877 if !dir.exists() {
1878 return Ok(json!({"files": 0, "events": 0}));
1879 }
1880 let mut files = 0usize;
1881 let mut events = 0usize;
1882 for entry in std::fs::read_dir(dir)? {
1883 let path = entry?.path();
1884 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
1885 files += 1;
1886 if let Ok(body) = std::fs::read_to_string(&path) {
1887 events += body.lines().filter(|l| !l.trim().is_empty()).count();
1888 }
1889 }
1890 }
1891 Ok(json!({"files": files, "events": events}))
1892}
1893
1894fn responder_status_allowed(status: &str) -> bool {
1897 matches!(
1898 status,
1899 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
1900 )
1901}
1902
1903fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
1904 let state = config::read_relay_state()?;
1905 let (label, slot_info) = match peer {
1906 Some(peer) => (
1907 peer.to_string(),
1908 state
1909 .get("peers")
1910 .and_then(|p| p.get(peer))
1911 .ok_or_else(|| {
1912 anyhow!(
1913 "unknown peer {peer:?} in relay state — pair with them first:\n \
1914 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
1915 (`wire peers` lists who you've already paired with.)"
1916 )
1917 })?,
1918 ),
1919 None => (
1920 "self".to_string(),
1921 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
1922 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
1923 })?,
1924 ),
1925 };
1926 let relay_url = slot_info["relay_url"]
1927 .as_str()
1928 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
1929 .to_string();
1930 let slot_id = slot_info["slot_id"]
1931 .as_str()
1932 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
1933 .to_string();
1934 let slot_token = slot_info["slot_token"]
1935 .as_str()
1936 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
1937 .to_string();
1938 Ok((label, relay_url, slot_id, slot_token))
1939}
1940
1941fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
1942 if !responder_status_allowed(status) {
1943 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
1944 }
1945 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
1946 let now = time::OffsetDateTime::now_utc()
1947 .format(&time::format_description::well_known::Rfc3339)
1948 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1949 let mut record = json!({
1950 "status": status,
1951 "set_at": now,
1952 });
1953 if let Some(reason) = reason {
1954 record["reason"] = json!(reason);
1955 }
1956 if status == "online" {
1957 record["last_success_at"] = json!(now);
1958 }
1959 let client = crate::relay_client::RelayClient::new(&relay_url);
1960 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
1961 if as_json {
1962 println!("{}", serde_json::to_string(&saved)?);
1963 } else {
1964 let reason = saved
1965 .get("reason")
1966 .and_then(Value::as_str)
1967 .map(|r| format!(" — {r}"))
1968 .unwrap_or_default();
1969 println!(
1970 "responder {}{}",
1971 saved
1972 .get("status")
1973 .and_then(Value::as_str)
1974 .unwrap_or(status),
1975 reason
1976 );
1977 }
1978 Ok(())
1979}
1980
1981fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
1982 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
1983 let client = crate::relay_client::RelayClient::new(&relay_url);
1984 let health = client.responder_health_get(&slot_id, &slot_token)?;
1985 if as_json {
1986 println!(
1987 "{}",
1988 serde_json::to_string(&json!({
1989 "target": label,
1990 "responder_health": health,
1991 }))?
1992 );
1993 } else if health.is_null() {
1994 println!("{label}: responder health not reported");
1995 } else {
1996 let status = health
1997 .get("status")
1998 .and_then(Value::as_str)
1999 .unwrap_or("unknown");
2000 let reason = health
2001 .get("reason")
2002 .and_then(Value::as_str)
2003 .map(|r| format!(" — {r}"))
2004 .unwrap_or_default();
2005 let last_success = health
2006 .get("last_success_at")
2007 .and_then(Value::as_str)
2008 .map(|t| format!(" (last_success: {t})"))
2009 .unwrap_or_default();
2010 println!("{label}: {status}{reason}{last_success}");
2011 }
2012 Ok(())
2013}
2014
2015fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2016 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2017 let client = crate::relay_client::RelayClient::new(&relay_url);
2018
2019 let started = std::time::Instant::now();
2020 let transport_ok = client.healthz().unwrap_or(false);
2021 let latency_ms = started.elapsed().as_millis() as u64;
2022
2023 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2024 let now = std::time::SystemTime::now()
2025 .duration_since(std::time::UNIX_EPOCH)
2026 .map(|d| d.as_secs())
2027 .unwrap_or(0);
2028 let attention = match last_pull_at_unix {
2029 Some(last) if now.saturating_sub(last) <= 300 => json!({
2030 "status": "ok",
2031 "last_pull_at_unix": last,
2032 "age_seconds": now.saturating_sub(last),
2033 "event_count": event_count,
2034 }),
2035 Some(last) => json!({
2036 "status": "stale",
2037 "last_pull_at_unix": last,
2038 "age_seconds": now.saturating_sub(last),
2039 "event_count": event_count,
2040 }),
2041 None => json!({
2042 "status": "never_pulled",
2043 "last_pull_at_unix": Value::Null,
2044 "event_count": event_count,
2045 }),
2046 };
2047
2048 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2049 let responder = if responder_health.is_null() {
2050 json!({"status": "not_reported", "record": Value::Null})
2051 } else {
2052 json!({
2053 "status": responder_health
2054 .get("status")
2055 .and_then(Value::as_str)
2056 .unwrap_or("unknown"),
2057 "record": responder_health,
2058 })
2059 };
2060
2061 let report = json!({
2062 "peer": peer,
2063 "transport": {
2064 "status": if transport_ok { "ok" } else { "error" },
2065 "relay_url": relay_url,
2066 "latency_ms": latency_ms,
2067 },
2068 "attention": attention,
2069 "responder": responder,
2070 });
2071
2072 if as_json {
2073 println!("{}", serde_json::to_string(&report)?);
2074 } else {
2075 let transport_line = if transport_ok {
2076 format!("ok relay reachable ({latency_ms}ms)")
2077 } else {
2078 "error relay unreachable".to_string()
2079 };
2080 println!("transport {transport_line}");
2081 match report["attention"]["status"].as_str().unwrap_or("unknown") {
2082 "ok" => println!(
2083 "attention ok last pull {}s ago",
2084 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2085 ),
2086 "stale" => println!(
2087 "attention stale last pull {}m ago",
2088 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2089 ),
2090 "never_pulled" => println!("attention never pulled since relay reset"),
2091 other => println!("attention {other}"),
2092 }
2093 if report["responder"]["status"] == "not_reported" {
2094 println!("auto-responder not reported");
2095 } else {
2096 let record = &report["responder"]["record"];
2097 let status = record
2098 .get("status")
2099 .and_then(Value::as_str)
2100 .unwrap_or("unknown");
2101 let reason = record
2102 .get("reason")
2103 .and_then(Value::as_str)
2104 .map(|r| format!(" — {r}"))
2105 .unwrap_or_default();
2106 println!("auto-responder {status}{reason}");
2107 }
2108 }
2109 Ok(())
2110}
2111
2112fn current_cwd_display() -> String {
2120 let cwd = match std::env::current_dir() {
2121 Ok(c) => c,
2122 Err(_) => return String::from("?"),
2123 };
2124 if let Some(home) = dirs::home_dir()
2125 && let Ok(rel) = cwd.strip_prefix(&home)
2126 {
2127 let rel_str = rel.to_string_lossy();
2129 if rel_str.is_empty() {
2130 return String::from("~");
2131 }
2132 return format!("~/{}", rel_str);
2133 }
2134 cwd.to_string_lossy().into_owned()
2135}
2136
2137fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2138 if !config::is_initialized()? {
2139 bail!("not initialized — run `wire init <handle>` first");
2140 }
2141 let card = config::read_agent_card()?;
2142 let did = card
2143 .get("did")
2144 .and_then(Value::as_str)
2145 .unwrap_or("")
2146 .to_string();
2147 let handle = card
2148 .get("handle")
2149 .and_then(Value::as_str)
2150 .map(str::to_string)
2151 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2152 let overrides = config::read_display_overrides().unwrap_or_default();
2155 let character = crate::character::Character::from_did_with_override(
2156 &did,
2157 overrides.nickname.as_deref(),
2158 overrides.emoji.as_deref(),
2159 );
2160
2161 let cwd_display = current_cwd_display();
2167
2168 if short {
2171 println!("{} · {}", character.short(), cwd_display);
2172 return Ok(());
2173 }
2174 if colored {
2175 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2176 return Ok(());
2177 }
2178
2179 let pk_b64 = card
2180 .get("verify_keys")
2181 .and_then(Value::as_object)
2182 .and_then(|m| m.values().next())
2183 .and_then(|v| v.get("key"))
2184 .and_then(Value::as_str)
2185 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2186 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2187 let fp = fingerprint(&pk_bytes);
2188 let key_id = make_key_id(&handle, &pk_bytes);
2189 let capabilities = card
2190 .get("capabilities")
2191 .cloned()
2192 .unwrap_or_else(|| json!(["wire/v3.1"]));
2193
2194 if as_json {
2195 let has_override = overrides.nickname.is_some() || overrides.emoji.is_some();
2196 println!(
2197 "{}",
2198 serde_json::to_string(&json!({
2199 "did": did,
2200 "handle": handle,
2201 "fingerprint": fp,
2202 "key_id": key_id,
2203 "public_key_b64": pk_b64,
2204 "capabilities": capabilities,
2205 "config_dir": config::config_dir()?.to_string_lossy(),
2206 "character": character,
2207 "character_override": has_override,
2208 }))?
2209 );
2210 } else {
2211 println!("{}", character.colored());
2212 println!("{did} (ed25519:{key_id})");
2213 println!("fingerprint: {fp}");
2214 println!("capabilities: {capabilities}");
2215 }
2216 Ok(())
2217}
2218
2219fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2222 match cmd {
2223 IdentityCommand::Rename {
2224 name,
2225 emoji,
2226 clear,
2227 random,
2228 json,
2229 } => cmd_identity_rename(name, emoji, clear || random, random, json),
2230 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2231 IdentityCommand::List { json } => cmd_session_list(json),
2232 IdentityCommand::Publish {
2233 nick,
2234 relay,
2235 public_url,
2236 hidden,
2237 json,
2238 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2239 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2240 IdentityCommand::Create {
2241 name,
2242 anonymous,
2243 local: _,
2244 json,
2245 } => cmd_identity_create(name.as_deref(), anonymous, json),
2246 IdentityCommand::Persist {
2247 name,
2248 as_name,
2249 json,
2250 } => cmd_identity_persist(&name, as_name.as_deref(), json),
2251 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2252 }
2253}
2254
2255fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2260 if anonymous {
2261 let rand_suffix = format!("{:08x}", rand::random::<u32>());
2263 let anon_name = name
2264 .map(crate::session::sanitize_name)
2265 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2266 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2267 std::fs::create_dir_all(&anon_root)
2268 .with_context(|| format!("creating anon root {anon_root:?}"))?;
2269 let session_home = anon_root.join("sessions").join(&anon_name);
2271 std::fs::create_dir_all(&session_home)?;
2272 let status = run_wire_with_home(&session_home, &["init", &anon_name])?;
2273 if !status.success() {
2274 bail!("anonymous identity init failed: {status}");
2275 }
2276 let marker = anon_root.join("anon-marker.json");
2279 std::fs::write(
2280 &marker,
2281 serde_json::to_vec_pretty(&serde_json::json!({
2282 "name": anon_name,
2283 "session_home": session_home.to_string_lossy(),
2284 "created_at": time::OffsetDateTime::now_utc()
2285 .format(&time::format_description::well_known::Rfc3339)
2286 .unwrap_or_default(),
2287 "kind": "anonymous",
2288 }))?,
2289 )?;
2290 let card = serde_json::from_slice::<Value>(&std::fs::read(
2291 session_home
2292 .join("config")
2293 .join("wire")
2294 .join("agent-card.json"),
2295 )?)?;
2296 let did = card
2297 .get("did")
2298 .and_then(Value::as_str)
2299 .unwrap_or("")
2300 .to_string();
2301 if as_json {
2302 println!(
2303 "{}",
2304 serde_json::to_string(&json!({
2305 "kind": "anonymous",
2306 "name": anon_name,
2307 "did": did,
2308 "session_home": session_home.to_string_lossy(),
2309 "anon_root": anon_root.to_string_lossy(),
2310 }))?
2311 );
2312 } else {
2313 println!("created anonymous identity `{anon_name}` ({did})");
2314 println!(
2315 " session_home: {} (dies on reboot — /tmp)",
2316 session_home.display()
2317 );
2318 println!();
2319 println!("activate in this shell:");
2320 println!(" export WIRE_HOME={}", session_home.display());
2321 println!();
2322 println!("promote to persistent later with:");
2323 println!(" wire identity persist {anon_name}");
2324 }
2325 return Ok(());
2326 }
2327 let name_arg = name.map(|s| s.to_string());
2329 cmd_session_new(
2330 name_arg.as_deref(),
2331 "https://wireup.net",
2332 false,
2333 "http://127.0.0.1:8771",
2334 false,
2335 None,
2336 false,
2337 None,
2338 true, true, as_json,
2341 )
2342}
2343
2344fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2347 let temp = std::env::temp_dir();
2349 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2350 for entry in std::fs::read_dir(&temp)?.flatten() {
2351 let path = entry.path();
2352 if !path
2353 .file_name()
2354 .and_then(|s| s.to_str())
2355 .map(|s| s.starts_with("wire-anon-"))
2356 .unwrap_or(false)
2357 {
2358 continue;
2359 }
2360 let marker = path.join("anon-marker.json");
2361 if let Ok(bytes) = std::fs::read(&marker)
2362 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2363 && json.get("name").and_then(Value::as_str) == Some(name)
2364 {
2365 let session_home = json
2366 .get("session_home")
2367 .and_then(Value::as_str)
2368 .map(std::path::PathBuf::from)
2369 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2370 found = Some((path, session_home));
2371 break;
2372 }
2373 }
2374 let (anon_root, anon_session_home) = found.ok_or_else(|| {
2375 anyhow!(
2376 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2377 run `wire identity list` to see available identities"
2378 )
2379 })?;
2380
2381 let new_name = as_name.unwrap_or(name);
2382 let new_session_home = crate::session::session_dir(new_name)?;
2383 if new_session_home.exists() {
2384 bail!(
2385 "target session `{new_name}` already exists at {new_session_home:?} — \
2386 pick a different name with --as <new-name>"
2387 );
2388 }
2389
2390 if let Some(parent) = new_session_home.parent() {
2392 std::fs::create_dir_all(parent)?;
2393 }
2394 std::fs::rename(&anon_session_home, &new_session_home)
2395 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2396
2397 let _ = std::fs::remove_dir_all(&anon_root);
2399
2400 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2403 let cwd_key = cwd.to_string_lossy().into_owned();
2404 let new_name_for_reg = new_name.to_string();
2405 if let Err(e) = crate::session::update_registry(|reg| {
2406 reg.by_cwd.insert(cwd_key, new_name_for_reg);
2407 Ok(())
2408 }) {
2409 eprintln!("wire identity persist: failed to update registry: {e:#}");
2410 }
2411
2412 if as_json {
2413 println!(
2414 "{}",
2415 serde_json::to_string(&json!({
2416 "kind": "persisted",
2417 "from_name": name,
2418 "to_name": new_name,
2419 "session_home": new_session_home.to_string_lossy(),
2420 }))?
2421 );
2422 } else {
2423 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2424 println!(
2425 " session_home: {} (survives reboot)",
2426 new_session_home.display()
2427 );
2428 println!(" registered cwd: {}", cwd.display());
2429 }
2430 Ok(())
2431}
2432
2433fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2439 let sessions = crate::session::list_sessions()?;
2440 let session = sessions
2441 .iter()
2442 .find(|s| s.name == name)
2443 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2444 let relay_state_path = session
2445 .home_dir
2446 .join("config")
2447 .join("wire")
2448 .join("relay.json");
2449 if !relay_state_path.exists() {
2450 bail!("session `{name}` has no relay state — already demoted?");
2451 }
2452 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2453 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2454 let had_fed = self_obj
2455 .get("relay_url")
2456 .and_then(Value::as_str)
2457 .map(|u| {
2458 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2459 })
2460 .unwrap_or(false);
2461 if !had_fed {
2462 if as_json {
2463 println!(
2464 "{}",
2465 serde_json::to_string(
2466 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2467 )?
2468 );
2469 } else {
2470 println!("session `{name}` has no federation slot — nothing to demote");
2471 }
2472 return Ok(());
2473 }
2474 if let Some(self_mut) = state
2477 .as_object_mut()
2478 .and_then(|m| m.get_mut("self"))
2479 .and_then(|s| s.as_object_mut())
2480 {
2481 self_mut.remove("relay_url");
2482 self_mut.remove("slot_id");
2483 self_mut.remove("slot_token");
2484 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2485 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2486 }
2487 }
2488 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2489
2490 if as_json {
2491 println!(
2492 "{}",
2493 serde_json::to_string(
2494 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2495 )?
2496 );
2497 } else {
2498 println!("demoted `{name}` from federation → local");
2499 println!(" relay slot binding removed; keypair + agent-card retained");
2500 println!(" re-publish with `wire identity publish <nick>`");
2501 }
2502 Ok(())
2503}
2504
2505fn cmd_identity_rename(
2506 name: Option<String>,
2507 emoji: Option<String>,
2508 clear: bool,
2509 random_announce: bool,
2510 as_json: bool,
2511) -> Result<()> {
2512 if !config::is_initialized()? {
2513 bail!("not initialized — run `wire init <handle>` first");
2514 }
2515
2516 let card = config::read_agent_card()?;
2518 let did = card
2519 .get("did")
2520 .and_then(Value::as_str)
2521 .unwrap_or("")
2522 .to_string();
2523
2524 let new_overrides = if clear {
2525 config::DisplayOverrides::default()
2526 } else {
2527 let mut existing = config::read_display_overrides().unwrap_or_default();
2529 if let Some(n) = name {
2530 let cleaned = crate::character::sanitize_display_text(&n);
2535 if cleaned.is_empty() {
2536 bail!(
2537 "nickname `{n:?}` is empty after stripping control characters — pick a name with printable codepoints (max {} chars).",
2538 crate::character::MAX_DISPLAY_CHARS
2539 );
2540 }
2541 if cleaned != n {
2542 eprintln!(
2543 "wire identity rename: stripped control characters from nickname → `{cleaned}`"
2544 );
2545 }
2546 existing.nickname = Some(cleaned);
2547 }
2548 if let Some(e) = emoji {
2549 let cleaned = crate::character::sanitize_display_text(&e);
2550 if cleaned.is_empty() {
2551 bail!(
2552 "emoji `{e:?}` is empty after stripping control characters — pick a printable emoji glyph."
2553 );
2554 }
2555 if cleaned != e {
2556 eprintln!(
2557 "wire identity rename: stripped control characters from emoji → `{cleaned}`"
2558 );
2559 }
2560 existing.emoji = Some(cleaned);
2561 }
2562 existing
2563 };
2564
2565 let no_fields_provided = new_overrides.nickname.is_none()
2568 && new_overrides.emoji.is_none()
2569 && !clear
2570 && !random_announce;
2571 if no_fields_provided {
2572 bail!("nothing to do — pass --name, --emoji, --clear, or --random");
2573 }
2574
2575 config::write_display_overrides(&new_overrides)?;
2576
2577 let signed_card = {
2589 let mut card = config::read_agent_card()?;
2590 if let Some(card_obj) = card.as_object_mut() {
2591 card_obj.remove("signature");
2594 if new_overrides.nickname.is_none() && new_overrides.emoji.is_none() {
2595 card_obj.remove("display");
2596 } else {
2597 let mut display = serde_json::Map::new();
2598 if let Some(n) = &new_overrides.nickname {
2599 display.insert("nickname".into(), Value::String(n.clone()));
2600 }
2601 if let Some(e) = &new_overrides.emoji {
2602 display.insert("emoji".into(), Value::String(e.clone()));
2603 }
2604 card_obj.insert("display".into(), Value::Object(display));
2605 }
2606 }
2607 let sk_seed = config::read_private_key()?;
2608 let signed = crate::agent_card::sign_agent_card(&card, &sk_seed);
2609 config::write_agent_card(&signed)?;
2610 signed
2611 };
2612
2613 if let Ok(state) = config::read_relay_state() {
2617 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2618 let fed_url = self_obj.get("relay_url").and_then(Value::as_str);
2619 let fed_slot_id = self_obj.get("slot_id").and_then(Value::as_str);
2620 let fed_slot_token = self_obj.get("slot_token").and_then(Value::as_str);
2621 if let (Some(url), Some(slot_id), Some(slot_token)) = (fed_url, fed_slot_id, fed_slot_token)
2622 {
2623 let is_publishable = url.starts_with("https://")
2626 || (url.starts_with("http://")
2627 && !url.contains("127.0.0.1")
2628 && !url.contains("localhost"));
2629 if is_publishable {
2630 let nick_for_claim = signed_card
2631 .get("handle")
2632 .and_then(Value::as_str)
2633 .map(str::to_string);
2634 if let Some(nick) = nick_for_claim {
2635 let client = crate::relay_client::RelayClient::new(url);
2636 match client.handle_claim_v2(
2637 &nick,
2638 slot_id,
2639 slot_token,
2640 None,
2641 &signed_card,
2642 None,
2643 ) {
2644 Ok(_) => {
2645 eprintln!("wire identity rename: re-published updated card to {url}");
2646 }
2647 Err(e) => {
2648 eprintln!(
2649 "wire identity rename: failed to re-publish to relay {url}: {e:#} — local rename is in effect; federated peers will see the old card until next `wire claim` succeeds"
2650 );
2651 }
2652 }
2653 }
2654 }
2655 }
2656 }
2657
2658 if random_announce {
2659 eprintln!(
2660 "wire identity rename: overrides cleared; falling back to auto-derived character (DID-deterministic, so the character is the same as it was before any rename)."
2661 );
2662 }
2663
2664 let character = crate::character::Character::from_did_with_override(
2665 &did,
2666 new_overrides.nickname.as_deref(),
2667 new_overrides.emoji.as_deref(),
2668 );
2669
2670 if as_json {
2671 println!(
2672 "{}",
2673 serde_json::to_string(&json!({
2674 "did": did,
2675 "character": character,
2676 "overrides": new_overrides,
2677 }))?
2678 );
2679 } else {
2680 println!("renamed → {}", character.colored());
2681 eprintln!(" · palette stays DID-derived (sticky color across renames)");
2682 eprintln!(
2683 " · re-published to your federation relay (if bound); future federation lookups serve \
2684 the updated card. Existing pinned peers have a cached card from pair-time and won't \
2685 see the new name until they re-pair OR fetch your card fresh."
2686 );
2687 }
2688 Ok(())
2689}
2690
2691fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2706 let raw = crate::trust::get_tier(trust, handle);
2707 if raw != "VERIFIED" {
2708 return raw.to_string();
2709 }
2710 let token = relay_state
2711 .get("peers")
2712 .and_then(|p| p.get(handle))
2713 .and_then(|p| p.get("slot_token"))
2714 .and_then(Value::as_str)
2715 .unwrap_or("");
2716 if token.is_empty() {
2717 "PENDING_ACK".to_string()
2718 } else {
2719 raw.to_string()
2720 }
2721}
2722
2723fn cmd_peers(as_json: bool) -> Result<()> {
2724 let trust = config::read_trust()?;
2725 let agents = trust
2726 .get("agents")
2727 .and_then(Value::as_object)
2728 .cloned()
2729 .unwrap_or_default();
2730 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2731
2732 let mut self_did: Option<String> = None;
2733 if let Ok(card) = config::read_agent_card() {
2734 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2735 }
2736
2737 let mut peers = Vec::new();
2738 for (handle, agent) in agents.iter() {
2739 let did = agent
2740 .get("did")
2741 .and_then(Value::as_str)
2742 .unwrap_or("")
2743 .to_string();
2744 if Some(did.as_str()) == self_did.as_deref() {
2745 continue; }
2747 let tier = effective_peer_tier(&trust, &relay_state, handle);
2748 let capabilities = agent
2749 .get("card")
2750 .and_then(|c| c.get("capabilities"))
2751 .cloned()
2752 .unwrap_or_else(|| json!([]));
2753 let character = if did.is_empty() {
2758 None
2759 } else {
2760 let card_obj = agent.get("card");
2761 Some(match card_obj {
2762 Some(card) => crate::character::Character::from_card(card),
2763 None => crate::character::Character::from_did(&did),
2764 })
2765 };
2766 peers.push(json!({
2767 "handle": handle,
2768 "did": did,
2769 "tier": tier,
2770 "capabilities": capabilities,
2771 "character": character,
2772 }));
2773 }
2774
2775 if as_json {
2776 println!("{}", serde_json::to_string(&peers)?);
2777 } else if peers.is_empty() {
2778 println!("no peers pinned (run `wire join <code>` to pair)");
2779 } else {
2780 for p in &peers {
2786 let char_json = &p["character"];
2787 let (colored_char, plain_len): (String, usize) = match char_json {
2788 serde_json::Value::Null => ("?".to_string(), 1),
2789 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2790 Ok(c) => {
2791 let plain = c.short().chars().count() + 1; (c.colored(), plain)
2793 }
2794 Err(_) => ("?".to_string(), 1),
2795 },
2796 };
2797 let pad = 22usize.saturating_sub(plain_len);
2798 println!(
2799 "{}{} {:<20} {:<10} {}",
2800 colored_char,
2801 " ".repeat(pad),
2802 p["handle"].as_str().unwrap_or(""),
2803 p["tier"].as_str().unwrap_or(""),
2804 p["did"].as_str().unwrap_or(""),
2805 );
2806 }
2807 }
2808 Ok(())
2809}
2810
2811fn maybe_warn_peer_attentiveness(peer: &str) {
2821 let state = match config::read_relay_state() {
2822 Ok(s) => s,
2823 Err(_) => return,
2824 };
2825 let p = state.get("peers").and_then(|p| p.get(peer));
2826 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2827 Some(s) if !s.is_empty() => s,
2828 _ => return,
2829 };
2830 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2831 Some(s) if !s.is_empty() => s,
2832 _ => return,
2833 };
2834 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
2835 Some(s) if !s.is_empty() => s.to_string(),
2836 _ => match state
2837 .get("self")
2838 .and_then(|s| s.get("relay_url"))
2839 .and_then(Value::as_str)
2840 {
2841 Some(s) if !s.is_empty() => s.to_string(),
2842 _ => return,
2843 },
2844 };
2845 let client = crate::relay_client::RelayClient::new(&relay_url);
2846 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
2847 Ok(t) => t,
2848 Err(_) => return,
2849 };
2850 let now = std::time::SystemTime::now()
2851 .duration_since(std::time::UNIX_EPOCH)
2852 .map(|d| d.as_secs())
2853 .unwrap_or(0);
2854 match last_pull {
2855 None => {
2856 eprintln!(
2857 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
2858 );
2859 }
2860 Some(t) if now.saturating_sub(t) > 300 => {
2861 let mins = now.saturating_sub(t) / 60;
2862 eprintln!(
2863 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
2864 );
2865 }
2866 _ => {}
2867 }
2868}
2869
2870pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
2871 let trimmed = input.trim();
2872 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
2873 {
2874 return Ok(trimmed.to_string());
2875 }
2876 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
2877 let n: i64 = amount
2878 .parse()
2879 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
2880 if n <= 0 {
2881 bail!("deadline duration must be positive: {input:?}");
2882 }
2883 let duration = match unit {
2884 "m" => time::Duration::minutes(n),
2885 "h" => time::Duration::hours(n),
2886 "d" => time::Duration::days(n),
2887 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
2888 };
2889 Ok((time::OffsetDateTime::now_utc() + duration)
2890 .format(&time::format_description::well_known::Rfc3339)
2891 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
2892}
2893
2894fn cmd_send(
2895 peer: &str,
2896 kind: &str,
2897 body_arg: &str,
2898 deadline: Option<&str>,
2899 as_json: bool,
2900) -> Result<()> {
2901 if !config::is_initialized()? {
2902 bail!("not initialized — run `wire init <handle>` first");
2903 }
2904 let peer_in = crate::agent_card::bare_handle(peer).to_string();
2905 let peer = match resolve_peer_handle(&peer_in) {
2912 Ok(Some(resolved)) if resolved != peer_in => {
2913 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
2914 resolved
2915 }
2916 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
2919 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
2920 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
2921 candidates.len(),
2922 candidates.join(", ")
2923 ),
2924 Err(ResolveError::NotFound) => peer_in, };
2926 let peer = peer.as_str();
2927 let sk_seed = config::read_private_key()?;
2928 let card = config::read_agent_card()?;
2929 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
2930 let handle = crate::agent_card::display_handle_from_did(did).to_string();
2931 let pk_b64 = card
2932 .get("verify_keys")
2933 .and_then(Value::as_object)
2934 .and_then(|m| m.values().next())
2935 .and_then(|v| v.get("key"))
2936 .and_then(Value::as_str)
2937 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2938 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2939
2940 let body_value: Value = if body_arg == "-" {
2945 use std::io::Read;
2946 let mut raw = String::new();
2947 std::io::stdin()
2948 .read_to_string(&mut raw)
2949 .with_context(|| "reading body from stdin")?;
2950 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
2953 } else if let Some(path) = body_arg.strip_prefix('@') {
2954 let raw =
2955 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
2956 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
2957 } else {
2958 Value::String(body_arg.to_string())
2959 };
2960
2961 let kind_id = parse_kind(kind)?;
2962
2963 let now = time::OffsetDateTime::now_utc()
2964 .format(&time::format_description::well_known::Rfc3339)
2965 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2966
2967 let mut event = json!({
2968 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
2969 "timestamp": now,
2970 "from": did,
2971 "to": format!("did:wire:{peer}"),
2972 "type": kind,
2973 "kind": kind_id,
2974 "body": body_value,
2975 });
2976 if let Some(deadline) = deadline {
2977 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
2978 }
2979 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
2980 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
2981
2982 maybe_warn_peer_attentiveness(peer);
2987
2988 let line = serde_json::to_vec(&signed)?;
2993 let outbox = config::append_outbox_record(peer, &line)?;
2994
2995 if as_json {
2996 println!(
2997 "{}",
2998 serde_json::to_string(&json!({
2999 "event_id": event_id,
3000 "status": "queued",
3001 "peer": peer,
3002 "outbox": outbox.to_string_lossy(),
3003 }))?
3004 );
3005 } else {
3006 println!(
3007 "queued event {event_id} → {peer} (outbox: {})",
3008 outbox.display()
3009 );
3010 }
3011 Ok(())
3012}
3013
3014fn parse_kind(s: &str) -> Result<u32> {
3015 if let Ok(n) = s.parse::<u32>() {
3016 return Ok(n);
3017 }
3018 for (id, name) in crate::signing::kinds() {
3019 if *name == s {
3020 return Ok(*id);
3021 }
3022 }
3023 Ok(1)
3025}
3026
3027fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3030 let inbox = config::inbox_dir()?;
3031 if !inbox.exists() {
3032 if !as_json {
3033 eprintln!("no inbox yet — daemon hasn't run, or no events received");
3034 }
3035 return Ok(());
3036 }
3037 let trust = config::read_trust()?;
3038 let mut count = 0usize;
3039
3040 let entries: Vec<_> = std::fs::read_dir(&inbox)?
3041 .filter_map(|e| e.ok())
3042 .map(|e| e.path())
3043 .filter(|p| {
3044 p.extension().map(|x| x == "jsonl").unwrap_or(false)
3045 && match peer {
3046 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3047 None => true,
3048 }
3049 })
3050 .collect();
3051
3052 for path in entries {
3053 let body = std::fs::read_to_string(&path)?;
3054 for line in body.lines() {
3055 let event: Value = match serde_json::from_str(line) {
3056 Ok(v) => v,
3057 Err(_) => continue,
3058 };
3059 let verified = verify_message_v31(&event, &trust).is_ok();
3060 if as_json {
3061 let mut event_with_meta = event.clone();
3062 if let Some(obj) = event_with_meta.as_object_mut() {
3063 obj.insert("verified".into(), json!(verified));
3064 }
3065 println!("{}", serde_json::to_string(&event_with_meta)?);
3066 } else {
3067 let ts = event
3068 .get("timestamp")
3069 .and_then(Value::as_str)
3070 .unwrap_or("?");
3071 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3072 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3073 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3074 let summary = event
3075 .get("body")
3076 .map(|b| match b {
3077 Value::String(s) => s.clone(),
3078 _ => b.to_string(),
3079 })
3080 .unwrap_or_default();
3081 let mark = if verified { "✓" } else { "✗" };
3082 let deadline = event
3083 .get("time_sensitive_until")
3084 .and_then(Value::as_str)
3085 .map(|d| format!(" deadline: {d}"))
3086 .unwrap_or_default();
3087 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3088 }
3089 count += 1;
3090 if limit > 0 && count >= limit {
3091 return Ok(());
3092 }
3093 }
3094 }
3095 Ok(())
3096}
3097
3098fn monitor_is_noise_kind(kind: &str) -> bool {
3104 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3105}
3106
3107fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3111 if as_json {
3112 Ok(serde_json::to_string(e)?)
3113 } else {
3114 let eid_short: String = e.event_id.chars().take(12).collect();
3115 let body = e.body_preview.replace('\n', " ");
3116 let ts: String = e.timestamp.chars().take(19).collect();
3117 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3118 }
3119}
3120
3121fn cmd_monitor(
3137 peer_filter: Option<&str>,
3138 as_json: bool,
3139 include_handshake: bool,
3140 interval_ms: u64,
3141 replay: usize,
3142) -> Result<()> {
3143 let inbox_dir = config::inbox_dir()?;
3144 if !inbox_dir.exists() && !as_json {
3145 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3146 }
3147 if replay > 0 && inbox_dir.exists() {
3153 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3154 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3155 let path = entry.path();
3156 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3157 continue;
3158 }
3159 let peer = match path.file_stem().and_then(|s| s.to_str()) {
3160 Some(s) => s.to_string(),
3161 None => continue,
3162 };
3163 if let Some(filter) = peer_filter
3164 && peer != filter
3165 {
3166 continue;
3167 }
3168 let body = std::fs::read_to_string(&path).unwrap_or_default();
3169 for line in body.lines() {
3170 let line = line.trim();
3171 if line.is_empty() {
3172 continue;
3173 }
3174 let signed: Value = match serde_json::from_str(line) {
3175 Ok(v) => v,
3176 Err(_) => continue,
3177 };
3178 let ev = crate::inbox_watch::InboxEvent::from_signed(
3179 &peer, signed, true,
3180 );
3181 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3182 continue;
3183 }
3184 all.push(ev);
3185 }
3186 }
3187 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3190 let start = all.len().saturating_sub(replay);
3191 for ev in &all[start..] {
3192 println!("{}", monitor_render(ev, as_json)?);
3193 }
3194 use std::io::Write;
3195 std::io::stdout().flush().ok();
3196 }
3197
3198 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3201 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3202
3203 loop {
3204 let events = w.poll()?;
3205 let mut wrote = false;
3206 for ev in events {
3207 if let Some(filter) = peer_filter
3208 && ev.peer != filter
3209 {
3210 continue;
3211 }
3212 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3213 continue;
3214 }
3215 println!("{}", monitor_render(&ev, as_json)?);
3216 wrote = true;
3217 }
3218 if wrote {
3219 use std::io::Write;
3220 std::io::stdout().flush().ok();
3221 }
3222 std::thread::sleep(sleep_dur);
3223 }
3224}
3225
3226#[cfg(test)]
3227mod tier_tests {
3228 use super::*;
3229 use serde_json::json;
3230
3231 fn trust_with(handle: &str, tier: &str) -> Value {
3232 json!({
3233 "version": 1,
3234 "agents": {
3235 handle: {
3236 "tier": tier,
3237 "did": format!("did:wire:{handle}"),
3238 "card": {"capabilities": ["wire/v3.1"]}
3239 }
3240 }
3241 })
3242 }
3243
3244 #[test]
3245 fn pending_ack_when_verified_but_no_slot_token() {
3246 let trust = trust_with("willard", "VERIFIED");
3250 let relay_state = json!({
3251 "peers": {
3252 "willard": {
3253 "relay_url": "https://relay",
3254 "slot_id": "abc",
3255 "slot_token": "",
3256 }
3257 }
3258 });
3259 assert_eq!(
3260 effective_peer_tier(&trust, &relay_state, "willard"),
3261 "PENDING_ACK"
3262 );
3263 }
3264
3265 #[test]
3266 fn verified_when_slot_token_present() {
3267 let trust = trust_with("willard", "VERIFIED");
3268 let relay_state = json!({
3269 "peers": {
3270 "willard": {
3271 "relay_url": "https://relay",
3272 "slot_id": "abc",
3273 "slot_token": "tok123",
3274 }
3275 }
3276 });
3277 assert_eq!(
3278 effective_peer_tier(&trust, &relay_state, "willard"),
3279 "VERIFIED"
3280 );
3281 }
3282
3283 #[test]
3284 fn raw_tier_passes_through_for_non_verified() {
3285 let trust = trust_with("willard", "UNTRUSTED");
3288 let relay_state = json!({
3289 "peers": {"willard": {"slot_token": ""}}
3290 });
3291 assert_eq!(
3292 effective_peer_tier(&trust, &relay_state, "willard"),
3293 "UNTRUSTED"
3294 );
3295 }
3296
3297 #[test]
3298 fn pending_ack_when_relay_state_missing_peer() {
3299 let trust = trust_with("willard", "VERIFIED");
3303 let relay_state = json!({"peers": {}});
3304 assert_eq!(
3305 effective_peer_tier(&trust, &relay_state, "willard"),
3306 "PENDING_ACK"
3307 );
3308 }
3309}
3310
3311#[cfg(test)]
3312mod monitor_tests {
3313 use super::*;
3314 use crate::inbox_watch::InboxEvent;
3315 use serde_json::Value;
3316
3317 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
3318 InboxEvent {
3319 peer: peer.to_string(),
3320 event_id: "abcd1234567890ef".to_string(),
3321 kind: kind.to_string(),
3322 body_preview: body.to_string(),
3323 verified: true,
3324 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
3325 raw: Value::Null,
3326 }
3327 }
3328
3329 #[test]
3330 fn monitor_filter_drops_handshake_kinds_by_default() {
3331 assert!(monitor_is_noise_kind("pair_drop"));
3336 assert!(monitor_is_noise_kind("pair_drop_ack"));
3337 assert!(monitor_is_noise_kind("heartbeat"));
3338
3339 assert!(!monitor_is_noise_kind("claim"));
3341 assert!(!monitor_is_noise_kind("decision"));
3342 assert!(!monitor_is_noise_kind("ack"));
3343 assert!(!monitor_is_noise_kind("request"));
3344 assert!(!monitor_is_noise_kind("note"));
3345 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
3349 }
3350
3351 #[test]
3352 fn monitor_render_plain_is_one_short_line() {
3353 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
3354 let line = monitor_render(&e, false).unwrap();
3355 assert!(!line.contains('\n'), "render must be one line: {line}");
3357 assert!(line.contains("willard"));
3359 assert!(line.contains("claim"));
3360 assert!(line.contains("real v8 train"));
3361 assert!(line.contains("abcd12345678"));
3363 assert!(
3364 !line.contains("abcd1234567890ef"),
3365 "should truncate full id"
3366 );
3367 assert!(line.contains("2026-05-15T23:14:07"));
3369 }
3370
3371 #[test]
3372 fn monitor_render_strips_newlines_from_body() {
3373 let e = ev("spark", "claim", "line one\nline two\nline three");
3378 let line = monitor_render(&e, false).unwrap();
3379 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
3380 assert!(line.contains("line one line two line three"));
3381 }
3382
3383 #[test]
3384 fn monitor_render_json_is_valid_jsonl() {
3385 let e = ev("spark", "claim", "hi");
3386 let line = monitor_render(&e, true).unwrap();
3387 assert!(!line.contains('\n'));
3388 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
3389 assert_eq!(parsed["peer"], "spark");
3390 assert_eq!(parsed["kind"], "claim");
3391 assert_eq!(parsed["body_preview"], "hi");
3392 }
3393
3394 #[test]
3395 fn monitor_does_not_drop_on_verified_null() {
3396 let mut e = ev("spark", "claim", "from disk with verified=null");
3407 e.verified = false; let line = monitor_render(&e, false).unwrap();
3409 assert!(line.contains("from disk with verified=null"));
3410 assert!(!monitor_is_noise_kind("claim"));
3412 }
3413}
3414
3415fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
3418 let body = if path == "-" {
3419 let mut buf = String::new();
3420 use std::io::Read;
3421 std::io::stdin().read_to_string(&mut buf)?;
3422 buf
3423 } else {
3424 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
3425 };
3426 let event: Value = serde_json::from_str(&body)?;
3427 let trust = config::read_trust()?;
3428 match verify_message_v31(&event, &trust) {
3429 Ok(()) => {
3430 if as_json {
3431 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
3432 } else {
3433 println!("verified ✓");
3434 }
3435 Ok(())
3436 }
3437 Err(e) => {
3438 let reason = e.to_string();
3439 if as_json {
3440 println!(
3441 "{}",
3442 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
3443 );
3444 } else {
3445 eprintln!("FAILED: {reason}");
3446 }
3447 std::process::exit(1);
3448 }
3449 }
3450}
3451
3452fn cmd_mcp() -> Result<()> {
3455 crate::mcp::run()
3456}
3457
3458fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
3459 if let Some(socket_path) = uds {
3464 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
3465 std::path::PathBuf::from(home)
3466 .join("state")
3467 .join("wire-relay")
3468 .join("uds")
3469 } else {
3470 dirs::state_dir()
3471 .or_else(dirs::data_local_dir)
3472 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
3473 .join("wire-relay")
3474 .join("uds")
3475 };
3476 let runtime = tokio::runtime::Builder::new_multi_thread()
3477 .enable_all()
3478 .build()?;
3479 return runtime.block_on(crate::relay_server::serve_uds(
3480 socket_path.to_path_buf(),
3481 base,
3482 ));
3483 }
3484 if local_only {
3488 validate_loopback_bind(bind)?;
3489 }
3490 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
3496 std::path::PathBuf::from(home)
3497 .join("state")
3498 .join("wire-relay")
3499 } else {
3500 dirs::state_dir()
3501 .or_else(dirs::data_local_dir)
3502 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
3503 .join("wire-relay")
3504 };
3505 let state_dir = if local_only { base.join("local") } else { base };
3506 let runtime = tokio::runtime::Builder::new_multi_thread()
3507 .enable_all()
3508 .build()?;
3509 runtime.block_on(crate::relay_server::serve_with_mode(
3510 bind,
3511 state_dir,
3512 crate::relay_server::ServerMode { local_only },
3513 ))
3514}
3515
3516fn validate_loopback_bind(bind: &str) -> Result<()> {
3534 let host = if let Some(stripped) = bind.strip_prefix('[') {
3536 let close = stripped
3537 .find(']')
3538 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
3539 stripped[..close].to_string()
3540 } else {
3541 bind.rsplit_once(':')
3542 .map(|(h, _)| h.to_string())
3543 .unwrap_or_else(|| bind.to_string())
3544 };
3545 use std::net::{IpAddr, ToSocketAddrs};
3546 let probe = format!("{host}:0");
3547 let resolved: Vec<_> = probe
3548 .to_socket_addrs()
3549 .with_context(|| format!("resolving bind host {host:?}"))?
3550 .collect();
3551 if resolved.is_empty() {
3552 bail!("--local-only: bind host {host:?} resolved to no addresses");
3553 }
3554 for addr in &resolved {
3555 let ip = addr.ip();
3556 let is_acceptable = match ip {
3557 IpAddr::V4(v4) => {
3558 v4.is_loopback() || v4.is_private() || {
3559 let octets = v4.octets();
3561 octets[0] == 100 && (64..=127).contains(&octets[1])
3562 }
3563 }
3564 IpAddr::V6(v6) => v6.is_loopback(), };
3566 if !is_acceptable {
3567 bail!(
3568 "--local-only refuses non-private bind: {host:?} resolves to {} \
3569 which is not loopback (127/8, ::1), RFC 1918 private \
3570 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
3571 (100.64.0.0/10). Remove --local-only to bind publicly.",
3572 ip
3573 );
3574 }
3575 }
3576 Ok(())
3577}
3578
3579fn cmd_bind_relay(url: &str, migrate_pinned: bool, as_json: bool) -> Result<()> {
3582 if !config::is_initialized()? {
3583 bail!("not initialized — run `wire init <handle>` first");
3584 }
3585 let card = config::read_agent_card()?;
3586 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3587 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3588
3589 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
3596 let pinned: Vec<String> = existing
3597 .get("peers")
3598 .and_then(|p| p.as_object())
3599 .map(|o| o.keys().cloned().collect())
3600 .unwrap_or_default();
3601 if !pinned.is_empty() && !migrate_pinned {
3602 let list = pinned.join(", ");
3603 bail!(
3604 "bind-relay would silently black-hole {n} pinned peer(s): {list}. \
3605 They are pinned to your CURRENT slot; without coordination they will keep \
3606 pushing to a slot you no longer read.\n\n\
3607 SAFE PATHS:\n\
3608 • `wire rotate-slot` — rotates slot on the SAME relay and emits a \
3609 wire_close event to every pinned peer so their daemons drop the stale \
3610 coords cleanly. This is the supported migration path.\n\
3611 • `wire bind-relay {url} --migrate-pinned` — acknowledges that pinned \
3612 peers will need to re-pin manually (you must notify them out-of-band, \
3613 via a fresh `wire add` from each peer or a re-shared invite). Use this \
3614 only when the current slot is unreachable so rotate-slot can't ack.\n\n\
3615 Issue #7 (silent black-hole on relay change) caught this — proceed only \
3616 if you understand the consequences.",
3617 n = pinned.len(),
3618 );
3619 }
3620
3621 let normalized = url.trim_end_matches('/');
3622 let client = crate::relay_client::RelayClient::new(normalized);
3623 client.check_healthz()?;
3624 let alloc = client.allocate_slot(Some(&handle))?;
3625 let mut state = existing;
3626 if !pinned.is_empty() {
3627 eprintln!(
3631 "wire bind-relay: migrating with {n} pinned peer(s) — they will black-hole \
3632 until they re-pin: {peers}",
3633 n = pinned.len(),
3634 peers = pinned.join(", "),
3635 );
3636 }
3637 state["self"] = json!({
3638 "relay_url": url,
3639 "slot_id": alloc.slot_id,
3640 "slot_token": alloc.slot_token,
3641 });
3642 config::write_relay_state(&state)?;
3643
3644 if as_json {
3645 println!(
3646 "{}",
3647 serde_json::to_string(&json!({
3648 "relay_url": url,
3649 "slot_id": alloc.slot_id,
3650 "slot_token_present": true,
3651 }))?
3652 );
3653 } else {
3654 println!("bound to relay {url}");
3655 println!("slot_id: {}", alloc.slot_id);
3656 println!(
3657 "(slot_token written to {} mode 0600)",
3658 config::relay_state_path()?.display()
3659 );
3660 }
3661 Ok(())
3662}
3663
3664fn cmd_add_peer_slot(
3667 handle: &str,
3668 url: &str,
3669 slot_id: &str,
3670 slot_token: &str,
3671 as_json: bool,
3672) -> Result<()> {
3673 let mut state = config::read_relay_state()?;
3674 let peers = state["peers"]
3675 .as_object_mut()
3676 .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
3677 peers.insert(
3678 handle.to_string(),
3679 json!({
3680 "relay_url": url,
3681 "slot_id": slot_id,
3682 "slot_token": slot_token,
3683 }),
3684 );
3685 config::write_relay_state(&state)?;
3686 if as_json {
3687 println!(
3688 "{}",
3689 serde_json::to_string(&json!({
3690 "handle": handle,
3691 "relay_url": url,
3692 "slot_id": slot_id,
3693 "added": true,
3694 }))?
3695 );
3696 } else {
3697 println!("pinned peer slot for {handle} at {url} ({slot_id})");
3698 }
3699 Ok(())
3700}
3701
3702fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
3705 let state = config::read_relay_state()?;
3706 let peers = state["peers"].as_object().cloned().unwrap_or_default();
3707 if peers.is_empty() {
3708 bail!(
3709 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
3710 );
3711 }
3712 let outbox_dir = config::outbox_dir()?;
3713 if outbox_dir.exists() {
3718 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
3719 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
3720 let path = entry.path();
3721 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3722 continue;
3723 }
3724 let stem = match path.file_stem().and_then(|s| s.to_str()) {
3725 Some(s) => s.to_string(),
3726 None => continue,
3727 };
3728 if pinned.contains(&stem) {
3729 continue;
3730 }
3731 let bare = crate::agent_card::bare_handle(&stem);
3734 if pinned.contains(bare) {
3735 eprintln!(
3736 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
3737 Merge with: `cat {} >> {}` then delete the FQDN file.",
3738 stem,
3739 path.display(),
3740 outbox_dir.join(format!("{bare}.jsonl")).display(),
3741 );
3742 }
3743 }
3744 }
3745 if !outbox_dir.exists() {
3746 if as_json {
3747 println!(
3748 "{}",
3749 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
3750 );
3751 } else {
3752 println!("phyllis: nothing to dial out — write a message first with `wire send`");
3753 }
3754 return Ok(());
3755 }
3756
3757 let mut pushed = Vec::new();
3758 let mut skipped = Vec::new();
3759
3760 for (peer_handle, _) in peers.iter() {
3766 if let Some(want) = peer_filter
3767 && peer_handle != want
3768 {
3769 continue;
3770 }
3771 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
3772 if !outbox.exists() {
3773 continue;
3774 }
3775 let ordered_endpoints =
3776 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
3777 if ordered_endpoints.is_empty() {
3778 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
3782 let event: Value = match serde_json::from_str(line) {
3783 Ok(v) => v,
3784 Err(_) => continue,
3785 };
3786 let event_id = event
3787 .get("event_id")
3788 .and_then(Value::as_str)
3789 .unwrap_or("")
3790 .to_string();
3791 skipped.push(json!({
3792 "peer": peer_handle,
3793 "event_id": event_id,
3794 "reason": "no reachable endpoint pinned for peer",
3795 }));
3796 }
3797 continue;
3798 }
3799 let body = std::fs::read_to_string(&outbox)?;
3800 for line in body.lines() {
3801 let event: Value = match serde_json::from_str(line) {
3802 Ok(v) => v,
3803 Err(_) => continue,
3804 };
3805 let event_id = event
3806 .get("event_id")
3807 .and_then(Value::as_str)
3808 .unwrap_or("")
3809 .to_string();
3810
3811 let mut delivered = false;
3812 let mut last_err_reason: Option<String> = None;
3813 for endpoint in &ordered_endpoints {
3814 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
3815 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
3816 Ok(resp) => {
3817 if resp.status == "duplicate" {
3818 skipped.push(json!({
3819 "peer": peer_handle,
3820 "event_id": event_id,
3821 "reason": "duplicate",
3822 "endpoint": endpoint.relay_url,
3823 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
3824 }));
3825 } else {
3826 pushed.push(json!({
3827 "peer": peer_handle,
3828 "event_id": event_id,
3829 "endpoint": endpoint.relay_url,
3830 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
3831 }));
3832 }
3833 delivered = true;
3834 break;
3835 }
3836 Err(e) => {
3837 last_err_reason = Some(crate::relay_client::format_transport_error(&e));
3842 }
3843 }
3844 }
3845 if !delivered {
3846 skipped.push(json!({
3847 "peer": peer_handle,
3848 "event_id": event_id,
3849 "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
3850 }));
3851 }
3852 }
3853 }
3854
3855 if as_json {
3856 println!(
3857 "{}",
3858 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
3859 );
3860 } else {
3861 println!(
3862 "pushed {} event(s); skipped {} ({})",
3863 pushed.len(),
3864 skipped.len(),
3865 if skipped.is_empty() {
3866 "none"
3867 } else {
3868 "see --json for detail"
3869 }
3870 );
3871 }
3872 Ok(())
3873}
3874
3875fn cmd_pull(as_json: bool) -> Result<()> {
3878 let state = config::read_relay_state()?;
3879 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
3880 if self_state.is_null() {
3881 bail!("self slot not bound — run `wire bind-relay <url>` first");
3882 }
3883
3884 let endpoints = crate::endpoints::self_endpoints(&state);
3893 if endpoints.is_empty() {
3894 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
3895 }
3896
3897 let inbox_dir = config::inbox_dir()?;
3898 config::ensure_dirs()?;
3899
3900 let mut total_seen = 0usize;
3901 let mut all_written: Vec<Value> = Vec::new();
3902 let mut all_rejected: Vec<Value> = Vec::new();
3903 let mut all_blocked = false;
3904 let mut all_advance_cursor_to: Option<String> = None;
3905
3906 for endpoint in &endpoints {
3907 let cursor_key = endpoint_cursor_key(endpoint.scope);
3908 let last_event_id = self_state
3909 .get(&cursor_key)
3910 .and_then(Value::as_str)
3911 .map(str::to_string);
3912 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
3913 let events = match client.list_events(
3914 &endpoint.slot_id,
3915 &endpoint.slot_token,
3916 last_event_id.as_deref(),
3917 Some(1000),
3918 ) {
3919 Ok(ev) => ev,
3920 Err(e) => {
3921 eprintln!(
3925 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
3926 endpoint.relay_url,
3927 endpoint.scope,
3928 crate::relay_client::format_transport_error(&e),
3929 );
3930 continue;
3931 }
3932 };
3933 total_seen += events.len();
3934 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
3935 all_written.extend(result.written.iter().cloned());
3936 all_rejected.extend(result.rejected.iter().cloned());
3937 if result.blocked {
3938 all_blocked = true;
3939 }
3940 if let Some(eid) = result.advance_cursor_to.clone() {
3943 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
3944 all_advance_cursor_to = Some(eid.clone());
3945 }
3946 let key = cursor_key.clone();
3947 config::update_relay_state(|state| {
3948 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
3949 self_obj.insert(key, Value::String(eid));
3950 }
3951 Ok(())
3952 })?;
3953 }
3954 }
3955
3956 let result = crate::pull::PullResult {
3961 written: all_written,
3962 rejected: all_rejected,
3963 blocked: all_blocked,
3964 advance_cursor_to: all_advance_cursor_to,
3965 };
3966 let events_len = total_seen;
3967
3968 if as_json {
3972 println!(
3973 "{}",
3974 serde_json::to_string(&json!({
3975 "written": result.written,
3976 "rejected": result.rejected,
3977 "total_seen": events_len,
3978 "cursor_blocked": result.blocked,
3979 "cursor_advanced_to": result.advance_cursor_to,
3980 }))?
3981 );
3982 } else {
3983 let blocking = result
3984 .rejected
3985 .iter()
3986 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
3987 .count();
3988 if blocking > 0 {
3989 println!(
3990 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
3991 events_len,
3992 result.written.len(),
3993 result.rejected.len(),
3994 blocking,
3995 );
3996 } else {
3997 println!(
3998 "pulled {} event(s); wrote {}; rejected {}",
3999 events_len,
4000 result.written.len(),
4001 result.rejected.len(),
4002 );
4003 }
4004 }
4005 Ok(())
4006}
4007
4008fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4013 match scope {
4014 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4015 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4016 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4017 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4018 }
4019}
4020
4021fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4024 if !config::is_initialized()? {
4025 bail!("not initialized — run `wire init <handle>` first");
4026 }
4027 let mut state = config::read_relay_state()?;
4028 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4029 if self_state.is_null() {
4030 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4031 }
4032 let url = self_state["relay_url"]
4033 .as_str()
4034 .ok_or_else(|| anyhow!("self.relay_url missing"))?
4035 .to_string();
4036 let old_slot_id = self_state["slot_id"]
4037 .as_str()
4038 .ok_or_else(|| anyhow!("self.slot_id missing"))?
4039 .to_string();
4040 let old_slot_token = self_state["slot_token"]
4041 .as_str()
4042 .ok_or_else(|| anyhow!("self.slot_token missing"))?
4043 .to_string();
4044
4045 let card = config::read_agent_card()?;
4047 let did = card
4048 .get("did")
4049 .and_then(Value::as_str)
4050 .unwrap_or("")
4051 .to_string();
4052 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4053 let pk_b64 = card
4054 .get("verify_keys")
4055 .and_then(Value::as_object)
4056 .and_then(|m| m.values().next())
4057 .and_then(|v| v.get("key"))
4058 .and_then(Value::as_str)
4059 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4060 .to_string();
4061 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4062 let sk_seed = config::read_private_key()?;
4063
4064 let normalized = url.trim_end_matches('/').to_string();
4066 let client = crate::relay_client::RelayClient::new(&normalized);
4067 client
4068 .check_healthz()
4069 .context("aborting rotation; old slot still valid")?;
4070 let alloc = client.allocate_slot(Some(&handle))?;
4071 let new_slot_id = alloc.slot_id.clone();
4072 let new_slot_token = alloc.slot_token.clone();
4073
4074 let mut announced: Vec<String> = Vec::new();
4081 if !no_announce {
4082 let now = time::OffsetDateTime::now_utc()
4083 .format(&time::format_description::well_known::Rfc3339)
4084 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4085 let body = json!({
4086 "reason": "operator-initiated slot rotation",
4087 "new_relay_url": url,
4088 "new_slot_id": new_slot_id,
4089 });
4093 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4094 for (peer_handle, _peer_info) in peers.iter() {
4095 let event = json!({
4096 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4097 "timestamp": now.clone(),
4098 "from": did,
4099 "to": format!("did:wire:{peer_handle}"),
4100 "type": "wire_close",
4101 "kind": 1201,
4102 "body": body.clone(),
4103 });
4104 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4105 Ok(s) => s,
4106 Err(e) => {
4107 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4108 continue;
4109 }
4110 };
4111 let peer_info = match state["peers"].get(peer_handle) {
4116 Some(p) => p.clone(),
4117 None => continue,
4118 };
4119 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4120 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4121 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4122 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4123 continue;
4124 }
4125 let peer_client = if peer_url == url {
4126 client.clone()
4127 } else {
4128 crate::relay_client::RelayClient::new(peer_url)
4129 };
4130 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4131 Ok(_) => announced.push(peer_handle.clone()),
4132 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4133 }
4134 }
4135 }
4136
4137 state["self"] = json!({
4139 "relay_url": url,
4140 "slot_id": new_slot_id,
4141 "slot_token": new_slot_token,
4142 });
4143 config::write_relay_state(&state)?;
4144
4145 if as_json {
4146 println!(
4147 "{}",
4148 serde_json::to_string(&json!({
4149 "rotated": true,
4150 "old_slot_id": old_slot_id,
4151 "new_slot_id": new_slot_id,
4152 "relay_url": url,
4153 "announced_to": announced,
4154 }))?
4155 );
4156 } else {
4157 println!("rotated slot on {url}");
4158 println!(
4159 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4160 );
4161 println!(" new slot_id: {new_slot_id}");
4162 if !announced.is_empty() {
4163 println!(
4164 " announced wire_close (kind=1201) to: {}",
4165 announced.join(", ")
4166 );
4167 }
4168 println!();
4169 println!("next steps:");
4170 println!(" - peers see the wire_close event in their next `wire pull`");
4171 println!(
4172 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4173 );
4174 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
4175 println!(" - until they do, you'll receive but they won't be able to reach you");
4176 let _ = old_slot_token;
4178 }
4179 Ok(())
4180}
4181
4182fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4185 let mut trust = config::read_trust()?;
4186 let mut removed_from_trust = false;
4187 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4188 && agents.remove(handle).is_some()
4189 {
4190 removed_from_trust = true;
4191 }
4192 config::write_trust(&trust)?;
4193
4194 let mut state = config::read_relay_state()?;
4195 let mut removed_from_relay = false;
4196 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4197 && peers.remove(handle).is_some()
4198 {
4199 removed_from_relay = true;
4200 }
4201 config::write_relay_state(&state)?;
4202
4203 let mut purged: Vec<String> = Vec::new();
4204 if purge {
4205 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4206 let path = dir.join(format!("{handle}.jsonl"));
4207 if path.exists() {
4208 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4209 purged.push(path.to_string_lossy().into());
4210 }
4211 }
4212 }
4213
4214 if !removed_from_trust && !removed_from_relay {
4215 if as_json {
4216 println!(
4217 "{}",
4218 serde_json::to_string(&json!({
4219 "removed": false,
4220 "reason": format!("peer {handle:?} not pinned"),
4221 }))?
4222 );
4223 } else {
4224 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
4225 }
4226 return Ok(());
4227 }
4228
4229 if as_json {
4230 println!(
4231 "{}",
4232 serde_json::to_string(&json!({
4233 "handle": handle,
4234 "removed_from_trust": removed_from_trust,
4235 "removed_from_relay_state": removed_from_relay,
4236 "purged_files": purged,
4237 }))?
4238 );
4239 } else {
4240 println!("forgot peer {handle:?}");
4241 if removed_from_trust {
4242 println!(" - removed from trust.json");
4243 }
4244 if removed_from_relay {
4245 println!(" - removed from relay.json");
4246 }
4247 if !purged.is_empty() {
4248 for p in &purged {
4249 println!(" - deleted {p}");
4250 }
4251 } else if !purge {
4252 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
4253 }
4254 }
4255 Ok(())
4256}
4257
4258fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
4261 if !config::is_initialized()? {
4262 bail!("not initialized — run `wire init <handle>` first");
4263 }
4264 let interval = std::time::Duration::from_secs(interval_secs.max(1));
4265
4266 if !as_json {
4267 if once {
4268 eprintln!("wire daemon: single sync cycle, then exit");
4269 } else {
4270 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
4271 }
4272 }
4273
4274 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
4278 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
4279 }
4280
4281 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
4287 if !once {
4288 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
4289 }
4290
4291 loop {
4292 let pushed = run_sync_push().unwrap_or_else(|e| {
4293 eprintln!("daemon: push error: {e:#}");
4294 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
4295 });
4296 let pulled = run_sync_pull().unwrap_or_else(|e| {
4297 eprintln!("daemon: pull error: {e:#}");
4298 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
4299 });
4300 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
4301 eprintln!("daemon: pending-pair tick error: {e:#}");
4302 json!({"transitions": []})
4303 });
4304
4305 if as_json {
4306 println!(
4307 "{}",
4308 serde_json::to_string(&json!({
4309 "ts": time::OffsetDateTime::now_utc()
4310 .format(&time::format_description::well_known::Rfc3339)
4311 .unwrap_or_default(),
4312 "push": pushed,
4313 "pull": pulled,
4314 "pairs": pairs,
4315 }))?
4316 );
4317 } else {
4318 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
4319 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
4320 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
4321 let pair_transitions = pairs["transitions"]
4322 .as_array()
4323 .map(|a| a.len())
4324 .unwrap_or(0);
4325 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
4326 eprintln!(
4327 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
4328 );
4329 }
4330 if let Some(arr) = pairs["transitions"].as_array() {
4332 for t in arr {
4333 eprintln!(
4334 " pair {} : {} → {}",
4335 t.get("code").and_then(Value::as_str).unwrap_or("?"),
4336 t.get("from").and_then(Value::as_str).unwrap_or("?"),
4337 t.get("to").and_then(Value::as_str).unwrap_or("?")
4338 );
4339 if let Some(sas) = t.get("sas").and_then(Value::as_str)
4340 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
4341 {
4342 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
4343 eprintln!(
4344 " Run: wire pair-confirm {} {}",
4345 t.get("code").and_then(Value::as_str).unwrap_or("?"),
4346 sas
4347 );
4348 }
4349 }
4350 }
4351 }
4352
4353 if once {
4354 return Ok(());
4355 }
4356 let _ = wake_rx.recv_timeout(interval);
4361 while wake_rx.try_recv().is_ok() {}
4362 }
4363}
4364
4365fn run_sync_push() -> Result<Value> {
4368 let state = config::read_relay_state()?;
4369 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4370 if peers.is_empty() {
4371 return Ok(json!({"pushed": [], "skipped": []}));
4372 }
4373 let outbox_dir = config::outbox_dir()?;
4374 if !outbox_dir.exists() {
4375 return Ok(json!({"pushed": [], "skipped": []}));
4376 }
4377 let mut pushed = Vec::new();
4378 let mut skipped = Vec::new();
4379 for (peer_handle, slot_info) in peers.iter() {
4380 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4381 if !outbox.exists() {
4382 continue;
4383 }
4384 let url = slot_info["relay_url"].as_str().unwrap_or("");
4385 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
4386 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
4387 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
4388 continue;
4389 }
4390 let client = crate::relay_client::RelayClient::new(url);
4391 let body = std::fs::read_to_string(&outbox)?;
4392 for line in body.lines() {
4393 let event: Value = match serde_json::from_str(line) {
4394 Ok(v) => v,
4395 Err(_) => continue,
4396 };
4397 let event_id = event
4398 .get("event_id")
4399 .and_then(Value::as_str)
4400 .unwrap_or("")
4401 .to_string();
4402 match client.post_event(slot_id, slot_token, &event) {
4403 Ok(resp) => {
4404 if resp.status == "duplicate" {
4405 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
4406 } else {
4407 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
4408 }
4409 }
4410 Err(e) => {
4411 let reason = crate::relay_client::format_transport_error(&e);
4415 skipped
4416 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
4417 }
4418 }
4419 }
4420 }
4421 Ok(json!({"pushed": pushed, "skipped": skipped}))
4422}
4423
4424fn run_sync_pull() -> Result<Value> {
4426 let state = config::read_relay_state()?;
4427 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4428 if self_state.is_null() {
4429 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
4430 }
4431 let url = self_state["relay_url"].as_str().unwrap_or("");
4432 let slot_id = self_state["slot_id"].as_str().unwrap_or("");
4433 let slot_token = self_state["slot_token"].as_str().unwrap_or("");
4434 let last_event_id = self_state
4435 .get("last_pulled_event_id")
4436 .and_then(Value::as_str)
4437 .map(str::to_string);
4438 if url.is_empty() {
4439 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
4440 }
4441 let client = crate::relay_client::RelayClient::new(url);
4442 let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
4443 let inbox_dir = config::inbox_dir()?;
4444 config::ensure_dirs()?;
4445
4446 let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
4450
4451 if let Some(eid) = &result.advance_cursor_to {
4453 let eid = eid.clone();
4454 config::update_relay_state(|state| {
4455 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4456 self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
4457 }
4458 Ok(())
4459 })?;
4460 }
4461
4462 Ok(json!({
4463 "written": result.written,
4464 "rejected": result.rejected,
4465 "total_seen": events.len(),
4466 "cursor_blocked": result.blocked,
4467 "cursor_advanced_to": result.advance_cursor_to,
4468 }))
4469}
4470
4471fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
4474 let body =
4475 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
4476 let card: Value =
4477 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
4478 crate::agent_card::verify_agent_card(&card)
4479 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
4480
4481 let mut trust = config::read_trust()?;
4482 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
4483
4484 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4485 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4486 config::write_trust(&trust)?;
4487
4488 if as_json {
4489 println!(
4490 "{}",
4491 serde_json::to_string(&json!({
4492 "handle": handle,
4493 "did": did,
4494 "tier": "VERIFIED",
4495 "pinned": true,
4496 }))?
4497 );
4498 } else {
4499 println!("pinned {handle} ({did}) at tier VERIFIED");
4500 }
4501 Ok(())
4502}
4503
4504fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
4507 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
4508}
4509
4510fn cmd_pair_join(
4511 code_phrase: &str,
4512 relay_url: &str,
4513 auto_yes: bool,
4514 timeout_secs: u64,
4515) -> Result<()> {
4516 pair_orchestrate(
4517 relay_url,
4518 Some(code_phrase),
4519 "guest",
4520 auto_yes,
4521 timeout_secs,
4522 )
4523}
4524
4525fn pair_orchestrate(
4531 relay_url: &str,
4532 code_in: Option<&str>,
4533 role: &str,
4534 auto_yes: bool,
4535 timeout_secs: u64,
4536) -> Result<()> {
4537 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
4538
4539 let mut s = pair_session_open(role, relay_url, code_in)?;
4540
4541 if role == "host" {
4542 eprintln!();
4543 eprintln!("share this code phrase with your peer:");
4544 eprintln!();
4545 eprintln!(" {}", s.code);
4546 eprintln!();
4547 eprintln!(
4548 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
4549 s.code
4550 );
4551 } else {
4552 eprintln!();
4553 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
4554 }
4555
4556 const HEARTBEAT_SECS: u64 = 10;
4561 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
4562 let started = std::time::Instant::now();
4563 let mut last_heartbeat = started;
4564 let formatted = loop {
4565 if let Some(sas) = pair_session_try_sas(&mut s)? {
4566 break sas;
4567 }
4568 let now = std::time::Instant::now();
4569 if now >= deadline {
4570 return Err(anyhow!(
4571 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
4572 ));
4573 }
4574 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
4575 let elapsed = now.duration_since(started).as_secs();
4576 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
4577 last_heartbeat = now;
4578 }
4579 std::thread::sleep(std::time::Duration::from_millis(250));
4580 };
4581
4582 eprintln!();
4583 eprintln!("SAS digits (must match peer's terminal):");
4584 eprintln!();
4585 eprintln!(" {formatted}");
4586 eprintln!();
4587
4588 if !auto_yes {
4591 eprint!("does this match your peer's terminal? [y/N]: ");
4592 use std::io::Write;
4593 std::io::stderr().flush().ok();
4594 let mut input = String::new();
4595 std::io::stdin().read_line(&mut input)?;
4596 let trimmed = input.trim().to_lowercase();
4597 if trimmed != "y" && trimmed != "yes" {
4598 bail!("SAS confirmation declined — aborting pairing");
4599 }
4600 }
4601 s.sas_confirmed = true;
4602
4603 let result = pair_session_finalize(&mut s, timeout_secs)?;
4605
4606 let peer_did = result["paired_with"].as_str().unwrap_or("");
4607 let peer_role = if role == "host" { "guest" } else { "host" };
4608 eprintln!("paired with {peer_did} (peer role: {peer_role})");
4609 eprintln!("peer card pinned at tier VERIFIED");
4610 eprintln!(
4611 "peer relay slot saved to {}",
4612 config::relay_state_path()?.display()
4613 );
4614
4615 println!("{}", serde_json::to_string(&result)?);
4616 Ok(())
4617}
4618
4619fn cmd_pair(
4625 handle: &str,
4626 code: Option<&str>,
4627 relay: &str,
4628 auto_yes: bool,
4629 timeout_secs: u64,
4630 no_setup: bool,
4631) -> Result<()> {
4632 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
4635 let did = init_result
4636 .get("did")
4637 .and_then(|v| v.as_str())
4638 .unwrap_or("(unknown)")
4639 .to_string();
4640 let already = init_result
4641 .get("already_initialized")
4642 .and_then(|v| v.as_bool())
4643 .unwrap_or(false);
4644 if already {
4645 println!("(identity {did} already initialized — reusing)");
4646 } else {
4647 println!("initialized {did}");
4648 }
4649 println!();
4650
4651 match code {
4653 None => {
4654 println!("hosting pair on {relay} (no code = host) ...");
4655 cmd_pair_host(relay, auto_yes, timeout_secs)?;
4656 }
4657 Some(c) => {
4658 println!("joining pair with code {c} on {relay} ...");
4659 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
4660 }
4661 }
4662
4663 if !no_setup {
4665 println!();
4666 println!("registering wire as MCP server in detected client configs ...");
4667 if let Err(e) = cmd_setup(true) {
4668 eprintln!("warn: setup --apply failed: {e}");
4670 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
4671 }
4672 }
4673
4674 println!();
4675 println!("pair complete. Next steps:");
4676 println!(" wire daemon start # background sync of inbox/outbox vs relay");
4677 println!(" wire send <peer> claim <msg> # send your peer something");
4678 println!(" wire tail # watch incoming events");
4679 Ok(())
4680}
4681
4682fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
4688 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
4689 let did = init_result
4690 .get("did")
4691 .and_then(|v| v.as_str())
4692 .unwrap_or("(unknown)")
4693 .to_string();
4694 let already = init_result
4695 .get("already_initialized")
4696 .and_then(|v| v.as_bool())
4697 .unwrap_or(false);
4698 if already {
4699 println!("(identity {did} already initialized — reusing)");
4700 } else {
4701 println!("initialized {did}");
4702 }
4703 println!();
4704 match code {
4705 None => cmd_pair_host_detach(relay, false),
4706 Some(c) => cmd_pair_join_detach(c, relay, false),
4707 }
4708}
4709
4710fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
4711 if !config::is_initialized()? {
4712 bail!("not initialized — run `wire init <handle>` first");
4713 }
4714 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
4715 Ok(b) => b,
4716 Err(e) => {
4717 if !as_json {
4718 eprintln!(
4719 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
4720 );
4721 }
4722 false
4723 }
4724 };
4725 let code = crate::sas::generate_code_phrase();
4726 let code_hash = crate::pair_session::derive_code_hash(&code);
4727 let now = time::OffsetDateTime::now_utc()
4728 .format(&time::format_description::well_known::Rfc3339)
4729 .unwrap_or_default();
4730 let p = crate::pending_pair::PendingPair {
4731 code: code.clone(),
4732 code_hash,
4733 role: "host".to_string(),
4734 relay_url: relay_url.to_string(),
4735 status: "request_host".to_string(),
4736 sas: None,
4737 peer_did: None,
4738 created_at: now,
4739 last_error: None,
4740 pair_id: None,
4741 our_slot_id: None,
4742 our_slot_token: None,
4743 spake2_seed_b64: None,
4744 };
4745 crate::pending_pair::write_pending(&p)?;
4746 if as_json {
4747 println!(
4748 "{}",
4749 serde_json::to_string(&json!({
4750 "state": "queued",
4751 "code_phrase": code,
4752 "relay_url": relay_url,
4753 "role": "host",
4754 "daemon_spawned": daemon_spawned,
4755 }))?
4756 );
4757 } else {
4758 if daemon_spawned {
4759 println!("(started wire daemon in background)");
4760 }
4761 println!("detached pair-host queued. Share this code with your peer:\n");
4762 println!(" {code}\n");
4763 println!("Next steps:");
4764 println!(" wire pair-list # check status");
4765 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
4766 println!(" wire pair-cancel {code} # to abort");
4767 }
4768 Ok(())
4769}
4770
4771fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
4772 if !config::is_initialized()? {
4773 bail!("not initialized — run `wire init <handle>` first");
4774 }
4775 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
4776 Ok(b) => b,
4777 Err(e) => {
4778 if !as_json {
4779 eprintln!(
4780 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
4781 );
4782 }
4783 false
4784 }
4785 };
4786 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
4787 let code_hash = crate::pair_session::derive_code_hash(&code);
4788 let now = time::OffsetDateTime::now_utc()
4789 .format(&time::format_description::well_known::Rfc3339)
4790 .unwrap_or_default();
4791 let p = crate::pending_pair::PendingPair {
4792 code: code.clone(),
4793 code_hash,
4794 role: "guest".to_string(),
4795 relay_url: relay_url.to_string(),
4796 status: "request_guest".to_string(),
4797 sas: None,
4798 peer_did: None,
4799 created_at: now,
4800 last_error: None,
4801 pair_id: None,
4802 our_slot_id: None,
4803 our_slot_token: None,
4804 spake2_seed_b64: None,
4805 };
4806 crate::pending_pair::write_pending(&p)?;
4807 if as_json {
4808 println!(
4809 "{}",
4810 serde_json::to_string(&json!({
4811 "state": "queued",
4812 "code_phrase": code,
4813 "relay_url": relay_url,
4814 "role": "guest",
4815 "daemon_spawned": daemon_spawned,
4816 }))?
4817 );
4818 } else {
4819 if daemon_spawned {
4820 println!("(started wire daemon in background)");
4821 }
4822 println!("detached pair-join queued for code {code}.");
4823 println!(
4824 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
4825 );
4826 }
4827 Ok(())
4828}
4829
4830fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
4831 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
4832 let typed: String = typed_digits
4833 .chars()
4834 .filter(|c| c.is_ascii_digit())
4835 .collect();
4836 if typed.len() != 6 {
4837 bail!(
4838 "expected 6 digits (got {} after stripping non-digits)",
4839 typed.len()
4840 );
4841 }
4842 let mut p = crate::pending_pair::read_pending(&code)?
4843 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
4844 if p.status != "sas_ready" {
4845 bail!(
4846 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
4847 p.status
4848 );
4849 }
4850 let stored = p
4851 .sas
4852 .as_ref()
4853 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
4854 .clone();
4855 if stored == typed {
4856 p.status = "confirmed".to_string();
4857 crate::pending_pair::write_pending(&p)?;
4858 if as_json {
4859 println!(
4860 "{}",
4861 serde_json::to_string(&json!({
4862 "state": "confirmed",
4863 "code_phrase": code,
4864 }))?
4865 );
4866 } else {
4867 println!("digits match. Daemon will finalize the handshake on its next tick.");
4868 println!("Run `wire peers` after a few seconds to confirm.");
4869 }
4870 } else {
4871 p.status = "aborted".to_string();
4872 p.last_error = Some(format!(
4873 "SAS digit mismatch (typed {typed}, expected {stored})"
4874 ));
4875 let client = crate::relay_client::RelayClient::new(&p.relay_url);
4876 let _ = client.pair_abandon(&p.code_hash);
4877 crate::pending_pair::write_pending(&p)?;
4878 crate::os_notify::toast(
4879 &format!("wire — pair aborted ({})", p.code),
4880 p.last_error.as_deref().unwrap_or("digits mismatch"),
4881 );
4882 if as_json {
4883 println!(
4884 "{}",
4885 serde_json::to_string(&json!({
4886 "state": "aborted",
4887 "code_phrase": code,
4888 "error": "digits mismatch",
4889 }))?
4890 );
4891 }
4892 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
4893 }
4894 Ok(())
4895}
4896
4897fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
4898 if watch {
4899 return cmd_pair_list_watch(watch_interval_secs);
4900 }
4901 let spake2_items = crate::pending_pair::list_pending()?;
4902 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
4903 if as_json {
4904 println!("{}", serde_json::to_string(&spake2_items)?);
4909 return Ok(());
4910 }
4911 if spake2_items.is_empty() && inbound_items.is_empty() {
4912 println!("no pending pair sessions.");
4913 return Ok(());
4914 }
4915 if !inbound_items.is_empty() {
4918 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
4919 println!(
4920 "{:<20} {:<35} {:<25} NEXT STEP",
4921 "PEER", "RELAY", "RECEIVED"
4922 );
4923 for p in &inbound_items {
4924 println!(
4925 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
4926 p.peer_handle,
4927 p.peer_relay_url,
4928 p.received_at,
4929 peer = p.peer_handle,
4930 );
4931 }
4932 println!();
4933 }
4934 if !spake2_items.is_empty() {
4935 println!("SPAKE2 SESSIONS");
4936 println!(
4937 "{:<15} {:<8} {:<18} {:<10} NOTE",
4938 "CODE", "ROLE", "STATUS", "SAS"
4939 );
4940 for p in spake2_items {
4941 let sas = p
4942 .sas
4943 .as_ref()
4944 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
4945 .unwrap_or_else(|| "—".to_string());
4946 let note = p
4947 .last_error
4948 .as_deref()
4949 .or(p.peer_did.as_deref())
4950 .unwrap_or("");
4951 println!(
4952 "{:<15} {:<8} {:<18} {:<10} {}",
4953 p.code, p.role, p.status, sas, note
4954 );
4955 }
4956 }
4957 Ok(())
4958}
4959
4960fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
4972 use std::collections::HashMap;
4973 use std::io::Write;
4974 let interval = std::time::Duration::from_secs(interval_secs.max(1));
4975 let mut prev: HashMap<String, String> = HashMap::new();
4978 {
4979 let items = crate::pending_pair::list_pending()?;
4980 for p in &items {
4981 println!("{}", serde_json::to_string(&p)?);
4982 prev.insert(p.code.clone(), p.status.clone());
4983 }
4984 let _ = std::io::stdout().flush();
4986 }
4987 loop {
4988 std::thread::sleep(interval);
4989 let items = match crate::pending_pair::list_pending() {
4990 Ok(v) => v,
4991 Err(_) => continue,
4992 };
4993 let mut cur: HashMap<String, String> = HashMap::new();
4994 for p in &items {
4995 cur.insert(p.code.clone(), p.status.clone());
4996 match prev.get(&p.code) {
4997 None => {
4998 println!("{}", serde_json::to_string(&p)?);
5000 }
5001 Some(prev_status) if prev_status != &p.status => {
5002 println!("{}", serde_json::to_string(&p)?);
5004 }
5005 _ => {}
5006 }
5007 }
5008 for code in prev.keys() {
5009 if !cur.contains_key(code) {
5010 println!(
5013 "{}",
5014 serde_json::to_string(&json!({
5015 "code": code,
5016 "status": "removed",
5017 "_synthetic": true,
5018 }))?
5019 );
5020 }
5021 }
5022 let _ = std::io::stdout().flush();
5023 prev = cur;
5024 }
5025}
5026
5027fn cmd_pair_watch(
5031 code_phrase: &str,
5032 target_status: &str,
5033 timeout_secs: u64,
5034 as_json: bool,
5035) -> Result<()> {
5036 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5037 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5038 let mut last_seen_status: Option<String> = None;
5039 loop {
5040 let p_opt = crate::pending_pair::read_pending(&code)?;
5041 let now = std::time::Instant::now();
5042 match p_opt {
5043 None => {
5044 if last_seen_status.is_some() {
5048 if as_json {
5049 println!(
5050 "{}",
5051 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5052 );
5053 } else {
5054 println!("pair {code} finalized (file removed)");
5055 }
5056 return Ok(());
5057 } else {
5058 if as_json {
5059 println!(
5060 "{}",
5061 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5062 );
5063 }
5064 std::process::exit(1);
5065 }
5066 }
5067 Some(p) => {
5068 let cur = p.status.clone();
5069 if Some(cur.clone()) != last_seen_status {
5070 if as_json {
5071 println!("{}", serde_json::to_string(&p)?);
5073 }
5074 last_seen_status = Some(cur.clone());
5075 }
5076 if cur == target_status {
5077 if !as_json {
5078 let sas_str = p
5079 .sas
5080 .as_ref()
5081 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5082 .unwrap_or_else(|| "—".to_string());
5083 println!("pair {code} reached {target_status} (SAS: {sas_str})");
5084 }
5085 return Ok(());
5086 }
5087 if cur == "aborted" || cur == "aborted_restart" {
5088 if !as_json {
5089 let err = p.last_error.as_deref().unwrap_or("(no detail)");
5090 eprintln!("pair {code} {cur}: {err}");
5091 }
5092 std::process::exit(1);
5093 }
5094 }
5095 }
5096 if now >= deadline {
5097 if !as_json {
5098 eprintln!(
5099 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5100 );
5101 }
5102 std::process::exit(2);
5103 }
5104 std::thread::sleep(std::time::Duration::from_millis(250));
5105 }
5106}
5107
5108fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5109 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5110 let p = crate::pending_pair::read_pending(&code)?
5111 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5112 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5113 let _ = client.pair_abandon(&p.code_hash);
5114 crate::pending_pair::delete_pending(&code)?;
5115 if as_json {
5116 println!(
5117 "{}",
5118 serde_json::to_string(&json!({
5119 "state": "cancelled",
5120 "code_phrase": code,
5121 }))?
5122 );
5123 } else {
5124 println!("cancelled pending pair {code} (relay slot released, file removed).");
5125 }
5126 Ok(())
5127}
5128
5129fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5132 let code = crate::sas::parse_code_phrase(code_phrase)?;
5135 let code_hash = crate::pair_session::derive_code_hash(code);
5136 let client = crate::relay_client::RelayClient::new(relay_url);
5137 client.pair_abandon(&code_hash)?;
5138 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
5139 println!("host can now issue a fresh code; guest can re-join.");
5140 Ok(())
5141}
5142
5143fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
5146 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
5147
5148 let share_payload: Option<Value> = if share {
5151 let client = reqwest::blocking::Client::new();
5152 let single_use = if uses == 1 { Some(1u32) } else { None };
5153 let body = json!({
5154 "invite_url": url,
5155 "ttl_seconds": ttl,
5156 "uses": single_use,
5157 });
5158 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
5159 let resp = client.post(&endpoint).json(&body).send()?;
5160 if !resp.status().is_success() {
5161 let code = resp.status();
5162 let txt = resp.text().unwrap_or_default();
5163 bail!("relay {code} on /v1/invite/register: {txt}");
5164 }
5165 let parsed: Value = resp.json()?;
5166 let token = parsed
5167 .get("token")
5168 .and_then(Value::as_str)
5169 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
5170 .to_string();
5171 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
5172 let curl_line = format!("curl -fsSL {share_url} | sh");
5173 Some(json!({
5174 "token": token,
5175 "share_url": share_url,
5176 "curl": curl_line,
5177 "expires_unix": parsed.get("expires_unix"),
5178 }))
5179 } else {
5180 None
5181 };
5182
5183 if as_json {
5184 let mut out = json!({
5185 "invite_url": url,
5186 "ttl_secs": ttl,
5187 "uses": uses,
5188 "relay": relay,
5189 });
5190 if let Some(s) = &share_payload {
5191 out["share"] = s.clone();
5192 }
5193 println!("{}", serde_json::to_string(&out)?);
5194 } else if let Some(s) = share_payload {
5195 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
5196 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
5197 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
5198 println!("{curl}");
5199 } else {
5200 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
5201 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
5202 println!("{url}");
5203 }
5204 Ok(())
5205}
5206
5207fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
5208 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
5212 let sep = if url.contains('?') { '&' } else { '?' };
5213 let resolve_url = format!("{url}{sep}format=url");
5214 let client = reqwest::blocking::Client::new();
5215 let resp = client
5216 .get(&resolve_url)
5217 .send()
5218 .with_context(|| format!("GET {resolve_url}"))?;
5219 if !resp.status().is_success() {
5220 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
5221 }
5222 let body = resp.text().unwrap_or_default().trim().to_string();
5223 if !body.starts_with("wire://pair?") {
5224 bail!(
5225 "short URL {url} did not resolve to a wire:// invite. \
5226 (got: {}{})",
5227 body.chars().take(80).collect::<String>(),
5228 if body.chars().count() > 80 { "…" } else { "" }
5229 );
5230 }
5231 body
5232 } else {
5233 url.to_string()
5234 };
5235
5236 let result = crate::pair_invite::accept_invite(&resolved)?;
5237 if as_json {
5238 println!("{}", serde_json::to_string(&result)?);
5239 } else {
5240 let did = result
5241 .get("paired_with")
5242 .and_then(Value::as_str)
5243 .unwrap_or("?");
5244 println!("paired with {did}");
5245 println!(
5246 "you can now: wire send {} <kind> <body>",
5247 crate::agent_card::display_handle_from_did(did)
5248 );
5249 }
5250 Ok(())
5251}
5252
5253fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
5256 if let Some(h) = handle {
5257 let parsed = crate::pair_profile::parse_handle(h)?;
5258 if config::is_initialized()? {
5261 let card = config::read_agent_card()?;
5262 let local_handle = card
5263 .get("profile")
5264 .and_then(|p| p.get("handle"))
5265 .and_then(Value::as_str)
5266 .map(str::to_string);
5267 if local_handle.as_deref() == Some(h) {
5268 return cmd_whois(None, as_json, None);
5269 }
5270 }
5271 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
5273 if as_json {
5274 println!("{}", serde_json::to_string(&resolved)?);
5275 } else {
5276 print_resolved_profile(&resolved);
5277 }
5278 return Ok(());
5279 }
5280 let card = config::read_agent_card()?;
5281 if as_json {
5282 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
5283 println!(
5284 "{}",
5285 serde_json::to_string(&json!({
5286 "did": card.get("did").cloned().unwrap_or(Value::Null),
5287 "profile": profile,
5288 }))?
5289 );
5290 } else {
5291 print!("{}", crate::pair_profile::render_self_summary()?);
5292 }
5293 Ok(())
5294}
5295
5296fn print_resolved_profile(resolved: &Value) {
5297 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
5298 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
5299 let relay = resolved
5300 .get("relay_url")
5301 .and_then(Value::as_str)
5302 .unwrap_or("");
5303 let slot = resolved
5304 .get("slot_id")
5305 .and_then(Value::as_str)
5306 .unwrap_or("");
5307 let profile = resolved
5308 .get("card")
5309 .and_then(|c| c.get("profile"))
5310 .cloned()
5311 .unwrap_or(Value::Null);
5312 println!("{did}");
5313 println!(" nick: {nick}");
5314 if !relay.is_empty() {
5315 println!(" relay_url: {relay}");
5316 }
5317 if !slot.is_empty() {
5318 println!(" slot_id: {slot}");
5319 }
5320 let pick =
5321 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
5322 if let Some(s) = pick("display_name") {
5323 println!(" display_name: {s}");
5324 }
5325 if let Some(s) = pick("emoji") {
5326 println!(" emoji: {s}");
5327 }
5328 if let Some(s) = pick("motto") {
5329 println!(" motto: {s}");
5330 }
5331 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
5332 let joined: Vec<String> = arr
5333 .iter()
5334 .filter_map(|v| v.as_str().map(str::to_string))
5335 .collect();
5336 println!(" vibe: {}", joined.join(", "));
5337 }
5338 if let Some(s) = pick("pronouns") {
5339 println!(" pronouns: {s}");
5340 }
5341}
5342
5343fn host_of_url(url: &str) -> String {
5351 let no_scheme = url
5352 .trim_start_matches("https://")
5353 .trim_start_matches("http://");
5354 no_scheme
5355 .split('/')
5356 .next()
5357 .unwrap_or("")
5358 .split(':')
5359 .next()
5360 .unwrap_or("")
5361 .to_string()
5362}
5363
5364fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
5368 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
5370 let peer_domain = peer_domain.trim().to_ascii_lowercase();
5371 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
5372 return true;
5373 }
5374 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
5377 if !our_host.is_empty() && our_host == peer_domain {
5378 return true;
5379 }
5380 false
5381}
5382
5383fn resolve_local_session<'a>(
5401 sessions: &'a [crate::session::SessionInfo],
5402 input: &str,
5403) -> Result<&'a crate::session::SessionInfo, ResolveError> {
5404 if let Some(s) = sessions.iter().find(|s| s.name == input) {
5407 return Ok(s);
5408 }
5409 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
5410 .iter()
5411 .filter(|s| {
5412 s.character
5413 .as_ref()
5414 .map(|c| c.nickname == input)
5415 .unwrap_or(false)
5416 })
5417 .collect();
5418 match nick_matches.len() {
5419 0 => Err(ResolveError::NotFound),
5420 1 => Ok(nick_matches[0]),
5421 _ => Err(ResolveError::Ambiguous(
5422 nick_matches.iter().map(|s| s.name.clone()).collect(),
5423 )),
5424 }
5425}
5426
5427#[derive(Debug)]
5428enum ResolveError {
5429 NotFound,
5430 Ambiguous(Vec<String>),
5431}
5432
5433fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
5449 let trust = match config::read_trust() {
5450 Ok(t) => t,
5451 Err(_) => return Ok(None),
5452 };
5453 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
5454 Some(a) => a,
5455 None => return Ok(None),
5456 };
5457 if agents.contains_key(input) {
5458 return Ok(Some(input.to_string()));
5459 }
5460 let mut nick_matches: Vec<String> = Vec::new();
5461 for (handle, agent) in agents.iter() {
5462 let character = match agent.get("card") {
5466 Some(card) => crate::character::Character::from_card(card),
5467 None => match agent.get("did").and_then(Value::as_str) {
5468 Some(did) => crate::character::Character::from_did(did),
5469 None => continue,
5470 },
5471 };
5472 if character.nickname == input {
5473 nick_matches.push(handle.clone());
5474 }
5475 }
5476 match nick_matches.len() {
5477 0 => Ok(None),
5478 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
5479 _ => Err(ResolveError::Ambiguous(nick_matches)),
5480 }
5481}
5482
5483fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
5484 let sessions = crate::session::list_sessions()?;
5486 let sister = match resolve_local_session(&sessions, sister_name) {
5487 Ok(s) => s,
5488 Err(ResolveError::NotFound) => bail!(
5489 "no sister session named `{sister_name}` (matched by session name or character nickname). \
5490 Run `wire session list` to see what's available."
5491 ),
5492 Err(ResolveError::Ambiguous(candidates)) => bail!(
5493 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
5494 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
5495 candidates.len(),
5496 candidates.join(", ")
5497 ),
5498 };
5499 if sister.name != sister_name {
5502 eprintln!(
5503 "wire add: resolved nickname `{sister_name}` → session `{}`",
5504 sister.name
5505 );
5506 }
5507
5508 let our_card = config::read_agent_card()
5511 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
5512 let our_did = our_card
5513 .get("did")
5514 .and_then(Value::as_str)
5515 .ok_or_else(|| anyhow!("agent-card missing did"))?
5516 .to_string();
5517 if let Some(sister_did) = sister.did.as_deref()
5518 && sister_did == our_did
5519 {
5520 bail!("refusing to add self (`{sister_name}` is this very session)");
5521 }
5522
5523 let sister_card_path = sister
5525 .home_dir
5526 .join("config")
5527 .join("wire")
5528 .join("agent-card.json");
5529 let sister_card: Value = serde_json::from_slice(
5530 &std::fs::read(&sister_card_path)
5531 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
5532 )
5533 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
5534 let sister_relay_state: Value = std::fs::read(
5535 sister
5536 .home_dir
5537 .join("config")
5538 .join("wire")
5539 .join("relay.json"),
5540 )
5541 .ok()
5542 .and_then(|b| serde_json::from_slice(&b).ok())
5543 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
5544
5545 let sister_did = sister_card
5546 .get("did")
5547 .and_then(Value::as_str)
5548 .ok_or_else(|| anyhow!("sister card missing did"))?
5549 .to_string();
5550 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
5551
5552 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
5556 if sister_endpoints.is_empty() {
5557 bail!(
5558 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
5559 );
5560 }
5561 let sister_local = sister_endpoints
5562 .iter()
5563 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
5564 let delivery_endpoint = match sister_local {
5565 Some(e) => e.clone(),
5566 None => sister_endpoints[0].clone(),
5567 };
5568
5569 let our_relay_state = config::read_relay_state()?;
5575 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
5576 if our_endpoints.is_empty() {
5577 bail!(
5578 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
5579 );
5580 }
5581 let our_advertised = our_endpoints
5582 .iter()
5583 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
5584 .cloned()
5585 .unwrap_or_else(|| our_endpoints[0].clone());
5586
5587 let mut trust = config::read_trust()?;
5591 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
5592 config::write_trust(&trust)?;
5593 let mut relay_state = config::read_relay_state()?;
5594 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
5595 config::write_relay_state(&relay_state)?;
5596
5597 let sk_seed = config::read_private_key()?;
5600 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
5601 let pk_b64 = our_card
5602 .get("verify_keys")
5603 .and_then(Value::as_object)
5604 .and_then(|m| m.values().next())
5605 .and_then(|v| v.get("key"))
5606 .and_then(Value::as_str)
5607 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
5608 let pk_bytes = crate::signing::b64decode(pk_b64)?;
5609 let now = time::OffsetDateTime::now_utc()
5610 .format(&time::format_description::well_known::Rfc3339)
5611 .unwrap_or_default();
5612 let mut body = json!({
5613 "card": our_card,
5614 "relay_url": our_advertised.relay_url,
5615 "slot_id": our_advertised.slot_id,
5616 "slot_token": our_advertised.slot_token,
5617 });
5618 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
5619 let event = json!({
5620 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5621 "timestamp": now,
5622 "from": our_did,
5623 "to": sister_did,
5624 "type": "pair_drop",
5625 "kind": 1100u32,
5626 "body": body,
5627 });
5628 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
5629 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
5630
5631 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
5635 client
5636 .post_event(
5637 &delivery_endpoint.slot_id,
5638 &delivery_endpoint.slot_token,
5639 &signed,
5640 )
5641 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
5642
5643 if as_json {
5644 println!(
5645 "{}",
5646 serde_json::to_string(&json!({
5647 "handle": sister_name,
5648 "paired_with": sister_did,
5649 "peer_handle": sister_handle,
5650 "event_id": event_id,
5651 "delivered_via": match delivery_endpoint.scope {
5652 crate::endpoints::EndpointScope::Local => "local",
5653 crate::endpoints::EndpointScope::Lan => "lan",
5654 crate::endpoints::EndpointScope::Uds => "uds",
5655 crate::endpoints::EndpointScope::Federation => "federation",
5656 },
5657 "status": "drop_sent",
5658 }))?
5659 );
5660 } else {
5661 let scope = match delivery_endpoint.scope {
5662 crate::endpoints::EndpointScope::Local => "local",
5663 crate::endpoints::EndpointScope::Lan => "lan",
5664 crate::endpoints::EndpointScope::Uds => "uds",
5665 crate::endpoints::EndpointScope::Federation => "federation",
5666 };
5667 println!(
5668 "→ found sister `{sister_name}` (did={sister_did})\n→ pinned peer locally\n→ pair_drop delivered to {scope} slot on {}\nawaiting pair_drop_ack from {sister_handle} to complete bilateral pin.",
5669 delivery_endpoint.relay_url
5670 );
5671 }
5672 Ok(())
5673}
5674
5675fn cmd_add(
5676 handle_arg: &str,
5677 relay_override: Option<&str>,
5678 local_sister: bool,
5679 as_json: bool,
5680) -> Result<()> {
5681 if local_sister {
5682 return cmd_add_local_sister(handle_arg, as_json);
5683 }
5684 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
5685
5686 let (our_did, our_relay, our_slot_id, our_slot_token) =
5688 crate::pair_invite::ensure_self_with_relay(relay_override)?;
5689 if our_did == format!("did:wire:{}", parsed.nick) {
5690 bail!("refusing to add self (handle matches own DID)");
5692 }
5693
5694 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
5704 return cmd_add_accept_pending(
5705 handle_arg,
5706 &parsed.nick,
5707 &pending,
5708 &our_relay,
5709 &our_slot_id,
5710 &our_slot_token,
5711 as_json,
5712 );
5713 }
5714
5715 if !is_known_relay_domain(&parsed.domain, &our_relay) {
5732 eprintln!(
5733 "wire add: WARN unfamiliar relay domain `{}`.",
5734 parsed.domain
5735 );
5736 eprintln!(
5737 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
5738 host_of_url(&our_relay)
5739 );
5740 eprintln!(
5741 " and not on the known-good list. If you meant `{}@wireup.net`, ",
5742 parsed.nick
5743 );
5744 eprintln!(
5745 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
5746 parsed.nick
5747 );
5748 eprintln!(" peer out-of-band that they actually run a relay at this domain");
5749 eprintln!(" before relying on the pair. (See issue #9.4.)");
5750 }
5751
5752 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
5754 let peer_card = resolved
5755 .get("card")
5756 .cloned()
5757 .ok_or_else(|| anyhow!("resolved missing card"))?;
5758 let peer_did = resolved
5759 .get("did")
5760 .and_then(Value::as_str)
5761 .ok_or_else(|| anyhow!("resolved missing did"))?
5762 .to_string();
5763 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
5764 let peer_slot_id = resolved
5765 .get("slot_id")
5766 .and_then(Value::as_str)
5767 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
5768 .to_string();
5769 let peer_relay = resolved
5770 .get("relay_url")
5771 .and_then(Value::as_str)
5772 .map(str::to_string)
5773 .or_else(|| relay_override.map(str::to_string))
5774 .unwrap_or_else(|| format!("https://{}", parsed.domain));
5775
5776 let mut trust = config::read_trust()?;
5778 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
5779 config::write_trust(&trust)?;
5780 let mut relay_state = config::read_relay_state()?;
5781 let existing_token = relay_state
5782 .get("peers")
5783 .and_then(|p| p.get(&peer_handle))
5784 .and_then(|p| p.get("slot_token"))
5785 .and_then(Value::as_str)
5786 .map(str::to_string)
5787 .unwrap_or_default();
5788 relay_state["peers"][&peer_handle] = json!({
5789 "relay_url": peer_relay,
5790 "slot_id": peer_slot_id,
5791 "slot_token": existing_token, });
5793 config::write_relay_state(&relay_state)?;
5794
5795 let our_card = config::read_agent_card()?;
5798 let sk_seed = config::read_private_key()?;
5799 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
5800 let pk_b64 = our_card
5801 .get("verify_keys")
5802 .and_then(Value::as_object)
5803 .and_then(|m| m.values().next())
5804 .and_then(|v| v.get("key"))
5805 .and_then(Value::as_str)
5806 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
5807 let pk_bytes = crate::signing::b64decode(pk_b64)?;
5808 let now = time::OffsetDateTime::now_utc()
5809 .format(&time::format_description::well_known::Rfc3339)
5810 .unwrap_or_default();
5811 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
5816 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
5817 let mut body = json!({
5818 "card": our_card,
5819 "relay_url": our_relay,
5820 "slot_id": our_slot_id,
5821 "slot_token": our_slot_token,
5822 });
5823 if !our_endpoints.is_empty() {
5824 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
5825 }
5826 let event = json!({
5827 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5828 "timestamp": now,
5829 "from": our_did,
5830 "to": peer_did,
5831 "type": "pair_drop",
5832 "kind": 1100u32,
5833 "body": body,
5834 });
5835 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
5836
5837 let client = crate::relay_client::RelayClient::new(&peer_relay);
5839 let resp = client.handle_intro(&parsed.nick, &signed)?;
5840 let event_id = signed
5841 .get("event_id")
5842 .and_then(Value::as_str)
5843 .unwrap_or("")
5844 .to_string();
5845
5846 if as_json {
5847 println!(
5848 "{}",
5849 serde_json::to_string(&json!({
5850 "handle": handle_arg,
5851 "paired_with": peer_did,
5852 "peer_handle": peer_handle,
5853 "event_id": event_id,
5854 "drop_response": resp,
5855 "status": "drop_sent",
5856 }))?
5857 );
5858 } else {
5859 println!(
5860 "→ resolved {handle_arg} (did={peer_did})\n→ pinned peer locally\n→ intro dropped to {peer_relay}\nawaiting pair_drop_ack from {peer_handle} to complete bilateral pin."
5861 );
5862 }
5863 Ok(())
5864}
5865
5866fn cmd_add_accept_pending(
5873 handle_arg: &str,
5874 peer_nick: &str,
5875 pending: &crate::pending_inbound_pair::PendingInboundPair,
5876 _our_relay: &str,
5877 _our_slot_id: &str,
5878 _our_slot_token: &str,
5879 as_json: bool,
5880) -> Result<()> {
5881 let mut trust = config::read_trust()?;
5884 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
5885 config::write_trust(&trust)?;
5886
5887 let mut relay_state = config::read_relay_state()?;
5893 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
5894 vec![crate::endpoints::Endpoint::federation(
5895 pending.peer_relay_url.clone(),
5896 pending.peer_slot_id.clone(),
5897 pending.peer_slot_token.clone(),
5898 )]
5899 } else {
5900 pending.peer_endpoints.clone()
5901 };
5902 crate::endpoints::pin_peer_endpoints(
5903 &mut relay_state,
5904 &pending.peer_handle,
5905 &endpoints_to_pin,
5906 )?;
5907 config::write_relay_state(&relay_state)?;
5908
5909 crate::pair_invite::send_pair_drop_ack(
5911 &pending.peer_handle,
5912 &pending.peer_relay_url,
5913 &pending.peer_slot_id,
5914 &pending.peer_slot_token,
5915 )
5916 .with_context(|| {
5917 format!(
5918 "pair_drop_ack send to {} @ {} slot {} failed",
5919 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
5920 )
5921 })?;
5922
5923 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
5925
5926 if as_json {
5927 println!(
5928 "{}",
5929 serde_json::to_string(&json!({
5930 "handle": handle_arg,
5931 "paired_with": pending.peer_did,
5932 "peer_handle": pending.peer_handle,
5933 "status": "bilateral_accepted",
5934 "via": "pending_inbound",
5935 }))?
5936 );
5937 } else {
5938 println!(
5939 "→ accepted pending pair from {peer}\n→ pinned VERIFIED, slot_token recorded\n→ shipped our slot_token back via pair_drop_ack\nbilateral pair complete. Send with `wire send {peer} \"...\"`.",
5940 peer = pending.peer_handle,
5941 );
5942 }
5943 Ok(())
5944}
5945
5946fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
5953 let nick = crate::agent_card::bare_handle(peer_nick);
5954 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
5955 anyhow!(
5956 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
5957 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
5958 )
5959 })?;
5960 let (_our_did, our_relay, our_slot_id, our_slot_token) =
5961 crate::pair_invite::ensure_self_with_relay(None)?;
5962 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
5963 cmd_add_accept_pending(
5964 &handle_arg,
5965 nick,
5966 &pending,
5967 &our_relay,
5968 &our_slot_id,
5969 &our_slot_token,
5970 as_json,
5971 )
5972}
5973
5974fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
5977 let items = crate::pending_inbound_pair::list_pending_inbound()?;
5978 if as_json {
5979 println!("{}", serde_json::to_string(&items)?);
5980 return Ok(());
5981 }
5982 if items.is_empty() {
5983 println!("no pending inbound pair requests.");
5984 return Ok(());
5985 }
5986 println!("{:<20} {:<35} {:<25} DID", "PEER", "RELAY", "RECEIVED");
5987 for p in items {
5988 println!(
5989 "{:<20} {:<35} {:<25} {}",
5990 p.peer_handle, p.peer_relay_url, p.received_at, p.peer_did,
5991 );
5992 }
5993 println!("→ accept with `wire pair-accept <peer>`; refuse with `wire pair-reject <peer>`.");
5994 Ok(())
5995}
5996
5997fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
6001 let nick = crate::agent_card::bare_handle(peer_nick);
6002 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
6003 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
6004
6005 if as_json {
6006 println!(
6007 "{}",
6008 serde_json::to_string(&json!({
6009 "peer": nick,
6010 "rejected": existed.is_some(),
6011 "had_pending": existed.is_some(),
6012 }))?
6013 );
6014 } else if existed.is_some() {
6015 println!(
6016 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6017 );
6018 } else {
6019 println!("no pending pair from {nick} — nothing to reject");
6020 }
6021 Ok(())
6022}
6023
6024fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6035 match cmd {
6036 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6037 MeshCommand::Broadcast {
6038 kind,
6039 scope,
6040 exclude,
6041 noreply,
6042 body,
6043 json,
6044 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6045 MeshCommand::Role { action } => cmd_mesh_role(action),
6046 MeshCommand::Route {
6047 role,
6048 strategy,
6049 exclude,
6050 kind,
6051 body,
6052 json,
6053 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
6054 }
6055}
6056
6057fn cmd_mesh_route(
6062 role: &str,
6063 strategy: &str,
6064 exclude: &[String],
6065 kind: &str,
6066 body_arg: &str,
6067 as_json: bool,
6068) -> Result<()> {
6069 use std::time::Instant;
6070
6071 if !config::is_initialized()? {
6072 bail!("not initialized — run `wire init <handle>` first");
6073 }
6074 let strategy = strategy.to_ascii_lowercase();
6075 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
6076 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
6077 }
6078
6079 let state = config::read_relay_state()?;
6082 let pinned: std::collections::BTreeSet<String> = state["peers"]
6083 .as_object()
6084 .map(|m| m.keys().cloned().collect())
6085 .unwrap_or_default();
6086
6087 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6088
6089 let sessions = crate::session::list_sessions()?;
6094 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
6096 let handle = match s.handle.as_ref() {
6097 Some(h) => h.clone(),
6098 None => continue,
6099 };
6100 if exclude_set.contains(handle.as_str()) {
6101 continue;
6102 }
6103 if !pinned.contains(&handle) {
6104 continue;
6105 }
6106 let card_path = s
6107 .home_dir
6108 .join("config")
6109 .join("wire")
6110 .join("agent-card.json");
6111 let card_role = std::fs::read(&card_path)
6112 .ok()
6113 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6114 .and_then(|c| {
6115 c.get("profile")
6116 .and_then(|p| p.get("role"))
6117 .and_then(Value::as_str)
6118 .map(str::to_string)
6119 });
6120 if card_role.as_deref() == Some(role) {
6121 candidates.push((handle, s.did.clone()));
6122 }
6123 }
6124
6125 candidates.sort_by(|a, b| a.0.cmp(&b.0));
6126 candidates.dedup_by(|a, b| a.0 == b.0);
6127
6128 if candidates.is_empty() {
6129 bail!(
6130 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
6131 );
6132 }
6133
6134 let chosen = match strategy.as_str() {
6135 "first" => candidates[0].clone(),
6136 "random" => {
6137 use rand::Rng;
6138 let idx = rand::thread_rng().gen_range(0..candidates.len());
6139 candidates[idx].clone()
6140 }
6141 "round-robin" => {
6142 let cursor_path = mesh_route_cursor_path()?;
6147 let mut cursors: std::collections::BTreeMap<String, String> =
6148 read_mesh_route_cursors(&cursor_path);
6149 let last = cursors.get(role).cloned();
6150 let pick = match last {
6151 None => candidates[0].clone(),
6152 Some(last_h) => candidates
6153 .iter()
6154 .find(|(h, _)| h.as_str() > last_h.as_str())
6155 .cloned()
6156 .unwrap_or_else(|| candidates[0].clone()),
6157 };
6158 cursors.insert(role.to_string(), pick.0.clone());
6159 write_mesh_route_cursors(&cursor_path, &cursors)?;
6160 pick
6161 }
6162 _ => unreachable!(),
6163 };
6164
6165 let (chosen_handle, _chosen_did) = chosen;
6166
6167 let body_value: Value = if body_arg == "-" {
6169 use std::io::Read;
6170 let mut raw = String::new();
6171 std::io::stdin()
6172 .read_to_string(&mut raw)
6173 .with_context(|| "reading body from stdin")?;
6174 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6175 } else if let Some(path) = body_arg.strip_prefix('@') {
6176 let raw =
6177 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6178 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6179 } else {
6180 Value::String(body_arg.to_string())
6181 };
6182
6183 let sk_seed = config::read_private_key()?;
6184 let card = config::read_agent_card()?;
6185 let did = card
6186 .get("did")
6187 .and_then(Value::as_str)
6188 .ok_or_else(|| anyhow!("agent-card missing did"))?
6189 .to_string();
6190 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6191 let pk_b64 = card
6192 .get("verify_keys")
6193 .and_then(Value::as_object)
6194 .and_then(|m| m.values().next())
6195 .and_then(|v| v.get("key"))
6196 .and_then(Value::as_str)
6197 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6198 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6199
6200 let kind_id = parse_kind(kind)?;
6201 let now_iso = time::OffsetDateTime::now_utc()
6202 .format(&time::format_description::well_known::Rfc3339)
6203 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6204
6205 let event = json!({
6206 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6207 "timestamp": now_iso,
6208 "from": did,
6209 "to": format!("did:wire:{chosen_handle}"),
6210 "type": kind,
6211 "kind": kind_id,
6212 "body": json!({
6213 "content": body_value,
6214 "routed_via": {
6215 "role": role,
6216 "strategy": strategy,
6217 },
6218 }),
6219 });
6220 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
6221 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
6222 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6223
6224 let line = serde_json::to_vec(&signed)?;
6225 config::append_outbox_record(&chosen_handle, &line)?;
6226
6227 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
6228 if endpoints.is_empty() {
6229 bail!(
6230 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
6231 );
6232 }
6233 let start = Instant::now();
6234 let mut delivered = false;
6235 let mut last_err: Option<String> = None;
6236 let mut via_scope: Option<String> = None;
6237 for ep in &endpoints {
6238 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
6243 Ok(_) => {
6244 delivered = true;
6245 via_scope = Some(
6246 match ep.scope {
6247 crate::endpoints::EndpointScope::Local => "local",
6248 crate::endpoints::EndpointScope::Lan => "lan",
6249 crate::endpoints::EndpointScope::Uds => "uds",
6250 crate::endpoints::EndpointScope::Federation => "federation",
6251 }
6252 .to_string(),
6253 );
6254 break;
6255 }
6256 Err(e) => last_err = Some(format!("{e:#}")),
6257 }
6258 }
6259 let rtt_ms = start.elapsed().as_millis() as u64;
6260
6261 let summary = json!({
6262 "role": role,
6263 "strategy": strategy,
6264 "routed_to": chosen_handle,
6265 "event_id": event_id,
6266 "delivered": delivered,
6267 "delivered_via": via_scope,
6268 "rtt_ms": rtt_ms,
6269 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
6270 "error": last_err,
6271 });
6272
6273 if as_json {
6274 println!("{}", serde_json::to_string(&summary)?);
6275 } else if delivered {
6276 let via = via_scope.as_deref().unwrap_or("?");
6277 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
6278 } else {
6279 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
6280 bail!("delivery to `{chosen_handle}` failed: {err}");
6281 }
6282 Ok(())
6283}
6284
6285fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
6286 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
6287}
6288
6289fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
6290 std::fs::read(path)
6291 .ok()
6292 .and_then(|b| serde_json::from_slice(&b).ok())
6293 .unwrap_or_default()
6294}
6295
6296fn write_mesh_route_cursors(
6297 path: &std::path::Path,
6298 cursors: &std::collections::BTreeMap<String, String>,
6299) -> Result<()> {
6300 if let Some(parent) = path.parent() {
6301 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
6302 }
6303 let body = serde_json::to_vec_pretty(cursors)?;
6304 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
6305 Ok(())
6306}
6307
6308fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
6313 match action {
6314 MeshRoleAction::Set { role, json } => {
6315 validate_role_tag(&role)?;
6316 let new_profile =
6317 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
6318 if json {
6319 println!(
6320 "{}",
6321 serde_json::to_string(&json!({
6322 "role": role,
6323 "profile": new_profile,
6324 }))?
6325 );
6326 } else {
6327 println!("self role = {role} (signed into agent-card)");
6328 }
6329 }
6330 MeshRoleAction::Get { peer, json } => {
6331 let (who, role) = match peer.as_deref() {
6332 None => {
6333 let card = config::read_agent_card()?;
6334 let role = card
6335 .get("profile")
6336 .and_then(|p| p.get("role"))
6337 .and_then(Value::as_str)
6338 .map(str::to_string);
6339 let who = card
6340 .get("did")
6341 .and_then(Value::as_str)
6342 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
6343 .unwrap_or_else(|| "self".to_string());
6344 (who, role)
6345 }
6346 Some(handle) => {
6347 let bare = crate::agent_card::bare_handle(handle).to_string();
6348 let trust = config::read_trust()?;
6349 let role = trust
6350 .get("agents")
6351 .and_then(|a| a.get(&bare))
6352 .and_then(|a| a.get("card"))
6353 .and_then(|c| c.get("profile"))
6354 .and_then(|p| p.get("role"))
6355 .and_then(Value::as_str)
6356 .map(str::to_string);
6357 (bare, role)
6358 }
6359 };
6360 if json {
6361 println!(
6362 "{}",
6363 serde_json::to_string(&json!({
6364 "handle": who,
6365 "role": role,
6366 }))?
6367 );
6368 } else {
6369 match role {
6370 Some(r) => println!("{who}: {r}"),
6371 None => println!("{who}: (unset)"),
6372 }
6373 }
6374 }
6375 MeshRoleAction::List { json } => {
6376 let mut self_did: Option<String> = None;
6377 if let Ok(card) = config::read_agent_card() {
6378 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
6379 }
6380 let sessions = crate::session::list_sessions()?;
6381 let mut rows: Vec<Value> = Vec::new();
6382 for s in &sessions {
6383 let card_path = s
6384 .home_dir
6385 .join("config")
6386 .join("wire")
6387 .join("agent-card.json");
6388 let role = std::fs::read(&card_path)
6389 .ok()
6390 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6391 .and_then(|c| {
6392 c.get("profile")
6393 .and_then(|p| p.get("role"))
6394 .and_then(Value::as_str)
6395 .map(str::to_string)
6396 });
6397 let is_self = match (&self_did, &s.did) {
6398 (Some(a), Some(b)) => a == b,
6399 _ => false,
6400 };
6401 rows.push(json!({
6402 "name": s.name,
6403 "handle": s.handle,
6404 "role": role,
6405 "self": is_self,
6406 }));
6407 }
6408 rows.sort_by(|a, b| {
6409 a["name"]
6410 .as_str()
6411 .unwrap_or("")
6412 .cmp(b["name"].as_str().unwrap_or(""))
6413 });
6414 if json {
6415 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
6416 } else if rows.is_empty() {
6417 println!("no sister sessions on this machine.");
6418 } else {
6419 println!("SISTER ROLES (this machine):");
6420 for r in &rows {
6421 let name = r["name"].as_str().unwrap_or("?");
6422 let role = r["role"].as_str().unwrap_or("(unset)");
6423 let marker = if r["self"].as_bool().unwrap_or(false) {
6424 " ← you"
6425 } else {
6426 ""
6427 };
6428 println!(" {name:<24} {role}{marker}");
6429 }
6430 }
6431 }
6432 MeshRoleAction::Clear { json } => {
6433 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
6434 if json {
6435 println!(
6436 "{}",
6437 serde_json::to_string(&json!({
6438 "cleared": true,
6439 "profile": new_profile,
6440 }))?
6441 );
6442 } else {
6443 println!("self role cleared");
6444 }
6445 }
6446 }
6447 Ok(())
6448}
6449
6450fn validate_role_tag(role: &str) -> Result<()> {
6455 if role.is_empty() {
6456 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
6457 }
6458 if role.len() > 32 {
6459 bail!("role too long ({} chars; max 32)", role.len());
6460 }
6461 for c in role.chars() {
6462 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
6463 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
6464 }
6465 }
6466 Ok(())
6467}
6468
6469fn cmd_mesh_broadcast(
6489 kind: &str,
6490 scope_str: &str,
6491 exclude: &[String],
6492 _noreply: bool,
6493 body_arg: &str,
6494 as_json: bool,
6495) -> Result<()> {
6496 use std::time::Instant;
6497
6498 if !config::is_initialized()? {
6499 bail!("not initialized — run `wire init <handle>` first");
6500 }
6501
6502 let scope = match scope_str {
6503 "local" => crate::endpoints::EndpointScope::Local,
6504 "federation" => crate::endpoints::EndpointScope::Federation,
6505 "both" => {
6506 crate::endpoints::EndpointScope::Local
6510 }
6511 other => bail!("unknown scope `{other}` — use local | federation | both"),
6512 };
6513 let any_scope = scope_str == "both";
6514
6515 let state = config::read_relay_state()?;
6516 let peers = state["peers"].as_object().cloned().unwrap_or_default();
6517 if peers.is_empty() {
6518 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
6519 }
6520
6521 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6522
6523 struct Target {
6527 handle: String,
6528 endpoints: Vec<crate::endpoints::Endpoint>,
6529 }
6530 let mut targets: Vec<Target> = Vec::new();
6531 let mut skipped_wrong_scope: Vec<String> = Vec::new();
6532 let mut skipped_excluded: Vec<String> = Vec::new();
6533 for handle in peers.keys() {
6534 if exclude_set.contains(handle.as_str()) {
6535 skipped_excluded.push(handle.clone());
6536 continue;
6537 }
6538 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
6539 let filtered: Vec<crate::endpoints::Endpoint> = ordered
6540 .into_iter()
6541 .filter(|ep| any_scope || ep.scope == scope)
6542 .collect();
6543 if filtered.is_empty() {
6544 skipped_wrong_scope.push(handle.clone());
6545 continue;
6546 }
6547 targets.push(Target {
6548 handle: handle.clone(),
6549 endpoints: filtered,
6550 });
6551 }
6552
6553 if targets.is_empty() {
6554 bail!(
6555 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
6556 skipped_excluded.len(),
6557 skipped_wrong_scope.len()
6558 );
6559 }
6560
6561 let sk_seed = config::read_private_key()?;
6563 let card = config::read_agent_card()?;
6564 let did = card
6565 .get("did")
6566 .and_then(Value::as_str)
6567 .ok_or_else(|| anyhow!("agent-card missing did"))?
6568 .to_string();
6569 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6570 let pk_b64 = card
6571 .get("verify_keys")
6572 .and_then(Value::as_object)
6573 .and_then(|m| m.values().next())
6574 .and_then(|v| v.get("key"))
6575 .and_then(Value::as_str)
6576 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6577 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6578
6579 let body_value: Value = if body_arg == "-" {
6580 use std::io::Read;
6581 let mut raw = String::new();
6582 std::io::stdin()
6583 .read_to_string(&mut raw)
6584 .with_context(|| "reading body from stdin")?;
6585 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6586 } else if let Some(path) = body_arg.strip_prefix('@') {
6587 let raw =
6588 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6589 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6590 } else {
6591 Value::String(body_arg.to_string())
6592 };
6593
6594 let kind_id = parse_kind(kind)?;
6595 let now_iso = time::OffsetDateTime::now_utc()
6596 .format(&time::format_description::well_known::Rfc3339)
6597 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6598
6599 let broadcast_id = generate_broadcast_id();
6600 let target_count = targets.len();
6601
6602 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
6606 Vec::with_capacity(targets.len());
6607 for t in &targets {
6608 let body = json!({
6609 "content": body_value,
6610 "broadcast_id": broadcast_id,
6611 "broadcast_target_count": target_count,
6612 });
6613 let event = json!({
6614 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6615 "timestamp": now_iso,
6616 "from": did,
6617 "to": format!("did:wire:{}", t.handle),
6618 "type": kind,
6619 "kind": kind_id,
6620 "body": body,
6621 });
6622 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
6623 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
6624 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6625 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
6626 }
6627
6628 for (peer, _, signed, _) in &signed_per_peer {
6632 let line = serde_json::to_vec(signed)?;
6633 config::append_outbox_record(peer, &line)?;
6634 }
6635
6636 use std::sync::mpsc;
6640 let (tx, rx) = mpsc::channel::<Value>();
6641 std::thread::scope(|s| {
6642 for (peer, endpoints, signed, event_id) in &signed_per_peer {
6643 let tx = tx.clone();
6644 let peer = peer.clone();
6645 let event_id = event_id.clone();
6646 let endpoints = endpoints.clone();
6647 let signed = signed.clone();
6648 s.spawn(move || {
6649 let start = Instant::now();
6650 let mut delivered = false;
6651 let mut last_err: Option<String> = None;
6652 let mut delivered_via: Option<String> = None;
6653 for ep in &endpoints {
6654 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
6659 Ok(_) => {
6660 delivered = true;
6661 delivered_via = Some(
6662 match ep.scope {
6663 crate::endpoints::EndpointScope::Local => "local",
6664 crate::endpoints::EndpointScope::Lan => "lan",
6665 crate::endpoints::EndpointScope::Uds => "uds",
6666 crate::endpoints::EndpointScope::Federation => "federation",
6667 }
6668 .to_string(),
6669 );
6670 break;
6671 }
6672 Err(e) => last_err = Some(format!("{e:#}")),
6673 }
6674 }
6675 let rtt_ms = start.elapsed().as_millis() as u64;
6676 let _ = tx.send(json!({
6677 "peer": peer,
6678 "event_id": event_id,
6679 "delivered": delivered,
6680 "delivered_via": delivered_via,
6681 "rtt_ms": rtt_ms,
6682 "error": last_err,
6683 }));
6684 });
6685 }
6686 });
6687 drop(tx);
6688
6689 let mut results: Vec<Value> = rx.iter().collect();
6690 results.sort_by(|a, b| {
6691 a["peer"]
6692 .as_str()
6693 .unwrap_or("")
6694 .cmp(b["peer"].as_str().unwrap_or(""))
6695 });
6696
6697 let delivered = results
6698 .iter()
6699 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
6700 .count();
6701 let failed = results.len() - delivered;
6702
6703 let summary = json!({
6704 "broadcast_id": broadcast_id,
6705 "kind": kind,
6706 "scope": scope_str,
6707 "target_count": target_count,
6708 "delivered": delivered,
6709 "failed": failed,
6710 "skipped_excluded": skipped_excluded,
6711 "skipped_wrong_scope": skipped_wrong_scope,
6712 "results": results,
6713 });
6714
6715 if as_json {
6716 println!("{}", serde_json::to_string(&summary)?);
6717 return Ok(());
6718 }
6719
6720 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
6721 for r in &results {
6722 let peer = r["peer"].as_str().unwrap_or("?");
6723 let delivered = r["delivered"].as_bool().unwrap_or(false);
6724 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
6725 let via = r["delivered_via"].as_str().unwrap_or("");
6726 if delivered {
6727 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
6728 } else {
6729 let err = r["error"].as_str().unwrap_or("?");
6730 println!(" {peer:<24} ✗ failed — {err}");
6731 }
6732 }
6733 if !skipped_excluded.is_empty() {
6734 println!(" excluded: {}", skipped_excluded.join(", "));
6735 }
6736 if !skipped_wrong_scope.is_empty() {
6737 println!(
6738 " skipped (wrong scope): {}",
6739 skipped_wrong_scope.join(", ")
6740 );
6741 }
6742 println!("broadcast_id: {broadcast_id}");
6743 Ok(())
6744}
6745
6746fn generate_broadcast_id() -> String {
6750 use rand::RngCore;
6751 let mut buf = [0u8; 16];
6752 rand::thread_rng().fill_bytes(&mut buf);
6753 let h = hex::encode(buf);
6754 format!(
6755 "{}-{}-{}-{}-{}",
6756 &h[0..8],
6757 &h[8..12],
6758 &h[12..16],
6759 &h[16..20],
6760 &h[20..32],
6761 )
6762}
6763
6764fn cmd_session(cmd: SessionCommand) -> Result<()> {
6765 match cmd {
6766 SessionCommand::New {
6767 name,
6768 relay,
6769 with_local,
6770 local_relay,
6771 with_lan,
6772 lan_relay,
6773 with_uds,
6774 uds_socket,
6775 no_daemon,
6776 local_only,
6777 json,
6778 } => cmd_session_new(
6779 name.as_deref(),
6780 &relay,
6781 with_local,
6782 &local_relay,
6783 with_lan,
6784 lan_relay.as_deref(),
6785 with_uds,
6786 uds_socket.as_deref(),
6787 no_daemon,
6788 local_only,
6789 json,
6790 ),
6791 SessionCommand::List { json } => cmd_session_list(json),
6792 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
6793 SessionCommand::PairAllLocal {
6794 settle_secs,
6795 federation_relay,
6796 json,
6797 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
6798 SessionCommand::MeshStatus { stale_secs, json } => {
6799 cmd_session_mesh_status(stale_secs, json)
6800 }
6801 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
6802 SessionCommand::Current { json } => cmd_session_current(json),
6803 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
6804 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
6805 }
6806}
6807
6808fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
6809 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6810 let cwd_str = cwd.to_string_lossy().into_owned();
6811
6812 let resolved_name = match name_arg {
6813 Some(n) => crate::session::sanitize_name(n),
6814 None => crate::session::sanitize_name(
6815 cwd.file_name()
6816 .and_then(|s| s.to_str())
6817 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
6818 ),
6819 };
6820
6821 let session_home = crate::session::session_dir(&resolved_name)?;
6822 if !session_home.exists() {
6823 bail!(
6824 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
6825 session_home.display()
6826 );
6827 }
6828
6829 let prior = crate::session::read_registry()
6830 .ok()
6831 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
6832 if prior.as_deref() == Some(resolved_name.as_str()) {
6833 if json {
6834 println!(
6835 "{}",
6836 serde_json::to_string(&json!({
6837 "cwd": cwd_str,
6838 "session": resolved_name,
6839 "changed": false,
6840 }))?
6841 );
6842 } else {
6843 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
6844 }
6845 return Ok(());
6846 }
6847 if let Some(prior_name) = &prior {
6848 eprintln!(
6849 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
6850 );
6851 }
6852
6853 crate::session::update_registry(|reg| {
6854 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
6855 Ok(())
6856 })?;
6857
6858 if json {
6859 println!(
6860 "{}",
6861 serde_json::to_string(&json!({
6862 "cwd": cwd_str,
6863 "session": resolved_name,
6864 "changed": true,
6865 "previous": prior,
6866 }))?
6867 );
6868 } else {
6869 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
6870 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
6871 }
6872 Ok(())
6873}
6874
6875fn resolve_session_name(name: Option<&str>) -> Result<String> {
6876 if let Some(n) = name {
6877 return Ok(crate::session::sanitize_name(n));
6878 }
6879 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6880 let registry = crate::session::read_registry().unwrap_or_default();
6881 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
6882}
6883
6884#[allow(clippy::too_many_arguments)] fn cmd_session_new(
6888 name_arg: Option<&str>,
6889 relay: &str,
6890 with_local: bool,
6891 local_relay: &str,
6892 with_lan: bool,
6893 lan_relay: Option<&str>,
6894 with_uds: bool,
6895 uds_socket: Option<&std::path::Path>,
6896 no_daemon: bool,
6897 local_only: bool,
6898 as_json: bool,
6899) -> Result<()> {
6900 let with_local = with_local || local_only;
6903 if with_lan && lan_relay.is_none() {
6905 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
6906 }
6907 if with_uds && uds_socket.is_none() {
6909 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
6910 }
6911 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6912 let mut registry = crate::session::read_registry().unwrap_or_default();
6913 let name = match name_arg {
6914 Some(n) => crate::session::sanitize_name(n),
6915 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
6916 };
6917 let session_home = crate::session::session_dir(&name)?;
6918
6919 let already_exists = session_home.exists()
6920 && session_home
6921 .join("config")
6922 .join("wire")
6923 .join("agent-card.json")
6924 .exists();
6925 if already_exists {
6926 registry
6930 .by_cwd
6931 .insert(cwd.to_string_lossy().into_owned(), name.clone());
6932 crate::session::write_registry(®istry)?;
6933 let info = render_session_info(&name, &session_home, &cwd)?;
6934 emit_session_new_result(&info, "already_exists", as_json)?;
6935 if !no_daemon {
6936 ensure_session_daemon(&session_home)?;
6937 }
6938 return Ok(());
6939 }
6940
6941 std::fs::create_dir_all(&session_home)
6942 .with_context(|| format!("creating session dir {session_home:?}"))?;
6943
6944 let init_args: Vec<&str> = if local_only {
6949 vec!["init", &name]
6950 } else {
6951 vec!["init", &name, "--relay", relay]
6952 };
6953 let init_status = run_wire_with_home(&session_home, &init_args)?;
6954 if !init_status.success() {
6955 let how = if local_only {
6956 format!("`wire init {name}` (local-only)")
6957 } else {
6958 format!("`wire init {name} --relay {relay}`")
6959 };
6960 bail!("{how} failed inside session dir {session_home:?}");
6961 }
6962
6963 let effective_handle = if local_only {
6968 name.clone()
6969 } else {
6970 let mut claim_attempt = 0u32;
6971 let mut effective = name.clone();
6972 loop {
6973 claim_attempt += 1;
6974 let status =
6975 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
6976 if status.success() {
6977 break;
6978 }
6979 if claim_attempt >= 5 {
6980 bail!(
6981 "5 failed attempts to claim a handle on {relay} for session {name}. \
6982 Try `wire session destroy {name} --force` and re-run with a different name, \
6983 or use `--local-only` if you don't need a federation address."
6984 );
6985 }
6986 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
6987 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
6988 let token = suffix
6989 .rsplit('-')
6990 .next()
6991 .filter(|t| t.len() == 4)
6992 .map(str::to_string)
6993 .unwrap_or_else(|| format!("{claim_attempt}"));
6994 effective = format!("{name}-{token}");
6995 }
6996 effective
6997 };
6998
6999 registry
7002 .by_cwd
7003 .insert(cwd.to_string_lossy().into_owned(), name.clone());
7004 crate::session::write_registry(®istry)?;
7005
7006 if with_local {
7017 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
7018 if local_only {
7019 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
7024 let state: Value = std::fs::read(&relay_state_path)
7025 .ok()
7026 .and_then(|b| serde_json::from_slice(&b).ok())
7027 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7028 let endpoints = crate::endpoints::self_endpoints(&state);
7029 let has_local = endpoints
7030 .iter()
7031 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
7032 if !has_local {
7033 bail!(
7034 "--local-only requested but local-relay probe at {local_relay} failed — \
7035 ensure the local relay is running (`wire service install --local-relay`), \
7036 then re-run `wire session new {name} --local-only`."
7037 );
7038 }
7039 }
7040 }
7041
7042 if with_lan && let Some(lan_url) = lan_relay {
7046 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
7047 }
7048 if with_uds && let Some(socket_path) = uds_socket {
7050 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
7051 }
7052
7053 if !no_daemon {
7054 ensure_session_daemon(&session_home)?;
7055 }
7056
7057 let info = render_session_info(&name, &session_home, &cwd)?;
7058 emit_session_new_result(&info, "created", as_json)
7059}
7060
7061#[cfg(unix)]
7071fn try_allocate_uds_slot(
7072 session_home: &std::path::Path,
7073 handle: &str,
7074 uds_socket: &std::path::Path,
7075) {
7076 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
7079 Ok((200, _)) => true,
7080 Ok((status, body)) => {
7081 eprintln!(
7082 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
7083 String::from_utf8_lossy(&body)
7084 );
7085 return;
7086 }
7087 Err(e) => {
7088 eprintln!(
7089 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
7090 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
7091 );
7092 return;
7093 }
7094 };
7095 if !healthz {
7096 return;
7097 }
7098
7099 let alloc_body = serde_json::json!({"handle": handle}).to_string();
7101 let (status, body) = match crate::relay_client::uds_request(
7102 uds_socket,
7103 "POST",
7104 "/v1/slot/allocate",
7105 &[("Content-Type", "application/json")],
7106 alloc_body.as_bytes(),
7107 ) {
7108 Ok(r) => r,
7109 Err(e) => {
7110 eprintln!(
7111 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
7112 );
7113 return;
7114 }
7115 };
7116 if status >= 300 {
7117 eprintln!(
7118 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
7119 String::from_utf8_lossy(&body)
7120 );
7121 return;
7122 }
7123 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
7124 Ok(a) => a,
7125 Err(e) => {
7126 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
7127 return;
7128 }
7129 };
7130
7131 let state_path = session_home.join("config").join("wire").join("relay.json");
7132 let mut state: serde_json::Value = std::fs::read(&state_path)
7133 .ok()
7134 .and_then(|b| serde_json::from_slice(&b).ok())
7135 .unwrap_or_else(|| serde_json::json!({}));
7136
7137 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7138 .get("self")
7139 .and_then(|s| s.get("endpoints"))
7140 .and_then(|e| e.as_array())
7141 .map(|arr| {
7142 arr.iter()
7143 .filter_map(|v| {
7144 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7145 })
7146 .collect()
7147 })
7148 .unwrap_or_default();
7149 endpoints.push(crate::endpoints::Endpoint::uds(
7150 format!("unix://{}", uds_socket.display()),
7151 alloc.slot_id.clone(),
7152 alloc.slot_token.clone(),
7153 ));
7154
7155 let self_obj = state
7156 .as_object_mut()
7157 .expect("relay_state root is an object")
7158 .entry("self")
7159 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7160 if !self_obj.is_object() {
7161 *self_obj = serde_json::Value::Object(serde_json::Map::new());
7162 }
7163 if let Some(obj) = self_obj.as_object_mut() {
7164 obj.insert(
7165 "endpoints".into(),
7166 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7167 );
7168 }
7169 if let Err(e) = std::fs::write(
7170 &state_path,
7171 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7172 ) {
7173 eprintln!("wire session new: failed to write {state_path:?}: {e}");
7174 return;
7175 }
7176 eprintln!(
7177 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
7178 uds_socket.display(),
7179 alloc.slot_id
7180 );
7181}
7182
7183#[cfg(not(unix))]
7184fn try_allocate_uds_slot(
7185 _session_home: &std::path::Path,
7186 _handle: &str,
7187 _uds_socket: &std::path::Path,
7188) {
7189 eprintln!(
7190 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
7191 );
7192}
7193
7194fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
7204 let probe = match crate::relay_client::build_blocking_client(Some(
7205 std::time::Duration::from_millis(500),
7206 )) {
7207 Ok(c) => c,
7208 Err(e) => {
7209 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
7210 return;
7211 }
7212 };
7213 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
7214 match probe.get(&healthz_url).send() {
7215 Ok(resp) if resp.status().is_success() => {}
7216 Ok(resp) => {
7217 eprintln!(
7218 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
7219 resp.status()
7220 );
7221 return;
7222 }
7223 Err(e) => {
7224 eprintln!(
7225 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
7226 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
7227 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
7228 );
7229 return;
7230 }
7231 };
7232
7233 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
7234 let alloc = match lan_client.allocate_slot(Some(handle)) {
7235 Ok(a) => a,
7236 Err(e) => {
7237 eprintln!(
7238 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
7239 );
7240 return;
7241 }
7242 };
7243
7244 let state_path = session_home.join("config").join("wire").join("relay.json");
7245 let mut state: serde_json::Value = std::fs::read(&state_path)
7246 .ok()
7247 .and_then(|b| serde_json::from_slice(&b).ok())
7248 .unwrap_or_else(|| serde_json::json!({}));
7249
7250 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7253 .get("self")
7254 .and_then(|s| s.get("endpoints"))
7255 .and_then(|e| e.as_array())
7256 .map(|arr| {
7257 arr.iter()
7258 .filter_map(|v| {
7259 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7260 })
7261 .collect()
7262 })
7263 .unwrap_or_default();
7264 endpoints.push(crate::endpoints::Endpoint::lan(
7265 lan_relay.trim_end_matches('/').to_string(),
7266 alloc.slot_id.clone(),
7267 alloc.slot_token.clone(),
7268 ));
7269
7270 let self_obj = state
7271 .as_object_mut()
7272 .expect("relay_state root is an object")
7273 .entry("self")
7274 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7275 if !self_obj.is_object() {
7276 *self_obj = serde_json::Value::Object(serde_json::Map::new());
7277 }
7278 if let Some(obj) = self_obj.as_object_mut() {
7279 obj.insert(
7280 "endpoints".into(),
7281 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7282 );
7283 }
7284 if let Err(e) = std::fs::write(
7285 &state_path,
7286 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7287 ) {
7288 eprintln!("wire session new: failed to write {state_path:?}: {e}");
7289 return;
7290 }
7291 eprintln!(
7292 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
7293 alloc.slot_id
7294 );
7295}
7296
7297fn try_allocate_local_slot(
7305 session_home: &std::path::Path,
7306 handle: &str,
7307 _federation_relay: &str,
7308 local_relay: &str,
7309) {
7310 let probe = match crate::relay_client::build_blocking_client(Some(
7313 std::time::Duration::from_millis(500),
7314 )) {
7315 Ok(c) => c,
7316 Err(e) => {
7317 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
7318 return;
7319 }
7320 };
7321 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
7322 match probe.get(&healthz_url).send() {
7323 Ok(resp) if resp.status().is_success() => {}
7324 Ok(resp) => {
7325 eprintln!(
7326 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
7327 resp.status()
7328 );
7329 return;
7330 }
7331 Err(e) => {
7332 eprintln!(
7333 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
7334 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
7335 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
7336 );
7337 return;
7338 }
7339 };
7340
7341 let local_client = crate::relay_client::RelayClient::new(local_relay);
7343 let alloc = match local_client.allocate_slot(Some(handle)) {
7344 Ok(a) => a,
7345 Err(e) => {
7346 eprintln!(
7347 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
7348 );
7349 return;
7350 }
7351 };
7352
7353 let state_path = session_home.join("config").join("wire").join("relay.json");
7368 let mut state: serde_json::Value = std::fs::read(&state_path)
7369 .ok()
7370 .and_then(|b| serde_json::from_slice(&b).ok())
7371 .unwrap_or_else(|| serde_json::json!({}));
7372 let fed_endpoint = state.get("self").and_then(|s| {
7375 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
7376 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
7377 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
7378 Some(crate::endpoints::Endpoint::federation(
7379 url.to_string(),
7380 slot_id.to_string(),
7381 slot_token.to_string(),
7382 ))
7383 });
7384
7385 let local_endpoint = crate::endpoints::Endpoint::local(
7386 local_relay.trim_end_matches('/').to_string(),
7387 alloc.slot_id.clone(),
7388 alloc.slot_token.clone(),
7389 );
7390
7391 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
7392 if let Some(f) = fed_endpoint.clone() {
7393 endpoints.push(f);
7394 }
7395 endpoints.push(local_endpoint);
7396
7397 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
7407 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
7408 None => (
7409 local_relay.trim_end_matches('/').to_string(),
7410 alloc.slot_id.clone(),
7411 alloc.slot_token.clone(),
7412 ),
7413 };
7414 let self_obj = state
7415 .as_object_mut()
7416 .expect("relay_state root is an object")
7417 .entry("self")
7418 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7419 if !self_obj.is_object() {
7422 *self_obj = serde_json::Value::Object(serde_json::Map::new());
7423 }
7424 if let Some(obj) = self_obj.as_object_mut() {
7425 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
7426 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
7427 obj.insert(
7428 "slot_token".into(),
7429 serde_json::Value::String(legacy_slot_token),
7430 );
7431 obj.insert(
7432 "endpoints".into(),
7433 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7434 );
7435 }
7436
7437 if let Err(e) = std::fs::write(
7438 &state_path,
7439 serde_json::to_vec_pretty(&state).unwrap_or_default(),
7440 ) {
7441 eprintln!(
7442 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
7443 );
7444 return;
7445 }
7446 eprintln!(
7447 "wire session new: local slot allocated on {local_relay} (slot_id={})",
7448 alloc.slot_id
7449 );
7450}
7451
7452fn render_session_info(
7453 name: &str,
7454 session_home: &std::path::Path,
7455 cwd: &std::path::Path,
7456) -> Result<serde_json::Value> {
7457 let card_path = session_home
7458 .join("config")
7459 .join("wire")
7460 .join("agent-card.json");
7461 let (did, handle) = if card_path.exists() {
7462 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
7463 let did = card
7464 .get("did")
7465 .and_then(Value::as_str)
7466 .unwrap_or("")
7467 .to_string();
7468 let handle = card
7469 .get("handle")
7470 .and_then(Value::as_str)
7471 .map(str::to_string)
7472 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
7473 (did, handle)
7474 } else {
7475 (String::new(), String::new())
7476 };
7477 Ok(json!({
7478 "name": name,
7479 "home_dir": session_home.to_string_lossy(),
7480 "cwd": cwd.to_string_lossy(),
7481 "did": did,
7482 "handle": handle,
7483 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
7484 }))
7485}
7486
7487fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
7488 if as_json {
7489 let mut obj = info.clone();
7490 obj["status"] = json!(status);
7491 println!("{}", serde_json::to_string(&obj)?);
7492 } else {
7493 let name = info["name"].as_str().unwrap_or("?");
7494 let handle = info["handle"].as_str().unwrap_or("?");
7495 let home = info["home_dir"].as_str().unwrap_or("?");
7496 let did = info["did"].as_str().unwrap_or("?");
7497 let export = info["export"].as_str().unwrap_or("?");
7498 let prefix = if status == "already_exists" {
7499 "session already exists (re-registered cwd)"
7500 } else {
7501 "session created"
7502 };
7503 println!(
7504 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
7505 );
7506 }
7507 Ok(())
7508}
7509
7510fn run_wire_with_home(
7511 session_home: &std::path::Path,
7512 args: &[&str],
7513) -> Result<std::process::ExitStatus> {
7514 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
7515 let status = std::process::Command::new(&bin)
7516 .env("WIRE_HOME", session_home)
7517 .env_remove("RUST_LOG")
7518 .env("WIRE_AUTO_INIT", "0")
7521 .args(args)
7522 .status()
7523 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
7524 Ok(status)
7525}
7526
7527pub fn maybe_auto_init_cwd_session(label: &str) {
7546 if std::env::var("WIRE_HOME").is_ok() {
7547 return; }
7549 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
7550 return; }
7552 let cwd = match std::env::current_dir() {
7553 Ok(c) => c,
7554 Err(_) => return,
7555 };
7556 if crate::session::detect_session_wire_home(&cwd).is_some() {
7559 return;
7560 }
7561
7562 use fs2::FileExt;
7579 let sessions_root = match crate::session::sessions_root() {
7580 Ok(r) => r,
7581 Err(_) => return,
7582 };
7583 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
7584 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
7585 return;
7586 }
7587 let lock_path = sessions_root.join(".auto-init.lock");
7588 let lock_file = match std::fs::OpenOptions::new()
7589 .create(true)
7590 .truncate(false)
7591 .read(true)
7592 .write(true)
7593 .open(&lock_path)
7594 {
7595 Ok(f) => f,
7596 Err(e) => {
7597 eprintln!(
7598 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
7599 );
7600 return;
7601 }
7602 };
7603 if let Err(e) = lock_file.lock_exclusive() {
7604 eprintln!(
7605 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
7606 );
7607 return;
7608 }
7609 let registry = crate::session::read_registry().unwrap_or_default();
7614 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
7615 let session_home = match crate::session::session_dir(&name) {
7616 Ok(h) => h,
7617 Err(_) => {
7618 let _ = fs2::FileExt::unlock(&lock_file);
7619 return;
7620 }
7621 };
7622 let agent_card_path = session_home
7623 .join("config")
7624 .join("wire")
7625 .join("agent-card.json");
7626 let needs_init = !agent_card_path.exists();
7627
7628 if needs_init {
7629 if let Err(e) = std::fs::create_dir_all(&session_home) {
7630 eprintln!(
7631 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
7632 );
7633 let _ = fs2::FileExt::unlock(&lock_file);
7634 return;
7635 }
7636 match run_wire_with_home(&session_home, &["init", &name]) {
7637 Ok(status) if status.success() => {}
7638 Ok(status) => {
7639 eprintln!(
7640 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
7641 );
7642 let _ = fs2::FileExt::unlock(&lock_file);
7643 return;
7644 }
7645 Err(e) => {
7646 eprintln!(
7647 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
7648 );
7649 let _ = fs2::FileExt::unlock(&lock_file);
7650 return;
7651 }
7652 }
7653 try_allocate_local_slot(
7660 &session_home,
7661 &name,
7662 "https://wireup.net",
7663 "http://127.0.0.1:8771",
7664 );
7665 } else {
7666 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
7670 eprintln!(
7671 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
7672 );
7673 }
7674 }
7675 let cwd_key = cwd.to_string_lossy().into_owned();
7685 let name_for_reg = name.clone();
7686 if let Err(e) = crate::session::update_registry(|reg| {
7687 reg.by_cwd.insert(cwd_key, name_for_reg);
7688 Ok(())
7689 }) {
7690 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
7691 }
7693 let _ = fs2::FileExt::unlock(&lock_file);
7696
7697 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
7698 eprintln!(
7699 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
7700 cwd.display(),
7701 session_home.display()
7702 );
7703 }
7704 unsafe {
7707 std::env::set_var("WIRE_HOME", &session_home);
7708 }
7709}
7710
7711fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
7712 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
7715 if pidfile.exists() {
7716 let bytes = std::fs::read(&pidfile).unwrap_or_default();
7717 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
7718 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
7719 } else {
7720 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
7721 };
7722 if let Some(p) = pid {
7723 let alive = {
7724 #[cfg(target_os = "linux")]
7725 {
7726 std::path::Path::new(&format!("/proc/{p}")).exists()
7727 }
7728 #[cfg(not(target_os = "linux"))]
7729 {
7730 std::process::Command::new("kill")
7731 .args(["-0", &p.to_string()])
7732 .output()
7733 .map(|o| o.status.success())
7734 .unwrap_or(false)
7735 }
7736 };
7737 if alive {
7738 return Ok(());
7739 }
7740 }
7741 }
7742
7743 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
7746 let log_path = session_home.join("state").join("wire").join("daemon.log");
7747 if let Some(parent) = log_path.parent() {
7748 std::fs::create_dir_all(parent).ok();
7749 }
7750 let log_file = std::fs::OpenOptions::new()
7751 .create(true)
7752 .append(true)
7753 .open(&log_path)
7754 .with_context(|| format!("opening daemon log {log_path:?}"))?;
7755 let log_err = log_file.try_clone()?;
7756 std::process::Command::new(&bin)
7757 .env("WIRE_HOME", session_home)
7758 .env_remove("RUST_LOG")
7759 .args(["daemon", "--interval", "5"])
7760 .stdout(log_file)
7761 .stderr(log_err)
7762 .stdin(std::process::Stdio::null())
7763 .spawn()
7764 .with_context(|| "spawning session-local `wire daemon`")?;
7765 Ok(())
7766}
7767
7768fn cmd_session_list(as_json: bool) -> Result<()> {
7769 let items = crate::session::list_sessions()?;
7770 if as_json {
7771 println!("{}", serde_json::to_string(&items)?);
7772 return Ok(());
7773 }
7774 if items.is_empty() {
7775 println!("no sessions on this machine. `wire session new` to create one.");
7776 return Ok(());
7777 }
7778 println!(
7779 "{:<22} {:<24} {:<24} {:<10} CWD",
7780 "CHARACTER", "NAME", "HANDLE", "DAEMON"
7781 );
7782 for s in items {
7783 let plain = s
7787 .character
7788 .as_ref()
7789 .map(|c| c.short())
7790 .unwrap_or_else(|| "?".to_string());
7791 let colored = s
7792 .character
7793 .as_ref()
7794 .map(|c| c.colored())
7795 .unwrap_or_else(|| "?".to_string());
7796 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
7801 println!(
7802 "{}{} {:<24} {:<24} {:<10} {}",
7803 colored,
7804 " ".repeat(pad),
7805 s.name,
7806 s.handle.as_deref().unwrap_or("?"),
7807 if s.daemon_running { "running" } else { "down" },
7808 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7809 );
7810 }
7811 Ok(())
7812}
7813
7814fn cmd_session_list_local(as_json: bool) -> Result<()> {
7826 let listing = crate::session::list_local_sessions()?;
7827 if as_json {
7828 println!("{}", serde_json::to_string(&listing)?);
7829 return Ok(());
7830 }
7831
7832 if listing.local.is_empty() && listing.federation_only.is_empty() {
7833 println!(
7834 "no sessions on this machine. `wire session new --with-local` to create one \
7835 with a local-relay endpoint (start the relay first: \
7836 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
7837 );
7838 return Ok(());
7839 }
7840
7841 if listing.local.is_empty() {
7842 println!(
7843 "no sister sessions reachable via a local relay. \
7844 Re-run `wire session new --with-local` to add a Local endpoint, or \
7845 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
7846 );
7847 } else {
7848 let mut keys: Vec<&String> = listing.local.keys().collect();
7850 keys.sort();
7851 for relay_url in keys {
7852 let group = &listing.local[relay_url];
7853 println!("LOCAL RELAY: {relay_url}");
7854 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
7855 for s in group {
7856 println!(
7857 " {:<24} {:<32} {:<10} {}",
7858 s.name,
7859 s.handle.as_deref().unwrap_or("?"),
7860 if s.daemon_running { "running" } else { "down" },
7861 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7862 );
7863 }
7864 println!();
7865 }
7866 }
7867
7868 if !listing.federation_only.is_empty() {
7869 println!("federation-only (no local endpoint):");
7870 for s in &listing.federation_only {
7871 println!(
7872 " {:<24} {:<32} {}",
7873 s.name,
7874 s.handle.as_deref().unwrap_or("?"),
7875 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7876 );
7877 }
7878 }
7879 Ok(())
7880}
7881
7882fn cmd_session_pair_all_local(
7901 settle_secs: u64,
7902 federation_relay: &str,
7903 as_json: bool,
7904) -> Result<()> {
7905 use std::collections::BTreeSet;
7906 use std::time::Duration;
7907
7908 let listing = crate::session::list_local_sessions()?;
7909 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
7913 Default::default();
7914 for group in listing.local.into_values() {
7915 for s in group {
7916 by_name.entry(s.name.clone()).or_insert(s);
7917 }
7918 }
7919 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
7920
7921 if sessions.len() < 2 {
7922 let msg = format!(
7923 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
7924 sessions.len()
7925 );
7926 if as_json {
7927 println!(
7928 "{}",
7929 serde_json::to_string(&json!({
7930 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
7931 "pairs_attempted": 0,
7932 "pairs_succeeded": 0,
7933 "pairs_skipped_already_paired": 0,
7934 "pairs_failed": 0,
7935 "note": msg,
7936 }))?
7937 );
7938 } else {
7939 println!("{msg}");
7940 if let Some(s) = sessions.first() {
7941 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
7942 }
7943 println!("Use `wire session new --with-local` to add more.");
7944 }
7945 return Ok(());
7946 }
7947
7948 let fed_host = host_of_url(federation_relay);
7949 if fed_host.is_empty() {
7950 bail!(
7951 "federation_relay `{federation_relay}` has no parseable host — \
7952 pass a full URL like `https://wireup.net`."
7953 );
7954 }
7955
7956 let mut attempted = 0u32;
7958 let mut succeeded = 0u32;
7959 let mut skipped_already = 0u32;
7960 let mut failed = 0u32;
7961 let mut per_pair: Vec<Value> = Vec::new();
7962
7963 for i in 0..sessions.len() {
7964 for j in (i + 1)..sessions.len() {
7965 let a = &sessions[i];
7966 let b = &sessions[j];
7967 attempted += 1;
7968
7969 let a_pinned_b = session_has_peer(&a.home_dir, &b.name);
7972 let b_pinned_a = session_has_peer(&b.home_dir, &a.name);
7973 if a_pinned_b && b_pinned_a {
7974 skipped_already += 1;
7975 per_pair.push(json!({
7976 "from": a.name,
7977 "to": b.name,
7978 "status": "already_paired",
7979 }));
7980 continue;
7981 }
7982
7983 let pair_result = drive_bilateral_pair(
7984 &a.home_dir,
7985 &a.name,
7986 &b.home_dir,
7987 &b.name,
7988 &fed_host,
7989 federation_relay,
7990 settle_secs,
7991 );
7992
7993 match pair_result {
7994 Ok(()) => {
7995 succeeded += 1;
7996 per_pair.push(json!({
7997 "from": a.name,
7998 "to": b.name,
7999 "status": "paired",
8000 }));
8001 }
8002 Err(e) => {
8003 failed += 1;
8004 let detail = format!("{e:#}");
8005 per_pair.push(json!({
8006 "from": a.name,
8007 "to": b.name,
8008 "status": "failed",
8009 "error": detail,
8010 }));
8011 }
8012 }
8013
8014 std::thread::sleep(Duration::from_millis(200));
8017 }
8018 }
8019
8020 let _ = BTreeSet::<String>::new(); let summary = json!({
8022 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
8023 "pairs_attempted": attempted,
8024 "pairs_succeeded": succeeded,
8025 "pairs_skipped_already_paired": skipped_already,
8026 "pairs_failed": failed,
8027 "results": per_pair,
8028 });
8029 if as_json {
8030 println!("{}", serde_json::to_string(&summary)?);
8031 } else {
8032 println!(
8033 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
8034 sessions.len(),
8035 attempted
8036 );
8037 println!(" paired: {succeeded}");
8038 println!(" skipped (already pinned): {skipped_already}");
8039 println!(" failed: {failed}");
8040 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
8041 let from = entry["from"].as_str().unwrap_or("?");
8042 let to = entry["to"].as_str().unwrap_or("?");
8043 let status = entry["status"].as_str().unwrap_or("?");
8044 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
8045 if err.is_empty() {
8046 println!(" {from:<24} ↔ {to:<24} {status}");
8047 } else {
8048 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
8049 }
8050 }
8051 }
8052 Ok(())
8053}
8054
8055fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
8058 val_session_relay_state(session_home)
8059 .and_then(|v| v.get("peers").cloned())
8060 .and_then(|p| p.get(peer_name).cloned())
8061 .is_some()
8062}
8063
8064fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
8069 let path = session_home.join("config").join("wire").join("relay.json");
8070 let bytes = std::fs::read(&path).ok()?;
8071 serde_json::from_slice(&bytes).ok()
8072}
8073
8074fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
8078 use std::collections::BTreeMap;
8079
8080 let listing = crate::session::list_local_sessions()?;
8083 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
8084 for group in listing.local.into_values() {
8085 for s in group {
8086 by_name.entry(s.name.clone()).or_insert(s);
8087 }
8088 }
8089 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8090 let federation_only = listing.federation_only;
8091
8092 if sessions.is_empty() {
8093 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
8094 if as_json {
8095 println!(
8096 "{}",
8097 serde_json::to_string(&json!({
8098 "sessions": [],
8099 "edges": [],
8100 "local_relay": null,
8101 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8102 "summary": {
8103 "session_count": 0,
8104 "edge_count": 0,
8105 "healthy": 0,
8106 "stale": 0,
8107 "asymmetric": 0,
8108 },
8109 "note": msg,
8110 }))?
8111 );
8112 } else {
8113 println!("{msg}");
8114 println!("Use `wire session new --with-local` to create one.");
8115 }
8116 return Ok(());
8117 }
8118
8119 struct SessionState {
8121 view: crate::session::LocalSessionView,
8122 relay_state: Value,
8123 local_relay_url: Option<String>,
8124 }
8125 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
8126 for s in sessions {
8127 let relay_state = val_session_relay_state(&s.home_dir)
8128 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8129 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
8130 sstates.push(SessionState {
8131 view: s,
8132 relay_state,
8133 local_relay_url,
8134 });
8135 }
8136
8137 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
8140 for s in &sstates {
8141 if let Some(url) = &s.local_relay_url
8142 && !local_relays.contains_key(url)
8143 {
8144 let healthy = probe_relay_healthz(url);
8145 local_relays.insert(url.clone(), healthy);
8146 }
8147 }
8148
8149 let now = std::time::SystemTime::now()
8150 .duration_since(std::time::UNIX_EPOCH)
8151 .map(|d| d.as_secs())
8152 .unwrap_or(0);
8153
8154 let mut edges: Vec<Value> = Vec::new();
8158 let mut healthy_count = 0u32;
8159 let mut stale_count = 0u32;
8160 let mut asymmetric_count = 0u32;
8161
8162 for i in 0..sstates.len() {
8163 for j in (i + 1)..sstates.len() {
8164 let a = &sstates[i];
8165 let b = &sstates[j];
8166 let a_to_b = probe_directed_edge(&a.relay_state, &b.view.name, now);
8167 let b_to_a = probe_directed_edge(&b.relay_state, &a.view.name, now);
8168
8169 let bilateral = a_to_b.pinned && b_to_a.pinned;
8170 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
8174 (Some("local"), _) | (_, Some("local")) => "local",
8175 (Some("federation"), _) | (_, Some("federation")) => "federation",
8176 _ => "unknown",
8177 };
8178
8179 let mut status = if bilateral { "healthy" } else { "asymmetric" };
8182 if bilateral {
8183 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
8184 Some(s) => s > stale_secs,
8185 None => d.probed,
8186 });
8187 if either_stale {
8188 status = "stale";
8189 }
8190 }
8191
8192 match status {
8193 "healthy" => healthy_count += 1,
8194 "stale" => stale_count += 1,
8195 "asymmetric" => asymmetric_count += 1,
8196 _ => {}
8197 }
8198
8199 edges.push(json!({
8200 "from": a.view.name,
8201 "to": b.view.name,
8202 "bilateral": bilateral,
8203 "scope": scope,
8204 "status": status,
8205 "directions": {
8206 a.view.name.clone(): direction_summary(&a_to_b),
8207 b.view.name.clone(): direction_summary(&b_to_a),
8208 },
8209 }));
8210 }
8211 }
8212
8213 let summary = json!({
8214 "sessions": sstates.iter().map(|s| json!({
8215 "name": s.view.name,
8216 "handle": s.view.handle,
8217 "cwd": s.view.cwd,
8218 "daemon_running": s.view.daemon_running,
8219 "local_relay": s.local_relay_url,
8220 })).collect::<Vec<_>>(),
8221 "edges": edges,
8222 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
8223 "url": url,
8224 "healthy": healthy,
8225 })).collect::<Vec<_>>(),
8226 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8227 "summary": {
8228 "session_count": sstates.len(),
8229 "edge_count": edges.len(),
8230 "healthy": healthy_count,
8231 "stale": stale_count,
8232 "asymmetric": asymmetric_count,
8233 "stale_threshold_secs": stale_secs,
8234 },
8235 });
8236
8237 if as_json {
8238 println!("{}", serde_json::to_string(&summary)?);
8239 return Ok(());
8240 }
8241
8242 println!(
8243 "wire mesh: {} session(s), {} edge(s)",
8244 sstates.len(),
8245 edges.len()
8246 );
8247 for (url, healthy) in &local_relays {
8248 let tick = if *healthy { "✓" } else { "✗" };
8249 println!(" local-relay {url} {tick}");
8250 }
8251 if !federation_only.is_empty() {
8252 print!(" federation-only sessions:");
8253 for f in &federation_only {
8254 print!(" {}", f.name);
8255 }
8256 println!();
8257 }
8258
8259 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
8261 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
8262 print!("\n{:>col_w$}", "", col_w = col_w);
8263 for n in &names {
8264 print!("{:>col_w$}", n, col_w = col_w);
8265 }
8266 println!();
8267 for (i, row) in names.iter().enumerate() {
8268 print!("{:>col_w$}", row, col_w = col_w);
8269 for (j, col) in names.iter().enumerate() {
8270 let cell = if i == j {
8271 "self".to_string()
8272 } else {
8273 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
8274 match d.scope.as_deref() {
8275 Some("local") => "local".to_string(),
8276 Some("federation") => "fed".to_string(),
8277 _ => "—".to_string(),
8278 }
8279 };
8280 print!("{:>col_w$}", cell, col_w = col_w);
8281 }
8282 println!();
8283 }
8284
8285 println!("\nHealth (stale threshold: {stale_secs}s):");
8286 for e in &edges {
8287 let from = e["from"].as_str().unwrap_or("?");
8288 let to = e["to"].as_str().unwrap_or("?");
8289 let scope = e["scope"].as_str().unwrap_or("?");
8290 let status = e["status"].as_str().unwrap_or("?");
8291 let mark = match status {
8292 "healthy" => "✓",
8293 "stale" => "⚠",
8294 "asymmetric" => "!",
8295 _ => "?",
8296 };
8297 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
8298 let mut details: Vec<String> = Vec::new();
8299 for (who, d) in &dirs {
8300 let silent = d.get("silent_secs").and_then(Value::as_u64);
8301 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
8302 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
8303 let label = match (pinned, probed, silent) {
8304 (false, _, _) => format!("{who} has not pinned"),
8305 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
8306 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
8307 (true, true, Some(s)) => format!("{who} silent {s}s"),
8308 (true, true, None) => format!("{who} never pulled"),
8309 };
8310 details.push(label);
8311 }
8312 println!(
8313 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
8314 details.join(" | ")
8315 );
8316 }
8317 Ok(())
8318}
8319
8320#[derive(Default)]
8321struct DirectedEdge {
8322 pinned: bool,
8323 scope: Option<String>,
8324 last_pull_at_unix: Option<u64>,
8325 silent_secs: Option<u64>,
8326 probed: bool,
8327 event_count: usize,
8328}
8329
8330fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
8336 let pinned = from_state
8337 .get("peers")
8338 .and_then(|p| p.get(to_name))
8339 .is_some();
8340 if !pinned {
8341 return DirectedEdge::default();
8342 }
8343 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
8344 let ep = match endpoints.into_iter().next() {
8345 Some(e) => e,
8346 None => {
8347 return DirectedEdge {
8348 pinned: true,
8349 ..Default::default()
8350 };
8351 }
8352 };
8353 let scope = Some(
8354 match ep.scope {
8355 crate::endpoints::EndpointScope::Local => "local",
8356 crate::endpoints::EndpointScope::Lan => "lan",
8357 crate::endpoints::EndpointScope::Uds => "uds",
8358 crate::endpoints::EndpointScope::Federation => "federation",
8359 }
8360 .to_string(),
8361 );
8362 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
8363 let (count, last) = client
8364 .slot_state(&ep.slot_id, &ep.slot_token)
8365 .unwrap_or((0, None));
8366 let silent = last.map(|t| now.saturating_sub(t));
8367 DirectedEdge {
8368 pinned: true,
8369 scope,
8370 last_pull_at_unix: last,
8371 silent_secs: silent,
8372 probed: true,
8373 event_count: count,
8374 }
8375}
8376
8377fn direction_summary(d: &DirectedEdge) -> Value {
8378 json!({
8379 "pinned": d.pinned,
8380 "scope": d.scope,
8381 "probed": d.probed,
8382 "last_pull_at_unix": d.last_pull_at_unix,
8383 "silent_secs": d.silent_secs,
8384 "event_count": d.event_count,
8385 })
8386}
8387
8388fn probe_relay_healthz(url: &str) -> bool {
8390 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
8391 let client = match reqwest::blocking::Client::builder()
8392 .timeout(std::time::Duration::from_millis(500))
8393 .build()
8394 {
8395 Ok(c) => c,
8396 Err(_) => return false,
8397 };
8398 match client.get(&probe_url).send() {
8399 Ok(r) => r.status().is_success(),
8400 Err(_) => false,
8401 }
8402}
8403
8404fn drive_bilateral_pair(
8419 a_home: &std::path::Path,
8420 a_name: &str,
8421 b_home: &std::path::Path,
8422 b_name: &str,
8423 _fed_host: &str,
8424 _federation_relay: &str,
8425 settle_secs: u64,
8426) -> Result<()> {
8427 use std::time::Duration;
8428 let bin = std::env::current_exe().context("locating self exe")?;
8429
8430 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
8431 let out = std::process::Command::new(&bin)
8432 .env("WIRE_HOME", home)
8433 .env_remove("RUST_LOG")
8434 .args(args)
8435 .output()
8436 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8437 if !out.status.success() {
8438 bail!(
8439 "`wire {}` failed: stderr={}",
8440 args.join(" "),
8441 String::from_utf8_lossy(&out.stderr).trim()
8442 );
8443 }
8444 Ok(())
8445 };
8446
8447 run(a_home, &["add", b_name, "--local-sister", "--json"])
8453 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
8454
8455 std::thread::sleep(Duration::from_secs(settle_secs));
8457
8458 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
8460 run(b_home, &["pair-accept", a_name, "--json"])
8461 .with_context(|| format!("step 5/8: {b_name} `wire pair-accept {a_name}`"))?;
8462 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
8463
8464 std::thread::sleep(Duration::from_secs(settle_secs));
8466
8467 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
8469
8470 Ok(())
8471}
8472
8473fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
8474 let name = resolve_session_name(name_arg)?;
8475 let session_home = crate::session::session_dir(&name)?;
8476 if !session_home.exists() {
8477 bail!(
8478 "no session named {name:?} on this machine. `wire session list` to enumerate, \
8479 `wire session new {name}` to create."
8480 );
8481 }
8482 if as_json {
8483 println!(
8484 "{}",
8485 serde_json::to_string(&json!({
8486 "name": name,
8487 "home_dir": session_home.to_string_lossy(),
8488 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8489 }))?
8490 );
8491 } else {
8492 println!("export WIRE_HOME={}", session_home.to_string_lossy());
8493 }
8494 Ok(())
8495}
8496
8497fn cmd_session_current(as_json: bool) -> Result<()> {
8498 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8499 let registry = crate::session::read_registry().unwrap_or_default();
8500 let cwd_key = cwd.to_string_lossy().into_owned();
8501 let name = registry.by_cwd.get(&cwd_key).cloned();
8502 if as_json {
8503 println!(
8504 "{}",
8505 serde_json::to_string(&json!({
8506 "cwd": cwd_key,
8507 "session": name,
8508 }))?
8509 );
8510 } else if let Some(n) = name {
8511 println!("{n}");
8512 } else {
8513 println!("(no session registered for this cwd)");
8514 }
8515 Ok(())
8516}
8517
8518fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
8519 let name = crate::session::sanitize_name(name_arg);
8520 let session_home = crate::session::session_dir(&name)?;
8521 if !session_home.exists() {
8522 if as_json {
8523 println!(
8524 "{}",
8525 serde_json::to_string(&json!({
8526 "name": name,
8527 "destroyed": false,
8528 "reason": "no such session",
8529 }))?
8530 );
8531 } else {
8532 println!("no session named {name:?} — nothing to destroy.");
8533 }
8534 return Ok(());
8535 }
8536 if !force {
8537 bail!(
8538 "destroying session {name:?} would delete its keypair + state irrecoverably. \
8539 Pass --force to confirm."
8540 );
8541 }
8542
8543 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8545 if let Ok(bytes) = std::fs::read(&pidfile) {
8546 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8547 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8548 } else {
8549 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8550 };
8551 if let Some(p) = pid {
8552 let _ = std::process::Command::new("kill")
8553 .args(["-TERM", &p.to_string()])
8554 .output();
8555 }
8556 }
8557
8558 std::fs::remove_dir_all(&session_home)
8559 .with_context(|| format!("removing session dir {session_home:?}"))?;
8560
8561 let mut registry = crate::session::read_registry().unwrap_or_default();
8563 registry.by_cwd.retain(|_, v| v != &name);
8564 crate::session::write_registry(®istry)?;
8565
8566 if as_json {
8567 println!(
8568 "{}",
8569 serde_json::to_string(&json!({
8570 "name": name,
8571 "destroyed": true,
8572 }))?
8573 );
8574 } else {
8575 println!("destroyed session {name:?}.");
8576 }
8577 Ok(())
8578}
8579
8580fn cmd_diag(action: DiagAction) -> Result<()> {
8583 let state = config::state_dir()?;
8584 let knob = state.join("diag.enabled");
8585 let log_path = state.join("diag.jsonl");
8586 match action {
8587 DiagAction::Tail { limit, json } => {
8588 let entries = crate::diag::tail(limit);
8589 if json {
8590 for e in entries {
8591 println!("{}", serde_json::to_string(&e)?);
8592 }
8593 } else if entries.is_empty() {
8594 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
8595 } else {
8596 for e in entries {
8597 let ts = e["ts"].as_u64().unwrap_or(0);
8598 let ty = e["type"].as_str().unwrap_or("?");
8599 let pid = e["pid"].as_u64().unwrap_or(0);
8600 let payload = e["payload"].to_string();
8601 println!("[{ts}] pid={pid} {ty} {payload}");
8602 }
8603 }
8604 }
8605 DiagAction::Enable => {
8606 config::ensure_dirs()?;
8607 std::fs::write(&knob, "1")?;
8608 println!("wire diag: enabled at {knob:?}");
8609 }
8610 DiagAction::Disable => {
8611 if knob.exists() {
8612 std::fs::remove_file(&knob)?;
8613 }
8614 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
8615 }
8616 DiagAction::Status { json } => {
8617 let enabled = crate::diag::is_enabled();
8618 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
8619 if json {
8620 println!(
8621 "{}",
8622 serde_json::to_string(&serde_json::json!({
8623 "enabled": enabled,
8624 "log_path": log_path,
8625 "log_size_bytes": size,
8626 }))?
8627 );
8628 } else {
8629 println!("wire diag status");
8630 println!(" enabled: {enabled}");
8631 println!(" log: {log_path:?}");
8632 println!(" log size: {size} bytes");
8633 }
8634 }
8635 }
8636 Ok(())
8637}
8638
8639fn cmd_service(action: ServiceAction) -> Result<()> {
8642 let kind = |local_relay: bool| {
8643 if local_relay {
8644 crate::service::ServiceKind::LocalRelay
8645 } else {
8646 crate::service::ServiceKind::Daemon
8647 }
8648 };
8649 let (report, as_json) = match action {
8650 ServiceAction::Install { local_relay, json } => {
8651 (crate::service::install_kind(kind(local_relay))?, json)
8652 }
8653 ServiceAction::Uninstall { local_relay, json } => {
8654 (crate::service::uninstall_kind(kind(local_relay))?, json)
8655 }
8656 ServiceAction::Status { local_relay, json } => {
8657 (crate::service::status_kind(kind(local_relay))?, json)
8658 }
8659 };
8660 if as_json {
8661 println!("{}", serde_json::to_string(&report)?);
8662 } else {
8663 println!("wire service {}", report.action);
8664 println!(" platform: {}", report.platform);
8665 println!(" unit: {}", report.unit_path);
8666 println!(" status: {}", report.status);
8667 println!(" detail: {}", report.detail);
8668 }
8669 Ok(())
8670}
8671
8672fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
8687 let pgrep_out = std::process::Command::new("pgrep")
8689 .args(["-f", "wire daemon"])
8690 .output();
8691 let running_pids: Vec<u32> = match pgrep_out {
8692 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
8693 .split_whitespace()
8694 .filter_map(|s| s.parse::<u32>().ok())
8695 .collect(),
8696 _ => Vec::new(),
8697 };
8698
8699 let record = crate::ensure_up::read_pid_record("daemon");
8701 let recorded_version: Option<String> = match &record {
8702 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
8703 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
8704 _ => None,
8705 };
8706 let cli_version = env!("CARGO_PKG_VERSION").to_string();
8707
8708 let sessions_to_respawn_after_kill: Vec<std::path::PathBuf> = crate::session::list_sessions()
8715 .unwrap_or_default()
8716 .into_iter()
8717 .filter(|s| s.daemon_running)
8718 .map(|s| s.home_dir)
8719 .collect();
8720
8721 if check_only {
8722 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
8724 .unwrap_or_default()
8725 .iter()
8726 .filter(|s| s.daemon_running)
8727 .map(|s| s.name.clone())
8728 .collect();
8729 let mut path_dupes: Vec<String> = Vec::new();
8730 if let Ok(path) = std::env::var("PATH") {
8731 let mut seen: std::collections::HashSet<std::path::PathBuf> =
8732 std::collections::HashSet::new();
8733 for dir in path.split(':') {
8734 let candidate = std::path::PathBuf::from(dir).join("wire");
8735 if candidate.exists() {
8736 let canon = candidate.canonicalize().unwrap_or(candidate);
8737 if seen.insert(canon.clone()) {
8738 path_dupes.push(canon.to_string_lossy().into_owned());
8739 }
8740 }
8741 }
8742 }
8743 let report = json!({
8744 "running_pids": running_pids,
8745 "pidfile_version": recorded_version,
8746 "cli_version": cli_version,
8747 "would_kill": running_pids,
8748 "session_daemons_running": sessions_with_daemons,
8749 "path_binaries": path_dupes,
8750 "path_duplicate_warning": path_dupes.len() > 1,
8751 });
8752 if as_json {
8753 println!("{}", serde_json::to_string(&report)?);
8754 } else {
8755 println!("wire upgrade --check");
8756 println!(" cli version: {cli_version}");
8757 println!(
8758 " pidfile version: {}",
8759 recorded_version.as_deref().unwrap_or("(missing)")
8760 );
8761 if running_pids.is_empty() {
8762 println!(" running daemons: none");
8763 } else {
8764 let pids: Vec<String> = running_pids.iter().map(|p| p.to_string()).collect();
8765 println!(" running daemons: pids {}", pids.join(", "));
8766 println!(" would kill all + spawn fresh");
8767 }
8768 if !sessions_with_daemons.is_empty() {
8769 println!(
8770 " session daemons: {} (would respawn under new binary)",
8771 sessions_with_daemons.join(", ")
8772 );
8773 }
8774 if path_dupes.len() > 1 {
8775 println!(
8776 " PATH warning: {} distinct `wire` binaries on PATH:",
8777 path_dupes.len()
8778 );
8779 for b in &path_dupes {
8780 println!(" {b}");
8781 }
8782 println!(" operators should remove the stale ones");
8783 }
8784 }
8785 return Ok(());
8786 }
8787
8788 let mut killed: Vec<u32> = Vec::new();
8791 for pid in &running_pids {
8792 let _ = std::process::Command::new("kill")
8794 .args(["-15", &pid.to_string()])
8795 .status();
8796 killed.push(*pid);
8797 }
8798 if !killed.is_empty() {
8800 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
8801 loop {
8802 let still_alive: Vec<u32> = killed
8803 .iter()
8804 .copied()
8805 .filter(|p| process_alive_pid(*p))
8806 .collect();
8807 if still_alive.is_empty() {
8808 break;
8809 }
8810 if std::time::Instant::now() >= deadline {
8811 for pid in still_alive {
8813 let _ = std::process::Command::new("kill")
8814 .args(["-9", &pid.to_string()])
8815 .status();
8816 }
8817 break;
8818 }
8819 std::thread::sleep(std::time::Duration::from_millis(50));
8820 }
8821 }
8822
8823 let pidfile = config::state_dir()?.join("daemon.pid");
8826 if pidfile.exists() {
8827 let _ = std::fs::remove_file(&pidfile);
8828 }
8829
8830 if let Ok(sessions) = crate::session::list_sessions() {
8837 for s in &sessions {
8838 let session_pidfile = s.home_dir.join("state").join("wire").join("daemon.pid");
8839 if session_pidfile.exists() {
8840 let _ = std::fs::remove_file(&session_pidfile);
8841 }
8842 }
8843 }
8844 let session_daemons_to_respawn = sessions_to_respawn_after_kill;
8845
8846 let mut path_dupes: Vec<String> = Vec::new();
8851 if let Ok(path) = std::env::var("PATH") {
8852 let mut seen: std::collections::HashSet<std::path::PathBuf> =
8853 std::collections::HashSet::new();
8854 for dir in path.split(':') {
8855 let candidate = std::path::PathBuf::from(dir).join("wire");
8856 if candidate.exists() {
8857 let canon = candidate.canonicalize().unwrap_or(candidate);
8858 if seen.insert(canon.clone()) {
8859 path_dupes.push(canon.to_string_lossy().into_owned());
8860 }
8861 }
8862 }
8863 }
8864 let path_warning = if path_dupes.len() > 1 {
8865 Some(format!(
8866 "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n {}",
8867 path_dupes.len(),
8868 path_dupes.join("\n ")
8869 ))
8870 } else {
8871 None
8872 };
8873
8874 let spawned = crate::ensure_up::ensure_daemon_running()?;
8877
8878 let mut session_respawns: Vec<Value> = Vec::new();
8884 for home in &session_daemons_to_respawn {
8885 match ensure_session_daemon(home) {
8886 Ok(()) => session_respawns.push(json!({
8887 "session_home": home.to_string_lossy(),
8888 "status": "respawned",
8889 })),
8890 Err(e) => session_respawns.push(json!({
8891 "session_home": home.to_string_lossy(),
8892 "status": "failed",
8893 "error": format!("{e:#}"),
8894 })),
8895 }
8896 }
8897
8898 let new_record = crate::ensure_up::read_pid_record("daemon");
8899 let new_pid = new_record.pid();
8900 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
8901 Some(d.version.clone())
8902 } else {
8903 None
8904 };
8905
8906 if as_json {
8907 println!(
8908 "{}",
8909 serde_json::to_string(&json!({
8910 "killed": killed,
8911 "spawned_fresh_daemon": spawned,
8912 "new_pid": new_pid,
8913 "new_version": new_version,
8914 "cli_version": cli_version,
8915 "session_respawns": session_respawns,
8916 "path_binaries": path_dupes,
8917 "path_warning": path_warning,
8918 }))?
8919 );
8920 } else {
8921 if killed.is_empty() {
8922 println!("wire upgrade: no stale daemons running");
8923 } else {
8924 println!(
8925 "wire upgrade: killed {} daemon(s) (pids {})",
8926 killed.len(),
8927 killed
8928 .iter()
8929 .map(|p| p.to_string())
8930 .collect::<Vec<_>>()
8931 .join(", ")
8932 );
8933 }
8934 if spawned {
8935 println!(
8936 "wire upgrade: spawned fresh daemon (pid {} v{})",
8937 new_pid
8938 .map(|p| p.to_string())
8939 .unwrap_or_else(|| "?".to_string()),
8940 new_version.as_deref().unwrap_or(&cli_version),
8941 );
8942 } else {
8943 println!("wire upgrade: daemon was already running on current binary");
8944 }
8945 if !session_respawns.is_empty() {
8946 println!(
8947 "wire upgrade: refreshed {} session daemon(s):",
8948 session_respawns.len()
8949 );
8950 for r in &session_respawns {
8951 let h = r["session_home"].as_str().unwrap_or("?");
8952 let s = r["status"].as_str().unwrap_or("?");
8953 let label = std::path::Path::new(h)
8954 .file_name()
8955 .map(|f| f.to_string_lossy().into_owned())
8956 .unwrap_or_else(|| h.to_string());
8957 println!(" {label:<24} {s}");
8958 }
8959 }
8960 if let Some(msg) = &path_warning {
8961 eprintln!("wire upgrade: {msg}");
8962 }
8963 }
8964 Ok(())
8965}
8966
8967fn process_alive_pid(pid: u32) -> bool {
8968 #[cfg(target_os = "linux")]
8969 {
8970 std::path::Path::new(&format!("/proc/{pid}")).exists()
8971 }
8972 #[cfg(not(target_os = "linux"))]
8973 {
8974 std::process::Command::new("kill")
8975 .args(["-0", &pid.to_string()])
8976 .stdin(std::process::Stdio::null())
8977 .stdout(std::process::Stdio::null())
8978 .stderr(std::process::Stdio::null())
8979 .status()
8980 .map(|s| s.success())
8981 .unwrap_or(false)
8982 }
8983}
8984
8985#[derive(Clone, Debug, serde::Serialize)]
8989pub struct DoctorCheck {
8990 pub id: String,
8993 pub status: String,
8995 pub detail: String,
8997 #[serde(skip_serializing_if = "Option::is_none")]
8999 pub fix: Option<String>,
9000}
9001
9002impl DoctorCheck {
9003 fn pass(id: &str, detail: impl Into<String>) -> Self {
9004 Self {
9005 id: id.into(),
9006 status: "PASS".into(),
9007 detail: detail.into(),
9008 fix: None,
9009 }
9010 }
9011 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
9012 Self {
9013 id: id.into(),
9014 status: "WARN".into(),
9015 detail: detail.into(),
9016 fix: Some(fix.into()),
9017 }
9018 }
9019 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
9020 Self {
9021 id: id.into(),
9022 status: "FAIL".into(),
9023 detail: detail.into(),
9024 fix: Some(fix.into()),
9025 }
9026 }
9027}
9028
9029fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
9034 let checks: Vec<DoctorCheck> = vec![
9035 check_daemon_health(),
9036 check_daemon_pid_consistency(),
9037 check_relay_reachable(),
9038 check_pair_rejections(recent_rejections),
9039 check_cursor_progress(),
9040 ];
9041
9042 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
9043 let warns = checks.iter().filter(|c| c.status == "WARN").count();
9044
9045 if as_json {
9046 println!(
9047 "{}",
9048 serde_json::to_string(&json!({
9049 "checks": checks,
9050 "fail_count": fails,
9051 "warn_count": warns,
9052 "ok": fails == 0,
9053 }))?
9054 );
9055 } else {
9056 println!("wire doctor — {} checks", checks.len());
9057 for c in &checks {
9058 let bullet = match c.status.as_str() {
9059 "PASS" => "✓",
9060 "WARN" => "!",
9061 "FAIL" => "✗",
9062 _ => "?",
9063 };
9064 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
9065 if let Some(fix) = &c.fix {
9066 println!(" fix: {fix}");
9067 }
9068 }
9069 println!();
9070 if fails == 0 && warns == 0 {
9071 println!("ALL GREEN");
9072 } else {
9073 println!("{fails} FAIL, {warns} WARN");
9074 }
9075 }
9076
9077 if fails > 0 {
9078 std::process::exit(1);
9079 }
9080 Ok(())
9081}
9082
9083fn check_daemon_health() -> DoctorCheck {
9090 let snap = crate::ensure_up::daemon_liveness();
9096 let pgrep_pids = &snap.pgrep_pids;
9097 let pidfile_pid = snap.pidfile_pid;
9098 let pidfile_alive = snap.pidfile_alive;
9099 let orphan_pids = &snap.orphan_pids;
9100
9101 let fmt_pids = |xs: &[u32]| -> String {
9102 xs.iter()
9103 .map(|p| p.to_string())
9104 .collect::<Vec<_>>()
9105 .join(", ")
9106 };
9107
9108 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
9109 (0, _, _) => DoctorCheck::fail(
9110 "daemon",
9111 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
9112 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
9113 ),
9114 (1, true, true) => DoctorCheck::pass(
9116 "daemon",
9117 format!(
9118 "one daemon running (pid {}, matches pidfile)",
9119 pgrep_pids[0]
9120 ),
9121 ),
9122 (n, true, false) => DoctorCheck::fail(
9124 "daemon",
9125 format!(
9126 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
9127 The orphans race the relay cursor — they advance past events your current binary can't process. \
9128 (Issue #2 exact class.)",
9129 fmt_pids(pgrep_pids),
9130 pidfile_pid.unwrap(),
9131 fmt_pids(orphan_pids),
9132 ),
9133 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
9134 ),
9135 (n, false, _) => DoctorCheck::fail(
9137 "daemon",
9138 format!(
9139 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
9140 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
9141 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
9142 fmt_pids(pgrep_pids),
9143 match pidfile_pid {
9144 Some(p) => format!("claims pid {p} which is dead"),
9145 None => "is missing".to_string(),
9146 },
9147 ),
9148 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
9149 ),
9150 (n, true, true) => DoctorCheck::warn(
9152 "daemon",
9153 format!(
9154 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
9155 fmt_pids(pgrep_pids)
9156 ),
9157 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
9158 ),
9159 }
9160}
9161
9162fn check_daemon_pid_consistency() -> DoctorCheck {
9174 let snap = crate::ensure_up::daemon_liveness();
9175 match &snap.record {
9176 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
9177 "daemon_pid_consistency",
9178 "no daemon.pid yet — fresh box or daemon never started",
9179 ),
9180 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
9181 "daemon_pid_consistency",
9182 format!("daemon.pid is corrupt: {reason}"),
9183 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
9184 ),
9185 crate::ensure_up::PidRecord::LegacyInt(pid) => {
9186 let pid = *pid;
9189 if !crate::ensure_up::pid_is_alive(pid) {
9190 return DoctorCheck::warn(
9191 "daemon_pid_consistency",
9192 format!(
9193 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
9194 Stale pidfile from a crashed pre-0.5.11 daemon. \
9195 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
9196 ),
9197 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
9198 );
9199 }
9200 DoctorCheck::warn(
9201 "daemon_pid_consistency",
9202 format!(
9203 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
9204 Daemon was started by a pre-0.5.11 binary."
9205 ),
9206 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
9207 )
9208 }
9209 crate::ensure_up::PidRecord::Json(d) => {
9210 if !snap.pidfile_alive {
9214 return DoctorCheck::warn(
9215 "daemon_pid_consistency",
9216 format!(
9217 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
9218 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
9219 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
9220 pid = d.pid,
9221 version = d.version,
9222 ),
9223 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
9224 (kills any orphan daemon advancing the cursor without coordination)",
9225 );
9226 }
9227 let mut issues: Vec<String> = Vec::new();
9228 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
9229 issues.push(format!(
9230 "schema={} (expected {})",
9231 d.schema,
9232 crate::ensure_up::DAEMON_PID_SCHEMA
9233 ));
9234 }
9235 let cli_version = env!("CARGO_PKG_VERSION");
9236 if d.version != cli_version {
9237 issues.push(format!("version daemon={} cli={cli_version}", d.version));
9238 }
9239 if !std::path::Path::new(&d.bin_path).exists() {
9240 issues.push(format!("bin_path {} missing on disk", d.bin_path));
9241 }
9242 if let Ok(card) = config::read_agent_card()
9244 && let Some(current_did) = card.get("did").and_then(Value::as_str)
9245 && let Some(recorded_did) = &d.did
9246 && recorded_did != current_did
9247 {
9248 issues.push(format!(
9249 "did daemon={recorded_did} config={current_did} — identity drift"
9250 ));
9251 }
9252 if let Ok(state) = config::read_relay_state()
9253 && let Some(current_relay) = state
9254 .get("self")
9255 .and_then(|s| s.get("relay_url"))
9256 .and_then(Value::as_str)
9257 && let Some(recorded_relay) = &d.relay_url
9258 && recorded_relay != current_relay
9259 {
9260 issues.push(format!(
9261 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
9262 ));
9263 }
9264 if issues.is_empty() {
9265 DoctorCheck::pass(
9266 "daemon_pid_consistency",
9267 format!(
9268 "daemon v{} bound to {} as {}",
9269 d.version,
9270 d.relay_url.as_deref().unwrap_or("?"),
9271 d.did.as_deref().unwrap_or("?")
9272 ),
9273 )
9274 } else {
9275 DoctorCheck::warn(
9276 "daemon_pid_consistency",
9277 format!("daemon pidfile drift: {}", issues.join("; ")),
9278 "`wire upgrade` to atomically restart daemon with current config".to_string(),
9279 )
9280 }
9281 }
9282 }
9283}
9284
9285fn check_relay_reachable() -> DoctorCheck {
9287 let state = match config::read_relay_state() {
9288 Ok(s) => s,
9289 Err(e) => {
9290 return DoctorCheck::fail(
9291 "relay",
9292 format!("could not read relay state: {e}"),
9293 "run `wire up <handle>@<relay>` to bootstrap",
9294 );
9295 }
9296 };
9297 let url = state
9298 .get("self")
9299 .and_then(|s| s.get("relay_url"))
9300 .and_then(Value::as_str)
9301 .unwrap_or("");
9302 if url.is_empty() {
9303 return DoctorCheck::warn(
9304 "relay",
9305 "no relay bound — wire send/pull will not work",
9306 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
9307 );
9308 }
9309 let client = crate::relay_client::RelayClient::new(url);
9310 match client.check_healthz() {
9311 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
9312 Err(e) => DoctorCheck::fail(
9313 "relay",
9314 format!("{url} unreachable: {e}"),
9315 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
9316 ),
9317 }
9318}
9319
9320fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
9324 let path = match config::state_dir() {
9325 Ok(d) => d.join("pair-rejected.jsonl"),
9326 Err(e) => {
9327 return DoctorCheck::warn(
9328 "pair_rejections",
9329 format!("could not resolve state dir: {e}"),
9330 "set WIRE_HOME or fix XDG_STATE_HOME",
9331 );
9332 }
9333 };
9334 if !path.exists() {
9335 return DoctorCheck::pass(
9336 "pair_rejections",
9337 "no pair-rejected.jsonl — no recorded pair failures",
9338 );
9339 }
9340 let body = match std::fs::read_to_string(&path) {
9341 Ok(b) => b,
9342 Err(e) => {
9343 return DoctorCheck::warn(
9344 "pair_rejections",
9345 format!("could not read {path:?}: {e}"),
9346 "check file permissions",
9347 );
9348 }
9349 };
9350 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
9351 if lines.is_empty() {
9352 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
9353 }
9354 let total = lines.len();
9355 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
9356 let mut summary: Vec<String> = Vec::new();
9357 for line in &recent {
9358 if let Ok(rec) = serde_json::from_str::<Value>(line) {
9359 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
9360 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
9361 summary.push(format!("{peer}/{code}"));
9362 }
9363 }
9364 DoctorCheck::warn(
9365 "pair_rejections",
9366 format!(
9367 "{total} pair failures recorded. recent: [{}]",
9368 summary.join(", ")
9369 ),
9370 format!(
9371 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
9372 ),
9373 )
9374}
9375
9376fn check_cursor_progress() -> DoctorCheck {
9381 let state = match config::read_relay_state() {
9382 Ok(s) => s,
9383 Err(e) => {
9384 return DoctorCheck::warn(
9385 "cursor",
9386 format!("could not read relay state: {e}"),
9387 "check ~/Library/Application Support/wire/relay.json",
9388 );
9389 }
9390 };
9391 let cursor = state
9392 .get("self")
9393 .and_then(|s| s.get("last_pulled_event_id"))
9394 .and_then(Value::as_str)
9395 .map(|s| s.chars().take(16).collect::<String>())
9396 .unwrap_or_else(|| "<none>".to_string());
9397 DoctorCheck::pass(
9398 "cursor",
9399 format!(
9400 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
9401 ),
9402 )
9403}
9404
9405#[cfg(test)]
9406mod doctor_tests {
9407 use super::*;
9408
9409 #[test]
9410 fn doctor_check_constructors_set_status_correctly() {
9411 let p = DoctorCheck::pass("x", "ok");
9416 assert_eq!(p.status, "PASS");
9417 assert_eq!(p.fix, None);
9418
9419 let w = DoctorCheck::warn("x", "watch out", "do this");
9420 assert_eq!(w.status, "WARN");
9421 assert_eq!(w.fix, Some("do this".to_string()));
9422
9423 let f = DoctorCheck::fail("x", "broken", "fix it");
9424 assert_eq!(f.status, "FAIL");
9425 assert_eq!(f.fix, Some("fix it".to_string()));
9426 }
9427
9428 #[test]
9429 fn check_pair_rejections_no_file_is_pass() {
9430 config::test_support::with_temp_home(|| {
9433 config::ensure_dirs().unwrap();
9434 let c = check_pair_rejections(5);
9435 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
9436 });
9437 }
9438
9439 #[test]
9440 fn check_pair_rejections_with_entries_warns() {
9441 config::test_support::with_temp_home(|| {
9445 config::ensure_dirs().unwrap();
9446 crate::pair_invite::record_pair_rejection(
9447 "willard",
9448 "pair_drop_ack_send_failed",
9449 "POST 502",
9450 );
9451 let c = check_pair_rejections(5);
9452 assert_eq!(c.status, "WARN");
9453 assert!(c.detail.contains("1 pair failures"));
9454 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
9455 });
9456 }
9457}
9458
9459fn cmd_up(handle_arg: &str, name: Option<&str>, as_json: bool) -> Result<()> {
9471 let (nick, relay_url) = match handle_arg.split_once('@') {
9472 Some((n, host)) => {
9473 let url = if host.starts_with("http://") || host.starts_with("https://") {
9474 host.to_string()
9475 } else {
9476 format!("https://{host}")
9477 };
9478 (n.to_string(), url)
9479 }
9480 None => (
9481 handle_arg.to_string(),
9482 crate::pair_invite::DEFAULT_RELAY.to_string(),
9483 ),
9484 };
9485
9486 let mut report: Vec<(String, String)> = Vec::new();
9487 let mut step = |stage: &str, detail: String| {
9488 report.push((stage.to_string(), detail.clone()));
9489 if !as_json {
9490 eprintln!("wire up: {stage} — {detail}");
9491 }
9492 };
9493
9494 if config::is_initialized()? {
9496 let card = config::read_agent_card()?;
9497 let existing_did = card.get("did").and_then(Value::as_str).unwrap_or("");
9498 let existing_handle = crate::agent_card::display_handle_from_did(existing_did).to_string();
9499 if existing_handle != nick {
9500 bail!(
9501 "wire up: already initialized as {existing_handle:?} but you asked for {nick:?}. \
9502 Either run with the existing handle (`wire up {existing_handle}@<relay>`) or \
9503 delete `{:?}` to start fresh.",
9504 config::config_dir()?
9505 );
9506 }
9507 step("init", format!("already initialized as {existing_handle}"));
9508 } else {
9509 cmd_init(&nick, name, Some(&relay_url), false)?;
9510 step(
9511 "init",
9512 format!("created identity {nick} bound to {relay_url}"),
9513 );
9514 }
9515
9516 let relay_state = config::read_relay_state()?;
9520 let bound_relay = relay_state
9521 .get("self")
9522 .and_then(|s| s.get("relay_url"))
9523 .and_then(Value::as_str)
9524 .unwrap_or("")
9525 .to_string();
9526 if bound_relay.is_empty() {
9527 cmd_bind_relay(
9531 &relay_url, false, false,
9532 )?;
9533 step("bind-relay", format!("bound to {relay_url}"));
9534 } else if bound_relay != relay_url {
9535 step(
9536 "bind-relay",
9537 format!(
9538 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
9539 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
9540 ),
9541 );
9542 } else {
9543 step("bind-relay", format!("already bound to {bound_relay}"));
9544 }
9545
9546 match cmd_claim(
9549 &nick,
9550 Some(&relay_url),
9551 None,
9552 false,
9553 false,
9554 ) {
9555 Ok(()) => step(
9556 "claim",
9557 format!("{nick}@{} claimed", strip_proto(&relay_url)),
9558 ),
9559 Err(e) => step(
9560 "claim",
9561 format!("WARNING: claim failed: {e}. You can retry `wire claim {nick}`."),
9562 ),
9563 }
9564
9565 match crate::ensure_up::ensure_daemon_running() {
9567 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
9568 Ok(false) => step("daemon", "already running".to_string()),
9569 Err(e) => step(
9570 "daemon",
9571 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
9572 ),
9573 }
9574
9575 let summary =
9577 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
9578 `wire monitor` to watch incoming events."
9579 .to_string();
9580 step("ready", summary.clone());
9581
9582 if as_json {
9583 let steps_json: Vec<_> = report
9584 .iter()
9585 .map(|(k, v)| json!({"stage": k, "detail": v}))
9586 .collect();
9587 println!(
9588 "{}",
9589 serde_json::to_string(&json!({
9590 "nick": nick,
9591 "relay": relay_url,
9592 "steps": steps_json,
9593 }))?
9594 );
9595 }
9596 Ok(())
9597}
9598
9599fn strip_proto(url: &str) -> String {
9601 url.trim_start_matches("https://")
9602 .trim_start_matches("http://")
9603 .to_string()
9604}
9605
9606fn cmd_pair_megacommand(
9620 handle_arg: &str,
9621 relay_override: Option<&str>,
9622 timeout_secs: u64,
9623 _as_json: bool,
9624) -> Result<()> {
9625 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
9626 let peer_handle = parsed.nick.clone();
9627
9628 eprintln!("wire pair: resolving {handle_arg}...");
9629 cmd_add(
9630 handle_arg,
9631 relay_override,
9632 false,
9633 false,
9634 )?;
9635
9636 eprintln!(
9637 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
9638 to ack (their daemon must be running + pulling)..."
9639 );
9640
9641 let _ = run_sync_pull();
9645
9646 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
9647 let poll_interval = std::time::Duration::from_millis(500);
9648
9649 loop {
9650 let _ = run_sync_pull();
9652 let relay_state = config::read_relay_state()?;
9653 let peer_entry = relay_state
9654 .get("peers")
9655 .and_then(|p| p.get(&peer_handle))
9656 .cloned();
9657 let token = peer_entry
9658 .as_ref()
9659 .and_then(|e| e.get("slot_token"))
9660 .and_then(Value::as_str)
9661 .unwrap_or("");
9662
9663 if !token.is_empty() {
9664 let trust = config::read_trust()?;
9666 let pinned_in_trust = trust
9667 .get("agents")
9668 .and_then(|a| a.get(&peer_handle))
9669 .is_some();
9670 println!(
9671 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
9672 if pinned_in_trust {
9673 "VERIFIED"
9674 } else {
9675 "MISSING (bug)"
9676 }
9677 );
9678 return Ok(());
9679 }
9680
9681 if std::time::Instant::now() >= deadline {
9682 bail!(
9689 "wire pair: timed out after {timeout_secs}s. \
9690 peer {peer_handle} never sent pair_drop_ack. \
9691 likely causes: (a) their daemon is down — ask them to run \
9692 `wire status` and `wire daemon &`; (b) their binary is older \
9693 than 0.5.x and doesn't understand pair_drop events — ask \
9694 them to `wire upgrade`; (c) network / relay blip — re-run \
9695 `wire pair {handle_arg}` to retry."
9696 );
9697 }
9698
9699 std::thread::sleep(poll_interval);
9700 }
9701}
9702
9703fn cmd_claim(
9704 nick: &str,
9705 relay_override: Option<&str>,
9706 public_url: Option<&str>,
9707 hidden: bool,
9708 as_json: bool,
9709) -> Result<()> {
9710 if !crate::pair_profile::is_valid_nick(nick) {
9711 bail!(
9712 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
9713 );
9714 }
9715 let (_did, relay_url, slot_id, slot_token) =
9718 crate::pair_invite::ensure_self_with_relay(relay_override)?;
9719 let card = config::read_agent_card()?;
9720
9721 let client = crate::relay_client::RelayClient::new(&relay_url);
9722 let discoverable = if hidden { Some(false) } else { None };
9726 let resp =
9727 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
9728
9729 if as_json {
9730 println!(
9731 "{}",
9732 serde_json::to_string(&json!({
9733 "nick": nick,
9734 "relay": relay_url,
9735 "response": resp,
9736 }))?
9737 );
9738 } else {
9739 let domain = public_url
9743 .unwrap_or(&relay_url)
9744 .trim_start_matches("https://")
9745 .trim_start_matches("http://")
9746 .trim_end_matches('/')
9747 .split('/')
9748 .next()
9749 .unwrap_or("<this-relay-domain>")
9750 .to_string();
9751 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
9752 println!("verify with: wire whois {nick}@{domain}");
9753 }
9754 Ok(())
9755}
9756
9757fn cmd_profile(action: ProfileAction) -> Result<()> {
9758 match action {
9759 ProfileAction::Set { field, value, json } => {
9760 let parsed: Value =
9764 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
9765 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
9766 if json {
9767 println!(
9768 "{}",
9769 serde_json::to_string(&json!({
9770 "field": field,
9771 "profile": new_profile,
9772 }))?
9773 );
9774 } else {
9775 println!("profile.{field} set");
9776 }
9777 }
9778 ProfileAction::Get { json } => return cmd_whois(None, json, None),
9779 ProfileAction::Clear { field, json } => {
9780 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
9781 if json {
9782 println!(
9783 "{}",
9784 serde_json::to_string(&json!({
9785 "field": field,
9786 "cleared": true,
9787 "profile": new_profile,
9788 }))?
9789 );
9790 } else {
9791 println!("profile.{field} cleared");
9792 }
9793 }
9794 }
9795 Ok(())
9796}
9797
9798fn cmd_setup(apply: bool) -> Result<()> {
9801 use std::path::PathBuf;
9802
9803 let entry = json!({"command": "wire", "args": ["mcp"]});
9804 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
9805
9806 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
9809 if let Some(home) = dirs::home_dir() {
9810 targets.push(("Claude Code", home.join(".claude.json")));
9813 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
9815 #[cfg(target_os = "macos")]
9817 targets.push((
9818 "Claude Desktop (macOS)",
9819 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
9820 ));
9821 #[cfg(target_os = "windows")]
9823 if let Ok(appdata) = std::env::var("APPDATA") {
9824 targets.push((
9825 "Claude Desktop (Windows)",
9826 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
9827 ));
9828 }
9829 targets.push(("Cursor", home.join(".cursor/mcp.json")));
9831 }
9832 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
9834
9835 println!("wire setup\n");
9836 println!("MCP server snippet (add this to your client's mcpServers):");
9837 println!();
9838 println!("{entry_pretty}");
9839 println!();
9840
9841 if !apply {
9842 println!("Probable MCP host config locations on this machine:");
9843 for (name, path) in &targets {
9844 let marker = if path.exists() {
9845 "✓ found"
9846 } else {
9847 " (would create)"
9848 };
9849 println!(" {marker:14} {name}: {}", path.display());
9850 }
9851 println!();
9852 println!("Run `wire setup --apply` to merge wire into each config above.");
9853 println!(
9854 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
9855 );
9856 return Ok(());
9857 }
9858
9859 let mut modified: Vec<String> = Vec::new();
9860 let mut skipped: Vec<String> = Vec::new();
9861 for (name, path) in &targets {
9862 match upsert_mcp_entry(path, "wire", &entry) {
9863 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
9864 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
9865 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
9866 }
9867 }
9868 if !modified.is_empty() {
9869 println!("Modified:");
9870 for line in &modified {
9871 println!(" {line}");
9872 }
9873 println!();
9874 println!("Restart the app(s) above to load wire MCP.");
9875 }
9876 if !skipped.is_empty() {
9877 println!();
9878 println!("Skipped:");
9879 for line in &skipped {
9880 println!(" {line}");
9881 }
9882 }
9883 Ok(())
9884}
9885
9886fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
9889 let mut cfg: Value = if path.exists() {
9890 let body = std::fs::read_to_string(path).context("reading config")?;
9891 serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
9892 } else {
9893 json!({})
9894 };
9895 if !cfg.is_object() {
9896 cfg = json!({});
9897 }
9898 let root = cfg.as_object_mut().unwrap();
9899 let servers = root
9900 .entry("mcpServers".to_string())
9901 .or_insert_with(|| json!({}));
9902 if !servers.is_object() {
9903 *servers = json!({});
9904 }
9905 let map = servers.as_object_mut().unwrap();
9906 if map.get(server_name) == Some(entry) {
9907 return Ok(false);
9908 }
9909 map.insert(server_name.to_string(), entry.clone());
9910 if let Some(parent) = path.parent()
9911 && !parent.as_os_str().is_empty()
9912 {
9913 std::fs::create_dir_all(parent).context("creating parent dir")?;
9914 }
9915 let out = serde_json::to_string_pretty(&cfg)? + "\n";
9916 std::fs::write(path, out).context("writing config")?;
9917 Ok(true)
9918}
9919
9920#[allow(clippy::too_many_arguments)]
9923fn cmd_reactor(
9924 on_event: &str,
9925 peer_filter: Option<&str>,
9926 kind_filter: Option<&str>,
9927 verified_only: bool,
9928 interval_secs: u64,
9929 once: bool,
9930 dry_run: bool,
9931 max_per_minute: u32,
9932 max_chain_depth: u32,
9933) -> Result<()> {
9934 use crate::inbox_watch::{InboxEvent, InboxWatcher};
9935 use std::collections::{HashMap, HashSet, VecDeque};
9936 use std::io::Write;
9937 use std::process::{Command, Stdio};
9938 use std::time::{Duration, Instant};
9939
9940 let cursor_path = config::state_dir()?.join("reactor.cursor");
9941 let emitted_path = config::state_dir()?.join("reactor-emitted.log");
9950 let mut emitted_ids: HashSet<String> = HashSet::new();
9951 if emitted_path.exists()
9952 && let Ok(body) = std::fs::read_to_string(&emitted_path)
9953 {
9954 for line in body.lines() {
9955 let t = line.trim();
9956 if !t.is_empty() {
9957 emitted_ids.insert(t.to_string());
9958 }
9959 }
9960 }
9961 let outbox_dir = config::outbox_dir()?;
9963 let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
9966
9967 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
9968
9969 let kind_num: Option<u32> = match kind_filter {
9970 Some(k) => Some(parse_kind(k)?),
9971 None => None,
9972 };
9973
9974 let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
9976
9977 let dispatch = |ev: &InboxEvent,
9978 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
9979 emitted_ids: &HashSet<String>|
9980 -> Result<bool> {
9981 if let Some(p) = peer_filter
9982 && ev.peer != p
9983 {
9984 return Ok(false);
9985 }
9986 if verified_only && !ev.verified {
9987 return Ok(false);
9988 }
9989 if let Some(want) = kind_num {
9990 let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
9991 if ev_kind != Some(want) {
9992 return Ok(false);
9993 }
9994 }
9995
9996 if max_chain_depth > 0 {
10000 let body_str = match &ev.raw["body"] {
10001 Value::String(s) => s.clone(),
10002 other => serde_json::to_string(other).unwrap_or_default(),
10003 };
10004 if let Some(referenced) = parse_re_marker(&body_str) {
10005 let matched = emitted_ids.contains(&referenced)
10008 || emitted_ids.iter().any(|full| full.starts_with(&referenced));
10009 if matched {
10010 eprintln!(
10011 "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
10012 ev.event_id, ev.peer, referenced
10013 );
10014 return Ok(false);
10015 }
10016 }
10017 }
10018
10019 if max_per_minute > 0 {
10021 let now = Instant::now();
10022 let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
10023 while let Some(&front) = win.front() {
10024 if now.duration_since(front) > Duration::from_secs(60) {
10025 win.pop_front();
10026 } else {
10027 break;
10028 }
10029 }
10030 if win.len() as u32 >= max_per_minute {
10031 eprintln!(
10032 "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
10033 ev.event_id, ev.peer, max_per_minute
10034 );
10035 return Ok(false);
10036 }
10037 win.push_back(now);
10038 }
10039
10040 if dry_run {
10041 println!("{}", serde_json::to_string(&ev.raw)?);
10042 return Ok(true);
10043 }
10044
10045 let mut child = Command::new("sh")
10046 .arg("-c")
10047 .arg(on_event)
10048 .stdin(Stdio::piped())
10049 .stdout(Stdio::inherit())
10050 .stderr(Stdio::inherit())
10051 .env("WIRE_EVENT_PEER", &ev.peer)
10052 .env("WIRE_EVENT_ID", &ev.event_id)
10053 .env("WIRE_EVENT_KIND", &ev.kind)
10054 .spawn()
10055 .with_context(|| format!("spawning reactor handler: {on_event}"))?;
10056 if let Some(mut stdin) = child.stdin.take() {
10057 let body = serde_json::to_vec(&ev.raw)?;
10058 let _ = stdin.write_all(&body);
10059 let _ = stdin.write_all(b"\n");
10060 }
10061 std::mem::drop(child);
10062 Ok(true)
10063 };
10064
10065 let scan_outbox = |emitted_ids: &mut HashSet<String>,
10067 outbox_cursors: &mut HashMap<String, u64>|
10068 -> Result<usize> {
10069 if !outbox_dir.exists() {
10070 return Ok(0);
10071 }
10072 let mut added = 0;
10073 let mut new_ids: Vec<String> = Vec::new();
10074 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
10075 let path = entry.path();
10076 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
10077 continue;
10078 }
10079 let peer = match path.file_stem().and_then(|s| s.to_str()) {
10080 Some(s) => s.to_string(),
10081 None => continue,
10082 };
10083 let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
10084 let start = *outbox_cursors.get(&peer).unwrap_or(&0);
10085 if cur_len <= start {
10086 outbox_cursors.insert(peer, start);
10087 continue;
10088 }
10089 let body = std::fs::read_to_string(&path).unwrap_or_default();
10090 let tail = &body[start as usize..];
10091 for line in tail.lines() {
10092 if let Ok(v) = serde_json::from_str::<Value>(line)
10093 && let Some(eid) = v.get("event_id").and_then(Value::as_str)
10094 && emitted_ids.insert(eid.to_string())
10095 {
10096 new_ids.push(eid.to_string());
10097 added += 1;
10098 }
10099 }
10100 outbox_cursors.insert(peer, cur_len);
10101 }
10102 if !new_ids.is_empty() {
10103 let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
10105 if all.len() > 500 {
10106 all.sort();
10107 let drop_n = all.len() - 500;
10108 let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
10109 emitted_ids.retain(|x| !dropped.contains(x));
10110 all = emitted_ids.iter().cloned().collect();
10111 }
10112 let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
10113 }
10114 Ok(added)
10115 };
10116
10117 let sweep = |watcher: &mut InboxWatcher,
10118 emitted_ids: &mut HashSet<String>,
10119 outbox_cursors: &mut HashMap<String, u64>,
10120 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
10121 -> Result<usize> {
10122 let _ = scan_outbox(emitted_ids, outbox_cursors);
10124
10125 let events = watcher.poll()?;
10126 let mut fired = 0usize;
10127 for ev in &events {
10128 match dispatch(ev, peer_dispatch_log, emitted_ids) {
10129 Ok(true) => fired += 1,
10130 Ok(false) => {}
10131 Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
10132 }
10133 }
10134 watcher.save_cursors(&cursor_path)?;
10135 Ok(fired)
10136 };
10137
10138 if once {
10139 sweep(
10140 &mut watcher,
10141 &mut emitted_ids,
10142 &mut outbox_cursors,
10143 &mut peer_dispatch_log,
10144 )?;
10145 return Ok(());
10146 }
10147 let interval = std::time::Duration::from_secs(interval_secs.max(1));
10148 loop {
10149 if let Err(e) = sweep(
10150 &mut watcher,
10151 &mut emitted_ids,
10152 &mut outbox_cursors,
10153 &mut peer_dispatch_log,
10154 ) {
10155 eprintln!("wire reactor: sweep error: {e}");
10156 }
10157 std::thread::sleep(interval);
10158 }
10159}
10160
10161fn parse_re_marker(body: &str) -> Option<String> {
10164 let needle = "(re:";
10165 let i = body.find(needle)?;
10166 let rest = &body[i + needle.len()..];
10167 let end = rest.find(')')?;
10168 let id = rest[..end].trim().to_string();
10169 if id.is_empty() {
10170 return None;
10171 }
10172 Some(id)
10173}
10174
10175fn cmd_notify(
10178 interval_secs: u64,
10179 peer_filter: Option<&str>,
10180 once: bool,
10181 as_json: bool,
10182) -> Result<()> {
10183 use crate::inbox_watch::InboxWatcher;
10184 let cursor_path = config::state_dir()?.join("notify.cursor");
10185 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
10186
10187 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
10188 let events = watcher.poll()?;
10189 for ev in events {
10190 if let Some(p) = peer_filter
10191 && ev.peer != p
10192 {
10193 continue;
10194 }
10195 if as_json {
10196 println!("{}", serde_json::to_string(&ev)?);
10197 } else {
10198 os_notify_inbox_event(&ev);
10199 }
10200 }
10201 watcher.save_cursors(&cursor_path)?;
10202 Ok(())
10203 };
10204
10205 if once {
10206 return sweep(&mut watcher);
10207 }
10208
10209 let interval = std::time::Duration::from_secs(interval_secs.max(1));
10210 loop {
10211 if let Err(e) = sweep(&mut watcher) {
10212 eprintln!("wire notify: sweep error: {e}");
10213 }
10214 std::thread::sleep(interval);
10215 }
10216}
10217
10218fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
10219 let title = if ev.verified {
10220 format!("wire ← {}", ev.peer)
10221 } else {
10222 format!("wire ← {} (UNVERIFIED)", ev.peer)
10223 };
10224 let body = format!("{}: {}", ev.kind, ev.body_preview);
10225 crate::os_notify::toast(&title, &body);
10226}
10227
10228#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
10229fn os_toast(title: &str, body: &str) {
10230 eprintln!("[wire notify] {title}\n {body}");
10231}
10232
10233