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