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    },
59    /// List pinned peers with their tiers and capabilities.
60    Peers {
61        #[arg(long)]
62        json: bool,
63    },
64    /// Sign and queue an event to a peer.
65    ///
66    /// Forms (P0.S 0.5.11):
67    ///   wire send <peer> <body>              # kind defaults to "claim"
68    ///   wire send <peer> <kind> <body>       # explicit kind (back-compat)
69    ///   wire send <peer> -                   # body from stdin (kind=claim)
70    ///   wire send <peer> @/path/to/body.json # body from file
71    Send {
72        /// Peer handle (without `did:wire:` prefix).
73        peer: String,
74        /// When `<body>` is omitted, this is the event body (kind defaults
75        /// to `claim`). When both this and `<body>` are given, this is the
76        /// event kind (`decision`, `claim`, etc., or numeric kind id) and
77        /// the next positional is the body.
78        kind_or_body: String,
79        /// Event body — free-form text, `@/path/to/body.json` to load from
80        /// a file, or `-` to read from stdin. Optional; omit to use
81        /// `<kind_or_body>` as the body with kind=`claim`.
82        body: Option<String>,
83        /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
84        #[arg(long)]
85        deadline: Option<String>,
86        /// Emit JSON.
87        #[arg(long)]
88        json: bool,
89    },
90    /// Stream signed events from peers.
91    Tail {
92        /// Optional peer filter; if omitted, tails all peers.
93        peer: Option<String>,
94        /// Emit JSONL (one event per line).
95        #[arg(long)]
96        json: bool,
97        /// Maximum events to read before exiting (0 = stream until SIGINT).
98        #[arg(long, default_value_t = 0)]
99        limit: usize,
100    },
101    /// Live tail of new inbox events across all pinned peers — one line per
102    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
103    /// by default.
104    ///
105    /// Designed to be left running in an agent harness's stream-watcher
106    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
107    /// session as they arrive, not on next manual `wire pull`.
108    ///
109    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
110    /// template.
111    Monitor {
112        /// Only show events from this peer.
113        #[arg(long)]
114        peer: Option<String>,
115        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
116        #[arg(long)]
117        json: bool,
118        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
119        /// Default filters them out as noise.
120        #[arg(long)]
121        include_handshake: bool,
122        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
123        #[arg(long, default_value_t = 500)]
124        interval_ms: u64,
125        /// Replay last N events from history before going live (0 = none).
126        #[arg(long, default_value_t = 0)]
127        replay: usize,
128    },
129    /// Verify a signed event from a JSON file or stdin (`-`).
130    Verify {
131        /// Path to event JSON, or `-` for stdin.
132        path: String,
133        /// Emit JSON.
134        #[arg(long)]
135        json: bool,
136    },
137    /// Run the MCP (Model Context Protocol) server over stdio.
138    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
139    /// `wire_send`, `wire_tail`, etc. as native tools.
140    Mcp,
141    /// Run a relay server on this host.
142    RelayServer {
143        /// Bind address (e.g. `127.0.0.1:8770`).
144        #[arg(long, default_value = "127.0.0.1:8770")]
145        bind: String,
146    },
147    /// Allocate a slot on a relay; bind it to this agent's identity.
148    BindRelay {
149        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
150        url: String,
151        #[arg(long)]
152        json: bool,
153    },
154    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
155    /// real `wire join` lands in the SPAKE2 iter.)
156    AddPeerSlot {
157        /// Peer handle (becomes did:wire:<handle>).
158        handle: String,
159        /// Peer's relay base URL.
160        url: String,
161        /// Peer's slot id.
162        slot_id: String,
163        /// Slot bearer token (shared between paired peers in v0.1).
164        slot_token: String,
165        #[arg(long)]
166        json: bool,
167    },
168    /// Drain outbox JSONL files to peers' relay slots.
169    Push {
170        /// Optional peer filter; default = all peers with outbox entries.
171        peer: Option<String>,
172        #[arg(long)]
173        json: bool,
174    },
175    /// Pull events from our relay slot, verify, write to inbox.
176    Pull {
177        #[arg(long)]
178        json: bool,
179    },
180    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
181    /// Useful as a single "where am I" check.
182    Status {
183        /// Inspect a paired peer's transport / attention / responder health.
184        #[arg(long)]
185        peer: Option<String>,
186        #[arg(long)]
187        json: bool,
188    },
189    /// Publish or inspect auto-responder health for this slot.
190    Responder {
191        #[command(subcommand)]
192        command: ResponderCommand,
193    },
194    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
195    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
196    Pin {
197        /// Path to peer's signed agent-card JSON.
198        card_file: String,
199        #[arg(long)]
200        json: bool,
201    },
202    /// Allocate a NEW slot on the same relay and abandon the old one.
203    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
204    /// slot announcing the new mailbox before swapping. After rotation,
205    /// peers must re-pair (or operator runs `add-peer-slot` with the new
206    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
207    ///
208    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
209    /// abusive bearer-holder spamming your slot). Rotate → old slot is
210    /// orphaned → attacker's leverage gone. Operator pairs again with
211    /// peers they still want.
212    RotateSlot {
213        /// Skip the wire_close announcement to peers (faster but they won't know
214        /// where you went).
215        #[arg(long)]
216        no_announce: bool,
217        #[arg(long)]
218        json: bool,
219    },
220    /// Remove a peer from trust + relay state. Inbox/outbox files for that
221    /// peer are NOT deleted (operator can grep history); pass --purge to
222    /// also wipe the JSONL files.
223    ForgetPeer {
224        /// Peer handle to forget.
225        handle: String,
226        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
227        #[arg(long)]
228        purge: bool,
229        #[arg(long)]
230        json: bool,
231    },
232    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
233    /// peers' relay slots and pull inbox from our own slot. Foreground process;
234    /// background it with systemd / `&` / tmux as you prefer.
235    Daemon {
236        /// Sync interval in seconds. Default 5.
237        #[arg(long, default_value_t = 5)]
238        interval: u64,
239        /// Run a single sync cycle and exit (useful for cron-driven setups).
240        #[arg(long)]
241        once: bool,
242        #[arg(long)]
243        json: bool,
244    },
245    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
246    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
247    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
248    /// read the SAS digits aloud and confirm.)
249    PairHost {
250        /// Relay base URL.
251        #[arg(long)]
252        relay: String,
253        /// Skip the SAS confirmation prompt. ONLY use when piping under
254        /// automated tests or when the SAS has already been verified by
255        /// another channel. Documented as test-only.
256        #[arg(long)]
257        yes: bool,
258        /// How long (seconds) to wait for the peer to join before timing out.
259        #[arg(long, default_value_t = 300)]
260        timeout: u64,
261        /// Detach: write a pending-pair file, print the code phrase, and exit
262        /// immediately. The running `wire daemon` does the handshake in the
263        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
264        /// `wire pair-list` shows pending sessions. Default is foreground
265        /// blocking behavior for backward compat.
266        #[arg(long)]
267        detach: bool,
268        /// Emit JSON instead of text. Currently only meaningful with --detach.
269        #[arg(long)]
270        json: bool,
271    },
272    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
273    ///
274    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
275    #[command(alias = "join")]
276    PairJoin {
277        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
278        code_phrase: String,
279        /// Relay base URL (must match the host's relay).
280        #[arg(long)]
281        relay: String,
282        #[arg(long)]
283        yes: bool,
284        #[arg(long, default_value_t = 300)]
285        timeout: u64,
286        /// Detach: see `pair-host --detach`.
287        #[arg(long)]
288        detach: bool,
289        /// Emit JSON instead of text. Currently only meaningful with --detach.
290        #[arg(long)]
291        json: bool,
292    },
293    /// Confirm SAS digits for a detached pending pair. The daemon must be
294    /// running for this to do anything — it picks up the confirmation on its
295    /// next tick. Mismatch aborts the pair.
296    PairConfirm {
297        /// The code phrase the original `wire pair-host --detach` printed.
298        code_phrase: String,
299        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
300        digits: String,
301        /// Emit JSON instead of human-readable text.
302        #[arg(long)]
303        json: bool,
304    },
305    /// List all pending detached pair sessions and their state.
306    PairList {
307        /// Emit JSON instead of the table.
308        #[arg(long)]
309        json: bool,
310        /// Stream mode: never exit; print one JSON line per status transition
311        /// (creation, status change, deletion) across all pending pairs.
312        /// Compose with bash `while read` to react in shell. Implies --json.
313        #[arg(long)]
314        watch: bool,
315        /// Poll interval in seconds for --watch.
316        #[arg(long, default_value_t = 1)]
317        watch_interval: u64,
318    },
319    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
320    PairCancel {
321        code_phrase: String,
322        #[arg(long)]
323        json: bool,
324    },
325    /// Block until a pending pair reaches a target status (default sas_ready),
326    /// or terminates (finalized = file removed, aborted, aborted_restart), or
327    /// the timeout expires. Useful for shell scripts that want to drive the
328    /// detached flow without polling pair-list themselves.
329    ///
330    /// Exit codes:
331    ///   0 — reached target status (or finalized, if target was sas_ready)
332    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
333    ///   2 — timeout
334    PairWatch {
335        code_phrase: String,
336        /// Target status to wait for. Default: sas_ready.
337        #[arg(long, default_value = "sas_ready")]
338        status: String,
339        /// Max seconds to wait.
340        #[arg(long, default_value_t = 300)]
341        timeout: u64,
342        /// Emit JSON on each status change (one per line) instead of just on exit.
343        #[arg(long)]
344        json: bool,
345    },
346    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
347    /// pair-join, then registers wire as an MCP server. Single command from
348    /// nothing to paired and ready — no separate init/pair-host/setup steps.
349    /// Operator still must confirm SAS digits.
350    ///
351    /// Examples:
352    ///   wire pair paul                          # host a new pair on default relay
353    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
354    Pair {
355        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
356        /// step if no identity exists; ignored if already initialized.
357        handle: String,
358        /// Code phrase from peer's pair-host output. Omit to be the host
359        /// (this command will print one for you to share).
360        #[arg(long)]
361        code: Option<String>,
362        /// Relay base URL. Defaults to the laulpogan public-good relay.
363        #[arg(long, default_value = "https://wireup.net")]
364        relay: String,
365        /// Skip SAS prompt. Test-only.
366        #[arg(long)]
367        yes: bool,
368        /// Pair-step timeout in seconds.
369        #[arg(long, default_value_t = 300)]
370        timeout: u64,
371        /// Skip the post-pair `setup --apply` step (don't register wire as
372        /// an MCP server in detected client configs).
373        #[arg(long)]
374        no_setup: bool,
375        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
376        /// exits immediately, daemon does the handshake). Confirm via
377        /// `wire pair-confirm <code> <digits>` from any terminal. See
378        /// `pair-host --detach` for details.
379        #[arg(long)]
380        detach: bool,
381    },
382    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
383    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
384    /// confirmation, leaving the relay-side slot stuck with "guest already
385    /// registered" or "host already registered" until the 5-minute TTL expires.
386    /// Either side can call. Idempotent.
387    PairAbandon {
388        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
389        code_phrase: String,
390        /// Relay base URL.
391        #[arg(long, default_value = "https://wireup.net")]
392        relay: String,
393    },
394    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
395    /// you@<your-relay>` against your handle, their signed pair_drop lands
396    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
397    /// <peer>` to delete the record without pairing. The peer never receives
398    /// our slot_token; from their side the pair stays pending until they
399    /// time out.
400    PairReject {
401        /// Bare peer handle (without `@<relay>`).
402        peer: String,
403        /// Emit JSON.
404        #[arg(long)]
405        json: bool,
406    },
407    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
408    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
409    /// `pair-list --json` shape but for inbound). Use this in scripts that
410    /// need to enumerate inbound pair requests without parsing the SPAKE2
411    /// table format from `wire pair-list`.
412    PairListInbound {
413        /// Emit JSON.
414        #[arg(long)]
415        json: bool,
416    },
417    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
418    /// Cursor, project-local) and either print or auto-merge the wire MCP
419    /// server entry. Default prints; pass `--apply` to actually modify config
420    /// files. Idempotent — re-running is safe.
421    Setup {
422        /// Actually write the changes (default = print only).
423        #[arg(long)]
424        apply: bool,
425    },
426    /// Show an agent's profile. With no arg, prints local self. With a
427    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
428    /// endpoint and verifies the returned signed card before display.
429    Whois {
430        /// Optional handle (`nick@domain`). Omit to show self.
431        handle: Option<String>,
432        #[arg(long)]
433        json: bool,
434        /// Override the relay base URL used for resolution (default:
435        /// `https://<domain>` from the handle).
436        #[arg(long)]
437        relay: Option<String>,
438    },
439    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
440    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
441    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
442    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
443    /// their slot_token so we can `wire send` to them).
444    Add {
445        /// Peer handle (`nick@domain`).
446        handle: String,
447        /// Override the relay base URL used for resolution.
448        #[arg(long)]
449        relay: Option<String>,
450        #[arg(long)]
451        json: bool,
452    },
453    /// One-shot full bootstrap — `wire up <nick@relay-host>` does in one
454    /// command what 0.5.10 took five (init + bind-relay + claim + daemon-
455    /// background + remember-to-restart-on-login). Idempotent: re-run on
456    /// an already-set-up box prints state without churn.
457    ///
458    /// Examples:
459    ///   wire up paul@wireup.net           # full bootstrap
460    ///   wire up paul-mac@wireup.net       # ditto, nick = paul-mac
461    ///   wire up paul                      # bootstrap, default relay
462    Up {
463        /// Full handle in `nick@relay-host` form, or just `nick` (defaults
464        /// to the configured public relay wireup.net).
465        handle: String,
466        /// Optional display name (defaults to capitalized nick).
467        #[arg(long)]
468        name: Option<String>,
469        #[arg(long)]
470        json: bool,
471    },
472    /// Diagnose wire setup health. Single command that surfaces every
473    /// silent-fail class — daemon down or duplicated, relay unreachable,
474    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
475    /// Replaces today's 30-minute manual debug.
476    ///
477    /// Exit code non-zero if any FAIL findings.
478    Doctor {
479        /// Emit JSON.
480        #[arg(long)]
481        json: bool,
482        /// Show last N entries from pair-rejected.jsonl in the report.
483        #[arg(long, default_value_t = 5)]
484        recent_rejections: usize,
485    },
486    /// Atomic upgrade: kill every `wire daemon` process, spawn a fresh
487    /// one from the current binary, write a new pidfile. Eliminates the
488    /// "stale binary text in memory under a fresh symlink" bug class that
489    /// burned 30 minutes today.
490    Upgrade {
491        /// Report drift without taking action (lists processes that would
492        /// be killed + the version of each).
493        #[arg(long)]
494        check: bool,
495        #[arg(long)]
496        json: bool,
497    },
498    /// Install / inspect / remove a launchd plist (macOS) or systemd
499    /// user unit (linux) that runs `wire daemon` on login + restarts
500    /// on crash. Replaces today's "background it with tmux/&/systemd
501    /// as you prefer" footgun.
502    Service {
503        #[command(subcommand)]
504        action: ServiceAction,
505    },
506    /// Inspect or toggle the structured diagnostic trace
507    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
508    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
509    /// (writes the file knob a running daemon picks up automatically).
510    Diag {
511        #[command(subcommand)]
512        action: DiagAction,
513    },
514    /// Claim a nick on a relay's handle directory. Anyone can then reach
515    /// this agent by `<nick>@<relay-domain>` via the relay's
516    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
517    Claim {
518        nick: String,
519        /// Relay to claim the nick on. Default = relay our slot is on.
520        #[arg(long)]
521        relay: Option<String>,
522        /// Public URL the relay should advertise to resolvers (default = relay).
523        #[arg(long)]
524        public_url: Option<String>,
525        #[arg(long)]
526        json: bool,
527    },
528    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
529    /// avatar_url, handle, now). Re-signs the agent-card atomically.
530    ///
531    /// Examples:
532    ///   wire profile set motto "compiles or dies trying"
533    ///   wire profile set emoji "🦀"
534    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
535    ///   wire profile set handle "coffee-ghost@anthropic.dev"
536    ///   wire profile get
537    Profile {
538        #[command(subcommand)]
539        action: ProfileAction,
540    },
541    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
542    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
543    /// a relay slot on first use. Default TTL 24h, single-use.
544    Invite {
545        /// Override the relay URL for first-time auto-allocation.
546        #[arg(long, default_value = "https://wireup.net")]
547        relay: String,
548        /// Invite lifetime in seconds (default 86400 = 24h).
549        #[arg(long, default_value_t = 86_400)]
550        ttl: u64,
551        /// Number of distinct peers that can accept this invite before it's
552        /// consumed (default 1).
553        #[arg(long, default_value_t = 1)]
554        uses: u32,
555        /// Register the invite at the relay's short-URL endpoint and print
556        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
557        /// Installs wire if missing, then accepts the invite, then pairs.
558        #[arg(long)]
559        share: bool,
560        /// Emit JSON.
561        #[arg(long)]
562        json: bool,
563    },
564    /// Accept a wire invite URL. Single-step pair — pins issuer, sends our
565    /// signed card to issuer's slot. Auto-inits + auto-allocates if needed.
566    Accept {
567        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
568        url: String,
569        /// Emit JSON.
570        #[arg(long)]
571        json: bool,
572    },
573    /// Long-running event dispatcher. Watches inbox for new verified events
574    /// and spawns the given shell command per event, passing the event JSON
575    /// on stdin. Use to wire up autonomous reply loops:
576    ///   wire reactor --on-event 'claude -p "respond via wire send"'
577    /// Cursor persisted to `$WIRE_HOME/state/wire/reactor.cursor`.
578    Reactor {
579        /// Shell command to spawn per event. Event JSON written to its stdin.
580        #[arg(long)]
581        on_event: String,
582        /// Only fire for events from this peer.
583        #[arg(long)]
584        peer: Option<String>,
585        /// Only fire for events of this kind (numeric or name, e.g. 1 / decision).
586        #[arg(long)]
587        kind: Option<String>,
588        /// Skip events whose verified flag is false (default true).
589        #[arg(long, default_value_t = true)]
590        verified_only: bool,
591        /// Poll interval in seconds.
592        #[arg(long, default_value_t = 2)]
593        interval: u64,
594        /// Process one sweep and exit.
595        #[arg(long)]
596        once: bool,
597        /// Don't actually spawn — print one JSONL line per event for smoke-testing.
598        #[arg(long)]
599        dry_run: bool,
600        /// Hard rate-limit: max events handler is fired for per peer per minute.
601        /// 0 = unlimited. Default 6 — covers normal conversational tempo, kills
602        /// LLM-vs-LLM feedback loops (which fire 10+/sec).
603        #[arg(long, default_value_t = 6)]
604        max_per_minute: u32,
605        /// Anti-loop chain depth. Track event_ids this reactor emitted; if an
606        /// incoming event body contains `(re:X)` where X is in our emitted log,
607        /// skip — that's a reply-to-our-reply, depth ≥ 2. Disable with 0.
608        #[arg(long, default_value_t = 1)]
609        max_chain_depth: u32,
610    },
611    /// Watch the inbox for new verified events and fire an OS notification per
612    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
613    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
614    /// re-emit history.
615    Notify {
616        /// Poll interval in seconds.
617        #[arg(long, default_value_t = 2)]
618        interval: u64,
619        /// Only notify for events from this peer (handle, no did: prefix).
620        #[arg(long)]
621        peer: Option<String>,
622        /// Run a single sweep and exit (useful for cron / tests).
623        #[arg(long)]
624        once: bool,
625        /// Suppress the OS notification call; print one JSON line per event to
626        /// stdout instead (for piping into other tooling or smoke-testing
627        /// without a desktop session).
628        #[arg(long)]
629        json: bool,
630    },
631}
632
633#[derive(Subcommand, Debug)]
634pub enum DiagAction {
635    /// Tail the last N entries from diag.jsonl.
636    Tail {
637        #[arg(long, default_value_t = 20)]
638        limit: usize,
639        #[arg(long)]
640        json: bool,
641    },
642    /// Flip the file-based knob ON. Running daemons pick this up on
643    /// the next emit call without restart.
644    Enable,
645    /// Flip the file-based knob OFF.
646    Disable,
647    /// Report whether diag is currently enabled + the file's size.
648    Status {
649        #[arg(long)]
650        json: bool,
651    },
652}
653
654#[derive(Subcommand, Debug)]
655pub enum ServiceAction {
656    /// Write the launchd plist (macOS) or systemd user unit (linux) and
657    /// load it. Idempotent — re-running re-bootstraps an existing service.
658    Install {
659        #[arg(long)]
660        json: bool,
661    },
662    /// Unload + delete the service unit. Daemon keeps running until the
663    /// next reboot or `wire upgrade`; this only changes the boot-time
664    /// behaviour.
665    Uninstall {
666        #[arg(long)]
667        json: bool,
668    },
669    /// Report whether the unit is installed + active.
670    Status {
671        #[arg(long)]
672        json: bool,
673    },
674}
675
676#[derive(Subcommand, Debug)]
677pub enum ResponderCommand {
678    /// Publish this agent's auto-responder health.
679    Set {
680        /// One of: online, offline, oauth_locked, rate_limited, degraded.
681        status: String,
682        /// Optional operator-facing reason.
683        #[arg(long)]
684        reason: Option<String>,
685        /// Emit JSON.
686        #[arg(long)]
687        json: bool,
688    },
689    /// Read responder health for self, or for a paired peer.
690    Get {
691        /// Optional peer handle; omitted means this agent's own slot.
692        peer: Option<String>,
693        /// Emit JSON.
694        #[arg(long)]
695        json: bool,
696    },
697}
698
699#[derive(Subcommand, Debug)]
700pub enum ProfileAction {
701    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
702    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
703    /// (JSON array) and `now` (JSON object).
704    Set {
705        field: String,
706        value: String,
707        #[arg(long)]
708        json: bool,
709    },
710    /// Show all profile fields. Equivalent to `wire whois`.
711    Get {
712        #[arg(long)]
713        json: bool,
714    },
715    /// Clear a profile field.
716    Clear {
717        field: String,
718        #[arg(long)]
719        json: bool,
720    },
721}
722
723/// Entry point — parse and dispatch.
724pub fn run() -> Result<()> {
725    let cli = Cli::parse();
726    match cli.command {
727        Command::Init {
728            handle,
729            name,
730            relay,
731            json,
732        } => cmd_init(&handle, name.as_deref(), relay.as_deref(), json),
733        Command::Status { peer, json } => {
734            if let Some(peer) = peer {
735                cmd_status_peer(&peer, json)
736            } else {
737                cmd_status(json)
738            }
739        }
740        Command::Whoami { json } => cmd_whoami(json),
741        Command::Peers { json } => cmd_peers(json),
742        Command::Send {
743            peer,
744            kind_or_body,
745            body,
746            deadline,
747            json,
748        } => {
749            // P0.S: smart-positional API. `wire send peer body` =
750            // kind=claim. `wire send peer kind body` = explicit kind.
751            let (kind, body) = match body {
752                Some(real_body) => (kind_or_body, real_body),
753                None => ("claim".to_string(), kind_or_body),
754            };
755            cmd_send(&peer, &kind, &body, deadline.as_deref(), json)
756        }
757        Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
758        Command::Monitor {
759            peer,
760            json,
761            include_handshake,
762            interval_ms,
763            replay,
764        } => cmd_monitor(peer.as_deref(), json, include_handshake, interval_ms, replay),
765        Command::Verify { path, json } => cmd_verify(&path, json),
766        Command::Responder { command } => match command {
767            ResponderCommand::Set {
768                status,
769                reason,
770                json,
771            } => cmd_responder_set(&status, reason.as_deref(), json),
772            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
773        },
774        Command::Mcp => cmd_mcp(),
775        Command::RelayServer { bind } => cmd_relay_server(&bind),
776        Command::BindRelay { url, json } => cmd_bind_relay(&url, json),
777        Command::AddPeerSlot {
778            handle,
779            url,
780            slot_id,
781            slot_token,
782            json,
783        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
784        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
785        Command::Pull { json } => cmd_pull(json),
786        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
787        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
788        Command::ForgetPeer {
789            handle,
790            purge,
791            json,
792        } => cmd_forget_peer(&handle, purge, json),
793        Command::Daemon {
794            interval,
795            once,
796            json,
797        } => cmd_daemon(interval, once, json),
798        Command::PairHost {
799            relay,
800            yes,
801            timeout,
802            detach,
803            json,
804        } => {
805            if detach {
806                cmd_pair_host_detach(&relay, json)
807            } else {
808                cmd_pair_host(&relay, yes, timeout)
809            }
810        }
811        Command::PairJoin {
812            code_phrase,
813            relay,
814            yes,
815            timeout,
816            detach,
817            json,
818        } => {
819            if detach {
820                cmd_pair_join_detach(&code_phrase, &relay, json)
821            } else {
822                cmd_pair_join(&code_phrase, &relay, yes, timeout)
823            }
824        }
825        Command::PairConfirm {
826            code_phrase,
827            digits,
828            json,
829        } => cmd_pair_confirm(&code_phrase, &digits, json),
830        Command::PairList {
831            json,
832            watch,
833            watch_interval,
834        } => cmd_pair_list(json, watch, watch_interval),
835        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
836        Command::PairWatch {
837            code_phrase,
838            status,
839            timeout,
840            json,
841        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
842        Command::Pair {
843            handle,
844            code,
845            relay,
846            yes,
847            timeout,
848            no_setup,
849            detach,
850        } => {
851            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
852            // the zero-paste megacommand path — `wire pair slancha-spark@
853            // wireup.net` does add + poll-for-ack + verify in one shot. The
854            // SAS / code-based pair flow stays available for handles without
855            // `@` (bootstrap pairing between two boxes that don't yet share a
856            // relay directory).
857            if handle.contains('@') && code.is_none() {
858                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
859            } else if detach {
860                cmd_pair_detach(&handle, code.as_deref(), &relay)
861            } else {
862                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
863            }
864        }
865        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
866        Command::PairReject { peer, json } => cmd_pair_reject(&peer, json),
867        Command::PairListInbound { json } => cmd_pair_list_inbound(json),
868        Command::Invite {
869            relay,
870            ttl,
871            uses,
872            share,
873            json,
874        } => cmd_invite(&relay, ttl, uses, share, json),
875        Command::Accept { url, json } => cmd_accept(&url, json),
876        Command::Whois {
877            handle,
878            json,
879            relay,
880        } => cmd_whois(handle.as_deref(), json, relay.as_deref()),
881        Command::Add {
882            handle,
883            relay,
884            json,
885        } => cmd_add(&handle, relay.as_deref(), json),
886        Command::Up {
887            handle,
888            name,
889            json,
890        } => cmd_up(&handle, name.as_deref(), json),
891        Command::Doctor {
892            json,
893            recent_rejections,
894        } => cmd_doctor(json, recent_rejections),
895        Command::Upgrade { check, json } => cmd_upgrade(check, json),
896        Command::Service { action } => cmd_service(action),
897        Command::Diag { action } => cmd_diag(action),
898        Command::Claim {
899            nick,
900            relay,
901            public_url,
902            json,
903        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), json),
904        Command::Profile { action } => cmd_profile(action),
905        Command::Setup { apply } => cmd_setup(apply),
906        Command::Reactor {
907            on_event,
908            peer,
909            kind,
910            verified_only,
911            interval,
912            once,
913            dry_run,
914            max_per_minute,
915            max_chain_depth,
916        } => cmd_reactor(
917            &on_event,
918            peer.as_deref(),
919            kind.as_deref(),
920            verified_only,
921            interval,
922            once,
923            dry_run,
924            max_per_minute,
925            max_chain_depth,
926        ),
927        Command::Notify {
928            interval,
929            peer,
930            once,
931            json,
932        } => cmd_notify(interval, peer.as_deref(), once, json),
933    }
934}
935
936// ---------- init ----------
937
938fn cmd_init(handle: &str, name: Option<&str>, relay: Option<&str>, as_json: bool) -> Result<()> {
939    if !handle
940        .chars()
941        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
942    {
943        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
944    }
945    if config::is_initialized()? {
946        bail!(
947            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
948            config::config_dir()?
949        );
950    }
951
952    config::ensure_dirs()?;
953    let (sk_seed, pk_bytes) = generate_keypair();
954    config::write_private_key(&sk_seed)?;
955
956    let card = build_agent_card(handle, &pk_bytes, name, None, None);
957    let signed = sign_agent_card(&card, &sk_seed);
958    config::write_agent_card(&signed)?;
959
960    let mut trust = empty_trust();
961    add_self_to_trust(&mut trust, handle, &pk_bytes);
962    config::write_trust(&trust)?;
963
964    let fp = fingerprint(&pk_bytes);
965    let key_id = make_key_id(handle, &pk_bytes);
966
967    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
968    let mut relay_info: Option<(String, String)> = None;
969    if let Some(url) = relay {
970        let normalized = url.trim_end_matches('/');
971        let client = crate::relay_client::RelayClient::new(normalized);
972        client.check_healthz()?;
973        let alloc = client.allocate_slot(Some(handle))?;
974        let mut state = config::read_relay_state()?;
975        state["self"] = json!({
976            "relay_url": normalized,
977            "slot_id": alloc.slot_id.clone(),
978            "slot_token": alloc.slot_token,
979        });
980        config::write_relay_state(&state)?;
981        relay_info = Some((normalized.to_string(), alloc.slot_id));
982    }
983
984    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
985    if as_json {
986        let mut out = json!({
987            "did": did_str.clone(),
988            "fingerprint": fp,
989            "key_id": key_id,
990            "config_dir": config::config_dir()?.to_string_lossy(),
991        });
992        if let Some((url, slot_id)) = &relay_info {
993            out["relay_url"] = json!(url);
994            out["slot_id"] = json!(slot_id);
995        }
996        println!("{}", serde_json::to_string(&out)?);
997    } else {
998        println!("generated {did_str} (ed25519:{key_id})");
999        println!(
1000            "config written to {}",
1001            config::config_dir()?.to_string_lossy()
1002        );
1003        if let Some((url, slot_id)) = &relay_info {
1004            println!("bound to relay {url} (slot {slot_id})");
1005            println!();
1006            println!(
1007                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1008            );
1009        } else {
1010            println!();
1011            println!(
1012                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1013            );
1014        }
1015    }
1016    Ok(())
1017}
1018
1019// ---------- status ----------
1020
1021fn cmd_status(as_json: bool) -> Result<()> {
1022    let initialized = config::is_initialized()?;
1023
1024    let mut summary = json!({
1025        "initialized": initialized,
1026    });
1027
1028    if initialized {
1029        let card = config::read_agent_card()?;
1030        let did = card
1031            .get("did")
1032            .and_then(Value::as_str)
1033            .unwrap_or("")
1034            .to_string();
1035        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
1036        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
1037        // legacy cards.
1038        let handle = card
1039            .get("handle")
1040            .and_then(Value::as_str)
1041            .map(str::to_string)
1042            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1043        let pk_b64 = card
1044            .get("verify_keys")
1045            .and_then(Value::as_object)
1046            .and_then(|m| m.values().next())
1047            .and_then(|v| v.get("key"))
1048            .and_then(Value::as_str)
1049            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1050        let pk_bytes = crate::signing::b64decode(pk_b64)?;
1051        summary["did"] = json!(did);
1052        summary["handle"] = json!(handle);
1053        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1054        summary["capabilities"] = card
1055            .get("capabilities")
1056            .cloned()
1057            .unwrap_or_else(|| json!([]));
1058
1059        let trust = config::read_trust()?;
1060        let relay_state_for_tier = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1061        let mut peers = Vec::new();
1062        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1063            for (peer_handle, _agent) in agents {
1064                if peer_handle == &handle {
1065                    continue; // self
1066                }
1067                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
1068                // for peers we've pinned but never received a pair_drop_ack
1069                // from, so the operator sees the "we can't send to them yet"
1070                // state instead of seeing a misleading VERIFIED.
1071                peers.push(json!({
1072                    "handle": peer_handle,
1073                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
1074                }));
1075            }
1076        }
1077        summary["peers"] = json!(peers);
1078
1079        let relay_state = config::read_relay_state()?;
1080        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
1081        if !summary["self_relay"].is_null() {
1082            // Hide slot_token from default view.
1083            if let Some(obj) = summary["self_relay"].as_object_mut() {
1084                obj.remove("slot_token");
1085            }
1086        }
1087        summary["peer_slots_count"] = json!(
1088            relay_state
1089                .get("peers")
1090                .and_then(Value::as_object)
1091                .map(|m| m.len())
1092                .unwrap_or(0)
1093        );
1094
1095        // Outbox / inbox queue depth (file count + total events)
1096        let outbox = config::outbox_dir()?;
1097        let inbox = config::inbox_dir()?;
1098        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
1099        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
1100
1101        // P1.7 (0.5.11): daemon liveness now consults the structured
1102        // pidfile (P0.4) AND `pgrep -f "wire daemon"` to detect orphans
1103        // that the pidfile didn't record. Today's debug had a 4-day-old
1104        // 0.2.4 daemon (PID 54017) running while the pidfile pointed at
1105        // an unrelated dead PID — wire status said `daemon: DOWN` while
1106        // the box was actually full of stale-daemon-eating-events
1107        // behaviour. Catch THAT class here.
1108        let record = crate::ensure_up::read_pid_record("daemon");
1109        let pidfile_pid = record.pid();
1110        let pidfile_alive = pidfile_pid
1111            .map(|pid| {
1112                #[cfg(target_os = "linux")]
1113                {
1114                    std::path::Path::new(&format!("/proc/{pid}")).exists()
1115                }
1116                #[cfg(not(target_os = "linux"))]
1117                {
1118                    std::process::Command::new("kill")
1119                        .args(["-0", &pid.to_string()])
1120                        .output()
1121                        .map(|o| o.status.success())
1122                        .unwrap_or(false)
1123                }
1124            })
1125            .unwrap_or(false);
1126
1127        // Cross-check with pgrep — surfaces orphan daemons not in pidfile.
1128        let pgrep_pids: Vec<u32> = std::process::Command::new("pgrep")
1129            .args(["-f", "wire daemon"])
1130            .output()
1131            .ok()
1132            .filter(|o| o.status.success())
1133            .map(|o| {
1134                String::from_utf8_lossy(&o.stdout)
1135                    .split_whitespace()
1136                    .filter_map(|s| s.parse::<u32>().ok())
1137                    .collect()
1138            })
1139            .unwrap_or_default();
1140        let orphan_pids: Vec<u32> = pgrep_pids
1141            .iter()
1142            .filter(|p| Some(**p) != pidfile_pid)
1143            .copied()
1144            .collect();
1145
1146        let mut daemon = json!({
1147            "running": pidfile_alive,
1148            "pid": pidfile_pid,
1149            "all_running_pids": pgrep_pids,
1150            "orphans": orphan_pids,
1151        });
1152        if let crate::ensure_up::PidRecord::Json(d) = &record {
1153            daemon["version"] = json!(d.version);
1154            daemon["bin_path"] = json!(d.bin_path);
1155            daemon["did"] = json!(d.did);
1156            daemon["relay_url"] = json!(d.relay_url);
1157            daemon["started_at"] = json!(d.started_at);
1158            daemon["schema"] = json!(d.schema);
1159            if d.version != env!("CARGO_PKG_VERSION") {
1160                daemon["version_mismatch"] = json!({
1161                    "daemon": d.version.clone(),
1162                    "cli": env!("CARGO_PKG_VERSION"),
1163                });
1164            }
1165        } else if matches!(record, crate::ensure_up::PidRecord::LegacyInt(_)) {
1166            daemon["pidfile_form"] = json!("legacy-int");
1167            daemon["version_mismatch"] = json!({
1168                "daemon": "<pre-0.5.11>",
1169                "cli": env!("CARGO_PKG_VERSION"),
1170            });
1171        }
1172        summary["daemon"] = daemon;
1173
1174        // Pending pair sessions — counts by status.
1175        let pending = crate::pending_pair::list_pending().unwrap_or_default();
1176        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
1177        for p in &pending {
1178            *counts.entry(p.status.clone()).or_default() += 1;
1179        }
1180        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
1181        let pending_inbound =
1182            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
1183        let inbound_handles: Vec<&str> = pending_inbound
1184            .iter()
1185            .map(|p| p.peer_handle.as_str())
1186            .collect();
1187        summary["pending_pairs"] = json!({
1188            "total": pending.len(),
1189            "by_status": counts,
1190            "inbound_count": pending_inbound.len(),
1191            "inbound_handles": inbound_handles,
1192        });
1193    }
1194
1195    if as_json {
1196        println!("{}", serde_json::to_string(&summary)?);
1197    } else if !initialized {
1198        println!("not initialized — run `wire init <handle>` first");
1199    } else {
1200        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
1201        println!(
1202            "fingerprint:   {}",
1203            summary["fingerprint"].as_str().unwrap_or("?")
1204        );
1205        println!("capabilities:  {}", summary["capabilities"]);
1206        if !summary["self_relay"].is_null() {
1207            println!(
1208                "self relay:    {} (slot {})",
1209                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
1210                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
1211            );
1212        } else {
1213            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
1214        }
1215        println!(
1216            "peers:         {}",
1217            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
1218        );
1219        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
1220            println!(
1221                "  - {:<20} tier={}",
1222                p["handle"].as_str().unwrap_or(""),
1223                p["tier"].as_str().unwrap_or("?")
1224            );
1225        }
1226        println!(
1227            "outbox:        {} file(s), {} event(s) queued",
1228            summary["outbox"]["files"].as_u64().unwrap_or(0),
1229            summary["outbox"]["events"].as_u64().unwrap_or(0)
1230        );
1231        println!(
1232            "inbox:         {} file(s), {} event(s) received",
1233            summary["inbox"]["files"].as_u64().unwrap_or(0),
1234            summary["inbox"]["events"].as_u64().unwrap_or(0)
1235        );
1236        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
1237        let daemon_pid = summary["daemon"]["pid"]
1238            .as_u64()
1239            .map(|p| p.to_string())
1240            .unwrap_or_else(|| "—".to_string());
1241        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
1242        let version_suffix = if !daemon_version.is_empty() {
1243            format!(" v{daemon_version}")
1244        } else {
1245            String::new()
1246        };
1247        println!(
1248            "daemon:        {} (pid {}{})",
1249            if daemon_running { "running" } else { "DOWN" },
1250            daemon_pid,
1251            version_suffix,
1252        );
1253        // P1.7: surface version mismatch + orphan procs loudly.
1254        if let Some(mm) = summary["daemon"].get("version_mismatch") {
1255            println!(
1256                "               !! version mismatch: daemon={} CLI={}. \
1257                 run `wire upgrade` to swap atomically.",
1258                mm["daemon"].as_str().unwrap_or("?"),
1259                mm["cli"].as_str().unwrap_or("?"),
1260            );
1261        }
1262        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
1263            && !orphans.is_empty()
1264        {
1265            let pids: Vec<String> = orphans
1266                .iter()
1267                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
1268                .collect();
1269            println!(
1270                "               !! orphan daemon process(es): pids {}. \
1271                 pgrep saw them but pidfile didn't — likely stale process from \
1272                 prior install. Multiple daemons race the relay cursor.",
1273                pids.join(", ")
1274            );
1275        }
1276        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
1277        let inbound_count = summary["pending_pairs"]["inbound_count"]
1278            .as_u64()
1279            .unwrap_or(0);
1280        if pending_total > 0 {
1281            print!("pending pairs: {pending_total}");
1282            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
1283                let parts: Vec<String> = obj
1284                    .iter()
1285                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
1286                    .collect();
1287                if !parts.is_empty() {
1288                    print!(" ({})", parts.join(", "));
1289                }
1290            }
1291            println!();
1292        } else if inbound_count == 0 {
1293            println!("pending pairs: none");
1294        }
1295        // v0.5.14: separate line for pending-inbound zero-paste requests.
1296        // Loud because each one is awaiting an operator gesture and the
1297        // capability hasn't flowed yet.
1298        if inbound_count > 0 {
1299            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
1300                .as_array()
1301                .map(|a| {
1302                    a.iter()
1303                        .filter_map(|v| v.as_str().map(str::to_string))
1304                        .collect()
1305                })
1306                .unwrap_or_default();
1307            println!(
1308                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire add <peer>@<relay>` to accept, `wire pair-reject <peer>` to refuse",
1309                handles.join(", "),
1310            );
1311        }
1312    }
1313    Ok(())
1314}
1315
1316fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
1317    if !dir.exists() {
1318        return Ok(json!({"files": 0, "events": 0}));
1319    }
1320    let mut files = 0usize;
1321    let mut events = 0usize;
1322    for entry in std::fs::read_dir(dir)? {
1323        let path = entry?.path();
1324        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
1325            files += 1;
1326            if let Ok(body) = std::fs::read_to_string(&path) {
1327                events += body.lines().filter(|l| !l.trim().is_empty()).count();
1328            }
1329        }
1330    }
1331    Ok(json!({"files": files, "events": events}))
1332}
1333
1334// ---------- responder health ----------
1335
1336fn responder_status_allowed(status: &str) -> bool {
1337    matches!(
1338        status,
1339        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
1340    )
1341}
1342
1343fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
1344    let state = config::read_relay_state()?;
1345    let (label, slot_info) = match peer {
1346        Some(peer) => (
1347            peer.to_string(),
1348            state
1349                .get("peers")
1350                .and_then(|p| p.get(peer))
1351                .ok_or_else(|| {
1352                    anyhow!(
1353                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
1354                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
1355                         (`wire peers` lists who you've already paired with.)"
1356                    )
1357                })?,
1358        ),
1359        None => (
1360            "self".to_string(),
1361            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
1362                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
1363            })?,
1364        ),
1365    };
1366    let relay_url = slot_info["relay_url"]
1367        .as_str()
1368        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
1369        .to_string();
1370    let slot_id = slot_info["slot_id"]
1371        .as_str()
1372        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
1373        .to_string();
1374    let slot_token = slot_info["slot_token"]
1375        .as_str()
1376        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
1377        .to_string();
1378    Ok((label, relay_url, slot_id, slot_token))
1379}
1380
1381fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
1382    if !responder_status_allowed(status) {
1383        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
1384    }
1385    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
1386    let now = time::OffsetDateTime::now_utc()
1387        .format(&time::format_description::well_known::Rfc3339)
1388        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1389    let mut record = json!({
1390        "status": status,
1391        "set_at": now,
1392    });
1393    if let Some(reason) = reason {
1394        record["reason"] = json!(reason);
1395    }
1396    if status == "online" {
1397        record["last_success_at"] = json!(now);
1398    }
1399    let client = crate::relay_client::RelayClient::new(&relay_url);
1400    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
1401    if as_json {
1402        println!("{}", serde_json::to_string(&saved)?);
1403    } else {
1404        let reason = saved
1405            .get("reason")
1406            .and_then(Value::as_str)
1407            .map(|r| format!(" — {r}"))
1408            .unwrap_or_default();
1409        println!(
1410            "responder {}{}",
1411            saved
1412                .get("status")
1413                .and_then(Value::as_str)
1414                .unwrap_or(status),
1415            reason
1416        );
1417    }
1418    Ok(())
1419}
1420
1421fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
1422    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
1423    let client = crate::relay_client::RelayClient::new(&relay_url);
1424    let health = client.responder_health_get(&slot_id, &slot_token)?;
1425    if as_json {
1426        println!(
1427            "{}",
1428            serde_json::to_string(&json!({
1429                "target": label,
1430                "responder_health": health,
1431            }))?
1432        );
1433    } else if health.is_null() {
1434        println!("{label}: responder health not reported");
1435    } else {
1436        let status = health
1437            .get("status")
1438            .and_then(Value::as_str)
1439            .unwrap_or("unknown");
1440        let reason = health
1441            .get("reason")
1442            .and_then(Value::as_str)
1443            .map(|r| format!(" — {r}"))
1444            .unwrap_or_default();
1445        let last_success = health
1446            .get("last_success_at")
1447            .and_then(Value::as_str)
1448            .map(|t| format!(" (last_success: {t})"))
1449            .unwrap_or_default();
1450        println!("{label}: {status}{reason}{last_success}");
1451    }
1452    Ok(())
1453}
1454
1455fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
1456    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
1457    let client = crate::relay_client::RelayClient::new(&relay_url);
1458
1459    let started = std::time::Instant::now();
1460    let transport_ok = client.healthz().unwrap_or(false);
1461    let latency_ms = started.elapsed().as_millis() as u64;
1462
1463    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
1464    let now = std::time::SystemTime::now()
1465        .duration_since(std::time::UNIX_EPOCH)
1466        .map(|d| d.as_secs())
1467        .unwrap_or(0);
1468    let attention = match last_pull_at_unix {
1469        Some(last) if now.saturating_sub(last) <= 300 => json!({
1470            "status": "ok",
1471            "last_pull_at_unix": last,
1472            "age_seconds": now.saturating_sub(last),
1473            "event_count": event_count,
1474        }),
1475        Some(last) => json!({
1476            "status": "stale",
1477            "last_pull_at_unix": last,
1478            "age_seconds": now.saturating_sub(last),
1479            "event_count": event_count,
1480        }),
1481        None => json!({
1482            "status": "never_pulled",
1483            "last_pull_at_unix": Value::Null,
1484            "event_count": event_count,
1485        }),
1486    };
1487
1488    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
1489    let responder = if responder_health.is_null() {
1490        json!({"status": "not_reported", "record": Value::Null})
1491    } else {
1492        json!({
1493            "status": responder_health
1494                .get("status")
1495                .and_then(Value::as_str)
1496                .unwrap_or("unknown"),
1497            "record": responder_health,
1498        })
1499    };
1500
1501    let report = json!({
1502        "peer": peer,
1503        "transport": {
1504            "status": if transport_ok { "ok" } else { "error" },
1505            "relay_url": relay_url,
1506            "latency_ms": latency_ms,
1507        },
1508        "attention": attention,
1509        "responder": responder,
1510    });
1511
1512    if as_json {
1513        println!("{}", serde_json::to_string(&report)?);
1514    } else {
1515        let transport_line = if transport_ok {
1516            format!("ok relay reachable ({latency_ms}ms)")
1517        } else {
1518            "error relay unreachable".to_string()
1519        };
1520        println!("transport      {transport_line}");
1521        match report["attention"]["status"].as_str().unwrap_or("unknown") {
1522            "ok" => println!(
1523                "attention      ok last pull {}s ago",
1524                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
1525            ),
1526            "stale" => println!(
1527                "attention      stale last pull {}m ago",
1528                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
1529            ),
1530            "never_pulled" => println!("attention      never pulled since relay reset"),
1531            other => println!("attention      {other}"),
1532        }
1533        if report["responder"]["status"] == "not_reported" {
1534            println!("auto-responder not reported");
1535        } else {
1536            let record = &report["responder"]["record"];
1537            let status = record
1538                .get("status")
1539                .and_then(Value::as_str)
1540                .unwrap_or("unknown");
1541            let reason = record
1542                .get("reason")
1543                .and_then(Value::as_str)
1544                .map(|r| format!(" — {r}"))
1545                .unwrap_or_default();
1546            println!("auto-responder {status}{reason}");
1547        }
1548    }
1549    Ok(())
1550}
1551
1552// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
1553
1554// ---------- whoami ----------
1555
1556fn cmd_whoami(as_json: bool) -> Result<()> {
1557    if !config::is_initialized()? {
1558        bail!("not initialized — run `wire init <handle>` first");
1559    }
1560    let card = config::read_agent_card()?;
1561    let did = card
1562        .get("did")
1563        .and_then(Value::as_str)
1564        .unwrap_or("")
1565        .to_string();
1566    let handle = card
1567        .get("handle")
1568        .and_then(Value::as_str)
1569        .map(str::to_string)
1570        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1571    let pk_b64 = card
1572        .get("verify_keys")
1573        .and_then(Value::as_object)
1574        .and_then(|m| m.values().next())
1575        .and_then(|v| v.get("key"))
1576        .and_then(Value::as_str)
1577        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1578    let pk_bytes = crate::signing::b64decode(pk_b64)?;
1579    let fp = fingerprint(&pk_bytes);
1580    let key_id = make_key_id(&handle, &pk_bytes);
1581    let capabilities = card
1582        .get("capabilities")
1583        .cloned()
1584        .unwrap_or_else(|| json!(["wire/v3.1"]));
1585
1586    if as_json {
1587        println!(
1588            "{}",
1589            serde_json::to_string(&json!({
1590                "did": did,
1591                "handle": handle,
1592                "fingerprint": fp,
1593                "key_id": key_id,
1594                "public_key_b64": pk_b64,
1595                "capabilities": capabilities,
1596                "config_dir": config::config_dir()?.to_string_lossy(),
1597            }))?
1598        );
1599    } else {
1600        println!("{did} (ed25519:{key_id})");
1601        println!("fingerprint: {fp}");
1602        println!("capabilities: {capabilities}");
1603    }
1604    Ok(())
1605}
1606
1607// ---------- peers ----------
1608
1609/// P0.Y (0.5.11): effective tier shown to operators. `wire add` pins a
1610/// peer's card into trust at VERIFIED immediately, but the bilateral pin
1611/// isn't complete until that peer's `pair_drop_ack` arrives carrying their
1612/// slot_token. Until then we CAN'T send to them. Displaying VERIFIED is
1613/// misleading — spark observed this in real usage.
1614///
1615/// Effective rules:
1616///   trust.tier == VERIFIED + relay_state.peers[h].slot_token empty -> "PENDING_ACK"
1617///   otherwise -> raw trust tier (UNTRUSTED / VERIFIED / etc.)
1618///
1619/// Strictly a display concern — trust state machine itself is untouched
1620/// so existing promote/demote logic still works.
1621fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
1622    let raw = crate::trust::get_tier(trust, handle);
1623    if raw != "VERIFIED" {
1624        return raw.to_string();
1625    }
1626    let token = relay_state
1627        .get("peers")
1628        .and_then(|p| p.get(handle))
1629        .and_then(|p| p.get("slot_token"))
1630        .and_then(Value::as_str)
1631        .unwrap_or("");
1632    if token.is_empty() {
1633        "PENDING_ACK".to_string()
1634    } else {
1635        raw.to_string()
1636    }
1637}
1638
1639fn cmd_peers(as_json: bool) -> Result<()> {
1640    let trust = config::read_trust()?;
1641    let agents = trust
1642        .get("agents")
1643        .and_then(Value::as_object)
1644        .cloned()
1645        .unwrap_or_default();
1646    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1647
1648    let mut self_did: Option<String> = None;
1649    if let Ok(card) = config::read_agent_card() {
1650        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1651    }
1652
1653    let mut peers = Vec::new();
1654    for (handle, agent) in agents.iter() {
1655        let did = agent
1656            .get("did")
1657            .and_then(Value::as_str)
1658            .unwrap_or("")
1659            .to_string();
1660        if Some(did.as_str()) == self_did.as_deref() {
1661            continue; // skip self-attestation
1662        }
1663        let tier = effective_peer_tier(&trust, &relay_state, handle);
1664        let capabilities = agent
1665            .get("card")
1666            .and_then(|c| c.get("capabilities"))
1667            .cloned()
1668            .unwrap_or_else(|| json!([]));
1669        peers.push(json!({
1670            "handle": handle,
1671            "did": did,
1672            "tier": tier,
1673            "capabilities": capabilities,
1674        }));
1675    }
1676
1677    if as_json {
1678        println!("{}", serde_json::to_string(&peers)?);
1679    } else if peers.is_empty() {
1680        println!("no peers pinned (run `wire join <code>` to pair)");
1681    } else {
1682        for p in &peers {
1683            println!(
1684                "{:<20} {:<10} {}",
1685                p["handle"].as_str().unwrap_or(""),
1686                p["tier"].as_str().unwrap_or(""),
1687                p["did"].as_str().unwrap_or(""),
1688            );
1689        }
1690    }
1691    Ok(())
1692}
1693
1694// ---------- send ----------
1695
1696/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
1697///
1698/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
1699/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
1700/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
1701/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
1702/// hasn't crossed two heartbeats means probably degraded.
1703fn maybe_warn_peer_attentiveness(peer: &str) {
1704    let state = match config::read_relay_state() {
1705        Ok(s) => s,
1706        Err(_) => return,
1707    };
1708    let p = state.get("peers").and_then(|p| p.get(peer));
1709    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
1710        Some(s) if !s.is_empty() => s,
1711        _ => return,
1712    };
1713    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
1714        Some(s) if !s.is_empty() => s,
1715        _ => return,
1716    };
1717    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
1718        Some(s) if !s.is_empty() => s.to_string(),
1719        _ => match state
1720            .get("self")
1721            .and_then(|s| s.get("relay_url"))
1722            .and_then(Value::as_str)
1723        {
1724            Some(s) if !s.is_empty() => s.to_string(),
1725            _ => return,
1726        },
1727    };
1728    let client = crate::relay_client::RelayClient::new(&relay_url);
1729    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
1730        Ok(t) => t,
1731        Err(_) => return,
1732    };
1733    let now = std::time::SystemTime::now()
1734        .duration_since(std::time::UNIX_EPOCH)
1735        .map(|d| d.as_secs())
1736        .unwrap_or(0);
1737    match last_pull {
1738        None => {
1739            eprintln!(
1740                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
1741            );
1742        }
1743        Some(t) if now.saturating_sub(t) > 300 => {
1744            let mins = now.saturating_sub(t) / 60;
1745            eprintln!(
1746                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
1747            );
1748        }
1749        _ => {}
1750    }
1751}
1752
1753pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
1754    let trimmed = input.trim();
1755    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
1756    {
1757        return Ok(trimmed.to_string());
1758    }
1759    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
1760    let n: i64 = amount
1761        .parse()
1762        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
1763    if n <= 0 {
1764        bail!("deadline duration must be positive: {input:?}");
1765    }
1766    let duration = match unit {
1767        "m" => time::Duration::minutes(n),
1768        "h" => time::Duration::hours(n),
1769        "d" => time::Duration::days(n),
1770        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
1771    };
1772    Ok((time::OffsetDateTime::now_utc() + duration)
1773        .format(&time::format_description::well_known::Rfc3339)
1774        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
1775}
1776
1777fn cmd_send(
1778    peer: &str,
1779    kind: &str,
1780    body_arg: &str,
1781    deadline: Option<&str>,
1782    as_json: bool,
1783) -> Result<()> {
1784    if !config::is_initialized()? {
1785        bail!("not initialized — run `wire init <handle>` first");
1786    }
1787    let peer = crate::agent_card::bare_handle(peer);
1788    let sk_seed = config::read_private_key()?;
1789    let card = config::read_agent_card()?;
1790    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
1791    let handle = crate::agent_card::display_handle_from_did(did).to_string();
1792    let pk_b64 = card
1793        .get("verify_keys")
1794        .and_then(Value::as_object)
1795        .and_then(|m| m.values().next())
1796        .and_then(|v| v.get("key"))
1797        .and_then(Value::as_str)
1798        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1799    let pk_bytes = crate::signing::b64decode(pk_b64)?;
1800
1801    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
1802    // P0.S (0.5.11): stdin support lets shells pipe in long content
1803    // without quoting/escaping ceremony, and supports heredocs naturally:
1804    //   wire send peer - <<EOF ... EOF
1805    let body_value: Value = if body_arg == "-" {
1806        use std::io::Read;
1807        let mut raw = String::new();
1808        std::io::stdin()
1809            .read_to_string(&mut raw)
1810            .with_context(|| "reading body from stdin")?;
1811        // Try parsing as JSON first; fall back to string literal for
1812        // plain-text bodies.
1813        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
1814    } else if let Some(path) = body_arg.strip_prefix('@') {
1815        let raw =
1816            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
1817        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
1818    } else {
1819        Value::String(body_arg.to_string())
1820    };
1821
1822    let kind_id = parse_kind(kind)?;
1823
1824    let now = time::OffsetDateTime::now_utc()
1825        .format(&time::format_description::well_known::Rfc3339)
1826        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1827
1828    let mut event = json!({
1829        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
1830        "timestamp": now,
1831        "from": did,
1832        "to": format!("did:wire:{peer}"),
1833        "type": kind,
1834        "kind": kind_id,
1835        "body": body_value,
1836    });
1837    if let Some(deadline) = deadline {
1838        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
1839    }
1840    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
1841    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1842
1843    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
1844    // coords in relay-state and ask the relay how recently the peer pulled.
1845    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
1846    // Never blocks the send — the event still queues to outbox.
1847    maybe_warn_peer_attentiveness(peer);
1848
1849    // For now we append to outbox JSONL and rely on a future daemon to push
1850    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
1851    // Append goes through `config::append_outbox_record` which holds a per-
1852    // path mutex so concurrent senders cannot interleave bytes mid-line.
1853    let line = serde_json::to_vec(&signed)?;
1854    let outbox = config::append_outbox_record(peer, &line)?;
1855
1856    if as_json {
1857        println!(
1858            "{}",
1859            serde_json::to_string(&json!({
1860                "event_id": event_id,
1861                "status": "queued",
1862                "peer": peer,
1863                "outbox": outbox.to_string_lossy(),
1864            }))?
1865        );
1866    } else {
1867        println!(
1868            "queued event {event_id} → {peer} (outbox: {})",
1869            outbox.display()
1870        );
1871    }
1872    Ok(())
1873}
1874
1875fn parse_kind(s: &str) -> Result<u32> {
1876    if let Ok(n) = s.parse::<u32>() {
1877        return Ok(n);
1878    }
1879    for (id, name) in crate::signing::kinds() {
1880        if *name == s {
1881            return Ok(*id);
1882        }
1883    }
1884    // Unknown name — default to kind 1 (decision) for v0.1.
1885    Ok(1)
1886}
1887
1888// ---------- tail ----------
1889
1890fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
1891    let inbox = config::inbox_dir()?;
1892    if !inbox.exists() {
1893        if !as_json {
1894            eprintln!("no inbox yet — daemon hasn't run, or no events received");
1895        }
1896        return Ok(());
1897    }
1898    let trust = config::read_trust()?;
1899    let mut count = 0usize;
1900
1901    let entries: Vec<_> = std::fs::read_dir(&inbox)?
1902        .filter_map(|e| e.ok())
1903        .map(|e| e.path())
1904        .filter(|p| {
1905            p.extension().map(|x| x == "jsonl").unwrap_or(false)
1906                && match peer {
1907                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1908                    None => true,
1909                }
1910        })
1911        .collect();
1912
1913    for path in entries {
1914        let body = std::fs::read_to_string(&path)?;
1915        for line in body.lines() {
1916            let event: Value = match serde_json::from_str(line) {
1917                Ok(v) => v,
1918                Err(_) => continue,
1919            };
1920            let verified = verify_message_v31(&event, &trust).is_ok();
1921            if as_json {
1922                let mut event_with_meta = event.clone();
1923                if let Some(obj) = event_with_meta.as_object_mut() {
1924                    obj.insert("verified".into(), json!(verified));
1925                }
1926                println!("{}", serde_json::to_string(&event_with_meta)?);
1927            } else {
1928                let ts = event
1929                    .get("timestamp")
1930                    .and_then(Value::as_str)
1931                    .unwrap_or("?");
1932                let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
1933                let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
1934                let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
1935                let summary = event
1936                    .get("body")
1937                    .map(|b| match b {
1938                        Value::String(s) => s.clone(),
1939                        _ => b.to_string(),
1940                    })
1941                    .unwrap_or_default();
1942                let mark = if verified { "✓" } else { "✗" };
1943                let deadline = event
1944                    .get("time_sensitive_until")
1945                    .and_then(Value::as_str)
1946                    .map(|d| format!(" deadline: {d}"))
1947                    .unwrap_or_default();
1948                println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
1949            }
1950            count += 1;
1951            if limit > 0 && count >= limit {
1952                return Ok(());
1953            }
1954        }
1955    }
1956    Ok(())
1957}
1958
1959// ---------- monitor (live-tail across all peers, harness-friendly) ----------
1960
1961/// Events filtered out of `wire monitor` by default — pair handshake +
1962/// liveness pings. Operators almost never want these surfaced; an explicit
1963/// `--include-handshake` brings them back.
1964fn monitor_is_noise_kind(kind: &str) -> bool {
1965    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
1966}
1967
1968/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
1969/// full structured event for tooling consumption; the plain form is a tight
1970/// one-line summary suitable as a harness stream-watcher notification.
1971fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
1972    if as_json {
1973        Ok(serde_json::to_string(e)?)
1974    } else {
1975        let eid_short: String = e.event_id.chars().take(12).collect();
1976        let body = e.body_preview.replace('\n', " ");
1977        let ts: String = e.timestamp.chars().take(19).collect();
1978        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
1979    }
1980}
1981
1982/// `wire monitor` — long-running line-per-event stream of new inbox events.
1983///
1984/// Built for agent harnesses that have an "every stdout line is a chat
1985/// notification" stream watcher (Claude Code Monitor tool, etc.). One
1986/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
1987/// | python parse | grep -v pair_drop` pipeline operators improvise on day
1988/// one of every wire session.
1989///
1990/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
1991/// pure handshake / liveness noise that operators almost never want
1992/// surfaced. Pass `--include-handshake` if you do.
1993///
1994/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
1995/// doesn't drown the operator in replay), with optional `--replay N` to
1996/// emit the last N events first.
1997fn cmd_monitor(
1998    peer_filter: Option<&str>,
1999    as_json: bool,
2000    include_handshake: bool,
2001    interval_ms: u64,
2002    replay: usize,
2003) -> Result<()> {
2004    let inbox_dir = config::inbox_dir()?;
2005    if !inbox_dir.exists() {
2006        if !as_json {
2007            eprintln!(
2008                "wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?"
2009            );
2010        }
2011        // Still proceed — InboxWatcher::from_dir_head handles missing dir.
2012    }
2013
2014    // Optional replay — read existing files and emit the last `replay` events
2015    // (post-filter) before going live. Useful when the harness restarts and
2016    // wants recent context.
2017    if replay > 0 && inbox_dir.exists() {
2018        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
2019        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
2020            let path = entry.path();
2021            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
2022                continue;
2023            }
2024            let peer = match path.file_stem().and_then(|s| s.to_str()) {
2025                Some(s) => s.to_string(),
2026                None => continue,
2027            };
2028            if let Some(filter) = peer_filter {
2029                if peer != filter {
2030                    continue;
2031                }
2032            }
2033            let body = std::fs::read_to_string(&path).unwrap_or_default();
2034            for line in body.lines() {
2035                let line = line.trim();
2036                if line.is_empty() {
2037                    continue;
2038                }
2039                let signed: Value = match serde_json::from_str(line) {
2040                    Ok(v) => v,
2041                    Err(_) => continue,
2042                };
2043                let ev = crate::inbox_watch::InboxEvent::from_signed(
2044                    &peer,
2045                    signed,
2046                    /* verified */ true,
2047                );
2048                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
2049                    continue;
2050                }
2051                all.push(ev);
2052            }
2053        }
2054        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
2055        // chronological for same-zoned timestamps).
2056        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
2057        let start = all.len().saturating_sub(replay);
2058        for ev in &all[start..] {
2059            println!("{}", monitor_render(ev, as_json)?);
2060        }
2061        use std::io::Write;
2062        std::io::stdout().flush().ok();
2063    }
2064
2065    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
2066    // the first poll only returns events that arrived AFTER startup.
2067    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
2068    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
2069
2070    loop {
2071        let events = w.poll()?;
2072        let mut wrote = false;
2073        for ev in events {
2074            if let Some(filter) = peer_filter {
2075                if ev.peer != filter {
2076                    continue;
2077                }
2078            }
2079            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
2080                continue;
2081            }
2082            println!("{}", monitor_render(&ev, as_json)?);
2083            wrote = true;
2084        }
2085        if wrote {
2086            use std::io::Write;
2087            std::io::stdout().flush().ok();
2088        }
2089        std::thread::sleep(sleep_dur);
2090    }
2091}
2092
2093#[cfg(test)]
2094mod tier_tests {
2095    use super::*;
2096    use serde_json::json;
2097
2098    fn trust_with(handle: &str, tier: &str) -> Value {
2099        json!({
2100            "version": 1,
2101            "agents": {
2102                handle: {
2103                    "tier": tier,
2104                    "did": format!("did:wire:{handle}"),
2105                    "card": {"capabilities": ["wire/v3.1"]}
2106                }
2107            }
2108        })
2109    }
2110
2111    #[test]
2112    fn pending_ack_when_verified_but_no_slot_token() {
2113        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
2114        // slot_token hasn't arrived yet. Display PENDING_ACK so the
2115        // operator knows wire send won't work yet.
2116        let trust = trust_with("willard", "VERIFIED");
2117        let relay_state = json!({
2118            "peers": {
2119                "willard": {
2120                    "relay_url": "https://relay",
2121                    "slot_id": "abc",
2122                    "slot_token": "",
2123                }
2124            }
2125        });
2126        assert_eq!(
2127            effective_peer_tier(&trust, &relay_state, "willard"),
2128            "PENDING_ACK"
2129        );
2130    }
2131
2132    #[test]
2133    fn verified_when_slot_token_present() {
2134        let trust = trust_with("willard", "VERIFIED");
2135        let relay_state = json!({
2136            "peers": {
2137                "willard": {
2138                    "relay_url": "https://relay",
2139                    "slot_id": "abc",
2140                    "slot_token": "tok123",
2141                }
2142            }
2143        });
2144        assert_eq!(
2145            effective_peer_tier(&trust, &relay_state, "willard"),
2146            "VERIFIED"
2147        );
2148    }
2149
2150    #[test]
2151    fn raw_tier_passes_through_for_non_verified() {
2152        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
2153        // UNTRUSTED regardless of slot_token state.
2154        let trust = trust_with("willard", "UNTRUSTED");
2155        let relay_state = json!({
2156            "peers": {"willard": {"slot_token": ""}}
2157        });
2158        assert_eq!(
2159            effective_peer_tier(&trust, &relay_state, "willard"),
2160            "UNTRUSTED"
2161        );
2162    }
2163
2164    #[test]
2165    fn pending_ack_when_relay_state_missing_peer() {
2166        // After wire add, trust gets updated BEFORE relay_state.peers does.
2167        // If relay_state has no entry for the peer at all, the operator
2168        // still hasn't completed the bilateral pin — show PENDING_ACK.
2169        let trust = trust_with("willard", "VERIFIED");
2170        let relay_state = json!({"peers": {}});
2171        assert_eq!(
2172            effective_peer_tier(&trust, &relay_state, "willard"),
2173            "PENDING_ACK"
2174        );
2175    }
2176}
2177
2178#[cfg(test)]
2179mod monitor_tests {
2180    use super::*;
2181    use crate::inbox_watch::InboxEvent;
2182    use serde_json::Value;
2183
2184    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
2185        InboxEvent {
2186            peer: peer.to_string(),
2187            event_id: "abcd1234567890ef".to_string(),
2188            kind: kind.to_string(),
2189            body_preview: body.to_string(),
2190            verified: true,
2191            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
2192            raw: Value::Null,
2193        }
2194    }
2195
2196    #[test]
2197    fn monitor_filter_drops_handshake_kinds_by_default() {
2198        // The whole point: pair_drop / pair_drop_ack / heartbeat are
2199        // protocol noise. If they leak into the operator's chat stream by
2200        // default, the recipe is useless ("wire monitor talks too much,
2201        // disabled it"). Burn this rule in.
2202        assert!(monitor_is_noise_kind("pair_drop"));
2203        assert!(monitor_is_noise_kind("pair_drop_ack"));
2204        assert!(monitor_is_noise_kind("heartbeat"));
2205
2206        // Real-payload kinds — operator wants every one.
2207        assert!(!monitor_is_noise_kind("claim"));
2208        assert!(!monitor_is_noise_kind("decision"));
2209        assert!(!monitor_is_noise_kind("ack"));
2210        assert!(!monitor_is_noise_kind("request"));
2211        assert!(!monitor_is_noise_kind("note"));
2212        // Unknown future kinds shouldn't be filtered as noise either —
2213        // operator probably wants to see something they don't recognise,
2214        // not have it silently dropped (the P0.1 lesson at the UX layer).
2215        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
2216    }
2217
2218    #[test]
2219    fn monitor_render_plain_is_one_short_line() {
2220        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
2221        let line = monitor_render(&e, false).unwrap();
2222        // Must be single-line.
2223        assert!(!line.contains('\n'), "render must be one line: {line}");
2224        // Must include peer, kind, body fragment, short event_id.
2225        assert!(line.contains("willard"));
2226        assert!(line.contains("claim"));
2227        assert!(line.contains("real v8 train"));
2228        // Short event id (first 12 chars).
2229        assert!(line.contains("abcd12345678"));
2230        assert!(!line.contains("abcd1234567890ef"), "should truncate full id");
2231        // RFC3339-ish second precision.
2232        assert!(line.contains("2026-05-15T23:14:07"));
2233    }
2234
2235    #[test]
2236    fn monitor_render_strips_newlines_from_body() {
2237        // Multi-line bodies (markdown lists, code, etc.) must collapse to
2238        // one line — otherwise a single message produces multiple
2239        // notifications in the harness, ruining the "one event = one line"
2240        // contract the Monitor tool relies on.
2241        let e = ev("spark", "claim", "line one\nline two\nline three");
2242        let line = monitor_render(&e, false).unwrap();
2243        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
2244        assert!(line.contains("line one line two line three"));
2245    }
2246
2247    #[test]
2248    fn monitor_render_json_is_valid_jsonl() {
2249        let e = ev("spark", "claim", "hi");
2250        let line = monitor_render(&e, true).unwrap();
2251        assert!(!line.contains('\n'));
2252        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
2253        assert_eq!(parsed["peer"], "spark");
2254        assert_eq!(parsed["kind"], "claim");
2255        assert_eq!(parsed["body_preview"], "hi");
2256    }
2257
2258    #[test]
2259    fn monitor_does_not_drop_on_verified_null() {
2260        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
2261        // `select(.verified == true)` against inbox JSONL. Daemon writes
2262        // events with verified=null (verification happens at tail-time, not
2263        // write-time), so the filter silently rejected everything — same
2264        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
2265        // never surfaced for ~30min.
2266        //
2267        // wire monitor's render path must NOT consult `.verified` for any
2268        // filter decision. Lock that in here so a future "be conservative,
2269        // only emit verified" patch can't quietly land.
2270        let mut e = ev("spark", "claim", "from disk with verified=null");
2271        e.verified = false; // worst case — even if disk says unverified, emit
2272        let line = monitor_render(&e, false).unwrap();
2273        assert!(line.contains("from disk with verified=null"));
2274        // Noise filter operates purely on kind, never on verified.
2275        assert!(!monitor_is_noise_kind("claim"));
2276    }
2277}
2278
2279// ---------- verify ----------
2280
2281fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
2282    let body = if path == "-" {
2283        let mut buf = String::new();
2284        use std::io::Read;
2285        std::io::stdin().read_to_string(&mut buf)?;
2286        buf
2287    } else {
2288        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
2289    };
2290    let event: Value = serde_json::from_str(&body)?;
2291    let trust = config::read_trust()?;
2292    match verify_message_v31(&event, &trust) {
2293        Ok(()) => {
2294            if as_json {
2295                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
2296            } else {
2297                println!("verified ✓");
2298            }
2299            Ok(())
2300        }
2301        Err(e) => {
2302            let reason = e.to_string();
2303            if as_json {
2304                println!(
2305                    "{}",
2306                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
2307                );
2308            } else {
2309                eprintln!("FAILED: {reason}");
2310            }
2311            std::process::exit(1);
2312        }
2313    }
2314}
2315
2316// ---------- mcp / relay-server stubs ----------
2317
2318fn cmd_mcp() -> Result<()> {
2319    crate::mcp::run()
2320}
2321
2322fn cmd_relay_server(bind: &str) -> Result<()> {
2323    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
2324    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
2325    // so a single user can run both client and server on one machine.
2326    let state_dir = if let Ok(home) = std::env::var("WIRE_HOME") {
2327        std::path::PathBuf::from(home)
2328            .join("state")
2329            .join("wire-relay")
2330    } else {
2331        dirs::state_dir()
2332            .or_else(dirs::data_local_dir)
2333            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
2334            .join("wire-relay")
2335    };
2336    let runtime = tokio::runtime::Builder::new_multi_thread()
2337        .enable_all()
2338        .build()?;
2339    runtime.block_on(crate::relay_server::serve(bind, state_dir))
2340}
2341
2342// ---------- bind-relay ----------
2343
2344fn cmd_bind_relay(url: &str, as_json: bool) -> Result<()> {
2345    if !config::is_initialized()? {
2346        bail!("not initialized — run `wire init <handle>` first");
2347    }
2348    let card = config::read_agent_card()?;
2349    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
2350    let handle = crate::agent_card::display_handle_from_did(did).to_string();
2351
2352    let normalized = url.trim_end_matches('/');
2353    let client = crate::relay_client::RelayClient::new(normalized);
2354    client.check_healthz()?;
2355    let alloc = client.allocate_slot(Some(&handle))?;
2356    let mut state = config::read_relay_state()?;
2357    state["self"] = json!({
2358        "relay_url": url,
2359        "slot_id": alloc.slot_id,
2360        "slot_token": alloc.slot_token,
2361    });
2362    config::write_relay_state(&state)?;
2363
2364    if as_json {
2365        println!(
2366            "{}",
2367            serde_json::to_string(&json!({
2368                "relay_url": url,
2369                "slot_id": alloc.slot_id,
2370                "slot_token_present": true,
2371            }))?
2372        );
2373    } else {
2374        println!("bound to relay {url}");
2375        println!("slot_id: {}", alloc.slot_id);
2376        println!(
2377            "(slot_token written to {} mode 0600)",
2378            config::relay_state_path()?.display()
2379        );
2380    }
2381    Ok(())
2382}
2383
2384// ---------- add-peer-slot ----------
2385
2386fn cmd_add_peer_slot(
2387    handle: &str,
2388    url: &str,
2389    slot_id: &str,
2390    slot_token: &str,
2391    as_json: bool,
2392) -> Result<()> {
2393    let mut state = config::read_relay_state()?;
2394    let peers = state["peers"]
2395        .as_object_mut()
2396        .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
2397    peers.insert(
2398        handle.to_string(),
2399        json!({
2400            "relay_url": url,
2401            "slot_id": slot_id,
2402            "slot_token": slot_token,
2403        }),
2404    );
2405    config::write_relay_state(&state)?;
2406    if as_json {
2407        println!(
2408            "{}",
2409            serde_json::to_string(&json!({
2410                "handle": handle,
2411                "relay_url": url,
2412                "slot_id": slot_id,
2413                "added": true,
2414            }))?
2415        );
2416    } else {
2417        println!("pinned peer slot for {handle} at {url} ({slot_id})");
2418    }
2419    Ok(())
2420}
2421
2422// ---------- push ----------
2423
2424fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
2425    let state = config::read_relay_state()?;
2426    let peers = state["peers"].as_object().cloned().unwrap_or_default();
2427    if peers.is_empty() {
2428        bail!(
2429            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
2430        );
2431    }
2432    let outbox_dir = config::outbox_dir()?;
2433    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
2434    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
2435    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
2436    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
2437    if outbox_dir.exists() {
2438        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
2439        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
2440            let path = entry.path();
2441            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
2442                continue;
2443            }
2444            let stem = match path.file_stem().and_then(|s| s.to_str()) {
2445                Some(s) => s.to_string(),
2446                None => continue,
2447            };
2448            if pinned.contains(&stem) {
2449                continue;
2450            }
2451            // Try the bare-handle of the orphaned stem — if THAT matches a
2452            // pinned peer, the stem is a stale FQDN-suffixed file.
2453            let bare = crate::agent_card::bare_handle(&stem);
2454            if pinned.contains(bare) {
2455                eprintln!(
2456                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
2457                     Merge with: `cat {} >> {}` then delete the FQDN file.",
2458                    stem,
2459                    path.display(),
2460                    outbox_dir.join(format!("{bare}.jsonl")).display(),
2461                );
2462            }
2463        }
2464    }
2465    if !outbox_dir.exists() {
2466        if as_json {
2467            println!(
2468                "{}",
2469                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
2470            );
2471        } else {
2472            println!("phyllis: nothing to dial out — write a message first with `wire send`");
2473        }
2474        return Ok(());
2475    }
2476
2477    let mut pushed = Vec::new();
2478    let mut skipped = Vec::new();
2479
2480    for (peer_handle, slot_info) in peers.iter() {
2481        if let Some(want) = peer_filter
2482            && peer_handle != want
2483        {
2484            continue;
2485        }
2486        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
2487        if !outbox.exists() {
2488            continue;
2489        }
2490        let url = slot_info["relay_url"]
2491            .as_str()
2492            .ok_or_else(|| anyhow!("peer {peer_handle} missing relay_url"))?;
2493        let slot_id = slot_info["slot_id"]
2494            .as_str()
2495            .ok_or_else(|| anyhow!("peer {peer_handle} missing slot_id"))?;
2496        let slot_token = slot_info["slot_token"]
2497            .as_str()
2498            .ok_or_else(|| anyhow!("peer {peer_handle} missing slot_token"))?;
2499        let client = crate::relay_client::RelayClient::new(url);
2500        let body = std::fs::read_to_string(&outbox)?;
2501        for line in body.lines() {
2502            let event: Value = match serde_json::from_str(line) {
2503                Ok(v) => v,
2504                Err(_) => continue,
2505            };
2506            let event_id = event
2507                .get("event_id")
2508                .and_then(Value::as_str)
2509                .unwrap_or("")
2510                .to_string();
2511            match client.post_event(slot_id, slot_token, &event) {
2512                Ok(resp) => {
2513                    if resp.status == "duplicate" {
2514                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
2515                    } else {
2516                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
2517                    }
2518                }
2519                Err(e) => {
2520                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
2521                    // errors aren't hidden behind the topmost-context URL string.
2522                    // Issue #6 highest-impact silent-fail fix.
2523                    let reason = crate::relay_client::format_transport_error(&e);
2524                    skipped.push(
2525                        json!({"peer": peer_handle, "event_id": event_id, "reason": reason}),
2526                    );
2527                }
2528            }
2529        }
2530    }
2531
2532    if as_json {
2533        println!(
2534            "{}",
2535            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
2536        );
2537    } else {
2538        println!(
2539            "pushed {} event(s); skipped {} ({})",
2540            pushed.len(),
2541            skipped.len(),
2542            if skipped.is_empty() {
2543                "none"
2544            } else {
2545                "see --json for detail"
2546            }
2547        );
2548    }
2549    Ok(())
2550}
2551
2552// ---------- pull ----------
2553
2554fn cmd_pull(as_json: bool) -> Result<()> {
2555    let state = config::read_relay_state()?;
2556    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
2557    if self_state.is_null() {
2558        bail!("self slot not bound — run `wire bind-relay <url>` first");
2559    }
2560    let url = self_state["relay_url"]
2561        .as_str()
2562        .ok_or_else(|| anyhow!("self.relay_url missing"))?;
2563    let slot_id = self_state["slot_id"]
2564        .as_str()
2565        .ok_or_else(|| anyhow!("self.slot_id missing"))?;
2566    let slot_token = self_state["slot_token"]
2567        .as_str()
2568        .ok_or_else(|| anyhow!("self.slot_token missing"))?;
2569    let last_event_id = self_state
2570        .get("last_pulled_event_id")
2571        .and_then(Value::as_str)
2572        .map(str::to_string);
2573
2574    let client = crate::relay_client::RelayClient::new(url);
2575    let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
2576
2577    let inbox_dir = config::inbox_dir()?;
2578    config::ensure_dirs()?;
2579
2580    // P0.1 (0.5.11): cursor advancement now blocks on unknown kinds and
2581    // transient verify errors. See `pull::process_events`.
2582    let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
2583
2584    // P0.3 (0.5.11): cursor write goes through update_relay_state, which
2585    // takes an exclusive flock on relay.lock for the whole RMW. Concurrent
2586    // wire processes (multi-daemon, CLI vs daemon, CLI vs MCP) serialise
2587    // through the lock — no more racing the cursor like today's debug.
2588    if let Some(eid) = &result.advance_cursor_to {
2589        let eid = eid.clone();
2590        config::update_relay_state(|state| {
2591            if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
2592                self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
2593            }
2594            Ok(())
2595        })?;
2596    }
2597
2598    if as_json {
2599        println!(
2600            "{}",
2601            serde_json::to_string(&json!({
2602                "written": result.written,
2603                "rejected": result.rejected,
2604                "total_seen": events.len(),
2605                "cursor_blocked": result.blocked,
2606                "cursor_advanced_to": result.advance_cursor_to,
2607            }))?
2608        );
2609    } else {
2610        let blocking = result
2611            .rejected
2612            .iter()
2613            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
2614            .count();
2615        if blocking > 0 {
2616            println!(
2617                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
2618                events.len(),
2619                result.written.len(),
2620                result.rejected.len(),
2621                blocking,
2622            );
2623        } else {
2624            println!(
2625                "pulled {} event(s); wrote {}; rejected {}",
2626                events.len(),
2627                result.written.len(),
2628                result.rejected.len(),
2629            );
2630        }
2631    }
2632    Ok(())
2633}
2634
2635// ---------- rotate-slot ----------
2636
2637fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
2638    if !config::is_initialized()? {
2639        bail!("not initialized — run `wire init <handle>` first");
2640    }
2641    let mut state = config::read_relay_state()?;
2642    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
2643    if self_state.is_null() {
2644        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
2645    }
2646    let url = self_state["relay_url"]
2647        .as_str()
2648        .ok_or_else(|| anyhow!("self.relay_url missing"))?
2649        .to_string();
2650    let old_slot_id = self_state["slot_id"]
2651        .as_str()
2652        .ok_or_else(|| anyhow!("self.slot_id missing"))?
2653        .to_string();
2654    let old_slot_token = self_state["slot_token"]
2655        .as_str()
2656        .ok_or_else(|| anyhow!("self.slot_token missing"))?
2657        .to_string();
2658
2659    // Read identity to sign the announcement.
2660    let card = config::read_agent_card()?;
2661    let did = card
2662        .get("did")
2663        .and_then(Value::as_str)
2664        .unwrap_or("")
2665        .to_string();
2666    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
2667    let pk_b64 = card
2668        .get("verify_keys")
2669        .and_then(Value::as_object)
2670        .and_then(|m| m.values().next())
2671        .and_then(|v| v.get("key"))
2672        .and_then(Value::as_str)
2673        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
2674        .to_string();
2675    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
2676    let sk_seed = config::read_private_key()?;
2677
2678    // Allocate new slot on the same relay.
2679    let normalized = url.trim_end_matches('/').to_string();
2680    let client = crate::relay_client::RelayClient::new(&normalized);
2681    client
2682        .check_healthz()
2683        .context("aborting rotation; old slot still valid")?;
2684    let alloc = client.allocate_slot(Some(&handle))?;
2685    let new_slot_id = alloc.slot_id.clone();
2686    let new_slot_token = alloc.slot_token.clone();
2687
2688    // Optionally announce the rotation to every paired peer via the OLD slot.
2689    // Each peer's recipient-side `wire pull` will pick up this event before
2690    // their daemon next polls the new slot — but auto-update of peer's
2691    // relay.json from a wire_close event is a v0.2 daemon feature; for now
2692    // peers see the event and an operator must manually `add-peer-slot` the
2693    // new coords, OR re-pair via SAS.
2694    let mut announced: Vec<String> = Vec::new();
2695    if !no_announce {
2696        let now = time::OffsetDateTime::now_utc()
2697            .format(&time::format_description::well_known::Rfc3339)
2698            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2699        let body = json!({
2700            "reason": "operator-initiated slot rotation",
2701            "new_relay_url": url,
2702            "new_slot_id": new_slot_id,
2703            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
2704            // In v0.1 slot tokens are bilateral-shared, so peer can post via
2705            // existing add-peer-slot flow if operator chooses to re-issue.
2706        });
2707        let peers = state["peers"].as_object().cloned().unwrap_or_default();
2708        for (peer_handle, _peer_info) in peers.iter() {
2709            let event = json!({
2710                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
2711                "timestamp": now.clone(),
2712                "from": did,
2713                "to": format!("did:wire:{peer_handle}"),
2714                "type": "wire_close",
2715                "kind": 1201,
2716                "body": body.clone(),
2717            });
2718            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
2719                Ok(s) => s,
2720                Err(e) => {
2721                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
2722                    continue;
2723                }
2724            };
2725            // Post to OUR old slot (we're announcing on our own slot, NOT
2726            // peer's slot — peer reads from us). Wait, this is wrong: peers
2727            // read from THEIR OWN slot via wire pull. To reach peer A, we
2728            // post to peer A's slot. Use the existing per-peer slot mapping.
2729            let peer_info = match state["peers"].get(peer_handle) {
2730                Some(p) => p.clone(),
2731                None => continue,
2732            };
2733            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
2734            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
2735            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
2736            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
2737                continue;
2738            }
2739            let peer_client = if peer_url == url {
2740                client.clone()
2741            } else {
2742                crate::relay_client::RelayClient::new(peer_url)
2743            };
2744            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
2745                Ok(_) => announced.push(peer_handle.clone()),
2746                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
2747            }
2748        }
2749    }
2750
2751    // Swap the self-slot to the new one.
2752    state["self"] = json!({
2753        "relay_url": url,
2754        "slot_id": new_slot_id,
2755        "slot_token": new_slot_token,
2756    });
2757    config::write_relay_state(&state)?;
2758
2759    if as_json {
2760        println!(
2761            "{}",
2762            serde_json::to_string(&json!({
2763                "rotated": true,
2764                "old_slot_id": old_slot_id,
2765                "new_slot_id": new_slot_id,
2766                "relay_url": url,
2767                "announced_to": announced,
2768            }))?
2769        );
2770    } else {
2771        println!("rotated slot on {url}");
2772        println!(
2773            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
2774        );
2775        println!("  new slot_id: {new_slot_id}");
2776        if !announced.is_empty() {
2777            println!(
2778                "  announced wire_close (kind=1201) to: {}",
2779                announced.join(", ")
2780            );
2781        }
2782        println!();
2783        println!("next steps:");
2784        println!("  - peers see the wire_close event in their next `wire pull`");
2785        println!(
2786            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
2787        );
2788        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
2789        println!("  - until they do, you'll receive but they won't be able to reach you");
2790        // Suppress unused warning
2791        let _ = old_slot_token;
2792    }
2793    Ok(())
2794}
2795
2796// ---------- forget-peer ----------
2797
2798fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
2799    let mut trust = config::read_trust()?;
2800    let mut removed_from_trust = false;
2801    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
2802        && agents.remove(handle).is_some()
2803    {
2804        removed_from_trust = true;
2805    }
2806    config::write_trust(&trust)?;
2807
2808    let mut state = config::read_relay_state()?;
2809    let mut removed_from_relay = false;
2810    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
2811        && peers.remove(handle).is_some()
2812    {
2813        removed_from_relay = true;
2814    }
2815    config::write_relay_state(&state)?;
2816
2817    let mut purged: Vec<String> = Vec::new();
2818    if purge {
2819        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
2820            let path = dir.join(format!("{handle}.jsonl"));
2821            if path.exists() {
2822                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
2823                purged.push(path.to_string_lossy().into());
2824            }
2825        }
2826    }
2827
2828    if !removed_from_trust && !removed_from_relay {
2829        if as_json {
2830            println!(
2831                "{}",
2832                serde_json::to_string(&json!({
2833                    "removed": false,
2834                    "reason": format!("peer {handle:?} not pinned"),
2835                }))?
2836            );
2837        } else {
2838            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
2839        }
2840        return Ok(());
2841    }
2842
2843    if as_json {
2844        println!(
2845            "{}",
2846            serde_json::to_string(&json!({
2847                "handle": handle,
2848                "removed_from_trust": removed_from_trust,
2849                "removed_from_relay_state": removed_from_relay,
2850                "purged_files": purged,
2851            }))?
2852        );
2853    } else {
2854        println!("forgot peer {handle:?}");
2855        if removed_from_trust {
2856            println!("  - removed from trust.json");
2857        }
2858        if removed_from_relay {
2859            println!("  - removed from relay.json");
2860        }
2861        if !purged.is_empty() {
2862            for p in &purged {
2863                println!("  - deleted {p}");
2864            }
2865        } else if !purge {
2866            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
2867        }
2868    }
2869    Ok(())
2870}
2871
2872// ---------- daemon (long-lived push+pull sync) ----------
2873
2874fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
2875    if !config::is_initialized()? {
2876        bail!("not initialized — run `wire init <handle>` first");
2877    }
2878    let interval = std::time::Duration::from_secs(interval_secs.max(1));
2879
2880    if !as_json {
2881        if once {
2882            eprintln!("wire daemon: single sync cycle, then exit");
2883        } else {
2884            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
2885        }
2886    }
2887
2888    // Recover from prior crash: any pending pair in transient state had its
2889    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
2890    // the relay slots and mark the files so the operator can re-issue.
2891    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
2892        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
2893    }
2894
2895    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
2896    // to our slot, the subscriber signals `wake_rx`; we use it as the
2897    // sleep-or-wake gate of the polling loop. Polling stays as the
2898    // safety net — stream errors fall back transparently to the existing
2899    // interval-based cadence.
2900    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
2901    if !once {
2902        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
2903    }
2904
2905    loop {
2906        let pushed = run_sync_push().unwrap_or_else(|e| {
2907            eprintln!("daemon: push error: {e:#}");
2908            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
2909        });
2910        let pulled = run_sync_pull().unwrap_or_else(|e| {
2911            eprintln!("daemon: pull error: {e:#}");
2912            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
2913        });
2914        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
2915            eprintln!("daemon: pending-pair tick error: {e:#}");
2916            json!({"transitions": []})
2917        });
2918
2919        if as_json {
2920            println!(
2921                "{}",
2922                serde_json::to_string(&json!({
2923                    "ts": time::OffsetDateTime::now_utc()
2924                        .format(&time::format_description::well_known::Rfc3339)
2925                        .unwrap_or_default(),
2926                    "push": pushed,
2927                    "pull": pulled,
2928                    "pairs": pairs,
2929                }))?
2930            );
2931        } else {
2932            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
2933            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
2934            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
2935            let pair_transitions = pairs["transitions"]
2936                .as_array()
2937                .map(|a| a.len())
2938                .unwrap_or(0);
2939            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
2940                eprintln!(
2941                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
2942                );
2943            }
2944            // Loud per-transition logging so operator sees pair progress live.
2945            if let Some(arr) = pairs["transitions"].as_array() {
2946                for t in arr {
2947                    eprintln!(
2948                        "  pair {} : {} → {}",
2949                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
2950                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
2951                        t.get("to").and_then(Value::as_str).unwrap_or("?")
2952                    );
2953                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
2954                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
2955                    {
2956                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
2957                        eprintln!(
2958                            "    Run: wire pair-confirm {} {}",
2959                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
2960                            sas
2961                        );
2962                    }
2963                }
2964            }
2965        }
2966
2967        if once {
2968            return Ok(());
2969        }
2970        // Wait either for the next poll-interval tick OR for a stream
2971        // wake signal — whichever comes first. Drain any additional
2972        // wake-ups that accumulated during the previous cycle since one
2973        // pull catches up everything.
2974        let _ = wake_rx.recv_timeout(interval);
2975        while wake_rx.try_recv().is_ok() {}
2976    }
2977}
2978
2979/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
2980/// shape `wire push --json` emits.
2981fn run_sync_push() -> Result<Value> {
2982    let state = config::read_relay_state()?;
2983    let peers = state["peers"].as_object().cloned().unwrap_or_default();
2984    if peers.is_empty() {
2985        return Ok(json!({"pushed": [], "skipped": []}));
2986    }
2987    let outbox_dir = config::outbox_dir()?;
2988    if !outbox_dir.exists() {
2989        return Ok(json!({"pushed": [], "skipped": []}));
2990    }
2991    let mut pushed = Vec::new();
2992    let mut skipped = Vec::new();
2993    for (peer_handle, slot_info) in peers.iter() {
2994        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
2995        if !outbox.exists() {
2996            continue;
2997        }
2998        let url = slot_info["relay_url"].as_str().unwrap_or("");
2999        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
3000        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
3001        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
3002            continue;
3003        }
3004        let client = crate::relay_client::RelayClient::new(url);
3005        let body = std::fs::read_to_string(&outbox)?;
3006        for line in body.lines() {
3007            let event: Value = match serde_json::from_str(line) {
3008                Ok(v) => v,
3009                Err(_) => continue,
3010            };
3011            let event_id = event
3012                .get("event_id")
3013                .and_then(Value::as_str)
3014                .unwrap_or("")
3015                .to_string();
3016            match client.post_event(slot_id, slot_token, &event) {
3017                Ok(resp) => {
3018                    if resp.status == "duplicate" {
3019                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
3020                    } else {
3021                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
3022                    }
3023                }
3024                Err(e) => {
3025                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
3026                    // errors aren't hidden behind the topmost-context URL string.
3027                    // Issue #6 highest-impact silent-fail fix.
3028                    let reason = crate::relay_client::format_transport_error(&e);
3029                    skipped.push(
3030                        json!({"peer": peer_handle, "event_id": event_id, "reason": reason}),
3031                    );
3032                }
3033            }
3034        }
3035    }
3036    Ok(json!({"pushed": pushed, "skipped": skipped}))
3037}
3038
3039/// Programmatic pull. Same shape as `wire pull --json`.
3040fn run_sync_pull() -> Result<Value> {
3041    let state = config::read_relay_state()?;
3042    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
3043    if self_state.is_null() {
3044        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
3045    }
3046    let url = self_state["relay_url"].as_str().unwrap_or("");
3047    let slot_id = self_state["slot_id"].as_str().unwrap_or("");
3048    let slot_token = self_state["slot_token"].as_str().unwrap_or("");
3049    let last_event_id = self_state
3050        .get("last_pulled_event_id")
3051        .and_then(Value::as_str)
3052        .map(str::to_string);
3053    if url.is_empty() {
3054        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
3055    }
3056    let client = crate::relay_client::RelayClient::new(url);
3057    let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
3058    let inbox_dir = config::inbox_dir()?;
3059    config::ensure_dirs()?;
3060
3061    // P0.1 (0.5.11): shared cursor-blocking logic. Daemon's --once path
3062    // must match the CLI's `wire pull` semantics or version-skew bugs
3063    // re-emerge by another route.
3064    let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
3065
3066    // P0.3 (0.5.11): same flock-protected RMW as cmd_pull.
3067    if let Some(eid) = &result.advance_cursor_to {
3068        let eid = eid.clone();
3069        config::update_relay_state(|state| {
3070            if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
3071                self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
3072            }
3073            Ok(())
3074        })?;
3075    }
3076
3077    Ok(json!({
3078        "written": result.written,
3079        "rejected": result.rejected,
3080        "total_seen": events.len(),
3081        "cursor_blocked": result.blocked,
3082        "cursor_advanced_to": result.advance_cursor_to,
3083    }))
3084}
3085
3086// ---------- pin (manual out-of-band peer pairing) ----------
3087
3088fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
3089    let body =
3090        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
3091    let card: Value =
3092        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
3093    crate::agent_card::verify_agent_card(&card)
3094        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
3095
3096    let mut trust = config::read_trust()?;
3097    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
3098
3099    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3100    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3101    config::write_trust(&trust)?;
3102
3103    if as_json {
3104        println!(
3105            "{}",
3106            serde_json::to_string(&json!({
3107                "handle": handle,
3108                "did": did,
3109                "tier": "VERIFIED",
3110                "pinned": true,
3111            }))?
3112        );
3113    } else {
3114        println!("pinned {handle} ({did}) at tier VERIFIED");
3115    }
3116    Ok(())
3117}
3118
3119// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
3120
3121fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
3122    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
3123}
3124
3125fn cmd_pair_join(
3126    code_phrase: &str,
3127    relay_url: &str,
3128    auto_yes: bool,
3129    timeout_secs: u64,
3130) -> Result<()> {
3131    pair_orchestrate(
3132        relay_url,
3133        Some(code_phrase),
3134        "guest",
3135        auto_yes,
3136        timeout_secs,
3137    )
3138}
3139
3140/// Shared orchestration for both sides of the SAS pairing.
3141///
3142/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
3143/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
3144/// `pair_session_confirm_sas` instead.
3145fn pair_orchestrate(
3146    relay_url: &str,
3147    code_in: Option<&str>,
3148    role: &str,
3149    auto_yes: bool,
3150    timeout_secs: u64,
3151) -> Result<()> {
3152    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
3153
3154    let mut s = pair_session_open(role, relay_url, code_in)?;
3155
3156    if role == "host" {
3157        eprintln!();
3158        eprintln!("share this code phrase with your peer:");
3159        eprintln!();
3160        eprintln!("    {}", s.code);
3161        eprintln!();
3162        eprintln!(
3163            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
3164            s.code
3165        );
3166    } else {
3167        eprintln!();
3168        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
3169    }
3170
3171    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
3172    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
3173    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
3174    // see the process is alive while the other side connects.
3175    const HEARTBEAT_SECS: u64 = 10;
3176    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
3177    let started = std::time::Instant::now();
3178    let mut last_heartbeat = started;
3179    let formatted = loop {
3180        if let Some(sas) = pair_session_try_sas(&mut s)? {
3181            break sas;
3182        }
3183        let now = std::time::Instant::now();
3184        if now >= deadline {
3185            return Err(anyhow!(
3186                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
3187            ));
3188        }
3189        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
3190            let elapsed = now.duration_since(started).as_secs();
3191            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
3192            last_heartbeat = now;
3193        }
3194        std::thread::sleep(std::time::Duration::from_millis(250));
3195    };
3196
3197    eprintln!();
3198    eprintln!("SAS digits (must match peer's terminal):");
3199    eprintln!();
3200    eprintln!("    {formatted}");
3201    eprintln!();
3202
3203    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
3204    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
3205    if !auto_yes {
3206        eprint!("does this match your peer's terminal? [y/N]: ");
3207        use std::io::Write;
3208        std::io::stderr().flush().ok();
3209        let mut input = String::new();
3210        std::io::stdin().read_line(&mut input)?;
3211        let trimmed = input.trim().to_lowercase();
3212        if trimmed != "y" && trimmed != "yes" {
3213            bail!("SAS confirmation declined — aborting pairing");
3214        }
3215    }
3216    s.sas_confirmed = true;
3217
3218    // Stage 4 — seal+exchange bootstrap, pin peer.
3219    let result = pair_session_finalize(&mut s, timeout_secs)?;
3220
3221    let peer_did = result["paired_with"].as_str().unwrap_or("");
3222    let peer_role = if role == "host" { "guest" } else { "host" };
3223    eprintln!("paired with {peer_did} (peer role: {peer_role})");
3224    eprintln!("peer card pinned at tier VERIFIED");
3225    eprintln!(
3226        "peer relay slot saved to {}",
3227        config::relay_state_path()?.display()
3228    );
3229
3230    println!("{}", serde_json::to_string(&result)?);
3231    Ok(())
3232}
3233
3234// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
3235// and pair_session_finalize, both of which inline their own deadline loops.)
3236
3237// ---------- pair — single-shot init + pair-* + setup ----------
3238
3239fn cmd_pair(
3240    handle: &str,
3241    code: Option<&str>,
3242    relay: &str,
3243    auto_yes: bool,
3244    timeout_secs: u64,
3245    no_setup: bool,
3246) -> Result<()> {
3247    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
3248    // bails loudly if a different handle is already set (operator must explicitly delete).
3249    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
3250    let did = init_result
3251        .get("did")
3252        .and_then(|v| v.as_str())
3253        .unwrap_or("(unknown)")
3254        .to_string();
3255    let already = init_result
3256        .get("already_initialized")
3257        .and_then(|v| v.as_bool())
3258        .unwrap_or(false);
3259    if already {
3260        println!("(identity {did} already initialized — reusing)");
3261    } else {
3262        println!("initialized {did}");
3263    }
3264    println!();
3265
3266    // Step 2 — pair-host or pair-join based on code presence.
3267    match code {
3268        None => {
3269            println!("hosting pair on {relay} (no code = host) ...");
3270            cmd_pair_host(relay, auto_yes, timeout_secs)?;
3271        }
3272        Some(c) => {
3273            println!("joining pair with code {c} on {relay} ...");
3274            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
3275        }
3276    }
3277
3278    // Step 3 — register wire as MCP server in detected client configs (idempotent).
3279    if !no_setup {
3280        println!();
3281        println!("registering wire as MCP server in detected client configs ...");
3282        if let Err(e) = cmd_setup(true) {
3283            // Non-fatal — pair succeeded, just print the warning.
3284            eprintln!("warn: setup --apply failed: {e}");
3285            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
3286        }
3287    }
3288
3289    println!();
3290    println!("pair complete. Next steps:");
3291    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
3292    println!("  wire send <peer> claim <msg>   # send your peer something");
3293    println!("  wire tail                      # watch incoming events");
3294    Ok(())
3295}
3296
3297// ---------- detached pair (daemon-orchestrated) ----------
3298
3299/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
3300/// pair-host/-join into a single command. The non-detached variant lives in
3301/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
3302fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
3303    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
3304    let did = init_result
3305        .get("did")
3306        .and_then(|v| v.as_str())
3307        .unwrap_or("(unknown)")
3308        .to_string();
3309    let already = init_result
3310        .get("already_initialized")
3311        .and_then(|v| v.as_bool())
3312        .unwrap_or(false);
3313    if already {
3314        println!("(identity {did} already initialized — reusing)");
3315    } else {
3316        println!("initialized {did}");
3317    }
3318    println!();
3319    match code {
3320        None => cmd_pair_host_detach(relay, false),
3321        Some(c) => cmd_pair_join_detach(c, relay, false),
3322    }
3323}
3324
3325fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
3326    if !config::is_initialized()? {
3327        bail!("not initialized — run `wire init <handle>` first");
3328    }
3329    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
3330        Ok(b) => b,
3331        Err(e) => {
3332            if !as_json {
3333                eprintln!(
3334                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
3335                );
3336            }
3337            false
3338        }
3339    };
3340    let code = crate::sas::generate_code_phrase();
3341    let code_hash = crate::pair_session::derive_code_hash(&code);
3342    let now = time::OffsetDateTime::now_utc()
3343        .format(&time::format_description::well_known::Rfc3339)
3344        .unwrap_or_default();
3345    let p = crate::pending_pair::PendingPair {
3346        code: code.clone(),
3347        code_hash,
3348        role: "host".to_string(),
3349        relay_url: relay_url.to_string(),
3350        status: "request_host".to_string(),
3351        sas: None,
3352        peer_did: None,
3353        created_at: now,
3354        last_error: None,
3355        pair_id: None,
3356        our_slot_id: None,
3357        our_slot_token: None,
3358        spake2_seed_b64: None,
3359    };
3360    crate::pending_pair::write_pending(&p)?;
3361    if as_json {
3362        println!(
3363            "{}",
3364            serde_json::to_string(&json!({
3365                "state": "queued",
3366                "code_phrase": code,
3367                "relay_url": relay_url,
3368                "role": "host",
3369                "daemon_spawned": daemon_spawned,
3370            }))?
3371        );
3372    } else {
3373        if daemon_spawned {
3374            println!("(started wire daemon in background)");
3375        }
3376        println!("detached pair-host queued. Share this code with your peer:\n");
3377        println!("    {code}\n");
3378        println!("Next steps:");
3379        println!("  wire pair-list                                # check status");
3380        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
3381        println!("  wire pair-cancel  {code}            # to abort");
3382    }
3383    Ok(())
3384}
3385
3386fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
3387    if !config::is_initialized()? {
3388        bail!("not initialized — run `wire init <handle>` first");
3389    }
3390    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
3391        Ok(b) => b,
3392        Err(e) => {
3393            if !as_json {
3394                eprintln!(
3395                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
3396                );
3397            }
3398            false
3399        }
3400    };
3401    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3402    let code_hash = crate::pair_session::derive_code_hash(&code);
3403    let now = time::OffsetDateTime::now_utc()
3404        .format(&time::format_description::well_known::Rfc3339)
3405        .unwrap_or_default();
3406    let p = crate::pending_pair::PendingPair {
3407        code: code.clone(),
3408        code_hash,
3409        role: "guest".to_string(),
3410        relay_url: relay_url.to_string(),
3411        status: "request_guest".to_string(),
3412        sas: None,
3413        peer_did: None,
3414        created_at: now,
3415        last_error: None,
3416        pair_id: None,
3417        our_slot_id: None,
3418        our_slot_token: None,
3419        spake2_seed_b64: None,
3420    };
3421    crate::pending_pair::write_pending(&p)?;
3422    if as_json {
3423        println!(
3424            "{}",
3425            serde_json::to_string(&json!({
3426                "state": "queued",
3427                "code_phrase": code,
3428                "relay_url": relay_url,
3429                "role": "guest",
3430                "daemon_spawned": daemon_spawned,
3431            }))?
3432        );
3433    } else {
3434        if daemon_spawned {
3435            println!("(started wire daemon in background)");
3436        }
3437        println!("detached pair-join queued for code {code}.");
3438        println!(
3439            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
3440        );
3441    }
3442    Ok(())
3443}
3444
3445fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
3446    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3447    let typed: String = typed_digits
3448        .chars()
3449        .filter(|c| c.is_ascii_digit())
3450        .collect();
3451    if typed.len() != 6 {
3452        bail!(
3453            "expected 6 digits (got {} after stripping non-digits)",
3454            typed.len()
3455        );
3456    }
3457    let mut p = crate::pending_pair::read_pending(&code)?
3458        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
3459    if p.status != "sas_ready" {
3460        bail!(
3461            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
3462            p.status
3463        );
3464    }
3465    let stored = p
3466        .sas
3467        .as_ref()
3468        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
3469        .clone();
3470    if stored == typed {
3471        p.status = "confirmed".to_string();
3472        crate::pending_pair::write_pending(&p)?;
3473        if as_json {
3474            println!(
3475                "{}",
3476                serde_json::to_string(&json!({
3477                    "state": "confirmed",
3478                    "code_phrase": code,
3479                }))?
3480            );
3481        } else {
3482            println!("digits match. Daemon will finalize the handshake on its next tick.");
3483            println!("Run `wire peers` after a few seconds to confirm.");
3484        }
3485    } else {
3486        p.status = "aborted".to_string();
3487        p.last_error = Some(format!(
3488            "SAS digit mismatch (typed {typed}, expected {stored})"
3489        ));
3490        let client = crate::relay_client::RelayClient::new(&p.relay_url);
3491        let _ = client.pair_abandon(&p.code_hash);
3492        crate::pending_pair::write_pending(&p)?;
3493        crate::os_notify::toast(
3494            &format!("wire — pair aborted ({})", p.code),
3495            p.last_error.as_deref().unwrap_or("digits mismatch"),
3496        );
3497        if as_json {
3498            println!(
3499                "{}",
3500                serde_json::to_string(&json!({
3501                    "state": "aborted",
3502                    "code_phrase": code,
3503                    "error": "digits mismatch",
3504                }))?
3505            );
3506        }
3507        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
3508    }
3509    Ok(())
3510}
3511
3512fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
3513    if watch {
3514        return cmd_pair_list_watch(watch_interval_secs);
3515    }
3516    let spake2_items = crate::pending_pair::list_pending()?;
3517    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
3518    if as_json {
3519        // Backwards-compat: flat SPAKE2 array (the shape every existing
3520        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
3521        // surface programmatically via `wire pair-list-inbound --json`
3522        // and via `wire status --json` `pending_pairs.inbound_*` fields.
3523        println!("{}", serde_json::to_string(&spake2_items)?);
3524        return Ok(());
3525    }
3526    if spake2_items.is_empty() && inbound_items.is_empty() {
3527        println!("no pending pair sessions.");
3528        return Ok(());
3529    }
3530    // v0.5.14: inbound section first — these need operator action right now.
3531    // SPAKE2 sessions are typically already mid-flow.
3532    if !inbound_items.is_empty() {
3533        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
3534        println!(
3535            "{:<20} {:<35} {:<25} NEXT STEP",
3536            "PEER", "RELAY", "RECEIVED"
3537        );
3538        for p in &inbound_items {
3539            println!(
3540                "{:<20} {:<35} {:<25} `wire add {peer}@{relay_host}` to accept; `wire pair-reject {peer}` to refuse",
3541                p.peer_handle,
3542                p.peer_relay_url,
3543                p.received_at,
3544                peer = p.peer_handle,
3545                relay_host = p
3546                    .peer_relay_url
3547                    .trim_start_matches("https://")
3548                    .trim_start_matches("http://")
3549                    .trim_end_matches('/'),
3550            );
3551        }
3552        println!();
3553    }
3554    if !spake2_items.is_empty() {
3555        println!("SPAKE2 SESSIONS");
3556        println!(
3557            "{:<15} {:<8} {:<18} {:<10} NOTE",
3558            "CODE", "ROLE", "STATUS", "SAS"
3559        );
3560        for p in spake2_items {
3561            let sas = p
3562                .sas
3563                .as_ref()
3564                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
3565                .unwrap_or_else(|| "—".to_string());
3566            let note = p
3567                .last_error
3568                .as_deref()
3569                .or(p.peer_did.as_deref())
3570                .unwrap_or("");
3571            println!(
3572                "{:<15} {:<8} {:<18} {:<10} {}",
3573                p.code, p.role, p.status, sas, note
3574            );
3575        }
3576    }
3577    Ok(())
3578}
3579
3580/// Stream-mode pair-list: never exits. Diffs per-code state every
3581/// `interval_secs` and prints one JSON line per transition (creation,
3582/// status flip, deletion). Useful for shell pipelines:
3583///
3584/// ```text
3585/// wire pair-list --watch | while read line; do
3586///     CODE=$(echo "$line" | jq -r .code)
3587///     STATUS=$(echo "$line" | jq -r .status)
3588///     ...
3589/// done
3590/// ```
3591fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
3592    use std::collections::HashMap;
3593    use std::io::Write;
3594    let interval = std::time::Duration::from_secs(interval_secs.max(1));
3595    // Emit a snapshot synthetic event for every currently-pending pair on
3596    // startup so a consumer that arrives mid-flight sees the current state.
3597    let mut prev: HashMap<String, String> = HashMap::new();
3598    {
3599        let items = crate::pending_pair::list_pending()?;
3600        for p in &items {
3601            println!("{}", serde_json::to_string(&p)?);
3602            prev.insert(p.code.clone(), p.status.clone());
3603        }
3604        // Flush so the consumer's `while read` gets the snapshot promptly.
3605        let _ = std::io::stdout().flush();
3606    }
3607    loop {
3608        std::thread::sleep(interval);
3609        let items = match crate::pending_pair::list_pending() {
3610            Ok(v) => v,
3611            Err(_) => continue,
3612        };
3613        let mut cur: HashMap<String, String> = HashMap::new();
3614        for p in &items {
3615            cur.insert(p.code.clone(), p.status.clone());
3616            match prev.get(&p.code) {
3617                None => {
3618                    // New code appeared.
3619                    println!("{}", serde_json::to_string(&p)?);
3620                }
3621                Some(prev_status) if prev_status != &p.status => {
3622                    // Status flipped.
3623                    println!("{}", serde_json::to_string(&p)?);
3624                }
3625                _ => {}
3626            }
3627        }
3628        for code in prev.keys() {
3629            if !cur.contains_key(code) {
3630                // File disappeared → finalized or cancelled. Emit a synthetic
3631                // "removed" marker so the consumer sees the terminal event.
3632                println!(
3633                    "{}",
3634                    serde_json::to_string(&json!({
3635                        "code": code,
3636                        "status": "removed",
3637                        "_synthetic": true,
3638                    }))?
3639                );
3640            }
3641        }
3642        let _ = std::io::stdout().flush();
3643        prev = cur;
3644    }
3645}
3646
3647/// Block until a pending pair reaches `target_status` or terminates. Process
3648/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
3649/// timeout) so shell scripts can branch directly.
3650fn cmd_pair_watch(
3651    code_phrase: &str,
3652    target_status: &str,
3653    timeout_secs: u64,
3654    as_json: bool,
3655) -> Result<()> {
3656    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3657    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
3658    let mut last_seen_status: Option<String> = None;
3659    loop {
3660        let p_opt = crate::pending_pair::read_pending(&code)?;
3661        let now = std::time::Instant::now();
3662        match p_opt {
3663            None => {
3664                // File gone — either finalized (success if target=sas_ready
3665                // since finalization implies it passed sas_ready) or never
3666                // existed. Distinguish by whether we ever saw it.
3667                if last_seen_status.is_some() {
3668                    if as_json {
3669                        println!(
3670                            "{}",
3671                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
3672                        );
3673                    } else {
3674                        println!("pair {code} finalized (file removed)");
3675                    }
3676                    return Ok(());
3677                } else {
3678                    if as_json {
3679                        println!(
3680                            "{}",
3681                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
3682                        );
3683                    }
3684                    std::process::exit(1);
3685                }
3686            }
3687            Some(p) => {
3688                let cur = p.status.clone();
3689                if Some(cur.clone()) != last_seen_status {
3690                    if as_json {
3691                        // Emit per-transition line so scripts can stream.
3692                        println!("{}", serde_json::to_string(&p)?);
3693                    }
3694                    last_seen_status = Some(cur.clone());
3695                }
3696                if cur == target_status {
3697                    if !as_json {
3698                        let sas_str = p
3699                            .sas
3700                            .as_ref()
3701                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
3702                            .unwrap_or_else(|| "—".to_string());
3703                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
3704                    }
3705                    return Ok(());
3706                }
3707                if cur == "aborted" || cur == "aborted_restart" {
3708                    if !as_json {
3709                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
3710                        eprintln!("pair {code} {cur}: {err}");
3711                    }
3712                    std::process::exit(1);
3713                }
3714            }
3715        }
3716        if now >= deadline {
3717            if !as_json {
3718                eprintln!(
3719                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
3720                );
3721            }
3722            std::process::exit(2);
3723        }
3724        std::thread::sleep(std::time::Duration::from_millis(250));
3725    }
3726}
3727
3728fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
3729    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3730    let p = crate::pending_pair::read_pending(&code)?
3731        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
3732    let client = crate::relay_client::RelayClient::new(&p.relay_url);
3733    let _ = client.pair_abandon(&p.code_hash);
3734    crate::pending_pair::delete_pending(&code)?;
3735    if as_json {
3736        println!(
3737            "{}",
3738            serde_json::to_string(&json!({
3739                "state": "cancelled",
3740                "code_phrase": code,
3741            }))?
3742        );
3743    } else {
3744        println!("cancelled pending pair {code} (relay slot released, file removed).");
3745    }
3746    Ok(())
3747}
3748
3749// ---------- pair-abandon — release stuck pair-slot ----------
3750
3751fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
3752    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
3753    // typed — normalize via the existing parser.
3754    let code = crate::sas::parse_code_phrase(code_phrase)?;
3755    let code_hash = crate::pair_session::derive_code_hash(code);
3756    let client = crate::relay_client::RelayClient::new(relay_url);
3757    client.pair_abandon(&code_hash)?;
3758    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
3759    println!("host can now issue a fresh code; guest can re-join.");
3760    Ok(())
3761}
3762
3763// ---------- invite / accept — one-paste pair (v0.4.0) ----------
3764
3765fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
3766    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
3767
3768    // If --share, register the invite at the relay's short-URL endpoint and
3769    // build the one-curl onboarding line for the peer to paste.
3770    let share_payload: Option<Value> = if share {
3771        let client = reqwest::blocking::Client::new();
3772        let single_use = if uses == 1 { Some(1u32) } else { None };
3773        let body = json!({
3774            "invite_url": url,
3775            "ttl_seconds": ttl,
3776            "uses": single_use,
3777        });
3778        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
3779        let resp = client.post(&endpoint).json(&body).send()?;
3780        if !resp.status().is_success() {
3781            let code = resp.status();
3782            let txt = resp.text().unwrap_or_default();
3783            bail!("relay {code} on /v1/invite/register: {txt}");
3784        }
3785        let parsed: Value = resp.json()?;
3786        let token = parsed
3787            .get("token")
3788            .and_then(Value::as_str)
3789            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
3790            .to_string();
3791        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
3792        let curl_line = format!("curl -fsSL {share_url} | sh");
3793        Some(json!({
3794            "token": token,
3795            "share_url": share_url,
3796            "curl": curl_line,
3797            "expires_unix": parsed.get("expires_unix"),
3798        }))
3799    } else {
3800        None
3801    };
3802
3803    if as_json {
3804        let mut out = json!({
3805            "invite_url": url,
3806            "ttl_secs": ttl,
3807            "uses": uses,
3808            "relay": relay,
3809        });
3810        if let Some(s) = &share_payload {
3811            out["share"] = s.clone();
3812        }
3813        println!("{}", serde_json::to_string(&out)?);
3814    } else if let Some(s) = share_payload {
3815        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
3816        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
3817        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
3818        println!("{curl}");
3819    } else {
3820        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
3821        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
3822        println!("{url}");
3823    }
3824    Ok(())
3825}
3826
3827fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
3828    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
3829    // resolve it to the underlying wire://pair?... URL via ?format=url before
3830    // accepting. Saves them from having to know which URL shape goes where.
3831    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
3832        let sep = if url.contains('?') { '&' } else { '?' };
3833        let resolve_url = format!("{url}{sep}format=url");
3834        let client = reqwest::blocking::Client::new();
3835        let resp = client
3836            .get(&resolve_url)
3837            .send()
3838            .with_context(|| format!("GET {resolve_url}"))?;
3839        if !resp.status().is_success() {
3840            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
3841        }
3842        let body = resp.text().unwrap_or_default().trim().to_string();
3843        if !body.starts_with("wire://pair?") {
3844            bail!(
3845                "short URL {url} did not resolve to a wire:// invite. \
3846                 (got: {}{})",
3847                body.chars().take(80).collect::<String>(),
3848                if body.chars().count() > 80 { "…" } else { "" }
3849            );
3850        }
3851        body
3852    } else {
3853        url.to_string()
3854    };
3855
3856    let result = crate::pair_invite::accept_invite(&resolved)?;
3857    if as_json {
3858        println!("{}", serde_json::to_string(&result)?);
3859    } else {
3860        let did = result
3861            .get("paired_with")
3862            .and_then(Value::as_str)
3863            .unwrap_or("?");
3864        println!("paired with {did}");
3865        println!(
3866            "you can now: wire send {} <kind> <body>",
3867            crate::agent_card::display_handle_from_did(did)
3868        );
3869    }
3870    Ok(())
3871}
3872
3873// ---------- whois / profile (v0.5) ----------
3874
3875fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
3876    if let Some(h) = handle {
3877        let parsed = crate::pair_profile::parse_handle(h)?;
3878        // Special-case: if the supplied handle matches our own, skip the
3879        // network round-trip and print local.
3880        if config::is_initialized()? {
3881            let card = config::read_agent_card()?;
3882            let local_handle = card
3883                .get("profile")
3884                .and_then(|p| p.get("handle"))
3885                .and_then(Value::as_str)
3886                .map(str::to_string);
3887            if local_handle.as_deref() == Some(h) {
3888                return cmd_whois(None, as_json, None);
3889            }
3890        }
3891        // Remote resolution via .well-known/wire/agent on the handle's domain.
3892        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
3893        if as_json {
3894            println!("{}", serde_json::to_string(&resolved)?);
3895        } else {
3896            print_resolved_profile(&resolved);
3897        }
3898        return Ok(());
3899    }
3900    let card = config::read_agent_card()?;
3901    if as_json {
3902        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
3903        println!(
3904            "{}",
3905            serde_json::to_string(&json!({
3906                "did": card.get("did").cloned().unwrap_or(Value::Null),
3907                "profile": profile,
3908            }))?
3909        );
3910    } else {
3911        print!("{}", crate::pair_profile::render_self_summary()?);
3912    }
3913    Ok(())
3914}
3915
3916fn print_resolved_profile(resolved: &Value) {
3917    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
3918    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
3919    let relay = resolved
3920        .get("relay_url")
3921        .and_then(Value::as_str)
3922        .unwrap_or("");
3923    let slot = resolved
3924        .get("slot_id")
3925        .and_then(Value::as_str)
3926        .unwrap_or("");
3927    let profile = resolved
3928        .get("card")
3929        .and_then(|c| c.get("profile"))
3930        .cloned()
3931        .unwrap_or(Value::Null);
3932    println!("{did}");
3933    println!("  nick:         {nick}");
3934    if !relay.is_empty() {
3935        println!("  relay_url:    {relay}");
3936    }
3937    if !slot.is_empty() {
3938        println!("  slot_id:      {slot}");
3939    }
3940    let pick =
3941        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
3942    if let Some(s) = pick("display_name") {
3943        println!("  display_name: {s}");
3944    }
3945    if let Some(s) = pick("emoji") {
3946        println!("  emoji:        {s}");
3947    }
3948    if let Some(s) = pick("motto") {
3949        println!("  motto:        {s}");
3950    }
3951    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
3952        let joined: Vec<String> = arr
3953            .iter()
3954            .filter_map(|v| v.as_str().map(str::to_string))
3955            .collect();
3956        println!("  vibe:         {}", joined.join(", "));
3957    }
3958    if let Some(s) = pick("pronouns") {
3959        println!("  pronouns:     {s}");
3960    }
3961}
3962
3963/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
3964/// signed pair_drop event with our card + slot coords, deliver via the
3965/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
3966/// Peer's daemon completes the bilateral pin on its next pull and emits a
3967/// pair_drop_ack carrying their slot_token so we can send back.
3968fn cmd_add(handle_arg: &str, relay_override: Option<&str>, as_json: bool) -> Result<()> {
3969    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
3970
3971    // 1. Auto-init self if needed + ensure a relay slot.
3972    let (our_did, our_relay, our_slot_id, our_slot_token) =
3973        crate::pair_invite::ensure_self_with_relay(relay_override)?;
3974    if our_did == format!("did:wire:{}", parsed.nick) {
3975        // Lazy guard — actual self-add would also be caught by FCFS later.
3976        bail!("refusing to add self (handle matches own DID)");
3977    }
3978
3979    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
3980    // already sitting in pending-inbound, the operator is now accepting it.
3981    // Pin trust, save relay coords + slot_token from the stored drop, ship
3982    // our own slot_token back via pair_drop_ack, delete the pending record.
3983    //
3984    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
3985    // receiver-side auto-promote was removed there; operator consent flows
3986    // through here. After this branch returns, both sides are bilaterally
3987    // pinned and capability flows in both directions.
3988    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
3989        return cmd_add_accept_pending(
3990            handle_arg,
3991            &parsed.nick,
3992            &pending,
3993            &our_relay,
3994            &our_slot_id,
3995            &our_slot_token,
3996            as_json,
3997        );
3998    }
3999
4000    // 2. Resolve peer via .well-known on their relay.
4001    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
4002    let peer_card = resolved
4003        .get("card")
4004        .cloned()
4005        .ok_or_else(|| anyhow!("resolved missing card"))?;
4006    let peer_did = resolved
4007        .get("did")
4008        .and_then(Value::as_str)
4009        .ok_or_else(|| anyhow!("resolved missing did"))?
4010        .to_string();
4011    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
4012    let peer_slot_id = resolved
4013        .get("slot_id")
4014        .and_then(Value::as_str)
4015        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
4016        .to_string();
4017    let peer_relay = resolved
4018        .get("relay_url")
4019        .and_then(Value::as_str)
4020        .map(str::to_string)
4021        .or_else(|| relay_override.map(str::to_string))
4022        .unwrap_or_else(|| format!("https://{}", parsed.domain));
4023
4024    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
4025    let mut trust = config::read_trust()?;
4026    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
4027    config::write_trust(&trust)?;
4028    let mut relay_state = config::read_relay_state()?;
4029    let existing_token = relay_state
4030        .get("peers")
4031        .and_then(|p| p.get(&peer_handle))
4032        .and_then(|p| p.get("slot_token"))
4033        .and_then(Value::as_str)
4034        .map(str::to_string)
4035        .unwrap_or_default();
4036    relay_state["peers"][&peer_handle] = json!({
4037        "relay_url": peer_relay,
4038        "slot_id": peer_slot_id,
4039        "slot_token": existing_token, // empty until pair_drop_ack lands
4040    });
4041    config::write_relay_state(&relay_state)?;
4042
4043    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
4044    // is the v0.5 zero-paste open-mode path).
4045    let our_card = config::read_agent_card()?;
4046    let sk_seed = config::read_private_key()?;
4047    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
4048    let pk_b64 = our_card
4049        .get("verify_keys")
4050        .and_then(Value::as_object)
4051        .and_then(|m| m.values().next())
4052        .and_then(|v| v.get("key"))
4053        .and_then(Value::as_str)
4054        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
4055    let pk_bytes = crate::signing::b64decode(pk_b64)?;
4056    let now = time::OffsetDateTime::now_utc()
4057        .format(&time::format_description::well_known::Rfc3339)
4058        .unwrap_or_default();
4059    let event = json!({
4060        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4061        "timestamp": now,
4062        "from": our_did,
4063        "to": peer_did,
4064        "type": "pair_drop",
4065        "kind": 1100u32,
4066        "body": {
4067            "card": our_card,
4068            "relay_url": our_relay,
4069            "slot_id": our_slot_id,
4070            "slot_token": our_slot_token,
4071        },
4072    });
4073    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
4074
4075    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
4076    let client = crate::relay_client::RelayClient::new(&peer_relay);
4077    let resp = client.handle_intro(&parsed.nick, &signed)?;
4078    let event_id = signed
4079        .get("event_id")
4080        .and_then(Value::as_str)
4081        .unwrap_or("")
4082        .to_string();
4083
4084    if as_json {
4085        println!(
4086            "{}",
4087            serde_json::to_string(&json!({
4088                "handle": handle_arg,
4089                "paired_with": peer_did,
4090                "peer_handle": peer_handle,
4091                "event_id": event_id,
4092                "drop_response": resp,
4093                "status": "drop_sent",
4094            }))?
4095        );
4096    } else {
4097        println!(
4098            "→ 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."
4099        );
4100    }
4101    Ok(())
4102}
4103
4104/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
4105/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
4106/// coords + slot_token from the stored drop, ship our slot_token back via
4107/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
4108/// invite-URL path (which is already bilateral by virtue of the pre-shared
4109/// nonce).
4110fn cmd_add_accept_pending(
4111    handle_arg: &str,
4112    peer_nick: &str,
4113    pending: &crate::pending_inbound_pair::PendingInboundPair,
4114    _our_relay: &str,
4115    _our_slot_id: &str,
4116    _our_slot_token: &str,
4117    as_json: bool,
4118) -> Result<()> {
4119    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
4120    //    `wire add` against this handle while a drop was waiting.
4121    let mut trust = config::read_trust()?;
4122    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
4123    config::write_trust(&trust)?;
4124
4125    // 2. Record peer's relay coords + slot_token (already shipped to us in
4126    //    the original drop body; held back until now).
4127    let mut relay_state = config::read_relay_state()?;
4128    relay_state["peers"][&pending.peer_handle] = json!({
4129        "relay_url": pending.peer_relay_url,
4130        "slot_id": pending.peer_slot_id,
4131        "slot_token": pending.peer_slot_token,
4132    });
4133    config::write_relay_state(&relay_state)?;
4134
4135    // 3. Ship our slot_token to peer via pair_drop_ack so they can write back.
4136    crate::pair_invite::send_pair_drop_ack(
4137        &pending.peer_handle,
4138        &pending.peer_relay_url,
4139        &pending.peer_slot_id,
4140        &pending.peer_slot_token,
4141    )
4142    .with_context(|| {
4143        format!(
4144            "pair_drop_ack send to {} @ {} slot {} failed",
4145            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
4146        )
4147    })?;
4148
4149    // 4. Delete the pending-inbound record now that bilateral is complete.
4150    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
4151
4152    if as_json {
4153        println!(
4154            "{}",
4155            serde_json::to_string(&json!({
4156                "handle": handle_arg,
4157                "paired_with": pending.peer_did,
4158                "peer_handle": pending.peer_handle,
4159                "status": "bilateral_accepted",
4160                "via": "pending_inbound",
4161            }))?
4162        );
4163    } else {
4164        println!(
4165            "→ 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} \"...\"`.",
4166            peer = pending.peer_handle,
4167        );
4168    }
4169    Ok(())
4170}
4171
4172/// v0.5.14: programmatic access to pending-inbound for scripts.
4173/// `wire pair-list-inbound --json` returns a flat array of records.
4174fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
4175    let items = crate::pending_inbound_pair::list_pending_inbound()?;
4176    if as_json {
4177        println!("{}", serde_json::to_string(&items)?);
4178        return Ok(());
4179    }
4180    if items.is_empty() {
4181        println!("no pending inbound pair requests.");
4182        return Ok(());
4183    }
4184    println!("{:<20} {:<35} {:<25} DID", "PEER", "RELAY", "RECEIVED");
4185    for p in items {
4186        println!(
4187            "{:<20} {:<35} {:<25} {}",
4188            p.peer_handle, p.peer_relay_url, p.received_at, p.peer_did,
4189        );
4190    }
4191    println!(
4192        "→ accept with `wire add <peer>@<relay-host>`; refuse with `wire pair-reject <peer>`."
4193    );
4194    Ok(())
4195}
4196
4197/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
4198/// without pairing. No event is sent back to the peer; their side stays
4199/// pending until they time out or the operator-side data ages out.
4200fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
4201    let nick = crate::agent_card::bare_handle(peer_nick);
4202    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
4203    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
4204
4205    if as_json {
4206        println!(
4207            "{}",
4208            serde_json::to_string(&json!({
4209                "peer": nick,
4210                "rejected": existed.is_some(),
4211                "had_pending": existed.is_some(),
4212            }))?
4213        );
4214    } else if existed.is_some() {
4215        println!("→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent.");
4216    } else {
4217        println!("no pending pair from {nick} — nothing to reject");
4218    }
4219    Ok(())
4220}
4221
4222// ---------- diag (structured trace) ----------
4223
4224fn cmd_diag(action: DiagAction) -> Result<()> {
4225    let state = config::state_dir()?;
4226    let knob = state.join("diag.enabled");
4227    let log_path = state.join("diag.jsonl");
4228    match action {
4229        DiagAction::Tail { limit, json } => {
4230            let entries = crate::diag::tail(limit);
4231            if json {
4232                for e in entries {
4233                    println!("{}", serde_json::to_string(&e)?);
4234                }
4235            } else if entries.is_empty() {
4236                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
4237            } else {
4238                for e in entries {
4239                    let ts = e["ts"].as_u64().unwrap_or(0);
4240                    let ty = e["type"].as_str().unwrap_or("?");
4241                    let pid = e["pid"].as_u64().unwrap_or(0);
4242                    let payload = e["payload"].to_string();
4243                    println!("[{ts}] pid={pid} {ty} {payload}");
4244                }
4245            }
4246        }
4247        DiagAction::Enable => {
4248            config::ensure_dirs()?;
4249            std::fs::write(&knob, "1")?;
4250            println!("wire diag: enabled at {knob:?}");
4251        }
4252        DiagAction::Disable => {
4253            if knob.exists() {
4254                std::fs::remove_file(&knob)?;
4255            }
4256            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
4257        }
4258        DiagAction::Status { json } => {
4259            let enabled = crate::diag::is_enabled();
4260            let size = std::fs::metadata(&log_path)
4261                .map(|m| m.len())
4262                .unwrap_or(0);
4263            if json {
4264                println!(
4265                    "{}",
4266                    serde_json::to_string(&serde_json::json!({
4267                        "enabled": enabled,
4268                        "log_path": log_path,
4269                        "log_size_bytes": size,
4270                    }))?
4271                );
4272            } else {
4273                println!("wire diag status");
4274                println!("  enabled:    {enabled}");
4275                println!("  log:        {log_path:?}");
4276                println!("  log size:   {size} bytes");
4277            }
4278        }
4279    }
4280    Ok(())
4281}
4282
4283// ---------- service (install / uninstall / status) ----------
4284
4285fn cmd_service(action: ServiceAction) -> Result<()> {
4286    let (report, as_json) = match action {
4287        ServiceAction::Install { json } => (crate::service::install()?, json),
4288        ServiceAction::Uninstall { json } => (crate::service::uninstall()?, json),
4289        ServiceAction::Status { json } => (crate::service::status()?, json),
4290    };
4291    if as_json {
4292        println!("{}", serde_json::to_string(&report)?);
4293    } else {
4294        println!("wire service {}", report.action);
4295        println!("  platform:  {}", report.platform);
4296        println!("  unit:      {}", report.unit_path);
4297        println!("  status:    {}", report.status);
4298        println!("  detail:    {}", report.detail);
4299    }
4300    Ok(())
4301}
4302
4303// ---------- upgrade (atomic daemon swap) ----------
4304
4305/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
4306/// fresh one from the currently-installed binary, write a new versioned
4307/// pidfile. The fix for today's exact failure mode: a daemon process that
4308/// kept running OLD binary text in memory under a symlink that had since
4309/// been repointed at a NEW binary on disk.
4310///
4311/// Idempotent. If no stale daemon is running, just starts a fresh one
4312/// (same as `wire daemon &` but with the wait-until-alive guard from
4313/// ensure_up::ensure_daemon_running).
4314///
4315/// `--check` mode reports drift without acting — lists the processes
4316/// that WOULD be killed and the binary version of each.
4317fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
4318    // 1. Identify all `wire daemon` processes.
4319    let pgrep_out = std::process::Command::new("pgrep")
4320        .args(["-f", "wire daemon"])
4321        .output();
4322    let running_pids: Vec<u32> = match pgrep_out {
4323        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
4324            .split_whitespace()
4325            .filter_map(|s| s.parse::<u32>().ok())
4326            .collect(),
4327        _ => Vec::new(),
4328    };
4329
4330    // 2. Read pidfile to surface what the daemon THINKS it is.
4331    let record = crate::ensure_up::read_pid_record("daemon");
4332    let recorded_version: Option<String> = match &record {
4333        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
4334        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
4335        _ => None,
4336    };
4337    let cli_version = env!("CARGO_PKG_VERSION").to_string();
4338
4339    if check_only {
4340        let report = json!({
4341            "running_pids": running_pids,
4342            "pidfile_version": recorded_version,
4343            "cli_version": cli_version,
4344            "would_kill": running_pids,
4345        });
4346        if as_json {
4347            println!("{}", serde_json::to_string(&report)?);
4348        } else {
4349            println!("wire upgrade --check");
4350            println!("  cli version:      {cli_version}");
4351            println!("  pidfile version:  {}", recorded_version.as_deref().unwrap_or("(missing)"));
4352            if running_pids.is_empty() {
4353                println!("  running daemons:  none");
4354            } else {
4355                let pids: Vec<String> = running_pids.iter().map(|p| p.to_string()).collect();
4356                println!("  running daemons:  pids {}", pids.join(", "));
4357                println!("  would kill all + spawn fresh");
4358            }
4359        }
4360        return Ok(());
4361    }
4362
4363    // 3. Kill every running wire daemon. Use SIGTERM first, then SIGKILL
4364    // after a brief grace period.
4365    let mut killed: Vec<u32> = Vec::new();
4366    for pid in &running_pids {
4367        // SIGTERM (15).
4368        let _ = std::process::Command::new("kill")
4369            .args(["-15", &pid.to_string()])
4370            .status();
4371        killed.push(*pid);
4372    }
4373    // Wait up to ~2s for graceful exit.
4374    if !killed.is_empty() {
4375        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
4376        loop {
4377            let still_alive: Vec<u32> = killed
4378                .iter()
4379                .copied()
4380                .filter(|p| process_alive_pid(*p))
4381                .collect();
4382            if still_alive.is_empty() {
4383                break;
4384            }
4385            if std::time::Instant::now() >= deadline {
4386                // SIGKILL hold-outs.
4387                for pid in still_alive {
4388                    let _ = std::process::Command::new("kill")
4389                        .args(["-9", &pid.to_string()])
4390                        .status();
4391                }
4392                break;
4393            }
4394            std::thread::sleep(std::time::Duration::from_millis(50));
4395        }
4396    }
4397
4398    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
4399    //    old daemon is still owning it.
4400    let pidfile = config::state_dir()?.join("daemon.pid");
4401    if pidfile.exists() {
4402        let _ = std::fs::remove_file(&pidfile);
4403    }
4404
4405    // 5. Spawn fresh daemon via ensure_up — atomically waits for
4406    //    process_alive + writes the versioned pidfile.
4407    let spawned = crate::ensure_up::ensure_daemon_running()?;
4408
4409    let new_record = crate::ensure_up::read_pid_record("daemon");
4410    let new_pid = new_record.pid();
4411    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
4412        Some(d.version.clone())
4413    } else {
4414        None
4415    };
4416
4417    if as_json {
4418        println!(
4419            "{}",
4420            serde_json::to_string(&json!({
4421                "killed": killed,
4422                "spawned_fresh_daemon": spawned,
4423                "new_pid": new_pid,
4424                "new_version": new_version,
4425                "cli_version": cli_version,
4426            }))?
4427        );
4428    } else {
4429        if killed.is_empty() {
4430            println!("wire upgrade: no stale daemons running");
4431        } else {
4432            println!("wire upgrade: killed {} daemon(s) (pids {})",
4433                killed.len(),
4434                killed.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", "));
4435        }
4436        if spawned {
4437            println!(
4438                "wire upgrade: spawned fresh daemon (pid {} v{})",
4439                new_pid.map(|p| p.to_string()).unwrap_or_else(|| "?".to_string()),
4440                new_version.as_deref().unwrap_or(&cli_version),
4441            );
4442        } else {
4443            println!("wire upgrade: daemon was already running on current binary");
4444        }
4445    }
4446    Ok(())
4447}
4448
4449fn process_alive_pid(pid: u32) -> bool {
4450    #[cfg(target_os = "linux")]
4451    {
4452        std::path::Path::new(&format!("/proc/{pid}")).exists()
4453    }
4454    #[cfg(not(target_os = "linux"))]
4455    {
4456        std::process::Command::new("kill")
4457            .args(["-0", &pid.to_string()])
4458            .stdin(std::process::Stdio::null())
4459            .stdout(std::process::Stdio::null())
4460            .stderr(std::process::Stdio::null())
4461            .status()
4462            .map(|s| s.success())
4463            .unwrap_or(false)
4464    }
4465}
4466
4467// ---------- doctor (single-command diagnostic) ----------
4468
4469/// One DoctorCheck = one verdict on one health dimension.
4470#[derive(Clone, Debug, serde::Serialize)]
4471pub struct DoctorCheck {
4472    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
4473    /// Stable across versions for tooling consumption.
4474    pub id: String,
4475    /// PASS / WARN / FAIL.
4476    pub status: String,
4477    /// One-line human summary.
4478    pub detail: String,
4479    /// Optional remediation hint shown after the failing line.
4480    #[serde(skip_serializing_if = "Option::is_none")]
4481    pub fix: Option<String>,
4482}
4483
4484impl DoctorCheck {
4485    fn pass(id: &str, detail: impl Into<String>) -> Self {
4486        Self {
4487            id: id.into(),
4488            status: "PASS".into(),
4489            detail: detail.into(),
4490            fix: None,
4491        }
4492    }
4493    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
4494        Self {
4495            id: id.into(),
4496            status: "WARN".into(),
4497            detail: detail.into(),
4498            fix: Some(fix.into()),
4499        }
4500    }
4501    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
4502        Self {
4503            id: id.into(),
4504            status: "FAIL".into(),
4505            detail: detail.into(),
4506            fix: Some(fix.into()),
4507        }
4508    }
4509}
4510
4511/// `wire doctor` — single-command diagnostic for the silent-fail classes
4512/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
4513/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
4514/// so operators don't have to know where each lives.
4515fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
4516    let mut checks: Vec<DoctorCheck> = Vec::new();
4517
4518    checks.push(check_daemon_health());
4519    checks.push(check_daemon_pid_consistency());
4520    checks.push(check_relay_reachable());
4521    checks.push(check_pair_rejections(recent_rejections));
4522    checks.push(check_cursor_progress());
4523
4524    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
4525    let warns = checks.iter().filter(|c| c.status == "WARN").count();
4526
4527    if as_json {
4528        println!(
4529            "{}",
4530            serde_json::to_string(&json!({
4531                "checks": checks,
4532                "fail_count": fails,
4533                "warn_count": warns,
4534                "ok": fails == 0,
4535            }))?
4536        );
4537    } else {
4538        println!("wire doctor — {} checks", checks.len());
4539        for c in &checks {
4540            let bullet = match c.status.as_str() {
4541                "PASS" => "✓",
4542                "WARN" => "!",
4543                "FAIL" => "✗",
4544                _ => "?",
4545            };
4546            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
4547            if let Some(fix) = &c.fix {
4548                println!("      fix: {fix}");
4549            }
4550        }
4551        println!();
4552        if fails == 0 && warns == 0 {
4553            println!("ALL GREEN");
4554        } else {
4555            println!("{fails} FAIL, {warns} WARN");
4556        }
4557    }
4558
4559    if fails > 0 {
4560        std::process::exit(1);
4561    }
4562    Ok(())
4563}
4564
4565/// Check: daemon running, exactly one instance, no orphans.
4566///
4567/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
4568/// days, advancing cursor without pinning). `wire status` lied about it.
4569/// `wire doctor` must catch THIS class: multiple daemons running, OR
4570/// pid-file claims daemon down while a process is actually up.
4571fn check_daemon_health() -> DoctorCheck {
4572    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
4573    // `wire status` reported DOWN, disagreeing for 25 min. Doctor used
4574    // pgrep alone; status cross-checked the pidfile. Doctor now consults
4575    // BOTH so the two surfaces never disagree.
4576    let output = std::process::Command::new("pgrep")
4577        .args(["-f", "wire daemon"])
4578        .output();
4579    let pgrep_pids: Vec<u32> = match output {
4580        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
4581            .split_whitespace()
4582            .filter_map(|s| s.parse::<u32>().ok())
4583            .collect(),
4584        _ => Vec::new(),
4585    };
4586    let pidfile_pid = crate::ensure_up::read_pid_record("daemon").pid();
4587    // Is the pidfile-claimed daemon actually alive?
4588    let pidfile_alive = pidfile_pid
4589        .map(|pid| {
4590            #[cfg(target_os = "linux")]
4591            {
4592                std::path::Path::new(&format!("/proc/{pid}")).exists()
4593            }
4594            #[cfg(not(target_os = "linux"))]
4595            {
4596                std::process::Command::new("kill")
4597                    .args(["-0", &pid.to_string()])
4598                    .output()
4599                    .map(|o| o.status.success())
4600                    .unwrap_or(false)
4601            }
4602        })
4603        .unwrap_or(false);
4604    let orphan_pids: Vec<u32> = pgrep_pids
4605        .iter()
4606        .filter(|p| Some(**p) != pidfile_pid)
4607        .copied()
4608        .collect();
4609
4610    let fmt_pids = |xs: &[u32]| -> String {
4611        xs.iter()
4612            .map(|p| p.to_string())
4613            .collect::<Vec<_>>()
4614            .join(", ")
4615    };
4616
4617    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
4618        (0, _, _) => DoctorCheck::fail(
4619            "daemon",
4620            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
4621            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
4622        ),
4623        // Single daemon AND it matches the pidfile → healthy.
4624        (1, true, true) => DoctorCheck::pass(
4625            "daemon",
4626            format!(
4627                "one daemon running (pid {}, matches pidfile)",
4628                pgrep_pids[0]
4629            ),
4630        ),
4631        // Pidfile is alive but pgrep ALSO sees orphan processes.
4632        (n, true, false) => DoctorCheck::fail(
4633            "daemon",
4634            format!(
4635                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
4636                 The orphans race the relay cursor — they advance past events your current binary can't process. \
4637                 (Issue #2 exact class.)",
4638                fmt_pids(&pgrep_pids),
4639                pidfile_pid.unwrap(),
4640                fmt_pids(&orphan_pids),
4641            ),
4642            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
4643        ),
4644        // Pidfile is dead but processes ARE running → all are orphans.
4645        (n, false, _) => DoctorCheck::fail(
4646            "daemon",
4647            format!(
4648                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
4649                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
4650                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
4651                fmt_pids(&pgrep_pids),
4652                match pidfile_pid {
4653                    Some(p) => format!("claims pid {p} which is dead"),
4654                    None => "is missing".to_string(),
4655                },
4656            ),
4657            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
4658        ),
4659        // Multiple daemons all matching … impossible by construction; fall back to warn.
4660        (n, true, true) => DoctorCheck::warn(
4661            "daemon",
4662            format!(
4663                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
4664                fmt_pids(&pgrep_pids)
4665            ),
4666            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
4667        ),
4668    }
4669}
4670
4671/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
4672/// check. Surfaces version mismatch (daemon running old binary text in
4673/// memory under a current symlink — today's exact bug class), schema
4674/// drift (future format bumps), and identity contamination (daemon's
4675/// recorded DID doesn't match this box's configured DID).
4676fn check_daemon_pid_consistency() -> DoctorCheck {
4677    let record = crate::ensure_up::read_pid_record("daemon");
4678    match record {
4679        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
4680            "daemon_pid_consistency",
4681            "no daemon.pid yet — fresh box or daemon never started",
4682        ),
4683        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
4684            "daemon_pid_consistency",
4685            format!("daemon.pid is corrupt: {reason}"),
4686            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
4687        ),
4688        crate::ensure_up::PidRecord::LegacyInt(pid) => DoctorCheck::warn(
4689            "daemon_pid_consistency",
4690            format!(
4691                "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
4692                 Daemon was started by a pre-0.5.11 binary."
4693            ),
4694            "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
4695        ),
4696        crate::ensure_up::PidRecord::Json(d) => {
4697            let mut issues: Vec<String> = Vec::new();
4698            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
4699                issues.push(format!(
4700                    "schema={} (expected {})",
4701                    d.schema,
4702                    crate::ensure_up::DAEMON_PID_SCHEMA
4703                ));
4704            }
4705            let cli_version = env!("CARGO_PKG_VERSION");
4706            if d.version != cli_version {
4707                issues.push(format!(
4708                    "version daemon={} cli={cli_version}",
4709                    d.version
4710                ));
4711            }
4712            if !std::path::Path::new(&d.bin_path).exists() {
4713                issues.push(format!("bin_path {} missing on disk", d.bin_path));
4714            }
4715            // Cross-check DID + relay against current config (best-effort).
4716            if let Ok(card) = config::read_agent_card()
4717                && let Some(current_did) = card.get("did").and_then(Value::as_str)
4718                && let Some(recorded_did) = &d.did
4719                && recorded_did != current_did
4720            {
4721                issues.push(format!(
4722                    "did daemon={recorded_did} config={current_did} — identity drift"
4723                ));
4724            }
4725            if let Ok(state) = config::read_relay_state()
4726                && let Some(current_relay) = state
4727                    .get("self")
4728                    .and_then(|s| s.get("relay_url"))
4729                    .and_then(Value::as_str)
4730                && let Some(recorded_relay) = &d.relay_url
4731                && recorded_relay != current_relay
4732            {
4733                issues.push(format!(
4734                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
4735                ));
4736            }
4737            if issues.is_empty() {
4738                DoctorCheck::pass(
4739                    "daemon_pid_consistency",
4740                    format!(
4741                        "daemon v{} bound to {} as {}",
4742                        d.version,
4743                        d.relay_url.as_deref().unwrap_or("?"),
4744                        d.did.as_deref().unwrap_or("?")
4745                    ),
4746                )
4747            } else {
4748                DoctorCheck::warn(
4749                    "daemon_pid_consistency",
4750                    format!("daemon pidfile drift: {}", issues.join("; ")),
4751                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
4752                )
4753            }
4754        }
4755    }
4756}
4757
4758/// Check: bound relay's /healthz returns 200.
4759fn check_relay_reachable() -> DoctorCheck {
4760    let state = match config::read_relay_state() {
4761        Ok(s) => s,
4762        Err(e) => return DoctorCheck::fail(
4763            "relay",
4764            format!("could not read relay state: {e}"),
4765            "run `wire up <handle>@<relay>` to bootstrap",
4766        ),
4767    };
4768    let url = state
4769        .get("self")
4770        .and_then(|s| s.get("relay_url"))
4771        .and_then(Value::as_str)
4772        .unwrap_or("");
4773    if url.is_empty() {
4774        return DoctorCheck::warn(
4775            "relay",
4776            "no relay bound — wire send/pull will not work",
4777            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
4778        );
4779    }
4780    let client = crate::relay_client::RelayClient::new(url);
4781    match client.check_healthz() {
4782        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
4783        Err(e) => DoctorCheck::fail(
4784            "relay",
4785            format!("{url} unreachable: {e}"),
4786            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
4787        ),
4788    }
4789}
4790
4791/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
4792/// entry there is a silent failure that, pre-0.5.11, would have left the
4793/// operator wondering why pairing didn't complete.
4794fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
4795    let path = match config::state_dir() {
4796        Ok(d) => d.join("pair-rejected.jsonl"),
4797        Err(e) => return DoctorCheck::warn(
4798            "pair_rejections",
4799            format!("could not resolve state dir: {e}"),
4800            "set WIRE_HOME or fix XDG_STATE_HOME",
4801        ),
4802    };
4803    if !path.exists() {
4804        return DoctorCheck::pass(
4805            "pair_rejections",
4806            "no pair-rejected.jsonl — no recorded pair failures",
4807        );
4808    }
4809    let body = match std::fs::read_to_string(&path) {
4810        Ok(b) => b,
4811        Err(e) => return DoctorCheck::warn(
4812            "pair_rejections",
4813            format!("could not read {path:?}: {e}"),
4814            "check file permissions",
4815        ),
4816    };
4817    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
4818    if lines.is_empty() {
4819        return DoctorCheck::pass(
4820            "pair_rejections",
4821            "pair-rejected.jsonl present but empty",
4822        );
4823    }
4824    let total = lines.len();
4825    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
4826    let mut summary: Vec<String> = Vec::new();
4827    for line in &recent {
4828        if let Ok(rec) = serde_json::from_str::<Value>(line) {
4829            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
4830            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
4831            summary.push(format!("{peer}/{code}"));
4832        }
4833    }
4834    DoctorCheck::warn(
4835        "pair_rejections",
4836        format!(
4837            "{total} pair failures recorded. recent: [{}]",
4838            summary.join(", ")
4839        ),
4840        format!(
4841            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
4842        ),
4843    )
4844}
4845
4846/// Check: cursor isn't stuck. We can't tell without polling — but we can
4847/// report the current cursor position so operators see if it changes.
4848/// Real "stuck" detection needs two pulls separated in time; defer that
4849/// behaviour to a `wire doctor --watch` mode.
4850fn check_cursor_progress() -> DoctorCheck {
4851    let state = match config::read_relay_state() {
4852        Ok(s) => s,
4853        Err(e) => return DoctorCheck::warn(
4854            "cursor",
4855            format!("could not read relay state: {e}"),
4856            "check ~/Library/Application Support/wire/relay.json",
4857        ),
4858    };
4859    let cursor = state
4860        .get("self")
4861        .and_then(|s| s.get("last_pulled_event_id"))
4862        .and_then(Value::as_str)
4863        .map(|s| s.chars().take(16).collect::<String>())
4864        .unwrap_or_else(|| "<none>".to_string());
4865    DoctorCheck::pass(
4866        "cursor",
4867        format!(
4868            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
4869        ),
4870    )
4871}
4872
4873#[cfg(test)]
4874mod doctor_tests {
4875    use super::*;
4876
4877    #[test]
4878    fn doctor_check_constructors_set_status_correctly() {
4879        // Silent-fail-prevention rule: pass/warn/fail must be visibly
4880        // distinguishable to operators. If any constructor lets the wrong
4881        // status through, `wire doctor` lies and we're back to today's
4882        // 30-minute debug.
4883        let p = DoctorCheck::pass("x", "ok");
4884        assert_eq!(p.status, "PASS");
4885        assert_eq!(p.fix, None);
4886
4887        let w = DoctorCheck::warn("x", "watch out", "do this");
4888        assert_eq!(w.status, "WARN");
4889        assert_eq!(w.fix, Some("do this".to_string()));
4890
4891        let f = DoctorCheck::fail("x", "broken", "fix it");
4892        assert_eq!(f.status, "FAIL");
4893        assert_eq!(f.fix, Some("fix it".to_string()));
4894    }
4895
4896    #[test]
4897    fn check_pair_rejections_no_file_is_pass() {
4898        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
4899        // as a problem.
4900        config::test_support::with_temp_home(|| {
4901            config::ensure_dirs().unwrap();
4902            let c = check_pair_rejections(5);
4903            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
4904        });
4905    }
4906
4907    #[test]
4908    fn check_pair_rejections_with_entries_warns() {
4909        // Existence of rejections is itself a signal — even if each entry
4910        // is a "known good failure," the operator wants to know they
4911        // happened.
4912        config::test_support::with_temp_home(|| {
4913            config::ensure_dirs().unwrap();
4914            crate::pair_invite::record_pair_rejection(
4915                "willard",
4916                "pair_drop_ack_send_failed",
4917                "POST 502",
4918            );
4919            let c = check_pair_rejections(5);
4920            assert_eq!(c.status, "WARN");
4921            assert!(c.detail.contains("1 pair failures"));
4922            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
4923        });
4924    }
4925}
4926
4927// ---------- up megacommand (full bootstrap) ----------
4928
4929/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
4930/// pair. Composes the steps that today's onboarding walks operators through
4931/// one by one (init / bind-relay / claim / background daemon / arm monitor
4932/// recipe). Idempotent: every step checks current state and skips if done.
4933///
4934/// Argument parsing accepts:
4935///   - `<nick>@<relay-host>` — explicit relay
4936///   - `<nick>`              — defaults to wireup.net (the configured public
4937///                             relay)
4938fn cmd_up(handle_arg: &str, name: Option<&str>, as_json: bool) -> Result<()> {
4939    let (nick, relay_url) = match handle_arg.split_once('@') {
4940        Some((n, host)) => {
4941            let url = if host.starts_with("http://") || host.starts_with("https://") {
4942                host.to_string()
4943            } else {
4944                format!("https://{host}")
4945            };
4946            (n.to_string(), url)
4947        }
4948        None => (handle_arg.to_string(), crate::pair_invite::DEFAULT_RELAY.to_string()),
4949    };
4950
4951    let mut report: Vec<(String, String)> = Vec::new();
4952    let mut step = |stage: &str, detail: String| {
4953        report.push((stage.to_string(), detail.clone()));
4954        if !as_json {
4955            eprintln!("wire up: {stage} — {detail}");
4956        }
4957    };
4958
4959    // 1. init (or verify existing identity matches the requested nick).
4960    if config::is_initialized()? {
4961        let card = config::read_agent_card()?;
4962        let existing_did = card.get("did").and_then(Value::as_str).unwrap_or("");
4963        let existing_handle =
4964            crate::agent_card::display_handle_from_did(existing_did).to_string();
4965        if existing_handle != nick {
4966            bail!(
4967                "wire up: already initialized as {existing_handle:?} but you asked for {nick:?}. \
4968                 Either run with the existing handle (`wire up {existing_handle}@<relay>`) or \
4969                 delete `{:?}` to start fresh.",
4970                config::config_dir()?
4971            );
4972        }
4973        step("init", format!("already initialized as {existing_handle}"));
4974    } else {
4975        cmd_init(&nick, name, Some(&relay_url), /* as_json */ false)?;
4976        step("init", format!("created identity {nick} bound to {relay_url}"));
4977    }
4978
4979    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
4980    // already initialized we may need to bind to the requested relay
4981    // separately (operator switched relays).
4982    let relay_state = config::read_relay_state()?;
4983    let bound_relay = relay_state
4984        .get("self")
4985        .and_then(|s| s.get("relay_url"))
4986        .and_then(Value::as_str)
4987        .unwrap_or("")
4988        .to_string();
4989    if bound_relay.is_empty() {
4990        // Identity exists but never bound to a relay — bind now.
4991        cmd_bind_relay(&relay_url, /* as_json */ false)?;
4992        step("bind-relay", format!("bound to {relay_url}"));
4993    } else if bound_relay != relay_url {
4994        step(
4995            "bind-relay",
4996            format!(
4997                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
4998                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
4999            ),
5000        );
5001    } else {
5002        step("bind-relay", format!("already bound to {bound_relay}"));
5003    }
5004
5005    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
5006    // re-claims are accepted by the relay.
5007    match cmd_claim(&nick, Some(&relay_url), None, /* as_json */ false) {
5008        Ok(()) => step("claim", format!("{nick}@{} claimed", strip_proto(&relay_url))),
5009        Err(e) => step(
5010            "claim",
5011            format!("WARNING: claim failed: {e}. You can retry `wire claim {nick}`."),
5012        ),
5013    }
5014
5015    // 4. Background daemon — must be running for pull/push/ack to flow.
5016    match crate::ensure_up::ensure_daemon_running() {
5017        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
5018        Ok(false) => step("daemon", "already running".to_string()),
5019        Err(e) => step(
5020            "daemon",
5021            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
5022        ),
5023    }
5024
5025    // 5. Final summary — point operator at the next commands.
5026    let summary = format!(
5027        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
5028         `wire monitor` to watch incoming events."
5029    );
5030    step("ready", summary.clone());
5031
5032    if as_json {
5033        let steps_json: Vec<_> = report
5034            .iter()
5035            .map(|(k, v)| json!({"stage": k, "detail": v}))
5036            .collect();
5037        println!(
5038            "{}",
5039            serde_json::to_string(&json!({
5040                "nick": nick,
5041                "relay": relay_url,
5042                "steps": steps_json,
5043            }))?
5044        );
5045    }
5046    Ok(())
5047}
5048
5049/// Strip http:// or https:// prefix for display in `wire up` step output.
5050fn strip_proto(url: &str) -> String {
5051    url.trim_start_matches("https://")
5052        .trim_start_matches("http://")
5053        .to_string()
5054}
5055
5056// ---------- pair megacommand (zero-paste handle-based) ----------
5057
5058/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
5059/// the handle is in `nick@domain` form. Wraps:
5060///
5061///   1. cmd_add — resolve, pin, drop intro
5062///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
5063///      (signalled by `peers.<handle>.slot_token` populating in relay state)
5064///   3. Verify bilateral pin: trust contains peer + relay state has token
5065///   4. Print final state — both sides VERIFIED + can `wire send`
5066///
5067/// On timeout: hard-errors with the specific stuck step so the operator
5068/// knows which side to chase. No silent partial success.
5069fn cmd_pair_megacommand(
5070    handle_arg: &str,
5071    relay_override: Option<&str>,
5072    timeout_secs: u64,
5073    _as_json: bool,
5074) -> Result<()> {
5075    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
5076    let peer_handle = parsed.nick.clone();
5077
5078    eprintln!("wire pair: resolving {handle_arg}...");
5079    cmd_add(handle_arg, relay_override, /* as_json */ false)?;
5080
5081    eprintln!(
5082        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
5083         to ack (their daemon must be running + pulling)..."
5084    );
5085
5086    // Trigger an immediate daemon-style pull so we don't wait the full daemon
5087    // interval. Best-effort — if it fails, we still fall through to the
5088    // polling loop.
5089    let _ = run_sync_pull();
5090
5091    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5092    let poll_interval = std::time::Duration::from_millis(500);
5093
5094    loop {
5095        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
5096        let _ = run_sync_pull();
5097        let relay_state = config::read_relay_state()?;
5098        let peer_entry = relay_state
5099            .get("peers")
5100            .and_then(|p| p.get(&peer_handle))
5101            .cloned();
5102        let token = peer_entry
5103            .as_ref()
5104            .and_then(|e| e.get("slot_token"))
5105            .and_then(Value::as_str)
5106            .unwrap_or("");
5107
5108        if !token.is_empty() {
5109            // Bilateral pin complete — we have their slot_token, we can send.
5110            let trust = config::read_trust()?;
5111            let pinned_in_trust = trust
5112                .get("agents")
5113                .and_then(|a| a.get(&peer_handle))
5114                .is_some();
5115            println!(
5116                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
5117                if pinned_in_trust { "VERIFIED" } else { "MISSING (bug)" }
5118            );
5119            return Ok(());
5120        }
5121
5122        if std::time::Instant::now() >= deadline {
5123            // Timeout — surface the EXACT stuck step. Likely culprits:
5124            //   - peer daemon not running on their box
5125            //   - peer's relay slot is offline
5126            //   - their daemon is on an older binary that doesn't know
5127            //     pair_drop kind=1100 (the P0.1 class — now visible via
5128            //     wire pull --json on their side as a blocking rejection)
5129            bail!(
5130                "wire pair: timed out after {timeout_secs}s. \
5131                 peer {peer_handle} never sent pair_drop_ack. \
5132                 likely causes: (a) their daemon is down — ask them to run \
5133                 `wire status` and `wire daemon &`; (b) their binary is older \
5134                 than 0.5.x and doesn't understand pair_drop events — ask \
5135                 them to `wire upgrade`; (c) network / relay blip — re-run \
5136                 `wire pair {handle_arg}` to retry."
5137            );
5138        }
5139
5140        std::thread::sleep(poll_interval);
5141    }
5142}
5143
5144fn cmd_claim(
5145    nick: &str,
5146    relay_override: Option<&str>,
5147    public_url: Option<&str>,
5148    as_json: bool,
5149) -> Result<()> {
5150    if !crate::pair_profile::is_valid_nick(nick) {
5151        bail!(
5152            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
5153        );
5154    }
5155    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
5156    // + claim handle. Operator should never have to run init/bind-relay first.
5157    let (_did, relay_url, slot_id, slot_token) =
5158        crate::pair_invite::ensure_self_with_relay(relay_override)?;
5159    let card = config::read_agent_card()?;
5160
5161    let client = crate::relay_client::RelayClient::new(&relay_url);
5162    let resp = client.handle_claim(nick, &slot_id, &slot_token, public_url, &card)?;
5163
5164    if as_json {
5165        println!(
5166            "{}",
5167            serde_json::to_string(&json!({
5168                "nick": nick,
5169                "relay": relay_url,
5170                "response": resp,
5171            }))?
5172        );
5173    } else {
5174        // Best-effort: derive the public domain from the relay URL. If
5175        // operator passed --public-url that's the canonical address; else
5176        // the relay URL itself. Falls back to a placeholder if both miss.
5177        let domain = public_url
5178            .unwrap_or(&relay_url)
5179            .trim_start_matches("https://")
5180            .trim_start_matches("http://")
5181            .trim_end_matches('/')
5182            .split('/')
5183            .next()
5184            .unwrap_or("<this-relay-domain>")
5185            .to_string();
5186        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
5187        println!("verify with: wire whois {nick}@{domain}");
5188    }
5189    Ok(())
5190}
5191
5192fn cmd_profile(action: ProfileAction) -> Result<()> {
5193    match action {
5194        ProfileAction::Set { field, value, json } => {
5195            // Try parsing the value as JSON; if that fails, treat it as a
5196            // bare string. Lets operators pass either `42` or `"hello"` or
5197            // `["rust","late-night"]` without quoting hell.
5198            let parsed: Value =
5199                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
5200            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
5201            if json {
5202                println!(
5203                    "{}",
5204                    serde_json::to_string(&json!({
5205                        "field": field,
5206                        "profile": new_profile,
5207                    }))?
5208                );
5209            } else {
5210                println!("profile.{field} set");
5211            }
5212        }
5213        ProfileAction::Get { json } => return cmd_whois(None, json, None),
5214        ProfileAction::Clear { field, json } => {
5215            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
5216            if json {
5217                println!(
5218                    "{}",
5219                    serde_json::to_string(&json!({
5220                        "field": field,
5221                        "cleared": true,
5222                        "profile": new_profile,
5223                    }))?
5224                );
5225            } else {
5226                println!("profile.{field} cleared");
5227            }
5228        }
5229    }
5230    Ok(())
5231}
5232
5233// ---------- setup — one-shot MCP host registration ----------
5234
5235fn cmd_setup(apply: bool) -> Result<()> {
5236    use std::path::PathBuf;
5237
5238    let entry = json!({"command": "wire", "args": ["mcp"]});
5239    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
5240
5241    // Detect probable MCP host config locations. Cross-platform — we only
5242    // touch the file if it already exists OR --apply was passed.
5243    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
5244    if let Some(home) = dirs::home_dir() {
5245        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
5246        // The mcpServers map lives at the top level of that file.
5247        targets.push(("Claude Code", home.join(".claude.json")));
5248        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
5249        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
5250        // Claude Desktop macOS
5251        #[cfg(target_os = "macos")]
5252        targets.push((
5253            "Claude Desktop (macOS)",
5254            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
5255        ));
5256        // Claude Desktop Windows
5257        #[cfg(target_os = "windows")]
5258        if let Ok(appdata) = std::env::var("APPDATA") {
5259            targets.push((
5260                "Claude Desktop (Windows)",
5261                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
5262            ));
5263        }
5264        // Cursor
5265        targets.push(("Cursor", home.join(".cursor/mcp.json")));
5266    }
5267    // Project-local — works for several MCP-aware tools
5268    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
5269
5270    println!("wire setup\n");
5271    println!("MCP server snippet (add this to your client's mcpServers):");
5272    println!();
5273    println!("{entry_pretty}");
5274    println!();
5275
5276    if !apply {
5277        println!("Probable MCP host config locations on this machine:");
5278        for (name, path) in &targets {
5279            let marker = if path.exists() {
5280                "✓ found"
5281            } else {
5282                "  (would create)"
5283            };
5284            println!("  {marker:14}  {name}: {}", path.display());
5285        }
5286        println!();
5287        println!("Run `wire setup --apply` to merge wire into each config above.");
5288        println!(
5289            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
5290        );
5291        return Ok(());
5292    }
5293
5294    let mut modified: Vec<String> = Vec::new();
5295    let mut skipped: Vec<String> = Vec::new();
5296    for (name, path) in &targets {
5297        match upsert_mcp_entry(path, "wire", &entry) {
5298            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
5299            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
5300            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
5301        }
5302    }
5303    if !modified.is_empty() {
5304        println!("Modified:");
5305        for line in &modified {
5306            println!("  {line}");
5307        }
5308        println!();
5309        println!("Restart the app(s) above to load wire MCP.");
5310    }
5311    if !skipped.is_empty() {
5312        println!();
5313        println!("Skipped:");
5314        for line in &skipped {
5315            println!("  {line}");
5316        }
5317    }
5318    Ok(())
5319}
5320
5321/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
5322/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
5323fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
5324    let mut cfg: Value = if path.exists() {
5325        let body = std::fs::read_to_string(path).context("reading config")?;
5326        serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
5327    } else {
5328        json!({})
5329    };
5330    if !cfg.is_object() {
5331        cfg = json!({});
5332    }
5333    let root = cfg.as_object_mut().unwrap();
5334    let servers = root
5335        .entry("mcpServers".to_string())
5336        .or_insert_with(|| json!({}));
5337    if !servers.is_object() {
5338        *servers = json!({});
5339    }
5340    let map = servers.as_object_mut().unwrap();
5341    if map.get(server_name) == Some(entry) {
5342        return Ok(false);
5343    }
5344    map.insert(server_name.to_string(), entry.clone());
5345    if let Some(parent) = path.parent()
5346        && !parent.as_os_str().is_empty()
5347    {
5348        std::fs::create_dir_all(parent).context("creating parent dir")?;
5349    }
5350    let out = serde_json::to_string_pretty(&cfg)? + "\n";
5351    std::fs::write(path, out).context("writing config")?;
5352    Ok(true)
5353}
5354
5355// ---------- reactor — event-handler dispatch loop ----------
5356
5357#[allow(clippy::too_many_arguments)]
5358fn cmd_reactor(
5359    on_event: &str,
5360    peer_filter: Option<&str>,
5361    kind_filter: Option<&str>,
5362    verified_only: bool,
5363    interval_secs: u64,
5364    once: bool,
5365    dry_run: bool,
5366    max_per_minute: u32,
5367    max_chain_depth: u32,
5368) -> Result<()> {
5369    use crate::inbox_watch::{InboxEvent, InboxWatcher};
5370    use std::collections::{HashMap, HashSet, VecDeque};
5371    use std::io::Write;
5372    use std::process::{Command, Stdio};
5373    use std::time::{Duration, Instant};
5374
5375    let cursor_path = config::state_dir()?.join("reactor.cursor");
5376    // event_ids THIS reactor's handler has caused to be sent (via wire send).
5377    // Used by chain-depth check — an incoming `(re:X)` where X is in this set
5378    // means peer is replying to something we just said → don't reply back.
5379    //
5380    // Persisted across restarts so a reactor that crashes mid-conversation
5381    // doesn't re-enter the loop. Reads on startup, writes after each
5382    // outbox-grow detection. Capped at 500 entries (LRU-ish — old entries
5383    // dropped from front of file).
5384    let emitted_path = config::state_dir()?.join("reactor-emitted.log");
5385    let mut emitted_ids: HashSet<String> = HashSet::new();
5386    if emitted_path.exists()
5387        && let Ok(body) = std::fs::read_to_string(&emitted_path)
5388    {
5389        for line in body.lines() {
5390            let t = line.trim();
5391            if !t.is_empty() {
5392                emitted_ids.insert(t.to_string());
5393            }
5394        }
5395    }
5396    // Outbox file paths the reactor watches for new sent-event_ids.
5397    let outbox_dir = config::outbox_dir()?;
5398    // (peer → file size we've already scanned). Lets us notice new outbox
5399    // appends without re-reading the whole file each sweep.
5400    let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
5401
5402    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
5403
5404    let kind_num: Option<u32> = match kind_filter {
5405        Some(k) => Some(parse_kind(k)?),
5406        None => None,
5407    };
5408
5409    // Per-peer sliding window of dispatch instants for rate-limit check.
5410    let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
5411
5412    let dispatch = |ev: &InboxEvent,
5413                    peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
5414                    emitted_ids: &HashSet<String>|
5415     -> Result<bool> {
5416        if let Some(p) = peer_filter
5417            && ev.peer != p
5418        {
5419            return Ok(false);
5420        }
5421        if verified_only && !ev.verified {
5422            return Ok(false);
5423        }
5424        if let Some(want) = kind_num {
5425            let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
5426            if ev_kind != Some(want) {
5427                return Ok(false);
5428            }
5429        }
5430
5431        // Chain-depth check: if the body contains `(re:<event_id>)` and that
5432        // event_id is in our emitted set, this is a reply to one of our
5433        // replies → loop suspected, skip.
5434        if max_chain_depth > 0 {
5435            let body_str = match &ev.raw["body"] {
5436                Value::String(s) => s.clone(),
5437                other => serde_json::to_string(other).unwrap_or_default(),
5438            };
5439            if let Some(referenced) = parse_re_marker(&body_str) {
5440                // Handler scripts usually truncate event_id (e.g. ${ID:0:12}).
5441                // Match emitted set by prefix to catch both full + truncated.
5442                let matched = emitted_ids.contains(&referenced)
5443                    || emitted_ids.iter().any(|full| full.starts_with(&referenced));
5444                if matched {
5445                    eprintln!(
5446                        "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
5447                        ev.event_id, ev.peer, referenced
5448                    );
5449                    return Ok(false);
5450                }
5451            }
5452        }
5453
5454        // Per-peer rate-limit check (sliding 60s window).
5455        if max_per_minute > 0 {
5456            let now = Instant::now();
5457            let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
5458            while let Some(&front) = win.front() {
5459                if now.duration_since(front) > Duration::from_secs(60) {
5460                    win.pop_front();
5461                } else {
5462                    break;
5463                }
5464            }
5465            if win.len() as u32 >= max_per_minute {
5466                eprintln!(
5467                    "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
5468                    ev.event_id, ev.peer, max_per_minute
5469                );
5470                return Ok(false);
5471            }
5472            win.push_back(now);
5473        }
5474
5475        if dry_run {
5476            println!("{}", serde_json::to_string(&ev.raw)?);
5477            return Ok(true);
5478        }
5479
5480        let mut child = Command::new("sh")
5481            .arg("-c")
5482            .arg(on_event)
5483            .stdin(Stdio::piped())
5484            .stdout(Stdio::inherit())
5485            .stderr(Stdio::inherit())
5486            .env("WIRE_EVENT_PEER", &ev.peer)
5487            .env("WIRE_EVENT_ID", &ev.event_id)
5488            .env("WIRE_EVENT_KIND", &ev.kind)
5489            .spawn()
5490            .with_context(|| format!("spawning reactor handler: {on_event}"))?;
5491        if let Some(mut stdin) = child.stdin.take() {
5492            let body = serde_json::to_vec(&ev.raw)?;
5493            let _ = stdin.write_all(&body);
5494            let _ = stdin.write_all(b"\n");
5495        }
5496        std::mem::drop(child);
5497        Ok(true)
5498    };
5499
5500    // Scan outbox files for newly-appended event_ids and add to emitted set.
5501    let scan_outbox = |emitted_ids: &mut HashSet<String>,
5502                       outbox_cursors: &mut HashMap<String, u64>|
5503     -> Result<usize> {
5504        if !outbox_dir.exists() {
5505            return Ok(0);
5506        }
5507        let mut added = 0;
5508        let mut new_ids: Vec<String> = Vec::new();
5509        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
5510            let path = entry.path();
5511            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5512                continue;
5513            }
5514            let peer = match path.file_stem().and_then(|s| s.to_str()) {
5515                Some(s) => s.to_string(),
5516                None => continue,
5517            };
5518            let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
5519            let start = *outbox_cursors.get(&peer).unwrap_or(&0);
5520            if cur_len <= start {
5521                outbox_cursors.insert(peer, start);
5522                continue;
5523            }
5524            let body = std::fs::read_to_string(&path).unwrap_or_default();
5525            let tail = &body[start as usize..];
5526            for line in tail.lines() {
5527                if let Ok(v) = serde_json::from_str::<Value>(line)
5528                    && let Some(eid) = v.get("event_id").and_then(Value::as_str)
5529                    && emitted_ids.insert(eid.to_string())
5530                {
5531                    new_ids.push(eid.to_string());
5532                    added += 1;
5533                }
5534            }
5535            outbox_cursors.insert(peer, cur_len);
5536        }
5537        if !new_ids.is_empty() {
5538            // Append new ids to disk, cap on-disk file at 500 entries.
5539            let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
5540            if all.len() > 500 {
5541                all.sort();
5542                let drop_n = all.len() - 500;
5543                let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
5544                emitted_ids.retain(|x| !dropped.contains(x));
5545                all = emitted_ids.iter().cloned().collect();
5546            }
5547            let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
5548        }
5549        Ok(added)
5550    };
5551
5552    let sweep = |watcher: &mut InboxWatcher,
5553                 emitted_ids: &mut HashSet<String>,
5554                 outbox_cursors: &mut HashMap<String, u64>,
5555                 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
5556     -> Result<usize> {
5557        // Pick up any event_ids we sent since last sweep.
5558        let _ = scan_outbox(emitted_ids, outbox_cursors);
5559
5560        let events = watcher.poll()?;
5561        let mut fired = 0usize;
5562        for ev in &events {
5563            match dispatch(ev, peer_dispatch_log, emitted_ids) {
5564                Ok(true) => fired += 1,
5565                Ok(false) => {}
5566                Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
5567            }
5568        }
5569        watcher.save_cursors(&cursor_path)?;
5570        Ok(fired)
5571    };
5572
5573    if once {
5574        sweep(
5575            &mut watcher,
5576            &mut emitted_ids,
5577            &mut outbox_cursors,
5578            &mut peer_dispatch_log,
5579        )?;
5580        return Ok(());
5581    }
5582    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5583    loop {
5584        if let Err(e) = sweep(
5585            &mut watcher,
5586            &mut emitted_ids,
5587            &mut outbox_cursors,
5588            &mut peer_dispatch_log,
5589        ) {
5590            eprintln!("wire reactor: sweep error: {e}");
5591        }
5592        std::thread::sleep(interval);
5593    }
5594}
5595
5596/// Parse `(re:<event_id>)` marker out of an event body. Returns the
5597/// referenced event_id (full or prefix) if present. Tolerates spaces.
5598fn parse_re_marker(body: &str) -> Option<String> {
5599    let needle = "(re:";
5600    let i = body.find(needle)?;
5601    let rest = &body[i + needle.len()..];
5602    let end = rest.find(')')?;
5603    let id = rest[..end].trim().to_string();
5604    if id.is_empty() {
5605        return None;
5606    }
5607    Some(id)
5608}
5609
5610// ---------- notify (Goal 2) ----------
5611
5612fn cmd_notify(
5613    interval_secs: u64,
5614    peer_filter: Option<&str>,
5615    once: bool,
5616    as_json: bool,
5617) -> Result<()> {
5618    use crate::inbox_watch::InboxWatcher;
5619    let cursor_path = config::state_dir()?.join("notify.cursor");
5620    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
5621
5622    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
5623        let events = watcher.poll()?;
5624        for ev in events {
5625            if let Some(p) = peer_filter
5626                && ev.peer != p
5627            {
5628                continue;
5629            }
5630            if as_json {
5631                println!("{}", serde_json::to_string(&ev)?);
5632            } else {
5633                os_notify_inbox_event(&ev);
5634            }
5635        }
5636        watcher.save_cursors(&cursor_path)?;
5637        Ok(())
5638    };
5639
5640    if once {
5641        return sweep(&mut watcher);
5642    }
5643
5644    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5645    loop {
5646        if let Err(e) = sweep(&mut watcher) {
5647            eprintln!("wire notify: sweep error: {e}");
5648        }
5649        std::thread::sleep(interval);
5650    }
5651}
5652
5653fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
5654    let title = if ev.verified {
5655        format!("wire ← {}", ev.peer)
5656    } else {
5657        format!("wire ← {} (UNVERIFIED)", ev.peer)
5658    };
5659    let body = format!("{}: {}", ev.kind, ev.body_preview);
5660    crate::os_notify::toast(&title, &body);
5661}
5662
5663#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
5664fn os_toast(title: &str, body: &str) {
5665    eprintln!("[wire notify] {title}\n  {body}");
5666}
5667
5668// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).