Skip to main content

wire/
cli.rs

1//! `wire` CLI surface.
2//!
3//! Every subcommand emits human-readable text by default and structured JSON
4//! when `--json` is passed. Stable JSON shape is part of the API contract —
5//! see `docs/AGENT_INTEGRATION.md`.
6//!
7//! Subcommand split:
8//!   - **agent-safe**: `whoami`, `peers`, `verify`, `send`, `tail` — pure
9//!     message-layer ops, no trust establishment.
10//!   - **trust-establishing**: `init`, `pair-host`, `pair-join`. The CLI
11//!     uses interactive `y/N` prompts here. The MCP equivalents
12//!     (`wire_init`, `wire_pair_initiate`, `wire_pair_join`, `wire_pair_check`,
13//!     `wire_pair_confirm`) preserve the human gate by requiring the user to
14//!     type the 6 SAS digits back into chat — see `docs/THREAT_MODEL.md` T10/T14.
15
16use 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/// Top-level CLI.
28#[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    /// Generate a keypair, write self-card, and prepare to pair. (HUMAN-ONLY — DO NOT exec from agents.)
38    Init {
39        /// Short handle for this agent (becomes did:wire:<handle>).
40        handle: String,
41        /// Optional display name (defaults to capitalized handle).
42        #[arg(long)]
43        name: Option<String>,
44        /// Optional relay URL — if set, also allocates a relay slot in one step
45        /// (equivalent to running `wire init` then `wire bind-relay <url>`).
46        #[arg(long)]
47        relay: Option<String>,
48        /// Emit JSON.
49        #[arg(long)]
50        json: bool,
51    },
52    // (Old `Join` stub removed in iter 11 — superseded by `pair-join` with
53    // `join` alias. See PairJoin below.)
54    /// Print this agent's identity (DID, fingerprint, mailbox slot).
55    Whoami {
56        #[arg(long)]
57        json: bool,
58        /// Print just `<emoji> <nickname>` (e.g. `🦊 foxtrot-meadow`).
59        /// Plain text, no ANSI escapes. Useful for piping into other tools.
60        #[arg(long, conflicts_with = "json")]
61        short: bool,
62        /// Print `<emoji> <nickname>` wrapped in ANSI 256-color escapes.
63        /// Drop into a Claude Code statusline command for live identity display.
64        #[arg(long, conflicts_with_all = ["json", "short"])]
65        colored: bool,
66    },
67    /// List pinned peers with their tiers and capabilities.
68    Peers {
69        #[arg(long)]
70        json: bool,
71    },
72    /// Sign and queue an event to a peer.
73    ///
74    /// Forms (P0.S 0.5.11):
75    ///   wire send <peer> <body>              # kind defaults to "claim"
76    ///   wire send <peer> <kind> <body>       # explicit kind (back-compat)
77    ///   wire send <peer> -                   # body from stdin (kind=claim)
78    ///   wire send <peer> @/path/to/body.json # body from file
79    Send {
80        /// Peer handle (without `did:wire:` prefix).
81        peer: String,
82        /// When `<body>` is omitted, this is the event body (kind defaults
83        /// to `claim`). When both this and `<body>` are given, this is the
84        /// event kind (`decision`, `claim`, etc., or numeric kind id) and
85        /// the next positional is the body.
86        kind_or_body: String,
87        /// Event body — free-form text, `@/path/to/body.json` to load from
88        /// a file, or `-` to read from stdin. Optional; omit to use
89        /// `<kind_or_body>` as the body with kind=`claim`.
90        body: Option<String>,
91        /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
92        #[arg(long)]
93        deadline: Option<String>,
94        /// Emit JSON.
95        #[arg(long)]
96        json: bool,
97    },
98    /// Stream signed events from peers.
99    Tail {
100        /// Optional peer filter; if omitted, tails all peers.
101        peer: Option<String>,
102        /// Emit JSONL (one event per line).
103        #[arg(long)]
104        json: bool,
105        /// Maximum events to read before exiting (0 = stream until SIGINT).
106        #[arg(long, default_value_t = 0)]
107        limit: usize,
108    },
109    /// Live tail of new inbox events across all pinned peers — one line per
110    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
111    /// by default.
112    ///
113    /// Designed to be left running in an agent harness's stream-watcher
114    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
115    /// session as they arrive, not on next manual `wire pull`.
116    ///
117    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
118    /// template.
119    Monitor {
120        /// Only show events from this peer.
121        #[arg(long)]
122        peer: Option<String>,
123        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
124        #[arg(long)]
125        json: bool,
126        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
127        /// Default filters them out as noise.
128        #[arg(long)]
129        include_handshake: bool,
130        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
131        #[arg(long, default_value_t = 500)]
132        interval_ms: u64,
133        /// Replay last N events from history before going live (0 = none).
134        #[arg(long, default_value_t = 0)]
135        replay: usize,
136    },
137    /// Verify a signed event from a JSON file or stdin (`-`).
138    Verify {
139        /// Path to event JSON, or `-` for stdin.
140        path: String,
141        /// Emit JSON.
142        #[arg(long)]
143        json: bool,
144    },
145    /// Run the MCP (Model Context Protocol) server over stdio.
146    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
147    /// `wire_send`, `wire_tail`, etc. as native tools.
148    Mcp,
149    /// Run a relay server on this host.
150    RelayServer {
151        /// Bind address (e.g. `127.0.0.1:8770`).
152        #[arg(long, default_value = "127.0.0.1:8770")]
153        bind: String,
154        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
155        /// skip `.well-known/wire/agent` serving. The relay becomes
156        /// invisible from outside the box — only same-machine processes
157        /// can pair through it. Right call for within-machine agent
158        /// coordination where you don't want metadata leaking to a
159        /// public relay. Pair this with `wire session new` which probes
160        /// `127.0.0.1:8771` and allocates a local slot automatically.
161        #[arg(long)]
162        local_only: bool,
163        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
164        /// When set, --bind is ignored. Implies --local-only semantics
165        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
166        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
167        /// sister sessions. Unix only (Windows refuses).
168        #[arg(long)]
169        uds: Option<std::path::PathBuf>,
170    },
171    /// Allocate a slot on a relay; bind it to this agent's identity.
172    ///
173    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
174    /// current slot, this command refuses by default — silent migration
175    /// silently black-holes their inbound messages. Pass
176    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
177    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
178    /// for safe rotation.
179    BindRelay {
180        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
181        url: String,
182        /// Acknowledge that pinned peers will black-hole until they
183        /// re-pin manually. Required when `state.peers` is non-empty;
184        /// ignored on fresh boxes. Use `wire rotate-slot` instead for
185        /// the supported same-relay rotation path.
186        #[arg(long)]
187        migrate_pinned: bool,
188        #[arg(long)]
189        json: bool,
190    },
191    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
192    /// real `wire join` lands in the SPAKE2 iter.)
193    AddPeerSlot {
194        /// Peer handle (becomes did:wire:<handle>).
195        handle: String,
196        /// Peer's relay base URL.
197        url: String,
198        /// Peer's slot id.
199        slot_id: String,
200        /// Slot bearer token (shared between paired peers in v0.1).
201        slot_token: String,
202        #[arg(long)]
203        json: bool,
204    },
205    /// Drain outbox JSONL files to peers' relay slots.
206    Push {
207        /// Optional peer filter; default = all peers with outbox entries.
208        peer: Option<String>,
209        #[arg(long)]
210        json: bool,
211    },
212    /// Pull events from our relay slot, verify, write to inbox.
213    Pull {
214        #[arg(long)]
215        json: bool,
216    },
217    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
218    /// Useful as a single "where am I" check.
219    Status {
220        /// Inspect a paired peer's transport / attention / responder health.
221        #[arg(long)]
222        peer: Option<String>,
223        #[arg(long)]
224        json: bool,
225    },
226    /// Publish or inspect auto-responder health for this slot.
227    Responder {
228        #[command(subcommand)]
229        command: ResponderCommand,
230    },
231    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
232    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
233    Pin {
234        /// Path to peer's signed agent-card JSON.
235        card_file: String,
236        #[arg(long)]
237        json: bool,
238    },
239    /// Allocate a NEW slot on the same relay and abandon the old one.
240    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
241    /// slot announcing the new mailbox before swapping. After rotation,
242    /// peers must re-pair (or operator runs `add-peer-slot` with the new
243    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
244    ///
245    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
246    /// abusive bearer-holder spamming your slot). Rotate → old slot is
247    /// orphaned → attacker's leverage gone. Operator pairs again with
248    /// peers they still want.
249    RotateSlot {
250        /// Skip the wire_close announcement to peers (faster but they won't know
251        /// where you went).
252        #[arg(long)]
253        no_announce: bool,
254        #[arg(long)]
255        json: bool,
256    },
257    /// Remove a peer from trust + relay state. Inbox/outbox files for that
258    /// peer are NOT deleted (operator can grep history); pass --purge to
259    /// also wipe the JSONL files.
260    ForgetPeer {
261        /// Peer handle to forget.
262        handle: String,
263        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
264        #[arg(long)]
265        purge: bool,
266        #[arg(long)]
267        json: bool,
268    },
269    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
270    /// peers' relay slots and pull inbox from our own slot. Foreground process;
271    /// background it with systemd / `&` / tmux as you prefer.
272    Daemon {
273        /// Sync interval in seconds. Default 5.
274        #[arg(long, default_value_t = 5)]
275        interval: u64,
276        /// Run a single sync cycle and exit (useful for cron-driven setups).
277        #[arg(long)]
278        once: bool,
279        #[arg(long)]
280        json: bool,
281    },
282    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
283    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
284    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
285    /// read the SAS digits aloud and confirm.)
286    PairHost {
287        /// Relay base URL.
288        #[arg(long)]
289        relay: String,
290        /// Skip the SAS confirmation prompt. ONLY use when piping under
291        /// automated tests or when the SAS has already been verified by
292        /// another channel. Documented as test-only.
293        #[arg(long)]
294        yes: bool,
295        /// How long (seconds) to wait for the peer to join before timing out.
296        #[arg(long, default_value_t = 300)]
297        timeout: u64,
298        /// Detach: write a pending-pair file, print the code phrase, and exit
299        /// immediately. The running `wire daemon` does the handshake in the
300        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
301        /// `wire pair-list` shows pending sessions. Default is foreground
302        /// blocking behavior for backward compat.
303        #[arg(long)]
304        detach: bool,
305        /// Emit JSON instead of text. Currently only meaningful with --detach.
306        #[arg(long)]
307        json: bool,
308    },
309    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
310    ///
311    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
312    #[command(alias = "join")]
313    PairJoin {
314        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
315        code_phrase: String,
316        /// Relay base URL (must match the host's relay).
317        #[arg(long)]
318        relay: String,
319        #[arg(long)]
320        yes: bool,
321        #[arg(long, default_value_t = 300)]
322        timeout: u64,
323        /// Detach: see `pair-host --detach`.
324        #[arg(long)]
325        detach: bool,
326        /// Emit JSON instead of text. Currently only meaningful with --detach.
327        #[arg(long)]
328        json: bool,
329    },
330    /// Confirm SAS digits for a detached pending pair. The daemon must be
331    /// running for this to do anything — it picks up the confirmation on its
332    /// next tick. Mismatch aborts the pair.
333    PairConfirm {
334        /// The code phrase the original `wire pair-host --detach` printed.
335        code_phrase: String,
336        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
337        digits: String,
338        /// Emit JSON instead of human-readable text.
339        #[arg(long)]
340        json: bool,
341    },
342    /// List all pending detached pair sessions and their state.
343    PairList {
344        /// Emit JSON instead of the table.
345        #[arg(long)]
346        json: bool,
347        /// Stream mode: never exit; print one JSON line per status transition
348        /// (creation, status change, deletion) across all pending pairs.
349        /// Compose with bash `while read` to react in shell. Implies --json.
350        #[arg(long)]
351        watch: bool,
352        /// Poll interval in seconds for --watch.
353        #[arg(long, default_value_t = 1)]
354        watch_interval: u64,
355    },
356    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
357    PairCancel {
358        code_phrase: String,
359        #[arg(long)]
360        json: bool,
361    },
362    /// Block until a pending pair reaches a target status (default sas_ready),
363    /// or terminates (finalized = file removed, aborted, aborted_restart), or
364    /// the timeout expires. Useful for shell scripts that want to drive the
365    /// detached flow without polling pair-list themselves.
366    ///
367    /// Exit codes:
368    ///   0 — reached target status (or finalized, if target was sas_ready)
369    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
370    ///   2 — timeout
371    PairWatch {
372        code_phrase: String,
373        /// Target status to wait for. Default: sas_ready.
374        #[arg(long, default_value = "sas_ready")]
375        status: String,
376        /// Max seconds to wait.
377        #[arg(long, default_value_t = 300)]
378        timeout: u64,
379        /// Emit JSON on each status change (one per line) instead of just on exit.
380        #[arg(long)]
381        json: bool,
382    },
383    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
384    /// pair-join, then registers wire as an MCP server. Single command from
385    /// nothing to paired and ready — no separate init/pair-host/setup steps.
386    /// Operator still must confirm SAS digits.
387    ///
388    /// Examples:
389    ///   wire pair paul                          # host a new pair on default relay
390    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
391    Pair {
392        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
393        /// step if no identity exists; ignored if already initialized.
394        handle: String,
395        /// Code phrase from peer's pair-host output. Omit to be the host
396        /// (this command will print one for you to share).
397        #[arg(long)]
398        code: Option<String>,
399        /// Relay base URL. Defaults to the laulpogan public-good relay.
400        #[arg(long, default_value = "https://wireup.net")]
401        relay: String,
402        /// Skip SAS prompt. Test-only.
403        #[arg(long)]
404        yes: bool,
405        /// Pair-step timeout in seconds.
406        #[arg(long, default_value_t = 300)]
407        timeout: u64,
408        /// Skip the post-pair `setup --apply` step (don't register wire as
409        /// an MCP server in detected client configs).
410        #[arg(long)]
411        no_setup: bool,
412        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
413        /// exits immediately, daemon does the handshake). Confirm via
414        /// `wire pair-confirm <code> <digits>` from any terminal. See
415        /// `pair-host --detach` for details.
416        #[arg(long)]
417        detach: bool,
418    },
419    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
420    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
421    /// confirmation, leaving the relay-side slot stuck with "guest already
422    /// registered" or "host already registered" until the 5-minute TTL expires.
423    /// Either side can call. Idempotent.
424    PairAbandon {
425        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
426        code_phrase: String,
427        /// Relay base URL.
428        #[arg(long, default_value = "https://wireup.net")]
429        relay: String,
430    },
431    /// Accept a pending-inbound pair request (v0.5.14). Explicit alias for
432    /// the bilateral-completion path that `wire add <peer>@<relay>` also
433    /// drives — but doesn't require remembering the peer's relay domain
434    /// (the relay coords come from the stored pair_drop). Errors if no
435    /// pending-inbound record exists for that peer.
436    PairAccept {
437        /// Bare peer handle (without `@<relay>`).
438        peer: String,
439        /// Emit JSON.
440        #[arg(long)]
441        json: bool,
442    },
443    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
444    /// you@<your-relay>` against your handle, their signed pair_drop lands
445    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
446    /// <peer>` to delete the record without pairing. The peer never receives
447    /// our slot_token; from their side the pair stays pending until they
448    /// time out.
449    PairReject {
450        /// Bare peer handle (without `@<relay>`).
451        peer: String,
452        /// Emit JSON.
453        #[arg(long)]
454        json: bool,
455    },
456    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
457    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
458    /// `pair-list --json` shape but for inbound). Use this in scripts that
459    /// need to enumerate inbound pair requests without parsing the SPAKE2
460    /// table format from `wire pair-list`.
461    PairListInbound {
462        /// Emit JSON.
463        #[arg(long)]
464        json: bool,
465    },
466    /// Manage isolated wire sessions on this machine (v0.5.16).
467    ///
468    /// Each session = its own DID + handle + relay slot + daemon + inbox/
469    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
470    /// in different projects) run on the same machine — without sessions
471    /// they all share one identity and race the inbox cursor.
472    ///
473    /// Names are derived from `basename(cwd)` and cached in a registry,
474    /// so re-entering the same project reuses the same identity.
475    #[command(subcommand)]
476    Session(SessionCommand),
477    /// Manage this session's identity display layer (character override).
478    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
479    /// itself picks a custom nickname + emoji that overrides the
480    /// auto-derived hash-based defaults.
481    Identity {
482        #[command(subcommand)]
483        cmd: IdentityCommand,
484    },
485    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
486    /// sister-session mesh. `wire mesh status` is the live view of every
487    /// paired sister (alias for `wire session mesh-status`); `wire mesh
488    /// broadcast` fans one signed event to every pinned peer.
489    #[command(subcommand)]
490    Mesh(MeshCommand),
491    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
492    /// Cursor, project-local) and either print or auto-merge the wire MCP
493    /// server entry. Default prints; pass `--apply` to actually modify config
494    /// files. Idempotent — re-running is safe.
495    Setup {
496        /// Actually write the changes (default = print only).
497        #[arg(long)]
498        apply: bool,
499    },
500    /// Show an agent's profile. With no arg, prints local self. With a
501    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
502    /// endpoint and verifies the returned signed card before display.
503    Whois {
504        /// Optional handle (`nick@domain`). Omit to show self.
505        handle: Option<String>,
506        #[arg(long)]
507        json: bool,
508        /// Override the relay base URL used for resolution (default:
509        /// `https://<domain>` from the handle).
510        #[arg(long)]
511        relay: Option<String>,
512    },
513    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
514    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
515    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
516    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
517    /// their slot_token so we can `wire send` to them).
518    Add {
519        /// Peer handle (`nick@domain`), OR a bare sister-session name
520        /// when `--local-sister` is set.
521        handle: String,
522        /// Override the relay base URL used for resolution.
523        #[arg(long)]
524        relay: Option<String>,
525        /// v0.6.6: pair with a sister session on this machine without
526        /// touching federation. Looks up `handle` as a session name in
527        /// `wire session list`, reads that session's agent-card +
528        /// endpoints from disk, pins directly, then delivers the
529        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
530        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
531        /// addressable because they don't need a federation claim.
532        #[arg(long)]
533        local_sister: bool,
534        #[arg(long)]
535        json: bool,
536    },
537    /// One-shot full bootstrap — `wire up <nick@relay-host>` does in one
538    /// command what 0.5.10 took five (init + bind-relay + claim + daemon-
539    /// background + remember-to-restart-on-login). Idempotent: re-run on
540    /// an already-set-up box prints state without churn.
541    ///
542    /// Examples:
543    ///   wire up paul@wireup.net           # full bootstrap
544    ///   wire up paul-mac@wireup.net       # ditto, nick = paul-mac
545    ///   wire up paul                      # bootstrap, default relay
546    Up {
547        /// Full handle in `nick@relay-host` form, or just `nick` (defaults
548        /// to the configured public relay wireup.net).
549        handle: String,
550        /// Optional display name (defaults to capitalized nick).
551        #[arg(long)]
552        name: Option<String>,
553        #[arg(long)]
554        json: bool,
555    },
556    /// Diagnose wire setup health. Single command that surfaces every
557    /// silent-fail class — daemon down or duplicated, relay unreachable,
558    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
559    /// Replaces today's 30-minute manual debug.
560    ///
561    /// Exit code non-zero if any FAIL findings.
562    Doctor {
563        /// Emit JSON.
564        #[arg(long)]
565        json: bool,
566        /// Show last N entries from pair-rejected.jsonl in the report.
567        #[arg(long, default_value_t = 5)]
568        recent_rejections: usize,
569    },
570    /// Atomic upgrade: kill every `wire daemon` process, spawn a fresh
571    /// one from the current binary, write a new pidfile. Eliminates the
572    /// "stale binary text in memory under a fresh symlink" bug class that
573    /// burned 30 minutes today.
574    Upgrade {
575        /// Report drift without taking action (lists processes that would
576        /// be killed + the version of each).
577        #[arg(long)]
578        check: bool,
579        #[arg(long)]
580        json: bool,
581    },
582    /// Install / inspect / remove a launchd plist (macOS) or systemd
583    /// user unit (linux) that runs `wire daemon` on login + restarts
584    /// on crash. Replaces today's "background it with tmux/&/systemd
585    /// as you prefer" footgun.
586    Service {
587        #[command(subcommand)]
588        action: ServiceAction,
589    },
590    /// Inspect or toggle the structured diagnostic trace
591    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
592    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
593    /// (writes the file knob a running daemon picks up automatically).
594    Diag {
595        #[command(subcommand)]
596        action: DiagAction,
597    },
598    /// Claim a nick on a relay's handle directory. Anyone can then reach
599    /// this agent by `<nick>@<relay-domain>` via the relay's
600    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
601    Claim {
602        nick: String,
603        /// Relay to claim the nick on. Default = relay our slot is on.
604        #[arg(long)]
605        relay: Option<String>,
606        /// Public URL the relay should advertise to resolvers (default = relay).
607        #[arg(long)]
608        public_url: Option<String>,
609        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
610        /// directory listing. The handle stays claimed (FCFS still
611        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
612        /// still resolves, so peers you share the handle with out-of-band
613        /// can still pair. Bulk scrapers / phonebook crawlers will not
614        /// see the nick. Use this for handles meant for known-peer
615        /// pairing only — see issue #9.
616        #[arg(long)]
617        hidden: bool,
618        #[arg(long)]
619        json: bool,
620    },
621    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
622    /// avatar_url, handle, now). Re-signs the agent-card atomically.
623    ///
624    /// Examples:
625    ///   wire profile set motto "compiles or dies trying"
626    ///   wire profile set emoji "🦀"
627    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
628    ///   wire profile set handle "coffee-ghost@anthropic.dev"
629    ///   wire profile get
630    Profile {
631        #[command(subcommand)]
632        action: ProfileAction,
633    },
634    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
635    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
636    /// a relay slot on first use. Default TTL 24h, single-use.
637    Invite {
638        /// Override the relay URL for first-time auto-allocation.
639        #[arg(long, default_value = "https://wireup.net")]
640        relay: String,
641        /// Invite lifetime in seconds (default 86400 = 24h).
642        #[arg(long, default_value_t = 86_400)]
643        ttl: u64,
644        /// Number of distinct peers that can accept this invite before it's
645        /// consumed (default 1).
646        #[arg(long, default_value_t = 1)]
647        uses: u32,
648        /// Register the invite at the relay's short-URL endpoint and print
649        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
650        /// Installs wire if missing, then accepts the invite, then pairs.
651        #[arg(long)]
652        share: bool,
653        /// Emit JSON.
654        #[arg(long)]
655        json: bool,
656    },
657    /// Accept a wire invite URL. Single-step pair — pins issuer, sends our
658    /// signed card to issuer's slot. Auto-inits + auto-allocates if needed.
659    Accept {
660        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
661        url: String,
662        /// Emit JSON.
663        #[arg(long)]
664        json: bool,
665    },
666    /// Long-running event dispatcher. Watches inbox for new verified events
667    /// and spawns the given shell command per event, passing the event JSON
668    /// on stdin. Use to wire up autonomous reply loops:
669    ///   wire reactor --on-event 'claude -p "respond via wire send"'
670    /// Cursor persisted to `$WIRE_HOME/state/wire/reactor.cursor`.
671    Reactor {
672        /// Shell command to spawn per event. Event JSON written to its stdin.
673        #[arg(long)]
674        on_event: String,
675        /// Only fire for events from this peer.
676        #[arg(long)]
677        peer: Option<String>,
678        /// Only fire for events of this kind (numeric or name, e.g. 1 / decision).
679        #[arg(long)]
680        kind: Option<String>,
681        /// Skip events whose verified flag is false (default true).
682        #[arg(long, default_value_t = true)]
683        verified_only: bool,
684        /// Poll interval in seconds.
685        #[arg(long, default_value_t = 2)]
686        interval: u64,
687        /// Process one sweep and exit.
688        #[arg(long)]
689        once: bool,
690        /// Don't actually spawn — print one JSONL line per event for smoke-testing.
691        #[arg(long)]
692        dry_run: bool,
693        /// Hard rate-limit: max events handler is fired for per peer per minute.
694        /// 0 = unlimited. Default 6 — covers normal conversational tempo, kills
695        /// LLM-vs-LLM feedback loops (which fire 10+/sec).
696        #[arg(long, default_value_t = 6)]
697        max_per_minute: u32,
698        /// Anti-loop chain depth. Track event_ids this reactor emitted; if an
699        /// incoming event body contains `(re:X)` where X is in our emitted log,
700        /// skip — that's a reply-to-our-reply, depth ≥ 2. Disable with 0.
701        #[arg(long, default_value_t = 1)]
702        max_chain_depth: u32,
703    },
704    /// Watch the inbox for new verified events and fire an OS notification per
705    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
706    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
707    /// re-emit history.
708    Notify {
709        /// Poll interval in seconds.
710        #[arg(long, default_value_t = 2)]
711        interval: u64,
712        /// Only notify for events from this peer (handle, no did: prefix).
713        #[arg(long)]
714        peer: Option<String>,
715        /// Run a single sweep and exit (useful for cron / tests).
716        #[arg(long)]
717        once: bool,
718        /// Suppress the OS notification call; print one JSON line per event to
719        /// stdout instead (for piping into other tooling or smoke-testing
720        /// without a desktop session).
721        #[arg(long)]
722        json: bool,
723    },
724}
725
726#[derive(Subcommand, Debug)]
727pub enum DiagAction {
728    /// Tail the last N entries from diag.jsonl.
729    Tail {
730        #[arg(long, default_value_t = 20)]
731        limit: usize,
732        #[arg(long)]
733        json: bool,
734    },
735    /// Flip the file-based knob ON. Running daemons pick this up on
736    /// the next emit call without restart.
737    Enable,
738    /// Flip the file-based knob OFF.
739    Disable,
740    /// Report whether diag is currently enabled + the file's size.
741    Status {
742        #[arg(long)]
743        json: bool,
744    },
745}
746
747#[derive(Subcommand, Debug)]
748pub enum IdentityCommand {
749    /// Override the auto-derived nickname and/or emoji. Persists to
750    /// `<WIRE_HOME>/config/wire/display.json`. Local-only; peers still
751    /// see the auto-derived character from your DID (until federation
752    /// publishes overrides in a future release).
753    ///
754    /// Examples:
755    ///   wire identity rename --name foxtrot-meadow --emoji 🦊
756    ///   wire identity rename --emoji 🐉      (keep auto nickname)
757    ///   wire identity rename --random        (re-roll auto from seed; clears overrides)
758    Rename {
759        /// New nickname (any non-empty string; convention is
760        /// `adjective-noun`, e.g. `foxtrot-meadow`). Omit to leave nickname
761        /// at its current value (auto-derived unless previously set).
762        #[arg(long)]
763        name: Option<String>,
764        /// New emoji glyph. Any single grapheme; the curated set is
765        /// recommended for cross-terminal compatibility.
766        #[arg(long)]
767        emoji: Option<String>,
768        /// Clear all overrides; revert to auto-derived from DID hash.
769        /// Mutually exclusive with `--name` / `--emoji`.
770        #[arg(long, conflicts_with_all = ["name", "emoji"])]
771        clear: bool,
772        /// Re-roll: alias for `--clear` plus an explanatory stderr line.
773        /// Auto-derived is itself deterministic, so this just removes
774        /// any previously-set override.
775        #[arg(long, conflicts_with_all = ["name", "emoji", "clear"])]
776        random: bool,
777        #[arg(long)]
778        json: bool,
779    },
780    /// Print the current character (auto-derived OR override).
781    /// Equivalent to `wire whoami --short` but scoped here for grouping.
782    Show {
783        #[arg(long)]
784        json: bool,
785    },
786    /// List all identities on this machine — one row per session, with
787    /// each session's character, DID, federation handle, and cwd. Same
788    /// shape as `wire session list`, scoped here for the v0.7+ noun-
789    /// CLI surface.
790    List {
791        #[arg(long)]
792        json: bool,
793    },
794    /// Promote this identity to FEDERATION lifecycle: claim a handle on
795    /// the relay so peers can `wire add <name>@<relay-domain>` you.
796    /// Re-claims with current display fields (after `wire identity rename`)
797    /// so the relay always serves the latest signed card. Equivalent to
798    /// `wire claim` but scoped here for the v0.7+ noun-CLI surface.
799    Publish {
800        /// The handle to claim on the relay (e.g. `coffee-ghost`).
801        nick: String,
802        /// Override the relay URL. Defaults to the session's bound relay
803        /// from `wire init --relay <url>`. Public relay if unset.
804        #[arg(long)]
805        relay: Option<String>,
806        /// Public-facing URL for the agent-card location (when the relay
807        /// is behind a CDN with a different public domain).
808        #[arg(long, alias = "public")]
809        public_url: Option<String>,
810        /// Skip listing in the relay's public phonebook. The card is
811        /// still claimable + reachable; just doesn't appear in
812        /// `wireup.net/phonebook` for stranger-discovery.
813        #[arg(long)]
814        hidden: bool,
815        #[arg(long)]
816        json: bool,
817    },
818    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
819    /// Equivalent to `wire session destroy <name>`, scoped here for the
820    /// noun-CLI surface. Requires `--force` (the underlying command does).
821    Destroy {
822        /// Session name to destroy (use `wire identity list` to see).
823        name: String,
824        /// Bypass the confirmation prompt.
825        #[arg(long)]
826        force: bool,
827        #[arg(long)]
828        json: bool,
829    },
830    /// Create an identity in an EXPLICIT lifecycle state (vs. the
831    /// implicit `wire init` + `wire claim` flow).
832    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
833    ///
834    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
835    /// next reboot). In-memory semantics not yet supported — the
836    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
837    /// For pure-RAM identities, see v1.0 vision.
838    ///
839    /// `--local` is the explicit form of today's default; identity
840    /// persists to the machine-wide sessions root.
841    Create {
842        /// Session name. Defaults to derived from cwd (anonymous mode
843        /// uses a random name).
844        #[arg(long)]
845        name: Option<String>,
846        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
847        /// reboot, no federation). Mutually exclusive with --local.
848        #[arg(long, conflicts_with = "local")]
849        anonymous: bool,
850        /// Create a LOCAL identity (machine-persistent, no federation).
851        /// Default — explicit flag for clarity.
852        #[arg(long)]
853        local: bool,
854        #[arg(long)]
855        json: bool,
856    },
857    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
858    /// the machine-wide sessions root + register in the cwd map.
859    /// After persist, the identity survives reboot.
860    /// v0.7.0-alpha.20.
861    Persist {
862        /// The anonymous identity's name (from `wire identity list`).
863        name: String,
864        /// Optional rename during persist. Default: keep the anon name.
865        #[arg(long = "as", value_name = "NEW_NAME")]
866        as_name: Option<String>,
867        #[arg(long)]
868        json: bool,
869    },
870    /// Demote an identity ONE level in the lifecycle:
871    ///   federation → local: removes the relay slot binding but keeps
872    ///   the keypair + agent-card. Operator can later re-publish with
873    ///   `wire identity publish`. v0.7.0-alpha.20.
874    ///
875    /// (local → anonymous is not exposed; the safer flow is destroy +
876    /// recreate, since "demoting" a persistent identity to ephemeral
877    /// has surprising semantics — what about the keypair? what about
878    /// pinned peers? Better to be explicit with destroy.)
879    Demote {
880        /// Session name to demote.
881        name: String,
882        #[arg(long)]
883        json: bool,
884    },
885}
886
887#[derive(Subcommand, Debug)]
888pub enum SessionCommand {
889    /// Bootstrap a new isolated session in this machine's sessions root.
890    /// With no name, derives one from `basename(cwd)` and caches it in
891    /// the registry so re-running from the same project reuses it.
892    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
893    /// the new session's WIRE_HOME. Output includes the `export
894    /// WIRE_HOME=...` line operators paste into their shell to activate
895    /// it.
896    New {
897        /// Optional session name. Default = derived from `basename(cwd)`.
898        name: Option<String>,
899        /// Relay URL for the session's slot allocation + handle claim.
900        #[arg(long, default_value = "https://wireup.net")]
901        relay: String,
902        /// v0.5.17: also allocate a second slot on a same-machine local
903        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
904        /// sister-session traffic prefers this path: zero round-trip
905        /// latency, zero metadata exposure to the public relay. Probes
906        /// `<local-relay>/healthz` first; silently skips if the local
907        /// relay isn't running.
908        #[arg(long)]
909        with_local: bool,
910        /// v0.5.17: override the local relay URL probed by `--with-local`.
911        /// Default is `http://127.0.0.1:8771` to match
912        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
913        #[arg(long, default_value = "http://127.0.0.1:8771")]
914        local_relay: String,
915        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
916        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
917        /// Lets other machines on the same network reach this session
918        /// directly without round-tripping the public federation relay
919        /// at https://wireup.net. LAN endpoint is published in the
920        /// agent-card; opt-in per session (default off).
921        #[arg(long)]
922        with_lan: bool,
923        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
924        /// LAN IP — operator must type the address). Example:
925        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
926        #[arg(long)]
927        lan_relay: Option<String>,
928        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
929        /// relay (must be running e.g. via `wire relay-server --uds
930        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
931        /// bypasses the macOS firewall + Tailscale userspace-netstack
932        /// class of issues entirely for sister-session traffic. UDS
933        /// endpoint is published in the agent-card.
934        #[arg(long)]
935        with_uds: bool,
936        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
937        /// is set. Example: `/tmp/wire.sock` or
938        /// `~/.wire/local.sock`.
939        #[arg(long)]
940        uds_socket: Option<std::path::PathBuf>,
941        /// Skip spawning the session-local daemon. Use when you want
942        /// to drive sync explicitly from the agent or test rig.
943        #[arg(long)]
944        no_daemon: bool,
945        /// v0.6.6: create a federation-free session — no nick claim on
946        /// `--relay`, no federation slot allocation. Implies
947        /// `--with-local`. The session exists only to coordinate with
948        /// other sister sessions on this machine; it has no public
949        /// address and cannot be reached from outside. Reserved nicks
950        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
951        /// to publish them.
952        #[arg(long)]
953        local_only: bool,
954        /// Emit JSON.
955        #[arg(long)]
956        json: bool,
957    },
958    /// List all sessions on this machine with their handle, DID,
959    /// daemon liveness, and the cwd they're associated with.
960    List {
961        #[arg(long)]
962        json: bool,
963    },
964    /// List sister sessions reachable via a same-machine local relay
965    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
966    /// share. Sessions without a Local-scope endpoint are listed
967    /// separately so the operator can tell which are federation-only.
968    /// Read-only — does not probe any relay or touch daemons.
969    ListLocal {
970        #[arg(long)]
971        json: bool,
972    },
973    /// v0.6.0 (issue #12): mesh-pair every sister session against every
974    /// other in O(N²) handshakes. For each unordered pair (A, B) that
975    /// is not already paired, drives the bilateral flow end-to-end:
976    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
977    /// B's side, then a final pull on A so the ack lands. Idempotent —
978    /// re-running skips pairs already in `state.peers`.
979    ///
980    /// **Trust anchor:** the operator running this command owns every
981    /// session listed in `wire session list-local` (they all live under
982    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
983    /// That filesystem-permission boundary IS the consent for both
984    /// sides — the bilateral SAS / network-level handshake assumes
985    /// strangers; same-uid sister sessions are by definition not
986    /// strangers. Cross-uid sister sessions are out of scope; today
987    /// `wire session list-local` only enumerates this user's sessions.
988    PairAllLocal {
989        /// Seconds to wait between handshake stages for pair_drop /
990        /// pair_drop_ack to propagate over the relay. Default 1s
991        /// (local-relay is typically <100ms RTT). Bump if you see
992        /// "pending-inbound never arrived" errors on a slow relay.
993        #[arg(long, default_value_t = 1)]
994        settle_secs: u64,
995        /// Federation relay to bind each `wire add` against. Default
996        /// `https://wireup.net`. Sister sessions should be bound to
997        /// the same federation relay; the pair handshake routes through
998        /// it for the .well-known resolution + pair_drop deposit.
999        #[arg(long, default_value = "https://wireup.net")]
1000        federation_relay: String,
1001        #[arg(long)]
1002        json: bool,
1003    },
1004    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1005    /// machine. Enumerates every session in `wire session list-local`,
1006    /// walks each session's `relay.json#peers` to find which other sister
1007    /// sessions it has pinned, and probes the local relay for each edge's
1008    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1009    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1010    /// local_relay, summary}` so scripts can scrape.
1011    ///
1012    /// Read-only — does NOT touch peers or daemons, only the relay's
1013    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1014    /// already hold. Silent on any probe failure (degrades to "no
1015    /// signal" rather than abort) so a half-broken mesh is still
1016    /// inspectable.
1017    MeshStatus {
1018        /// Threshold in seconds for "stale" classification on an edge.
1019        /// An edge whose receiver hasn't polled their slot in this long
1020        /// is flagged. Default 300s (5 min) — same as the per-send
1021        /// `phyllis` attentiveness nag.
1022        #[arg(long, default_value_t = 300)]
1023        stale_secs: u64,
1024        #[arg(long)]
1025        json: bool,
1026    },
1027    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1028    /// can `eval $(wire session env <name>)` to activate it. With no
1029    /// name, resolves the cwd through the registry.
1030    Env {
1031        /// Session name. Default = derived from cwd via the registry.
1032        name: Option<String>,
1033        #[arg(long)]
1034        json: bool,
1035    },
1036    /// Identify which session the current cwd maps to in the registry.
1037    /// Prints `(none)` if cwd isn't registered — `wire session new`
1038    /// would create one.
1039    Current {
1040        #[arg(long)]
1041        json: bool,
1042    },
1043    /// Tear down a session: kills its daemon (if running), deletes its
1044    /// state directory, and removes it from the registry. Requires
1045    /// `--force` because state loss is unrecoverable (keypair gone).
1046    Destroy {
1047        name: String,
1048        /// Confirm state-deleting operation.
1049        #[arg(long)]
1050        force: bool,
1051        #[arg(long)]
1052        json: bool,
1053    },
1054}
1055
1056/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1057/// session's view of the pinned peer set. `status` is the read-only
1058/// observability primitive (alias for `wire session mesh-status`);
1059/// `broadcast` fans a signed event to every pinned peer in one call.
1060#[derive(Subcommand, Debug)]
1061pub enum MeshCommand {
1062    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1063    /// per-edge health roll-up across every sister session on this machine.
1064    Status {
1065        /// Threshold in seconds for "stale" classification on an edge.
1066        #[arg(long, default_value_t = 300)]
1067        stale_secs: u64,
1068        #[arg(long)]
1069        json: bool,
1070    },
1071    /// Fan one signed event to every pinned peer. Each peer receives a
1072    /// distinct `event_id` but every copy shares the same `broadcast_id`
1073    /// UUID so receivers can correlate them as a single broadcast.
1074    ///
1075    /// `--scope local` (default) only fans to peers reachable via a same-
1076    /// machine local relay. `--scope federation` only to public-relay
1077    /// peers. `--scope both` to every pinned peer.
1078    ///
1079    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1080    /// for "ack-loop" prevention: a peer responding to a broadcast can
1081    /// exclude its own broadcaster when re-broadcasting.
1082    ///
1083    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1084    /// file, `-` reads stdin (JSON if parseable, else literal).
1085    ///
1086    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1087    /// peers — that would re-introduce the phonebook-scrape risk closed
1088    /// in v0.5.14 (T8).
1089    Broadcast {
1090        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1091        /// `heartbeat`. Same vocabulary as `wire send`.
1092        #[arg(long, default_value = "claim")]
1093        kind: String,
1094        /// `local`, `federation`, or `both`. Default `local`.
1095        #[arg(long, default_value = "local")]
1096        scope: String,
1097        /// Skip a specific peer handle. Repeatable.
1098        #[arg(long)]
1099        exclude: Vec<String>,
1100        /// Drop the broadcast event ID from the relay-side attentiveness
1101        /// nag (`phyllis`) — useful when broadcasting to many peers and
1102        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1103        #[arg(long)]
1104        noreply: bool,
1105        /// Body — string, `@/path` for a file, or `-` for stdin.
1106        body: String,
1107        #[arg(long)]
1108        json: bool,
1109    },
1110    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1111    /// capability-aware addressing. Stored as `profile.role` on the
1112    /// signed agent-card — propagates over the existing pair / .well-
1113    /// known plumbing, no new persistence.
1114    ///
1115    /// First slice of the Layer-2 capability metadata umbrella (#13).
1116    /// `wire mesh route` (issue #21) will consume these tags to pick
1117    /// the right sister for a task.
1118    Role {
1119        #[command(subcommand)]
1120        action: MeshRoleAction,
1121    },
1122    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1123    /// to one sister session and deliver an event to that one peer.
1124    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1125    /// can now address "the reviewer" instead of hard-coding a handle.
1126    ///
1127    /// Strategies:
1128    ///   - `round-robin` (default): per-role cursor, persisted at
1129    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1130    ///   - `first`: alphabetically-first matching sister. Deterministic.
1131    ///   - `random`: uniform random among matches. Stateless.
1132    ///
1133    /// Pinned-peers-only by construction (same posture as `broadcast`).
1134    /// Caller must already have the target sister pinned in
1135    /// `state.peers` — otherwise we can't sign + push. Run
1136    /// `wire session pair-all-local` first if the mesh isn't wired.
1137    Route {
1138        /// Role to match (operator-defined tag from `wire mesh role set`).
1139        role: String,
1140        /// `round-robin` (default), `first`, or `random`.
1141        #[arg(long, default_value = "round-robin")]
1142        strategy: String,
1143        /// Skip a specific sister handle. Repeatable.
1144        #[arg(long)]
1145        exclude: Vec<String>,
1146        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1147        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1148        #[arg(long, default_value = "claim")]
1149        kind: String,
1150        /// Body — string, `@/path` for a file, or `-` for stdin.
1151        body: String,
1152        #[arg(long)]
1153        json: bool,
1154    },
1155}
1156
1157/// v0.6.4: subcommands of `wire mesh role`.
1158#[derive(Subcommand, Debug)]
1159pub enum MeshRoleAction {
1160    /// Assign self to a role. Role is a free-form ASCII string
1161    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1162    /// the vocabulary out-of-band — common starters: `planner`,
1163    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1164    Set {
1165        role: String,
1166        #[arg(long)]
1167        json: bool,
1168    },
1169    /// Read self or a peer's role. With no arg, prints self. With a
1170    /// handle, reads from the peer's pinned agent-card.
1171    Get {
1172        peer: Option<String>,
1173        #[arg(long)]
1174        json: bool,
1175    },
1176    /// List roles across every sister session on this machine. Reads
1177    /// each session's agent-card by path — no network, no env mutation.
1178    List {
1179        #[arg(long)]
1180        json: bool,
1181    },
1182    /// Remove self from any assigned role. Re-signs the card with
1183    /// `profile.role: null`.
1184    Clear {
1185        #[arg(long)]
1186        json: bool,
1187    },
1188}
1189
1190#[derive(Subcommand, Debug)]
1191pub enum ServiceAction {
1192    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1193    /// load it. Idempotent — re-running re-bootstraps an existing service.
1194    ///
1195    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1196    /// process). Pass `--local-relay` to install the loopback relay
1197    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1198    /// transport sister-Claudes use to coordinate on the same machine
1199    /// (v0.5.17 dual-slot). The two services have distinct labels +
1200    /// log files, so you can install both.
1201    Install {
1202        /// Install the local-relay service instead of the daemon.
1203        #[arg(long)]
1204        local_relay: bool,
1205        #[arg(long)]
1206        json: bool,
1207    },
1208    /// Unload + delete the service unit. Daemon keeps running until the
1209    /// next reboot or `wire upgrade`; this only changes the boot-time
1210    /// behaviour.
1211    Uninstall {
1212        /// Uninstall the local-relay service instead of the daemon.
1213        #[arg(long)]
1214        local_relay: bool,
1215        #[arg(long)]
1216        json: bool,
1217    },
1218    /// Report whether the unit is installed + active.
1219    Status {
1220        /// Show status of the local-relay service instead of the daemon.
1221        #[arg(long)]
1222        local_relay: bool,
1223        #[arg(long)]
1224        json: bool,
1225    },
1226}
1227
1228#[derive(Subcommand, Debug)]
1229pub enum ResponderCommand {
1230    /// Publish this agent's auto-responder health.
1231    Set {
1232        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1233        status: String,
1234        /// Optional operator-facing reason.
1235        #[arg(long)]
1236        reason: Option<String>,
1237        /// Emit JSON.
1238        #[arg(long)]
1239        json: bool,
1240    },
1241    /// Read responder health for self, or for a paired peer.
1242    Get {
1243        /// Optional peer handle; omitted means this agent's own slot.
1244        peer: Option<String>,
1245        /// Emit JSON.
1246        #[arg(long)]
1247        json: bool,
1248    },
1249}
1250
1251#[derive(Subcommand, Debug)]
1252pub enum ProfileAction {
1253    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1254    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1255    /// (JSON array) and `now` (JSON object).
1256    Set {
1257        field: String,
1258        value: String,
1259        #[arg(long)]
1260        json: bool,
1261    },
1262    /// Show all profile fields. Equivalent to `wire whois`.
1263    Get {
1264        #[arg(long)]
1265        json: bool,
1266    },
1267    /// Clear a profile field.
1268    Clear {
1269        field: String,
1270        #[arg(long)]
1271        json: bool,
1272    },
1273}
1274
1275/// Entry point — parse and dispatch.
1276pub fn run() -> Result<()> {
1277    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1278    // the session registry and adopt that session's home for this
1279    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1280    // detect — `wire whoami` / `wire monitor` from a project cwd now
1281    // resolve to that project's session identity, not the machine
1282    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1283    //
1284    // MUST run before any thread spawn — call it FIRST, before
1285    // `Cli::parse` (which uses clap internals only) and before any
1286    // command dispatch (which may spawn workers).
1287    crate::session::maybe_adopt_session_wire_home("cli");
1288    let cli = Cli::parse();
1289    match cli.command {
1290        Command::Init {
1291            handle,
1292            name,
1293            relay,
1294            json,
1295        } => cmd_init(&handle, name.as_deref(), relay.as_deref(), json),
1296        Command::Status { peer, json } => {
1297            if let Some(peer) = peer {
1298                cmd_status_peer(&peer, json)
1299            } else {
1300                cmd_status(json)
1301            }
1302        }
1303        Command::Whoami {
1304            json,
1305            short,
1306            colored,
1307        } => cmd_whoami(json, short, colored),
1308        Command::Peers { json } => cmd_peers(json),
1309        Command::Send {
1310            peer,
1311            kind_or_body,
1312            body,
1313            deadline,
1314            json,
1315        } => {
1316            // P0.S: smart-positional API. `wire send peer body` =
1317            // kind=claim. `wire send peer kind body` = explicit kind.
1318            let (kind, body) = match body {
1319                Some(real_body) => (kind_or_body, real_body),
1320                None => ("claim".to_string(), kind_or_body),
1321            };
1322            cmd_send(&peer, &kind, &body, deadline.as_deref(), json)
1323        }
1324        Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1325        Command::Monitor {
1326            peer,
1327            json,
1328            include_handshake,
1329            interval_ms,
1330            replay,
1331        } => cmd_monitor(
1332            peer.as_deref(),
1333            json,
1334            include_handshake,
1335            interval_ms,
1336            replay,
1337        ),
1338        Command::Verify { path, json } => cmd_verify(&path, json),
1339        Command::Responder { command } => match command {
1340            ResponderCommand::Set {
1341                status,
1342                reason,
1343                json,
1344            } => cmd_responder_set(&status, reason.as_deref(), json),
1345            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1346        },
1347        Command::Mcp => cmd_mcp(),
1348        Command::RelayServer {
1349            bind,
1350            local_only,
1351            uds,
1352        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1353        Command::BindRelay {
1354            url,
1355            migrate_pinned,
1356            json,
1357        } => cmd_bind_relay(&url, migrate_pinned, json),
1358        Command::AddPeerSlot {
1359            handle,
1360            url,
1361            slot_id,
1362            slot_token,
1363            json,
1364        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1365        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1366        Command::Pull { json } => cmd_pull(json),
1367        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1368        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1369        Command::ForgetPeer {
1370            handle,
1371            purge,
1372            json,
1373        } => cmd_forget_peer(&handle, purge, json),
1374        Command::Daemon {
1375            interval,
1376            once,
1377            json,
1378        } => cmd_daemon(interval, once, json),
1379        Command::PairHost {
1380            relay,
1381            yes,
1382            timeout,
1383            detach,
1384            json,
1385        } => {
1386            if detach {
1387                cmd_pair_host_detach(&relay, json)
1388            } else {
1389                cmd_pair_host(&relay, yes, timeout)
1390            }
1391        }
1392        Command::PairJoin {
1393            code_phrase,
1394            relay,
1395            yes,
1396            timeout,
1397            detach,
1398            json,
1399        } => {
1400            if detach {
1401                cmd_pair_join_detach(&code_phrase, &relay, json)
1402            } else {
1403                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1404            }
1405        }
1406        Command::PairConfirm {
1407            code_phrase,
1408            digits,
1409            json,
1410        } => cmd_pair_confirm(&code_phrase, &digits, json),
1411        Command::PairList {
1412            json,
1413            watch,
1414            watch_interval,
1415        } => cmd_pair_list(json, watch, watch_interval),
1416        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1417        Command::PairWatch {
1418            code_phrase,
1419            status,
1420            timeout,
1421            json,
1422        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1423        Command::Pair {
1424            handle,
1425            code,
1426            relay,
1427            yes,
1428            timeout,
1429            no_setup,
1430            detach,
1431        } => {
1432            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1433            // the zero-paste megacommand path — `wire pair slancha-spark@
1434            // wireup.net` does add + poll-for-ack + verify in one shot. The
1435            // SAS / code-based pair flow stays available for handles without
1436            // `@` (bootstrap pairing between two boxes that don't yet share a
1437            // relay directory).
1438            if handle.contains('@') && code.is_none() {
1439                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1440            } else if detach {
1441                cmd_pair_detach(&handle, code.as_deref(), &relay)
1442            } else {
1443                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1444            }
1445        }
1446        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1447        Command::PairAccept { peer, json } => cmd_pair_accept(&peer, json),
1448        Command::PairReject { peer, json } => cmd_pair_reject(&peer, json),
1449        Command::PairListInbound { json } => cmd_pair_list_inbound(json),
1450        Command::Session(cmd) => cmd_session(cmd),
1451        Command::Identity { cmd } => cmd_identity(cmd),
1452        Command::Mesh(cmd) => cmd_mesh(cmd),
1453        Command::Invite {
1454            relay,
1455            ttl,
1456            uses,
1457            share,
1458            json,
1459        } => cmd_invite(&relay, ttl, uses, share, json),
1460        Command::Accept { url, json } => cmd_accept(&url, json),
1461        Command::Whois {
1462            handle,
1463            json,
1464            relay,
1465        } => cmd_whois(handle.as_deref(), json, relay.as_deref()),
1466        Command::Add {
1467            handle,
1468            relay,
1469            local_sister,
1470            json,
1471        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1472        Command::Up { handle, name, json } => cmd_up(&handle, name.as_deref(), json),
1473        Command::Doctor {
1474            json,
1475            recent_rejections,
1476        } => cmd_doctor(json, recent_rejections),
1477        Command::Upgrade { check, json } => cmd_upgrade(check, json),
1478        Command::Service { action } => cmd_service(action),
1479        Command::Diag { action } => cmd_diag(action),
1480        Command::Claim {
1481            nick,
1482            relay,
1483            public_url,
1484            hidden,
1485            json,
1486        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1487        Command::Profile { action } => cmd_profile(action),
1488        Command::Setup { apply } => cmd_setup(apply),
1489        Command::Reactor {
1490            on_event,
1491            peer,
1492            kind,
1493            verified_only,
1494            interval,
1495            once,
1496            dry_run,
1497            max_per_minute,
1498            max_chain_depth,
1499        } => cmd_reactor(
1500            &on_event,
1501            peer.as_deref(),
1502            kind.as_deref(),
1503            verified_only,
1504            interval,
1505            once,
1506            dry_run,
1507            max_per_minute,
1508            max_chain_depth,
1509        ),
1510        Command::Notify {
1511            interval,
1512            peer,
1513            once,
1514            json,
1515        } => cmd_notify(interval, peer.as_deref(), once, json),
1516    }
1517}
1518
1519// ---------- init ----------
1520
1521fn cmd_init(handle: &str, name: Option<&str>, relay: Option<&str>, as_json: bool) -> Result<()> {
1522    if !handle
1523        .chars()
1524        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1525    {
1526        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
1527    }
1528    if config::is_initialized()? {
1529        bail!(
1530            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1531            config::config_dir()?
1532        );
1533    }
1534
1535    config::ensure_dirs()?;
1536    let (sk_seed, pk_bytes) = generate_keypair();
1537    config::write_private_key(&sk_seed)?;
1538
1539    let card = build_agent_card(handle, &pk_bytes, name, None, None);
1540    let signed = sign_agent_card(&card, &sk_seed);
1541    config::write_agent_card(&signed)?;
1542
1543    let mut trust = empty_trust();
1544    add_self_to_trust(&mut trust, handle, &pk_bytes);
1545    config::write_trust(&trust)?;
1546
1547    let fp = fingerprint(&pk_bytes);
1548    let key_id = make_key_id(handle, &pk_bytes);
1549
1550    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
1551    let mut relay_info: Option<(String, String)> = None;
1552    if let Some(url) = relay {
1553        let normalized = url.trim_end_matches('/');
1554        let client = crate::relay_client::RelayClient::new(normalized);
1555        client.check_healthz()?;
1556        let alloc = client.allocate_slot(Some(handle))?;
1557        let mut state = config::read_relay_state()?;
1558        state["self"] = json!({
1559            "relay_url": normalized,
1560            "slot_id": alloc.slot_id.clone(),
1561            "slot_token": alloc.slot_token,
1562        });
1563        config::write_relay_state(&state)?;
1564        relay_info = Some((normalized.to_string(), alloc.slot_id));
1565    }
1566
1567    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1568    if as_json {
1569        let mut out = json!({
1570            "did": did_str.clone(),
1571            "fingerprint": fp,
1572            "key_id": key_id,
1573            "config_dir": config::config_dir()?.to_string_lossy(),
1574        });
1575        if let Some((url, slot_id)) = &relay_info {
1576            out["relay_url"] = json!(url);
1577            out["slot_id"] = json!(slot_id);
1578        }
1579        println!("{}", serde_json::to_string(&out)?);
1580    } else {
1581        println!("generated {did_str} (ed25519:{key_id})");
1582        println!(
1583            "config written to {}",
1584            config::config_dir()?.to_string_lossy()
1585        );
1586        if let Some((url, slot_id)) = &relay_info {
1587            println!("bound to relay {url} (slot {slot_id})");
1588            println!();
1589            println!(
1590                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1591            );
1592        } else {
1593            println!();
1594            println!(
1595                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1596            );
1597        }
1598    }
1599    Ok(())
1600}
1601
1602// ---------- status ----------
1603
1604fn cmd_status(as_json: bool) -> Result<()> {
1605    let initialized = config::is_initialized()?;
1606
1607    let mut summary = json!({
1608        "initialized": initialized,
1609    });
1610
1611    if initialized {
1612        let card = config::read_agent_card()?;
1613        let did = card
1614            .get("did")
1615            .and_then(Value::as_str)
1616            .unwrap_or("")
1617            .to_string();
1618        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
1619        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
1620        // legacy cards.
1621        let handle = card
1622            .get("handle")
1623            .and_then(Value::as_str)
1624            .map(str::to_string)
1625            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1626        let pk_b64 = card
1627            .get("verify_keys")
1628            .and_then(Value::as_object)
1629            .and_then(|m| m.values().next())
1630            .and_then(|v| v.get("key"))
1631            .and_then(Value::as_str)
1632            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1633        let pk_bytes = crate::signing::b64decode(pk_b64)?;
1634        summary["did"] = json!(did);
1635        summary["handle"] = json!(handle);
1636        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1637        summary["capabilities"] = card
1638            .get("capabilities")
1639            .cloned()
1640            .unwrap_or_else(|| json!([]));
1641
1642        let trust = config::read_trust()?;
1643        let relay_state_for_tier =
1644            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1645        let mut peers = Vec::new();
1646        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1647            for (peer_handle, _agent) in agents {
1648                if peer_handle == &handle {
1649                    continue; // self
1650                }
1651                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
1652                // for peers we've pinned but never received a pair_drop_ack
1653                // from, so the operator sees the "we can't send to them yet"
1654                // state instead of seeing a misleading VERIFIED.
1655                peers.push(json!({
1656                    "handle": peer_handle,
1657                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
1658                }));
1659            }
1660        }
1661        summary["peers"] = json!(peers);
1662
1663        let relay_state = config::read_relay_state()?;
1664        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
1665        if !summary["self_relay"].is_null() {
1666            // Hide slot_token from default view.
1667            if let Some(obj) = summary["self_relay"].as_object_mut() {
1668                obj.remove("slot_token");
1669            }
1670        }
1671        summary["peer_slots_count"] = json!(
1672            relay_state
1673                .get("peers")
1674                .and_then(Value::as_object)
1675                .map(|m| m.len())
1676                .unwrap_or(0)
1677        );
1678
1679        // Outbox / inbox queue depth (file count + total events)
1680        let outbox = config::outbox_dir()?;
1681        let inbox = config::inbox_dir()?;
1682        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
1683        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
1684
1685        // v0.5.19: liveness snapshot through a single helper so this
1686        // surface and `wire doctor` agree by construction. Issue #2:
1687        // doctor PASSed while status said DOWN for 25 min because each
1688        // computed liveness independently. ensure_up::daemon_liveness
1689        // is the only path now.
1690        let snap = crate::ensure_up::daemon_liveness();
1691        let mut daemon = json!({
1692            "running": snap.pidfile_alive,
1693            "pid": snap.pidfile_pid,
1694            "all_running_pids": snap.pgrep_pids,
1695            "orphans": snap.orphan_pids,
1696        });
1697        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1698            daemon["version"] = json!(d.version);
1699            daemon["bin_path"] = json!(d.bin_path);
1700            daemon["did"] = json!(d.did);
1701            daemon["relay_url"] = json!(d.relay_url);
1702            daemon["started_at"] = json!(d.started_at);
1703            daemon["schema"] = json!(d.schema);
1704            if d.version != env!("CARGO_PKG_VERSION") {
1705                daemon["version_mismatch"] = json!({
1706                    "daemon": d.version.clone(),
1707                    "cli": env!("CARGO_PKG_VERSION"),
1708                });
1709            }
1710        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
1711            daemon["pidfile_form"] = json!("legacy-int");
1712            daemon["version_mismatch"] = json!({
1713                "daemon": "<pre-0.5.11>",
1714                "cli": env!("CARGO_PKG_VERSION"),
1715            });
1716        }
1717        summary["daemon"] = daemon;
1718
1719        // Pending pair sessions — counts by status.
1720        let pending = crate::pending_pair::list_pending().unwrap_or_default();
1721        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
1722        for p in &pending {
1723            *counts.entry(p.status.clone()).or_default() += 1;
1724        }
1725        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
1726        let pending_inbound =
1727            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
1728        let inbound_handles: Vec<&str> = pending_inbound
1729            .iter()
1730            .map(|p| p.peer_handle.as_str())
1731            .collect();
1732        summary["pending_pairs"] = json!({
1733            "total": pending.len(),
1734            "by_status": counts,
1735            "inbound_count": pending_inbound.len(),
1736            "inbound_handles": inbound_handles,
1737        });
1738    }
1739
1740    if as_json {
1741        println!("{}", serde_json::to_string(&summary)?);
1742    } else if !initialized {
1743        println!("not initialized — run `wire init <handle>` first");
1744    } else {
1745        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
1746        println!(
1747            "fingerprint:   {}",
1748            summary["fingerprint"].as_str().unwrap_or("?")
1749        );
1750        println!("capabilities:  {}", summary["capabilities"]);
1751        if !summary["self_relay"].is_null() {
1752            println!(
1753                "self relay:    {} (slot {})",
1754                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
1755                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
1756            );
1757        } else {
1758            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
1759        }
1760        println!(
1761            "peers:         {}",
1762            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
1763        );
1764        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
1765            println!(
1766                "  - {:<20} tier={}",
1767                p["handle"].as_str().unwrap_or(""),
1768                p["tier"].as_str().unwrap_or("?")
1769            );
1770        }
1771        println!(
1772            "outbox:        {} file(s), {} event(s) queued",
1773            summary["outbox"]["files"].as_u64().unwrap_or(0),
1774            summary["outbox"]["events"].as_u64().unwrap_or(0)
1775        );
1776        println!(
1777            "inbox:         {} file(s), {} event(s) received",
1778            summary["inbox"]["files"].as_u64().unwrap_or(0),
1779            summary["inbox"]["events"].as_u64().unwrap_or(0)
1780        );
1781        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
1782        let daemon_pid = summary["daemon"]["pid"]
1783            .as_u64()
1784            .map(|p| p.to_string())
1785            .unwrap_or_else(|| "—".to_string());
1786        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
1787        let version_suffix = if !daemon_version.is_empty() {
1788            format!(" v{daemon_version}")
1789        } else {
1790            String::new()
1791        };
1792        println!(
1793            "daemon:        {} (pid {}{})",
1794            if daemon_running { "running" } else { "DOWN" },
1795            daemon_pid,
1796            version_suffix,
1797        );
1798        // P1.7: surface version mismatch + orphan procs loudly.
1799        if let Some(mm) = summary["daemon"].get("version_mismatch") {
1800            println!(
1801                "               !! version mismatch: daemon={} CLI={}. \
1802                 run `wire upgrade` to swap atomically.",
1803                mm["daemon"].as_str().unwrap_or("?"),
1804                mm["cli"].as_str().unwrap_or("?"),
1805            );
1806        }
1807        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
1808            && !orphans.is_empty()
1809        {
1810            let pids: Vec<String> = orphans
1811                .iter()
1812                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
1813                .collect();
1814            println!(
1815                "               !! orphan daemon process(es): pids {}. \
1816                 pgrep saw them but pidfile didn't — likely stale process from \
1817                 prior install. Multiple daemons race the relay cursor.",
1818                pids.join(", ")
1819            );
1820        }
1821        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
1822        let inbound_count = summary["pending_pairs"]["inbound_count"]
1823            .as_u64()
1824            .unwrap_or(0);
1825        if pending_total > 0 {
1826            print!("pending pairs: {pending_total}");
1827            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
1828                let parts: Vec<String> = obj
1829                    .iter()
1830                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
1831                    .collect();
1832                if !parts.is_empty() {
1833                    print!(" ({})", parts.join(", "));
1834                }
1835            }
1836            println!();
1837        } else if inbound_count == 0 {
1838            println!("pending pairs: none");
1839        }
1840        // v0.5.14: separate line for pending-inbound zero-paste requests.
1841        // Loud because each one is awaiting an operator gesture and the
1842        // capability hasn't flowed yet.
1843        if inbound_count > 0 {
1844            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
1845                .as_array()
1846                .map(|a| {
1847                    a.iter()
1848                        .filter_map(|v| v.as_str().map(str::to_string))
1849                        .collect()
1850                })
1851                .unwrap_or_default();
1852            println!(
1853                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
1854                handles.join(", "),
1855            );
1856        }
1857    }
1858    Ok(())
1859}
1860
1861fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
1862    if !dir.exists() {
1863        return Ok(json!({"files": 0, "events": 0}));
1864    }
1865    let mut files = 0usize;
1866    let mut events = 0usize;
1867    for entry in std::fs::read_dir(dir)? {
1868        let path = entry?.path();
1869        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
1870            files += 1;
1871            if let Ok(body) = std::fs::read_to_string(&path) {
1872                events += body.lines().filter(|l| !l.trim().is_empty()).count();
1873            }
1874        }
1875    }
1876    Ok(json!({"files": files, "events": events}))
1877}
1878
1879// ---------- responder health ----------
1880
1881fn responder_status_allowed(status: &str) -> bool {
1882    matches!(
1883        status,
1884        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
1885    )
1886}
1887
1888fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
1889    let state = config::read_relay_state()?;
1890    let (label, slot_info) = match peer {
1891        Some(peer) => (
1892            peer.to_string(),
1893            state
1894                .get("peers")
1895                .and_then(|p| p.get(peer))
1896                .ok_or_else(|| {
1897                    anyhow!(
1898                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
1899                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
1900                         (`wire peers` lists who you've already paired with.)"
1901                    )
1902                })?,
1903        ),
1904        None => (
1905            "self".to_string(),
1906            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
1907                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
1908            })?,
1909        ),
1910    };
1911    let relay_url = slot_info["relay_url"]
1912        .as_str()
1913        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
1914        .to_string();
1915    let slot_id = slot_info["slot_id"]
1916        .as_str()
1917        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
1918        .to_string();
1919    let slot_token = slot_info["slot_token"]
1920        .as_str()
1921        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
1922        .to_string();
1923    Ok((label, relay_url, slot_id, slot_token))
1924}
1925
1926fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
1927    if !responder_status_allowed(status) {
1928        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
1929    }
1930    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
1931    let now = time::OffsetDateTime::now_utc()
1932        .format(&time::format_description::well_known::Rfc3339)
1933        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1934    let mut record = json!({
1935        "status": status,
1936        "set_at": now,
1937    });
1938    if let Some(reason) = reason {
1939        record["reason"] = json!(reason);
1940    }
1941    if status == "online" {
1942        record["last_success_at"] = json!(now);
1943    }
1944    let client = crate::relay_client::RelayClient::new(&relay_url);
1945    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
1946    if as_json {
1947        println!("{}", serde_json::to_string(&saved)?);
1948    } else {
1949        let reason = saved
1950            .get("reason")
1951            .and_then(Value::as_str)
1952            .map(|r| format!(" — {r}"))
1953            .unwrap_or_default();
1954        println!(
1955            "responder {}{}",
1956            saved
1957                .get("status")
1958                .and_then(Value::as_str)
1959                .unwrap_or(status),
1960            reason
1961        );
1962    }
1963    Ok(())
1964}
1965
1966fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
1967    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
1968    let client = crate::relay_client::RelayClient::new(&relay_url);
1969    let health = client.responder_health_get(&slot_id, &slot_token)?;
1970    if as_json {
1971        println!(
1972            "{}",
1973            serde_json::to_string(&json!({
1974                "target": label,
1975                "responder_health": health,
1976            }))?
1977        );
1978    } else if health.is_null() {
1979        println!("{label}: responder health not reported");
1980    } else {
1981        let status = health
1982            .get("status")
1983            .and_then(Value::as_str)
1984            .unwrap_or("unknown");
1985        let reason = health
1986            .get("reason")
1987            .and_then(Value::as_str)
1988            .map(|r| format!(" — {r}"))
1989            .unwrap_or_default();
1990        let last_success = health
1991            .get("last_success_at")
1992            .and_then(Value::as_str)
1993            .map(|t| format!(" (last_success: {t})"))
1994            .unwrap_or_default();
1995        println!("{label}: {status}{reason}{last_success}");
1996    }
1997    Ok(())
1998}
1999
2000fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2001    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2002    let client = crate::relay_client::RelayClient::new(&relay_url);
2003
2004    let started = std::time::Instant::now();
2005    let transport_ok = client.healthz().unwrap_or(false);
2006    let latency_ms = started.elapsed().as_millis() as u64;
2007
2008    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2009    let now = std::time::SystemTime::now()
2010        .duration_since(std::time::UNIX_EPOCH)
2011        .map(|d| d.as_secs())
2012        .unwrap_or(0);
2013    let attention = match last_pull_at_unix {
2014        Some(last) if now.saturating_sub(last) <= 300 => json!({
2015            "status": "ok",
2016            "last_pull_at_unix": last,
2017            "age_seconds": now.saturating_sub(last),
2018            "event_count": event_count,
2019        }),
2020        Some(last) => json!({
2021            "status": "stale",
2022            "last_pull_at_unix": last,
2023            "age_seconds": now.saturating_sub(last),
2024            "event_count": event_count,
2025        }),
2026        None => json!({
2027            "status": "never_pulled",
2028            "last_pull_at_unix": Value::Null,
2029            "event_count": event_count,
2030        }),
2031    };
2032
2033    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2034    let responder = if responder_health.is_null() {
2035        json!({"status": "not_reported", "record": Value::Null})
2036    } else {
2037        json!({
2038            "status": responder_health
2039                .get("status")
2040                .and_then(Value::as_str)
2041                .unwrap_or("unknown"),
2042            "record": responder_health,
2043        })
2044    };
2045
2046    let report = json!({
2047        "peer": peer,
2048        "transport": {
2049            "status": if transport_ok { "ok" } else { "error" },
2050            "relay_url": relay_url,
2051            "latency_ms": latency_ms,
2052        },
2053        "attention": attention,
2054        "responder": responder,
2055    });
2056
2057    if as_json {
2058        println!("{}", serde_json::to_string(&report)?);
2059    } else {
2060        let transport_line = if transport_ok {
2061            format!("ok relay reachable ({latency_ms}ms)")
2062        } else {
2063            "error relay unreachable".to_string()
2064        };
2065        println!("transport      {transport_line}");
2066        match report["attention"]["status"].as_str().unwrap_or("unknown") {
2067            "ok" => println!(
2068                "attention      ok last pull {}s ago",
2069                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2070            ),
2071            "stale" => println!(
2072                "attention      stale last pull {}m ago",
2073                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2074            ),
2075            "never_pulled" => println!("attention      never pulled since relay reset"),
2076            other => println!("attention      {other}"),
2077        }
2078        if report["responder"]["status"] == "not_reported" {
2079            println!("auto-responder not reported");
2080        } else {
2081            let record = &report["responder"]["record"];
2082            let status = record
2083                .get("status")
2084                .and_then(Value::as_str)
2085                .unwrap_or("unknown");
2086            let reason = record
2087                .get("reason")
2088                .and_then(Value::as_str)
2089                .map(|r| format!(" — {r}"))
2090                .unwrap_or_default();
2091            println!("auto-responder {status}{reason}");
2092        }
2093    }
2094    Ok(())
2095}
2096
2097// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
2098
2099// ---------- whoami ----------
2100
2101/// Return the current cwd with the user's home dir abbreviated to `~/`.
2102/// Used in whoami `--short` / `--colored` output so multi-window operators
2103/// see *what project* each Claude is working in alongside the character.
2104fn current_cwd_display() -> String {
2105    let cwd = match std::env::current_dir() {
2106        Ok(c) => c,
2107        Err(_) => return String::from("?"),
2108    };
2109    if let Some(home) = dirs::home_dir()
2110        && let Ok(rel) = cwd.strip_prefix(&home)
2111    {
2112        // strip_prefix returns "" for cwd == home itself; show "~" then.
2113        let rel_str = rel.to_string_lossy();
2114        if rel_str.is_empty() {
2115            return String::from("~");
2116        }
2117        return format!("~/{}", rel_str);
2118    }
2119    cwd.to_string_lossy().into_owned()
2120}
2121
2122fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2123    if !config::is_initialized()? {
2124        bail!("not initialized — run `wire init <handle>` first");
2125    }
2126    let card = config::read_agent_card()?;
2127    let did = card
2128        .get("did")
2129        .and_then(Value::as_str)
2130        .unwrap_or("")
2131        .to_string();
2132    let handle = card
2133        .get("handle")
2134        .and_then(Value::as_str)
2135        .map(str::to_string)
2136        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2137    // v0.7.0-alpha.3: read sidecar display.json for any operator-chosen
2138    // override of nickname/emoji. Palette stays auto-derived from DID.
2139    let overrides = config::read_display_overrides().unwrap_or_default();
2140    let character = crate::character::Character::from_did_with_override(
2141        &did,
2142        overrides.nickname.as_deref(),
2143        overrides.emoji.as_deref(),
2144    );
2145
2146    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
2147    // so operators tab-flipping between multiple Claude windows see both
2148    // *who* this session is (character) and *what* it's working on (cwd).
2149    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
2150    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
2151    let cwd_display = current_cwd_display();
2152
2153    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
2154    // beyond did — these calls are hot (statusline polls ~300ms).
2155    if short {
2156        println!("{} · {}", character.short(), cwd_display);
2157        return Ok(());
2158    }
2159    if colored {
2160        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2161        return Ok(());
2162    }
2163
2164    let pk_b64 = card
2165        .get("verify_keys")
2166        .and_then(Value::as_object)
2167        .and_then(|m| m.values().next())
2168        .and_then(|v| v.get("key"))
2169        .and_then(Value::as_str)
2170        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2171    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2172    let fp = fingerprint(&pk_bytes);
2173    let key_id = make_key_id(&handle, &pk_bytes);
2174    let capabilities = card
2175        .get("capabilities")
2176        .cloned()
2177        .unwrap_or_else(|| json!(["wire/v3.1"]));
2178
2179    if as_json {
2180        let has_override = overrides.nickname.is_some() || overrides.emoji.is_some();
2181        println!(
2182            "{}",
2183            serde_json::to_string(&json!({
2184                "did": did,
2185                "handle": handle,
2186                "fingerprint": fp,
2187                "key_id": key_id,
2188                "public_key_b64": pk_b64,
2189                "capabilities": capabilities,
2190                "config_dir": config::config_dir()?.to_string_lossy(),
2191                "character": character,
2192                "character_override": has_override,
2193            }))?
2194        );
2195    } else {
2196        println!("{}", character.colored());
2197        println!("{did} (ed25519:{key_id})");
2198        println!("fingerprint: {fp}");
2199        println!("capabilities: {capabilities}");
2200    }
2201    Ok(())
2202}
2203
2204// ---------- identity (v0.7.0-alpha.3) ----------
2205
2206fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2207    match cmd {
2208        IdentityCommand::Rename {
2209            name,
2210            emoji,
2211            clear,
2212            random,
2213            json,
2214        } => cmd_identity_rename(name, emoji, clear || random, random, json),
2215        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2216        IdentityCommand::List { json } => cmd_session_list(json),
2217        IdentityCommand::Publish {
2218            nick,
2219            relay,
2220            public_url,
2221            hidden,
2222            json,
2223        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2224        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2225        IdentityCommand::Create {
2226            name,
2227            anonymous,
2228            local: _,
2229            json,
2230        } => cmd_identity_create(name.as_deref(), anonymous, json),
2231        IdentityCommand::Persist {
2232            name,
2233            as_name,
2234            json,
2235        } => cmd_identity_persist(&name, as_name.as_deref(), json),
2236        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2237    }
2238}
2239
2240/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
2241/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
2242/// paste into their shell; the identity lives there until reboot
2243/// clears /tmp. Persist promotes it to the real sessions root.
2244fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2245    if anonymous {
2246        // Generate a unique tmpdir for this anonymous identity.
2247        let rand_suffix = format!("{:08x}", rand::random::<u32>());
2248        let anon_name = name
2249            .map(crate::session::sanitize_name)
2250            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2251        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2252        std::fs::create_dir_all(&anon_root)
2253            .with_context(|| format!("creating anon root {anon_root:?}"))?;
2254        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
2255        let session_home = anon_root.join("sessions").join(&anon_name);
2256        std::fs::create_dir_all(&session_home)?;
2257        let status = run_wire_with_home(&session_home, &["init", &anon_name])?;
2258        if !status.success() {
2259            bail!("anonymous identity init failed: {status}");
2260        }
2261        // Register the anonymous name in a SIDE registry so persist
2262        // can find it later. Stored at <anon_root>/anon-marker.json.
2263        let marker = anon_root.join("anon-marker.json");
2264        std::fs::write(
2265            &marker,
2266            serde_json::to_vec_pretty(&serde_json::json!({
2267                "name": anon_name,
2268                "session_home": session_home.to_string_lossy(),
2269                "created_at": time::OffsetDateTime::now_utc()
2270                    .format(&time::format_description::well_known::Rfc3339)
2271                    .unwrap_or_default(),
2272                "kind": "anonymous",
2273            }))?,
2274        )?;
2275        let card = serde_json::from_slice::<Value>(&std::fs::read(
2276            session_home
2277                .join("config")
2278                .join("wire")
2279                .join("agent-card.json"),
2280        )?)?;
2281        let did = card
2282            .get("did")
2283            .and_then(Value::as_str)
2284            .unwrap_or("")
2285            .to_string();
2286        if as_json {
2287            println!(
2288                "{}",
2289                serde_json::to_string(&json!({
2290                    "kind": "anonymous",
2291                    "name": anon_name,
2292                    "did": did,
2293                    "session_home": session_home.to_string_lossy(),
2294                    "anon_root": anon_root.to_string_lossy(),
2295                }))?
2296            );
2297        } else {
2298            println!("created anonymous identity `{anon_name}` ({did})");
2299            println!(
2300                "  session_home: {} (dies on reboot — /tmp)",
2301                session_home.display()
2302            );
2303            println!();
2304            println!("activate in this shell:");
2305            println!("  export WIRE_HOME={}", session_home.display());
2306            println!();
2307            println!("promote to persistent later with:");
2308            println!("  wire identity persist {anon_name}");
2309        }
2310        return Ok(());
2311    }
2312    // --local (or default): delegate to existing session new flow.
2313    let name_arg = name.map(|s| s.to_string());
2314    cmd_session_new(
2315        name_arg.as_deref(),
2316        "https://wireup.net",
2317        false,
2318        "http://127.0.0.1:8771",
2319        false,
2320        None,
2321        false,
2322        None,
2323        true, // no_daemon: identity create just allocates the identity, no daemon
2324        true, // local_only: explicit lifecycle
2325        as_json,
2326    )
2327}
2328
2329/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
2330/// tmpdir to the persistent sessions root + registers in the cwd map.
2331fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2332    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
2333    let temp = std::env::temp_dir();
2334    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2335    for entry in std::fs::read_dir(&temp)?.flatten() {
2336        let path = entry.path();
2337        if !path
2338            .file_name()
2339            .and_then(|s| s.to_str())
2340            .map(|s| s.starts_with("wire-anon-"))
2341            .unwrap_or(false)
2342        {
2343            continue;
2344        }
2345        let marker = path.join("anon-marker.json");
2346        if let Ok(bytes) = std::fs::read(&marker)
2347            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2348            && json.get("name").and_then(Value::as_str) == Some(name)
2349        {
2350            let session_home = json
2351                .get("session_home")
2352                .and_then(Value::as_str)
2353                .map(std::path::PathBuf::from)
2354                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2355            found = Some((path, session_home));
2356            break;
2357        }
2358    }
2359    let (anon_root, anon_session_home) = found.ok_or_else(|| {
2360        anyhow!(
2361            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2362             run `wire identity list` to see available identities"
2363        )
2364    })?;
2365
2366    let new_name = as_name.unwrap_or(name);
2367    let new_session_home = crate::session::session_dir(new_name)?;
2368    if new_session_home.exists() {
2369        bail!(
2370            "target session `{new_name}` already exists at {new_session_home:?} — \
2371             pick a different name with --as <new-name>"
2372        );
2373    }
2374
2375    // Move the session dir from tmpdir to persistent root.
2376    if let Some(parent) = new_session_home.parent() {
2377        std::fs::create_dir_all(parent)?;
2378    }
2379    std::fs::rename(&anon_session_home, &new_session_home)
2380        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2381
2382    // Clean up the (now-empty) anon root + marker.
2383    let _ = std::fs::remove_dir_all(&anon_root);
2384
2385    // Register cwd → new_name (operator may have cd'd elsewhere; use the
2386    // session_home's grandparent as the conceptual "cwd" if no other).
2387    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2388    let cwd_key = cwd.to_string_lossy().into_owned();
2389    let new_name_for_reg = new_name.to_string();
2390    if let Err(e) = crate::session::update_registry(|reg| {
2391        reg.by_cwd.insert(cwd_key, new_name_for_reg);
2392        Ok(())
2393    }) {
2394        eprintln!("wire identity persist: failed to update registry: {e:#}");
2395    }
2396
2397    if as_json {
2398        println!(
2399            "{}",
2400            serde_json::to_string(&json!({
2401                "kind": "persisted",
2402                "from_name": name,
2403                "to_name": new_name,
2404                "session_home": new_session_home.to_string_lossy(),
2405            }))?
2406        );
2407    } else {
2408        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2409        println!(
2410            "  session_home: {} (survives reboot)",
2411            new_session_home.display()
2412        );
2413        println!("  registered cwd: {}", cwd.display());
2414    }
2415    Ok(())
2416}
2417
2418/// v0.7.0-alpha.20: demote federation → local. Removes the federation
2419/// slot binding from relay.json (and the legacy top-level fields). Keeps
2420/// the keypair + agent-card so re-publish later just calls `wire identity
2421/// publish` again. local → anonymous is NOT supported; destroy + recreate
2422/// is the safer path for that step-down.
2423fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2424    let sessions = crate::session::list_sessions()?;
2425    let session = sessions
2426        .iter()
2427        .find(|s| s.name == name)
2428        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2429    let relay_state_path = session
2430        .home_dir
2431        .join("config")
2432        .join("wire")
2433        .join("relay.json");
2434    if !relay_state_path.exists() {
2435        bail!("session `{name}` has no relay state — already demoted?");
2436    }
2437    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2438    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2439    let had_fed = self_obj
2440        .get("relay_url")
2441        .and_then(Value::as_str)
2442        .map(|u| {
2443            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2444        })
2445        .unwrap_or(false);
2446    if !had_fed {
2447        if as_json {
2448            println!(
2449                "{}",
2450                serde_json::to_string(
2451                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2452                )?
2453            );
2454        } else {
2455            println!("session `{name}` has no federation slot — nothing to demote");
2456        }
2457        return Ok(());
2458    }
2459    // Strip federation: remove top-level relay_url/slot_id/slot_token,
2460    // remove federation-scope entries from endpoints[].
2461    if let Some(self_mut) = state
2462        .as_object_mut()
2463        .and_then(|m| m.get_mut("self"))
2464        .and_then(|s| s.as_object_mut())
2465    {
2466        self_mut.remove("relay_url");
2467        self_mut.remove("slot_id");
2468        self_mut.remove("slot_token");
2469        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2470            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2471        }
2472    }
2473    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2474
2475    if as_json {
2476        println!(
2477            "{}",
2478            serde_json::to_string(
2479                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2480            )?
2481        );
2482    } else {
2483        println!("demoted `{name}` from federation → local");
2484        println!("  relay slot binding removed; keypair + agent-card retained");
2485        println!("  re-publish with `wire identity publish <nick>`");
2486    }
2487    Ok(())
2488}
2489
2490fn cmd_identity_rename(
2491    name: Option<String>,
2492    emoji: Option<String>,
2493    clear: bool,
2494    random_announce: bool,
2495    as_json: bool,
2496) -> Result<()> {
2497    if !config::is_initialized()? {
2498        bail!("not initialized — run `wire init <handle>` first");
2499    }
2500
2501    // Read DID once for character derivation in the response.
2502    let card = config::read_agent_card()?;
2503    let did = card
2504        .get("did")
2505        .and_then(Value::as_str)
2506        .unwrap_or("")
2507        .to_string();
2508
2509    let new_overrides = if clear {
2510        config::DisplayOverrides::default()
2511    } else {
2512        // Merge: keep existing fields if not explicitly provided.
2513        let mut existing = config::read_display_overrides().unwrap_or_default();
2514        if let Some(n) = name {
2515            // v0.7.0-alpha.8 (review-fix #1): sanitize at write time and
2516            // refuse anything that fully reduces to empty (operator typed
2517            // only control chars / escape sequences). Defense against
2518            // self-pwn AND silent-no-op writes.
2519            let cleaned = crate::character::sanitize_display_text(&n);
2520            if cleaned.is_empty() {
2521                bail!(
2522                    "nickname `{n:?}` is empty after stripping control characters — pick a name with printable codepoints (max {} chars).",
2523                    crate::character::MAX_DISPLAY_CHARS
2524                );
2525            }
2526            if cleaned != n {
2527                eprintln!(
2528                    "wire identity rename: stripped control characters from nickname → `{cleaned}`"
2529                );
2530            }
2531            existing.nickname = Some(cleaned);
2532        }
2533        if let Some(e) = emoji {
2534            let cleaned = crate::character::sanitize_display_text(&e);
2535            if cleaned.is_empty() {
2536                bail!(
2537                    "emoji `{e:?}` is empty after stripping control characters — pick a printable emoji glyph."
2538                );
2539            }
2540            if cleaned != e {
2541                eprintln!(
2542                    "wire identity rename: stripped control characters from emoji → `{cleaned}`"
2543                );
2544            }
2545            existing.emoji = Some(cleaned);
2546        }
2547        existing
2548    };
2549
2550    // If clearing AND no overrides existed AND no flags given, refuse so we
2551    // don't silently no-op. Random implies clear with announcement.
2552    let no_fields_provided = new_overrides.nickname.is_none()
2553        && new_overrides.emoji.is_none()
2554        && !clear
2555        && !random_announce;
2556    if no_fields_provided {
2557        bail!("nothing to do — pass --name, --emoji, --clear, or --random");
2558    }
2559
2560    config::write_display_overrides(&new_overrides)?;
2561
2562    // v0.7.0-alpha.6: publish the override on the agent-card so federated
2563    // peers see what we call ourselves, not just the DID-hash default.
2564    // Re-signs the card with the same private key the rest of the identity
2565    // uses. Backward compat: peers with old wire versions ignore the
2566    // unknown `display` field, fall back to auto-derived.
2567    //
2568    // v0.7.0-alpha.12 (review-fix #134): also push the re-signed card
2569    // back to the federation relay so .well-known/wire/agent serves the
2570    // updated card. Pre-fix wrote the local card only; federated peers
2571    // resolving the handle saw the OLD (pre-rename) card. Best-effort —
2572    // failures log to stderr but don't bail (local rename still useful).
2573    let signed_card = {
2574        let mut card = config::read_agent_card()?;
2575        if let Some(card_obj) = card.as_object_mut() {
2576            // Strip prior signature; we'll re-sign over the new canonical
2577            // form including (or excluding) the display field.
2578            card_obj.remove("signature");
2579            if new_overrides.nickname.is_none() && new_overrides.emoji.is_none() {
2580                card_obj.remove("display");
2581            } else {
2582                let mut display = serde_json::Map::new();
2583                if let Some(n) = &new_overrides.nickname {
2584                    display.insert("nickname".into(), Value::String(n.clone()));
2585                }
2586                if let Some(e) = &new_overrides.emoji {
2587                    display.insert("emoji".into(), Value::String(e.clone()));
2588                }
2589                card_obj.insert("display".into(), Value::Object(display));
2590            }
2591        }
2592        let sk_seed = config::read_private_key()?;
2593        let signed = crate::agent_card::sign_agent_card(&card, &sk_seed);
2594        config::write_agent_card(&signed)?;
2595        signed
2596    };
2597
2598    // Re-publish to federation relay if we're bound. Walks the relay_state
2599    // self endpoints — pushes the updated card to whichever federation
2600    // relay holds our claimed handle. Local-only sessions skip silently.
2601    if let Ok(state) = config::read_relay_state() {
2602        let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2603        let fed_url = self_obj.get("relay_url").and_then(Value::as_str);
2604        let fed_slot_id = self_obj.get("slot_id").and_then(Value::as_str);
2605        let fed_slot_token = self_obj.get("slot_token").and_then(Value::as_str);
2606        if let (Some(url), Some(slot_id), Some(slot_token)) = (fed_url, fed_slot_id, fed_slot_token)
2607        {
2608            // Skip loopback / LAN relays (those don't publish handles to a
2609            // public phonebook — they're local-only mode).
2610            let is_publishable = url.starts_with("https://")
2611                || (url.starts_with("http://")
2612                    && !url.contains("127.0.0.1")
2613                    && !url.contains("localhost"));
2614            if is_publishable {
2615                let nick_for_claim = signed_card
2616                    .get("handle")
2617                    .and_then(Value::as_str)
2618                    .map(str::to_string);
2619                if let Some(nick) = nick_for_claim {
2620                    let client = crate::relay_client::RelayClient::new(url);
2621                    match client.handle_claim_v2(
2622                        &nick,
2623                        slot_id,
2624                        slot_token,
2625                        None,
2626                        &signed_card,
2627                        None,
2628                    ) {
2629                        Ok(_) => {
2630                            eprintln!("wire identity rename: re-published updated card to {url}");
2631                        }
2632                        Err(e) => {
2633                            eprintln!(
2634                                "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"
2635                            );
2636                        }
2637                    }
2638                }
2639            }
2640        }
2641    }
2642
2643    if random_announce {
2644        eprintln!(
2645            "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)."
2646        );
2647    }
2648
2649    let character = crate::character::Character::from_did_with_override(
2650        &did,
2651        new_overrides.nickname.as_deref(),
2652        new_overrides.emoji.as_deref(),
2653    );
2654
2655    if as_json {
2656        println!(
2657            "{}",
2658            serde_json::to_string(&json!({
2659                "did": did,
2660                "character": character,
2661                "overrides": new_overrides,
2662            }))?
2663        );
2664    } else {
2665        println!("renamed → {}", character.colored());
2666        eprintln!("  · palette stays DID-derived (sticky color across renames)");
2667        eprintln!(
2668            "  · re-published to your federation relay (if bound); future federation lookups serve \
2669             the updated card. Existing pinned peers have a cached card from pair-time and won't \
2670             see the new name until they re-pair OR fetch your card fresh."
2671        );
2672    }
2673    Ok(())
2674}
2675
2676// ---------- peers ----------
2677
2678/// P0.Y (0.5.11): effective tier shown to operators. `wire add` pins a
2679/// peer's card into trust at VERIFIED immediately, but the bilateral pin
2680/// isn't complete until that peer's `pair_drop_ack` arrives carrying their
2681/// slot_token. Until then we CAN'T send to them. Displaying VERIFIED is
2682/// misleading — spark observed this in real usage.
2683///
2684/// Effective rules:
2685///   trust.tier == VERIFIED + relay_state.peers[h].slot_token empty -> "PENDING_ACK"
2686///   otherwise -> raw trust tier (UNTRUSTED / VERIFIED / etc.)
2687///
2688/// Strictly a display concern — trust state machine itself is untouched
2689/// so existing promote/demote logic still works.
2690fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2691    let raw = crate::trust::get_tier(trust, handle);
2692    if raw != "VERIFIED" {
2693        return raw.to_string();
2694    }
2695    let token = relay_state
2696        .get("peers")
2697        .and_then(|p| p.get(handle))
2698        .and_then(|p| p.get("slot_token"))
2699        .and_then(Value::as_str)
2700        .unwrap_or("");
2701    if token.is_empty() {
2702        "PENDING_ACK".to_string()
2703    } else {
2704        raw.to_string()
2705    }
2706}
2707
2708fn cmd_peers(as_json: bool) -> Result<()> {
2709    let trust = config::read_trust()?;
2710    let agents = trust
2711        .get("agents")
2712        .and_then(Value::as_object)
2713        .cloned()
2714        .unwrap_or_default();
2715    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2716
2717    let mut self_did: Option<String> = None;
2718    if let Ok(card) = config::read_agent_card() {
2719        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2720    }
2721
2722    let mut peers = Vec::new();
2723    for (handle, agent) in agents.iter() {
2724        let did = agent
2725            .get("did")
2726            .and_then(Value::as_str)
2727            .unwrap_or("")
2728            .to_string();
2729        if Some(did.as_str()) == self_did.as_deref() {
2730            continue; // skip self-attestation
2731        }
2732        let tier = effective_peer_tier(&trust, &relay_state, handle);
2733        let capabilities = agent
2734            .get("card")
2735            .and_then(|c| c.get("capabilities"))
2736            .cloned()
2737            .unwrap_or_else(|| json!([]));
2738        // v0.7.0-alpha.6: prefer peer's published character override
2739        // (display.nickname / display.emoji on their pinned agent-card).
2740        // Falls back to auto-derived if peer hasn't renamed themselves
2741        // OR runs an older wire that doesn't publish the field.
2742        let character = if did.is_empty() {
2743            None
2744        } else {
2745            let card_obj = agent.get("card");
2746            Some(match card_obj {
2747                Some(card) => crate::character::Character::from_card(card),
2748                None => crate::character::Character::from_did(&did),
2749            })
2750        };
2751        peers.push(json!({
2752            "handle": handle,
2753            "did": did,
2754            "tier": tier,
2755            "capabilities": capabilities,
2756            "character": character,
2757        }));
2758    }
2759
2760    if as_json {
2761        println!("{}", serde_json::to_string(&peers)?);
2762    } else if peers.is_empty() {
2763        println!("no peers pinned (run `wire join <code>` to pair)");
2764    } else {
2765        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
2766        // computed above (from peer's agent-card, honoring override) so
2767        // text and JSON output never diverge. Pre-alpha.8 the text loop
2768        // recomputed via Character::from_did (no override) — operators
2769        // saw different identities depending on --json flag.
2770        for p in &peers {
2771            let char_json = &p["character"];
2772            let (colored_char, plain_len): (String, usize) = match char_json {
2773                serde_json::Value::Null => ("?".to_string(), 1),
2774                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2775                    Ok(c) => {
2776                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
2777                        (c.colored(), plain)
2778                    }
2779                    Err(_) => ("?".to_string(), 1),
2780                },
2781            };
2782            let pad = 22usize.saturating_sub(plain_len);
2783            println!(
2784                "{}{}  {:<20} {:<10} {}",
2785                colored_char,
2786                " ".repeat(pad),
2787                p["handle"].as_str().unwrap_or(""),
2788                p["tier"].as_str().unwrap_or(""),
2789                p["did"].as_str().unwrap_or(""),
2790            );
2791        }
2792    }
2793    Ok(())
2794}
2795
2796// ---------- send ----------
2797
2798/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
2799///
2800/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
2801/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
2802/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
2803/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
2804/// hasn't crossed two heartbeats means probably degraded.
2805fn maybe_warn_peer_attentiveness(peer: &str) {
2806    let state = match config::read_relay_state() {
2807        Ok(s) => s,
2808        Err(_) => return,
2809    };
2810    let p = state.get("peers").and_then(|p| p.get(peer));
2811    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2812        Some(s) if !s.is_empty() => s,
2813        _ => return,
2814    };
2815    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2816        Some(s) if !s.is_empty() => s,
2817        _ => return,
2818    };
2819    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
2820        Some(s) if !s.is_empty() => s.to_string(),
2821        _ => match state
2822            .get("self")
2823            .and_then(|s| s.get("relay_url"))
2824            .and_then(Value::as_str)
2825        {
2826            Some(s) if !s.is_empty() => s.to_string(),
2827            _ => return,
2828        },
2829    };
2830    let client = crate::relay_client::RelayClient::new(&relay_url);
2831    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
2832        Ok(t) => t,
2833        Err(_) => return,
2834    };
2835    let now = std::time::SystemTime::now()
2836        .duration_since(std::time::UNIX_EPOCH)
2837        .map(|d| d.as_secs())
2838        .unwrap_or(0);
2839    match last_pull {
2840        None => {
2841            eprintln!(
2842                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
2843            );
2844        }
2845        Some(t) if now.saturating_sub(t) > 300 => {
2846            let mins = now.saturating_sub(t) / 60;
2847            eprintln!(
2848                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
2849            );
2850        }
2851        _ => {}
2852    }
2853}
2854
2855pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
2856    let trimmed = input.trim();
2857    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
2858    {
2859        return Ok(trimmed.to_string());
2860    }
2861    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
2862    let n: i64 = amount
2863        .parse()
2864        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
2865    if n <= 0 {
2866        bail!("deadline duration must be positive: {input:?}");
2867    }
2868    let duration = match unit {
2869        "m" => time::Duration::minutes(n),
2870        "h" => time::Duration::hours(n),
2871        "d" => time::Duration::days(n),
2872        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
2873    };
2874    Ok((time::OffsetDateTime::now_utc() + duration)
2875        .format(&time::format_description::well_known::Rfc3339)
2876        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
2877}
2878
2879fn cmd_send(
2880    peer: &str,
2881    kind: &str,
2882    body_arg: &str,
2883    deadline: Option<&str>,
2884    as_json: bool,
2885) -> Result<()> {
2886    if !config::is_initialized()? {
2887        bail!("not initialized — run `wire init <handle>` first");
2888    }
2889    let peer_in = crate::agent_card::bare_handle(peer).to_string();
2890    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
2891    // match wins; nickname (DID-hash auto-derived) is the fallback.
2892    // Ambiguous nicknames (two pinned peers DID-hash to the same
2893    // adj-noun pair) fail loudly with disambiguation; unknown handles
2894    // pass through (matches existing `wire send` semantics — queue
2895    // first, deliver best-effort).
2896    let peer = match resolve_peer_handle(&peer_in) {
2897        Ok(Some(resolved)) if resolved != peer_in => {
2898            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
2899            resolved
2900        }
2901        Ok(Some(canonical)) => canonical, // exact handle match
2902        Ok(None) => peer_in,              // unknown — pass through, downstream errors
2903        Err(ResolveError::Ambiguous(candidates)) => bail!(
2904            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
2905             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
2906            candidates.len(),
2907            candidates.join(", ")
2908        ),
2909        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
2910    };
2911    let peer = peer.as_str();
2912    let sk_seed = config::read_private_key()?;
2913    let card = config::read_agent_card()?;
2914    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
2915    let handle = crate::agent_card::display_handle_from_did(did).to_string();
2916    let pk_b64 = card
2917        .get("verify_keys")
2918        .and_then(Value::as_object)
2919        .and_then(|m| m.values().next())
2920        .and_then(|v| v.get("key"))
2921        .and_then(Value::as_str)
2922        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2923    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2924
2925    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
2926    // P0.S (0.5.11): stdin support lets shells pipe in long content
2927    // without quoting/escaping ceremony, and supports heredocs naturally:
2928    //   wire send peer - <<EOF ... EOF
2929    let body_value: Value = if body_arg == "-" {
2930        use std::io::Read;
2931        let mut raw = String::new();
2932        std::io::stdin()
2933            .read_to_string(&mut raw)
2934            .with_context(|| "reading body from stdin")?;
2935        // Try parsing as JSON first; fall back to string literal for
2936        // plain-text bodies.
2937        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
2938    } else if let Some(path) = body_arg.strip_prefix('@') {
2939        let raw =
2940            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
2941        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
2942    } else {
2943        Value::String(body_arg.to_string())
2944    };
2945
2946    let kind_id = parse_kind(kind)?;
2947
2948    let now = time::OffsetDateTime::now_utc()
2949        .format(&time::format_description::well_known::Rfc3339)
2950        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2951
2952    let mut event = json!({
2953        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
2954        "timestamp": now,
2955        "from": did,
2956        "to": format!("did:wire:{peer}"),
2957        "type": kind,
2958        "kind": kind_id,
2959        "body": body_value,
2960    });
2961    if let Some(deadline) = deadline {
2962        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
2963    }
2964    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
2965    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
2966
2967    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
2968    // coords in relay-state and ask the relay how recently the peer pulled.
2969    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
2970    // Never blocks the send — the event still queues to outbox.
2971    maybe_warn_peer_attentiveness(peer);
2972
2973    // For now we append to outbox JSONL and rely on a future daemon to push
2974    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
2975    // Append goes through `config::append_outbox_record` which holds a per-
2976    // path mutex so concurrent senders cannot interleave bytes mid-line.
2977    let line = serde_json::to_vec(&signed)?;
2978    let outbox = config::append_outbox_record(peer, &line)?;
2979
2980    if as_json {
2981        println!(
2982            "{}",
2983            serde_json::to_string(&json!({
2984                "event_id": event_id,
2985                "status": "queued",
2986                "peer": peer,
2987                "outbox": outbox.to_string_lossy(),
2988            }))?
2989        );
2990    } else {
2991        println!(
2992            "queued event {event_id} → {peer} (outbox: {})",
2993            outbox.display()
2994        );
2995    }
2996    Ok(())
2997}
2998
2999fn parse_kind(s: &str) -> Result<u32> {
3000    if let Ok(n) = s.parse::<u32>() {
3001        return Ok(n);
3002    }
3003    for (id, name) in crate::signing::kinds() {
3004        if *name == s {
3005            return Ok(*id);
3006        }
3007    }
3008    // Unknown name — default to kind 1 (decision) for v0.1.
3009    Ok(1)
3010}
3011
3012// ---------- tail ----------
3013
3014fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3015    let inbox = config::inbox_dir()?;
3016    if !inbox.exists() {
3017        if !as_json {
3018            eprintln!("no inbox yet — daemon hasn't run, or no events received");
3019        }
3020        return Ok(());
3021    }
3022    let trust = config::read_trust()?;
3023    let mut count = 0usize;
3024
3025    let entries: Vec<_> = std::fs::read_dir(&inbox)?
3026        .filter_map(|e| e.ok())
3027        .map(|e| e.path())
3028        .filter(|p| {
3029            p.extension().map(|x| x == "jsonl").unwrap_or(false)
3030                && match peer {
3031                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3032                    None => true,
3033                }
3034        })
3035        .collect();
3036
3037    for path in entries {
3038        let body = std::fs::read_to_string(&path)?;
3039        for line in body.lines() {
3040            let event: Value = match serde_json::from_str(line) {
3041                Ok(v) => v,
3042                Err(_) => continue,
3043            };
3044            let verified = verify_message_v31(&event, &trust).is_ok();
3045            if as_json {
3046                let mut event_with_meta = event.clone();
3047                if let Some(obj) = event_with_meta.as_object_mut() {
3048                    obj.insert("verified".into(), json!(verified));
3049                }
3050                println!("{}", serde_json::to_string(&event_with_meta)?);
3051            } else {
3052                let ts = event
3053                    .get("timestamp")
3054                    .and_then(Value::as_str)
3055                    .unwrap_or("?");
3056                let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3057                let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3058                let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3059                let summary = event
3060                    .get("body")
3061                    .map(|b| match b {
3062                        Value::String(s) => s.clone(),
3063                        _ => b.to_string(),
3064                    })
3065                    .unwrap_or_default();
3066                let mark = if verified { "✓" } else { "✗" };
3067                let deadline = event
3068                    .get("time_sensitive_until")
3069                    .and_then(Value::as_str)
3070                    .map(|d| format!(" deadline: {d}"))
3071                    .unwrap_or_default();
3072                println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3073            }
3074            count += 1;
3075            if limit > 0 && count >= limit {
3076                return Ok(());
3077            }
3078        }
3079    }
3080    Ok(())
3081}
3082
3083// ---------- monitor (live-tail across all peers, harness-friendly) ----------
3084
3085/// Events filtered out of `wire monitor` by default — pair handshake +
3086/// liveness pings. Operators almost never want these surfaced; an explicit
3087/// `--include-handshake` brings them back.
3088fn monitor_is_noise_kind(kind: &str) -> bool {
3089    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3090}
3091
3092/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
3093/// full structured event for tooling consumption; the plain form is a tight
3094/// one-line summary suitable as a harness stream-watcher notification.
3095fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3096    if as_json {
3097        Ok(serde_json::to_string(e)?)
3098    } else {
3099        let eid_short: String = e.event_id.chars().take(12).collect();
3100        let body = e.body_preview.replace('\n', " ");
3101        let ts: String = e.timestamp.chars().take(19).collect();
3102        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3103    }
3104}
3105
3106/// `wire monitor` — long-running line-per-event stream of new inbox events.
3107///
3108/// Built for agent harnesses that have an "every stdout line is a chat
3109/// notification" stream watcher (Claude Code Monitor tool, etc.). One
3110/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
3111/// | python parse | grep -v pair_drop` pipeline operators improvise on day
3112/// one of every wire session.
3113///
3114/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
3115/// pure handshake / liveness noise that operators almost never want
3116/// surfaced. Pass `--include-handshake` if you do.
3117///
3118/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
3119/// doesn't drown the operator in replay), with optional `--replay N` to
3120/// emit the last N events first.
3121fn cmd_monitor(
3122    peer_filter: Option<&str>,
3123    as_json: bool,
3124    include_handshake: bool,
3125    interval_ms: u64,
3126    replay: usize,
3127) -> Result<()> {
3128    let inbox_dir = config::inbox_dir()?;
3129    if !inbox_dir.exists() && !as_json {
3130        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3131    }
3132    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
3133
3134    // Optional replay — read existing files and emit the last `replay` events
3135    // (post-filter) before going live. Useful when the harness restarts and
3136    // wants recent context.
3137    if replay > 0 && inbox_dir.exists() {
3138        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3139        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3140            let path = entry.path();
3141            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3142                continue;
3143            }
3144            let peer = match path.file_stem().and_then(|s| s.to_str()) {
3145                Some(s) => s.to_string(),
3146                None => continue,
3147            };
3148            if let Some(filter) = peer_filter
3149                && peer != filter
3150            {
3151                continue;
3152            }
3153            let body = std::fs::read_to_string(&path).unwrap_or_default();
3154            for line in body.lines() {
3155                let line = line.trim();
3156                if line.is_empty() {
3157                    continue;
3158                }
3159                let signed: Value = match serde_json::from_str(line) {
3160                    Ok(v) => v,
3161                    Err(_) => continue,
3162                };
3163                let ev = crate::inbox_watch::InboxEvent::from_signed(
3164                    &peer, signed, /* verified */ true,
3165                );
3166                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3167                    continue;
3168                }
3169                all.push(ev);
3170            }
3171        }
3172        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
3173        // chronological for same-zoned timestamps).
3174        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3175        let start = all.len().saturating_sub(replay);
3176        for ev in &all[start..] {
3177            println!("{}", monitor_render(ev, as_json)?);
3178        }
3179        use std::io::Write;
3180        std::io::stdout().flush().ok();
3181    }
3182
3183    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
3184    // the first poll only returns events that arrived AFTER startup.
3185    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3186    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3187
3188    loop {
3189        let events = w.poll()?;
3190        let mut wrote = false;
3191        for ev in events {
3192            if let Some(filter) = peer_filter
3193                && ev.peer != filter
3194            {
3195                continue;
3196            }
3197            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3198                continue;
3199            }
3200            println!("{}", monitor_render(&ev, as_json)?);
3201            wrote = true;
3202        }
3203        if wrote {
3204            use std::io::Write;
3205            std::io::stdout().flush().ok();
3206        }
3207        std::thread::sleep(sleep_dur);
3208    }
3209}
3210
3211#[cfg(test)]
3212mod tier_tests {
3213    use super::*;
3214    use serde_json::json;
3215
3216    fn trust_with(handle: &str, tier: &str) -> Value {
3217        json!({
3218            "version": 1,
3219            "agents": {
3220                handle: {
3221                    "tier": tier,
3222                    "did": format!("did:wire:{handle}"),
3223                    "card": {"capabilities": ["wire/v3.1"]}
3224                }
3225            }
3226        })
3227    }
3228
3229    #[test]
3230    fn pending_ack_when_verified_but_no_slot_token() {
3231        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
3232        // slot_token hasn't arrived yet. Display PENDING_ACK so the
3233        // operator knows wire send won't work yet.
3234        let trust = trust_with("willard", "VERIFIED");
3235        let relay_state = json!({
3236            "peers": {
3237                "willard": {
3238                    "relay_url": "https://relay",
3239                    "slot_id": "abc",
3240                    "slot_token": "",
3241                }
3242            }
3243        });
3244        assert_eq!(
3245            effective_peer_tier(&trust, &relay_state, "willard"),
3246            "PENDING_ACK"
3247        );
3248    }
3249
3250    #[test]
3251    fn verified_when_slot_token_present() {
3252        let trust = trust_with("willard", "VERIFIED");
3253        let relay_state = json!({
3254            "peers": {
3255                "willard": {
3256                    "relay_url": "https://relay",
3257                    "slot_id": "abc",
3258                    "slot_token": "tok123",
3259                }
3260            }
3261        });
3262        assert_eq!(
3263            effective_peer_tier(&trust, &relay_state, "willard"),
3264            "VERIFIED"
3265        );
3266    }
3267
3268    #[test]
3269    fn raw_tier_passes_through_for_non_verified() {
3270        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
3271        // UNTRUSTED regardless of slot_token state.
3272        let trust = trust_with("willard", "UNTRUSTED");
3273        let relay_state = json!({
3274            "peers": {"willard": {"slot_token": ""}}
3275        });
3276        assert_eq!(
3277            effective_peer_tier(&trust, &relay_state, "willard"),
3278            "UNTRUSTED"
3279        );
3280    }
3281
3282    #[test]
3283    fn pending_ack_when_relay_state_missing_peer() {
3284        // After wire add, trust gets updated BEFORE relay_state.peers does.
3285        // If relay_state has no entry for the peer at all, the operator
3286        // still hasn't completed the bilateral pin — show PENDING_ACK.
3287        let trust = trust_with("willard", "VERIFIED");
3288        let relay_state = json!({"peers": {}});
3289        assert_eq!(
3290            effective_peer_tier(&trust, &relay_state, "willard"),
3291            "PENDING_ACK"
3292        );
3293    }
3294}
3295
3296#[cfg(test)]
3297mod monitor_tests {
3298    use super::*;
3299    use crate::inbox_watch::InboxEvent;
3300    use serde_json::Value;
3301
3302    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
3303        InboxEvent {
3304            peer: peer.to_string(),
3305            event_id: "abcd1234567890ef".to_string(),
3306            kind: kind.to_string(),
3307            body_preview: body.to_string(),
3308            verified: true,
3309            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
3310            raw: Value::Null,
3311        }
3312    }
3313
3314    #[test]
3315    fn monitor_filter_drops_handshake_kinds_by_default() {
3316        // The whole point: pair_drop / pair_drop_ack / heartbeat are
3317        // protocol noise. If they leak into the operator's chat stream by
3318        // default, the recipe is useless ("wire monitor talks too much,
3319        // disabled it"). Burn this rule in.
3320        assert!(monitor_is_noise_kind("pair_drop"));
3321        assert!(monitor_is_noise_kind("pair_drop_ack"));
3322        assert!(monitor_is_noise_kind("heartbeat"));
3323
3324        // Real-payload kinds — operator wants every one.
3325        assert!(!monitor_is_noise_kind("claim"));
3326        assert!(!monitor_is_noise_kind("decision"));
3327        assert!(!monitor_is_noise_kind("ack"));
3328        assert!(!monitor_is_noise_kind("request"));
3329        assert!(!monitor_is_noise_kind("note"));
3330        // Unknown future kinds shouldn't be filtered as noise either —
3331        // operator probably wants to see something they don't recognise,
3332        // not have it silently dropped (the P0.1 lesson at the UX layer).
3333        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
3334    }
3335
3336    #[test]
3337    fn monitor_render_plain_is_one_short_line() {
3338        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
3339        let line = monitor_render(&e, false).unwrap();
3340        // Must be single-line.
3341        assert!(!line.contains('\n'), "render must be one line: {line}");
3342        // Must include peer, kind, body fragment, short event_id.
3343        assert!(line.contains("willard"));
3344        assert!(line.contains("claim"));
3345        assert!(line.contains("real v8 train"));
3346        // Short event id (first 12 chars).
3347        assert!(line.contains("abcd12345678"));
3348        assert!(
3349            !line.contains("abcd1234567890ef"),
3350            "should truncate full id"
3351        );
3352        // RFC3339-ish second precision.
3353        assert!(line.contains("2026-05-15T23:14:07"));
3354    }
3355
3356    #[test]
3357    fn monitor_render_strips_newlines_from_body() {
3358        // Multi-line bodies (markdown lists, code, etc.) must collapse to
3359        // one line — otherwise a single message produces multiple
3360        // notifications in the harness, ruining the "one event = one line"
3361        // contract the Monitor tool relies on.
3362        let e = ev("spark", "claim", "line one\nline two\nline three");
3363        let line = monitor_render(&e, false).unwrap();
3364        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
3365        assert!(line.contains("line one line two line three"));
3366    }
3367
3368    #[test]
3369    fn monitor_render_json_is_valid_jsonl() {
3370        let e = ev("spark", "claim", "hi");
3371        let line = monitor_render(&e, true).unwrap();
3372        assert!(!line.contains('\n'));
3373        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
3374        assert_eq!(parsed["peer"], "spark");
3375        assert_eq!(parsed["kind"], "claim");
3376        assert_eq!(parsed["body_preview"], "hi");
3377    }
3378
3379    #[test]
3380    fn monitor_does_not_drop_on_verified_null() {
3381        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
3382        // `select(.verified == true)` against inbox JSONL. Daemon writes
3383        // events with verified=null (verification happens at tail-time, not
3384        // write-time), so the filter silently rejected everything — same
3385        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
3386        // never surfaced for ~30min.
3387        //
3388        // wire monitor's render path must NOT consult `.verified` for any
3389        // filter decision. Lock that in here so a future "be conservative,
3390        // only emit verified" patch can't quietly land.
3391        let mut e = ev("spark", "claim", "from disk with verified=null");
3392        e.verified = false; // worst case — even if disk says unverified, emit
3393        let line = monitor_render(&e, false).unwrap();
3394        assert!(line.contains("from disk with verified=null"));
3395        // Noise filter operates purely on kind, never on verified.
3396        assert!(!monitor_is_noise_kind("claim"));
3397    }
3398}
3399
3400// ---------- verify ----------
3401
3402fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
3403    let body = if path == "-" {
3404        let mut buf = String::new();
3405        use std::io::Read;
3406        std::io::stdin().read_to_string(&mut buf)?;
3407        buf
3408    } else {
3409        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
3410    };
3411    let event: Value = serde_json::from_str(&body)?;
3412    let trust = config::read_trust()?;
3413    match verify_message_v31(&event, &trust) {
3414        Ok(()) => {
3415            if as_json {
3416                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
3417            } else {
3418                println!("verified ✓");
3419            }
3420            Ok(())
3421        }
3422        Err(e) => {
3423            let reason = e.to_string();
3424            if as_json {
3425                println!(
3426                    "{}",
3427                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
3428                );
3429            } else {
3430                eprintln!("FAILED: {reason}");
3431            }
3432            std::process::exit(1);
3433        }
3434    }
3435}
3436
3437// ---------- mcp / relay-server stubs ----------
3438
3439fn cmd_mcp() -> Result<()> {
3440    crate::mcp::run()
3441}
3442
3443fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
3444    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
3445    // overriding --bind. Implies --local-only semantics. Routed to a
3446    // separate serve_uds entry point with a manual hyper accept loop
3447    // (axum 0.7's `serve` is TcpListener-only).
3448    if let Some(socket_path) = uds {
3449        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
3450            std::path::PathBuf::from(home)
3451                .join("state")
3452                .join("wire-relay")
3453                .join("uds")
3454        } else {
3455            dirs::state_dir()
3456                .or_else(dirs::data_local_dir)
3457                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
3458                .join("wire-relay")
3459                .join("uds")
3460        };
3461        let runtime = tokio::runtime::Builder::new_multi_thread()
3462            .enable_all()
3463            .build()?;
3464        return runtime.block_on(crate::relay_server::serve_uds(
3465            socket_path.to_path_buf(),
3466            base,
3467        ));
3468    }
3469    // v0.5.17: --local-only refuses non-loopback binds. Catches the
3470    // "wait did I just bind a publicly-reachable local-only relay" mistake
3471    // at startup rather than discovering it via an empty phonebook later.
3472    if local_only {
3473        validate_loopback_bind(bind)?;
3474    }
3475    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
3476    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
3477    // so a single user can run both client and server on one machine.
3478    // For --local-only, suffix with /local so a single operator can run
3479    // both a federation relay and a local-only relay without state collision.
3480    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
3481        std::path::PathBuf::from(home)
3482            .join("state")
3483            .join("wire-relay")
3484    } else {
3485        dirs::state_dir()
3486            .or_else(dirs::data_local_dir)
3487            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
3488            .join("wire-relay")
3489    };
3490    let state_dir = if local_only { base.join("local") } else { base };
3491    let runtime = tokio::runtime::Builder::new_multi_thread()
3492        .enable_all()
3493        .build()?;
3494    runtime.block_on(crate::relay_server::serve_with_mode(
3495        bind,
3496        state_dir,
3497        crate::relay_server::ServerMode { local_only },
3498    ))
3499}
3500
3501/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
3502/// resolves to something outside `127.0.0.0/8` or `::1`.
3503///
3504/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
3505/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
3506/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
3507///
3508/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
3509/// is the IP range Tailscale uses for tailnet addresses. Lets operators
3510/// pair wire across machines using their tailnet IPs (e.g. Mac at
3511/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
3512/// auth + encryption + NAT traversal, wire handles protocol + identity.
3513/// Sidesteps host firewall config entirely (utun interface bypass).
3514///
3515/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
3516/// multicast, broadcast. Those would publish a "local-only" relay to
3517/// the global internet — the v0.5.17 security gate's whole point.
3518fn validate_loopback_bind(bind: &str) -> Result<()> {
3519    // Split host:port. IPv6 literals use `[::]:port` form.
3520    let host = if let Some(stripped) = bind.strip_prefix('[') {
3521        let close = stripped
3522            .find(']')
3523            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
3524        stripped[..close].to_string()
3525    } else {
3526        bind.rsplit_once(':')
3527            .map(|(h, _)| h.to_string())
3528            .unwrap_or_else(|| bind.to_string())
3529    };
3530    use std::net::{IpAddr, ToSocketAddrs};
3531    let probe = format!("{host}:0");
3532    let resolved: Vec<_> = probe
3533        .to_socket_addrs()
3534        .with_context(|| format!("resolving bind host {host:?}"))?
3535        .collect();
3536    if resolved.is_empty() {
3537        bail!("--local-only: bind host {host:?} resolved to no addresses");
3538    }
3539    for addr in &resolved {
3540        let ip = addr.ip();
3541        let is_acceptable = match ip {
3542            IpAddr::V4(v4) => {
3543                v4.is_loopback() || v4.is_private() || {
3544                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
3545                    let octets = v4.octets();
3546                    octets[0] == 100 && (64..=127).contains(&octets[1])
3547                }
3548            }
3549            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
3550        };
3551        if !is_acceptable {
3552            bail!(
3553                "--local-only refuses non-private bind: {host:?} resolves to {} \
3554                 which is not loopback (127/8, ::1), RFC 1918 private \
3555                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
3556                 (100.64.0.0/10). Remove --local-only to bind publicly.",
3557                ip
3558            );
3559        }
3560    }
3561    Ok(())
3562}
3563
3564// ---------- bind-relay ----------
3565
3566fn cmd_bind_relay(url: &str, migrate_pinned: bool, as_json: bool) -> Result<()> {
3567    if !config::is_initialized()? {
3568        bail!("not initialized — run `wire init <handle>` first");
3569    }
3570    let card = config::read_agent_card()?;
3571    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3572    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3573
3574    // v0.5.19 (issue #7): refuse silent migration that would black-hole
3575    // pinned peers. The peer's relay-state still points at our OLD slot;
3576    // they will keep POSTing successfully to a slot we no longer read,
3577    // and their messages disappear. Pre-fix this command silently
3578    // replaced state.self, the incident report logged 26 events lost
3579    // over 2 days.
3580    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
3581    let pinned: Vec<String> = existing
3582        .get("peers")
3583        .and_then(|p| p.as_object())
3584        .map(|o| o.keys().cloned().collect())
3585        .unwrap_or_default();
3586    if !pinned.is_empty() && !migrate_pinned {
3587        let list = pinned.join(", ");
3588        bail!(
3589            "bind-relay would silently black-hole {n} pinned peer(s): {list}. \
3590             They are pinned to your CURRENT slot; without coordination they will keep \
3591             pushing to a slot you no longer read.\n\n\
3592             SAFE PATHS:\n\
3593             • `wire rotate-slot` — rotates slot on the SAME relay and emits a \
3594             wire_close event to every pinned peer so their daemons drop the stale \
3595             coords cleanly. This is the supported migration path.\n\
3596             • `wire bind-relay {url} --migrate-pinned` — acknowledges that pinned \
3597             peers will need to re-pin manually (you must notify them out-of-band, \
3598             via a fresh `wire add` from each peer or a re-shared invite). Use this \
3599             only when the current slot is unreachable so rotate-slot can't ack.\n\n\
3600             Issue #7 (silent black-hole on relay change) caught this — proceed only \
3601             if you understand the consequences.",
3602            n = pinned.len(),
3603        );
3604    }
3605
3606    let normalized = url.trim_end_matches('/');
3607    let client = crate::relay_client::RelayClient::new(normalized);
3608    client.check_healthz()?;
3609    let alloc = client.allocate_slot(Some(&handle))?;
3610    let mut state = existing;
3611    if !pinned.is_empty() {
3612        // We're committing to the migration. Surface a final stderr
3613        // banner naming the peers operators must notify out-of-band so
3614        // there's a record in their shell history.
3615        eprintln!(
3616            "wire bind-relay: migrating with {n} pinned peer(s) — they will black-hole \
3617             until they re-pin: {peers}",
3618            n = pinned.len(),
3619            peers = pinned.join(", "),
3620        );
3621    }
3622    state["self"] = json!({
3623        "relay_url": url,
3624        "slot_id": alloc.slot_id,
3625        "slot_token": alloc.slot_token,
3626    });
3627    config::write_relay_state(&state)?;
3628
3629    if as_json {
3630        println!(
3631            "{}",
3632            serde_json::to_string(&json!({
3633                "relay_url": url,
3634                "slot_id": alloc.slot_id,
3635                "slot_token_present": true,
3636            }))?
3637        );
3638    } else {
3639        println!("bound to relay {url}");
3640        println!("slot_id: {}", alloc.slot_id);
3641        println!(
3642            "(slot_token written to {} mode 0600)",
3643            config::relay_state_path()?.display()
3644        );
3645    }
3646    Ok(())
3647}
3648
3649// ---------- add-peer-slot ----------
3650
3651fn cmd_add_peer_slot(
3652    handle: &str,
3653    url: &str,
3654    slot_id: &str,
3655    slot_token: &str,
3656    as_json: bool,
3657) -> Result<()> {
3658    let mut state = config::read_relay_state()?;
3659    let peers = state["peers"]
3660        .as_object_mut()
3661        .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
3662    peers.insert(
3663        handle.to_string(),
3664        json!({
3665            "relay_url": url,
3666            "slot_id": slot_id,
3667            "slot_token": slot_token,
3668        }),
3669    );
3670    config::write_relay_state(&state)?;
3671    if as_json {
3672        println!(
3673            "{}",
3674            serde_json::to_string(&json!({
3675                "handle": handle,
3676                "relay_url": url,
3677                "slot_id": slot_id,
3678                "added": true,
3679            }))?
3680        );
3681    } else {
3682        println!("pinned peer slot for {handle} at {url} ({slot_id})");
3683    }
3684    Ok(())
3685}
3686
3687// ---------- push ----------
3688
3689fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
3690    let state = config::read_relay_state()?;
3691    let peers = state["peers"].as_object().cloned().unwrap_or_default();
3692    if peers.is_empty() {
3693        bail!(
3694            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
3695        );
3696    }
3697    let outbox_dir = config::outbox_dir()?;
3698    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
3699    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
3700    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
3701    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
3702    if outbox_dir.exists() {
3703        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
3704        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
3705            let path = entry.path();
3706            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3707                continue;
3708            }
3709            let stem = match path.file_stem().and_then(|s| s.to_str()) {
3710                Some(s) => s.to_string(),
3711                None => continue,
3712            };
3713            if pinned.contains(&stem) {
3714                continue;
3715            }
3716            // Try the bare-handle of the orphaned stem — if THAT matches a
3717            // pinned peer, the stem is a stale FQDN-suffixed file.
3718            let bare = crate::agent_card::bare_handle(&stem);
3719            if pinned.contains(bare) {
3720                eprintln!(
3721                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
3722                     Merge with: `cat {} >> {}` then delete the FQDN file.",
3723                    stem,
3724                    path.display(),
3725                    outbox_dir.join(format!("{bare}.jsonl")).display(),
3726                );
3727            }
3728        }
3729    }
3730    if !outbox_dir.exists() {
3731        if as_json {
3732            println!(
3733                "{}",
3734                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
3735            );
3736        } else {
3737            println!("phyllis: nothing to dial out — write a message first with `wire send`");
3738        }
3739        return Ok(());
3740    }
3741
3742    let mut pushed = Vec::new();
3743    let mut skipped = Vec::new();
3744
3745    // v0.5.17: walk each peer's pinned endpoints in priority order (local
3746    // first if we share a local relay, federation second). Try POST on the
3747    // first endpoint; on transport failure, fall through to the next.
3748    // Falls back to the v0.5.16 legacy single-endpoint code path when the
3749    // peer record carries no `endpoints[]` array (back-compat).
3750    for (peer_handle, _) in peers.iter() {
3751        if let Some(want) = peer_filter
3752            && peer_handle != want
3753        {
3754            continue;
3755        }
3756        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
3757        if !outbox.exists() {
3758            continue;
3759        }
3760        let ordered_endpoints =
3761            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
3762        if ordered_endpoints.is_empty() {
3763            // Unreachable peer (no federation endpoint AND our local
3764            // relay doesn't match the peer's). Skip with a loud reason
3765            // rather than silently dropping events.
3766            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
3767                let event: Value = match serde_json::from_str(line) {
3768                    Ok(v) => v,
3769                    Err(_) => continue,
3770                };
3771                let event_id = event
3772                    .get("event_id")
3773                    .and_then(Value::as_str)
3774                    .unwrap_or("")
3775                    .to_string();
3776                skipped.push(json!({
3777                    "peer": peer_handle,
3778                    "event_id": event_id,
3779                    "reason": "no reachable endpoint pinned for peer",
3780                }));
3781            }
3782            continue;
3783        }
3784        let body = std::fs::read_to_string(&outbox)?;
3785        for line in body.lines() {
3786            let event: Value = match serde_json::from_str(line) {
3787                Ok(v) => v,
3788                Err(_) => continue,
3789            };
3790            let event_id = event
3791                .get("event_id")
3792                .and_then(Value::as_str)
3793                .unwrap_or("")
3794                .to_string();
3795
3796            let mut delivered = false;
3797            let mut last_err_reason: Option<String> = None;
3798            for endpoint in &ordered_endpoints {
3799                let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
3800                match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
3801                    Ok(resp) => {
3802                        if resp.status == "duplicate" {
3803                            skipped.push(json!({
3804                                "peer": peer_handle,
3805                                "event_id": event_id,
3806                                "reason": "duplicate",
3807                                "endpoint": endpoint.relay_url,
3808                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
3809                            }));
3810                        } else {
3811                            pushed.push(json!({
3812                                "peer": peer_handle,
3813                                "event_id": event_id,
3814                                "endpoint": endpoint.relay_url,
3815                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
3816                            }));
3817                        }
3818                        delivered = true;
3819                        break;
3820                    }
3821                    Err(e) => {
3822                        // Local-first endpoint failed; record reason and
3823                        // try the next endpoint silently (operator sees
3824                        // the federation success). If every endpoint
3825                        // fails, the last reason is what gets reported.
3826                        last_err_reason = Some(crate::relay_client::format_transport_error(&e));
3827                    }
3828                }
3829            }
3830            if !delivered {
3831                skipped.push(json!({
3832                    "peer": peer_handle,
3833                    "event_id": event_id,
3834                    "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
3835                }));
3836            }
3837        }
3838    }
3839
3840    if as_json {
3841        println!(
3842            "{}",
3843            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
3844        );
3845    } else {
3846        println!(
3847            "pushed {} event(s); skipped {} ({})",
3848            pushed.len(),
3849            skipped.len(),
3850            if skipped.is_empty() {
3851                "none"
3852            } else {
3853                "see --json for detail"
3854            }
3855        );
3856    }
3857    Ok(())
3858}
3859
3860// ---------- pull ----------
3861
3862fn cmd_pull(as_json: bool) -> Result<()> {
3863    let state = config::read_relay_state()?;
3864    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
3865    if self_state.is_null() {
3866        bail!("self slot not bound — run `wire bind-relay <url>` first");
3867    }
3868
3869    // v0.5.17: pull from every endpoint in self.endpoints (federation +
3870    // optional local). Each endpoint has its own per-scope cursor so we
3871    // don't re-pull events we've already seen on that path. Events from
3872    // all endpoints feed into the same inbox JSONL via process_events;
3873    // dedup by event_id is the last line of defense.
3874    // Falls back to a single federation endpoint synthesized from the
3875    // top-level legacy fields when self.endpoints is absent (v0.5.16
3876    // back-compat).
3877    let endpoints = crate::endpoints::self_endpoints(&state);
3878    if endpoints.is_empty() {
3879        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
3880    }
3881
3882    let inbox_dir = config::inbox_dir()?;
3883    config::ensure_dirs()?;
3884
3885    let mut total_seen = 0usize;
3886    let mut all_written: Vec<Value> = Vec::new();
3887    let mut all_rejected: Vec<Value> = Vec::new();
3888    let mut all_blocked = false;
3889    let mut all_advance_cursor_to: Option<String> = None;
3890
3891    for endpoint in &endpoints {
3892        let cursor_key = endpoint_cursor_key(endpoint.scope);
3893        let last_event_id = self_state
3894            .get(&cursor_key)
3895            .and_then(Value::as_str)
3896            .map(str::to_string);
3897        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
3898        let events = match client.list_events(
3899            &endpoint.slot_id,
3900            &endpoint.slot_token,
3901            last_event_id.as_deref(),
3902            Some(1000),
3903        ) {
3904            Ok(ev) => ev,
3905            Err(e) => {
3906                // One endpoint's failure shouldn't kill the whole pull.
3907                // The local-relay-down case in particular needs to
3908                // gracefully continue against federation.
3909                eprintln!(
3910                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
3911                    endpoint.relay_url,
3912                    endpoint.scope,
3913                    crate::relay_client::format_transport_error(&e),
3914                );
3915                continue;
3916            }
3917        };
3918        total_seen += events.len();
3919        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
3920        all_written.extend(result.written.iter().cloned());
3921        all_rejected.extend(result.rejected.iter().cloned());
3922        if result.blocked {
3923            all_blocked = true;
3924        }
3925        // Advance per-endpoint cursor. The cursor key is scope-specific
3926        // so federation and local don't trample each other.
3927        if let Some(eid) = result.advance_cursor_to.clone() {
3928            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
3929                all_advance_cursor_to = Some(eid.clone());
3930            }
3931            let key = cursor_key.clone();
3932            config::update_relay_state(|state| {
3933                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
3934                    self_obj.insert(key, Value::String(eid));
3935                }
3936                Ok(())
3937            })?;
3938        }
3939    }
3940
3941    // Compatibility shim for the legacy single-cursor code paths below:
3942    // `result` used to come from one process_events call; we now have
3943    // per-endpoint results aggregated into the all_* accumulators.
3944    // Reconstruct a synthetic result for the remaining display logic.
3945    let result = crate::pull::PullResult {
3946        written: all_written,
3947        rejected: all_rejected,
3948        blocked: all_blocked,
3949        advance_cursor_to: all_advance_cursor_to,
3950    };
3951    let events_len = total_seen;
3952
3953    // Cursor advance happened per-endpoint above; no aggregate cursor
3954    // write needed here.
3955
3956    if as_json {
3957        println!(
3958            "{}",
3959            serde_json::to_string(&json!({
3960                "written": result.written,
3961                "rejected": result.rejected,
3962                "total_seen": events_len,
3963                "cursor_blocked": result.blocked,
3964                "cursor_advanced_to": result.advance_cursor_to,
3965            }))?
3966        );
3967    } else {
3968        let blocking = result
3969            .rejected
3970            .iter()
3971            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
3972            .count();
3973        if blocking > 0 {
3974            println!(
3975                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
3976                events_len,
3977                result.written.len(),
3978                result.rejected.len(),
3979                blocking,
3980            );
3981        } else {
3982            println!(
3983                "pulled {} event(s); wrote {}; rejected {}",
3984                events_len,
3985                result.written.len(),
3986                result.rejected.len(),
3987            );
3988        }
3989    }
3990    Ok(())
3991}
3992
3993/// v0.5.17: cursor key for an endpoint's per-scope read position.
3994/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
3995/// back-compat with on-disk relay_state files; local uses a
3996/// `_local` suffix.
3997fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
3998    match scope {
3999        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4000        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4001        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4002        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4003    }
4004}
4005
4006// ---------- rotate-slot ----------
4007
4008fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4009    if !config::is_initialized()? {
4010        bail!("not initialized — run `wire init <handle>` first");
4011    }
4012    let mut state = config::read_relay_state()?;
4013    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4014    if self_state.is_null() {
4015        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4016    }
4017    let url = self_state["relay_url"]
4018        .as_str()
4019        .ok_or_else(|| anyhow!("self.relay_url missing"))?
4020        .to_string();
4021    let old_slot_id = self_state["slot_id"]
4022        .as_str()
4023        .ok_or_else(|| anyhow!("self.slot_id missing"))?
4024        .to_string();
4025    let old_slot_token = self_state["slot_token"]
4026        .as_str()
4027        .ok_or_else(|| anyhow!("self.slot_token missing"))?
4028        .to_string();
4029
4030    // Read identity to sign the announcement.
4031    let card = config::read_agent_card()?;
4032    let did = card
4033        .get("did")
4034        .and_then(Value::as_str)
4035        .unwrap_or("")
4036        .to_string();
4037    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4038    let pk_b64 = card
4039        .get("verify_keys")
4040        .and_then(Value::as_object)
4041        .and_then(|m| m.values().next())
4042        .and_then(|v| v.get("key"))
4043        .and_then(Value::as_str)
4044        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4045        .to_string();
4046    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4047    let sk_seed = config::read_private_key()?;
4048
4049    // Allocate new slot on the same relay.
4050    let normalized = url.trim_end_matches('/').to_string();
4051    let client = crate::relay_client::RelayClient::new(&normalized);
4052    client
4053        .check_healthz()
4054        .context("aborting rotation; old slot still valid")?;
4055    let alloc = client.allocate_slot(Some(&handle))?;
4056    let new_slot_id = alloc.slot_id.clone();
4057    let new_slot_token = alloc.slot_token.clone();
4058
4059    // Optionally announce the rotation to every paired peer via the OLD slot.
4060    // Each peer's recipient-side `wire pull` will pick up this event before
4061    // their daemon next polls the new slot — but auto-update of peer's
4062    // relay.json from a wire_close event is a v0.2 daemon feature; for now
4063    // peers see the event and an operator must manually `add-peer-slot` the
4064    // new coords, OR re-pair via SAS.
4065    let mut announced: Vec<String> = Vec::new();
4066    if !no_announce {
4067        let now = time::OffsetDateTime::now_utc()
4068            .format(&time::format_description::well_known::Rfc3339)
4069            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4070        let body = json!({
4071            "reason": "operator-initiated slot rotation",
4072            "new_relay_url": url,
4073            "new_slot_id": new_slot_id,
4074            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
4075            // In v0.1 slot tokens are bilateral-shared, so peer can post via
4076            // existing add-peer-slot flow if operator chooses to re-issue.
4077        });
4078        let peers = state["peers"].as_object().cloned().unwrap_or_default();
4079        for (peer_handle, _peer_info) in peers.iter() {
4080            let event = json!({
4081                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4082                "timestamp": now.clone(),
4083                "from": did,
4084                "to": format!("did:wire:{peer_handle}"),
4085                "type": "wire_close",
4086                "kind": 1201,
4087                "body": body.clone(),
4088            });
4089            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4090                Ok(s) => s,
4091                Err(e) => {
4092                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4093                    continue;
4094                }
4095            };
4096            // Post to OUR old slot (we're announcing on our own slot, NOT
4097            // peer's slot — peer reads from us). Wait, this is wrong: peers
4098            // read from THEIR OWN slot via wire pull. To reach peer A, we
4099            // post to peer A's slot. Use the existing per-peer slot mapping.
4100            let peer_info = match state["peers"].get(peer_handle) {
4101                Some(p) => p.clone(),
4102                None => continue,
4103            };
4104            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4105            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4106            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4107            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4108                continue;
4109            }
4110            let peer_client = if peer_url == url {
4111                client.clone()
4112            } else {
4113                crate::relay_client::RelayClient::new(peer_url)
4114            };
4115            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4116                Ok(_) => announced.push(peer_handle.clone()),
4117                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4118            }
4119        }
4120    }
4121
4122    // Swap the self-slot to the new one.
4123    state["self"] = json!({
4124        "relay_url": url,
4125        "slot_id": new_slot_id,
4126        "slot_token": new_slot_token,
4127    });
4128    config::write_relay_state(&state)?;
4129
4130    if as_json {
4131        println!(
4132            "{}",
4133            serde_json::to_string(&json!({
4134                "rotated": true,
4135                "old_slot_id": old_slot_id,
4136                "new_slot_id": new_slot_id,
4137                "relay_url": url,
4138                "announced_to": announced,
4139            }))?
4140        );
4141    } else {
4142        println!("rotated slot on {url}");
4143        println!(
4144            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4145        );
4146        println!("  new slot_id: {new_slot_id}");
4147        if !announced.is_empty() {
4148            println!(
4149                "  announced wire_close (kind=1201) to: {}",
4150                announced.join(", ")
4151            );
4152        }
4153        println!();
4154        println!("next steps:");
4155        println!("  - peers see the wire_close event in their next `wire pull`");
4156        println!(
4157            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4158        );
4159        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
4160        println!("  - until they do, you'll receive but they won't be able to reach you");
4161        // Suppress unused warning
4162        let _ = old_slot_token;
4163    }
4164    Ok(())
4165}
4166
4167// ---------- forget-peer ----------
4168
4169fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4170    let mut trust = config::read_trust()?;
4171    let mut removed_from_trust = false;
4172    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4173        && agents.remove(handle).is_some()
4174    {
4175        removed_from_trust = true;
4176    }
4177    config::write_trust(&trust)?;
4178
4179    let mut state = config::read_relay_state()?;
4180    let mut removed_from_relay = false;
4181    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4182        && peers.remove(handle).is_some()
4183    {
4184        removed_from_relay = true;
4185    }
4186    config::write_relay_state(&state)?;
4187
4188    let mut purged: Vec<String> = Vec::new();
4189    if purge {
4190        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4191            let path = dir.join(format!("{handle}.jsonl"));
4192            if path.exists() {
4193                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4194                purged.push(path.to_string_lossy().into());
4195            }
4196        }
4197    }
4198
4199    if !removed_from_trust && !removed_from_relay {
4200        if as_json {
4201            println!(
4202                "{}",
4203                serde_json::to_string(&json!({
4204                    "removed": false,
4205                    "reason": format!("peer {handle:?} not pinned"),
4206                }))?
4207            );
4208        } else {
4209            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
4210        }
4211        return Ok(());
4212    }
4213
4214    if as_json {
4215        println!(
4216            "{}",
4217            serde_json::to_string(&json!({
4218                "handle": handle,
4219                "removed_from_trust": removed_from_trust,
4220                "removed_from_relay_state": removed_from_relay,
4221                "purged_files": purged,
4222            }))?
4223        );
4224    } else {
4225        println!("forgot peer {handle:?}");
4226        if removed_from_trust {
4227            println!("  - removed from trust.json");
4228        }
4229        if removed_from_relay {
4230            println!("  - removed from relay.json");
4231        }
4232        if !purged.is_empty() {
4233            for p in &purged {
4234                println!("  - deleted {p}");
4235            }
4236        } else if !purge {
4237            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
4238        }
4239    }
4240    Ok(())
4241}
4242
4243// ---------- daemon (long-lived push+pull sync) ----------
4244
4245fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
4246    if !config::is_initialized()? {
4247        bail!("not initialized — run `wire init <handle>` first");
4248    }
4249    let interval = std::time::Duration::from_secs(interval_secs.max(1));
4250
4251    if !as_json {
4252        if once {
4253            eprintln!("wire daemon: single sync cycle, then exit");
4254        } else {
4255            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
4256        }
4257    }
4258
4259    // Recover from prior crash: any pending pair in transient state had its
4260    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
4261    // the relay slots and mark the files so the operator can re-issue.
4262    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
4263        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
4264    }
4265
4266    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
4267    // to our slot, the subscriber signals `wake_rx`; we use it as the
4268    // sleep-or-wake gate of the polling loop. Polling stays as the
4269    // safety net — stream errors fall back transparently to the existing
4270    // interval-based cadence.
4271    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
4272    if !once {
4273        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
4274    }
4275
4276    loop {
4277        let pushed = run_sync_push().unwrap_or_else(|e| {
4278            eprintln!("daemon: push error: {e:#}");
4279            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
4280        });
4281        let pulled = run_sync_pull().unwrap_or_else(|e| {
4282            eprintln!("daemon: pull error: {e:#}");
4283            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
4284        });
4285        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
4286            eprintln!("daemon: pending-pair tick error: {e:#}");
4287            json!({"transitions": []})
4288        });
4289
4290        if as_json {
4291            println!(
4292                "{}",
4293                serde_json::to_string(&json!({
4294                    "ts": time::OffsetDateTime::now_utc()
4295                        .format(&time::format_description::well_known::Rfc3339)
4296                        .unwrap_or_default(),
4297                    "push": pushed,
4298                    "pull": pulled,
4299                    "pairs": pairs,
4300                }))?
4301            );
4302        } else {
4303            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
4304            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
4305            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
4306            let pair_transitions = pairs["transitions"]
4307                .as_array()
4308                .map(|a| a.len())
4309                .unwrap_or(0);
4310            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
4311                eprintln!(
4312                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
4313                );
4314            }
4315            // Loud per-transition logging so operator sees pair progress live.
4316            if let Some(arr) = pairs["transitions"].as_array() {
4317                for t in arr {
4318                    eprintln!(
4319                        "  pair {} : {} → {}",
4320                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
4321                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
4322                        t.get("to").and_then(Value::as_str).unwrap_or("?")
4323                    );
4324                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
4325                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
4326                    {
4327                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
4328                        eprintln!(
4329                            "    Run: wire pair-confirm {} {}",
4330                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
4331                            sas
4332                        );
4333                    }
4334                }
4335            }
4336        }
4337
4338        if once {
4339            return Ok(());
4340        }
4341        // Wait either for the next poll-interval tick OR for a stream
4342        // wake signal — whichever comes first. Drain any additional
4343        // wake-ups that accumulated during the previous cycle since one
4344        // pull catches up everything.
4345        let _ = wake_rx.recv_timeout(interval);
4346        while wake_rx.try_recv().is_ok() {}
4347    }
4348}
4349
4350/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
4351/// shape `wire push --json` emits.
4352fn run_sync_push() -> Result<Value> {
4353    let state = config::read_relay_state()?;
4354    let peers = state["peers"].as_object().cloned().unwrap_or_default();
4355    if peers.is_empty() {
4356        return Ok(json!({"pushed": [], "skipped": []}));
4357    }
4358    let outbox_dir = config::outbox_dir()?;
4359    if !outbox_dir.exists() {
4360        return Ok(json!({"pushed": [], "skipped": []}));
4361    }
4362    let mut pushed = Vec::new();
4363    let mut skipped = Vec::new();
4364    for (peer_handle, slot_info) in peers.iter() {
4365        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4366        if !outbox.exists() {
4367            continue;
4368        }
4369        let url = slot_info["relay_url"].as_str().unwrap_or("");
4370        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
4371        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
4372        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
4373            continue;
4374        }
4375        let client = crate::relay_client::RelayClient::new(url);
4376        let body = std::fs::read_to_string(&outbox)?;
4377        for line in body.lines() {
4378            let event: Value = match serde_json::from_str(line) {
4379                Ok(v) => v,
4380                Err(_) => continue,
4381            };
4382            let event_id = event
4383                .get("event_id")
4384                .and_then(Value::as_str)
4385                .unwrap_or("")
4386                .to_string();
4387            match client.post_event(slot_id, slot_token, &event) {
4388                Ok(resp) => {
4389                    if resp.status == "duplicate" {
4390                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
4391                    } else {
4392                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
4393                    }
4394                }
4395                Err(e) => {
4396                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
4397                    // errors aren't hidden behind the topmost-context URL string.
4398                    // Issue #6 highest-impact silent-fail fix.
4399                    let reason = crate::relay_client::format_transport_error(&e);
4400                    skipped
4401                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
4402                }
4403            }
4404        }
4405    }
4406    Ok(json!({"pushed": pushed, "skipped": skipped}))
4407}
4408
4409/// Programmatic pull. Same shape as `wire pull --json`.
4410fn run_sync_pull() -> Result<Value> {
4411    let state = config::read_relay_state()?;
4412    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4413    if self_state.is_null() {
4414        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
4415    }
4416    let url = self_state["relay_url"].as_str().unwrap_or("");
4417    let slot_id = self_state["slot_id"].as_str().unwrap_or("");
4418    let slot_token = self_state["slot_token"].as_str().unwrap_or("");
4419    let last_event_id = self_state
4420        .get("last_pulled_event_id")
4421        .and_then(Value::as_str)
4422        .map(str::to_string);
4423    if url.is_empty() {
4424        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
4425    }
4426    let client = crate::relay_client::RelayClient::new(url);
4427    let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
4428    let inbox_dir = config::inbox_dir()?;
4429    config::ensure_dirs()?;
4430
4431    // P0.1 (0.5.11): shared cursor-blocking logic. Daemon's --once path
4432    // must match the CLI's `wire pull` semantics or version-skew bugs
4433    // re-emerge by another route.
4434    let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
4435
4436    // P0.3 (0.5.11): same flock-protected RMW as cmd_pull.
4437    if let Some(eid) = &result.advance_cursor_to {
4438        let eid = eid.clone();
4439        config::update_relay_state(|state| {
4440            if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4441                self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
4442            }
4443            Ok(())
4444        })?;
4445    }
4446
4447    Ok(json!({
4448        "written": result.written,
4449        "rejected": result.rejected,
4450        "total_seen": events.len(),
4451        "cursor_blocked": result.blocked,
4452        "cursor_advanced_to": result.advance_cursor_to,
4453    }))
4454}
4455
4456// ---------- pin (manual out-of-band peer pairing) ----------
4457
4458fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
4459    let body =
4460        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
4461    let card: Value =
4462        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
4463    crate::agent_card::verify_agent_card(&card)
4464        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
4465
4466    let mut trust = config::read_trust()?;
4467    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
4468
4469    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4470    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4471    config::write_trust(&trust)?;
4472
4473    if as_json {
4474        println!(
4475            "{}",
4476            serde_json::to_string(&json!({
4477                "handle": handle,
4478                "did": did,
4479                "tier": "VERIFIED",
4480                "pinned": true,
4481            }))?
4482        );
4483    } else {
4484        println!("pinned {handle} ({did}) at tier VERIFIED");
4485    }
4486    Ok(())
4487}
4488
4489// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
4490
4491fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
4492    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
4493}
4494
4495fn cmd_pair_join(
4496    code_phrase: &str,
4497    relay_url: &str,
4498    auto_yes: bool,
4499    timeout_secs: u64,
4500) -> Result<()> {
4501    pair_orchestrate(
4502        relay_url,
4503        Some(code_phrase),
4504        "guest",
4505        auto_yes,
4506        timeout_secs,
4507    )
4508}
4509
4510/// Shared orchestration for both sides of the SAS pairing.
4511///
4512/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
4513/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
4514/// `pair_session_confirm_sas` instead.
4515fn pair_orchestrate(
4516    relay_url: &str,
4517    code_in: Option<&str>,
4518    role: &str,
4519    auto_yes: bool,
4520    timeout_secs: u64,
4521) -> Result<()> {
4522    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
4523
4524    let mut s = pair_session_open(role, relay_url, code_in)?;
4525
4526    if role == "host" {
4527        eprintln!();
4528        eprintln!("share this code phrase with your peer:");
4529        eprintln!();
4530        eprintln!("    {}", s.code);
4531        eprintln!();
4532        eprintln!(
4533            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
4534            s.code
4535        );
4536    } else {
4537        eprintln!();
4538        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
4539    }
4540
4541    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
4542    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
4543    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
4544    // see the process is alive while the other side connects.
4545    const HEARTBEAT_SECS: u64 = 10;
4546    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
4547    let started = std::time::Instant::now();
4548    let mut last_heartbeat = started;
4549    let formatted = loop {
4550        if let Some(sas) = pair_session_try_sas(&mut s)? {
4551            break sas;
4552        }
4553        let now = std::time::Instant::now();
4554        if now >= deadline {
4555            return Err(anyhow!(
4556                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
4557            ));
4558        }
4559        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
4560            let elapsed = now.duration_since(started).as_secs();
4561            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
4562            last_heartbeat = now;
4563        }
4564        std::thread::sleep(std::time::Duration::from_millis(250));
4565    };
4566
4567    eprintln!();
4568    eprintln!("SAS digits (must match peer's terminal):");
4569    eprintln!();
4570    eprintln!("    {formatted}");
4571    eprintln!();
4572
4573    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
4574    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
4575    if !auto_yes {
4576        eprint!("does this match your peer's terminal? [y/N]: ");
4577        use std::io::Write;
4578        std::io::stderr().flush().ok();
4579        let mut input = String::new();
4580        std::io::stdin().read_line(&mut input)?;
4581        let trimmed = input.trim().to_lowercase();
4582        if trimmed != "y" && trimmed != "yes" {
4583            bail!("SAS confirmation declined — aborting pairing");
4584        }
4585    }
4586    s.sas_confirmed = true;
4587
4588    // Stage 4 — seal+exchange bootstrap, pin peer.
4589    let result = pair_session_finalize(&mut s, timeout_secs)?;
4590
4591    let peer_did = result["paired_with"].as_str().unwrap_or("");
4592    let peer_role = if role == "host" { "guest" } else { "host" };
4593    eprintln!("paired with {peer_did} (peer role: {peer_role})");
4594    eprintln!("peer card pinned at tier VERIFIED");
4595    eprintln!(
4596        "peer relay slot saved to {}",
4597        config::relay_state_path()?.display()
4598    );
4599
4600    println!("{}", serde_json::to_string(&result)?);
4601    Ok(())
4602}
4603
4604// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
4605// and pair_session_finalize, both of which inline their own deadline loops.)
4606
4607// ---------- pair — single-shot init + pair-* + setup ----------
4608
4609fn cmd_pair(
4610    handle: &str,
4611    code: Option<&str>,
4612    relay: &str,
4613    auto_yes: bool,
4614    timeout_secs: u64,
4615    no_setup: bool,
4616) -> Result<()> {
4617    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
4618    // bails loudly if a different handle is already set (operator must explicitly delete).
4619    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
4620    let did = init_result
4621        .get("did")
4622        .and_then(|v| v.as_str())
4623        .unwrap_or("(unknown)")
4624        .to_string();
4625    let already = init_result
4626        .get("already_initialized")
4627        .and_then(|v| v.as_bool())
4628        .unwrap_or(false);
4629    if already {
4630        println!("(identity {did} already initialized — reusing)");
4631    } else {
4632        println!("initialized {did}");
4633    }
4634    println!();
4635
4636    // Step 2 — pair-host or pair-join based on code presence.
4637    match code {
4638        None => {
4639            println!("hosting pair on {relay} (no code = host) ...");
4640            cmd_pair_host(relay, auto_yes, timeout_secs)?;
4641        }
4642        Some(c) => {
4643            println!("joining pair with code {c} on {relay} ...");
4644            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
4645        }
4646    }
4647
4648    // Step 3 — register wire as MCP server in detected client configs (idempotent).
4649    if !no_setup {
4650        println!();
4651        println!("registering wire as MCP server in detected client configs ...");
4652        if let Err(e) = cmd_setup(true) {
4653            // Non-fatal — pair succeeded, just print the warning.
4654            eprintln!("warn: setup --apply failed: {e}");
4655            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
4656        }
4657    }
4658
4659    println!();
4660    println!("pair complete. Next steps:");
4661    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
4662    println!("  wire send <peer> claim <msg>   # send your peer something");
4663    println!("  wire tail                      # watch incoming events");
4664    Ok(())
4665}
4666
4667// ---------- detached pair (daemon-orchestrated) ----------
4668
4669/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
4670/// pair-host/-join into a single command. The non-detached variant lives in
4671/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
4672fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
4673    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
4674    let did = init_result
4675        .get("did")
4676        .and_then(|v| v.as_str())
4677        .unwrap_or("(unknown)")
4678        .to_string();
4679    let already = init_result
4680        .get("already_initialized")
4681        .and_then(|v| v.as_bool())
4682        .unwrap_or(false);
4683    if already {
4684        println!("(identity {did} already initialized — reusing)");
4685    } else {
4686        println!("initialized {did}");
4687    }
4688    println!();
4689    match code {
4690        None => cmd_pair_host_detach(relay, false),
4691        Some(c) => cmd_pair_join_detach(c, relay, false),
4692    }
4693}
4694
4695fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
4696    if !config::is_initialized()? {
4697        bail!("not initialized — run `wire init <handle>` first");
4698    }
4699    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
4700        Ok(b) => b,
4701        Err(e) => {
4702            if !as_json {
4703                eprintln!(
4704                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
4705                );
4706            }
4707            false
4708        }
4709    };
4710    let code = crate::sas::generate_code_phrase();
4711    let code_hash = crate::pair_session::derive_code_hash(&code);
4712    let now = time::OffsetDateTime::now_utc()
4713        .format(&time::format_description::well_known::Rfc3339)
4714        .unwrap_or_default();
4715    let p = crate::pending_pair::PendingPair {
4716        code: code.clone(),
4717        code_hash,
4718        role: "host".to_string(),
4719        relay_url: relay_url.to_string(),
4720        status: "request_host".to_string(),
4721        sas: None,
4722        peer_did: None,
4723        created_at: now,
4724        last_error: None,
4725        pair_id: None,
4726        our_slot_id: None,
4727        our_slot_token: None,
4728        spake2_seed_b64: None,
4729    };
4730    crate::pending_pair::write_pending(&p)?;
4731    if as_json {
4732        println!(
4733            "{}",
4734            serde_json::to_string(&json!({
4735                "state": "queued",
4736                "code_phrase": code,
4737                "relay_url": relay_url,
4738                "role": "host",
4739                "daemon_spawned": daemon_spawned,
4740            }))?
4741        );
4742    } else {
4743        if daemon_spawned {
4744            println!("(started wire daemon in background)");
4745        }
4746        println!("detached pair-host queued. Share this code with your peer:\n");
4747        println!("    {code}\n");
4748        println!("Next steps:");
4749        println!("  wire pair-list                                # check status");
4750        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
4751        println!("  wire pair-cancel  {code}            # to abort");
4752    }
4753    Ok(())
4754}
4755
4756fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
4757    if !config::is_initialized()? {
4758        bail!("not initialized — run `wire init <handle>` first");
4759    }
4760    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
4761        Ok(b) => b,
4762        Err(e) => {
4763            if !as_json {
4764                eprintln!(
4765                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
4766                );
4767            }
4768            false
4769        }
4770    };
4771    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
4772    let code_hash = crate::pair_session::derive_code_hash(&code);
4773    let now = time::OffsetDateTime::now_utc()
4774        .format(&time::format_description::well_known::Rfc3339)
4775        .unwrap_or_default();
4776    let p = crate::pending_pair::PendingPair {
4777        code: code.clone(),
4778        code_hash,
4779        role: "guest".to_string(),
4780        relay_url: relay_url.to_string(),
4781        status: "request_guest".to_string(),
4782        sas: None,
4783        peer_did: None,
4784        created_at: now,
4785        last_error: None,
4786        pair_id: None,
4787        our_slot_id: None,
4788        our_slot_token: None,
4789        spake2_seed_b64: None,
4790    };
4791    crate::pending_pair::write_pending(&p)?;
4792    if as_json {
4793        println!(
4794            "{}",
4795            serde_json::to_string(&json!({
4796                "state": "queued",
4797                "code_phrase": code,
4798                "relay_url": relay_url,
4799                "role": "guest",
4800                "daemon_spawned": daemon_spawned,
4801            }))?
4802        );
4803    } else {
4804        if daemon_spawned {
4805            println!("(started wire daemon in background)");
4806        }
4807        println!("detached pair-join queued for code {code}.");
4808        println!(
4809            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
4810        );
4811    }
4812    Ok(())
4813}
4814
4815fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
4816    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
4817    let typed: String = typed_digits
4818        .chars()
4819        .filter(|c| c.is_ascii_digit())
4820        .collect();
4821    if typed.len() != 6 {
4822        bail!(
4823            "expected 6 digits (got {} after stripping non-digits)",
4824            typed.len()
4825        );
4826    }
4827    let mut p = crate::pending_pair::read_pending(&code)?
4828        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
4829    if p.status != "sas_ready" {
4830        bail!(
4831            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
4832            p.status
4833        );
4834    }
4835    let stored = p
4836        .sas
4837        .as_ref()
4838        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
4839        .clone();
4840    if stored == typed {
4841        p.status = "confirmed".to_string();
4842        crate::pending_pair::write_pending(&p)?;
4843        if as_json {
4844            println!(
4845                "{}",
4846                serde_json::to_string(&json!({
4847                    "state": "confirmed",
4848                    "code_phrase": code,
4849                }))?
4850            );
4851        } else {
4852            println!("digits match. Daemon will finalize the handshake on its next tick.");
4853            println!("Run `wire peers` after a few seconds to confirm.");
4854        }
4855    } else {
4856        p.status = "aborted".to_string();
4857        p.last_error = Some(format!(
4858            "SAS digit mismatch (typed {typed}, expected {stored})"
4859        ));
4860        let client = crate::relay_client::RelayClient::new(&p.relay_url);
4861        let _ = client.pair_abandon(&p.code_hash);
4862        crate::pending_pair::write_pending(&p)?;
4863        crate::os_notify::toast(
4864            &format!("wire — pair aborted ({})", p.code),
4865            p.last_error.as_deref().unwrap_or("digits mismatch"),
4866        );
4867        if as_json {
4868            println!(
4869                "{}",
4870                serde_json::to_string(&json!({
4871                    "state": "aborted",
4872                    "code_phrase": code,
4873                    "error": "digits mismatch",
4874                }))?
4875            );
4876        }
4877        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
4878    }
4879    Ok(())
4880}
4881
4882fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
4883    if watch {
4884        return cmd_pair_list_watch(watch_interval_secs);
4885    }
4886    let spake2_items = crate::pending_pair::list_pending()?;
4887    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
4888    if as_json {
4889        // Backwards-compat: flat SPAKE2 array (the shape every existing
4890        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
4891        // surface programmatically via `wire pair-list-inbound --json`
4892        // and via `wire status --json` `pending_pairs.inbound_*` fields.
4893        println!("{}", serde_json::to_string(&spake2_items)?);
4894        return Ok(());
4895    }
4896    if spake2_items.is_empty() && inbound_items.is_empty() {
4897        println!("no pending pair sessions.");
4898        return Ok(());
4899    }
4900    // v0.5.14: inbound section first — these need operator action right now.
4901    // SPAKE2 sessions are typically already mid-flow.
4902    if !inbound_items.is_empty() {
4903        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
4904        println!(
4905            "{:<20} {:<35} {:<25} NEXT STEP",
4906            "PEER", "RELAY", "RECEIVED"
4907        );
4908        for p in &inbound_items {
4909            println!(
4910                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
4911                p.peer_handle,
4912                p.peer_relay_url,
4913                p.received_at,
4914                peer = p.peer_handle,
4915            );
4916        }
4917        println!();
4918    }
4919    if !spake2_items.is_empty() {
4920        println!("SPAKE2 SESSIONS");
4921        println!(
4922            "{:<15} {:<8} {:<18} {:<10} NOTE",
4923            "CODE", "ROLE", "STATUS", "SAS"
4924        );
4925        for p in spake2_items {
4926            let sas = p
4927                .sas
4928                .as_ref()
4929                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
4930                .unwrap_or_else(|| "—".to_string());
4931            let note = p
4932                .last_error
4933                .as_deref()
4934                .or(p.peer_did.as_deref())
4935                .unwrap_or("");
4936            println!(
4937                "{:<15} {:<8} {:<18} {:<10} {}",
4938                p.code, p.role, p.status, sas, note
4939            );
4940        }
4941    }
4942    Ok(())
4943}
4944
4945/// Stream-mode pair-list: never exits. Diffs per-code state every
4946/// `interval_secs` and prints one JSON line per transition (creation,
4947/// status flip, deletion). Useful for shell pipelines:
4948///
4949/// ```text
4950/// wire pair-list --watch | while read line; do
4951///     CODE=$(echo "$line" | jq -r .code)
4952///     STATUS=$(echo "$line" | jq -r .status)
4953///     ...
4954/// done
4955/// ```
4956fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
4957    use std::collections::HashMap;
4958    use std::io::Write;
4959    let interval = std::time::Duration::from_secs(interval_secs.max(1));
4960    // Emit a snapshot synthetic event for every currently-pending pair on
4961    // startup so a consumer that arrives mid-flight sees the current state.
4962    let mut prev: HashMap<String, String> = HashMap::new();
4963    {
4964        let items = crate::pending_pair::list_pending()?;
4965        for p in &items {
4966            println!("{}", serde_json::to_string(&p)?);
4967            prev.insert(p.code.clone(), p.status.clone());
4968        }
4969        // Flush so the consumer's `while read` gets the snapshot promptly.
4970        let _ = std::io::stdout().flush();
4971    }
4972    loop {
4973        std::thread::sleep(interval);
4974        let items = match crate::pending_pair::list_pending() {
4975            Ok(v) => v,
4976            Err(_) => continue,
4977        };
4978        let mut cur: HashMap<String, String> = HashMap::new();
4979        for p in &items {
4980            cur.insert(p.code.clone(), p.status.clone());
4981            match prev.get(&p.code) {
4982                None => {
4983                    // New code appeared.
4984                    println!("{}", serde_json::to_string(&p)?);
4985                }
4986                Some(prev_status) if prev_status != &p.status => {
4987                    // Status flipped.
4988                    println!("{}", serde_json::to_string(&p)?);
4989                }
4990                _ => {}
4991            }
4992        }
4993        for code in prev.keys() {
4994            if !cur.contains_key(code) {
4995                // File disappeared → finalized or cancelled. Emit a synthetic
4996                // "removed" marker so the consumer sees the terminal event.
4997                println!(
4998                    "{}",
4999                    serde_json::to_string(&json!({
5000                        "code": code,
5001                        "status": "removed",
5002                        "_synthetic": true,
5003                    }))?
5004                );
5005            }
5006        }
5007        let _ = std::io::stdout().flush();
5008        prev = cur;
5009    }
5010}
5011
5012/// Block until a pending pair reaches `target_status` or terminates. Process
5013/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
5014/// timeout) so shell scripts can branch directly.
5015fn cmd_pair_watch(
5016    code_phrase: &str,
5017    target_status: &str,
5018    timeout_secs: u64,
5019    as_json: bool,
5020) -> Result<()> {
5021    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5022    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5023    let mut last_seen_status: Option<String> = None;
5024    loop {
5025        let p_opt = crate::pending_pair::read_pending(&code)?;
5026        let now = std::time::Instant::now();
5027        match p_opt {
5028            None => {
5029                // File gone — either finalized (success if target=sas_ready
5030                // since finalization implies it passed sas_ready) or never
5031                // existed. Distinguish by whether we ever saw it.
5032                if last_seen_status.is_some() {
5033                    if as_json {
5034                        println!(
5035                            "{}",
5036                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5037                        );
5038                    } else {
5039                        println!("pair {code} finalized (file removed)");
5040                    }
5041                    return Ok(());
5042                } else {
5043                    if as_json {
5044                        println!(
5045                            "{}",
5046                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5047                        );
5048                    }
5049                    std::process::exit(1);
5050                }
5051            }
5052            Some(p) => {
5053                let cur = p.status.clone();
5054                if Some(cur.clone()) != last_seen_status {
5055                    if as_json {
5056                        // Emit per-transition line so scripts can stream.
5057                        println!("{}", serde_json::to_string(&p)?);
5058                    }
5059                    last_seen_status = Some(cur.clone());
5060                }
5061                if cur == target_status {
5062                    if !as_json {
5063                        let sas_str = p
5064                            .sas
5065                            .as_ref()
5066                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5067                            .unwrap_or_else(|| "—".to_string());
5068                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
5069                    }
5070                    return Ok(());
5071                }
5072                if cur == "aborted" || cur == "aborted_restart" {
5073                    if !as_json {
5074                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
5075                        eprintln!("pair {code} {cur}: {err}");
5076                    }
5077                    std::process::exit(1);
5078                }
5079            }
5080        }
5081        if now >= deadline {
5082            if !as_json {
5083                eprintln!(
5084                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5085                );
5086            }
5087            std::process::exit(2);
5088        }
5089        std::thread::sleep(std::time::Duration::from_millis(250));
5090    }
5091}
5092
5093fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5094    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5095    let p = crate::pending_pair::read_pending(&code)?
5096        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5097    let client = crate::relay_client::RelayClient::new(&p.relay_url);
5098    let _ = client.pair_abandon(&p.code_hash);
5099    crate::pending_pair::delete_pending(&code)?;
5100    if as_json {
5101        println!(
5102            "{}",
5103            serde_json::to_string(&json!({
5104                "state": "cancelled",
5105                "code_phrase": code,
5106            }))?
5107        );
5108    } else {
5109        println!("cancelled pending pair {code} (relay slot released, file removed).");
5110    }
5111    Ok(())
5112}
5113
5114// ---------- pair-abandon — release stuck pair-slot ----------
5115
5116fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5117    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
5118    // typed — normalize via the existing parser.
5119    let code = crate::sas::parse_code_phrase(code_phrase)?;
5120    let code_hash = crate::pair_session::derive_code_hash(code);
5121    let client = crate::relay_client::RelayClient::new(relay_url);
5122    client.pair_abandon(&code_hash)?;
5123    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
5124    println!("host can now issue a fresh code; guest can re-join.");
5125    Ok(())
5126}
5127
5128// ---------- invite / accept — one-paste pair (v0.4.0) ----------
5129
5130fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
5131    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
5132
5133    // If --share, register the invite at the relay's short-URL endpoint and
5134    // build the one-curl onboarding line for the peer to paste.
5135    let share_payload: Option<Value> = if share {
5136        let client = reqwest::blocking::Client::new();
5137        let single_use = if uses == 1 { Some(1u32) } else { None };
5138        let body = json!({
5139            "invite_url": url,
5140            "ttl_seconds": ttl,
5141            "uses": single_use,
5142        });
5143        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
5144        let resp = client.post(&endpoint).json(&body).send()?;
5145        if !resp.status().is_success() {
5146            let code = resp.status();
5147            let txt = resp.text().unwrap_or_default();
5148            bail!("relay {code} on /v1/invite/register: {txt}");
5149        }
5150        let parsed: Value = resp.json()?;
5151        let token = parsed
5152            .get("token")
5153            .and_then(Value::as_str)
5154            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
5155            .to_string();
5156        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
5157        let curl_line = format!("curl -fsSL {share_url} | sh");
5158        Some(json!({
5159            "token": token,
5160            "share_url": share_url,
5161            "curl": curl_line,
5162            "expires_unix": parsed.get("expires_unix"),
5163        }))
5164    } else {
5165        None
5166    };
5167
5168    if as_json {
5169        let mut out = json!({
5170            "invite_url": url,
5171            "ttl_secs": ttl,
5172            "uses": uses,
5173            "relay": relay,
5174        });
5175        if let Some(s) = &share_payload {
5176            out["share"] = s.clone();
5177        }
5178        println!("{}", serde_json::to_string(&out)?);
5179    } else if let Some(s) = share_payload {
5180        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
5181        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
5182        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
5183        println!("{curl}");
5184    } else {
5185        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
5186        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
5187        println!("{url}");
5188    }
5189    Ok(())
5190}
5191
5192fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
5193    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
5194    // resolve it to the underlying wire://pair?... URL via ?format=url before
5195    // accepting. Saves them from having to know which URL shape goes where.
5196    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
5197        let sep = if url.contains('?') { '&' } else { '?' };
5198        let resolve_url = format!("{url}{sep}format=url");
5199        let client = reqwest::blocking::Client::new();
5200        let resp = client
5201            .get(&resolve_url)
5202            .send()
5203            .with_context(|| format!("GET {resolve_url}"))?;
5204        if !resp.status().is_success() {
5205            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
5206        }
5207        let body = resp.text().unwrap_or_default().trim().to_string();
5208        if !body.starts_with("wire://pair?") {
5209            bail!(
5210                "short URL {url} did not resolve to a wire:// invite. \
5211                 (got: {}{})",
5212                body.chars().take(80).collect::<String>(),
5213                if body.chars().count() > 80 { "…" } else { "" }
5214            );
5215        }
5216        body
5217    } else {
5218        url.to_string()
5219    };
5220
5221    let result = crate::pair_invite::accept_invite(&resolved)?;
5222    if as_json {
5223        println!("{}", serde_json::to_string(&result)?);
5224    } else {
5225        let did = result
5226            .get("paired_with")
5227            .and_then(Value::as_str)
5228            .unwrap_or("?");
5229        println!("paired with {did}");
5230        println!(
5231            "you can now: wire send {} <kind> <body>",
5232            crate::agent_card::display_handle_from_did(did)
5233        );
5234    }
5235    Ok(())
5236}
5237
5238// ---------- whois / profile (v0.5) ----------
5239
5240fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
5241    if let Some(h) = handle {
5242        let parsed = crate::pair_profile::parse_handle(h)?;
5243        // Special-case: if the supplied handle matches our own, skip the
5244        // network round-trip and print local.
5245        if config::is_initialized()? {
5246            let card = config::read_agent_card()?;
5247            let local_handle = card
5248                .get("profile")
5249                .and_then(|p| p.get("handle"))
5250                .and_then(Value::as_str)
5251                .map(str::to_string);
5252            if local_handle.as_deref() == Some(h) {
5253                return cmd_whois(None, as_json, None);
5254            }
5255        }
5256        // Remote resolution via .well-known/wire/agent on the handle's domain.
5257        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
5258        if as_json {
5259            println!("{}", serde_json::to_string(&resolved)?);
5260        } else {
5261            print_resolved_profile(&resolved);
5262        }
5263        return Ok(());
5264    }
5265    let card = config::read_agent_card()?;
5266    if as_json {
5267        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
5268        println!(
5269            "{}",
5270            serde_json::to_string(&json!({
5271                "did": card.get("did").cloned().unwrap_or(Value::Null),
5272                "profile": profile,
5273            }))?
5274        );
5275    } else {
5276        print!("{}", crate::pair_profile::render_self_summary()?);
5277    }
5278    Ok(())
5279}
5280
5281fn print_resolved_profile(resolved: &Value) {
5282    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
5283    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
5284    let relay = resolved
5285        .get("relay_url")
5286        .and_then(Value::as_str)
5287        .unwrap_or("");
5288    let slot = resolved
5289        .get("slot_id")
5290        .and_then(Value::as_str)
5291        .unwrap_or("");
5292    let profile = resolved
5293        .get("card")
5294        .and_then(|c| c.get("profile"))
5295        .cloned()
5296        .unwrap_or(Value::Null);
5297    println!("{did}");
5298    println!("  nick:         {nick}");
5299    if !relay.is_empty() {
5300        println!("  relay_url:    {relay}");
5301    }
5302    if !slot.is_empty() {
5303        println!("  slot_id:      {slot}");
5304    }
5305    let pick =
5306        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
5307    if let Some(s) = pick("display_name") {
5308        println!("  display_name: {s}");
5309    }
5310    if let Some(s) = pick("emoji") {
5311        println!("  emoji:        {s}");
5312    }
5313    if let Some(s) = pick("motto") {
5314        println!("  motto:        {s}");
5315    }
5316    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
5317        let joined: Vec<String> = arr
5318            .iter()
5319            .filter_map(|v| v.as_str().map(str::to_string))
5320            .collect();
5321        println!("  vibe:         {}", joined.join(", "));
5322    }
5323    if let Some(s) = pick("pronouns") {
5324        println!("  pronouns:     {s}");
5325    }
5326}
5327
5328/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
5329/// signed pair_drop event with our card + slot coords, deliver via the
5330/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
5331/// Peer's daemon completes the bilateral pin on its next pull and emits a
5332/// pair_drop_ack carrying their slot_token so we can send back.
5333/// Extract just the host portion from `https://host:port/path` → `host`.
5334/// Returns empty string if the URL is malformed.
5335fn host_of_url(url: &str) -> String {
5336    let no_scheme = url
5337        .trim_start_matches("https://")
5338        .trim_start_matches("http://");
5339    no_scheme
5340        .split('/')
5341        .next()
5342        .unwrap_or("")
5343        .split(':')
5344        .next()
5345        .unwrap_or("")
5346        .to_string()
5347}
5348
5349/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
5350/// operator's own relay? Used to suppress the cross-relay phishing
5351/// warning in `wire add` for the happy path.
5352fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
5353    // Hard-coded known-good list. wireup.net is the default relay.
5354    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
5355    let peer_domain = peer_domain.trim().to_ascii_lowercase();
5356    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
5357        return true;
5358    }
5359    // Operator's OWN relay is implicitly trusted — they're already
5360    // bound to it; pairing same-relay peers is the common case.
5361    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
5362    if !our_host.is_empty() && our_host == peer_domain {
5363        return true;
5364    }
5365    false
5366}
5367
5368/// v0.6.6: pair with a sister session on this machine without federation.
5369/// Reads the sister's agent-card + endpoints from disk, pins them into our
5370/// trust + relay_state, builds the same `pair_drop` event the federation
5371/// path would emit, then POSTs it directly to the sister's local-relay slot.
5372/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
5373/// the cwd-derived `wire`) are addressable because the local relay never
5374/// needed a public claim for sister coordination.
5375/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
5376/// to a local sister session.
5377///
5378/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
5379/// either form. Exact session-name matches always win; nickname matches
5380/// are a fallback so operators can type "winter-bay" instead of "wire".
5381/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
5382/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
5383/// with the candidate list so the caller can surface a disambiguation
5384/// hint instead of silently picking one.
5385fn resolve_local_session<'a>(
5386    sessions: &'a [crate::session::SessionInfo],
5387    input: &str,
5388) -> Result<&'a crate::session::SessionInfo, ResolveError> {
5389    // Exact session-name match always wins, even if a nickname elsewhere
5390    // also matches. Predictable for scripts and operator muscle memory.
5391    if let Some(s) = sessions.iter().find(|s| s.name == input) {
5392        return Ok(s);
5393    }
5394    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
5395        .iter()
5396        .filter(|s| {
5397            s.character
5398                .as_ref()
5399                .map(|c| c.nickname == input)
5400                .unwrap_or(false)
5401        })
5402        .collect();
5403    match nick_matches.len() {
5404        0 => Err(ResolveError::NotFound),
5405        1 => Ok(nick_matches[0]),
5406        _ => Err(ResolveError::Ambiguous(
5407            nick_matches.iter().map(|s| s.name.clone()).collect(),
5408        )),
5409    }
5410}
5411
5412#[derive(Debug)]
5413enum ResolveError {
5414    NotFound,
5415    Ambiguous(Vec<String>),
5416}
5417
5418/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
5419/// to a pinned peer's canonical handle.
5420///
5421/// `wire send <peer>` accepts either the handle the peer registered with
5422/// or their character nickname (DID-hash-derived). Exact handle match
5423/// always wins. When a nickname matches multiple peers (theoretically
5424/// possible via DID-hash collision in the (adj, noun) space), returns
5425/// `Ambiguous` so the caller can surface a disambiguation hint instead
5426/// of silently picking one.
5427///
5428/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
5429/// overrides on the peer's side live in their local `display.json` and
5430/// aren't yet published via agent-card. (That's the v0.7+ federation
5431/// lifecycle work — peers publishing overrides so we resolve by what
5432/// they call themselves, not just what their DID hashes to.)
5433fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
5434    let trust = match config::read_trust() {
5435        Ok(t) => t,
5436        Err(_) => return Ok(None),
5437    };
5438    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
5439        Some(a) => a,
5440        None => return Ok(None),
5441    };
5442    if agents.contains_key(input) {
5443        return Ok(Some(input.to_string()));
5444    }
5445    let mut nick_matches: Vec<String> = Vec::new();
5446    for (handle, agent) in agents.iter() {
5447        // v0.7.0-alpha.6: prefer peer's published display nickname over
5448        // auto-derived. Allows `wire send <their-chosen-name>` not just
5449        // `wire send <their-did-hash-derived-name>`.
5450        let character = match agent.get("card") {
5451            Some(card) => crate::character::Character::from_card(card),
5452            None => match agent.get("did").and_then(Value::as_str) {
5453                Some(did) => crate::character::Character::from_did(did),
5454                None => continue,
5455            },
5456        };
5457        if character.nickname == input {
5458            nick_matches.push(handle.clone());
5459        }
5460    }
5461    match nick_matches.len() {
5462        0 => Ok(None),
5463        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
5464        _ => Err(ResolveError::Ambiguous(nick_matches)),
5465    }
5466}
5467
5468fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
5469    // 1. Locate sister session by name OR character nickname.
5470    let sessions = crate::session::list_sessions()?;
5471    let sister = match resolve_local_session(&sessions, sister_name) {
5472        Ok(s) => s,
5473        Err(ResolveError::NotFound) => bail!(
5474            "no sister session named `{sister_name}` (matched by session name or character nickname). \
5475             Run `wire session list` to see what's available."
5476        ),
5477        Err(ResolveError::Ambiguous(candidates)) => bail!(
5478            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
5479             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
5480            candidates.len(),
5481            candidates.join(", ")
5482        ),
5483    };
5484    // If we matched via nickname (not exact name), surface that so the
5485    // operator sees what we resolved to. Quiet when names match exactly.
5486    if sister.name != sister_name {
5487        eprintln!(
5488            "wire add: resolved nickname `{sister_name}` → session `{}`",
5489            sister.name
5490        );
5491    }
5492
5493    // 2. Refuse self-pair — operator owns both sides, but a self-loop
5494    // breaks the bilateral state machine.
5495    let our_card = config::read_agent_card()
5496        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
5497    let our_did = our_card
5498        .get("did")
5499        .and_then(Value::as_str)
5500        .ok_or_else(|| anyhow!("agent-card missing did"))?
5501        .to_string();
5502    if let Some(sister_did) = sister.did.as_deref()
5503        && sister_did == our_did
5504    {
5505        bail!("refusing to add self (`{sister_name}` is this very session)");
5506    }
5507
5508    // 3. Read sister's agent-card + relay state from disk.
5509    let sister_card_path = sister
5510        .home_dir
5511        .join("config")
5512        .join("wire")
5513        .join("agent-card.json");
5514    let sister_card: Value = serde_json::from_slice(
5515        &std::fs::read(&sister_card_path)
5516            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
5517    )
5518    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
5519    let sister_relay_state: Value = std::fs::read(
5520        sister
5521            .home_dir
5522            .join("config")
5523            .join("wire")
5524            .join("relay.json"),
5525    )
5526    .ok()
5527    .and_then(|b| serde_json::from_slice(&b).ok())
5528    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
5529
5530    let sister_did = sister_card
5531        .get("did")
5532        .and_then(Value::as_str)
5533        .ok_or_else(|| anyhow!("sister card missing did"))?
5534        .to_string();
5535    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
5536
5537    // Pull sister's full endpoint set; we want the local one for delivery
5538    // and we'll pin all of them so OUR pushes prefer local-first per the
5539    // existing routing logic.
5540    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
5541    if sister_endpoints.is_empty() {
5542        bail!(
5543            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
5544        );
5545    }
5546    let sister_local = sister_endpoints
5547        .iter()
5548        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
5549    let delivery_endpoint = match sister_local {
5550        Some(e) => e.clone(),
5551        None => sister_endpoints[0].clone(),
5552    };
5553
5554    // 4. Ensure WE have a slot to advertise back. For local-only sessions
5555    // this is the local slot; for dual-slot sessions, federation is fine.
5556    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
5557    // for pure local-only — instead, pick our own existing federation
5558    // endpoint if present, else fall back to whatever's first.
5559    let our_relay_state = config::read_relay_state()?;
5560    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
5561    if our_endpoints.is_empty() {
5562        bail!(
5563            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
5564        );
5565    }
5566    let our_advertised = our_endpoints
5567        .iter()
5568        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
5569        .cloned()
5570        .unwrap_or_else(|| our_endpoints[0].clone());
5571
5572    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
5573    // relay_state.peers with their full endpoint set. slot_token lands
5574    // via pair_drop_ack as usual.
5575    let mut trust = config::read_trust()?;
5576    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
5577    config::write_trust(&trust)?;
5578    let mut relay_state = config::read_relay_state()?;
5579    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
5580    config::write_relay_state(&relay_state)?;
5581
5582    // 6. Build the same pair_drop event the federation path emits, with
5583    // our card + endpoints in the body so the sister can pin us back.
5584    let sk_seed = config::read_private_key()?;
5585    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
5586    let pk_b64 = our_card
5587        .get("verify_keys")
5588        .and_then(Value::as_object)
5589        .and_then(|m| m.values().next())
5590        .and_then(|v| v.get("key"))
5591        .and_then(Value::as_str)
5592        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
5593    let pk_bytes = crate::signing::b64decode(pk_b64)?;
5594    let now = time::OffsetDateTime::now_utc()
5595        .format(&time::format_description::well_known::Rfc3339)
5596        .unwrap_or_default();
5597    let mut body = json!({
5598        "card": our_card,
5599        "relay_url": our_advertised.relay_url,
5600        "slot_id": our_advertised.slot_id,
5601        "slot_token": our_advertised.slot_token,
5602    });
5603    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
5604    let event = json!({
5605        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5606        "timestamp": now,
5607        "from": our_did,
5608        "to": sister_did,
5609        "type": "pair_drop",
5610        "kind": 1100u32,
5611        "body": body,
5612    });
5613    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
5614    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
5615
5616    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
5617    // (the federation handle indexer) — we already know the slot coords
5618    // from disk, so post_event is sufficient.
5619    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
5620    client
5621        .post_event(
5622            &delivery_endpoint.slot_id,
5623            &delivery_endpoint.slot_token,
5624            &signed,
5625        )
5626        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
5627
5628    if as_json {
5629        println!(
5630            "{}",
5631            serde_json::to_string(&json!({
5632                "handle": sister_name,
5633                "paired_with": sister_did,
5634                "peer_handle": sister_handle,
5635                "event_id": event_id,
5636                "delivered_via": match delivery_endpoint.scope {
5637                    crate::endpoints::EndpointScope::Local => "local",
5638                    crate::endpoints::EndpointScope::Lan => "lan",
5639                    crate::endpoints::EndpointScope::Uds => "uds",
5640                    crate::endpoints::EndpointScope::Federation => "federation",
5641                },
5642                "status": "drop_sent",
5643            }))?
5644        );
5645    } else {
5646        let scope = match delivery_endpoint.scope {
5647            crate::endpoints::EndpointScope::Local => "local",
5648            crate::endpoints::EndpointScope::Lan => "lan",
5649            crate::endpoints::EndpointScope::Uds => "uds",
5650            crate::endpoints::EndpointScope::Federation => "federation",
5651        };
5652        println!(
5653            "→ 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.",
5654            delivery_endpoint.relay_url
5655        );
5656    }
5657    Ok(())
5658}
5659
5660fn cmd_add(
5661    handle_arg: &str,
5662    relay_override: Option<&str>,
5663    local_sister: bool,
5664    as_json: bool,
5665) -> Result<()> {
5666    if local_sister {
5667        return cmd_add_local_sister(handle_arg, as_json);
5668    }
5669    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
5670
5671    // 1. Auto-init self if needed + ensure a relay slot.
5672    let (our_did, our_relay, our_slot_id, our_slot_token) =
5673        crate::pair_invite::ensure_self_with_relay(relay_override)?;
5674    if our_did == format!("did:wire:{}", parsed.nick) {
5675        // Lazy guard — actual self-add would also be caught by FCFS later.
5676        bail!("refusing to add self (handle matches own DID)");
5677    }
5678
5679    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
5680    // already sitting in pending-inbound, the operator is now accepting it.
5681    // Pin trust, save relay coords + slot_token from the stored drop, ship
5682    // our own slot_token back via pair_drop_ack, delete the pending record.
5683    //
5684    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
5685    // receiver-side auto-promote was removed there; operator consent flows
5686    // through here. After this branch returns, both sides are bilaterally
5687    // pinned and capability flows in both directions.
5688    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
5689        return cmd_add_accept_pending(
5690            handle_arg,
5691            &parsed.nick,
5692            &pending,
5693            &our_relay,
5694            &our_slot_id,
5695            &our_slot_token,
5696            as_json,
5697        );
5698    }
5699
5700    // v0.5.19 (#9.4): cross-relay phishing guardrail.
5701    //
5702    // Threat: operator wants to add `boss@wireup.net` but types
5703    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
5704    // The .well-known resolution returns whoever claimed the nick on the
5705    // *typo* relay, the bilateral gate still completes (the attacker
5706    // accepts the pair on their side), and the operator pins the
5707    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
5708    // there's no asymmetry to detect when the attacker WANTS to be
5709    // paired.
5710    //
5711    // Mitigation: warn loudly when the peer's relay domain is novel
5712    // (not the operator's own relay, not in a small known-good set).
5713    // Doesn't block — operators have legitimate reasons to pair across
5714    // relays. The signal lands in shell history so a phished operator
5715    // can find it in retrospect.
5716    if !is_known_relay_domain(&parsed.domain, &our_relay) {
5717        eprintln!(
5718            "wire add: WARN unfamiliar relay domain `{}`.",
5719            parsed.domain
5720        );
5721        eprintln!(
5722            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
5723            host_of_url(&our_relay)
5724        );
5725        eprintln!(
5726            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
5727            parsed.nick
5728        );
5729        eprintln!(
5730            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
5731            parsed.nick
5732        );
5733        eprintln!("  peer out-of-band that they actually run a relay at this domain");
5734        eprintln!("  before relying on the pair. (See issue #9.4.)");
5735    }
5736
5737    // 2. Resolve peer via .well-known on their relay.
5738    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
5739    let peer_card = resolved
5740        .get("card")
5741        .cloned()
5742        .ok_or_else(|| anyhow!("resolved missing card"))?;
5743    let peer_did = resolved
5744        .get("did")
5745        .and_then(Value::as_str)
5746        .ok_or_else(|| anyhow!("resolved missing did"))?
5747        .to_string();
5748    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
5749    let peer_slot_id = resolved
5750        .get("slot_id")
5751        .and_then(Value::as_str)
5752        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
5753        .to_string();
5754    let peer_relay = resolved
5755        .get("relay_url")
5756        .and_then(Value::as_str)
5757        .map(str::to_string)
5758        .or_else(|| relay_override.map(str::to_string))
5759        .unwrap_or_else(|| format!("https://{}", parsed.domain));
5760
5761    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
5762    let mut trust = config::read_trust()?;
5763    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
5764    config::write_trust(&trust)?;
5765    let mut relay_state = config::read_relay_state()?;
5766    let existing_token = relay_state
5767        .get("peers")
5768        .and_then(|p| p.get(&peer_handle))
5769        .and_then(|p| p.get("slot_token"))
5770        .and_then(Value::as_str)
5771        .map(str::to_string)
5772        .unwrap_or_default();
5773    relay_state["peers"][&peer_handle] = json!({
5774        "relay_url": peer_relay,
5775        "slot_id": peer_slot_id,
5776        "slot_token": existing_token, // empty until pair_drop_ack lands
5777    });
5778    config::write_relay_state(&relay_state)?;
5779
5780    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
5781    // is the v0.5 zero-paste open-mode path).
5782    let our_card = config::read_agent_card()?;
5783    let sk_seed = config::read_private_key()?;
5784    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
5785    let pk_b64 = our_card
5786        .get("verify_keys")
5787        .and_then(Value::as_object)
5788        .and_then(|m| m.values().next())
5789        .and_then(|v| v.get("key"))
5790        .and_then(Value::as_str)
5791        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
5792    let pk_bytes = crate::signing::b64decode(pk_b64)?;
5793    let now = time::OffsetDateTime::now_utc()
5794        .format(&time::format_description::well_known::Rfc3339)
5795        .unwrap_or_default();
5796    // v0.5.17: advertise all our endpoints (federation + optional local)
5797    // to the peer in the pair_drop body. Back-compat: top-level
5798    // relay_url/slot_id/slot_token still point at the federation
5799    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
5800    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
5801    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
5802    let mut body = json!({
5803        "card": our_card,
5804        "relay_url": our_relay,
5805        "slot_id": our_slot_id,
5806        "slot_token": our_slot_token,
5807    });
5808    if !our_endpoints.is_empty() {
5809        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
5810    }
5811    let event = json!({
5812        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5813        "timestamp": now,
5814        "from": our_did,
5815        "to": peer_did,
5816        "type": "pair_drop",
5817        "kind": 1100u32,
5818        "body": body,
5819    });
5820    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
5821
5822    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
5823    let client = crate::relay_client::RelayClient::new(&peer_relay);
5824    let resp = client.handle_intro(&parsed.nick, &signed)?;
5825    let event_id = signed
5826        .get("event_id")
5827        .and_then(Value::as_str)
5828        .unwrap_or("")
5829        .to_string();
5830
5831    if as_json {
5832        println!(
5833            "{}",
5834            serde_json::to_string(&json!({
5835                "handle": handle_arg,
5836                "paired_with": peer_did,
5837                "peer_handle": peer_handle,
5838                "event_id": event_id,
5839                "drop_response": resp,
5840                "status": "drop_sent",
5841            }))?
5842        );
5843    } else {
5844        println!(
5845            "→ 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."
5846        );
5847    }
5848    Ok(())
5849}
5850
5851/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
5852/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
5853/// coords + slot_token from the stored drop, ship our slot_token back via
5854/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
5855/// invite-URL path (which is already bilateral by virtue of the pre-shared
5856/// nonce).
5857fn cmd_add_accept_pending(
5858    handle_arg: &str,
5859    peer_nick: &str,
5860    pending: &crate::pending_inbound_pair::PendingInboundPair,
5861    _our_relay: &str,
5862    _our_slot_id: &str,
5863    _our_slot_token: &str,
5864    as_json: bool,
5865) -> Result<()> {
5866    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
5867    //    `wire add` against this handle while a drop was waiting.
5868    let mut trust = config::read_trust()?;
5869    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
5870    config::write_trust(&trust)?;
5871
5872    // 2. Record peer's relay coords + slot_token (already shipped to us in
5873    //    the original drop body; held back until now).
5874    // v0.5.17: pin all advertised endpoints (federation + optional local).
5875    // Falls back to a single federation entry when the record was written
5876    // by v0.5.16-era code that didn't carry endpoints[].
5877    let mut relay_state = config::read_relay_state()?;
5878    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
5879        vec![crate::endpoints::Endpoint::federation(
5880            pending.peer_relay_url.clone(),
5881            pending.peer_slot_id.clone(),
5882            pending.peer_slot_token.clone(),
5883        )]
5884    } else {
5885        pending.peer_endpoints.clone()
5886    };
5887    crate::endpoints::pin_peer_endpoints(
5888        &mut relay_state,
5889        &pending.peer_handle,
5890        &endpoints_to_pin,
5891    )?;
5892    config::write_relay_state(&relay_state)?;
5893
5894    // 3. Ship our slot_token to peer via pair_drop_ack so they can write back.
5895    crate::pair_invite::send_pair_drop_ack(
5896        &pending.peer_handle,
5897        &pending.peer_relay_url,
5898        &pending.peer_slot_id,
5899        &pending.peer_slot_token,
5900    )
5901    .with_context(|| {
5902        format!(
5903            "pair_drop_ack send to {} @ {} slot {} failed",
5904            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
5905        )
5906    })?;
5907
5908    // 4. Delete the pending-inbound record now that bilateral is complete.
5909    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
5910
5911    if as_json {
5912        println!(
5913            "{}",
5914            serde_json::to_string(&json!({
5915                "handle": handle_arg,
5916                "paired_with": pending.peer_did,
5917                "peer_handle": pending.peer_handle,
5918                "status": "bilateral_accepted",
5919                "via": "pending_inbound",
5920            }))?
5921        );
5922    } else {
5923        println!(
5924            "→ 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} \"...\"`.",
5925            peer = pending.peer_handle,
5926        );
5927    }
5928    Ok(())
5929}
5930
5931/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
5932/// for a pending-inbound pair request. Pin trust, write relay_state from the
5933/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
5934/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
5935/// when a pending-inbound record exists, but without needing to remember
5936/// the peer's relay domain.
5937fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
5938    let nick = crate::agent_card::bare_handle(peer_nick);
5939    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
5940        anyhow!(
5941            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
5942             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
5943        )
5944    })?;
5945    let (_our_did, our_relay, our_slot_id, our_slot_token) =
5946        crate::pair_invite::ensure_self_with_relay(None)?;
5947    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
5948    cmd_add_accept_pending(
5949        &handle_arg,
5950        nick,
5951        &pending,
5952        &our_relay,
5953        &our_slot_id,
5954        &our_slot_token,
5955        as_json,
5956    )
5957}
5958
5959/// v0.5.14: programmatic access to pending-inbound for scripts.
5960/// `wire pair-list-inbound --json` returns a flat array of records.
5961fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
5962    let items = crate::pending_inbound_pair::list_pending_inbound()?;
5963    if as_json {
5964        println!("{}", serde_json::to_string(&items)?);
5965        return Ok(());
5966    }
5967    if items.is_empty() {
5968        println!("no pending inbound pair requests.");
5969        return Ok(());
5970    }
5971    println!("{:<20} {:<35} {:<25} DID", "PEER", "RELAY", "RECEIVED");
5972    for p in items {
5973        println!(
5974            "{:<20} {:<35} {:<25} {}",
5975            p.peer_handle, p.peer_relay_url, p.received_at, p.peer_did,
5976        );
5977    }
5978    println!("→ accept with `wire pair-accept <peer>`; refuse with `wire pair-reject <peer>`.");
5979    Ok(())
5980}
5981
5982/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
5983/// without pairing. No event is sent back to the peer; their side stays
5984/// pending until they time out or the operator-side data ages out.
5985fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
5986    let nick = crate::agent_card::bare_handle(peer_nick);
5987    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
5988    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
5989
5990    if as_json {
5991        println!(
5992            "{}",
5993            serde_json::to_string(&json!({
5994                "peer": nick,
5995                "rejected": existed.is_some(),
5996                "had_pending": existed.is_some(),
5997            }))?
5998        );
5999    } else if existed.is_some() {
6000        println!(
6001            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6002        );
6003    } else {
6004        println!("no pending pair from {nick} — nothing to reject");
6005    }
6006    Ok(())
6007}
6008
6009// ---------- session (v0.5.16) ----------
6010//
6011// Multi-session wire on one machine. See src/session.rs for the storage
6012// layout + naming rules. The CLI dispatcher here orchestrates child
6013// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
6014// each session-local `init` / `claim` / `daemon` runs in its own world
6015// without cross-contamination via env vars in this process.
6016
6017/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
6018/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
6019fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6020    match cmd {
6021        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6022        MeshCommand::Broadcast {
6023            kind,
6024            scope,
6025            exclude,
6026            noreply,
6027            body,
6028            json,
6029        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6030        MeshCommand::Role { action } => cmd_mesh_role(action),
6031        MeshCommand::Route {
6032            role,
6033            strategy,
6034            exclude,
6035            kind,
6036            body,
6037            json,
6038        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
6039    }
6040}
6041
6042/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
6043/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
6044/// picks ONE via the requested strategy, then signs + pushes the event
6045/// to that peer. Pinned-peers-only by construction (same as broadcast).
6046fn cmd_mesh_route(
6047    role: &str,
6048    strategy: &str,
6049    exclude: &[String],
6050    kind: &str,
6051    body_arg: &str,
6052    as_json: bool,
6053) -> Result<()> {
6054    use std::time::Instant;
6055
6056    if !config::is_initialized()? {
6057        bail!("not initialized — run `wire init <handle>` first");
6058    }
6059    let strategy = strategy.to_ascii_lowercase();
6060    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
6061        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
6062    }
6063
6064    // Our pinned-peer set: only these handles are addressable. mesh-route
6065    // refuses to invent a recipient, same posture as broadcast.
6066    let state = config::read_relay_state()?;
6067    let pinned: std::collections::BTreeSet<String> = state["peers"]
6068        .as_object()
6069        .map(|m| m.keys().cloned().collect())
6070        .unwrap_or_default();
6071
6072    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6073
6074    // Enumerate every sister on the box, read each one's role from its
6075    // signed agent-card. Filter: matching role AND pinned AND not
6076    // excluded. `list_sessions` returns the cross-session view (using the
6077    // v0.6.4 inside-session sessions_root fallback).
6078    let sessions = crate::session::list_sessions()?;
6079    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
6080    for s in &sessions {
6081        let handle = match s.handle.as_ref() {
6082            Some(h) => h.clone(),
6083            None => continue,
6084        };
6085        if exclude_set.contains(handle.as_str()) {
6086            continue;
6087        }
6088        if !pinned.contains(&handle) {
6089            continue;
6090        }
6091        let card_path = s
6092            .home_dir
6093            .join("config")
6094            .join("wire")
6095            .join("agent-card.json");
6096        let card_role = std::fs::read(&card_path)
6097            .ok()
6098            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6099            .and_then(|c| {
6100                c.get("profile")
6101                    .and_then(|p| p.get("role"))
6102                    .and_then(Value::as_str)
6103                    .map(str::to_string)
6104            });
6105        if card_role.as_deref() == Some(role) {
6106            candidates.push((handle, s.did.clone()));
6107        }
6108    }
6109
6110    candidates.sort_by(|a, b| a.0.cmp(&b.0));
6111    candidates.dedup_by(|a, b| a.0 == b.0);
6112
6113    if candidates.is_empty() {
6114        bail!(
6115            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
6116        );
6117    }
6118
6119    let chosen = match strategy.as_str() {
6120        "first" => candidates[0].clone(),
6121        "random" => {
6122            use rand::Rng;
6123            let idx = rand::thread_rng().gen_range(0..candidates.len());
6124            candidates[idx].clone()
6125        }
6126        "round-robin" => {
6127            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
6128            // `{role: last_picked_handle}`. Next pick = first candidate
6129            // alphabetically AFTER last_picked, wrapping around when no
6130            // candidate is greater.
6131            let cursor_path = mesh_route_cursor_path()?;
6132            let mut cursors: std::collections::BTreeMap<String, String> =
6133                read_mesh_route_cursors(&cursor_path);
6134            let last = cursors.get(role).cloned();
6135            let pick = match last {
6136                None => candidates[0].clone(),
6137                Some(last_h) => candidates
6138                    .iter()
6139                    .find(|(h, _)| h.as_str() > last_h.as_str())
6140                    .cloned()
6141                    .unwrap_or_else(|| candidates[0].clone()),
6142            };
6143            cursors.insert(role.to_string(), pick.0.clone());
6144            write_mesh_route_cursors(&cursor_path, &cursors)?;
6145            pick
6146        }
6147        _ => unreachable!(),
6148    };
6149
6150    let (chosen_handle, _chosen_did) = chosen;
6151
6152    // Body parsing follows wire send / mesh broadcast.
6153    let body_value: Value = if body_arg == "-" {
6154        use std::io::Read;
6155        let mut raw = String::new();
6156        std::io::stdin()
6157            .read_to_string(&mut raw)
6158            .with_context(|| "reading body from stdin")?;
6159        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6160    } else if let Some(path) = body_arg.strip_prefix('@') {
6161        let raw =
6162            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6163        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6164    } else {
6165        Value::String(body_arg.to_string())
6166    };
6167
6168    let sk_seed = config::read_private_key()?;
6169    let card = config::read_agent_card()?;
6170    let did = card
6171        .get("did")
6172        .and_then(Value::as_str)
6173        .ok_or_else(|| anyhow!("agent-card missing did"))?
6174        .to_string();
6175    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6176    let pk_b64 = card
6177        .get("verify_keys")
6178        .and_then(Value::as_object)
6179        .and_then(|m| m.values().next())
6180        .and_then(|v| v.get("key"))
6181        .and_then(Value::as_str)
6182        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6183    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6184
6185    let kind_id = parse_kind(kind)?;
6186    let now_iso = time::OffsetDateTime::now_utc()
6187        .format(&time::format_description::well_known::Rfc3339)
6188        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6189
6190    let event = json!({
6191        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6192        "timestamp": now_iso,
6193        "from": did,
6194        "to": format!("did:wire:{chosen_handle}"),
6195        "type": kind,
6196        "kind": kind_id,
6197        "body": json!({
6198            "content": body_value,
6199            "routed_via": {
6200                "role": role,
6201                "strategy": strategy,
6202            },
6203        }),
6204    });
6205    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
6206        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
6207    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6208
6209    let line = serde_json::to_vec(&signed)?;
6210    config::append_outbox_record(&chosen_handle, &line)?;
6211
6212    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
6213    if endpoints.is_empty() {
6214        bail!(
6215            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
6216        );
6217    }
6218    let start = Instant::now();
6219    let mut delivered = false;
6220    let mut last_err: Option<String> = None;
6221    let mut via_scope: Option<String> = None;
6222    for ep in &endpoints {
6223        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
6224        // route via uds_request, others via reqwest. Allows peers with
6225        // UDS-tagged endpoints in their agent-card to receive events
6226        // over the local socket instead of loopback HTTP.
6227        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
6228            Ok(_) => {
6229                delivered = true;
6230                via_scope = Some(
6231                    match ep.scope {
6232                        crate::endpoints::EndpointScope::Local => "local",
6233                        crate::endpoints::EndpointScope::Lan => "lan",
6234                        crate::endpoints::EndpointScope::Uds => "uds",
6235                        crate::endpoints::EndpointScope::Federation => "federation",
6236                    }
6237                    .to_string(),
6238                );
6239                break;
6240            }
6241            Err(e) => last_err = Some(format!("{e:#}")),
6242        }
6243    }
6244    let rtt_ms = start.elapsed().as_millis() as u64;
6245
6246    let summary = json!({
6247        "role": role,
6248        "strategy": strategy,
6249        "routed_to": chosen_handle,
6250        "event_id": event_id,
6251        "delivered": delivered,
6252        "delivered_via": via_scope,
6253        "rtt_ms": rtt_ms,
6254        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
6255        "error": last_err,
6256    });
6257
6258    if as_json {
6259        println!("{}", serde_json::to_string(&summary)?);
6260    } else if delivered {
6261        let via = via_scope.as_deref().unwrap_or("?");
6262        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
6263    } else {
6264        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
6265        bail!("delivery to `{chosen_handle}` failed: {err}");
6266    }
6267    Ok(())
6268}
6269
6270fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
6271    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
6272}
6273
6274fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
6275    std::fs::read(path)
6276        .ok()
6277        .and_then(|b| serde_json::from_slice(&b).ok())
6278        .unwrap_or_default()
6279}
6280
6281fn write_mesh_route_cursors(
6282    path: &std::path::Path,
6283    cursors: &std::collections::BTreeMap<String, String>,
6284) -> Result<()> {
6285    if let Some(parent) = path.parent() {
6286        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
6287    }
6288    let body = serde_json::to_vec_pretty(cursors)?;
6289    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
6290    Ok(())
6291}
6292
6293/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
6294/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
6295/// behind a discoverability-friendlier surface, plus cross-session
6296/// enumeration for the list path.
6297fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
6298    match action {
6299        MeshRoleAction::Set { role, json } => {
6300            validate_role_tag(&role)?;
6301            let new_profile =
6302                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
6303            if json {
6304                println!(
6305                    "{}",
6306                    serde_json::to_string(&json!({
6307                        "role": role,
6308                        "profile": new_profile,
6309                    }))?
6310                );
6311            } else {
6312                println!("self role = {role} (signed into agent-card)");
6313            }
6314        }
6315        MeshRoleAction::Get { peer, json } => {
6316            let (who, role) = match peer.as_deref() {
6317                None => {
6318                    let card = config::read_agent_card()?;
6319                    let role = card
6320                        .get("profile")
6321                        .and_then(|p| p.get("role"))
6322                        .and_then(Value::as_str)
6323                        .map(str::to_string);
6324                    let who = card
6325                        .get("did")
6326                        .and_then(Value::as_str)
6327                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
6328                        .unwrap_or_else(|| "self".to_string());
6329                    (who, role)
6330                }
6331                Some(handle) => {
6332                    let bare = crate::agent_card::bare_handle(handle).to_string();
6333                    let trust = config::read_trust()?;
6334                    let role = trust
6335                        .get("agents")
6336                        .and_then(|a| a.get(&bare))
6337                        .and_then(|a| a.get("card"))
6338                        .and_then(|c| c.get("profile"))
6339                        .and_then(|p| p.get("role"))
6340                        .and_then(Value::as_str)
6341                        .map(str::to_string);
6342                    (bare, role)
6343                }
6344            };
6345            if json {
6346                println!(
6347                    "{}",
6348                    serde_json::to_string(&json!({
6349                        "handle": who,
6350                        "role": role,
6351                    }))?
6352                );
6353            } else {
6354                match role {
6355                    Some(r) => println!("{who}: {r}"),
6356                    None => println!("{who}: (unset)"),
6357                }
6358            }
6359        }
6360        MeshRoleAction::List { json } => {
6361            let mut self_did: Option<String> = None;
6362            if let Ok(card) = config::read_agent_card() {
6363                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
6364            }
6365            let sessions = crate::session::list_sessions()?;
6366            let mut rows: Vec<Value> = Vec::new();
6367            for s in &sessions {
6368                let card_path = s
6369                    .home_dir
6370                    .join("config")
6371                    .join("wire")
6372                    .join("agent-card.json");
6373                let role = std::fs::read(&card_path)
6374                    .ok()
6375                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6376                    .and_then(|c| {
6377                        c.get("profile")
6378                            .and_then(|p| p.get("role"))
6379                            .and_then(Value::as_str)
6380                            .map(str::to_string)
6381                    });
6382                let is_self = match (&self_did, &s.did) {
6383                    (Some(a), Some(b)) => a == b,
6384                    _ => false,
6385                };
6386                rows.push(json!({
6387                    "name": s.name,
6388                    "handle": s.handle,
6389                    "role": role,
6390                    "self": is_self,
6391                }));
6392            }
6393            rows.sort_by(|a, b| {
6394                a["name"]
6395                    .as_str()
6396                    .unwrap_or("")
6397                    .cmp(b["name"].as_str().unwrap_or(""))
6398            });
6399            if json {
6400                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
6401            } else if rows.is_empty() {
6402                println!("no sister sessions on this machine.");
6403            } else {
6404                println!("SISTER ROLES (this machine):");
6405                for r in &rows {
6406                    let name = r["name"].as_str().unwrap_or("?");
6407                    let role = r["role"].as_str().unwrap_or("(unset)");
6408                    let marker = if r["self"].as_bool().unwrap_or(false) {
6409                        "    ← you"
6410                    } else {
6411                        ""
6412                    };
6413                    println!("  {name:<24} {role}{marker}");
6414                }
6415            }
6416        }
6417        MeshRoleAction::Clear { json } => {
6418            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
6419            if json {
6420                println!(
6421                    "{}",
6422                    serde_json::to_string(&json!({
6423                        "cleared": true,
6424                        "profile": new_profile,
6425                    }))?
6426                );
6427            } else {
6428                println!("self role cleared");
6429            }
6430        }
6431    }
6432    Ok(())
6433}
6434
6435/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
6436/// No vocabulary check — operators choose the taxonomy (planner /
6437/// reviewer / dispatcher / your-custom-tag). The constraint is purely
6438/// to keep the tag safe for filenames / URLs / shell args.
6439fn validate_role_tag(role: &str) -> Result<()> {
6440    if role.is_empty() {
6441        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
6442    }
6443    if role.len() > 32 {
6444        bail!("role too long ({} chars; max 32)", role.len());
6445    }
6446    for c in role.chars() {
6447        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
6448            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
6449        }
6450    }
6451    Ok(())
6452}
6453
6454/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
6455///
6456/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
6457/// canonical event including `to:`, so per-recipient signing is required;
6458/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
6459/// Per-recipient pushes happen in parallel via `std::thread::scope` so
6460/// broadcast-to-5 takes ~1× RTT, not 5×.
6461///
6462/// **Scope filter.** Default `local` — only peers reachable via a same-
6463/// machine local relay (priority-1 endpoint has `scope=local`). This is
6464/// the lowest-blast-radius default: local-only broadcasts cannot escape
6465/// the operator's machine. `federation` flips to public-relay peers
6466/// only; `both` removes the filter.
6467///
6468/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
6469/// resolution, never trust["agents"] expansion. Closes #8-class
6470/// phonebook-scrape vectors by construction: an attacker pinning a
6471/// hostile handle has to first be pinned bidirectionally by the
6472/// operator, and even then `--exclude` is the loud opt-out.
6473fn cmd_mesh_broadcast(
6474    kind: &str,
6475    scope_str: &str,
6476    exclude: &[String],
6477    _noreply: bool,
6478    body_arg: &str,
6479    as_json: bool,
6480) -> Result<()> {
6481    use std::time::Instant;
6482
6483    if !config::is_initialized()? {
6484        bail!("not initialized — run `wire init <handle>` first");
6485    }
6486
6487    let scope = match scope_str {
6488        "local" => crate::endpoints::EndpointScope::Local,
6489        "federation" => crate::endpoints::EndpointScope::Federation,
6490        "both" => {
6491            // Sentinel: we don't actually have a `Both` variant on the
6492            // scope enum; use a tri-state below. Treat as Local for the
6493            // typed match and special-case it via the bool below.
6494            crate::endpoints::EndpointScope::Local
6495        }
6496        other => bail!("unknown scope `{other}` — use local | federation | both"),
6497    };
6498    let any_scope = scope_str == "both";
6499
6500    let state = config::read_relay_state()?;
6501    let peers = state["peers"].as_object().cloned().unwrap_or_default();
6502    if peers.is_empty() {
6503        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
6504    }
6505
6506    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6507
6508    // Walk the pinned-peer set, filter by scope + exclude. Keep the
6509    // priority-ordered endpoint list for each match so the push can
6510    // try local first then fall through to federation (when scope=both).
6511    struct Target {
6512        handle: String,
6513        endpoints: Vec<crate::endpoints::Endpoint>,
6514    }
6515    let mut targets: Vec<Target> = Vec::new();
6516    let mut skipped_wrong_scope: Vec<String> = Vec::new();
6517    let mut skipped_excluded: Vec<String> = Vec::new();
6518    for handle in peers.keys() {
6519        if exclude_set.contains(handle.as_str()) {
6520            skipped_excluded.push(handle.clone());
6521            continue;
6522        }
6523        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
6524        let filtered: Vec<crate::endpoints::Endpoint> = ordered
6525            .into_iter()
6526            .filter(|ep| any_scope || ep.scope == scope)
6527            .collect();
6528        if filtered.is_empty() {
6529            skipped_wrong_scope.push(handle.clone());
6530            continue;
6531        }
6532        targets.push(Target {
6533            handle: handle.clone(),
6534            endpoints: filtered,
6535        });
6536    }
6537
6538    if targets.is_empty() {
6539        bail!(
6540            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
6541            skipped_excluded.len(),
6542            skipped_wrong_scope.len()
6543        );
6544    }
6545
6546    // Load signing material once; share across per-peer signatures.
6547    let sk_seed = config::read_private_key()?;
6548    let card = config::read_agent_card()?;
6549    let did = card
6550        .get("did")
6551        .and_then(Value::as_str)
6552        .ok_or_else(|| anyhow!("agent-card missing did"))?
6553        .to_string();
6554    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6555    let pk_b64 = card
6556        .get("verify_keys")
6557        .and_then(Value::as_object)
6558        .and_then(|m| m.values().next())
6559        .and_then(|v| v.get("key"))
6560        .and_then(Value::as_str)
6561        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6562    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6563
6564    let body_value: Value = if body_arg == "-" {
6565        use std::io::Read;
6566        let mut raw = String::new();
6567        std::io::stdin()
6568            .read_to_string(&mut raw)
6569            .with_context(|| "reading body from stdin")?;
6570        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6571    } else if let Some(path) = body_arg.strip_prefix('@') {
6572        let raw =
6573            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6574        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6575    } else {
6576        Value::String(body_arg.to_string())
6577    };
6578
6579    let kind_id = parse_kind(kind)?;
6580    let now_iso = time::OffsetDateTime::now_utc()
6581        .format(&time::format_description::well_known::Rfc3339)
6582        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6583
6584    let broadcast_id = generate_broadcast_id();
6585    let target_count = targets.len();
6586
6587    // Build + sign every event up front (sequential, ~50µs/sig). Then
6588    // queue to outbox + push to relay in parallel per-peer. Returns
6589    // a per-peer outcome we then sort by handle for deterministic output.
6590    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
6591        Vec::with_capacity(targets.len());
6592    for t in &targets {
6593        let body = json!({
6594            "content": body_value,
6595            "broadcast_id": broadcast_id,
6596            "broadcast_target_count": target_count,
6597        });
6598        let event = json!({
6599            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6600            "timestamp": now_iso,
6601            "from": did,
6602            "to": format!("did:wire:{}", t.handle),
6603            "type": kind,
6604            "kind": kind_id,
6605            "body": body,
6606        });
6607        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
6608            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
6609        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6610        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
6611    }
6612
6613    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
6614    // holds a per-path mutex; writes are independent across handles but
6615    // we want the side-effect ordering deterministic).
6616    for (peer, _, signed, _) in &signed_per_peer {
6617        let line = serde_json::to_vec(signed)?;
6618        config::append_outbox_record(peer, &line)?;
6619    }
6620
6621    // Per-peer parallel push. Each thread tries the priority-ordered
6622    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
6623    // error_opt) over a channel.
6624    use std::sync::mpsc;
6625    let (tx, rx) = mpsc::channel::<Value>();
6626    std::thread::scope(|s| {
6627        for (peer, endpoints, signed, event_id) in &signed_per_peer {
6628            let tx = tx.clone();
6629            let peer = peer.clone();
6630            let event_id = event_id.clone();
6631            let endpoints = endpoints.clone();
6632            let signed = signed.clone();
6633            s.spawn(move || {
6634                let start = Instant::now();
6635                let mut delivered = false;
6636                let mut last_err: Option<String> = None;
6637                let mut delivered_via: Option<String> = None;
6638                for ep in &endpoints {
6639                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
6640                    // uds_request, else reqwest). Same as cmd_send's
6641                    // single-peer path above; this is the parallel
6642                    // multi-peer broadcast loop.
6643                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
6644                        Ok(_) => {
6645                            delivered = true;
6646                            delivered_via = Some(
6647                                match ep.scope {
6648                                    crate::endpoints::EndpointScope::Local => "local",
6649                                    crate::endpoints::EndpointScope::Lan => "lan",
6650                                    crate::endpoints::EndpointScope::Uds => "uds",
6651                                    crate::endpoints::EndpointScope::Federation => "federation",
6652                                }
6653                                .to_string(),
6654                            );
6655                            break;
6656                        }
6657                        Err(e) => last_err = Some(format!("{e:#}")),
6658                    }
6659                }
6660                let rtt_ms = start.elapsed().as_millis() as u64;
6661                let _ = tx.send(json!({
6662                    "peer": peer,
6663                    "event_id": event_id,
6664                    "delivered": delivered,
6665                    "delivered_via": delivered_via,
6666                    "rtt_ms": rtt_ms,
6667                    "error": last_err,
6668                }));
6669            });
6670        }
6671    });
6672    drop(tx);
6673
6674    let mut results: Vec<Value> = rx.iter().collect();
6675    results.sort_by(|a, b| {
6676        a["peer"]
6677            .as_str()
6678            .unwrap_or("")
6679            .cmp(b["peer"].as_str().unwrap_or(""))
6680    });
6681
6682    let delivered = results
6683        .iter()
6684        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
6685        .count();
6686    let failed = results.len() - delivered;
6687
6688    let summary = json!({
6689        "broadcast_id": broadcast_id,
6690        "kind": kind,
6691        "scope": scope_str,
6692        "target_count": target_count,
6693        "delivered": delivered,
6694        "failed": failed,
6695        "skipped_excluded": skipped_excluded,
6696        "skipped_wrong_scope": skipped_wrong_scope,
6697        "results": results,
6698    });
6699
6700    if as_json {
6701        println!("{}", serde_json::to_string(&summary)?);
6702        return Ok(());
6703    }
6704
6705    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
6706    for r in &results {
6707        let peer = r["peer"].as_str().unwrap_or("?");
6708        let delivered = r["delivered"].as_bool().unwrap_or(false);
6709        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
6710        let via = r["delivered_via"].as_str().unwrap_or("");
6711        if delivered {
6712            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
6713        } else {
6714            let err = r["error"].as_str().unwrap_or("?");
6715            println!("  {peer:<24} ✗ failed — {err}");
6716        }
6717    }
6718    if !skipped_excluded.is_empty() {
6719        println!("  excluded: {}", skipped_excluded.join(", "));
6720    }
6721    if !skipped_wrong_scope.is_empty() {
6722        println!(
6723            "  skipped (wrong scope): {}",
6724            skipped_wrong_scope.join(", ")
6725        );
6726    }
6727    println!("broadcast_id: {broadcast_id}");
6728    Ok(())
6729}
6730
6731/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
6732/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
6733/// correlate by string equality, the shape is for human readability.
6734fn generate_broadcast_id() -> String {
6735    use rand::RngCore;
6736    let mut buf = [0u8; 16];
6737    rand::thread_rng().fill_bytes(&mut buf);
6738    let h = hex::encode(buf);
6739    format!(
6740        "{}-{}-{}-{}-{}",
6741        &h[0..8],
6742        &h[8..12],
6743        &h[12..16],
6744        &h[16..20],
6745        &h[20..32],
6746    )
6747}
6748
6749fn cmd_session(cmd: SessionCommand) -> Result<()> {
6750    match cmd {
6751        SessionCommand::New {
6752            name,
6753            relay,
6754            with_local,
6755            local_relay,
6756            with_lan,
6757            lan_relay,
6758            with_uds,
6759            uds_socket,
6760            no_daemon,
6761            local_only,
6762            json,
6763        } => cmd_session_new(
6764            name.as_deref(),
6765            &relay,
6766            with_local,
6767            &local_relay,
6768            with_lan,
6769            lan_relay.as_deref(),
6770            with_uds,
6771            uds_socket.as_deref(),
6772            no_daemon,
6773            local_only,
6774            json,
6775        ),
6776        SessionCommand::List { json } => cmd_session_list(json),
6777        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
6778        SessionCommand::PairAllLocal {
6779            settle_secs,
6780            federation_relay,
6781            json,
6782        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
6783        SessionCommand::MeshStatus { stale_secs, json } => {
6784            cmd_session_mesh_status(stale_secs, json)
6785        }
6786        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
6787        SessionCommand::Current { json } => cmd_session_current(json),
6788        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
6789    }
6790}
6791
6792fn resolve_session_name(name: Option<&str>) -> Result<String> {
6793    if let Some(n) = name {
6794        return Ok(crate::session::sanitize_name(n));
6795    }
6796    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6797    let registry = crate::session::read_registry().unwrap_or_default();
6798    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
6799}
6800
6801#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
6802// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
6803// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
6804fn cmd_session_new(
6805    name_arg: Option<&str>,
6806    relay: &str,
6807    with_local: bool,
6808    local_relay: &str,
6809    with_lan: bool,
6810    lan_relay: Option<&str>,
6811    with_uds: bool,
6812    uds_socket: Option<&std::path::Path>,
6813    no_daemon: bool,
6814    local_only: bool,
6815    as_json: bool,
6816) -> Result<()> {
6817    // v0.6.6: --local-only implies --with-local (a federation-free
6818    // session with no endpoints at all would be unaddressable).
6819    let with_local = with_local || local_only;
6820    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
6821    if with_lan && lan_relay.is_none() {
6822        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
6823    }
6824    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
6825    if with_uds && uds_socket.is_none() {
6826        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
6827    }
6828    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6829    let mut registry = crate::session::read_registry().unwrap_or_default();
6830    let name = match name_arg {
6831        Some(n) => crate::session::sanitize_name(n),
6832        None => crate::session::derive_name_from_cwd(&cwd, &registry),
6833    };
6834    let session_home = crate::session::session_dir(&name)?;
6835
6836    let already_exists = session_home.exists()
6837        && session_home
6838            .join("config")
6839            .join("wire")
6840            .join("agent-card.json")
6841            .exists();
6842    if already_exists {
6843        // Idempotent: re-register the cwd (if not already), refresh the
6844        // daemon if requested, surface the env-var line. Do not re-init
6845        // identity — that would clobber the keypair.
6846        registry
6847            .by_cwd
6848            .insert(cwd.to_string_lossy().into_owned(), name.clone());
6849        crate::session::write_registry(&registry)?;
6850        let info = render_session_info(&name, &session_home, &cwd)?;
6851        emit_session_new_result(&info, "already_exists", as_json)?;
6852        if !no_daemon {
6853            ensure_session_daemon(&session_home)?;
6854        }
6855        return Ok(());
6856    }
6857
6858    std::fs::create_dir_all(&session_home)
6859        .with_context(|| format!("creating session dir {session_home:?}"))?;
6860
6861    // Phase 1: init identity in the new session's WIRE_HOME. For
6862    // federation-bound sessions we pass `--relay` so init also
6863    // allocates a federation slot in the same step; for `--local-only`
6864    // we run init without --relay so no federation contact happens.
6865    let init_args: Vec<&str> = if local_only {
6866        vec!["init", &name]
6867    } else {
6868        vec!["init", &name, "--relay", relay]
6869    };
6870    let init_status = run_wire_with_home(&session_home, &init_args)?;
6871    if !init_status.success() {
6872        let how = if local_only {
6873            format!("`wire init {name}` (local-only)")
6874        } else {
6875            format!("`wire init {name} --relay {relay}`")
6876        };
6877        bail!("{how} failed inside session dir {session_home:?}");
6878    }
6879
6880    // Phase 2: claim the handle on the federation relay — SKIPPED when
6881    // `--local-only`. Local-only sessions have no public address and
6882    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
6883    // tries to publish them.
6884    let effective_handle = if local_only {
6885        name.clone()
6886    } else {
6887        let mut claim_attempt = 0u32;
6888        let mut effective = name.clone();
6889        loop {
6890            claim_attempt += 1;
6891            let status =
6892                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
6893            if status.success() {
6894                break;
6895            }
6896            if claim_attempt >= 5 {
6897                bail!(
6898                    "5 failed attempts to claim a handle on {relay} for session {name}. \
6899                     Try `wire session destroy {name} --force` and re-run with a different name, \
6900                     or use `--local-only` if you don't need a federation address."
6901                );
6902            }
6903            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
6904            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
6905            let token = suffix
6906                .rsplit('-')
6907                .next()
6908                .filter(|t| t.len() == 4)
6909                .map(str::to_string)
6910                .unwrap_or_else(|| format!("{claim_attempt}"));
6911            effective = format!("{name}-{token}");
6912        }
6913        effective
6914    };
6915
6916    // Persist the cwd → name mapping NOW so subsequent invocations from
6917    // this directory short-circuit to the "already_exists" branch.
6918    registry
6919        .by_cwd
6920        .insert(cwd.to_string_lossy().into_owned(), name.clone());
6921    crate::session::write_registry(&registry)?;
6922
6923    // v0.5.17: --with-local probes the local relay and, if it's
6924    // reachable, allocates a second slot there. The session's
6925    // relay_state.json grows a `self.endpoints[]` array carrying both
6926    // endpoints; routing layer (cmd_push) prefers local for sister-
6927    // session peers that also have a local slot.
6928    //
6929    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
6930    // allocation; a failed probe leaves the session with no endpoints,
6931    // which we surface as a hard error (the operator asked for local-
6932    // only but the local relay isn't running — fix that first).
6933    if with_local {
6934        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
6935        if local_only {
6936            // Verify the local slot landed. If the local relay was
6937            // unreachable, the session would be unreachable from
6938            // anywhere — surface that loudly instead of leaving an
6939            // orphaned session dir.
6940            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
6941            let state: Value = std::fs::read(&relay_state_path)
6942                .ok()
6943                .and_then(|b| serde_json::from_slice(&b).ok())
6944                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6945            let endpoints = crate::endpoints::self_endpoints(&state);
6946            let has_local = endpoints
6947                .iter()
6948                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
6949            if !has_local {
6950                bail!(
6951                    "--local-only requested but local-relay probe at {local_relay} failed — \
6952                     ensure the local relay is running (`wire service install --local-relay`), \
6953                     then re-run `wire session new {name} --local-only`."
6954                );
6955            }
6956        }
6957    }
6958
6959    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
6960    // Sits AFTER local because cmd_session_new's flow is "add endpoints
6961    // alongside existing self.endpoints[]" — order independent post-init.
6962    if with_lan && let Some(lan_url) = lan_relay {
6963        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
6964    }
6965    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
6966    if with_uds && let Some(socket_path) = uds_socket {
6967        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
6968    }
6969
6970    if !no_daemon {
6971        ensure_session_daemon(&session_home)?;
6972    }
6973
6974    let info = render_session_info(&name, &session_home, &cwd)?;
6975    emit_session_new_result(&info, "created", as_json)
6976}
6977
6978/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
6979/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
6980/// sister sessions can route over the local socket instead of loopback
6981/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
6982/// alpha.17 — reqwest has no UDS support.
6983///
6984/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
6985/// and try_allocate_lan_slot semantics): session stays at existing
6986/// endpoint mix, operator can retry once the UDS relay is up.
6987#[cfg(unix)]
6988fn try_allocate_uds_slot(
6989    session_home: &std::path::Path,
6990    handle: &str,
6991    uds_socket: &std::path::Path,
6992) {
6993    // Probe healthz first so we fail fast with a clear stderr if the
6994    // socket doesn't exist OR isn't a wire relay.
6995    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
6996        Ok((200, _)) => true,
6997        Ok((status, body)) => {
6998            eprintln!(
6999                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
7000                String::from_utf8_lossy(&body)
7001            );
7002            return;
7003        }
7004        Err(e) => {
7005            eprintln!(
7006                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
7007                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
7008            );
7009            return;
7010        }
7011    };
7012    if !healthz {
7013        return;
7014    }
7015
7016    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
7017    let alloc_body = serde_json::json!({"handle": handle}).to_string();
7018    let (status, body) = match crate::relay_client::uds_request(
7019        uds_socket,
7020        "POST",
7021        "/v1/slot/allocate",
7022        &[("Content-Type", "application/json")],
7023        alloc_body.as_bytes(),
7024    ) {
7025        Ok(r) => r,
7026        Err(e) => {
7027            eprintln!(
7028                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
7029            );
7030            return;
7031        }
7032    };
7033    if status >= 300 {
7034        eprintln!(
7035            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
7036            String::from_utf8_lossy(&body)
7037        );
7038        return;
7039    }
7040    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
7041        Ok(a) => a,
7042        Err(e) => {
7043            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
7044            return;
7045        }
7046    };
7047
7048    let state_path = session_home.join("config").join("wire").join("relay.json");
7049    let mut state: serde_json::Value = std::fs::read(&state_path)
7050        .ok()
7051        .and_then(|b| serde_json::from_slice(&b).ok())
7052        .unwrap_or_else(|| serde_json::json!({}));
7053
7054    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7055        .get("self")
7056        .and_then(|s| s.get("endpoints"))
7057        .and_then(|e| e.as_array())
7058        .map(|arr| {
7059            arr.iter()
7060                .filter_map(|v| {
7061                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7062                })
7063                .collect()
7064        })
7065        .unwrap_or_default();
7066    endpoints.push(crate::endpoints::Endpoint::uds(
7067        format!("unix://{}", uds_socket.display()),
7068        alloc.slot_id.clone(),
7069        alloc.slot_token.clone(),
7070    ));
7071
7072    let self_obj = state
7073        .as_object_mut()
7074        .expect("relay_state root is an object")
7075        .entry("self")
7076        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7077    if !self_obj.is_object() {
7078        *self_obj = serde_json::Value::Object(serde_json::Map::new());
7079    }
7080    if let Some(obj) = self_obj.as_object_mut() {
7081        obj.insert(
7082            "endpoints".into(),
7083            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7084        );
7085    }
7086    if let Err(e) = std::fs::write(
7087        &state_path,
7088        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7089    ) {
7090        eprintln!("wire session new: failed to write {state_path:?}: {e}");
7091        return;
7092    }
7093    eprintln!(
7094        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
7095        uds_socket.display(),
7096        alloc.slot_id
7097    );
7098}
7099
7100#[cfg(not(unix))]
7101fn try_allocate_uds_slot(
7102    _session_home: &std::path::Path,
7103    _handle: &str,
7104    _uds_socket: &std::path::Path,
7105) {
7106    eprintln!(
7107        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
7108    );
7109}
7110
7111/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
7112/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
7113/// pulling the agent-card see a third reachable address.
7114///
7115/// Mirrors `try_allocate_local_slot` but tags the endpoint
7116/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
7117/// session stays at whatever endpoint mix it already had — operators
7118/// can retry with `wire session new --with-lan --lan-relay <url>` once
7119/// the LAN relay is up.
7120fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
7121    let probe = match crate::relay_client::build_blocking_client(Some(
7122        std::time::Duration::from_millis(500),
7123    )) {
7124        Ok(c) => c,
7125        Err(e) => {
7126            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
7127            return;
7128        }
7129    };
7130    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
7131    match probe.get(&healthz_url).send() {
7132        Ok(resp) if resp.status().is_success() => {}
7133        Ok(resp) => {
7134            eprintln!(
7135                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
7136                resp.status()
7137            );
7138            return;
7139        }
7140        Err(e) => {
7141            eprintln!(
7142                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
7143                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
7144                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
7145            );
7146            return;
7147        }
7148    };
7149
7150    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
7151    let alloc = match lan_client.allocate_slot(Some(handle)) {
7152        Ok(a) => a,
7153        Err(e) => {
7154            eprintln!(
7155                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
7156            );
7157            return;
7158        }
7159    };
7160
7161    let state_path = session_home.join("config").join("wire").join("relay.json");
7162    let mut state: serde_json::Value = std::fs::read(&state_path)
7163        .ok()
7164        .and_then(|b| serde_json::from_slice(&b).ok())
7165        .unwrap_or_else(|| serde_json::json!({}));
7166
7167    // Read existing endpoints array and add the LAN one. Preserve
7168    // federation / local entries already there.
7169    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7170        .get("self")
7171        .and_then(|s| s.get("endpoints"))
7172        .and_then(|e| e.as_array())
7173        .map(|arr| {
7174            arr.iter()
7175                .filter_map(|v| {
7176                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7177                })
7178                .collect()
7179        })
7180        .unwrap_or_default();
7181    endpoints.push(crate::endpoints::Endpoint::lan(
7182        lan_relay.trim_end_matches('/').to_string(),
7183        alloc.slot_id.clone(),
7184        alloc.slot_token.clone(),
7185    ));
7186
7187    let self_obj = state
7188        .as_object_mut()
7189        .expect("relay_state root is an object")
7190        .entry("self")
7191        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7192    if !self_obj.is_object() {
7193        *self_obj = serde_json::Value::Object(serde_json::Map::new());
7194    }
7195    if let Some(obj) = self_obj.as_object_mut() {
7196        obj.insert(
7197            "endpoints".into(),
7198            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7199        );
7200    }
7201    if let Err(e) = std::fs::write(
7202        &state_path,
7203        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7204    ) {
7205        eprintln!("wire session new: failed to write {state_path:?}: {e}");
7206        return;
7207    }
7208    eprintln!(
7209        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
7210        alloc.slot_id
7211    );
7212}
7213
7214/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
7215/// a short timeout, allocate a slot there and update the session's
7216/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
7217///
7218/// Failure to reach the local relay is NOT fatal — the session stays
7219/// federation-only. Logs to stderr on failure so operators can tell
7220/// the local relay isn't running, but doesn't abort the bootstrap.
7221fn try_allocate_local_slot(
7222    session_home: &std::path::Path,
7223    handle: &str,
7224    _federation_relay: &str,
7225    local_relay: &str,
7226) {
7227    // Probe healthz with a tight timeout. Use a fresh client (don't
7228    // share the daemon-wide one) so the timeout is local to this call.
7229    let probe = match crate::relay_client::build_blocking_client(Some(
7230        std::time::Duration::from_millis(500),
7231    )) {
7232        Ok(c) => c,
7233        Err(e) => {
7234            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
7235            return;
7236        }
7237    };
7238    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
7239    match probe.get(&healthz_url).send() {
7240        Ok(resp) if resp.status().is_success() => {}
7241        Ok(resp) => {
7242            eprintln!(
7243                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
7244                resp.status()
7245            );
7246            return;
7247        }
7248        Err(e) => {
7249            eprintln!(
7250                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
7251                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
7252                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
7253            );
7254            return;
7255        }
7256    };
7257
7258    // Allocate a slot on the local relay.
7259    let local_client = crate::relay_client::RelayClient::new(local_relay);
7260    let alloc = match local_client.allocate_slot(Some(handle)) {
7261        Ok(a) => a,
7262        Err(e) => {
7263            eprintln!(
7264                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
7265            );
7266            return;
7267        }
7268    };
7269
7270    // Merge into the session's relay.json. We invoke wire via
7271    // run_wire_with_home for federation calls (subprocess isolation),
7272    // but relay.json is a simple file we can edit directly
7273    // — and need to, because there's no `wire bind-relay --add-local`
7274    // command yet (could add later; out of scope for v0.5.17 MVP).
7275    //
7276    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
7277    // does not exist (canonical filename is `relay.json` per
7278    // `config::relay_state_path`). The mis-named file write succeeded
7279    // but landed in a sibling path nothing else reads. Every
7280    // `wire session new --with-local` invocation silently degraded to
7281    // federation-only despite the "local slot allocated" stderr line.
7282    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
7283    // session's relay.json — it had only the federation endpoint.
7284    let state_path = session_home.join("config").join("wire").join("relay.json");
7285    let mut state: serde_json::Value = std::fs::read(&state_path)
7286        .ok()
7287        .and_then(|b| serde_json::from_slice(&b).ok())
7288        .unwrap_or_else(|| serde_json::json!({}));
7289    // Read the existing federation self info (already written by
7290    // `wire init` + `wire bind-relay` path during session bootstrap).
7291    let fed_endpoint = state.get("self").and_then(|s| {
7292        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
7293        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
7294        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
7295        Some(crate::endpoints::Endpoint::federation(
7296            url.to_string(),
7297            slot_id.to_string(),
7298            slot_token.to_string(),
7299        ))
7300    });
7301
7302    let local_endpoint = crate::endpoints::Endpoint::local(
7303        local_relay.trim_end_matches('/').to_string(),
7304        alloc.slot_id.clone(),
7305        alloc.slot_token.clone(),
7306    );
7307
7308    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
7309    if let Some(f) = fed_endpoint.clone() {
7310        endpoints.push(f);
7311    }
7312    endpoints.push(local_endpoint);
7313
7314    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
7315    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
7316    // `slot_token` fields must point at the LOCAL endpoint so callers
7317    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
7318    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
7319    // still find a valid slot. Pre-v0.6.6 this branch wrote
7320    // `relay_url: federation_relay` with no slot_id, which produced
7321    // half-populated self state that broke pair-accept on local-only
7322    // sessions.
7323    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
7324        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
7325        None => (
7326            local_relay.trim_end_matches('/').to_string(),
7327            alloc.slot_id.clone(),
7328            alloc.slot_token.clone(),
7329        ),
7330    };
7331    let self_obj = state
7332        .as_object_mut()
7333        .expect("relay_state root is an object")
7334        .entry("self")
7335        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7336    // The entry might be Value::Null (left by read_relay_state's default
7337    // template) — replace with an object before mutating.
7338    if !self_obj.is_object() {
7339        *self_obj = serde_json::Value::Object(serde_json::Map::new());
7340    }
7341    if let Some(obj) = self_obj.as_object_mut() {
7342        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
7343        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
7344        obj.insert(
7345            "slot_token".into(),
7346            serde_json::Value::String(legacy_slot_token),
7347        );
7348        obj.insert(
7349            "endpoints".into(),
7350            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7351        );
7352    }
7353
7354    if let Err(e) = std::fs::write(
7355        &state_path,
7356        serde_json::to_vec_pretty(&state).unwrap_or_default(),
7357    ) {
7358        eprintln!(
7359            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
7360        );
7361        return;
7362    }
7363    eprintln!(
7364        "wire session new: local slot allocated on {local_relay} (slot_id={})",
7365        alloc.slot_id
7366    );
7367}
7368
7369fn render_session_info(
7370    name: &str,
7371    session_home: &std::path::Path,
7372    cwd: &std::path::Path,
7373) -> Result<serde_json::Value> {
7374    let card_path = session_home
7375        .join("config")
7376        .join("wire")
7377        .join("agent-card.json");
7378    let (did, handle) = if card_path.exists() {
7379        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
7380        let did = card
7381            .get("did")
7382            .and_then(Value::as_str)
7383            .unwrap_or("")
7384            .to_string();
7385        let handle = card
7386            .get("handle")
7387            .and_then(Value::as_str)
7388            .map(str::to_string)
7389            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
7390        (did, handle)
7391    } else {
7392        (String::new(), String::new())
7393    };
7394    Ok(json!({
7395        "name": name,
7396        "home_dir": session_home.to_string_lossy(),
7397        "cwd": cwd.to_string_lossy(),
7398        "did": did,
7399        "handle": handle,
7400        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
7401    }))
7402}
7403
7404fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
7405    if as_json {
7406        let mut obj = info.clone();
7407        obj["status"] = json!(status);
7408        println!("{}", serde_json::to_string(&obj)?);
7409    } else {
7410        let name = info["name"].as_str().unwrap_or("?");
7411        let handle = info["handle"].as_str().unwrap_or("?");
7412        let home = info["home_dir"].as_str().unwrap_or("?");
7413        let did = info["did"].as_str().unwrap_or("?");
7414        let export = info["export"].as_str().unwrap_or("?");
7415        let prefix = if status == "already_exists" {
7416            "session already exists (re-registered cwd)"
7417        } else {
7418            "session created"
7419        };
7420        println!(
7421            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
7422        );
7423    }
7424    Ok(())
7425}
7426
7427fn run_wire_with_home(
7428    session_home: &std::path::Path,
7429    args: &[&str],
7430) -> Result<std::process::ExitStatus> {
7431    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
7432    let status = std::process::Command::new(&bin)
7433        .env("WIRE_HOME", session_home)
7434        .env_remove("RUST_LOG")
7435        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
7436        // We already own the session; nested init would clobber state.
7437        .env("WIRE_AUTO_INIT", "0")
7438        .args(args)
7439        .status()
7440        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
7441    Ok(status)
7442}
7443
7444/// v0.7.0-alpha.2: idempotent per-cwd session creation.
7445///
7446/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
7447/// registered session for the current cwd — including via parent-walk —
7448/// this creates one inline so every Claude tab in a fresh project gets
7449/// its own wire identity rather than collapsing onto the machine-wide
7450/// default. Without this, multiple Claudes in unwired cwds all render
7451/// the same character (the default identity's character), defeating the
7452/// "every session looks different" promise.
7453///
7454/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
7455/// `run_wire_with_home` subprocess context).
7456///
7457/// Best-effort: any failure (no home dir, name collision pathology,
7458/// `wire init` subprocess crash) is logged to stderr and we fall back
7459/// to default identity. Must not block MCP startup.
7460///
7461/// MUST be called BEFORE worker thread spawn (env::set_var safety).
7462pub fn maybe_auto_init_cwd_session(label: &str) {
7463    if std::env::var("WIRE_HOME").is_ok() {
7464        return; // explicit override OR auto-detect already won
7465    }
7466    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
7467        return; // operator opt-out
7468    }
7469    let cwd = match std::env::current_dir() {
7470        Ok(c) => c,
7471        Err(_) => return,
7472    };
7473    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
7474    // already runs but we want to be robust to ordering).
7475    if crate::session::detect_session_wire_home(&cwd).is_some() {
7476        return;
7477    }
7478
7479    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
7480    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
7481    // Two different cwds with the same basename (e.g. /a/projx +
7482    // /b/projx) used to race outside the lock: both read empty
7483    // registry, both derived name="projx", per-name lock didn't help
7484    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
7485    //
7486    // Single lock serializes ALL auto-init across the sessions_root.
7487    // Inside the lock: re-read registry, derive_name_from_cwd which
7488    // adds path-hash suffix when basename is occupied by another cwd
7489    // already committed to the registry. Different cwds get DIFFERENT
7490    // names guaranteed.
7491    //
7492    // Cost: parallel auto-inits in different cwds now serialize
7493    // (~hundreds of ms each when local relay is up). Acceptable —
7494    // auto-init runs once per cwd per machine; not a hot path.
7495    use fs2::FileExt;
7496    let sessions_root = match crate::session::sessions_root() {
7497        Ok(r) => r,
7498        Err(_) => return,
7499    };
7500    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
7501        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
7502        return;
7503    }
7504    let lock_path = sessions_root.join(".auto-init.lock");
7505    let lock_file = match std::fs::OpenOptions::new()
7506        .create(true)
7507        .truncate(false)
7508        .read(true)
7509        .write(true)
7510        .open(&lock_path)
7511    {
7512        Ok(f) => f,
7513        Err(e) => {
7514            eprintln!(
7515                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
7516            );
7517            return;
7518        }
7519    };
7520    if let Err(e) = lock_file.lock_exclusive() {
7521        eprintln!(
7522            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
7523        );
7524        return;
7525    }
7526    // Lock acquired. Read registry + derive name now that all parallel
7527    // racers serialize through us — derive_name_from_cwd adds a
7528    // path-hash suffix if the basename is already claimed by another
7529    // cwd in the (now-stable) registry.
7530    let registry = crate::session::read_registry().unwrap_or_default();
7531    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
7532    let session_home = match crate::session::session_dir(&name) {
7533        Ok(h) => h,
7534        Err(_) => {
7535            let _ = fs2::FileExt::unlock(&lock_file);
7536            return;
7537        }
7538    };
7539    let agent_card_path = session_home
7540        .join("config")
7541        .join("wire")
7542        .join("agent-card.json");
7543    let needs_init = !agent_card_path.exists();
7544
7545    if needs_init {
7546        if let Err(e) = std::fs::create_dir_all(&session_home) {
7547            eprintln!(
7548                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
7549            );
7550            let _ = fs2::FileExt::unlock(&lock_file);
7551            return;
7552        }
7553        match run_wire_with_home(&session_home, &["init", &name]) {
7554            Ok(status) if status.success() => {}
7555            Ok(status) => {
7556                eprintln!(
7557                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
7558                );
7559                let _ = fs2::FileExt::unlock(&lock_file);
7560                return;
7561            }
7562            Err(e) => {
7563                eprintln!(
7564                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
7565                );
7566                let _ = fs2::FileExt::unlock(&lock_file);
7567                return;
7568            }
7569        }
7570        // Best-effort: allocate a local-relay slot so this auto-init'd
7571        // session is addressable by sister sessions. Skipped silently when
7572        // the local relay isn't running (the function itself reports to
7573        // stderr). Auto-init'd sessions without endpoints can still
7574        // surface their character but cannot receive pair_drops until the
7575        // operator runs `wire bind-relay` or restarts the local relay.
7576        try_allocate_local_slot(
7577            &session_home,
7578            &name,
7579            "https://wireup.net",
7580            "http://127.0.0.1:8771",
7581        );
7582    } else {
7583        // Race loser path: peer already created the session. Surface
7584        // this honestly so the operator can see we adopted rather than
7585        // double-initialized.
7586        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
7587            eprintln!(
7588                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
7589            );
7590        }
7591    }
7592    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
7593    // BEFORE releasing the auto-init lock. Pre-fix released the lock
7594    // here and committed the registry update afterward — racers in
7595    // OTHER cwds with the same basename would acquire the lock,
7596    // read the registry (still without our entry), and derive the
7597    // SAME name we just claimed. Live regression test caught it:
7598    // two cwds /a/projx + /b/projx both got name "projx", both
7599    // mapped to the same identity. Update the registry WHILE STILL
7600    // holding the auto-init lock so the next racer sees our claim.
7601    let cwd_key = cwd.to_string_lossy().into_owned();
7602    let name_for_reg = name.clone();
7603    if let Err(e) = crate::session::update_registry(|reg| {
7604        reg.by_cwd.insert(cwd_key, name_for_reg);
7605        Ok(())
7606    }) {
7607        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
7608        // proceed — env var still gets set below
7609    }
7610    // NOW release the lock — racers waiting will see our registry
7611    // entry on their re-read.
7612    let _ = fs2::FileExt::unlock(&lock_file);
7613
7614    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
7615        eprintln!(
7616            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
7617            cwd.display(),
7618            session_home.display()
7619        );
7620    }
7621    // SAFETY: caller contract is "before any thread spawn." MCP::run
7622    // calls this immediately after `maybe_adopt_session_wire_home`.
7623    unsafe {
7624        std::env::set_var("WIRE_HOME", &session_home);
7625    }
7626}
7627
7628fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
7629    // Check if a daemon is already alive in this session's WIRE_HOME.
7630    // If so, no-op (let the existing process keep running).
7631    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
7632    if pidfile.exists() {
7633        let bytes = std::fs::read(&pidfile).unwrap_or_default();
7634        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
7635            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
7636        } else {
7637            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
7638        };
7639        if let Some(p) = pid {
7640            let alive = {
7641                #[cfg(target_os = "linux")]
7642                {
7643                    std::path::Path::new(&format!("/proc/{p}")).exists()
7644                }
7645                #[cfg(not(target_os = "linux"))]
7646                {
7647                    std::process::Command::new("kill")
7648                        .args(["-0", &p.to_string()])
7649                        .output()
7650                        .map(|o| o.status.success())
7651                        .unwrap_or(false)
7652                }
7653            };
7654            if alive {
7655                return Ok(());
7656            }
7657        }
7658    }
7659
7660    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
7661    // versioned pidfile; we just kick it off and return.
7662    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
7663    let log_path = session_home.join("state").join("wire").join("daemon.log");
7664    if let Some(parent) = log_path.parent() {
7665        std::fs::create_dir_all(parent).ok();
7666    }
7667    let log_file = std::fs::OpenOptions::new()
7668        .create(true)
7669        .append(true)
7670        .open(&log_path)
7671        .with_context(|| format!("opening daemon log {log_path:?}"))?;
7672    let log_err = log_file.try_clone()?;
7673    std::process::Command::new(&bin)
7674        .env("WIRE_HOME", session_home)
7675        .env_remove("RUST_LOG")
7676        .args(["daemon", "--interval", "5"])
7677        .stdout(log_file)
7678        .stderr(log_err)
7679        .stdin(std::process::Stdio::null())
7680        .spawn()
7681        .with_context(|| "spawning session-local `wire daemon`")?;
7682    Ok(())
7683}
7684
7685fn cmd_session_list(as_json: bool) -> Result<()> {
7686    let items = crate::session::list_sessions()?;
7687    if as_json {
7688        println!("{}", serde_json::to_string(&items)?);
7689        return Ok(());
7690    }
7691    if items.is_empty() {
7692        println!("no sessions on this machine. `wire session new` to create one.");
7693        return Ok(());
7694    }
7695    println!(
7696        "{:<22} {:<24} {:<24} {:<10} CWD",
7697        "CHARACTER", "NAME", "HANDLE", "DAEMON"
7698    );
7699    for s in items {
7700        // ANSI-escape-wrapped character takes more visual width than its
7701        // displayed glyph count; pad based on the plain-text form, then
7702        // wrap in escapes so the column lines up across rows.
7703        let plain = s
7704            .character
7705            .as_ref()
7706            .map(|c| c.short())
7707            .unwrap_or_else(|| "?".to_string());
7708        let colored = s
7709            .character
7710            .as_ref()
7711            .map(|c| c.colored())
7712            .unwrap_or_else(|| "?".to_string());
7713        // Approximate display width: emoji renders as ~2 cells in most
7714        // terminals; the rest are 1 cell each. We pad to 18 displayed
7715        // chars (≈22 byte slots when counting emoji).
7716        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
7717        let pad = 22usize.saturating_sub(displayed_width);
7718        println!(
7719            "{}{}  {:<24} {:<24} {:<10} {}",
7720            colored,
7721            " ".repeat(pad),
7722            s.name,
7723            s.handle.as_deref().unwrap_or("?"),
7724            if s.daemon_running { "running" } else { "down" },
7725            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7726        );
7727    }
7728    Ok(())
7729}
7730
7731/// v0.5.19: `wire session list-local` — sister-session discovery.
7732///
7733/// For each on-disk session, read its `relay-state.json` and surface
7734/// the ones that have a Local-scope endpoint (allocated via
7735/// `wire session new --with-local`). Group by the local-relay URL so
7736/// the operator can see at a glance which sessions are mutually
7737/// reachable over the same loopback relay.
7738///
7739/// Read-only, no daemon contact. Useful as the prelude to teaming /
7740/// pairing same-box sister claudes (see also `wire session
7741/// pair-all-local` once implemented).
7742fn cmd_session_list_local(as_json: bool) -> Result<()> {
7743    let listing = crate::session::list_local_sessions()?;
7744    if as_json {
7745        println!("{}", serde_json::to_string(&listing)?);
7746        return Ok(());
7747    }
7748
7749    if listing.local.is_empty() && listing.federation_only.is_empty() {
7750        println!(
7751            "no sessions on this machine. `wire session new --with-local` to create one \
7752             with a local-relay endpoint (start the relay first: \
7753             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
7754        );
7755        return Ok(());
7756    }
7757
7758    if listing.local.is_empty() {
7759        println!(
7760            "no sister sessions reachable via a local relay. \
7761             Re-run `wire session new --with-local` to add a Local endpoint, or \
7762             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
7763        );
7764    } else {
7765        // Stable iteration order: sort the relay URLs.
7766        let mut keys: Vec<&String> = listing.local.keys().collect();
7767        keys.sort();
7768        for relay_url in keys {
7769            let group = &listing.local[relay_url];
7770            println!("LOCAL RELAY: {relay_url}");
7771            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
7772            for s in group {
7773                println!(
7774                    "  {:<24} {:<32} {:<10} {}",
7775                    s.name,
7776                    s.handle.as_deref().unwrap_or("?"),
7777                    if s.daemon_running { "running" } else { "down" },
7778                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7779                );
7780            }
7781            println!();
7782        }
7783    }
7784
7785    if !listing.federation_only.is_empty() {
7786        println!("federation-only (no local endpoint):");
7787        for s in &listing.federation_only {
7788            println!(
7789                "  {:<24} {:<32} {}",
7790                s.name,
7791                s.handle.as_deref().unwrap_or("?"),
7792                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7793            );
7794        }
7795    }
7796    Ok(())
7797}
7798
7799/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
7800/// session that has a Local-scope endpoint. Skips already-paired
7801/// pairs; reports a per-pair outcome JSON suitable for scripting.
7802///
7803/// Same-uid trust anchor: the caller owns every session enumerated by
7804/// `list_local_sessions`, so the operator running this command IS the
7805/// consent for both sides. The bilateral SAS / network-level handshake
7806/// assumes strangers; same-uid sister sessions are not strangers.
7807///
7808/// Per-pair flow (sequential to keep relay-side load + log clarity):
7809///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
7810///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
7811///   3. sleep settle_secs                       (pair_drop reaches B)
7812///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
7813///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
7814///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
7815///   7. sleep settle_secs                       (ack reaches A)
7816///   8. WIRE_HOME=A wire pull --json            (A pins B)
7817fn cmd_session_pair_all_local(
7818    settle_secs: u64,
7819    federation_relay: &str,
7820    as_json: bool,
7821) -> Result<()> {
7822    use std::collections::BTreeSet;
7823    use std::time::Duration;
7824
7825    let listing = crate::session::list_local_sessions()?;
7826    // Flatten + dedup by session NAME (same session can appear under
7827    // multiple local-relay URLs if it advertises two local endpoints;
7828    // rare, but pair each pair exactly once).
7829    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
7830        Default::default();
7831    for group in listing.local.into_values() {
7832        for s in group {
7833            by_name.entry(s.name.clone()).or_insert(s);
7834        }
7835    }
7836    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
7837
7838    if sessions.len() < 2 {
7839        let msg = format!(
7840            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
7841            sessions.len()
7842        );
7843        if as_json {
7844            println!(
7845                "{}",
7846                serde_json::to_string(&json!({
7847                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
7848                    "pairs_attempted": 0,
7849                    "pairs_succeeded": 0,
7850                    "pairs_skipped_already_paired": 0,
7851                    "pairs_failed": 0,
7852                    "note": msg,
7853                }))?
7854            );
7855        } else {
7856            println!("{msg}");
7857            if let Some(s) = sessions.first() {
7858                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
7859            }
7860            println!("Use `wire session new --with-local` to add more.");
7861        }
7862        return Ok(());
7863    }
7864
7865    let fed_host = host_of_url(federation_relay);
7866    if fed_host.is_empty() {
7867        bail!(
7868            "federation_relay `{federation_relay}` has no parseable host — \
7869             pass a full URL like `https://wireup.net`."
7870        );
7871    }
7872
7873    // Enumerate unordered pairs deterministically by session name.
7874    let mut attempted = 0u32;
7875    let mut succeeded = 0u32;
7876    let mut skipped_already = 0u32;
7877    let mut failed = 0u32;
7878    let mut per_pair: Vec<Value> = Vec::new();
7879
7880    for i in 0..sessions.len() {
7881        for j in (i + 1)..sessions.len() {
7882            let a = &sessions[i];
7883            let b = &sessions[j];
7884            attempted += 1;
7885
7886            // Already-paired check: if A's relay-state has B's nick in
7887            // peers AND vice versa, skip.
7888            let a_pinned_b = session_has_peer(&a.home_dir, &b.name);
7889            let b_pinned_a = session_has_peer(&b.home_dir, &a.name);
7890            if a_pinned_b && b_pinned_a {
7891                skipped_already += 1;
7892                per_pair.push(json!({
7893                    "from": a.name,
7894                    "to": b.name,
7895                    "status": "already_paired",
7896                }));
7897                continue;
7898            }
7899
7900            let pair_result = drive_bilateral_pair(
7901                &a.home_dir,
7902                &a.name,
7903                &b.home_dir,
7904                &b.name,
7905                &fed_host,
7906                federation_relay,
7907                settle_secs,
7908            );
7909
7910            match pair_result {
7911                Ok(()) => {
7912                    succeeded += 1;
7913                    per_pair.push(json!({
7914                        "from": a.name,
7915                        "to": b.name,
7916                        "status": "paired",
7917                    }));
7918                }
7919                Err(e) => {
7920                    failed += 1;
7921                    let detail = format!("{e:#}");
7922                    per_pair.push(json!({
7923                        "from": a.name,
7924                        "to": b.name,
7925                        "status": "failed",
7926                        "error": detail,
7927                    }));
7928                }
7929            }
7930
7931            // Brief settle between pairs so we don't slam the relay
7932            // with N(N-1) parallel requests.
7933            std::thread::sleep(Duration::from_millis(200));
7934        }
7935    }
7936
7937    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
7938    let summary = json!({
7939        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
7940        "pairs_attempted": attempted,
7941        "pairs_succeeded": succeeded,
7942        "pairs_skipped_already_paired": skipped_already,
7943        "pairs_failed": failed,
7944        "results": per_pair,
7945    });
7946    if as_json {
7947        println!("{}", serde_json::to_string(&summary)?);
7948    } else {
7949        println!(
7950            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
7951            sessions.len(),
7952            attempted
7953        );
7954        println!("  paired:                 {succeeded}");
7955        println!("  skipped (already pinned): {skipped_already}");
7956        println!("  failed:                 {failed}");
7957        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
7958            let from = entry["from"].as_str().unwrap_or("?");
7959            let to = entry["to"].as_str().unwrap_or("?");
7960            let status = entry["status"].as_str().unwrap_or("?");
7961            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
7962            if err.is_empty() {
7963                println!("  {from:<24} ↔ {to:<24} {status}");
7964            } else {
7965                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
7966            }
7967        }
7968    }
7969    Ok(())
7970}
7971
7972/// Check whether `session_home`'s `relay.json` already lists `peer_name`
7973/// under `state.peers`. Best-effort — any read/parse error → false.
7974fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
7975    val_session_relay_state(session_home)
7976        .and_then(|v| v.get("peers").cloned())
7977        .and_then(|p| p.get(peer_name).cloned())
7978        .is_some()
7979}
7980
7981/// Read a session's `relay.json` directly without mutating the process'
7982/// WIRE_HOME env (which would race other threads / processes). Returns
7983/// `None` on any read or parse error — callers treat missing state as
7984/// "no peers / no endpoints" rather than aborting.
7985fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
7986    let path = session_home.join("config").join("wire").join("relay.json");
7987    let bytes = std::fs::read(&path).ok()?;
7988    serde_json::from_slice(&bytes).ok()
7989}
7990
7991/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
7992/// One probe per directed edge against the relay backing that edge's
7993/// priority-1 endpoint; output groups by undirected pair.
7994fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
7995    use std::collections::BTreeMap;
7996
7997    // Flatten by session NAME — same dedup logic as pair-all-local so a
7998    // session advertising two local endpoints doesn't get double-counted.
7999    let listing = crate::session::list_local_sessions()?;
8000    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
8001    for group in listing.local.into_values() {
8002        for s in group {
8003            by_name.entry(s.name.clone()).or_insert(s);
8004        }
8005    }
8006    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8007    let federation_only = listing.federation_only;
8008
8009    if sessions.is_empty() {
8010        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
8011        if as_json {
8012            println!(
8013                "{}",
8014                serde_json::to_string(&json!({
8015                    "sessions": [],
8016                    "edges": [],
8017                    "local_relay": null,
8018                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8019                    "summary": {
8020                        "session_count": 0,
8021                        "edge_count": 0,
8022                        "healthy": 0,
8023                        "stale": 0,
8024                        "asymmetric": 0,
8025                    },
8026                    "note": msg,
8027                }))?
8028            );
8029        } else {
8030            println!("{msg}");
8031            println!("Use `wire session new --with-local` to create one.");
8032        }
8033        return Ok(());
8034    }
8035
8036    // Build a name → session-state map: relay_state + reachable handle set.
8037    struct SessionState {
8038        view: crate::session::LocalSessionView,
8039        relay_state: Value,
8040        local_relay_url: Option<String>,
8041    }
8042    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
8043    for s in sessions {
8044        let relay_state = val_session_relay_state(&s.home_dir)
8045            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8046        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
8047        sstates.push(SessionState {
8048            view: s,
8049            relay_state,
8050            local_relay_url,
8051        });
8052    }
8053
8054    // Probe each unique local-relay URL once for healthz so the operator
8055    // sees one liveness line per local relay, not one per edge.
8056    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
8057    for s in &sstates {
8058        if let Some(url) = &s.local_relay_url
8059            && !local_relays.contains_key(url)
8060        {
8061            let healthy = probe_relay_healthz(url);
8062            local_relays.insert(url.clone(), healthy);
8063        }
8064    }
8065
8066    let now = std::time::SystemTime::now()
8067        .duration_since(std::time::UNIX_EPOCH)
8068        .map(|d| d.as_secs())
8069        .unwrap_or(0);
8070
8071    // Edges: walk every unordered pair, surface bilateral state + each
8072    // direction's last_pull. Probe priority-1 endpoint (local preferred
8073    // by `peer_endpoints_in_priority_order`).
8074    let mut edges: Vec<Value> = Vec::new();
8075    let mut healthy_count = 0u32;
8076    let mut stale_count = 0u32;
8077    let mut asymmetric_count = 0u32;
8078
8079    for i in 0..sstates.len() {
8080        for j in (i + 1)..sstates.len() {
8081            let a = &sstates[i];
8082            let b = &sstates[j];
8083            let a_to_b = probe_directed_edge(&a.relay_state, &b.view.name, now);
8084            let b_to_a = probe_directed_edge(&b.relay_state, &a.view.name, now);
8085
8086            let bilateral = a_to_b.pinned && b_to_a.pinned;
8087            // Scope = the most-local scope available in either direction.
8088            // (If a→b is local and b→a is federation, the asymmetric
8089            // detail surfaces below; the headline scope is the better.)
8090            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
8091                (Some("local"), _) | (_, Some("local")) => "local",
8092                (Some("federation"), _) | (_, Some("federation")) => "federation",
8093                _ => "unknown",
8094            };
8095
8096            // Health: stale if either direction's last_pull is older than
8097            // `stale_secs`, or never observed when both sides are pinned.
8098            let mut status = if bilateral { "healthy" } else { "asymmetric" };
8099            if bilateral {
8100                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
8101                    Some(s) => s > stale_secs,
8102                    None => d.probed,
8103                });
8104                if either_stale {
8105                    status = "stale";
8106                }
8107            }
8108
8109            match status {
8110                "healthy" => healthy_count += 1,
8111                "stale" => stale_count += 1,
8112                "asymmetric" => asymmetric_count += 1,
8113                _ => {}
8114            }
8115
8116            edges.push(json!({
8117                "from": a.view.name,
8118                "to": b.view.name,
8119                "bilateral": bilateral,
8120                "scope": scope,
8121                "status": status,
8122                "directions": {
8123                    a.view.name.clone(): direction_summary(&a_to_b),
8124                    b.view.name.clone(): direction_summary(&b_to_a),
8125                },
8126            }));
8127        }
8128    }
8129
8130    let summary = json!({
8131        "sessions": sstates.iter().map(|s| json!({
8132            "name": s.view.name,
8133            "handle": s.view.handle,
8134            "cwd": s.view.cwd,
8135            "daemon_running": s.view.daemon_running,
8136            "local_relay": s.local_relay_url,
8137        })).collect::<Vec<_>>(),
8138        "edges": edges,
8139        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
8140            "url": url,
8141            "healthy": healthy,
8142        })).collect::<Vec<_>>(),
8143        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8144        "summary": {
8145            "session_count": sstates.len(),
8146            "edge_count": edges.len(),
8147            "healthy": healthy_count,
8148            "stale": stale_count,
8149            "asymmetric": asymmetric_count,
8150            "stale_threshold_secs": stale_secs,
8151        },
8152    });
8153
8154    if as_json {
8155        println!("{}", serde_json::to_string(&summary)?);
8156        return Ok(());
8157    }
8158
8159    println!(
8160        "wire mesh: {} session(s), {} edge(s)",
8161        sstates.len(),
8162        edges.len()
8163    );
8164    for (url, healthy) in &local_relays {
8165        let tick = if *healthy { "✓" } else { "✗" };
8166        println!("  local-relay {url} {tick}");
8167    }
8168    if !federation_only.is_empty() {
8169        print!("  federation-only sessions:");
8170        for f in &federation_only {
8171            print!(" {}", f.name);
8172        }
8173        println!();
8174    }
8175
8176    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
8177    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
8178    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
8179    print!("\n{:>col_w$}", "", col_w = col_w);
8180    for n in &names {
8181        print!("{:>col_w$}", n, col_w = col_w);
8182    }
8183    println!();
8184    for (i, row) in names.iter().enumerate() {
8185        print!("{:>col_w$}", row, col_w = col_w);
8186        for (j, col) in names.iter().enumerate() {
8187            let cell = if i == j {
8188                "self".to_string()
8189            } else {
8190                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
8191                match d.scope.as_deref() {
8192                    Some("local") => "local".to_string(),
8193                    Some("federation") => "fed".to_string(),
8194                    _ => "—".to_string(),
8195                }
8196            };
8197            print!("{:>col_w$}", cell, col_w = col_w);
8198        }
8199        println!();
8200    }
8201
8202    println!("\nHealth (stale threshold: {stale_secs}s):");
8203    for e in &edges {
8204        let from = e["from"].as_str().unwrap_or("?");
8205        let to = e["to"].as_str().unwrap_or("?");
8206        let scope = e["scope"].as_str().unwrap_or("?");
8207        let status = e["status"].as_str().unwrap_or("?");
8208        let mark = match status {
8209            "healthy" => "✓",
8210            "stale" => "⚠",
8211            "asymmetric" => "!",
8212            _ => "?",
8213        };
8214        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
8215        let mut details: Vec<String> = Vec::new();
8216        for (who, d) in &dirs {
8217            let silent = d.get("silent_secs").and_then(Value::as_u64);
8218            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
8219            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
8220            let label = match (pinned, probed, silent) {
8221                (false, _, _) => format!("{who} has not pinned"),
8222                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
8223                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
8224                (true, true, Some(s)) => format!("{who} silent {s}s"),
8225                (true, true, None) => format!("{who} never pulled"),
8226            };
8227            details.push(label);
8228        }
8229        println!(
8230            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
8231            details.join(" | ")
8232        );
8233    }
8234    Ok(())
8235}
8236
8237#[derive(Default)]
8238struct DirectedEdge {
8239    pinned: bool,
8240    scope: Option<String>,
8241    last_pull_at_unix: Option<u64>,
8242    silent_secs: Option<u64>,
8243    probed: bool,
8244    event_count: usize,
8245}
8246
8247/// Probe a single directed edge from `from_state`'s view of `to_name`.
8248/// Picks the priority-1 endpoint (local preferred when reachable) and
8249/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
8250/// failure (the function records `probed = true`, `last_pull = None`,
8251/// which the caller treats as "never pulled, route exists" = stale).
8252fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
8253    let pinned = from_state
8254        .get("peers")
8255        .and_then(|p| p.get(to_name))
8256        .is_some();
8257    if !pinned {
8258        return DirectedEdge::default();
8259    }
8260    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
8261    let ep = match endpoints.into_iter().next() {
8262        Some(e) => e,
8263        None => {
8264            return DirectedEdge {
8265                pinned: true,
8266                ..Default::default()
8267            };
8268        }
8269    };
8270    let scope = Some(
8271        match ep.scope {
8272            crate::endpoints::EndpointScope::Local => "local",
8273            crate::endpoints::EndpointScope::Lan => "lan",
8274            crate::endpoints::EndpointScope::Uds => "uds",
8275            crate::endpoints::EndpointScope::Federation => "federation",
8276        }
8277        .to_string(),
8278    );
8279    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
8280    let (count, last) = client
8281        .slot_state(&ep.slot_id, &ep.slot_token)
8282        .unwrap_or((0, None));
8283    let silent = last.map(|t| now.saturating_sub(t));
8284    DirectedEdge {
8285        pinned: true,
8286        scope,
8287        last_pull_at_unix: last,
8288        silent_secs: silent,
8289        probed: true,
8290        event_count: count,
8291    }
8292}
8293
8294fn direction_summary(d: &DirectedEdge) -> Value {
8295    json!({
8296        "pinned": d.pinned,
8297        "scope": d.scope,
8298        "probed": d.probed,
8299        "last_pull_at_unix": d.last_pull_at_unix,
8300        "silent_secs": d.silent_secs,
8301        "event_count": d.event_count,
8302    })
8303}
8304
8305/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
8306fn probe_relay_healthz(url: &str) -> bool {
8307    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
8308    let client = match reqwest::blocking::Client::builder()
8309        .timeout(std::time::Duration::from_millis(500))
8310        .build()
8311    {
8312        Ok(c) => c,
8313        Err(_) => return false,
8314    };
8315    match client.get(&probe_url).send() {
8316        Ok(r) => r.status().is_success(),
8317        Err(_) => false,
8318    }
8319}
8320
8321/// Drive one bilateral pair handshake between two sister sessions
8322/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
8323/// flow so failures bubble up at the offending step, not buried in
8324/// a parallel race. See `cmd_session_pair_all_local` docstring.
8325///
8326/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
8327/// federation `.well-known/wire/agent` resolution. Reads B's card +
8328/// endpoints directly off disk under `b_home` and pins them. This
8329/// makes pair-all-local work for sister sessions whose federation
8330/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
8331/// for sessions created with `wire session new --local-only`
8332/// (no federation slot at all). The `_federation_relay` / `_fed_host`
8333/// parameters are retained for callers that want to log them but
8334/// the handshake itself no longer touches federation.
8335fn drive_bilateral_pair(
8336    a_home: &std::path::Path,
8337    a_name: &str,
8338    b_home: &std::path::Path,
8339    b_name: &str,
8340    _fed_host: &str,
8341    _federation_relay: &str,
8342    settle_secs: u64,
8343) -> Result<()> {
8344    use std::time::Duration;
8345    let bin = std::env::current_exe().context("locating self exe")?;
8346
8347    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
8348        let out = std::process::Command::new(&bin)
8349            .env("WIRE_HOME", home)
8350            .env_remove("RUST_LOG")
8351            .args(args)
8352            .output()
8353            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8354        if !out.status.success() {
8355            bail!(
8356                "`wire {}` failed: stderr={}",
8357                args.join(" "),
8358                String::from_utf8_lossy(&out.stderr).trim()
8359            );
8360        }
8361        Ok(())
8362    };
8363
8364    // 1. A initiates via --local-sister (reads B's card + endpoints
8365    // from disk, pins, delivers pair_drop direct to B's local slot)
8366    // → 2. NO separate push needed — `wire add --local-sister` does
8367    // the slot POST inline. Keeping a no-op push so the step count
8368    // matches the old federation flow for log/error continuity.
8369    run(a_home, &["add", b_name, "--local-sister", "--json"])
8370        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
8371
8372    // 3. settle so pair_drop reaches B's slot
8373    std::thread::sleep(Duration::from_secs(settle_secs));
8374
8375    // 4. B pulls pair_drop → 5. B pair-accept (pins A) → 6. B push pair_drop_ack
8376    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
8377    run(b_home, &["pair-accept", a_name, "--json"])
8378        .with_context(|| format!("step 5/8: {b_name} `wire pair-accept {a_name}`"))?;
8379    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
8380
8381    // 7. settle so ack reaches A's slot
8382    std::thread::sleep(Duration::from_secs(settle_secs));
8383
8384    // 8. A pulls ack (pins B with the slot_token + endpoints[] from the ack)
8385    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
8386
8387    Ok(())
8388}
8389
8390fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
8391    let name = resolve_session_name(name_arg)?;
8392    let session_home = crate::session::session_dir(&name)?;
8393    if !session_home.exists() {
8394        bail!(
8395            "no session named {name:?} on this machine. `wire session list` to enumerate, \
8396             `wire session new {name}` to create."
8397        );
8398    }
8399    if as_json {
8400        println!(
8401            "{}",
8402            serde_json::to_string(&json!({
8403                "name": name,
8404                "home_dir": session_home.to_string_lossy(),
8405                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8406            }))?
8407        );
8408    } else {
8409        println!("export WIRE_HOME={}", session_home.to_string_lossy());
8410    }
8411    Ok(())
8412}
8413
8414fn cmd_session_current(as_json: bool) -> Result<()> {
8415    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8416    let registry = crate::session::read_registry().unwrap_or_default();
8417    let cwd_key = cwd.to_string_lossy().into_owned();
8418    let name = registry.by_cwd.get(&cwd_key).cloned();
8419    if as_json {
8420        println!(
8421            "{}",
8422            serde_json::to_string(&json!({
8423                "cwd": cwd_key,
8424                "session": name,
8425            }))?
8426        );
8427    } else if let Some(n) = name {
8428        println!("{n}");
8429    } else {
8430        println!("(no session registered for this cwd)");
8431    }
8432    Ok(())
8433}
8434
8435fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
8436    let name = crate::session::sanitize_name(name_arg);
8437    let session_home = crate::session::session_dir(&name)?;
8438    if !session_home.exists() {
8439        if as_json {
8440            println!(
8441                "{}",
8442                serde_json::to_string(&json!({
8443                    "name": name,
8444                    "destroyed": false,
8445                    "reason": "no such session",
8446                }))?
8447            );
8448        } else {
8449            println!("no session named {name:?} — nothing to destroy.");
8450        }
8451        return Ok(());
8452    }
8453    if !force {
8454        bail!(
8455            "destroying session {name:?} would delete its keypair + state irrecoverably. \
8456             Pass --force to confirm."
8457        );
8458    }
8459
8460    // Kill the session-local daemon if alive.
8461    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8462    if let Ok(bytes) = std::fs::read(&pidfile) {
8463        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8464            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8465        } else {
8466            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8467        };
8468        if let Some(p) = pid {
8469            let _ = std::process::Command::new("kill")
8470                .args(["-TERM", &p.to_string()])
8471                .output();
8472        }
8473    }
8474
8475    std::fs::remove_dir_all(&session_home)
8476        .with_context(|| format!("removing session dir {session_home:?}"))?;
8477
8478    // Strip from registry.
8479    let mut registry = crate::session::read_registry().unwrap_or_default();
8480    registry.by_cwd.retain(|_, v| v != &name);
8481    crate::session::write_registry(&registry)?;
8482
8483    if as_json {
8484        println!(
8485            "{}",
8486            serde_json::to_string(&json!({
8487                "name": name,
8488                "destroyed": true,
8489            }))?
8490        );
8491    } else {
8492        println!("destroyed session {name:?}.");
8493    }
8494    Ok(())
8495}
8496
8497// ---------- diag (structured trace) ----------
8498
8499fn cmd_diag(action: DiagAction) -> Result<()> {
8500    let state = config::state_dir()?;
8501    let knob = state.join("diag.enabled");
8502    let log_path = state.join("diag.jsonl");
8503    match action {
8504        DiagAction::Tail { limit, json } => {
8505            let entries = crate::diag::tail(limit);
8506            if json {
8507                for e in entries {
8508                    println!("{}", serde_json::to_string(&e)?);
8509                }
8510            } else if entries.is_empty() {
8511                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
8512            } else {
8513                for e in entries {
8514                    let ts = e["ts"].as_u64().unwrap_or(0);
8515                    let ty = e["type"].as_str().unwrap_or("?");
8516                    let pid = e["pid"].as_u64().unwrap_or(0);
8517                    let payload = e["payload"].to_string();
8518                    println!("[{ts}] pid={pid} {ty} {payload}");
8519                }
8520            }
8521        }
8522        DiagAction::Enable => {
8523            config::ensure_dirs()?;
8524            std::fs::write(&knob, "1")?;
8525            println!("wire diag: enabled at {knob:?}");
8526        }
8527        DiagAction::Disable => {
8528            if knob.exists() {
8529                std::fs::remove_file(&knob)?;
8530            }
8531            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
8532        }
8533        DiagAction::Status { json } => {
8534            let enabled = crate::diag::is_enabled();
8535            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
8536            if json {
8537                println!(
8538                    "{}",
8539                    serde_json::to_string(&serde_json::json!({
8540                        "enabled": enabled,
8541                        "log_path": log_path,
8542                        "log_size_bytes": size,
8543                    }))?
8544                );
8545            } else {
8546                println!("wire diag status");
8547                println!("  enabled:    {enabled}");
8548                println!("  log:        {log_path:?}");
8549                println!("  log size:   {size} bytes");
8550            }
8551        }
8552    }
8553    Ok(())
8554}
8555
8556// ---------- service (install / uninstall / status) ----------
8557
8558fn cmd_service(action: ServiceAction) -> Result<()> {
8559    let kind = |local_relay: bool| {
8560        if local_relay {
8561            crate::service::ServiceKind::LocalRelay
8562        } else {
8563            crate::service::ServiceKind::Daemon
8564        }
8565    };
8566    let (report, as_json) = match action {
8567        ServiceAction::Install { local_relay, json } => {
8568            (crate::service::install_kind(kind(local_relay))?, json)
8569        }
8570        ServiceAction::Uninstall { local_relay, json } => {
8571            (crate::service::uninstall_kind(kind(local_relay))?, json)
8572        }
8573        ServiceAction::Status { local_relay, json } => {
8574            (crate::service::status_kind(kind(local_relay))?, json)
8575        }
8576    };
8577    if as_json {
8578        println!("{}", serde_json::to_string(&report)?);
8579    } else {
8580        println!("wire service {}", report.action);
8581        println!("  platform:  {}", report.platform);
8582        println!("  unit:      {}", report.unit_path);
8583        println!("  status:    {}", report.status);
8584        println!("  detail:    {}", report.detail);
8585    }
8586    Ok(())
8587}
8588
8589// ---------- upgrade (atomic daemon swap) ----------
8590
8591/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
8592/// fresh one from the currently-installed binary, write a new versioned
8593/// pidfile. The fix for today's exact failure mode: a daemon process that
8594/// kept running OLD binary text in memory under a symlink that had since
8595/// been repointed at a NEW binary on disk.
8596///
8597/// Idempotent. If no stale daemon is running, just starts a fresh one
8598/// (same as `wire daemon &` but with the wait-until-alive guard from
8599/// ensure_up::ensure_daemon_running).
8600///
8601/// `--check` mode reports drift without acting — lists the processes
8602/// that WOULD be killed and the binary version of each.
8603fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
8604    // 1. Identify all `wire daemon` processes.
8605    let pgrep_out = std::process::Command::new("pgrep")
8606        .args(["-f", "wire daemon"])
8607        .output();
8608    let running_pids: Vec<u32> = match pgrep_out {
8609        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
8610            .split_whitespace()
8611            .filter_map(|s| s.parse::<u32>().ok())
8612            .collect(),
8613        _ => Vec::new(),
8614    };
8615
8616    // 2. Read pidfile to surface what the daemon THINKS it is.
8617    let record = crate::ensure_up::read_pid_record("daemon");
8618    let recorded_version: Option<String> = match &record {
8619        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
8620        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
8621        _ => None,
8622    };
8623    let cli_version = env!("CARGO_PKG_VERSION").to_string();
8624
8625    // 2b. v0.6.9: snapshot which sessions HAD a running daemon BEFORE
8626    // we kill anything. Step 3's pgrep+SIGTERM also kills session-owned
8627    // daemons (they share the `wire daemon` command line), so by the
8628    // time the respawn loop runs, `daemon_running` would always be
8629    // false and zero sessions would respawn. Capture state up front
8630    // and respawn whatever was alive at the start.
8631    let sessions_to_respawn_after_kill: Vec<std::path::PathBuf> = crate::session::list_sessions()
8632        .unwrap_or_default()
8633        .into_iter()
8634        .filter(|s| s.daemon_running)
8635        .map(|s| s.home_dir)
8636        .collect();
8637
8638    if check_only {
8639        // v0.6.8: also surface session-level state + PATH dupes in --check.
8640        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
8641            .unwrap_or_default()
8642            .iter()
8643            .filter(|s| s.daemon_running)
8644            .map(|s| s.name.clone())
8645            .collect();
8646        let mut path_dupes: Vec<String> = Vec::new();
8647        if let Ok(path) = std::env::var("PATH") {
8648            let mut seen: std::collections::HashSet<std::path::PathBuf> =
8649                std::collections::HashSet::new();
8650            for dir in path.split(':') {
8651                let candidate = std::path::PathBuf::from(dir).join("wire");
8652                if candidate.exists() {
8653                    let canon = candidate.canonicalize().unwrap_or(candidate);
8654                    if seen.insert(canon.clone()) {
8655                        path_dupes.push(canon.to_string_lossy().into_owned());
8656                    }
8657                }
8658            }
8659        }
8660        let report = json!({
8661            "running_pids": running_pids,
8662            "pidfile_version": recorded_version,
8663            "cli_version": cli_version,
8664            "would_kill": running_pids,
8665            "session_daemons_running": sessions_with_daemons,
8666            "path_binaries": path_dupes,
8667            "path_duplicate_warning": path_dupes.len() > 1,
8668        });
8669        if as_json {
8670            println!("{}", serde_json::to_string(&report)?);
8671        } else {
8672            println!("wire upgrade --check");
8673            println!("  cli version:      {cli_version}");
8674            println!(
8675                "  pidfile version:  {}",
8676                recorded_version.as_deref().unwrap_or("(missing)")
8677            );
8678            if running_pids.is_empty() {
8679                println!("  running daemons:  none");
8680            } else {
8681                let pids: Vec<String> = running_pids.iter().map(|p| p.to_string()).collect();
8682                println!("  running daemons:  pids {}", pids.join(", "));
8683                println!("  would kill all + spawn fresh");
8684            }
8685            if !sessions_with_daemons.is_empty() {
8686                println!(
8687                    "  session daemons:  {} (would respawn under new binary)",
8688                    sessions_with_daemons.join(", ")
8689                );
8690            }
8691            if path_dupes.len() > 1 {
8692                println!(
8693                    "  PATH warning:     {} distinct `wire` binaries on PATH:",
8694                    path_dupes.len()
8695                );
8696                for b in &path_dupes {
8697                    println!("                      {b}");
8698                }
8699                println!("                    operators should remove the stale ones");
8700            }
8701        }
8702        return Ok(());
8703    }
8704
8705    // 3. Kill every running wire daemon. Use SIGTERM first, then SIGKILL
8706    // after a brief grace period.
8707    let mut killed: Vec<u32> = Vec::new();
8708    for pid in &running_pids {
8709        // SIGTERM (15).
8710        let _ = std::process::Command::new("kill")
8711            .args(["-15", &pid.to_string()])
8712            .status();
8713        killed.push(*pid);
8714    }
8715    // Wait up to ~2s for graceful exit.
8716    if !killed.is_empty() {
8717        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
8718        loop {
8719            let still_alive: Vec<u32> = killed
8720                .iter()
8721                .copied()
8722                .filter(|p| process_alive_pid(*p))
8723                .collect();
8724            if still_alive.is_empty() {
8725                break;
8726            }
8727            if std::time::Instant::now() >= deadline {
8728                // SIGKILL hold-outs.
8729                for pid in still_alive {
8730                    let _ = std::process::Command::new("kill")
8731                        .args(["-9", &pid.to_string()])
8732                        .status();
8733                }
8734                break;
8735            }
8736            std::thread::sleep(std::time::Duration::from_millis(50));
8737        }
8738    }
8739
8740    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
8741    //    old daemon is still owning it.
8742    let pidfile = config::state_dir()?.join("daemon.pid");
8743    if pidfile.exists() {
8744        let _ = std::fs::remove_file(&pidfile);
8745    }
8746
8747    // 4b. v0.6.8/9 stale-cleanup: wipe every session's pidfile (step 3's
8748    // pgrep+SIGTERM has already killed the processes; pidfile tombstones
8749    // would otherwise block ensure_session_daemon's "already running"
8750    // short-circuit). The respawn list comes from the v0.6.9 pre-kill
8751    // snapshot above — checking `daemon_running` here would always
8752    // return false because we just killed them.
8753    if let Ok(sessions) = crate::session::list_sessions() {
8754        for s in &sessions {
8755            let session_pidfile = s.home_dir.join("state").join("wire").join("daemon.pid");
8756            if session_pidfile.exists() {
8757                let _ = std::fs::remove_file(&session_pidfile);
8758            }
8759        }
8760    }
8761    let session_daemons_to_respawn = sessions_to_respawn_after_kill;
8762
8763    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
8764    // multiple distinct files on $PATH, surface the conflict — operators
8765    // get bitten when an old binary at /usr/local/bin shadows a fresh
8766    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
8767    let mut path_dupes: Vec<String> = Vec::new();
8768    if let Ok(path) = std::env::var("PATH") {
8769        let mut seen: std::collections::HashSet<std::path::PathBuf> =
8770            std::collections::HashSet::new();
8771        for dir in path.split(':') {
8772            let candidate = std::path::PathBuf::from(dir).join("wire");
8773            if candidate.exists() {
8774                let canon = candidate.canonicalize().unwrap_or(candidate);
8775                if seen.insert(canon.clone()) {
8776                    path_dupes.push(canon.to_string_lossy().into_owned());
8777                }
8778            }
8779        }
8780    }
8781    let path_warning = if path_dupes.len() > 1 {
8782        Some(format!(
8783            "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n  {}",
8784            path_dupes.len(),
8785            path_dupes.join("\n  ")
8786        ))
8787    } else {
8788        None
8789    };
8790
8791    // 5. Spawn fresh daemon via ensure_up — atomically waits for
8792    //    process_alive + writes the versioned pidfile.
8793    let spawned = crate::ensure_up::ensure_daemon_running()?;
8794
8795    // 5b. v0.6.8: respawn each session daemon under the new binary.
8796    // Reuses `ensure_session_daemon` — same code path `wire session new`
8797    // takes for the initial spawn (writes versioned pidfile, opens log,
8798    // detaches). Best effort: failure of one session's respawn doesn't
8799    // abort the upgrade for the others.
8800    let mut session_respawns: Vec<Value> = Vec::new();
8801    for home in &session_daemons_to_respawn {
8802        match ensure_session_daemon(home) {
8803            Ok(()) => session_respawns.push(json!({
8804                "session_home": home.to_string_lossy(),
8805                "status": "respawned",
8806            })),
8807            Err(e) => session_respawns.push(json!({
8808                "session_home": home.to_string_lossy(),
8809                "status": "failed",
8810                "error": format!("{e:#}"),
8811            })),
8812        }
8813    }
8814
8815    let new_record = crate::ensure_up::read_pid_record("daemon");
8816    let new_pid = new_record.pid();
8817    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
8818        Some(d.version.clone())
8819    } else {
8820        None
8821    };
8822
8823    if as_json {
8824        println!(
8825            "{}",
8826            serde_json::to_string(&json!({
8827                "killed": killed,
8828                "spawned_fresh_daemon": spawned,
8829                "new_pid": new_pid,
8830                "new_version": new_version,
8831                "cli_version": cli_version,
8832                "session_respawns": session_respawns,
8833                "path_binaries": path_dupes,
8834                "path_warning": path_warning,
8835            }))?
8836        );
8837    } else {
8838        if killed.is_empty() {
8839            println!("wire upgrade: no stale daemons running");
8840        } else {
8841            println!(
8842                "wire upgrade: killed {} daemon(s) (pids {})",
8843                killed.len(),
8844                killed
8845                    .iter()
8846                    .map(|p| p.to_string())
8847                    .collect::<Vec<_>>()
8848                    .join(", ")
8849            );
8850        }
8851        if spawned {
8852            println!(
8853                "wire upgrade: spawned fresh daemon (pid {} v{})",
8854                new_pid
8855                    .map(|p| p.to_string())
8856                    .unwrap_or_else(|| "?".to_string()),
8857                new_version.as_deref().unwrap_or(&cli_version),
8858            );
8859        } else {
8860            println!("wire upgrade: daemon was already running on current binary");
8861        }
8862        if !session_respawns.is_empty() {
8863            println!(
8864                "wire upgrade: refreshed {} session daemon(s):",
8865                session_respawns.len()
8866            );
8867            for r in &session_respawns {
8868                let h = r["session_home"].as_str().unwrap_or("?");
8869                let s = r["status"].as_str().unwrap_or("?");
8870                let label = std::path::Path::new(h)
8871                    .file_name()
8872                    .map(|f| f.to_string_lossy().into_owned())
8873                    .unwrap_or_else(|| h.to_string());
8874                println!("  {label:<24} {s}");
8875            }
8876        }
8877        if let Some(msg) = &path_warning {
8878            eprintln!("wire upgrade: {msg}");
8879        }
8880    }
8881    Ok(())
8882}
8883
8884fn process_alive_pid(pid: u32) -> bool {
8885    #[cfg(target_os = "linux")]
8886    {
8887        std::path::Path::new(&format!("/proc/{pid}")).exists()
8888    }
8889    #[cfg(not(target_os = "linux"))]
8890    {
8891        std::process::Command::new("kill")
8892            .args(["-0", &pid.to_string()])
8893            .stdin(std::process::Stdio::null())
8894            .stdout(std::process::Stdio::null())
8895            .stderr(std::process::Stdio::null())
8896            .status()
8897            .map(|s| s.success())
8898            .unwrap_or(false)
8899    }
8900}
8901
8902// ---------- doctor (single-command diagnostic) ----------
8903
8904/// One DoctorCheck = one verdict on one health dimension.
8905#[derive(Clone, Debug, serde::Serialize)]
8906pub struct DoctorCheck {
8907    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
8908    /// Stable across versions for tooling consumption.
8909    pub id: String,
8910    /// PASS / WARN / FAIL.
8911    pub status: String,
8912    /// One-line human summary.
8913    pub detail: String,
8914    /// Optional remediation hint shown after the failing line.
8915    #[serde(skip_serializing_if = "Option::is_none")]
8916    pub fix: Option<String>,
8917}
8918
8919impl DoctorCheck {
8920    fn pass(id: &str, detail: impl Into<String>) -> Self {
8921        Self {
8922            id: id.into(),
8923            status: "PASS".into(),
8924            detail: detail.into(),
8925            fix: None,
8926        }
8927    }
8928    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
8929        Self {
8930            id: id.into(),
8931            status: "WARN".into(),
8932            detail: detail.into(),
8933            fix: Some(fix.into()),
8934        }
8935    }
8936    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
8937        Self {
8938            id: id.into(),
8939            status: "FAIL".into(),
8940            detail: detail.into(),
8941            fix: Some(fix.into()),
8942        }
8943    }
8944}
8945
8946/// `wire doctor` — single-command diagnostic for the silent-fail classes
8947/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
8948/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
8949/// so operators don't have to know where each lives.
8950fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
8951    let checks: Vec<DoctorCheck> = vec![
8952        check_daemon_health(),
8953        check_daemon_pid_consistency(),
8954        check_relay_reachable(),
8955        check_pair_rejections(recent_rejections),
8956        check_cursor_progress(),
8957    ];
8958
8959    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
8960    let warns = checks.iter().filter(|c| c.status == "WARN").count();
8961
8962    if as_json {
8963        println!(
8964            "{}",
8965            serde_json::to_string(&json!({
8966                "checks": checks,
8967                "fail_count": fails,
8968                "warn_count": warns,
8969                "ok": fails == 0,
8970            }))?
8971        );
8972    } else {
8973        println!("wire doctor — {} checks", checks.len());
8974        for c in &checks {
8975            let bullet = match c.status.as_str() {
8976                "PASS" => "✓",
8977                "WARN" => "!",
8978                "FAIL" => "✗",
8979                _ => "?",
8980            };
8981            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
8982            if let Some(fix) = &c.fix {
8983                println!("      fix: {fix}");
8984            }
8985        }
8986        println!();
8987        if fails == 0 && warns == 0 {
8988            println!("ALL GREEN");
8989        } else {
8990            println!("{fails} FAIL, {warns} WARN");
8991        }
8992    }
8993
8994    if fails > 0 {
8995        std::process::exit(1);
8996    }
8997    Ok(())
8998}
8999
9000/// Check: daemon running, exactly one instance, no orphans.
9001///
9002/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
9003/// days, advancing cursor without pinning). `wire status` lied about it.
9004/// `wire doctor` must catch THIS class: multiple daemons running, OR
9005/// pid-file claims daemon down while a process is actually up.
9006fn check_daemon_health() -> DoctorCheck {
9007    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
9008    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
9009    // hardening): every surface routes through ensure_up::daemon_liveness
9010    // so they share one view of the world. No more parallel liveness
9011    // logic to drift out of sync.
9012    let snap = crate::ensure_up::daemon_liveness();
9013    let pgrep_pids = &snap.pgrep_pids;
9014    let pidfile_pid = snap.pidfile_pid;
9015    let pidfile_alive = snap.pidfile_alive;
9016    let orphan_pids = &snap.orphan_pids;
9017
9018    let fmt_pids = |xs: &[u32]| -> String {
9019        xs.iter()
9020            .map(|p| p.to_string())
9021            .collect::<Vec<_>>()
9022            .join(", ")
9023    };
9024
9025    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
9026        (0, _, _) => DoctorCheck::fail(
9027            "daemon",
9028            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
9029            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
9030        ),
9031        // Single daemon AND it matches the pidfile → healthy.
9032        (1, true, true) => DoctorCheck::pass(
9033            "daemon",
9034            format!(
9035                "one daemon running (pid {}, matches pidfile)",
9036                pgrep_pids[0]
9037            ),
9038        ),
9039        // Pidfile is alive but pgrep ALSO sees orphan processes.
9040        (n, true, false) => DoctorCheck::fail(
9041            "daemon",
9042            format!(
9043                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
9044                 The orphans race the relay cursor — they advance past events your current binary can't process. \
9045                 (Issue #2 exact class.)",
9046                fmt_pids(pgrep_pids),
9047                pidfile_pid.unwrap(),
9048                fmt_pids(orphan_pids),
9049            ),
9050            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
9051        ),
9052        // Pidfile is dead but processes ARE running → all are orphans.
9053        (n, false, _) => DoctorCheck::fail(
9054            "daemon",
9055            format!(
9056                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
9057                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
9058                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
9059                fmt_pids(pgrep_pids),
9060                match pidfile_pid {
9061                    Some(p) => format!("claims pid {p} which is dead"),
9062                    None => "is missing".to_string(),
9063                },
9064            ),
9065            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
9066        ),
9067        // Multiple daemons all matching … impossible by construction; fall back to warn.
9068        (n, true, true) => DoctorCheck::warn(
9069            "daemon",
9070            format!(
9071                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
9072                fmt_pids(pgrep_pids)
9073            ),
9074            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
9075        ),
9076    }
9077}
9078
9079/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
9080/// check. Surfaces version mismatch (daemon running old binary text in
9081/// memory under a current symlink — today's exact bug class), schema
9082/// drift (future format bumps), and identity contamination (daemon's
9083/// recorded DID doesn't match this box's configured DID).
9084///
9085/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
9086/// JSON pid record whose recorded `pid` is no longer a live OS process.
9087/// Pre-hardening this check PASSed in that state (it only validated
9088/// content, not liveness), letting `wire status: DOWN` and
9089/// `wire doctor: PASS` disagree for 25 min in incident #2.
9090fn check_daemon_pid_consistency() -> DoctorCheck {
9091    let snap = crate::ensure_up::daemon_liveness();
9092    match &snap.record {
9093        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
9094            "daemon_pid_consistency",
9095            "no daemon.pid yet — fresh box or daemon never started",
9096        ),
9097        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
9098            "daemon_pid_consistency",
9099            format!("daemon.pid is corrupt: {reason}"),
9100            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
9101        ),
9102        crate::ensure_up::PidRecord::LegacyInt(pid) => {
9103            // Legacy pidfile: still surface liveness so a dead legacy pid
9104            // doesn't quietly PASS this check while status says DOWN.
9105            let pid = *pid;
9106            if !crate::ensure_up::pid_is_alive(pid) {
9107                return DoctorCheck::warn(
9108                    "daemon_pid_consistency",
9109                    format!(
9110                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
9111                         Stale pidfile from a crashed pre-0.5.11 daemon. \
9112                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
9113                    ),
9114                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
9115                );
9116            }
9117            DoctorCheck::warn(
9118                "daemon_pid_consistency",
9119                format!(
9120                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
9121                     Daemon was started by a pre-0.5.11 binary."
9122                ),
9123                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
9124            )
9125        }
9126        crate::ensure_up::PidRecord::Json(d) => {
9127            // v0.5.19 liveness gate: if the recorded pid is dead, the
9128            // pidfile is stale and the rest of the content drift checks
9129            // are moot — `wire upgrade` is the answer regardless.
9130            if !snap.pidfile_alive {
9131                return DoctorCheck::warn(
9132                    "daemon_pid_consistency",
9133                    format!(
9134                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
9135                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
9136                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
9137                        pid = d.pid,
9138                        version = d.version,
9139                    ),
9140                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
9141                     (kills any orphan daemon advancing the cursor without coordination)",
9142                );
9143            }
9144            let mut issues: Vec<String> = Vec::new();
9145            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
9146                issues.push(format!(
9147                    "schema={} (expected {})",
9148                    d.schema,
9149                    crate::ensure_up::DAEMON_PID_SCHEMA
9150                ));
9151            }
9152            let cli_version = env!("CARGO_PKG_VERSION");
9153            if d.version != cli_version {
9154                issues.push(format!("version daemon={} cli={cli_version}", d.version));
9155            }
9156            if !std::path::Path::new(&d.bin_path).exists() {
9157                issues.push(format!("bin_path {} missing on disk", d.bin_path));
9158            }
9159            // Cross-check DID + relay against current config (best-effort).
9160            if let Ok(card) = config::read_agent_card()
9161                && let Some(current_did) = card.get("did").and_then(Value::as_str)
9162                && let Some(recorded_did) = &d.did
9163                && recorded_did != current_did
9164            {
9165                issues.push(format!(
9166                    "did daemon={recorded_did} config={current_did} — identity drift"
9167                ));
9168            }
9169            if let Ok(state) = config::read_relay_state()
9170                && let Some(current_relay) = state
9171                    .get("self")
9172                    .and_then(|s| s.get("relay_url"))
9173                    .and_then(Value::as_str)
9174                && let Some(recorded_relay) = &d.relay_url
9175                && recorded_relay != current_relay
9176            {
9177                issues.push(format!(
9178                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
9179                ));
9180            }
9181            if issues.is_empty() {
9182                DoctorCheck::pass(
9183                    "daemon_pid_consistency",
9184                    format!(
9185                        "daemon v{} bound to {} as {}",
9186                        d.version,
9187                        d.relay_url.as_deref().unwrap_or("?"),
9188                        d.did.as_deref().unwrap_or("?")
9189                    ),
9190                )
9191            } else {
9192                DoctorCheck::warn(
9193                    "daemon_pid_consistency",
9194                    format!("daemon pidfile drift: {}", issues.join("; ")),
9195                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
9196                )
9197            }
9198        }
9199    }
9200}
9201
9202/// Check: bound relay's /healthz returns 200.
9203fn check_relay_reachable() -> DoctorCheck {
9204    let state = match config::read_relay_state() {
9205        Ok(s) => s,
9206        Err(e) => {
9207            return DoctorCheck::fail(
9208                "relay",
9209                format!("could not read relay state: {e}"),
9210                "run `wire up <handle>@<relay>` to bootstrap",
9211            );
9212        }
9213    };
9214    let url = state
9215        .get("self")
9216        .and_then(|s| s.get("relay_url"))
9217        .and_then(Value::as_str)
9218        .unwrap_or("");
9219    if url.is_empty() {
9220        return DoctorCheck::warn(
9221            "relay",
9222            "no relay bound — wire send/pull will not work",
9223            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
9224        );
9225    }
9226    let client = crate::relay_client::RelayClient::new(url);
9227    match client.check_healthz() {
9228        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
9229        Err(e) => DoctorCheck::fail(
9230            "relay",
9231            format!("{url} unreachable: {e}"),
9232            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
9233        ),
9234    }
9235}
9236
9237/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
9238/// entry there is a silent failure that, pre-0.5.11, would have left the
9239/// operator wondering why pairing didn't complete.
9240fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
9241    let path = match config::state_dir() {
9242        Ok(d) => d.join("pair-rejected.jsonl"),
9243        Err(e) => {
9244            return DoctorCheck::warn(
9245                "pair_rejections",
9246                format!("could not resolve state dir: {e}"),
9247                "set WIRE_HOME or fix XDG_STATE_HOME",
9248            );
9249        }
9250    };
9251    if !path.exists() {
9252        return DoctorCheck::pass(
9253            "pair_rejections",
9254            "no pair-rejected.jsonl — no recorded pair failures",
9255        );
9256    }
9257    let body = match std::fs::read_to_string(&path) {
9258        Ok(b) => b,
9259        Err(e) => {
9260            return DoctorCheck::warn(
9261                "pair_rejections",
9262                format!("could not read {path:?}: {e}"),
9263                "check file permissions",
9264            );
9265        }
9266    };
9267    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
9268    if lines.is_empty() {
9269        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
9270    }
9271    let total = lines.len();
9272    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
9273    let mut summary: Vec<String> = Vec::new();
9274    for line in &recent {
9275        if let Ok(rec) = serde_json::from_str::<Value>(line) {
9276            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
9277            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
9278            summary.push(format!("{peer}/{code}"));
9279        }
9280    }
9281    DoctorCheck::warn(
9282        "pair_rejections",
9283        format!(
9284            "{total} pair failures recorded. recent: [{}]",
9285            summary.join(", ")
9286        ),
9287        format!(
9288            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
9289        ),
9290    )
9291}
9292
9293/// Check: cursor isn't stuck. We can't tell without polling — but we can
9294/// report the current cursor position so operators see if it changes.
9295/// Real "stuck" detection needs two pulls separated in time; defer that
9296/// behaviour to a `wire doctor --watch` mode.
9297fn check_cursor_progress() -> DoctorCheck {
9298    let state = match config::read_relay_state() {
9299        Ok(s) => s,
9300        Err(e) => {
9301            return DoctorCheck::warn(
9302                "cursor",
9303                format!("could not read relay state: {e}"),
9304                "check ~/Library/Application Support/wire/relay.json",
9305            );
9306        }
9307    };
9308    let cursor = state
9309        .get("self")
9310        .and_then(|s| s.get("last_pulled_event_id"))
9311        .and_then(Value::as_str)
9312        .map(|s| s.chars().take(16).collect::<String>())
9313        .unwrap_or_else(|| "<none>".to_string());
9314    DoctorCheck::pass(
9315        "cursor",
9316        format!(
9317            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
9318        ),
9319    )
9320}
9321
9322#[cfg(test)]
9323mod doctor_tests {
9324    use super::*;
9325
9326    #[test]
9327    fn doctor_check_constructors_set_status_correctly() {
9328        // Silent-fail-prevention rule: pass/warn/fail must be visibly
9329        // distinguishable to operators. If any constructor lets the wrong
9330        // status through, `wire doctor` lies and we're back to today's
9331        // 30-minute debug.
9332        let p = DoctorCheck::pass("x", "ok");
9333        assert_eq!(p.status, "PASS");
9334        assert_eq!(p.fix, None);
9335
9336        let w = DoctorCheck::warn("x", "watch out", "do this");
9337        assert_eq!(w.status, "WARN");
9338        assert_eq!(w.fix, Some("do this".to_string()));
9339
9340        let f = DoctorCheck::fail("x", "broken", "fix it");
9341        assert_eq!(f.status, "FAIL");
9342        assert_eq!(f.fix, Some("fix it".to_string()));
9343    }
9344
9345    #[test]
9346    fn check_pair_rejections_no_file_is_pass() {
9347        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
9348        // as a problem.
9349        config::test_support::with_temp_home(|| {
9350            config::ensure_dirs().unwrap();
9351            let c = check_pair_rejections(5);
9352            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
9353        });
9354    }
9355
9356    #[test]
9357    fn check_pair_rejections_with_entries_warns() {
9358        // Existence of rejections is itself a signal — even if each entry
9359        // is a "known good failure," the operator wants to know they
9360        // happened.
9361        config::test_support::with_temp_home(|| {
9362            config::ensure_dirs().unwrap();
9363            crate::pair_invite::record_pair_rejection(
9364                "willard",
9365                "pair_drop_ack_send_failed",
9366                "POST 502",
9367            );
9368            let c = check_pair_rejections(5);
9369            assert_eq!(c.status, "WARN");
9370            assert!(c.detail.contains("1 pair failures"));
9371            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
9372        });
9373    }
9374}
9375
9376// ---------- up megacommand (full bootstrap) ----------
9377
9378/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
9379/// pair. Composes the steps that today's onboarding walks operators through
9380/// one by one (init / bind-relay / claim / background daemon / arm monitor
9381/// recipe). Idempotent: every step checks current state and skips if done.
9382///
9383/// Argument parsing accepts:
9384///   - `<nick>@<relay-host>` — explicit relay
9385///   - `<nick>`              — defaults to wireup.net (the configured
9386///     public relay)
9387fn cmd_up(handle_arg: &str, name: Option<&str>, as_json: bool) -> Result<()> {
9388    let (nick, relay_url) = match handle_arg.split_once('@') {
9389        Some((n, host)) => {
9390            let url = if host.starts_with("http://") || host.starts_with("https://") {
9391                host.to_string()
9392            } else {
9393                format!("https://{host}")
9394            };
9395            (n.to_string(), url)
9396        }
9397        None => (
9398            handle_arg.to_string(),
9399            crate::pair_invite::DEFAULT_RELAY.to_string(),
9400        ),
9401    };
9402
9403    let mut report: Vec<(String, String)> = Vec::new();
9404    let mut step = |stage: &str, detail: String| {
9405        report.push((stage.to_string(), detail.clone()));
9406        if !as_json {
9407            eprintln!("wire up: {stage} — {detail}");
9408        }
9409    };
9410
9411    // 1. init (or verify existing identity matches the requested nick).
9412    if config::is_initialized()? {
9413        let card = config::read_agent_card()?;
9414        let existing_did = card.get("did").and_then(Value::as_str).unwrap_or("");
9415        let existing_handle = crate::agent_card::display_handle_from_did(existing_did).to_string();
9416        if existing_handle != nick {
9417            bail!(
9418                "wire up: already initialized as {existing_handle:?} but you asked for {nick:?}. \
9419                 Either run with the existing handle (`wire up {existing_handle}@<relay>`) or \
9420                 delete `{:?}` to start fresh.",
9421                config::config_dir()?
9422            );
9423        }
9424        step("init", format!("already initialized as {existing_handle}"));
9425    } else {
9426        cmd_init(&nick, name, Some(&relay_url), /* as_json */ false)?;
9427        step(
9428            "init",
9429            format!("created identity {nick} bound to {relay_url}"),
9430        );
9431    }
9432
9433    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
9434    // already initialized we may need to bind to the requested relay
9435    // separately (operator switched relays).
9436    let relay_state = config::read_relay_state()?;
9437    let bound_relay = relay_state
9438        .get("self")
9439        .and_then(|s| s.get("relay_url"))
9440        .and_then(Value::as_str)
9441        .unwrap_or("")
9442        .to_string();
9443    if bound_relay.is_empty() {
9444        // Identity exists but never bound to a relay — bind now.
9445        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
9446        // Pass `false` so the safety check kicks in if state was non-empty.
9447        cmd_bind_relay(
9448            &relay_url, /* migrate_pinned */ false, /* as_json */ false,
9449        )?;
9450        step("bind-relay", format!("bound to {relay_url}"));
9451    } else if bound_relay != relay_url {
9452        step(
9453            "bind-relay",
9454            format!(
9455                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
9456                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
9457            ),
9458        );
9459    } else {
9460        step("bind-relay", format!("already bound to {bound_relay}"));
9461    }
9462
9463    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
9464    // re-claims are accepted by the relay.
9465    match cmd_claim(
9466        &nick,
9467        Some(&relay_url),
9468        None,
9469        /* hidden */ false,
9470        /* as_json */ false,
9471    ) {
9472        Ok(()) => step(
9473            "claim",
9474            format!("{nick}@{} claimed", strip_proto(&relay_url)),
9475        ),
9476        Err(e) => step(
9477            "claim",
9478            format!("WARNING: claim failed: {e}. You can retry `wire claim {nick}`."),
9479        ),
9480    }
9481
9482    // 4. Background daemon — must be running for pull/push/ack to flow.
9483    match crate::ensure_up::ensure_daemon_running() {
9484        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
9485        Ok(false) => step("daemon", "already running".to_string()),
9486        Err(e) => step(
9487            "daemon",
9488            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
9489        ),
9490    }
9491
9492    // 5. Final summary — point operator at the next commands.
9493    let summary =
9494        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
9495         `wire monitor` to watch incoming events."
9496            .to_string();
9497    step("ready", summary.clone());
9498
9499    if as_json {
9500        let steps_json: Vec<_> = report
9501            .iter()
9502            .map(|(k, v)| json!({"stage": k, "detail": v}))
9503            .collect();
9504        println!(
9505            "{}",
9506            serde_json::to_string(&json!({
9507                "nick": nick,
9508                "relay": relay_url,
9509                "steps": steps_json,
9510            }))?
9511        );
9512    }
9513    Ok(())
9514}
9515
9516/// Strip http:// or https:// prefix for display in `wire up` step output.
9517fn strip_proto(url: &str) -> String {
9518    url.trim_start_matches("https://")
9519        .trim_start_matches("http://")
9520        .to_string()
9521}
9522
9523// ---------- pair megacommand (zero-paste handle-based) ----------
9524
9525/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
9526/// the handle is in `nick@domain` form. Wraps:
9527///
9528///   1. cmd_add — resolve, pin, drop intro
9529///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
9530///      (signalled by `peers.<handle>.slot_token` populating in relay state)
9531///   3. Verify bilateral pin: trust contains peer + relay state has token
9532///   4. Print final state — both sides VERIFIED + can `wire send`
9533///
9534/// On timeout: hard-errors with the specific stuck step so the operator
9535/// knows which side to chase. No silent partial success.
9536fn cmd_pair_megacommand(
9537    handle_arg: &str,
9538    relay_override: Option<&str>,
9539    timeout_secs: u64,
9540    _as_json: bool,
9541) -> Result<()> {
9542    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
9543    let peer_handle = parsed.nick.clone();
9544
9545    eprintln!("wire pair: resolving {handle_arg}...");
9546    cmd_add(
9547        handle_arg,
9548        relay_override,
9549        /* local_sister */ false,
9550        /* as_json */ false,
9551    )?;
9552
9553    eprintln!(
9554        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
9555         to ack (their daemon must be running + pulling)..."
9556    );
9557
9558    // Trigger an immediate daemon-style pull so we don't wait the full daemon
9559    // interval. Best-effort — if it fails, we still fall through to the
9560    // polling loop.
9561    let _ = run_sync_pull();
9562
9563    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
9564    let poll_interval = std::time::Duration::from_millis(500);
9565
9566    loop {
9567        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
9568        let _ = run_sync_pull();
9569        let relay_state = config::read_relay_state()?;
9570        let peer_entry = relay_state
9571            .get("peers")
9572            .and_then(|p| p.get(&peer_handle))
9573            .cloned();
9574        let token = peer_entry
9575            .as_ref()
9576            .and_then(|e| e.get("slot_token"))
9577            .and_then(Value::as_str)
9578            .unwrap_or("");
9579
9580        if !token.is_empty() {
9581            // Bilateral pin complete — we have their slot_token, we can send.
9582            let trust = config::read_trust()?;
9583            let pinned_in_trust = trust
9584                .get("agents")
9585                .and_then(|a| a.get(&peer_handle))
9586                .is_some();
9587            println!(
9588                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
9589                if pinned_in_trust {
9590                    "VERIFIED"
9591                } else {
9592                    "MISSING (bug)"
9593                }
9594            );
9595            return Ok(());
9596        }
9597
9598        if std::time::Instant::now() >= deadline {
9599            // Timeout — surface the EXACT stuck step. Likely culprits:
9600            //   - peer daemon not running on their box
9601            //   - peer's relay slot is offline
9602            //   - their daemon is on an older binary that doesn't know
9603            //     pair_drop kind=1100 (the P0.1 class — now visible via
9604            //     wire pull --json on their side as a blocking rejection)
9605            bail!(
9606                "wire pair: timed out after {timeout_secs}s. \
9607                 peer {peer_handle} never sent pair_drop_ack. \
9608                 likely causes: (a) their daemon is down — ask them to run \
9609                 `wire status` and `wire daemon &`; (b) their binary is older \
9610                 than 0.5.x and doesn't understand pair_drop events — ask \
9611                 them to `wire upgrade`; (c) network / relay blip — re-run \
9612                 `wire pair {handle_arg}` to retry."
9613            );
9614        }
9615
9616        std::thread::sleep(poll_interval);
9617    }
9618}
9619
9620fn cmd_claim(
9621    nick: &str,
9622    relay_override: Option<&str>,
9623    public_url: Option<&str>,
9624    hidden: bool,
9625    as_json: bool,
9626) -> Result<()> {
9627    if !crate::pair_profile::is_valid_nick(nick) {
9628        bail!(
9629            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
9630        );
9631    }
9632    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
9633    // + claim handle. Operator should never have to run init/bind-relay first.
9634    let (_did, relay_url, slot_id, slot_token) =
9635        crate::pair_invite::ensure_self_with_relay(relay_override)?;
9636    let card = config::read_agent_card()?;
9637
9638    let client = crate::relay_client::RelayClient::new(&relay_url);
9639    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
9640    // (back-compat); Some(false) for `--hidden`. Relays older than
9641    // v0.5.19 ignore the field, so this is safe to always send.
9642    let discoverable = if hidden { Some(false) } else { None };
9643    let resp =
9644        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
9645
9646    if as_json {
9647        println!(
9648            "{}",
9649            serde_json::to_string(&json!({
9650                "nick": nick,
9651                "relay": relay_url,
9652                "response": resp,
9653            }))?
9654        );
9655    } else {
9656        // Best-effort: derive the public domain from the relay URL. If
9657        // operator passed --public-url that's the canonical address; else
9658        // the relay URL itself. Falls back to a placeholder if both miss.
9659        let domain = public_url
9660            .unwrap_or(&relay_url)
9661            .trim_start_matches("https://")
9662            .trim_start_matches("http://")
9663            .trim_end_matches('/')
9664            .split('/')
9665            .next()
9666            .unwrap_or("<this-relay-domain>")
9667            .to_string();
9668        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
9669        println!("verify with: wire whois {nick}@{domain}");
9670    }
9671    Ok(())
9672}
9673
9674fn cmd_profile(action: ProfileAction) -> Result<()> {
9675    match action {
9676        ProfileAction::Set { field, value, json } => {
9677            // Try parsing the value as JSON; if that fails, treat it as a
9678            // bare string. Lets operators pass either `42` or `"hello"` or
9679            // `["rust","late-night"]` without quoting hell.
9680            let parsed: Value =
9681                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
9682            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
9683            if json {
9684                println!(
9685                    "{}",
9686                    serde_json::to_string(&json!({
9687                        "field": field,
9688                        "profile": new_profile,
9689                    }))?
9690                );
9691            } else {
9692                println!("profile.{field} set");
9693            }
9694        }
9695        ProfileAction::Get { json } => return cmd_whois(None, json, None),
9696        ProfileAction::Clear { field, json } => {
9697            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
9698            if json {
9699                println!(
9700                    "{}",
9701                    serde_json::to_string(&json!({
9702                        "field": field,
9703                        "cleared": true,
9704                        "profile": new_profile,
9705                    }))?
9706                );
9707            } else {
9708                println!("profile.{field} cleared");
9709            }
9710        }
9711    }
9712    Ok(())
9713}
9714
9715// ---------- setup — one-shot MCP host registration ----------
9716
9717fn cmd_setup(apply: bool) -> Result<()> {
9718    use std::path::PathBuf;
9719
9720    let entry = json!({"command": "wire", "args": ["mcp"]});
9721    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
9722
9723    // Detect probable MCP host config locations. Cross-platform — we only
9724    // touch the file if it already exists OR --apply was passed.
9725    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
9726    if let Some(home) = dirs::home_dir() {
9727        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
9728        // The mcpServers map lives at the top level of that file.
9729        targets.push(("Claude Code", home.join(".claude.json")));
9730        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
9731        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
9732        // Claude Desktop macOS
9733        #[cfg(target_os = "macos")]
9734        targets.push((
9735            "Claude Desktop (macOS)",
9736            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
9737        ));
9738        // Claude Desktop Windows
9739        #[cfg(target_os = "windows")]
9740        if let Ok(appdata) = std::env::var("APPDATA") {
9741            targets.push((
9742                "Claude Desktop (Windows)",
9743                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
9744            ));
9745        }
9746        // Cursor
9747        targets.push(("Cursor", home.join(".cursor/mcp.json")));
9748    }
9749    // Project-local — works for several MCP-aware tools
9750    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
9751
9752    println!("wire setup\n");
9753    println!("MCP server snippet (add this to your client's mcpServers):");
9754    println!();
9755    println!("{entry_pretty}");
9756    println!();
9757
9758    if !apply {
9759        println!("Probable MCP host config locations on this machine:");
9760        for (name, path) in &targets {
9761            let marker = if path.exists() {
9762                "✓ found"
9763            } else {
9764                "  (would create)"
9765            };
9766            println!("  {marker:14}  {name}: {}", path.display());
9767        }
9768        println!();
9769        println!("Run `wire setup --apply` to merge wire into each config above.");
9770        println!(
9771            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
9772        );
9773        return Ok(());
9774    }
9775
9776    let mut modified: Vec<String> = Vec::new();
9777    let mut skipped: Vec<String> = Vec::new();
9778    for (name, path) in &targets {
9779        match upsert_mcp_entry(path, "wire", &entry) {
9780            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
9781            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
9782            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
9783        }
9784    }
9785    if !modified.is_empty() {
9786        println!("Modified:");
9787        for line in &modified {
9788            println!("  {line}");
9789        }
9790        println!();
9791        println!("Restart the app(s) above to load wire MCP.");
9792    }
9793    if !skipped.is_empty() {
9794        println!();
9795        println!("Skipped:");
9796        for line in &skipped {
9797            println!("  {line}");
9798        }
9799    }
9800    Ok(())
9801}
9802
9803/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
9804/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
9805fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
9806    let mut cfg: Value = if path.exists() {
9807        let body = std::fs::read_to_string(path).context("reading config")?;
9808        serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
9809    } else {
9810        json!({})
9811    };
9812    if !cfg.is_object() {
9813        cfg = json!({});
9814    }
9815    let root = cfg.as_object_mut().unwrap();
9816    let servers = root
9817        .entry("mcpServers".to_string())
9818        .or_insert_with(|| json!({}));
9819    if !servers.is_object() {
9820        *servers = json!({});
9821    }
9822    let map = servers.as_object_mut().unwrap();
9823    if map.get(server_name) == Some(entry) {
9824        return Ok(false);
9825    }
9826    map.insert(server_name.to_string(), entry.clone());
9827    if let Some(parent) = path.parent()
9828        && !parent.as_os_str().is_empty()
9829    {
9830        std::fs::create_dir_all(parent).context("creating parent dir")?;
9831    }
9832    let out = serde_json::to_string_pretty(&cfg)? + "\n";
9833    std::fs::write(path, out).context("writing config")?;
9834    Ok(true)
9835}
9836
9837// ---------- reactor — event-handler dispatch loop ----------
9838
9839#[allow(clippy::too_many_arguments)]
9840fn cmd_reactor(
9841    on_event: &str,
9842    peer_filter: Option<&str>,
9843    kind_filter: Option<&str>,
9844    verified_only: bool,
9845    interval_secs: u64,
9846    once: bool,
9847    dry_run: bool,
9848    max_per_minute: u32,
9849    max_chain_depth: u32,
9850) -> Result<()> {
9851    use crate::inbox_watch::{InboxEvent, InboxWatcher};
9852    use std::collections::{HashMap, HashSet, VecDeque};
9853    use std::io::Write;
9854    use std::process::{Command, Stdio};
9855    use std::time::{Duration, Instant};
9856
9857    let cursor_path = config::state_dir()?.join("reactor.cursor");
9858    // event_ids THIS reactor's handler has caused to be sent (via wire send).
9859    // Used by chain-depth check — an incoming `(re:X)` where X is in this set
9860    // means peer is replying to something we just said → don't reply back.
9861    //
9862    // Persisted across restarts so a reactor that crashes mid-conversation
9863    // doesn't re-enter the loop. Reads on startup, writes after each
9864    // outbox-grow detection. Capped at 500 entries (LRU-ish — old entries
9865    // dropped from front of file).
9866    let emitted_path = config::state_dir()?.join("reactor-emitted.log");
9867    let mut emitted_ids: HashSet<String> = HashSet::new();
9868    if emitted_path.exists()
9869        && let Ok(body) = std::fs::read_to_string(&emitted_path)
9870    {
9871        for line in body.lines() {
9872            let t = line.trim();
9873            if !t.is_empty() {
9874                emitted_ids.insert(t.to_string());
9875            }
9876        }
9877    }
9878    // Outbox file paths the reactor watches for new sent-event_ids.
9879    let outbox_dir = config::outbox_dir()?;
9880    // (peer → file size we've already scanned). Lets us notice new outbox
9881    // appends without re-reading the whole file each sweep.
9882    let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
9883
9884    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
9885
9886    let kind_num: Option<u32> = match kind_filter {
9887        Some(k) => Some(parse_kind(k)?),
9888        None => None,
9889    };
9890
9891    // Per-peer sliding window of dispatch instants for rate-limit check.
9892    let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
9893
9894    let dispatch = |ev: &InboxEvent,
9895                    peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
9896                    emitted_ids: &HashSet<String>|
9897     -> Result<bool> {
9898        if let Some(p) = peer_filter
9899            && ev.peer != p
9900        {
9901            return Ok(false);
9902        }
9903        if verified_only && !ev.verified {
9904            return Ok(false);
9905        }
9906        if let Some(want) = kind_num {
9907            let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
9908            if ev_kind != Some(want) {
9909                return Ok(false);
9910            }
9911        }
9912
9913        // Chain-depth check: if the body contains `(re:<event_id>)` and that
9914        // event_id is in our emitted set, this is a reply to one of our
9915        // replies → loop suspected, skip.
9916        if max_chain_depth > 0 {
9917            let body_str = match &ev.raw["body"] {
9918                Value::String(s) => s.clone(),
9919                other => serde_json::to_string(other).unwrap_or_default(),
9920            };
9921            if let Some(referenced) = parse_re_marker(&body_str) {
9922                // Handler scripts usually truncate event_id (e.g. ${ID:0:12}).
9923                // Match emitted set by prefix to catch both full + truncated.
9924                let matched = emitted_ids.contains(&referenced)
9925                    || emitted_ids.iter().any(|full| full.starts_with(&referenced));
9926                if matched {
9927                    eprintln!(
9928                        "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
9929                        ev.event_id, ev.peer, referenced
9930                    );
9931                    return Ok(false);
9932                }
9933            }
9934        }
9935
9936        // Per-peer rate-limit check (sliding 60s window).
9937        if max_per_minute > 0 {
9938            let now = Instant::now();
9939            let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
9940            while let Some(&front) = win.front() {
9941                if now.duration_since(front) > Duration::from_secs(60) {
9942                    win.pop_front();
9943                } else {
9944                    break;
9945                }
9946            }
9947            if win.len() as u32 >= max_per_minute {
9948                eprintln!(
9949                    "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
9950                    ev.event_id, ev.peer, max_per_minute
9951                );
9952                return Ok(false);
9953            }
9954            win.push_back(now);
9955        }
9956
9957        if dry_run {
9958            println!("{}", serde_json::to_string(&ev.raw)?);
9959            return Ok(true);
9960        }
9961
9962        let mut child = Command::new("sh")
9963            .arg("-c")
9964            .arg(on_event)
9965            .stdin(Stdio::piped())
9966            .stdout(Stdio::inherit())
9967            .stderr(Stdio::inherit())
9968            .env("WIRE_EVENT_PEER", &ev.peer)
9969            .env("WIRE_EVENT_ID", &ev.event_id)
9970            .env("WIRE_EVENT_KIND", &ev.kind)
9971            .spawn()
9972            .with_context(|| format!("spawning reactor handler: {on_event}"))?;
9973        if let Some(mut stdin) = child.stdin.take() {
9974            let body = serde_json::to_vec(&ev.raw)?;
9975            let _ = stdin.write_all(&body);
9976            let _ = stdin.write_all(b"\n");
9977        }
9978        std::mem::drop(child);
9979        Ok(true)
9980    };
9981
9982    // Scan outbox files for newly-appended event_ids and add to emitted set.
9983    let scan_outbox = |emitted_ids: &mut HashSet<String>,
9984                       outbox_cursors: &mut HashMap<String, u64>|
9985     -> Result<usize> {
9986        if !outbox_dir.exists() {
9987            return Ok(0);
9988        }
9989        let mut added = 0;
9990        let mut new_ids: Vec<String> = Vec::new();
9991        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
9992            let path = entry.path();
9993            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
9994                continue;
9995            }
9996            let peer = match path.file_stem().and_then(|s| s.to_str()) {
9997                Some(s) => s.to_string(),
9998                None => continue,
9999            };
10000            let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
10001            let start = *outbox_cursors.get(&peer).unwrap_or(&0);
10002            if cur_len <= start {
10003                outbox_cursors.insert(peer, start);
10004                continue;
10005            }
10006            let body = std::fs::read_to_string(&path).unwrap_or_default();
10007            let tail = &body[start as usize..];
10008            for line in tail.lines() {
10009                if let Ok(v) = serde_json::from_str::<Value>(line)
10010                    && let Some(eid) = v.get("event_id").and_then(Value::as_str)
10011                    && emitted_ids.insert(eid.to_string())
10012                {
10013                    new_ids.push(eid.to_string());
10014                    added += 1;
10015                }
10016            }
10017            outbox_cursors.insert(peer, cur_len);
10018        }
10019        if !new_ids.is_empty() {
10020            // Append new ids to disk, cap on-disk file at 500 entries.
10021            let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
10022            if all.len() > 500 {
10023                all.sort();
10024                let drop_n = all.len() - 500;
10025                let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
10026                emitted_ids.retain(|x| !dropped.contains(x));
10027                all = emitted_ids.iter().cloned().collect();
10028            }
10029            let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
10030        }
10031        Ok(added)
10032    };
10033
10034    let sweep = |watcher: &mut InboxWatcher,
10035                 emitted_ids: &mut HashSet<String>,
10036                 outbox_cursors: &mut HashMap<String, u64>,
10037                 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
10038     -> Result<usize> {
10039        // Pick up any event_ids we sent since last sweep.
10040        let _ = scan_outbox(emitted_ids, outbox_cursors);
10041
10042        let events = watcher.poll()?;
10043        let mut fired = 0usize;
10044        for ev in &events {
10045            match dispatch(ev, peer_dispatch_log, emitted_ids) {
10046                Ok(true) => fired += 1,
10047                Ok(false) => {}
10048                Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
10049            }
10050        }
10051        watcher.save_cursors(&cursor_path)?;
10052        Ok(fired)
10053    };
10054
10055    if once {
10056        sweep(
10057            &mut watcher,
10058            &mut emitted_ids,
10059            &mut outbox_cursors,
10060            &mut peer_dispatch_log,
10061        )?;
10062        return Ok(());
10063    }
10064    let interval = std::time::Duration::from_secs(interval_secs.max(1));
10065    loop {
10066        if let Err(e) = sweep(
10067            &mut watcher,
10068            &mut emitted_ids,
10069            &mut outbox_cursors,
10070            &mut peer_dispatch_log,
10071        ) {
10072            eprintln!("wire reactor: sweep error: {e}");
10073        }
10074        std::thread::sleep(interval);
10075    }
10076}
10077
10078/// Parse `(re:<event_id>)` marker out of an event body. Returns the
10079/// referenced event_id (full or prefix) if present. Tolerates spaces.
10080fn parse_re_marker(body: &str) -> Option<String> {
10081    let needle = "(re:";
10082    let i = body.find(needle)?;
10083    let rest = &body[i + needle.len()..];
10084    let end = rest.find(')')?;
10085    let id = rest[..end].trim().to_string();
10086    if id.is_empty() {
10087        return None;
10088    }
10089    Some(id)
10090}
10091
10092// ---------- notify (Goal 2) ----------
10093
10094fn cmd_notify(
10095    interval_secs: u64,
10096    peer_filter: Option<&str>,
10097    once: bool,
10098    as_json: bool,
10099) -> Result<()> {
10100    use crate::inbox_watch::InboxWatcher;
10101    let cursor_path = config::state_dir()?.join("notify.cursor");
10102    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
10103
10104    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
10105        let events = watcher.poll()?;
10106        for ev in events {
10107            if let Some(p) = peer_filter
10108                && ev.peer != p
10109            {
10110                continue;
10111            }
10112            if as_json {
10113                println!("{}", serde_json::to_string(&ev)?);
10114            } else {
10115                os_notify_inbox_event(&ev);
10116            }
10117        }
10118        watcher.save_cursors(&cursor_path)?;
10119        Ok(())
10120    };
10121
10122    if once {
10123        return sweep(&mut watcher);
10124    }
10125
10126    let interval = std::time::Duration::from_secs(interval_secs.max(1));
10127    loop {
10128        if let Err(e) = sweep(&mut watcher) {
10129            eprintln!("wire notify: sweep error: {e}");
10130        }
10131        std::thread::sleep(interval);
10132    }
10133}
10134
10135fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
10136    let title = if ev.verified {
10137        format!("wire ← {}", ev.peer)
10138    } else {
10139        format!("wire ← {} (UNVERIFIED)", ev.peer)
10140    };
10141    let body = format!("{}: {}", ev.kind, ev.body_preview);
10142    crate::os_notify::toast(&title, &body);
10143}
10144
10145#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
10146fn os_toast(title: &str, body: &str) {
10147    eprintln!("[wire notify] {title}\n  {body}");
10148}
10149
10150// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).