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