Skip to main content

wire/
cli.rs

1//! `wire` CLI surface.
2//!
3//! Every subcommand emits human-readable text by default and structured JSON
4//! when `--json` is passed. Stable JSON shape is part of the API contract —
5//! see `docs/AGENT_INTEGRATION.md`.
6//!
7//! Subcommand split:
8//!   - **agent-safe**: `whoami`, `peers`, `verify`, `send`, `tail` — pure
9//!     message-layer ops, no trust establishment.
10//!   - **trust-establishing**: `init`, `pair-host`, `pair-join`. The CLI
11//!     uses interactive `y/N` prompts here. The MCP equivalents
12//!     (`wire_init`, `wire_pair_initiate`, `wire_pair_join`, `wire_pair_check`,
13//!     `wire_pair_confirm`) preserve the human gate by requiring the user to
14//!     type the 6 SAS digits back into chat — see `docs/THREAT_MODEL.md` T10/T14.
15
16use anyhow::{Context, Result, anyhow, bail};
17use clap::{Parser, Subcommand};
18use serde_json::{Value, json};
19
20use crate::{
21    agent_card::{build_agent_card, sign_agent_card},
22    config,
23    signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
24    trust::{add_self_to_trust, empty_trust},
25};
26
27/// Top-level CLI.
28#[derive(Parser, Debug)]
29#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
30pub struct Cli {
31    #[command(subcommand)]
32    pub command: Command,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Command {
37    /// Generate a keypair, write self-card, and prepare to pair. (HUMAN-ONLY — DO NOT exec from agents.)
38    Init {
39        /// Short handle for this agent (becomes did:wire:<handle>).
40        handle: String,
41        /// Optional display name (defaults to capitalized handle).
42        #[arg(long)]
43        name: Option<String>,
44        /// Optional relay URL — if set, also allocates a relay slot in one step
45        /// (equivalent to running `wire init` then `wire bind-relay <url>`).
46        #[arg(long)]
47        relay: Option<String>,
48        /// Emit JSON.
49        #[arg(long)]
50        json: bool,
51    },
52    // (Old `Join` stub removed in iter 11 — superseded by `pair-join` with
53    // `join` alias. See PairJoin below.)
54    /// Print this agent's identity (DID, fingerprint, mailbox slot).
55    Whoami {
56        #[arg(long)]
57        json: bool,
58        /// Print just `<emoji> <nickname>` (e.g. `🦊 foxtrot-meadow`).
59        /// Plain text, no ANSI escapes. Useful for piping into other tools.
60        #[arg(long, conflicts_with = "json")]
61        short: bool,
62        /// Print `<emoji> <nickname>` wrapped in ANSI 256-color escapes.
63        /// Drop into a Claude Code statusline command for live identity display.
64        #[arg(long, conflicts_with_all = ["json", "short"])]
65        colored: bool,
66    },
67    /// List pinned peers with their tiers and capabilities.
68    Peers {
69        #[arg(long)]
70        json: bool,
71    },
72    /// Sign and queue an event to a peer.
73    ///
74    /// Forms (P0.S 0.5.11):
75    ///   wire send <peer> <body>              # kind defaults to "claim"
76    ///   wire send <peer> <kind> <body>       # explicit kind (back-compat)
77    ///   wire send <peer> -                   # body from stdin (kind=claim)
78    ///   wire send <peer> @/path/to/body.json # body from file
79    Send {
80        /// Peer handle (without `did:wire:` prefix).
81        peer: String,
82        /// When `<body>` is omitted, this is the event body (kind defaults
83        /// to `claim`). When both this and `<body>` are given, this is the
84        /// event kind (`decision`, `claim`, etc., or numeric kind id) and
85        /// the next positional is the body.
86        kind_or_body: String,
87        /// Event body — free-form text, `@/path/to/body.json` to load from
88        /// a file, or `-` to read from stdin. Optional; omit to use
89        /// `<kind_or_body>` as the body with kind=`claim`.
90        body: Option<String>,
91        /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
92        #[arg(long)]
93        deadline: Option<String>,
94        /// Emit JSON.
95        #[arg(long)]
96        json: bool,
97    },
98    /// Stream signed events from peers.
99    Tail {
100        /// Optional peer filter; if omitted, tails all peers.
101        peer: Option<String>,
102        /// Emit JSONL (one event per line).
103        #[arg(long)]
104        json: bool,
105        /// Maximum events to read before exiting (0 = stream until SIGINT).
106        #[arg(long, default_value_t = 0)]
107        limit: usize,
108    },
109    /// Live tail of new inbox events across all pinned peers — one line per
110    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
111    /// by default.
112    ///
113    /// Designed to be left running in an agent harness's stream-watcher
114    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
115    /// session as they arrive, not on next manual `wire pull`.
116    ///
117    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
118    /// template.
119    Monitor {
120        /// Only show events from this peer.
121        #[arg(long)]
122        peer: Option<String>,
123        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
124        #[arg(long)]
125        json: bool,
126        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
127        /// Default filters them out as noise.
128        #[arg(long)]
129        include_handshake: bool,
130        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
131        #[arg(long, default_value_t = 500)]
132        interval_ms: u64,
133        /// Replay last N events from history before going live (0 = none).
134        #[arg(long, default_value_t = 0)]
135        replay: usize,
136    },
137    /// Verify a signed event from a JSON file or stdin (`-`).
138    Verify {
139        /// Path to event JSON, or `-` for stdin.
140        path: String,
141        /// Emit JSON.
142        #[arg(long)]
143        json: bool,
144    },
145    /// Run the MCP (Model Context Protocol) server over stdio.
146    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
147    /// `wire_send`, `wire_tail`, etc. as native tools.
148    Mcp,
149    /// Run a relay server on this host.
150    RelayServer {
151        /// Bind address (e.g. `127.0.0.1:8770`).
152        #[arg(long, default_value = "127.0.0.1:8770")]
153        bind: String,
154        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
155        /// skip `.well-known/wire/agent` serving. The relay becomes
156        /// invisible from outside the box — only same-machine processes
157        /// can pair through it. Right call for within-machine agent
158        /// coordination where you don't want metadata leaking to a
159        /// public relay. Pair this with `wire session new` which probes
160        /// `127.0.0.1:8771` and allocates a local slot automatically.
161        #[arg(long)]
162        local_only: bool,
163        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
164        /// When set, --bind is ignored. Implies --local-only semantics
165        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
166        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
167        /// sister sessions. Unix only (Windows refuses).
168        #[arg(long)]
169        uds: Option<std::path::PathBuf>,
170    },
171    /// Allocate a slot on a relay; bind it to this agent's identity.
172    ///
173    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
174    /// current slot, this command refuses by default — silent migration
175    /// silently black-holes their inbound messages. Pass
176    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
177    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
178    /// for safe rotation.
179    BindRelay {
180        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
181        url: String,
182        /// Acknowledge that pinned peers will black-hole until they
183        /// re-pin manually. Required when `state.peers` is non-empty;
184        /// ignored on fresh boxes. Use `wire rotate-slot` instead for
185        /// the supported same-relay rotation path.
186        #[arg(long)]
187        migrate_pinned: bool,
188        #[arg(long)]
189        json: bool,
190    },
191    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
192    /// real `wire join` lands in the SPAKE2 iter.)
193    AddPeerSlot {
194        /// Peer handle (becomes did:wire:<handle>).
195        handle: String,
196        /// Peer's relay base URL.
197        url: String,
198        /// Peer's slot id.
199        slot_id: String,
200        /// Slot bearer token (shared between paired peers in v0.1).
201        slot_token: String,
202        #[arg(long)]
203        json: bool,
204    },
205    /// Drain outbox JSONL files to peers' relay slots.
206    Push {
207        /// Optional peer filter; default = all peers with outbox entries.
208        peer: Option<String>,
209        #[arg(long)]
210        json: bool,
211    },
212    /// Pull events from our relay slot, verify, write to inbox.
213    Pull {
214        #[arg(long)]
215        json: bool,
216    },
217    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
218    /// Useful as a single "where am I" check.
219    Status {
220        /// Inspect a paired peer's transport / attention / responder health.
221        #[arg(long)]
222        peer: Option<String>,
223        #[arg(long)]
224        json: bool,
225    },
226    /// Publish or inspect auto-responder health for this slot.
227    Responder {
228        #[command(subcommand)]
229        command: ResponderCommand,
230    },
231    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
232    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
233    Pin {
234        /// Path to peer's signed agent-card JSON.
235        card_file: String,
236        #[arg(long)]
237        json: bool,
238    },
239    /// Allocate a NEW slot on the same relay and abandon the old one.
240    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
241    /// slot announcing the new mailbox before swapping. After rotation,
242    /// peers must re-pair (or operator runs `add-peer-slot` with the new
243    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
244    ///
245    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
246    /// abusive bearer-holder spamming your slot). Rotate → old slot is
247    /// orphaned → attacker's leverage gone. Operator pairs again with
248    /// peers they still want.
249    RotateSlot {
250        /// Skip the wire_close announcement to peers (faster but they won't know
251        /// where you went).
252        #[arg(long)]
253        no_announce: bool,
254        #[arg(long)]
255        json: bool,
256    },
257    /// Remove a peer from trust + relay state. Inbox/outbox files for that
258    /// peer are NOT deleted (operator can grep history); pass --purge to
259    /// also wipe the JSONL files.
260    ForgetPeer {
261        /// Peer handle to forget.
262        handle: String,
263        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
264        #[arg(long)]
265        purge: bool,
266        #[arg(long)]
267        json: bool,
268    },
269    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
270    /// peers' relay slots and pull inbox from our own slot. Foreground process;
271    /// background it with systemd / `&` / tmux as you prefer.
272    Daemon {
273        /// Sync interval in seconds. Default 5.
274        #[arg(long, default_value_t = 5)]
275        interval: u64,
276        /// Run a single sync cycle and exit (useful for cron-driven setups).
277        #[arg(long)]
278        once: bool,
279        #[arg(long)]
280        json: bool,
281    },
282    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
283    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
284    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
285    /// read the SAS digits aloud and confirm.)
286    PairHost {
287        /// Relay base URL.
288        #[arg(long)]
289        relay: String,
290        /// Skip the SAS confirmation prompt. ONLY use when piping under
291        /// automated tests or when the SAS has already been verified by
292        /// another channel. Documented as test-only.
293        #[arg(long)]
294        yes: bool,
295        /// How long (seconds) to wait for the peer to join before timing out.
296        #[arg(long, default_value_t = 300)]
297        timeout: u64,
298        /// Detach: write a pending-pair file, print the code phrase, and exit
299        /// immediately. The running `wire daemon` does the handshake in the
300        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
301        /// `wire pair-list` shows pending sessions. Default is foreground
302        /// blocking behavior for backward compat.
303        #[arg(long)]
304        detach: bool,
305        /// Emit JSON instead of text. Currently only meaningful with --detach.
306        #[arg(long)]
307        json: bool,
308    },
309    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
310    ///
311    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
312    #[command(alias = "join")]
313    PairJoin {
314        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
315        code_phrase: String,
316        /// Relay base URL (must match the host's relay).
317        #[arg(long)]
318        relay: String,
319        #[arg(long)]
320        yes: bool,
321        #[arg(long, default_value_t = 300)]
322        timeout: u64,
323        /// Detach: see `pair-host --detach`.
324        #[arg(long)]
325        detach: bool,
326        /// Emit JSON instead of text. Currently only meaningful with --detach.
327        #[arg(long)]
328        json: bool,
329    },
330    /// Confirm SAS digits for a detached pending pair. The daemon must be
331    /// running for this to do anything — it picks up the confirmation on its
332    /// next tick. Mismatch aborts the pair.
333    PairConfirm {
334        /// The code phrase the original `wire pair-host --detach` printed.
335        code_phrase: String,
336        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
337        digits: String,
338        /// Emit JSON instead of human-readable text.
339        #[arg(long)]
340        json: bool,
341    },
342    /// List all pending detached pair sessions and their state.
343    PairList {
344        /// Emit JSON instead of the table.
345        #[arg(long)]
346        json: bool,
347        /// Stream mode: never exit; print one JSON line per status transition
348        /// (creation, status change, deletion) across all pending pairs.
349        /// Compose with bash `while read` to react in shell. Implies --json.
350        #[arg(long)]
351        watch: bool,
352        /// Poll interval in seconds for --watch.
353        #[arg(long, default_value_t = 1)]
354        watch_interval: u64,
355    },
356    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
357    PairCancel {
358        code_phrase: String,
359        #[arg(long)]
360        json: bool,
361    },
362    /// Block until a pending pair reaches a target status (default sas_ready),
363    /// or terminates (finalized = file removed, aborted, aborted_restart), or
364    /// the timeout expires. Useful for shell scripts that want to drive the
365    /// detached flow without polling pair-list themselves.
366    ///
367    /// Exit codes:
368    ///   0 — reached target status (or finalized, if target was sas_ready)
369    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
370    ///   2 — timeout
371    PairWatch {
372        code_phrase: String,
373        /// Target status to wait for. Default: sas_ready.
374        #[arg(long, default_value = "sas_ready")]
375        status: String,
376        /// Max seconds to wait.
377        #[arg(long, default_value_t = 300)]
378        timeout: u64,
379        /// Emit JSON on each status change (one per line) instead of just on exit.
380        #[arg(long)]
381        json: bool,
382    },
383    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
384    /// pair-join, then registers wire as an MCP server. Single command from
385    /// nothing to paired and ready — no separate init/pair-host/setup steps.
386    /// Operator still must confirm SAS digits.
387    ///
388    /// Examples:
389    ///   wire pair paul                          # host a new pair on default relay
390    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
391    Pair {
392        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
393        /// step if no identity exists; ignored if already initialized.
394        handle: String,
395        /// Code phrase from peer's pair-host output. Omit to be the host
396        /// (this command will print one for you to share).
397        #[arg(long)]
398        code: Option<String>,
399        /// Relay base URL. Defaults to the laulpogan public-good relay.
400        #[arg(long, default_value = "https://wireup.net")]
401        relay: String,
402        /// Skip SAS prompt. Test-only.
403        #[arg(long)]
404        yes: bool,
405        /// Pair-step timeout in seconds.
406        #[arg(long, default_value_t = 300)]
407        timeout: u64,
408        /// Skip the post-pair `setup --apply` step (don't register wire as
409        /// an MCP server in detected client configs).
410        #[arg(long)]
411        no_setup: bool,
412        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
413        /// exits immediately, daemon does the handshake). Confirm via
414        /// `wire pair-confirm <code> <digits>` from any terminal. See
415        /// `pair-host --detach` for details.
416        #[arg(long)]
417        detach: bool,
418    },
419    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
420    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
421    /// confirmation, leaving the relay-side slot stuck with "guest already
422    /// registered" or "host already registered" until the 5-minute TTL expires.
423    /// Either side can call. Idempotent.
424    PairAbandon {
425        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
426        code_phrase: String,
427        /// Relay base URL.
428        #[arg(long, default_value = "https://wireup.net")]
429        relay: String,
430    },
431    /// Accept a pending-inbound pair request (v0.5.14). Explicit alias for
432    /// the bilateral-completion path that `wire add <peer>@<relay>` also
433    /// drives — but doesn't require remembering the peer's relay domain
434    /// (the relay coords come from the stored pair_drop). Errors if no
435    /// pending-inbound record exists for that peer.
436    PairAccept {
437        /// Bare peer handle (without `@<relay>`).
438        peer: String,
439        /// Emit JSON.
440        #[arg(long)]
441        json: bool,
442    },
443    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
444    /// you@<your-relay>` against your handle, their signed pair_drop lands
445    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
446    /// <peer>` to delete the record without pairing. The peer never receives
447    /// our slot_token; from their side the pair stays pending until they
448    /// time out.
449    PairReject {
450        /// Bare peer handle (without `@<relay>`).
451        peer: String,
452        /// Emit JSON.
453        #[arg(long)]
454        json: bool,
455    },
456    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
457    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
458    /// `pair-list --json` shape but for inbound). Use this in scripts that
459    /// need to enumerate inbound pair requests without parsing the SPAKE2
460    /// table format from `wire pair-list`.
461    PairListInbound {
462        /// Emit JSON.
463        #[arg(long)]
464        json: bool,
465    },
466    /// Manage isolated wire sessions on this machine (v0.5.16).
467    ///
468    /// Each session = its own DID + handle + relay slot + daemon + inbox/
469    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
470    /// in different projects) run on the same machine — without sessions
471    /// they all share one identity and race the inbox cursor.
472    ///
473    /// Names are derived from `basename(cwd)` and cached in a registry,
474    /// so re-entering the same project reuses the same identity.
475    #[command(subcommand)]
476    Session(SessionCommand),
477    /// Manage this session's identity display layer (character override).
478    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
479    /// itself picks a custom nickname + emoji that overrides the
480    /// auto-derived hash-based defaults.
481    Identity {
482        #[command(subcommand)]
483        cmd: IdentityCommand,
484    },
485    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
486    /// sister-session mesh. `wire mesh status` is the live view of every
487    /// paired sister (alias for `wire session mesh-status`); `wire mesh
488    /// broadcast` fans one signed event to every pinned peer.
489    #[command(subcommand)]
490    Mesh(MeshCommand),
491    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
492    /// Cursor, project-local) and either print or auto-merge the wire MCP
493    /// server entry. Default prints; pass `--apply` to actually modify config
494    /// files. Idempotent — re-running is safe.
495    Setup {
496        /// Actually write the changes (default = print only).
497        #[arg(long)]
498        apply: bool,
499    },
500    /// Show an agent's profile. With no arg, prints local self. With a
501    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
502    /// endpoint and verifies the returned signed card before display.
503    Whois {
504        /// Optional handle (`nick@domain`). Omit to show self.
505        handle: Option<String>,
506        #[arg(long)]
507        json: bool,
508        /// Override the relay base URL used for resolution (default:
509        /// `https://<domain>` from the handle).
510        #[arg(long)]
511        relay: Option<String>,
512    },
513    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
514    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
515    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
516    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
517    /// their slot_token so we can `wire send` to them).
518    Add {
519        /// Peer handle (`nick@domain`), OR a bare sister-session name
520        /// when `--local-sister` is set.
521        handle: String,
522        /// Override the relay base URL used for resolution.
523        #[arg(long)]
524        relay: Option<String>,
525        /// v0.6.6: pair with a sister session on this machine without
526        /// touching federation. Looks up `handle` as a session name in
527        /// `wire session list`, reads that session's agent-card +
528        /// endpoints from disk, pins directly, then delivers the
529        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
530        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
531        /// addressable because they don't need a federation claim.
532        #[arg(long)]
533        local_sister: bool,
534        #[arg(long)]
535        json: bool,
536    },
537    /// One-shot full bootstrap — `wire up <nick@relay-host>` does in one
538    /// command what 0.5.10 took five (init + bind-relay + claim + daemon-
539    /// background + remember-to-restart-on-login). Idempotent: re-run on
540    /// an already-set-up box prints state without churn.
541    ///
542    /// Examples:
543    ///   wire up paul@wireup.net           # full bootstrap
544    ///   wire up paul-mac@wireup.net       # ditto, nick = paul-mac
545    ///   wire up paul                      # bootstrap, default relay
546    Up {
547        /// Full handle in `nick@relay-host` form, or just `nick` (defaults
548        /// to the configured public relay wireup.net).
549        handle: String,
550        /// Optional display name (defaults to capitalized nick).
551        #[arg(long)]
552        name: Option<String>,
553        #[arg(long)]
554        json: bool,
555    },
556    /// Diagnose wire setup health. Single command that surfaces every
557    /// silent-fail class — daemon down or duplicated, relay unreachable,
558    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
559    /// Replaces today's 30-minute manual debug.
560    ///
561    /// Exit code non-zero if any FAIL findings.
562    Doctor {
563        /// Emit JSON.
564        #[arg(long)]
565        json: bool,
566        /// Show last N entries from pair-rejected.jsonl in the report.
567        #[arg(long, default_value_t = 5)]
568        recent_rejections: usize,
569    },
570    /// Atomic upgrade: kill every `wire daemon` process, spawn a fresh
571    /// one from the current binary, write a new pidfile. Eliminates the
572    /// "stale binary text in memory under a fresh symlink" bug class that
573    /// burned 30 minutes today.
574    Upgrade {
575        /// Report drift without taking action (lists processes that would
576        /// be killed + the version of each).
577        #[arg(long)]
578        check: bool,
579        #[arg(long)]
580        json: bool,
581    },
582    /// Install / inspect / remove a launchd plist (macOS) or systemd
583    /// user unit (linux) that runs `wire daemon` on login + restarts
584    /// on crash. Replaces today's "background it with tmux/&/systemd
585    /// as you prefer" footgun.
586    Service {
587        #[command(subcommand)]
588        action: ServiceAction,
589    },
590    /// Inspect or toggle the structured diagnostic trace
591    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
592    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
593    /// (writes the file knob a running daemon picks up automatically).
594    Diag {
595        #[command(subcommand)]
596        action: DiagAction,
597    },
598    /// Claim a nick on a relay's handle directory. Anyone can then reach
599    /// this agent by `<nick>@<relay-domain>` via the relay's
600    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
601    Claim {
602        nick: String,
603        /// Relay to claim the nick on. Default = relay our slot is on.
604        #[arg(long)]
605        relay: Option<String>,
606        /// Public URL the relay should advertise to resolvers (default = relay).
607        #[arg(long)]
608        public_url: Option<String>,
609        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
610        /// directory listing. The handle stays claimed (FCFS still
611        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
612        /// still resolves, so peers you share the handle with out-of-band
613        /// can still pair. Bulk scrapers / phonebook crawlers will not
614        /// see the nick. Use this for handles meant for known-peer
615        /// pairing only — see issue #9.
616        #[arg(long)]
617        hidden: bool,
618        #[arg(long)]
619        json: bool,
620    },
621    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
622    /// avatar_url, handle, now). Re-signs the agent-card atomically.
623    ///
624    /// Examples:
625    ///   wire profile set motto "compiles or dies trying"
626    ///   wire profile set emoji "🦀"
627    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
628    ///   wire profile set handle "coffee-ghost@anthropic.dev"
629    ///   wire profile get
630    Profile {
631        #[command(subcommand)]
632        action: ProfileAction,
633    },
634    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
635    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
636    /// a relay slot on first use. Default TTL 24h, single-use.
637    Invite {
638        /// Override the relay URL for first-time auto-allocation.
639        #[arg(long, default_value = "https://wireup.net")]
640        relay: String,
641        /// Invite lifetime in seconds (default 86400 = 24h).
642        #[arg(long, default_value_t = 86_400)]
643        ttl: u64,
644        /// Number of distinct peers that can accept this invite before it's
645        /// consumed (default 1).
646        #[arg(long, default_value_t = 1)]
647        uses: u32,
648        /// Register the invite at the relay's short-URL endpoint and print
649        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
650        /// Installs wire if missing, then accepts the invite, then pairs.
651        #[arg(long)]
652        share: bool,
653        /// Emit JSON.
654        #[arg(long)]
655        json: bool,
656    },
657    /// Accept a wire invite URL. Single-step pair — pins issuer, sends our
658    /// signed card to issuer's slot. Auto-inits + auto-allocates if needed.
659    Accept {
660        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
661        url: String,
662        /// Emit JSON.
663        #[arg(long)]
664        json: bool,
665    },
666    /// Long-running event dispatcher. Watches inbox for new verified events
667    /// and spawns the given shell command per event, passing the event JSON
668    /// on stdin. Use to wire up autonomous reply loops:
669    ///   wire reactor --on-event 'claude -p "respond via wire send"'
670    /// Cursor persisted to `$WIRE_HOME/state/wire/reactor.cursor`.
671    Reactor {
672        /// Shell command to spawn per event. Event JSON written to its stdin.
673        #[arg(long)]
674        on_event: String,
675        /// Only fire for events from this peer.
676        #[arg(long)]
677        peer: Option<String>,
678        /// Only fire for events of this kind (numeric or name, e.g. 1 / decision).
679        #[arg(long)]
680        kind: Option<String>,
681        /// Skip events whose verified flag is false (default true).
682        #[arg(long, default_value_t = true)]
683        verified_only: bool,
684        /// Poll interval in seconds.
685        #[arg(long, default_value_t = 2)]
686        interval: u64,
687        /// Process one sweep and exit.
688        #[arg(long)]
689        once: bool,
690        /// Don't actually spawn — print one JSONL line per event for smoke-testing.
691        #[arg(long)]
692        dry_run: bool,
693        /// Hard rate-limit: max events handler is fired for per peer per minute.
694        /// 0 = unlimited. Default 6 — covers normal conversational tempo, kills
695        /// LLM-vs-LLM feedback loops (which fire 10+/sec).
696        #[arg(long, default_value_t = 6)]
697        max_per_minute: u32,
698        /// Anti-loop chain depth. Track event_ids this reactor emitted; if an
699        /// incoming event body contains `(re:X)` where X is in our emitted log,
700        /// skip — that's a reply-to-our-reply, depth ≥ 2. Disable with 0.
701        #[arg(long, default_value_t = 1)]
702        max_chain_depth: u32,
703    },
704    /// Watch the inbox for new verified events and fire an OS notification per
705    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
706    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
707    /// re-emit history.
708    Notify {
709        /// Poll interval in seconds.
710        #[arg(long, default_value_t = 2)]
711        interval: u64,
712        /// Only notify for events from this peer (handle, no did: prefix).
713        #[arg(long)]
714        peer: Option<String>,
715        /// Run a single sweep and exit (useful for cron / tests).
716        #[arg(long)]
717        once: bool,
718        /// Suppress the OS notification call; print one JSON line per event to
719        /// stdout instead (for piping into other tooling or smoke-testing
720        /// without a desktop session).
721        #[arg(long)]
722        json: bool,
723    },
724}
725
726#[derive(Subcommand, Debug)]
727pub enum DiagAction {
728    /// Tail the last N entries from diag.jsonl.
729    Tail {
730        #[arg(long, default_value_t = 20)]
731        limit: usize,
732        #[arg(long)]
733        json: bool,
734    },
735    /// Flip the file-based knob ON. Running daemons pick this up on
736    /// the next emit call without restart.
737    Enable,
738    /// Flip the file-based knob OFF.
739    Disable,
740    /// Report whether diag is currently enabled + the file's size.
741    Status {
742        #[arg(long)]
743        json: bool,
744    },
745}
746
747#[derive(Subcommand, Debug)]
748pub enum IdentityCommand {
749    /// Override the auto-derived nickname and/or emoji. Persists to
750    /// `<WIRE_HOME>/config/wire/display.json`. Local-only; peers still
751    /// see the auto-derived character from your DID (until federation
752    /// publishes overrides in a future release).
753    ///
754    /// Examples:
755    ///   wire identity rename --name foxtrot-meadow --emoji 🦊
756    ///   wire identity rename --emoji 🐉      (keep auto nickname)
757    ///   wire identity rename --random        (re-roll auto from seed; clears overrides)
758    Rename {
759        /// New nickname (any non-empty string; convention is
760        /// `adjective-noun`, e.g. `foxtrot-meadow`). Omit to leave nickname
761        /// at its current value (auto-derived unless previously set).
762        #[arg(long)]
763        name: Option<String>,
764        /// New emoji glyph. Any single grapheme; the curated set is
765        /// recommended for cross-terminal compatibility.
766        #[arg(long)]
767        emoji: Option<String>,
768        /// Clear all overrides; revert to auto-derived from DID hash.
769        /// Mutually exclusive with `--name` / `--emoji`.
770        #[arg(long, conflicts_with_all = ["name", "emoji"])]
771        clear: bool,
772        /// Re-roll: alias for `--clear` plus an explanatory stderr line.
773        /// Auto-derived is itself deterministic, so this just removes
774        /// any previously-set override.
775        #[arg(long, conflicts_with_all = ["name", "emoji", "clear"])]
776        random: bool,
777        #[arg(long)]
778        json: bool,
779    },
780    /// Print the current character (auto-derived OR override).
781    /// Equivalent to `wire whoami --short` but scoped here for grouping.
782    Show {
783        #[arg(long)]
784        json: bool,
785    },
786    /// List all identities on this machine — one row per session, with
787    /// each session's character, DID, federation handle, and cwd. Same
788    /// shape as `wire session list`, scoped here for the v0.7+ noun-
789    /// CLI surface.
790    List {
791        #[arg(long)]
792        json: bool,
793    },
794    /// Promote this identity to FEDERATION lifecycle: claim a handle on
795    /// the relay so peers can `wire add <name>@<relay-domain>` you.
796    /// Re-claims with current display fields (after `wire identity rename`)
797    /// so the relay always serves the latest signed card. Equivalent to
798    /// `wire claim` but scoped here for the v0.7+ noun-CLI surface.
799    Publish {
800        /// The handle to claim on the relay (e.g. `coffee-ghost`).
801        nick: String,
802        /// Override the relay URL. Defaults to the session's bound relay
803        /// from `wire init --relay <url>`. Public relay if unset.
804        #[arg(long)]
805        relay: Option<String>,
806        /// Public-facing URL for the agent-card location (when the relay
807        /// is behind a CDN with a different public domain).
808        #[arg(long, alias = "public")]
809        public_url: Option<String>,
810        /// Skip listing in the relay's public phonebook. The card is
811        /// still claimable + reachable; just doesn't appear in
812        /// `wireup.net/phonebook` for stranger-discovery.
813        #[arg(long)]
814        hidden: bool,
815        #[arg(long)]
816        json: bool,
817    },
818    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
819    /// Equivalent to `wire session destroy <name>`, scoped here for the
820    /// noun-CLI surface. Requires `--force` (the underlying command does).
821    Destroy {
822        /// Session name to destroy (use `wire identity list` to see).
823        name: String,
824        /// Bypass the confirmation prompt.
825        #[arg(long)]
826        force: bool,
827        #[arg(long)]
828        json: bool,
829    },
830    /// Create an identity in an EXPLICIT lifecycle state (vs. the
831    /// implicit `wire init` + `wire claim` flow).
832    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
833    ///
834    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
835    /// next reboot). In-memory semantics not yet supported — the
836    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
837    /// For pure-RAM identities, see v1.0 vision.
838    ///
839    /// `--local` is the explicit form of today's default; identity
840    /// persists to the machine-wide sessions root.
841    Create {
842        /// Session name. Defaults to derived from cwd (anonymous mode
843        /// uses a random name).
844        #[arg(long)]
845        name: Option<String>,
846        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
847        /// reboot, no federation). Mutually exclusive with --local.
848        #[arg(long, conflicts_with = "local")]
849        anonymous: bool,
850        /// Create a LOCAL identity (machine-persistent, no federation).
851        /// Default — explicit flag for clarity.
852        #[arg(long)]
853        local: bool,
854        #[arg(long)]
855        json: bool,
856    },
857    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
858    /// the machine-wide sessions root + register in the cwd map.
859    /// After persist, the identity survives reboot.
860    /// v0.7.0-alpha.20.
861    Persist {
862        /// The anonymous identity's name (from `wire identity list`).
863        name: String,
864        /// Optional rename during persist. Default: keep the anon name.
865        #[arg(long = "as", value_name = "NEW_NAME")]
866        as_name: Option<String>,
867        #[arg(long)]
868        json: bool,
869    },
870    /// Demote an identity ONE level in the lifecycle:
871    ///   federation → local: removes the relay slot binding but keeps
872    ///   the keypair + agent-card. Operator can later re-publish with
873    ///   `wire identity publish`. v0.7.0-alpha.20.
874    ///
875    /// (local → anonymous is not exposed; the safer flow is destroy +
876    /// recreate, since "demoting" a persistent identity to ephemeral
877    /// has surprising semantics — what about the keypair? what about
878    /// pinned peers? Better to be explicit with destroy.)
879    Demote {
880        /// Session name to demote.
881        name: String,
882        #[arg(long)]
883        json: bool,
884    },
885}
886
887#[derive(Subcommand, Debug)]
888pub enum SessionCommand {
889    /// Bootstrap a new isolated session in this machine's sessions root.
890    /// With no name, derives one from `basename(cwd)` and caches it in
891    /// the registry so re-running from the same project reuses it.
892    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
893    /// the new session's WIRE_HOME. Output includes the `export
894    /// WIRE_HOME=...` line operators paste into their shell to activate
895    /// it.
896    New {
897        /// Optional session name. Default = derived from `basename(cwd)`.
898        name: Option<String>,
899        /// Relay URL for the session's slot allocation + handle claim.
900        #[arg(long, default_value = "https://wireup.net")]
901        relay: String,
902        /// v0.5.17: also allocate a second slot on a same-machine local
903        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
904        /// sister-session traffic prefers this path: zero round-trip
905        /// latency, zero metadata exposure to the public relay. Probes
906        /// `<local-relay>/healthz` first; silently skips if the local
907        /// relay isn't running.
908        #[arg(long)]
909        with_local: bool,
910        /// v0.5.17: override the local relay URL probed by `--with-local`.
911        /// Default is `http://127.0.0.1:8771` to match
912        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
913        #[arg(long, default_value = "http://127.0.0.1:8771")]
914        local_relay: String,
915        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
916        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
917        /// Lets other machines on the same network reach this session
918        /// directly without round-tripping the public federation relay
919        /// at https://wireup.net. LAN endpoint is published in the
920        /// agent-card; opt-in per session (default off).
921        #[arg(long)]
922        with_lan: bool,
923        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
924        /// LAN IP — operator must type the address). Example:
925        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
926        #[arg(long)]
927        lan_relay: Option<String>,
928        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
929        /// relay (must be running e.g. via `wire relay-server --uds
930        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
931        /// bypasses the macOS firewall + Tailscale userspace-netstack
932        /// class of issues entirely for sister-session traffic. UDS
933        /// endpoint is published in the agent-card.
934        #[arg(long)]
935        with_uds: bool,
936        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
937        /// is set. Example: `/tmp/wire.sock` or
938        /// `~/.wire/local.sock`.
939        #[arg(long)]
940        uds_socket: Option<std::path::PathBuf>,
941        /// Skip spawning the session-local daemon. Use when you want
942        /// to drive sync explicitly from the agent or test rig.
943        #[arg(long)]
944        no_daemon: bool,
945        /// v0.6.6: create a federation-free session — no nick claim on
946        /// `--relay`, no federation slot allocation. Implies
947        /// `--with-local`. The session exists only to coordinate with
948        /// other sister sessions on this machine; it has no public
949        /// address and cannot be reached from outside. Reserved nicks
950        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
951        /// to publish them.
952        #[arg(long)]
953        local_only: bool,
954        /// Emit JSON.
955        #[arg(long)]
956        json: bool,
957    },
958    /// List all sessions on this machine with their handle, DID,
959    /// daemon liveness, and the cwd they're associated with.
960    List {
961        #[arg(long)]
962        json: bool,
963    },
964    /// List sister sessions reachable via a same-machine local relay
965    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
966    /// share. Sessions without a Local-scope endpoint are listed
967    /// separately so the operator can tell which are federation-only.
968    /// Read-only — does not probe any relay or touch daemons.
969    ListLocal {
970        #[arg(long)]
971        json: bool,
972    },
973    /// v0.6.0 (issue #12): mesh-pair every sister session against every
974    /// other in O(N²) handshakes. For each unordered pair (A, B) that
975    /// is not already paired, drives the bilateral flow end-to-end:
976    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
977    /// B's side, then a final pull on A so the ack lands. Idempotent —
978    /// re-running skips pairs already in `state.peers`.
979    ///
980    /// **Trust anchor:** the operator running this command owns every
981    /// session listed in `wire session list-local` (they all live under
982    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
983    /// That filesystem-permission boundary IS the consent for both
984    /// sides — the bilateral SAS / network-level handshake assumes
985    /// strangers; same-uid sister sessions are by definition not
986    /// strangers. Cross-uid sister sessions are out of scope; today
987    /// `wire session list-local` only enumerates this user's sessions.
988    PairAllLocal {
989        /// Seconds to wait between handshake stages for pair_drop /
990        /// pair_drop_ack to propagate over the relay. Default 1s
991        /// (local-relay is typically <100ms RTT). Bump if you see
992        /// "pending-inbound never arrived" errors on a slow relay.
993        #[arg(long, default_value_t = 1)]
994        settle_secs: u64,
995        /// Federation relay to bind each `wire add` against. Default
996        /// `https://wireup.net`. Sister sessions should be bound to
997        /// the same federation relay; the pair handshake routes through
998        /// it for the .well-known resolution + pair_drop deposit.
999        #[arg(long, default_value = "https://wireup.net")]
1000        federation_relay: String,
1001        #[arg(long)]
1002        json: bool,
1003    },
1004    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1005    /// machine. Enumerates every session in `wire session list-local`,
1006    /// walks each session's `relay.json#peers` to find which other sister
1007    /// sessions it has pinned, and probes the local relay for each edge's
1008    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1009    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1010    /// local_relay, summary}` so scripts can scrape.
1011    ///
1012    /// Read-only — does NOT touch peers or daemons, only the relay's
1013    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1014    /// already hold. Silent on any probe failure (degrades to "no
1015    /// signal" rather than abort) so a half-broken mesh is still
1016    /// inspectable.
1017    MeshStatus {
1018        /// Threshold in seconds for "stale" classification on an edge.
1019        /// An edge whose receiver hasn't polled their slot in this long
1020        /// is flagged. Default 300s (5 min) — same as the per-send
1021        /// `phyllis` attentiveness nag.
1022        #[arg(long, default_value_t = 300)]
1023        stale_secs: u64,
1024        #[arg(long)]
1025        json: bool,
1026    },
1027    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1028    /// can `eval $(wire session env <name>)` to activate it. With no
1029    /// name, resolves the cwd through the registry.
1030    Env {
1031        /// Session name. Default = derived from cwd via the registry.
1032        name: Option<String>,
1033        #[arg(long)]
1034        json: bool,
1035    },
1036    /// Identify which session the current cwd maps to in the registry.
1037    /// Prints `(none)` if cwd isn't registered — `wire session new`
1038    /// would create one.
1039    Current {
1040        #[arg(long)]
1041        json: bool,
1042    },
1043    /// Attach an existing session to the current cwd in the registry,
1044    /// so subsequent auto-detect from this cwd resolves to that session
1045    /// instead of walking up to an ancestor's binding. Use when an
1046    /// ancestor dir (e.g. `~/Source`) is already registered and is
1047    /// shadowing per-project identities for cwds beneath it. Idempotent;
1048    /// re-binding to the same name is a no-op. Re-binding to a different
1049    /// name overwrites the prior entry with a stderr warning.
1050    Bind {
1051        /// Session name to bind. Must already exist (run `wire session
1052        /// new <name>` first if not). With no name, auto-derives from
1053        /// `basename(cwd)` and errors if no session of that name exists.
1054        name: Option<String>,
1055        #[arg(long)]
1056        json: bool,
1057    },
1058    /// Tear down a session: kills its daemon (if running), deletes its
1059    /// state directory, and removes it from the registry. Requires
1060    /// `--force` because state loss is unrecoverable (keypair gone).
1061    Destroy {
1062        name: String,
1063        /// Confirm state-deleting operation.
1064        #[arg(long)]
1065        force: bool,
1066        #[arg(long)]
1067        json: bool,
1068    },
1069}
1070
1071/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1072/// session's view of the pinned peer set. `status` is the read-only
1073/// observability primitive (alias for `wire session mesh-status`);
1074/// `broadcast` fans a signed event to every pinned peer in one call.
1075#[derive(Subcommand, Debug)]
1076pub enum MeshCommand {
1077    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1078    /// per-edge health roll-up across every sister session on this machine.
1079    Status {
1080        /// Threshold in seconds for "stale" classification on an edge.
1081        #[arg(long, default_value_t = 300)]
1082        stale_secs: u64,
1083        #[arg(long)]
1084        json: bool,
1085    },
1086    /// Fan one signed event to every pinned peer. Each peer receives a
1087    /// distinct `event_id` but every copy shares the same `broadcast_id`
1088    /// UUID so receivers can correlate them as a single broadcast.
1089    ///
1090    /// `--scope local` (default) only fans to peers reachable via a same-
1091    /// machine local relay. `--scope federation` only to public-relay
1092    /// peers. `--scope both` to every pinned peer.
1093    ///
1094    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1095    /// for "ack-loop" prevention: a peer responding to a broadcast can
1096    /// exclude its own broadcaster when re-broadcasting.
1097    ///
1098    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1099    /// file, `-` reads stdin (JSON if parseable, else literal).
1100    ///
1101    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1102    /// peers — that would re-introduce the phonebook-scrape risk closed
1103    /// in v0.5.14 (T8).
1104    Broadcast {
1105        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1106        /// `heartbeat`. Same vocabulary as `wire send`.
1107        #[arg(long, default_value = "claim")]
1108        kind: String,
1109        /// `local`, `federation`, or `both`. Default `local`.
1110        #[arg(long, default_value = "local")]
1111        scope: String,
1112        /// Skip a specific peer handle. Repeatable.
1113        #[arg(long)]
1114        exclude: Vec<String>,
1115        /// Drop the broadcast event ID from the relay-side attentiveness
1116        /// nag (`phyllis`) — useful when broadcasting to many peers and
1117        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1118        #[arg(long)]
1119        noreply: bool,
1120        /// Body — string, `@/path` for a file, or `-` for stdin.
1121        body: String,
1122        #[arg(long)]
1123        json: bool,
1124    },
1125    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1126    /// capability-aware addressing. Stored as `profile.role` on the
1127    /// signed agent-card — propagates over the existing pair / .well-
1128    /// known plumbing, no new persistence.
1129    ///
1130    /// First slice of the Layer-2 capability metadata umbrella (#13).
1131    /// `wire mesh route` (issue #21) will consume these tags to pick
1132    /// the right sister for a task.
1133    Role {
1134        #[command(subcommand)]
1135        action: MeshRoleAction,
1136    },
1137    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1138    /// to one sister session and deliver an event to that one peer.
1139    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1140    /// can now address "the reviewer" instead of hard-coding a handle.
1141    ///
1142    /// Strategies:
1143    ///   - `round-robin` (default): per-role cursor, persisted at
1144    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1145    ///   - `first`: alphabetically-first matching sister. Deterministic.
1146    ///   - `random`: uniform random among matches. Stateless.
1147    ///
1148    /// Pinned-peers-only by construction (same posture as `broadcast`).
1149    /// Caller must already have the target sister pinned in
1150    /// `state.peers` — otherwise we can't sign + push. Run
1151    /// `wire session pair-all-local` first if the mesh isn't wired.
1152    Route {
1153        /// Role to match (operator-defined tag from `wire mesh role set`).
1154        role: String,
1155        /// `round-robin` (default), `first`, or `random`.
1156        #[arg(long, default_value = "round-robin")]
1157        strategy: String,
1158        /// Skip a specific sister handle. Repeatable.
1159        #[arg(long)]
1160        exclude: Vec<String>,
1161        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1162        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1163        #[arg(long, default_value = "claim")]
1164        kind: String,
1165        /// Body — string, `@/path` for a file, or `-` for stdin.
1166        body: String,
1167        #[arg(long)]
1168        json: bool,
1169    },
1170}
1171
1172/// v0.6.4: subcommands of `wire mesh role`.
1173#[derive(Subcommand, Debug)]
1174pub enum MeshRoleAction {
1175    /// Assign self to a role. Role is a free-form ASCII string
1176    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1177    /// the vocabulary out-of-band — common starters: `planner`,
1178    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1179    Set {
1180        role: String,
1181        #[arg(long)]
1182        json: bool,
1183    },
1184    /// Read self or a peer's role. With no arg, prints self. With a
1185    /// handle, reads from the peer's pinned agent-card.
1186    Get {
1187        peer: Option<String>,
1188        #[arg(long)]
1189        json: bool,
1190    },
1191    /// List roles across every sister session on this machine. Reads
1192    /// each session's agent-card by path — no network, no env mutation.
1193    List {
1194        #[arg(long)]
1195        json: bool,
1196    },
1197    /// Remove self from any assigned role. Re-signs the card with
1198    /// `profile.role: null`.
1199    Clear {
1200        #[arg(long)]
1201        json: bool,
1202    },
1203}
1204
1205#[derive(Subcommand, Debug)]
1206pub enum ServiceAction {
1207    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1208    /// load it. Idempotent — re-running re-bootstraps an existing service.
1209    ///
1210    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1211    /// process). Pass `--local-relay` to install the loopback relay
1212    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1213    /// transport sister-Claudes use to coordinate on the same machine
1214    /// (v0.5.17 dual-slot). The two services have distinct labels +
1215    /// log files, so you can install both.
1216    Install {
1217        /// Install the local-relay service instead of the daemon.
1218        #[arg(long)]
1219        local_relay: bool,
1220        #[arg(long)]
1221        json: bool,
1222    },
1223    /// Unload + delete the service unit. Daemon keeps running until the
1224    /// next reboot or `wire upgrade`; this only changes the boot-time
1225    /// behaviour.
1226    Uninstall {
1227        /// Uninstall the local-relay service instead of the daemon.
1228        #[arg(long)]
1229        local_relay: bool,
1230        #[arg(long)]
1231        json: bool,
1232    },
1233    /// Report whether the unit is installed + active.
1234    Status {
1235        /// Show status of the local-relay service instead of the daemon.
1236        #[arg(long)]
1237        local_relay: bool,
1238        #[arg(long)]
1239        json: bool,
1240    },
1241}
1242
1243#[derive(Subcommand, Debug)]
1244pub enum ResponderCommand {
1245    /// Publish this agent's auto-responder health.
1246    Set {
1247        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1248        status: String,
1249        /// Optional operator-facing reason.
1250        #[arg(long)]
1251        reason: Option<String>,
1252        /// Emit JSON.
1253        #[arg(long)]
1254        json: bool,
1255    },
1256    /// Read responder health for self, or for a paired peer.
1257    Get {
1258        /// Optional peer handle; omitted means this agent's own slot.
1259        peer: Option<String>,
1260        /// Emit JSON.
1261        #[arg(long)]
1262        json: bool,
1263    },
1264}
1265
1266#[derive(Subcommand, Debug)]
1267pub enum ProfileAction {
1268    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1269    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1270    /// (JSON array) and `now` (JSON object).
1271    Set {
1272        field: String,
1273        value: String,
1274        #[arg(long)]
1275        json: bool,
1276    },
1277    /// Show all profile fields. Equivalent to `wire whois`.
1278    Get {
1279        #[arg(long)]
1280        json: bool,
1281    },
1282    /// Clear a profile field.
1283    Clear {
1284        field: String,
1285        #[arg(long)]
1286        json: bool,
1287    },
1288}
1289
1290/// Entry point — parse and dispatch.
1291pub fn run() -> Result<()> {
1292    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1293    // the session registry and adopt that session's home for this
1294    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1295    // detect — `wire whoami` / `wire monitor` from a project cwd now
1296    // resolve to that project's session identity, not the machine
1297    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1298    //
1299    // MUST run before any thread spawn — call it FIRST, before
1300    // `Cli::parse` (which uses clap internals only) and before any
1301    // command dispatch (which may spawn workers).
1302    crate::session::maybe_adopt_session_wire_home("cli");
1303    let cli = Cli::parse();
1304    match cli.command {
1305        Command::Init {
1306            handle,
1307            name,
1308            relay,
1309            json,
1310        } => cmd_init(&handle, name.as_deref(), relay.as_deref(), json),
1311        Command::Status { peer, json } => {
1312            if let Some(peer) = peer {
1313                cmd_status_peer(&peer, json)
1314            } else {
1315                cmd_status(json)
1316            }
1317        }
1318        Command::Whoami {
1319            json,
1320            short,
1321            colored,
1322        } => cmd_whoami(json, short, colored),
1323        Command::Peers { json } => cmd_peers(json),
1324        Command::Send {
1325            peer,
1326            kind_or_body,
1327            body,
1328            deadline,
1329            json,
1330        } => {
1331            // P0.S: smart-positional API. `wire send peer body` =
1332            // kind=claim. `wire send peer kind body` = explicit kind.
1333            let (kind, body) = match body {
1334                Some(real_body) => (kind_or_body, real_body),
1335                None => ("claim".to_string(), kind_or_body),
1336            };
1337            cmd_send(&peer, &kind, &body, deadline.as_deref(), json)
1338        }
1339        Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1340        Command::Monitor {
1341            peer,
1342            json,
1343            include_handshake,
1344            interval_ms,
1345            replay,
1346        } => cmd_monitor(
1347            peer.as_deref(),
1348            json,
1349            include_handshake,
1350            interval_ms,
1351            replay,
1352        ),
1353        Command::Verify { path, json } => cmd_verify(&path, json),
1354        Command::Responder { command } => match command {
1355            ResponderCommand::Set {
1356                status,
1357                reason,
1358                json,
1359            } => cmd_responder_set(&status, reason.as_deref(), json),
1360            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1361        },
1362        Command::Mcp => cmd_mcp(),
1363        Command::RelayServer {
1364            bind,
1365            local_only,
1366            uds,
1367        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1368        Command::BindRelay {
1369            url,
1370            migrate_pinned,
1371            json,
1372        } => cmd_bind_relay(&url, migrate_pinned, json),
1373        Command::AddPeerSlot {
1374            handle,
1375            url,
1376            slot_id,
1377            slot_token,
1378            json,
1379        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1380        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1381        Command::Pull { json } => cmd_pull(json),
1382        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1383        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1384        Command::ForgetPeer {
1385            handle,
1386            purge,
1387            json,
1388        } => cmd_forget_peer(&handle, purge, json),
1389        Command::Daemon {
1390            interval,
1391            once,
1392            json,
1393        } => cmd_daemon(interval, once, json),
1394        Command::PairHost {
1395            relay,
1396            yes,
1397            timeout,
1398            detach,
1399            json,
1400        } => {
1401            if detach {
1402                cmd_pair_host_detach(&relay, json)
1403            } else {
1404                cmd_pair_host(&relay, yes, timeout)
1405            }
1406        }
1407        Command::PairJoin {
1408            code_phrase,
1409            relay,
1410            yes,
1411            timeout,
1412            detach,
1413            json,
1414        } => {
1415            if detach {
1416                cmd_pair_join_detach(&code_phrase, &relay, json)
1417            } else {
1418                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1419            }
1420        }
1421        Command::PairConfirm {
1422            code_phrase,
1423            digits,
1424            json,
1425        } => cmd_pair_confirm(&code_phrase, &digits, json),
1426        Command::PairList {
1427            json,
1428            watch,
1429            watch_interval,
1430        } => cmd_pair_list(json, watch, watch_interval),
1431        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1432        Command::PairWatch {
1433            code_phrase,
1434            status,
1435            timeout,
1436            json,
1437        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1438        Command::Pair {
1439            handle,
1440            code,
1441            relay,
1442            yes,
1443            timeout,
1444            no_setup,
1445            detach,
1446        } => {
1447            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1448            // the zero-paste megacommand path — `wire pair slancha-spark@
1449            // wireup.net` does add + poll-for-ack + verify in one shot. The
1450            // SAS / code-based pair flow stays available for handles without
1451            // `@` (bootstrap pairing between two boxes that don't yet share a
1452            // relay directory).
1453            if handle.contains('@') && code.is_none() {
1454                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1455            } else if detach {
1456                cmd_pair_detach(&handle, code.as_deref(), &relay)
1457            } else {
1458                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1459            }
1460        }
1461        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1462        Command::PairAccept { peer, json } => cmd_pair_accept(&peer, json),
1463        Command::PairReject { peer, json } => cmd_pair_reject(&peer, json),
1464        Command::PairListInbound { json } => cmd_pair_list_inbound(json),
1465        Command::Session(cmd) => cmd_session(cmd),
1466        Command::Identity { cmd } => cmd_identity(cmd),
1467        Command::Mesh(cmd) => cmd_mesh(cmd),
1468        Command::Invite {
1469            relay,
1470            ttl,
1471            uses,
1472            share,
1473            json,
1474        } => cmd_invite(&relay, ttl, uses, share, json),
1475        Command::Accept { url, json } => cmd_accept(&url, json),
1476        Command::Whois {
1477            handle,
1478            json,
1479            relay,
1480        } => cmd_whois(handle.as_deref(), json, relay.as_deref()),
1481        Command::Add {
1482            handle,
1483            relay,
1484            local_sister,
1485            json,
1486        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1487        Command::Up { handle, name, json } => cmd_up(&handle, name.as_deref(), json),
1488        Command::Doctor {
1489            json,
1490            recent_rejections,
1491        } => cmd_doctor(json, recent_rejections),
1492        Command::Upgrade { check, json } => cmd_upgrade(check, json),
1493        Command::Service { action } => cmd_service(action),
1494        Command::Diag { action } => cmd_diag(action),
1495        Command::Claim {
1496            nick,
1497            relay,
1498            public_url,
1499            hidden,
1500            json,
1501        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1502        Command::Profile { action } => cmd_profile(action),
1503        Command::Setup { apply } => cmd_setup(apply),
1504        Command::Reactor {
1505            on_event,
1506            peer,
1507            kind,
1508            verified_only,
1509            interval,
1510            once,
1511            dry_run,
1512            max_per_minute,
1513            max_chain_depth,
1514        } => cmd_reactor(
1515            &on_event,
1516            peer.as_deref(),
1517            kind.as_deref(),
1518            verified_only,
1519            interval,
1520            once,
1521            dry_run,
1522            max_per_minute,
1523            max_chain_depth,
1524        ),
1525        Command::Notify {
1526            interval,
1527            peer,
1528            once,
1529            json,
1530        } => cmd_notify(interval, peer.as_deref(), once, json),
1531    }
1532}
1533
1534// ---------- init ----------
1535
1536fn cmd_init(handle: &str, name: Option<&str>, relay: Option<&str>, as_json: bool) -> Result<()> {
1537    if !handle
1538        .chars()
1539        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1540    {
1541        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
1542    }
1543    if config::is_initialized()? {
1544        bail!(
1545            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1546            config::config_dir()?
1547        );
1548    }
1549
1550    config::ensure_dirs()?;
1551    let (sk_seed, pk_bytes) = generate_keypair();
1552    config::write_private_key(&sk_seed)?;
1553
1554    let card = build_agent_card(handle, &pk_bytes, name, None, None);
1555    let signed = sign_agent_card(&card, &sk_seed);
1556    config::write_agent_card(&signed)?;
1557
1558    let mut trust = empty_trust();
1559    add_self_to_trust(&mut trust, handle, &pk_bytes);
1560    config::write_trust(&trust)?;
1561
1562    let fp = fingerprint(&pk_bytes);
1563    let key_id = make_key_id(handle, &pk_bytes);
1564
1565    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
1566    let mut relay_info: Option<(String, String)> = None;
1567    if let Some(url) = relay {
1568        let normalized = url.trim_end_matches('/');
1569        let client = crate::relay_client::RelayClient::new(normalized);
1570        client.check_healthz()?;
1571        let alloc = client.allocate_slot(Some(handle))?;
1572        let mut state = config::read_relay_state()?;
1573        state["self"] = json!({
1574            "relay_url": normalized,
1575            "slot_id": alloc.slot_id.clone(),
1576            "slot_token": alloc.slot_token,
1577        });
1578        config::write_relay_state(&state)?;
1579        relay_info = Some((normalized.to_string(), alloc.slot_id));
1580    }
1581
1582    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1583    if as_json {
1584        let mut out = json!({
1585            "did": did_str.clone(),
1586            "fingerprint": fp,
1587            "key_id": key_id,
1588            "config_dir": config::config_dir()?.to_string_lossy(),
1589        });
1590        if let Some((url, slot_id)) = &relay_info {
1591            out["relay_url"] = json!(url);
1592            out["slot_id"] = json!(slot_id);
1593        }
1594        println!("{}", serde_json::to_string(&out)?);
1595    } else {
1596        println!("generated {did_str} (ed25519:{key_id})");
1597        println!(
1598            "config written to {}",
1599            config::config_dir()?.to_string_lossy()
1600        );
1601        if let Some((url, slot_id)) = &relay_info {
1602            println!("bound to relay {url} (slot {slot_id})");
1603            println!();
1604            println!(
1605                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1606            );
1607        } else {
1608            println!();
1609            println!(
1610                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1611            );
1612        }
1613    }
1614    Ok(())
1615}
1616
1617// ---------- status ----------
1618
1619fn cmd_status(as_json: bool) -> Result<()> {
1620    let initialized = config::is_initialized()?;
1621
1622    let mut summary = json!({
1623        "initialized": initialized,
1624    });
1625
1626    if initialized {
1627        let card = config::read_agent_card()?;
1628        let did = card
1629            .get("did")
1630            .and_then(Value::as_str)
1631            .unwrap_or("")
1632            .to_string();
1633        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
1634        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
1635        // legacy cards.
1636        let handle = card
1637            .get("handle")
1638            .and_then(Value::as_str)
1639            .map(str::to_string)
1640            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1641        let pk_b64 = card
1642            .get("verify_keys")
1643            .and_then(Value::as_object)
1644            .and_then(|m| m.values().next())
1645            .and_then(|v| v.get("key"))
1646            .and_then(Value::as_str)
1647            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1648        let pk_bytes = crate::signing::b64decode(pk_b64)?;
1649        summary["did"] = json!(did);
1650        summary["handle"] = json!(handle);
1651        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1652        summary["capabilities"] = card
1653            .get("capabilities")
1654            .cloned()
1655            .unwrap_or_else(|| json!([]));
1656
1657        let trust = config::read_trust()?;
1658        let relay_state_for_tier =
1659            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1660        let mut peers = Vec::new();
1661        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1662            for (peer_handle, _agent) in agents {
1663                if peer_handle == &handle {
1664                    continue; // self
1665                }
1666                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
1667                // for peers we've pinned but never received a pair_drop_ack
1668                // from, so the operator sees the "we can't send to them yet"
1669                // state instead of seeing a misleading VERIFIED.
1670                peers.push(json!({
1671                    "handle": peer_handle,
1672                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
1673                }));
1674            }
1675        }
1676        summary["peers"] = json!(peers);
1677
1678        let relay_state = config::read_relay_state()?;
1679        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
1680        if !summary["self_relay"].is_null() {
1681            // Hide slot_token from default view.
1682            if let Some(obj) = summary["self_relay"].as_object_mut() {
1683                obj.remove("slot_token");
1684            }
1685        }
1686        summary["peer_slots_count"] = json!(
1687            relay_state
1688                .get("peers")
1689                .and_then(Value::as_object)
1690                .map(|m| m.len())
1691                .unwrap_or(0)
1692        );
1693
1694        // Outbox / inbox queue depth (file count + total events)
1695        let outbox = config::outbox_dir()?;
1696        let inbox = config::inbox_dir()?;
1697        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
1698        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
1699
1700        // v0.5.19: liveness snapshot through a single helper so this
1701        // surface and `wire doctor` agree by construction. Issue #2:
1702        // doctor PASSed while status said DOWN for 25 min because each
1703        // computed liveness independently. ensure_up::daemon_liveness
1704        // is the only path now.
1705        let snap = crate::ensure_up::daemon_liveness();
1706        let mut daemon = json!({
1707            "running": snap.pidfile_alive,
1708            "pid": snap.pidfile_pid,
1709            "all_running_pids": snap.pgrep_pids,
1710            "orphans": snap.orphan_pids,
1711        });
1712        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1713            daemon["version"] = json!(d.version);
1714            daemon["bin_path"] = json!(d.bin_path);
1715            daemon["did"] = json!(d.did);
1716            daemon["relay_url"] = json!(d.relay_url);
1717            daemon["started_at"] = json!(d.started_at);
1718            daemon["schema"] = json!(d.schema);
1719            if d.version != env!("CARGO_PKG_VERSION") {
1720                daemon["version_mismatch"] = json!({
1721                    "daemon": d.version.clone(),
1722                    "cli": env!("CARGO_PKG_VERSION"),
1723                });
1724            }
1725        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
1726            daemon["pidfile_form"] = json!("legacy-int");
1727            daemon["version_mismatch"] = json!({
1728                "daemon": "<pre-0.5.11>",
1729                "cli": env!("CARGO_PKG_VERSION"),
1730            });
1731        }
1732        summary["daemon"] = daemon;
1733
1734        // Pending pair sessions — counts by status.
1735        let pending = crate::pending_pair::list_pending().unwrap_or_default();
1736        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
1737        for p in &pending {
1738            *counts.entry(p.status.clone()).or_default() += 1;
1739        }
1740        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
1741        let pending_inbound =
1742            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
1743        let inbound_handles: Vec<&str> = pending_inbound
1744            .iter()
1745            .map(|p| p.peer_handle.as_str())
1746            .collect();
1747        summary["pending_pairs"] = json!({
1748            "total": pending.len(),
1749            "by_status": counts,
1750            "inbound_count": pending_inbound.len(),
1751            "inbound_handles": inbound_handles,
1752        });
1753    }
1754
1755    if as_json {
1756        println!("{}", serde_json::to_string(&summary)?);
1757    } else if !initialized {
1758        println!("not initialized — run `wire init <handle>` first");
1759    } else {
1760        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
1761        println!(
1762            "fingerprint:   {}",
1763            summary["fingerprint"].as_str().unwrap_or("?")
1764        );
1765        println!("capabilities:  {}", summary["capabilities"]);
1766        if !summary["self_relay"].is_null() {
1767            println!(
1768                "self relay:    {} (slot {})",
1769                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
1770                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
1771            );
1772        } else {
1773            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
1774        }
1775        println!(
1776            "peers:         {}",
1777            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
1778        );
1779        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
1780            println!(
1781                "  - {:<20} tier={}",
1782                p["handle"].as_str().unwrap_or(""),
1783                p["tier"].as_str().unwrap_or("?")
1784            );
1785        }
1786        println!(
1787            "outbox:        {} file(s), {} event(s) queued",
1788            summary["outbox"]["files"].as_u64().unwrap_or(0),
1789            summary["outbox"]["events"].as_u64().unwrap_or(0)
1790        );
1791        println!(
1792            "inbox:         {} file(s), {} event(s) received",
1793            summary["inbox"]["files"].as_u64().unwrap_or(0),
1794            summary["inbox"]["events"].as_u64().unwrap_or(0)
1795        );
1796        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
1797        let daemon_pid = summary["daemon"]["pid"]
1798            .as_u64()
1799            .map(|p| p.to_string())
1800            .unwrap_or_else(|| "—".to_string());
1801        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
1802        let version_suffix = if !daemon_version.is_empty() {
1803            format!(" v{daemon_version}")
1804        } else {
1805            String::new()
1806        };
1807        println!(
1808            "daemon:        {} (pid {}{})",
1809            if daemon_running { "running" } else { "DOWN" },
1810            daemon_pid,
1811            version_suffix,
1812        );
1813        // P1.7: surface version mismatch + orphan procs loudly.
1814        if let Some(mm) = summary["daemon"].get("version_mismatch") {
1815            println!(
1816                "               !! version mismatch: daemon={} CLI={}. \
1817                 run `wire upgrade` to swap atomically.",
1818                mm["daemon"].as_str().unwrap_or("?"),
1819                mm["cli"].as_str().unwrap_or("?"),
1820            );
1821        }
1822        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
1823            && !orphans.is_empty()
1824        {
1825            let pids: Vec<String> = orphans
1826                .iter()
1827                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
1828                .collect();
1829            println!(
1830                "               !! orphan daemon process(es): pids {}. \
1831                 pgrep saw them but pidfile didn't — likely stale process from \
1832                 prior install. Multiple daemons race the relay cursor.",
1833                pids.join(", ")
1834            );
1835        }
1836        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
1837        let inbound_count = summary["pending_pairs"]["inbound_count"]
1838            .as_u64()
1839            .unwrap_or(0);
1840        if pending_total > 0 {
1841            print!("pending pairs: {pending_total}");
1842            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
1843                let parts: Vec<String> = obj
1844                    .iter()
1845                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
1846                    .collect();
1847                if !parts.is_empty() {
1848                    print!(" ({})", parts.join(", "));
1849                }
1850            }
1851            println!();
1852        } else if inbound_count == 0 {
1853            println!("pending pairs: none");
1854        }
1855        // v0.5.14: separate line for pending-inbound zero-paste requests.
1856        // Loud because each one is awaiting an operator gesture and the
1857        // capability hasn't flowed yet.
1858        if inbound_count > 0 {
1859            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
1860                .as_array()
1861                .map(|a| {
1862                    a.iter()
1863                        .filter_map(|v| v.as_str().map(str::to_string))
1864                        .collect()
1865                })
1866                .unwrap_or_default();
1867            println!(
1868                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
1869                handles.join(", "),
1870            );
1871        }
1872    }
1873    Ok(())
1874}
1875
1876fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
1877    if !dir.exists() {
1878        return Ok(json!({"files": 0, "events": 0}));
1879    }
1880    let mut files = 0usize;
1881    let mut events = 0usize;
1882    for entry in std::fs::read_dir(dir)? {
1883        let path = entry?.path();
1884        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
1885            files += 1;
1886            if let Ok(body) = std::fs::read_to_string(&path) {
1887                events += body.lines().filter(|l| !l.trim().is_empty()).count();
1888            }
1889        }
1890    }
1891    Ok(json!({"files": files, "events": events}))
1892}
1893
1894// ---------- responder health ----------
1895
1896fn responder_status_allowed(status: &str) -> bool {
1897    matches!(
1898        status,
1899        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
1900    )
1901}
1902
1903fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
1904    let state = config::read_relay_state()?;
1905    let (label, slot_info) = match peer {
1906        Some(peer) => (
1907            peer.to_string(),
1908            state
1909                .get("peers")
1910                .and_then(|p| p.get(peer))
1911                .ok_or_else(|| {
1912                    anyhow!(
1913                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
1914                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
1915                         (`wire peers` lists who you've already paired with.)"
1916                    )
1917                })?,
1918        ),
1919        None => (
1920            "self".to_string(),
1921            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
1922                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
1923            })?,
1924        ),
1925    };
1926    let relay_url = slot_info["relay_url"]
1927        .as_str()
1928        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
1929        .to_string();
1930    let slot_id = slot_info["slot_id"]
1931        .as_str()
1932        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
1933        .to_string();
1934    let slot_token = slot_info["slot_token"]
1935        .as_str()
1936        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
1937        .to_string();
1938    Ok((label, relay_url, slot_id, slot_token))
1939}
1940
1941fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
1942    if !responder_status_allowed(status) {
1943        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
1944    }
1945    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
1946    let now = time::OffsetDateTime::now_utc()
1947        .format(&time::format_description::well_known::Rfc3339)
1948        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1949    let mut record = json!({
1950        "status": status,
1951        "set_at": now,
1952    });
1953    if let Some(reason) = reason {
1954        record["reason"] = json!(reason);
1955    }
1956    if status == "online" {
1957        record["last_success_at"] = json!(now);
1958    }
1959    let client = crate::relay_client::RelayClient::new(&relay_url);
1960    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
1961    if as_json {
1962        println!("{}", serde_json::to_string(&saved)?);
1963    } else {
1964        let reason = saved
1965            .get("reason")
1966            .and_then(Value::as_str)
1967            .map(|r| format!(" — {r}"))
1968            .unwrap_or_default();
1969        println!(
1970            "responder {}{}",
1971            saved
1972                .get("status")
1973                .and_then(Value::as_str)
1974                .unwrap_or(status),
1975            reason
1976        );
1977    }
1978    Ok(())
1979}
1980
1981fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
1982    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
1983    let client = crate::relay_client::RelayClient::new(&relay_url);
1984    let health = client.responder_health_get(&slot_id, &slot_token)?;
1985    if as_json {
1986        println!(
1987            "{}",
1988            serde_json::to_string(&json!({
1989                "target": label,
1990                "responder_health": health,
1991            }))?
1992        );
1993    } else if health.is_null() {
1994        println!("{label}: responder health not reported");
1995    } else {
1996        let status = health
1997            .get("status")
1998            .and_then(Value::as_str)
1999            .unwrap_or("unknown");
2000        let reason = health
2001            .get("reason")
2002            .and_then(Value::as_str)
2003            .map(|r| format!(" — {r}"))
2004            .unwrap_or_default();
2005        let last_success = health
2006            .get("last_success_at")
2007            .and_then(Value::as_str)
2008            .map(|t| format!(" (last_success: {t})"))
2009            .unwrap_or_default();
2010        println!("{label}: {status}{reason}{last_success}");
2011    }
2012    Ok(())
2013}
2014
2015fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2016    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2017    let client = crate::relay_client::RelayClient::new(&relay_url);
2018
2019    let started = std::time::Instant::now();
2020    let transport_ok = client.healthz().unwrap_or(false);
2021    let latency_ms = started.elapsed().as_millis() as u64;
2022
2023    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2024    let now = std::time::SystemTime::now()
2025        .duration_since(std::time::UNIX_EPOCH)
2026        .map(|d| d.as_secs())
2027        .unwrap_or(0);
2028    let attention = match last_pull_at_unix {
2029        Some(last) if now.saturating_sub(last) <= 300 => json!({
2030            "status": "ok",
2031            "last_pull_at_unix": last,
2032            "age_seconds": now.saturating_sub(last),
2033            "event_count": event_count,
2034        }),
2035        Some(last) => json!({
2036            "status": "stale",
2037            "last_pull_at_unix": last,
2038            "age_seconds": now.saturating_sub(last),
2039            "event_count": event_count,
2040        }),
2041        None => json!({
2042            "status": "never_pulled",
2043            "last_pull_at_unix": Value::Null,
2044            "event_count": event_count,
2045        }),
2046    };
2047
2048    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2049    let responder = if responder_health.is_null() {
2050        json!({"status": "not_reported", "record": Value::Null})
2051    } else {
2052        json!({
2053            "status": responder_health
2054                .get("status")
2055                .and_then(Value::as_str)
2056                .unwrap_or("unknown"),
2057            "record": responder_health,
2058        })
2059    };
2060
2061    let report = json!({
2062        "peer": peer,
2063        "transport": {
2064            "status": if transport_ok { "ok" } else { "error" },
2065            "relay_url": relay_url,
2066            "latency_ms": latency_ms,
2067        },
2068        "attention": attention,
2069        "responder": responder,
2070    });
2071
2072    if as_json {
2073        println!("{}", serde_json::to_string(&report)?);
2074    } else {
2075        let transport_line = if transport_ok {
2076            format!("ok relay reachable ({latency_ms}ms)")
2077        } else {
2078            "error relay unreachable".to_string()
2079        };
2080        println!("transport      {transport_line}");
2081        match report["attention"]["status"].as_str().unwrap_or("unknown") {
2082            "ok" => println!(
2083                "attention      ok last pull {}s ago",
2084                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2085            ),
2086            "stale" => println!(
2087                "attention      stale last pull {}m ago",
2088                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2089            ),
2090            "never_pulled" => println!("attention      never pulled since relay reset"),
2091            other => println!("attention      {other}"),
2092        }
2093        if report["responder"]["status"] == "not_reported" {
2094            println!("auto-responder not reported");
2095        } else {
2096            let record = &report["responder"]["record"];
2097            let status = record
2098                .get("status")
2099                .and_then(Value::as_str)
2100                .unwrap_or("unknown");
2101            let reason = record
2102                .get("reason")
2103                .and_then(Value::as_str)
2104                .map(|r| format!(" — {r}"))
2105                .unwrap_or_default();
2106            println!("auto-responder {status}{reason}");
2107        }
2108    }
2109    Ok(())
2110}
2111
2112// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
2113
2114// ---------- whoami ----------
2115
2116/// Return the current cwd with the user's home dir abbreviated to `~/`.
2117/// Used in whoami `--short` / `--colored` output so multi-window operators
2118/// see *what project* each Claude is working in alongside the character.
2119fn current_cwd_display() -> String {
2120    let cwd = match std::env::current_dir() {
2121        Ok(c) => c,
2122        Err(_) => return String::from("?"),
2123    };
2124    if let Some(home) = dirs::home_dir()
2125        && let Ok(rel) = cwd.strip_prefix(&home)
2126    {
2127        // strip_prefix returns "" for cwd == home itself; show "~" then.
2128        let rel_str = rel.to_string_lossy();
2129        if rel_str.is_empty() {
2130            return String::from("~");
2131        }
2132        return format!("~/{}", rel_str);
2133    }
2134    cwd.to_string_lossy().into_owned()
2135}
2136
2137fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2138    if !config::is_initialized()? {
2139        bail!("not initialized — run `wire init <handle>` first");
2140    }
2141    let card = config::read_agent_card()?;
2142    let did = card
2143        .get("did")
2144        .and_then(Value::as_str)
2145        .unwrap_or("")
2146        .to_string();
2147    let handle = card
2148        .get("handle")
2149        .and_then(Value::as_str)
2150        .map(str::to_string)
2151        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2152    // v0.7.0-alpha.3: read sidecar display.json for any operator-chosen
2153    // override of nickname/emoji. Palette stays auto-derived from DID.
2154    let overrides = config::read_display_overrides().unwrap_or_default();
2155    let character = crate::character::Character::from_did_with_override(
2156        &did,
2157        overrides.nickname.as_deref(),
2158        overrides.emoji.as_deref(),
2159    );
2160
2161    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
2162    // so operators tab-flipping between multiple Claude windows see both
2163    // *who* this session is (character) and *what* it's working on (cwd).
2164    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
2165    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
2166    let cwd_display = current_cwd_display();
2167
2168    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
2169    // beyond did — these calls are hot (statusline polls ~300ms).
2170    if short {
2171        println!("{} · {}", character.short(), cwd_display);
2172        return Ok(());
2173    }
2174    if colored {
2175        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2176        return Ok(());
2177    }
2178
2179    let pk_b64 = card
2180        .get("verify_keys")
2181        .and_then(Value::as_object)
2182        .and_then(|m| m.values().next())
2183        .and_then(|v| v.get("key"))
2184        .and_then(Value::as_str)
2185        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2186    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2187    let fp = fingerprint(&pk_bytes);
2188    let key_id = make_key_id(&handle, &pk_bytes);
2189    let capabilities = card
2190        .get("capabilities")
2191        .cloned()
2192        .unwrap_or_else(|| json!(["wire/v3.1"]));
2193
2194    if as_json {
2195        let has_override = overrides.nickname.is_some() || overrides.emoji.is_some();
2196        println!(
2197            "{}",
2198            serde_json::to_string(&json!({
2199                "did": did,
2200                "handle": handle,
2201                "fingerprint": fp,
2202                "key_id": key_id,
2203                "public_key_b64": pk_b64,
2204                "capabilities": capabilities,
2205                "config_dir": config::config_dir()?.to_string_lossy(),
2206                "character": character,
2207                "character_override": has_override,
2208            }))?
2209        );
2210    } else {
2211        println!("{}", character.colored());
2212        println!("{did} (ed25519:{key_id})");
2213        println!("fingerprint: {fp}");
2214        println!("capabilities: {capabilities}");
2215    }
2216    Ok(())
2217}
2218
2219// ---------- identity (v0.7.0-alpha.3) ----------
2220
2221fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2222    match cmd {
2223        IdentityCommand::Rename {
2224            name,
2225            emoji,
2226            clear,
2227            random,
2228            json,
2229        } => cmd_identity_rename(name, emoji, clear || random, random, json),
2230        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2231        IdentityCommand::List { json } => cmd_session_list(json),
2232        IdentityCommand::Publish {
2233            nick,
2234            relay,
2235            public_url,
2236            hidden,
2237            json,
2238        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2239        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2240        IdentityCommand::Create {
2241            name,
2242            anonymous,
2243            local: _,
2244            json,
2245        } => cmd_identity_create(name.as_deref(), anonymous, json),
2246        IdentityCommand::Persist {
2247            name,
2248            as_name,
2249            json,
2250        } => cmd_identity_persist(&name, as_name.as_deref(), json),
2251        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2252    }
2253}
2254
2255/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
2256/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
2257/// paste into their shell; the identity lives there until reboot
2258/// clears /tmp. Persist promotes it to the real sessions root.
2259fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2260    if anonymous {
2261        // Generate a unique tmpdir for this anonymous identity.
2262        let rand_suffix = format!("{:08x}", rand::random::<u32>());
2263        let anon_name = name
2264            .map(crate::session::sanitize_name)
2265            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2266        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2267        std::fs::create_dir_all(&anon_root)
2268            .with_context(|| format!("creating anon root {anon_root:?}"))?;
2269        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
2270        let session_home = anon_root.join("sessions").join(&anon_name);
2271        std::fs::create_dir_all(&session_home)?;
2272        let status = run_wire_with_home(&session_home, &["init", &anon_name])?;
2273        if !status.success() {
2274            bail!("anonymous identity init failed: {status}");
2275        }
2276        // Register the anonymous name in a SIDE registry so persist
2277        // can find it later. Stored at <anon_root>/anon-marker.json.
2278        let marker = anon_root.join("anon-marker.json");
2279        std::fs::write(
2280            &marker,
2281            serde_json::to_vec_pretty(&serde_json::json!({
2282                "name": anon_name,
2283                "session_home": session_home.to_string_lossy(),
2284                "created_at": time::OffsetDateTime::now_utc()
2285                    .format(&time::format_description::well_known::Rfc3339)
2286                    .unwrap_or_default(),
2287                "kind": "anonymous",
2288            }))?,
2289        )?;
2290        let card = serde_json::from_slice::<Value>(&std::fs::read(
2291            session_home
2292                .join("config")
2293                .join("wire")
2294                .join("agent-card.json"),
2295        )?)?;
2296        let did = card
2297            .get("did")
2298            .and_then(Value::as_str)
2299            .unwrap_or("")
2300            .to_string();
2301        if as_json {
2302            println!(
2303                "{}",
2304                serde_json::to_string(&json!({
2305                    "kind": "anonymous",
2306                    "name": anon_name,
2307                    "did": did,
2308                    "session_home": session_home.to_string_lossy(),
2309                    "anon_root": anon_root.to_string_lossy(),
2310                }))?
2311            );
2312        } else {
2313            println!("created anonymous identity `{anon_name}` ({did})");
2314            println!(
2315                "  session_home: {} (dies on reboot — /tmp)",
2316                session_home.display()
2317            );
2318            println!();
2319            println!("activate in this shell:");
2320            println!("  export WIRE_HOME={}", session_home.display());
2321            println!();
2322            println!("promote to persistent later with:");
2323            println!("  wire identity persist {anon_name}");
2324        }
2325        return Ok(());
2326    }
2327    // --local (or default): delegate to existing session new flow.
2328    let name_arg = name.map(|s| s.to_string());
2329    cmd_session_new(
2330        name_arg.as_deref(),
2331        "https://wireup.net",
2332        false,
2333        "http://127.0.0.1:8771",
2334        false,
2335        None,
2336        false,
2337        None,
2338        true, // no_daemon: identity create just allocates the identity, no daemon
2339        true, // local_only: explicit lifecycle
2340        as_json,
2341    )
2342}
2343
2344/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
2345/// tmpdir to the persistent sessions root + registers in the cwd map.
2346fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2347    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
2348    let temp = std::env::temp_dir();
2349    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2350    for entry in std::fs::read_dir(&temp)?.flatten() {
2351        let path = entry.path();
2352        if !path
2353            .file_name()
2354            .and_then(|s| s.to_str())
2355            .map(|s| s.starts_with("wire-anon-"))
2356            .unwrap_or(false)
2357        {
2358            continue;
2359        }
2360        let marker = path.join("anon-marker.json");
2361        if let Ok(bytes) = std::fs::read(&marker)
2362            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2363            && json.get("name").and_then(Value::as_str) == Some(name)
2364        {
2365            let session_home = json
2366                .get("session_home")
2367                .and_then(Value::as_str)
2368                .map(std::path::PathBuf::from)
2369                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2370            found = Some((path, session_home));
2371            break;
2372        }
2373    }
2374    let (anon_root, anon_session_home) = found.ok_or_else(|| {
2375        anyhow!(
2376            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2377             run `wire identity list` to see available identities"
2378        )
2379    })?;
2380
2381    let new_name = as_name.unwrap_or(name);
2382    let new_session_home = crate::session::session_dir(new_name)?;
2383    if new_session_home.exists() {
2384        bail!(
2385            "target session `{new_name}` already exists at {new_session_home:?} — \
2386             pick a different name with --as <new-name>"
2387        );
2388    }
2389
2390    // Move the session dir from tmpdir to persistent root.
2391    if let Some(parent) = new_session_home.parent() {
2392        std::fs::create_dir_all(parent)?;
2393    }
2394    std::fs::rename(&anon_session_home, &new_session_home)
2395        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2396
2397    // Clean up the (now-empty) anon root + marker.
2398    let _ = std::fs::remove_dir_all(&anon_root);
2399
2400    // Register cwd → new_name (operator may have cd'd elsewhere; use the
2401    // session_home's grandparent as the conceptual "cwd" if no other).
2402    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2403    let cwd_key = cwd.to_string_lossy().into_owned();
2404    let new_name_for_reg = new_name.to_string();
2405    if let Err(e) = crate::session::update_registry(|reg| {
2406        reg.by_cwd.insert(cwd_key, new_name_for_reg);
2407        Ok(())
2408    }) {
2409        eprintln!("wire identity persist: failed to update registry: {e:#}");
2410    }
2411
2412    if as_json {
2413        println!(
2414            "{}",
2415            serde_json::to_string(&json!({
2416                "kind": "persisted",
2417                "from_name": name,
2418                "to_name": new_name,
2419                "session_home": new_session_home.to_string_lossy(),
2420            }))?
2421        );
2422    } else {
2423        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2424        println!(
2425            "  session_home: {} (survives reboot)",
2426            new_session_home.display()
2427        );
2428        println!("  registered cwd: {}", cwd.display());
2429    }
2430    Ok(())
2431}
2432
2433/// v0.7.0-alpha.20: demote federation → local. Removes the federation
2434/// slot binding from relay.json (and the legacy top-level fields). Keeps
2435/// the keypair + agent-card so re-publish later just calls `wire identity
2436/// publish` again. local → anonymous is NOT supported; destroy + recreate
2437/// is the safer path for that step-down.
2438fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2439    let sessions = crate::session::list_sessions()?;
2440    let session = sessions
2441        .iter()
2442        .find(|s| s.name == name)
2443        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2444    let relay_state_path = session
2445        .home_dir
2446        .join("config")
2447        .join("wire")
2448        .join("relay.json");
2449    if !relay_state_path.exists() {
2450        bail!("session `{name}` has no relay state — already demoted?");
2451    }
2452    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2453    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2454    let had_fed = self_obj
2455        .get("relay_url")
2456        .and_then(Value::as_str)
2457        .map(|u| {
2458            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2459        })
2460        .unwrap_or(false);
2461    if !had_fed {
2462        if as_json {
2463            println!(
2464                "{}",
2465                serde_json::to_string(
2466                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2467                )?
2468            );
2469        } else {
2470            println!("session `{name}` has no federation slot — nothing to demote");
2471        }
2472        return Ok(());
2473    }
2474    // Strip federation: remove top-level relay_url/slot_id/slot_token,
2475    // remove federation-scope entries from endpoints[].
2476    if let Some(self_mut) = state
2477        .as_object_mut()
2478        .and_then(|m| m.get_mut("self"))
2479        .and_then(|s| s.as_object_mut())
2480    {
2481        self_mut.remove("relay_url");
2482        self_mut.remove("slot_id");
2483        self_mut.remove("slot_token");
2484        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2485            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2486        }
2487    }
2488    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2489
2490    if as_json {
2491        println!(
2492            "{}",
2493            serde_json::to_string(
2494                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2495            )?
2496        );
2497    } else {
2498        println!("demoted `{name}` from federation → local");
2499        println!("  relay slot binding removed; keypair + agent-card retained");
2500        println!("  re-publish with `wire identity publish <nick>`");
2501    }
2502    Ok(())
2503}
2504
2505fn cmd_identity_rename(
2506    name: Option<String>,
2507    emoji: Option<String>,
2508    clear: bool,
2509    random_announce: bool,
2510    as_json: bool,
2511) -> Result<()> {
2512    if !config::is_initialized()? {
2513        bail!("not initialized — run `wire init <handle>` first");
2514    }
2515
2516    // Read DID once for character derivation in the response.
2517    let card = config::read_agent_card()?;
2518    let did = card
2519        .get("did")
2520        .and_then(Value::as_str)
2521        .unwrap_or("")
2522        .to_string();
2523
2524    let new_overrides = if clear {
2525        config::DisplayOverrides::default()
2526    } else {
2527        // Merge: keep existing fields if not explicitly provided.
2528        let mut existing = config::read_display_overrides().unwrap_or_default();
2529        if let Some(n) = name {
2530            // v0.7.0-alpha.8 (review-fix #1): sanitize at write time and
2531            // refuse anything that fully reduces to empty (operator typed
2532            // only control chars / escape sequences). Defense against
2533            // self-pwn AND silent-no-op writes.
2534            let cleaned = crate::character::sanitize_display_text(&n);
2535            if cleaned.is_empty() {
2536                bail!(
2537                    "nickname `{n:?}` is empty after stripping control characters — pick a name with printable codepoints (max {} chars).",
2538                    crate::character::MAX_DISPLAY_CHARS
2539                );
2540            }
2541            if cleaned != n {
2542                eprintln!(
2543                    "wire identity rename: stripped control characters from nickname → `{cleaned}`"
2544                );
2545            }
2546            existing.nickname = Some(cleaned);
2547        }
2548        if let Some(e) = emoji {
2549            let cleaned = crate::character::sanitize_display_text(&e);
2550            if cleaned.is_empty() {
2551                bail!(
2552                    "emoji `{e:?}` is empty after stripping control characters — pick a printable emoji glyph."
2553                );
2554            }
2555            if cleaned != e {
2556                eprintln!(
2557                    "wire identity rename: stripped control characters from emoji → `{cleaned}`"
2558                );
2559            }
2560            existing.emoji = Some(cleaned);
2561        }
2562        existing
2563    };
2564
2565    // If clearing AND no overrides existed AND no flags given, refuse so we
2566    // don't silently no-op. Random implies clear with announcement.
2567    let no_fields_provided = new_overrides.nickname.is_none()
2568        && new_overrides.emoji.is_none()
2569        && !clear
2570        && !random_announce;
2571    if no_fields_provided {
2572        bail!("nothing to do — pass --name, --emoji, --clear, or --random");
2573    }
2574
2575    config::write_display_overrides(&new_overrides)?;
2576
2577    // v0.7.0-alpha.6: publish the override on the agent-card so federated
2578    // peers see what we call ourselves, not just the DID-hash default.
2579    // Re-signs the card with the same private key the rest of the identity
2580    // uses. Backward compat: peers with old wire versions ignore the
2581    // unknown `display` field, fall back to auto-derived.
2582    //
2583    // v0.7.0-alpha.12 (review-fix #134): also push the re-signed card
2584    // back to the federation relay so .well-known/wire/agent serves the
2585    // updated card. Pre-fix wrote the local card only; federated peers
2586    // resolving the handle saw the OLD (pre-rename) card. Best-effort —
2587    // failures log to stderr but don't bail (local rename still useful).
2588    let signed_card = {
2589        let mut card = config::read_agent_card()?;
2590        if let Some(card_obj) = card.as_object_mut() {
2591            // Strip prior signature; we'll re-sign over the new canonical
2592            // form including (or excluding) the display field.
2593            card_obj.remove("signature");
2594            if new_overrides.nickname.is_none() && new_overrides.emoji.is_none() {
2595                card_obj.remove("display");
2596            } else {
2597                let mut display = serde_json::Map::new();
2598                if let Some(n) = &new_overrides.nickname {
2599                    display.insert("nickname".into(), Value::String(n.clone()));
2600                }
2601                if let Some(e) = &new_overrides.emoji {
2602                    display.insert("emoji".into(), Value::String(e.clone()));
2603                }
2604                card_obj.insert("display".into(), Value::Object(display));
2605            }
2606        }
2607        let sk_seed = config::read_private_key()?;
2608        let signed = crate::agent_card::sign_agent_card(&card, &sk_seed);
2609        config::write_agent_card(&signed)?;
2610        signed
2611    };
2612
2613    // Re-publish to federation relay if we're bound. Walks the relay_state
2614    // self endpoints — pushes the updated card to whichever federation
2615    // relay holds our claimed handle. Local-only sessions skip silently.
2616    if let Ok(state) = config::read_relay_state() {
2617        let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2618        let fed_url = self_obj.get("relay_url").and_then(Value::as_str);
2619        let fed_slot_id = self_obj.get("slot_id").and_then(Value::as_str);
2620        let fed_slot_token = self_obj.get("slot_token").and_then(Value::as_str);
2621        if let (Some(url), Some(slot_id), Some(slot_token)) = (fed_url, fed_slot_id, fed_slot_token)
2622        {
2623            // Skip loopback / LAN relays (those don't publish handles to a
2624            // public phonebook — they're local-only mode).
2625            let is_publishable = url.starts_with("https://")
2626                || (url.starts_with("http://")
2627                    && !url.contains("127.0.0.1")
2628                    && !url.contains("localhost"));
2629            if is_publishable {
2630                let nick_for_claim = signed_card
2631                    .get("handle")
2632                    .and_then(Value::as_str)
2633                    .map(str::to_string);
2634                if let Some(nick) = nick_for_claim {
2635                    let client = crate::relay_client::RelayClient::new(url);
2636                    match client.handle_claim_v2(
2637                        &nick,
2638                        slot_id,
2639                        slot_token,
2640                        None,
2641                        &signed_card,
2642                        None,
2643                    ) {
2644                        Ok(_) => {
2645                            eprintln!("wire identity rename: re-published updated card to {url}");
2646                        }
2647                        Err(e) => {
2648                            eprintln!(
2649                                "wire identity rename: failed to re-publish to relay {url}: {e:#} — local rename is in effect; federated peers will see the old card until next `wire claim` succeeds"
2650                            );
2651                        }
2652                    }
2653                }
2654            }
2655        }
2656    }
2657
2658    if random_announce {
2659        eprintln!(
2660            "wire identity rename: overrides cleared; falling back to auto-derived character (DID-deterministic, so the character is the same as it was before any rename)."
2661        );
2662    }
2663
2664    let character = crate::character::Character::from_did_with_override(
2665        &did,
2666        new_overrides.nickname.as_deref(),
2667        new_overrides.emoji.as_deref(),
2668    );
2669
2670    if as_json {
2671        println!(
2672            "{}",
2673            serde_json::to_string(&json!({
2674                "did": did,
2675                "character": character,
2676                "overrides": new_overrides,
2677            }))?
2678        );
2679    } else {
2680        println!("renamed → {}", character.colored());
2681        eprintln!("  · palette stays DID-derived (sticky color across renames)");
2682        eprintln!(
2683            "  · re-published to your federation relay (if bound); future federation lookups serve \
2684             the updated card. Existing pinned peers have a cached card from pair-time and won't \
2685             see the new name until they re-pair OR fetch your card fresh."
2686        );
2687    }
2688    Ok(())
2689}
2690
2691// ---------- peers ----------
2692
2693/// P0.Y (0.5.11): effective tier shown to operators. `wire add` pins a
2694/// peer's card into trust at VERIFIED immediately, but the bilateral pin
2695/// isn't complete until that peer's `pair_drop_ack` arrives carrying their
2696/// slot_token. Until then we CAN'T send to them. Displaying VERIFIED is
2697/// misleading — spark observed this in real usage.
2698///
2699/// Effective rules:
2700///   trust.tier == VERIFIED + relay_state.peers[h].slot_token empty -> "PENDING_ACK"
2701///   otherwise -> raw trust tier (UNTRUSTED / VERIFIED / etc.)
2702///
2703/// Strictly a display concern — trust state machine itself is untouched
2704/// so existing promote/demote logic still works.
2705fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2706    let raw = crate::trust::get_tier(trust, handle);
2707    if raw != "VERIFIED" {
2708        return raw.to_string();
2709    }
2710    let token = relay_state
2711        .get("peers")
2712        .and_then(|p| p.get(handle))
2713        .and_then(|p| p.get("slot_token"))
2714        .and_then(Value::as_str)
2715        .unwrap_or("");
2716    if token.is_empty() {
2717        "PENDING_ACK".to_string()
2718    } else {
2719        raw.to_string()
2720    }
2721}
2722
2723fn cmd_peers(as_json: bool) -> Result<()> {
2724    let trust = config::read_trust()?;
2725    let agents = trust
2726        .get("agents")
2727        .and_then(Value::as_object)
2728        .cloned()
2729        .unwrap_or_default();
2730    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2731
2732    let mut self_did: Option<String> = None;
2733    if let Ok(card) = config::read_agent_card() {
2734        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2735    }
2736
2737    let mut peers = Vec::new();
2738    for (handle, agent) in agents.iter() {
2739        let did = agent
2740            .get("did")
2741            .and_then(Value::as_str)
2742            .unwrap_or("")
2743            .to_string();
2744        if Some(did.as_str()) == self_did.as_deref() {
2745            continue; // skip self-attestation
2746        }
2747        let tier = effective_peer_tier(&trust, &relay_state, handle);
2748        let capabilities = agent
2749            .get("card")
2750            .and_then(|c| c.get("capabilities"))
2751            .cloned()
2752            .unwrap_or_else(|| json!([]));
2753        // v0.7.0-alpha.6: prefer peer's published character override
2754        // (display.nickname / display.emoji on their pinned agent-card).
2755        // Falls back to auto-derived if peer hasn't renamed themselves
2756        // OR runs an older wire that doesn't publish the field.
2757        let character = if did.is_empty() {
2758            None
2759        } else {
2760            let card_obj = agent.get("card");
2761            Some(match card_obj {
2762                Some(card) => crate::character::Character::from_card(card),
2763                None => crate::character::Character::from_did(&did),
2764            })
2765        };
2766        peers.push(json!({
2767            "handle": handle,
2768            "did": did,
2769            "tier": tier,
2770            "capabilities": capabilities,
2771            "character": character,
2772        }));
2773    }
2774
2775    if as_json {
2776        println!("{}", serde_json::to_string(&peers)?);
2777    } else if peers.is_empty() {
2778        println!("no peers pinned (run `wire join <code>` to pair)");
2779    } else {
2780        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
2781        // computed above (from peer's agent-card, honoring override) so
2782        // text and JSON output never diverge. Pre-alpha.8 the text loop
2783        // recomputed via Character::from_did (no override) — operators
2784        // saw different identities depending on --json flag.
2785        for p in &peers {
2786            let char_json = &p["character"];
2787            let (colored_char, plain_len): (String, usize) = match char_json {
2788                serde_json::Value::Null => ("?".to_string(), 1),
2789                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2790                    Ok(c) => {
2791                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
2792                        (c.colored(), plain)
2793                    }
2794                    Err(_) => ("?".to_string(), 1),
2795                },
2796            };
2797            let pad = 22usize.saturating_sub(plain_len);
2798            println!(
2799                "{}{}  {:<20} {:<10} {}",
2800                colored_char,
2801                " ".repeat(pad),
2802                p["handle"].as_str().unwrap_or(""),
2803                p["tier"].as_str().unwrap_or(""),
2804                p["did"].as_str().unwrap_or(""),
2805            );
2806        }
2807    }
2808    Ok(())
2809}
2810
2811// ---------- send ----------
2812
2813/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
2814///
2815/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
2816/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
2817/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
2818/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
2819/// hasn't crossed two heartbeats means probably degraded.
2820fn maybe_warn_peer_attentiveness(peer: &str) {
2821    let state = match config::read_relay_state() {
2822        Ok(s) => s,
2823        Err(_) => return,
2824    };
2825    let p = state.get("peers").and_then(|p| p.get(peer));
2826    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2827        Some(s) if !s.is_empty() => s,
2828        _ => return,
2829    };
2830    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2831        Some(s) if !s.is_empty() => s,
2832        _ => return,
2833    };
2834    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
2835        Some(s) if !s.is_empty() => s.to_string(),
2836        _ => match state
2837            .get("self")
2838            .and_then(|s| s.get("relay_url"))
2839            .and_then(Value::as_str)
2840        {
2841            Some(s) if !s.is_empty() => s.to_string(),
2842            _ => return,
2843        },
2844    };
2845    let client = crate::relay_client::RelayClient::new(&relay_url);
2846    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
2847        Ok(t) => t,
2848        Err(_) => return,
2849    };
2850    let now = std::time::SystemTime::now()
2851        .duration_since(std::time::UNIX_EPOCH)
2852        .map(|d| d.as_secs())
2853        .unwrap_or(0);
2854    match last_pull {
2855        None => {
2856            eprintln!(
2857                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
2858            );
2859        }
2860        Some(t) if now.saturating_sub(t) > 300 => {
2861            let mins = now.saturating_sub(t) / 60;
2862            eprintln!(
2863                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
2864            );
2865        }
2866        _ => {}
2867    }
2868}
2869
2870pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
2871    let trimmed = input.trim();
2872    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
2873    {
2874        return Ok(trimmed.to_string());
2875    }
2876    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
2877    let n: i64 = amount
2878        .parse()
2879        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
2880    if n <= 0 {
2881        bail!("deadline duration must be positive: {input:?}");
2882    }
2883    let duration = match unit {
2884        "m" => time::Duration::minutes(n),
2885        "h" => time::Duration::hours(n),
2886        "d" => time::Duration::days(n),
2887        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
2888    };
2889    Ok((time::OffsetDateTime::now_utc() + duration)
2890        .format(&time::format_description::well_known::Rfc3339)
2891        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
2892}
2893
2894fn cmd_send(
2895    peer: &str,
2896    kind: &str,
2897    body_arg: &str,
2898    deadline: Option<&str>,
2899    as_json: bool,
2900) -> Result<()> {
2901    if !config::is_initialized()? {
2902        bail!("not initialized — run `wire init <handle>` first");
2903    }
2904    let peer_in = crate::agent_card::bare_handle(peer).to_string();
2905    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
2906    // match wins; nickname (DID-hash auto-derived) is the fallback.
2907    // Ambiguous nicknames (two pinned peers DID-hash to the same
2908    // adj-noun pair) fail loudly with disambiguation; unknown handles
2909    // pass through (matches existing `wire send` semantics — queue
2910    // first, deliver best-effort).
2911    let peer = match resolve_peer_handle(&peer_in) {
2912        Ok(Some(resolved)) if resolved != peer_in => {
2913            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
2914            resolved
2915        }
2916        Ok(Some(canonical)) => canonical, // exact handle match
2917        Ok(None) => peer_in,              // unknown — pass through, downstream errors
2918        Err(ResolveError::Ambiguous(candidates)) => bail!(
2919            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
2920             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
2921            candidates.len(),
2922            candidates.join(", ")
2923        ),
2924        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
2925    };
2926    let peer = peer.as_str();
2927    let sk_seed = config::read_private_key()?;
2928    let card = config::read_agent_card()?;
2929    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
2930    let handle = crate::agent_card::display_handle_from_did(did).to_string();
2931    let pk_b64 = card
2932        .get("verify_keys")
2933        .and_then(Value::as_object)
2934        .and_then(|m| m.values().next())
2935        .and_then(|v| v.get("key"))
2936        .and_then(Value::as_str)
2937        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2938    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2939
2940    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
2941    // P0.S (0.5.11): stdin support lets shells pipe in long content
2942    // without quoting/escaping ceremony, and supports heredocs naturally:
2943    //   wire send peer - <<EOF ... EOF
2944    let body_value: Value = if body_arg == "-" {
2945        use std::io::Read;
2946        let mut raw = String::new();
2947        std::io::stdin()
2948            .read_to_string(&mut raw)
2949            .with_context(|| "reading body from stdin")?;
2950        // Try parsing as JSON first; fall back to string literal for
2951        // plain-text bodies.
2952        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
2953    } else if let Some(path) = body_arg.strip_prefix('@') {
2954        let raw =
2955            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
2956        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
2957    } else {
2958        Value::String(body_arg.to_string())
2959    };
2960
2961    let kind_id = parse_kind(kind)?;
2962
2963    let now = time::OffsetDateTime::now_utc()
2964        .format(&time::format_description::well_known::Rfc3339)
2965        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2966
2967    let mut event = json!({
2968        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
2969        "timestamp": now,
2970        "from": did,
2971        "to": format!("did:wire:{peer}"),
2972        "type": kind,
2973        "kind": kind_id,
2974        "body": body_value,
2975    });
2976    if let Some(deadline) = deadline {
2977        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
2978    }
2979    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
2980    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
2981
2982    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
2983    // coords in relay-state and ask the relay how recently the peer pulled.
2984    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
2985    // Never blocks the send — the event still queues to outbox.
2986    maybe_warn_peer_attentiveness(peer);
2987
2988    // For now we append to outbox JSONL and rely on a future daemon to push
2989    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
2990    // Append goes through `config::append_outbox_record` which holds a per-
2991    // path mutex so concurrent senders cannot interleave bytes mid-line.
2992    let line = serde_json::to_vec(&signed)?;
2993    let outbox = config::append_outbox_record(peer, &line)?;
2994
2995    if as_json {
2996        println!(
2997            "{}",
2998            serde_json::to_string(&json!({
2999                "event_id": event_id,
3000                "status": "queued",
3001                "peer": peer,
3002                "outbox": outbox.to_string_lossy(),
3003            }))?
3004        );
3005    } else {
3006        println!(
3007            "queued event {event_id} → {peer} (outbox: {})",
3008            outbox.display()
3009        );
3010    }
3011    Ok(())
3012}
3013
3014fn parse_kind(s: &str) -> Result<u32> {
3015    if let Ok(n) = s.parse::<u32>() {
3016        return Ok(n);
3017    }
3018    for (id, name) in crate::signing::kinds() {
3019        if *name == s {
3020            return Ok(*id);
3021        }
3022    }
3023    // Unknown name — default to kind 1 (decision) for v0.1.
3024    Ok(1)
3025}
3026
3027// ---------- tail ----------
3028
3029fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3030    let inbox = config::inbox_dir()?;
3031    if !inbox.exists() {
3032        if !as_json {
3033            eprintln!("no inbox yet — daemon hasn't run, or no events received");
3034        }
3035        return Ok(());
3036    }
3037    let trust = config::read_trust()?;
3038    let mut count = 0usize;
3039
3040    let entries: Vec<_> = std::fs::read_dir(&inbox)?
3041        .filter_map(|e| e.ok())
3042        .map(|e| e.path())
3043        .filter(|p| {
3044            p.extension().map(|x| x == "jsonl").unwrap_or(false)
3045                && match peer {
3046                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3047                    None => true,
3048                }
3049        })
3050        .collect();
3051
3052    for path in entries {
3053        let body = std::fs::read_to_string(&path)?;
3054        for line in body.lines() {
3055            let event: Value = match serde_json::from_str(line) {
3056                Ok(v) => v,
3057                Err(_) => continue,
3058            };
3059            let verified = verify_message_v31(&event, &trust).is_ok();
3060            if as_json {
3061                let mut event_with_meta = event.clone();
3062                if let Some(obj) = event_with_meta.as_object_mut() {
3063                    obj.insert("verified".into(), json!(verified));
3064                }
3065                println!("{}", serde_json::to_string(&event_with_meta)?);
3066            } else {
3067                let ts = event
3068                    .get("timestamp")
3069                    .and_then(Value::as_str)
3070                    .unwrap_or("?");
3071                let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3072                let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3073                let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3074                let summary = event
3075                    .get("body")
3076                    .map(|b| match b {
3077                        Value::String(s) => s.clone(),
3078                        _ => b.to_string(),
3079                    })
3080                    .unwrap_or_default();
3081                let mark = if verified { "✓" } else { "✗" };
3082                let deadline = event
3083                    .get("time_sensitive_until")
3084                    .and_then(Value::as_str)
3085                    .map(|d| format!(" deadline: {d}"))
3086                    .unwrap_or_default();
3087                println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3088            }
3089            count += 1;
3090            if limit > 0 && count >= limit {
3091                return Ok(());
3092            }
3093        }
3094    }
3095    Ok(())
3096}
3097
3098// ---------- monitor (live-tail across all peers, harness-friendly) ----------
3099
3100/// Events filtered out of `wire monitor` by default — pair handshake +
3101/// liveness pings. Operators almost never want these surfaced; an explicit
3102/// `--include-handshake` brings them back.
3103fn monitor_is_noise_kind(kind: &str) -> bool {
3104    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3105}
3106
3107/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
3108/// full structured event for tooling consumption; the plain form is a tight
3109/// one-line summary suitable as a harness stream-watcher notification.
3110fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3111    if as_json {
3112        Ok(serde_json::to_string(e)?)
3113    } else {
3114        let eid_short: String = e.event_id.chars().take(12).collect();
3115        let body = e.body_preview.replace('\n', " ");
3116        let ts: String = e.timestamp.chars().take(19).collect();
3117        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3118    }
3119}
3120
3121/// `wire monitor` — long-running line-per-event stream of new inbox events.
3122///
3123/// Built for agent harnesses that have an "every stdout line is a chat
3124/// notification" stream watcher (Claude Code Monitor tool, etc.). One
3125/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
3126/// | python parse | grep -v pair_drop` pipeline operators improvise on day
3127/// one of every wire session.
3128///
3129/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
3130/// pure handshake / liveness noise that operators almost never want
3131/// surfaced. Pass `--include-handshake` if you do.
3132///
3133/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
3134/// doesn't drown the operator in replay), with optional `--replay N` to
3135/// emit the last N events first.
3136fn cmd_monitor(
3137    peer_filter: Option<&str>,
3138    as_json: bool,
3139    include_handshake: bool,
3140    interval_ms: u64,
3141    replay: usize,
3142) -> Result<()> {
3143    let inbox_dir = config::inbox_dir()?;
3144    if !inbox_dir.exists() && !as_json {
3145        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3146    }
3147    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
3148
3149    // Optional replay — read existing files and emit the last `replay` events
3150    // (post-filter) before going live. Useful when the harness restarts and
3151    // wants recent context.
3152    if replay > 0 && inbox_dir.exists() {
3153        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3154        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3155            let path = entry.path();
3156            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3157                continue;
3158            }
3159            let peer = match path.file_stem().and_then(|s| s.to_str()) {
3160                Some(s) => s.to_string(),
3161                None => continue,
3162            };
3163            if let Some(filter) = peer_filter
3164                && peer != filter
3165            {
3166                continue;
3167            }
3168            let body = std::fs::read_to_string(&path).unwrap_or_default();
3169            for line in body.lines() {
3170                let line = line.trim();
3171                if line.is_empty() {
3172                    continue;
3173                }
3174                let signed: Value = match serde_json::from_str(line) {
3175                    Ok(v) => v,
3176                    Err(_) => continue,
3177                };
3178                let ev = crate::inbox_watch::InboxEvent::from_signed(
3179                    &peer, signed, /* verified */ true,
3180                );
3181                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3182                    continue;
3183                }
3184                all.push(ev);
3185            }
3186        }
3187        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
3188        // chronological for same-zoned timestamps).
3189        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3190        let start = all.len().saturating_sub(replay);
3191        for ev in &all[start..] {
3192            println!("{}", monitor_render(ev, as_json)?);
3193        }
3194        use std::io::Write;
3195        std::io::stdout().flush().ok();
3196    }
3197
3198    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
3199    // the first poll only returns events that arrived AFTER startup.
3200    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3201    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3202
3203    loop {
3204        let events = w.poll()?;
3205        let mut wrote = false;
3206        for ev in events {
3207            if let Some(filter) = peer_filter
3208                && ev.peer != filter
3209            {
3210                continue;
3211            }
3212            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3213                continue;
3214            }
3215            println!("{}", monitor_render(&ev, as_json)?);
3216            wrote = true;
3217        }
3218        if wrote {
3219            use std::io::Write;
3220            std::io::stdout().flush().ok();
3221        }
3222        std::thread::sleep(sleep_dur);
3223    }
3224}
3225
3226#[cfg(test)]
3227mod tier_tests {
3228    use super::*;
3229    use serde_json::json;
3230
3231    fn trust_with(handle: &str, tier: &str) -> Value {
3232        json!({
3233            "version": 1,
3234            "agents": {
3235                handle: {
3236                    "tier": tier,
3237                    "did": format!("did:wire:{handle}"),
3238                    "card": {"capabilities": ["wire/v3.1"]}
3239                }
3240            }
3241        })
3242    }
3243
3244    #[test]
3245    fn pending_ack_when_verified_but_no_slot_token() {
3246        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
3247        // slot_token hasn't arrived yet. Display PENDING_ACK so the
3248        // operator knows wire send won't work yet.
3249        let trust = trust_with("willard", "VERIFIED");
3250        let relay_state = json!({
3251            "peers": {
3252                "willard": {
3253                    "relay_url": "https://relay",
3254                    "slot_id": "abc",
3255                    "slot_token": "",
3256                }
3257            }
3258        });
3259        assert_eq!(
3260            effective_peer_tier(&trust, &relay_state, "willard"),
3261            "PENDING_ACK"
3262        );
3263    }
3264
3265    #[test]
3266    fn verified_when_slot_token_present() {
3267        let trust = trust_with("willard", "VERIFIED");
3268        let relay_state = json!({
3269            "peers": {
3270                "willard": {
3271                    "relay_url": "https://relay",
3272                    "slot_id": "abc",
3273                    "slot_token": "tok123",
3274                }
3275            }
3276        });
3277        assert_eq!(
3278            effective_peer_tier(&trust, &relay_state, "willard"),
3279            "VERIFIED"
3280        );
3281    }
3282
3283    #[test]
3284    fn raw_tier_passes_through_for_non_verified() {
3285        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
3286        // UNTRUSTED regardless of slot_token state.
3287        let trust = trust_with("willard", "UNTRUSTED");
3288        let relay_state = json!({
3289            "peers": {"willard": {"slot_token": ""}}
3290        });
3291        assert_eq!(
3292            effective_peer_tier(&trust, &relay_state, "willard"),
3293            "UNTRUSTED"
3294        );
3295    }
3296
3297    #[test]
3298    fn pending_ack_when_relay_state_missing_peer() {
3299        // After wire add, trust gets updated BEFORE relay_state.peers does.
3300        // If relay_state has no entry for the peer at all, the operator
3301        // still hasn't completed the bilateral pin — show PENDING_ACK.
3302        let trust = trust_with("willard", "VERIFIED");
3303        let relay_state = json!({"peers": {}});
3304        assert_eq!(
3305            effective_peer_tier(&trust, &relay_state, "willard"),
3306            "PENDING_ACK"
3307        );
3308    }
3309}
3310
3311#[cfg(test)]
3312mod monitor_tests {
3313    use super::*;
3314    use crate::inbox_watch::InboxEvent;
3315    use serde_json::Value;
3316
3317    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
3318        InboxEvent {
3319            peer: peer.to_string(),
3320            event_id: "abcd1234567890ef".to_string(),
3321            kind: kind.to_string(),
3322            body_preview: body.to_string(),
3323            verified: true,
3324            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
3325            raw: Value::Null,
3326        }
3327    }
3328
3329    #[test]
3330    fn monitor_filter_drops_handshake_kinds_by_default() {
3331        // The whole point: pair_drop / pair_drop_ack / heartbeat are
3332        // protocol noise. If they leak into the operator's chat stream by
3333        // default, the recipe is useless ("wire monitor talks too much,
3334        // disabled it"). Burn this rule in.
3335        assert!(monitor_is_noise_kind("pair_drop"));
3336        assert!(monitor_is_noise_kind("pair_drop_ack"));
3337        assert!(monitor_is_noise_kind("heartbeat"));
3338
3339        // Real-payload kinds — operator wants every one.
3340        assert!(!monitor_is_noise_kind("claim"));
3341        assert!(!monitor_is_noise_kind("decision"));
3342        assert!(!monitor_is_noise_kind("ack"));
3343        assert!(!monitor_is_noise_kind("request"));
3344        assert!(!monitor_is_noise_kind("note"));
3345        // Unknown future kinds shouldn't be filtered as noise either —
3346        // operator probably wants to see something they don't recognise,
3347        // not have it silently dropped (the P0.1 lesson at the UX layer).
3348        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
3349    }
3350
3351    #[test]
3352    fn monitor_render_plain_is_one_short_line() {
3353        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
3354        let line = monitor_render(&e, false).unwrap();
3355        // Must be single-line.
3356        assert!(!line.contains('\n'), "render must be one line: {line}");
3357        // Must include peer, kind, body fragment, short event_id.
3358        assert!(line.contains("willard"));
3359        assert!(line.contains("claim"));
3360        assert!(line.contains("real v8 train"));
3361        // Short event id (first 12 chars).
3362        assert!(line.contains("abcd12345678"));
3363        assert!(
3364            !line.contains("abcd1234567890ef"),
3365            "should truncate full id"
3366        );
3367        // RFC3339-ish second precision.
3368        assert!(line.contains("2026-05-15T23:14:07"));
3369    }
3370
3371    #[test]
3372    fn monitor_render_strips_newlines_from_body() {
3373        // Multi-line bodies (markdown lists, code, etc.) must collapse to
3374        // one line — otherwise a single message produces multiple
3375        // notifications in the harness, ruining the "one event = one line"
3376        // contract the Monitor tool relies on.
3377        let e = ev("spark", "claim", "line one\nline two\nline three");
3378        let line = monitor_render(&e, false).unwrap();
3379        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
3380        assert!(line.contains("line one line two line three"));
3381    }
3382
3383    #[test]
3384    fn monitor_render_json_is_valid_jsonl() {
3385        let e = ev("spark", "claim", "hi");
3386        let line = monitor_render(&e, true).unwrap();
3387        assert!(!line.contains('\n'));
3388        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
3389        assert_eq!(parsed["peer"], "spark");
3390        assert_eq!(parsed["kind"], "claim");
3391        assert_eq!(parsed["body_preview"], "hi");
3392    }
3393
3394    #[test]
3395    fn monitor_does_not_drop_on_verified_null() {
3396        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
3397        // `select(.verified == true)` against inbox JSONL. Daemon writes
3398        // events with verified=null (verification happens at tail-time, not
3399        // write-time), so the filter silently rejected everything — same
3400        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
3401        // never surfaced for ~30min.
3402        //
3403        // wire monitor's render path must NOT consult `.verified` for any
3404        // filter decision. Lock that in here so a future "be conservative,
3405        // only emit verified" patch can't quietly land.
3406        let mut e = ev("spark", "claim", "from disk with verified=null");
3407        e.verified = false; // worst case — even if disk says unverified, emit
3408        let line = monitor_render(&e, false).unwrap();
3409        assert!(line.contains("from disk with verified=null"));
3410        // Noise filter operates purely on kind, never on verified.
3411        assert!(!monitor_is_noise_kind("claim"));
3412    }
3413}
3414
3415// ---------- verify ----------
3416
3417fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
3418    let body = if path == "-" {
3419        let mut buf = String::new();
3420        use std::io::Read;
3421        std::io::stdin().read_to_string(&mut buf)?;
3422        buf
3423    } else {
3424        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
3425    };
3426    let event: Value = serde_json::from_str(&body)?;
3427    let trust = config::read_trust()?;
3428    match verify_message_v31(&event, &trust) {
3429        Ok(()) => {
3430            if as_json {
3431                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
3432            } else {
3433                println!("verified ✓");
3434            }
3435            Ok(())
3436        }
3437        Err(e) => {
3438            let reason = e.to_string();
3439            if as_json {
3440                println!(
3441                    "{}",
3442                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
3443                );
3444            } else {
3445                eprintln!("FAILED: {reason}");
3446            }
3447            std::process::exit(1);
3448        }
3449    }
3450}
3451
3452// ---------- mcp / relay-server stubs ----------
3453
3454fn cmd_mcp() -> Result<()> {
3455    crate::mcp::run()
3456}
3457
3458fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
3459    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
3460    // overriding --bind. Implies --local-only semantics. Routed to a
3461    // separate serve_uds entry point with a manual hyper accept loop
3462    // (axum 0.7's `serve` is TcpListener-only).
3463    if let Some(socket_path) = uds {
3464        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
3465            std::path::PathBuf::from(home)
3466                .join("state")
3467                .join("wire-relay")
3468                .join("uds")
3469        } else {
3470            dirs::state_dir()
3471                .or_else(dirs::data_local_dir)
3472                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
3473                .join("wire-relay")
3474                .join("uds")
3475        };
3476        let runtime = tokio::runtime::Builder::new_multi_thread()
3477            .enable_all()
3478            .build()?;
3479        return runtime.block_on(crate::relay_server::serve_uds(
3480            socket_path.to_path_buf(),
3481            base,
3482        ));
3483    }
3484    // v0.5.17: --local-only refuses non-loopback binds. Catches the
3485    // "wait did I just bind a publicly-reachable local-only relay" mistake
3486    // at startup rather than discovering it via an empty phonebook later.
3487    if local_only {
3488        validate_loopback_bind(bind)?;
3489    }
3490    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
3491    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
3492    // so a single user can run both client and server on one machine.
3493    // For --local-only, suffix with /local so a single operator can run
3494    // both a federation relay and a local-only relay without state collision.
3495    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
3496        std::path::PathBuf::from(home)
3497            .join("state")
3498            .join("wire-relay")
3499    } else {
3500        dirs::state_dir()
3501            .or_else(dirs::data_local_dir)
3502            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
3503            .join("wire-relay")
3504    };
3505    let state_dir = if local_only { base.join("local") } else { base };
3506    let runtime = tokio::runtime::Builder::new_multi_thread()
3507        .enable_all()
3508        .build()?;
3509    runtime.block_on(crate::relay_server::serve_with_mode(
3510        bind,
3511        state_dir,
3512        crate::relay_server::ServerMode { local_only },
3513    ))
3514}
3515
3516/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
3517/// resolves to something outside `127.0.0.0/8` or `::1`.
3518///
3519/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
3520/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
3521/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
3522///
3523/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
3524/// is the IP range Tailscale uses for tailnet addresses. Lets operators
3525/// pair wire across machines using their tailnet IPs (e.g. Mac at
3526/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
3527/// auth + encryption + NAT traversal, wire handles protocol + identity.
3528/// Sidesteps host firewall config entirely (utun interface bypass).
3529///
3530/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
3531/// multicast, broadcast. Those would publish a "local-only" relay to
3532/// the global internet — the v0.5.17 security gate's whole point.
3533fn validate_loopback_bind(bind: &str) -> Result<()> {
3534    // Split host:port. IPv6 literals use `[::]:port` form.
3535    let host = if let Some(stripped) = bind.strip_prefix('[') {
3536        let close = stripped
3537            .find(']')
3538            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
3539        stripped[..close].to_string()
3540    } else {
3541        bind.rsplit_once(':')
3542            .map(|(h, _)| h.to_string())
3543            .unwrap_or_else(|| bind.to_string())
3544    };
3545    use std::net::{IpAddr, ToSocketAddrs};
3546    let probe = format!("{host}:0");
3547    let resolved: Vec<_> = probe
3548        .to_socket_addrs()
3549        .with_context(|| format!("resolving bind host {host:?}"))?
3550        .collect();
3551    if resolved.is_empty() {
3552        bail!("--local-only: bind host {host:?} resolved to no addresses");
3553    }
3554    for addr in &resolved {
3555        let ip = addr.ip();
3556        let is_acceptable = match ip {
3557            IpAddr::V4(v4) => {
3558                v4.is_loopback() || v4.is_private() || {
3559                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
3560                    let octets = v4.octets();
3561                    octets[0] == 100 && (64..=127).contains(&octets[1])
3562                }
3563            }
3564            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
3565        };
3566        if !is_acceptable {
3567            bail!(
3568                "--local-only refuses non-private bind: {host:?} resolves to {} \
3569                 which is not loopback (127/8, ::1), RFC 1918 private \
3570                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
3571                 (100.64.0.0/10). Remove --local-only to bind publicly.",
3572                ip
3573            );
3574        }
3575    }
3576    Ok(())
3577}
3578
3579// ---------- bind-relay ----------
3580
3581fn cmd_bind_relay(url: &str, migrate_pinned: bool, as_json: bool) -> Result<()> {
3582    if !config::is_initialized()? {
3583        bail!("not initialized — run `wire init <handle>` first");
3584    }
3585    let card = config::read_agent_card()?;
3586    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3587    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3588
3589    // v0.5.19 (issue #7): refuse silent migration that would black-hole
3590    // pinned peers. The peer's relay-state still points at our OLD slot;
3591    // they will keep POSTing successfully to a slot we no longer read,
3592    // and their messages disappear. Pre-fix this command silently
3593    // replaced state.self, the incident report logged 26 events lost
3594    // over 2 days.
3595    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
3596    let pinned: Vec<String> = existing
3597        .get("peers")
3598        .and_then(|p| p.as_object())
3599        .map(|o| o.keys().cloned().collect())
3600        .unwrap_or_default();
3601    if !pinned.is_empty() && !migrate_pinned {
3602        let list = pinned.join(", ");
3603        bail!(
3604            "bind-relay would silently black-hole {n} pinned peer(s): {list}. \
3605             They are pinned to your CURRENT slot; without coordination they will keep \
3606             pushing to a slot you no longer read.\n\n\
3607             SAFE PATHS:\n\
3608             • `wire rotate-slot` — rotates slot on the SAME relay and emits a \
3609             wire_close event to every pinned peer so their daemons drop the stale \
3610             coords cleanly. This is the supported migration path.\n\
3611             • `wire bind-relay {url} --migrate-pinned` — acknowledges that pinned \
3612             peers will need to re-pin manually (you must notify them out-of-band, \
3613             via a fresh `wire add` from each peer or a re-shared invite). Use this \
3614             only when the current slot is unreachable so rotate-slot can't ack.\n\n\
3615             Issue #7 (silent black-hole on relay change) caught this — proceed only \
3616             if you understand the consequences.",
3617            n = pinned.len(),
3618        );
3619    }
3620
3621    let normalized = url.trim_end_matches('/');
3622    let client = crate::relay_client::RelayClient::new(normalized);
3623    client.check_healthz()?;
3624    let alloc = client.allocate_slot(Some(&handle))?;
3625    let mut state = existing;
3626    if !pinned.is_empty() {
3627        // We're committing to the migration. Surface a final stderr
3628        // banner naming the peers operators must notify out-of-band so
3629        // there's a record in their shell history.
3630        eprintln!(
3631            "wire bind-relay: migrating with {n} pinned peer(s) — they will black-hole \
3632             until they re-pin: {peers}",
3633            n = pinned.len(),
3634            peers = pinned.join(", "),
3635        );
3636    }
3637    state["self"] = json!({
3638        "relay_url": url,
3639        "slot_id": alloc.slot_id,
3640        "slot_token": alloc.slot_token,
3641    });
3642    config::write_relay_state(&state)?;
3643
3644    if as_json {
3645        println!(
3646            "{}",
3647            serde_json::to_string(&json!({
3648                "relay_url": url,
3649                "slot_id": alloc.slot_id,
3650                "slot_token_present": true,
3651            }))?
3652        );
3653    } else {
3654        println!("bound to relay {url}");
3655        println!("slot_id: {}", alloc.slot_id);
3656        println!(
3657            "(slot_token written to {} mode 0600)",
3658            config::relay_state_path()?.display()
3659        );
3660    }
3661    Ok(())
3662}
3663
3664// ---------- add-peer-slot ----------
3665
3666fn cmd_add_peer_slot(
3667    handle: &str,
3668    url: &str,
3669    slot_id: &str,
3670    slot_token: &str,
3671    as_json: bool,
3672) -> Result<()> {
3673    let mut state = config::read_relay_state()?;
3674    let peers = state["peers"]
3675        .as_object_mut()
3676        .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
3677    peers.insert(
3678        handle.to_string(),
3679        json!({
3680            "relay_url": url,
3681            "slot_id": slot_id,
3682            "slot_token": slot_token,
3683        }),
3684    );
3685    config::write_relay_state(&state)?;
3686    if as_json {
3687        println!(
3688            "{}",
3689            serde_json::to_string(&json!({
3690                "handle": handle,
3691                "relay_url": url,
3692                "slot_id": slot_id,
3693                "added": true,
3694            }))?
3695        );
3696    } else {
3697        println!("pinned peer slot for {handle} at {url} ({slot_id})");
3698    }
3699    Ok(())
3700}
3701
3702// ---------- push ----------
3703
3704fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
3705    let state = config::read_relay_state()?;
3706    let peers = state["peers"].as_object().cloned().unwrap_or_default();
3707    if peers.is_empty() {
3708        bail!(
3709            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
3710        );
3711    }
3712    let outbox_dir = config::outbox_dir()?;
3713    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
3714    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
3715    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
3716    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
3717    if outbox_dir.exists() {
3718        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
3719        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
3720            let path = entry.path();
3721            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3722                continue;
3723            }
3724            let stem = match path.file_stem().and_then(|s| s.to_str()) {
3725                Some(s) => s.to_string(),
3726                None => continue,
3727            };
3728            if pinned.contains(&stem) {
3729                continue;
3730            }
3731            // Try the bare-handle of the orphaned stem — if THAT matches a
3732            // pinned peer, the stem is a stale FQDN-suffixed file.
3733            let bare = crate::agent_card::bare_handle(&stem);
3734            if pinned.contains(bare) {
3735                eprintln!(
3736                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
3737                     Merge with: `cat {} >> {}` then delete the FQDN file.",
3738                    stem,
3739                    path.display(),
3740                    outbox_dir.join(format!("{bare}.jsonl")).display(),
3741                );
3742            }
3743        }
3744    }
3745    if !outbox_dir.exists() {
3746        if as_json {
3747            println!(
3748                "{}",
3749                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
3750            );
3751        } else {
3752            println!("phyllis: nothing to dial out — write a message first with `wire send`");
3753        }
3754        return Ok(());
3755    }
3756
3757    let mut pushed = Vec::new();
3758    let mut skipped = Vec::new();
3759
3760    // v0.5.17: walk each peer's pinned endpoints in priority order (local
3761    // first if we share a local relay, federation second). Try POST on the
3762    // first endpoint; on transport failure, fall through to the next.
3763    // Falls back to the v0.5.16 legacy single-endpoint code path when the
3764    // peer record carries no `endpoints[]` array (back-compat).
3765    for (peer_handle, _) in peers.iter() {
3766        if let Some(want) = peer_filter
3767            && peer_handle != want
3768        {
3769            continue;
3770        }
3771        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
3772        if !outbox.exists() {
3773            continue;
3774        }
3775        let ordered_endpoints =
3776            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
3777        if ordered_endpoints.is_empty() {
3778            // Unreachable peer (no federation endpoint AND our local
3779            // relay doesn't match the peer's). Skip with a loud reason
3780            // rather than silently dropping events.
3781            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
3782                let event: Value = match serde_json::from_str(line) {
3783                    Ok(v) => v,
3784                    Err(_) => continue,
3785                };
3786                let event_id = event
3787                    .get("event_id")
3788                    .and_then(Value::as_str)
3789                    .unwrap_or("")
3790                    .to_string();
3791                skipped.push(json!({
3792                    "peer": peer_handle,
3793                    "event_id": event_id,
3794                    "reason": "no reachable endpoint pinned for peer",
3795                }));
3796            }
3797            continue;
3798        }
3799        let body = std::fs::read_to_string(&outbox)?;
3800        for line in body.lines() {
3801            let event: Value = match serde_json::from_str(line) {
3802                Ok(v) => v,
3803                Err(_) => continue,
3804            };
3805            let event_id = event
3806                .get("event_id")
3807                .and_then(Value::as_str)
3808                .unwrap_or("")
3809                .to_string();
3810
3811            let mut delivered = false;
3812            let mut last_err_reason: Option<String> = None;
3813            for endpoint in &ordered_endpoints {
3814                let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
3815                match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
3816                    Ok(resp) => {
3817                        if resp.status == "duplicate" {
3818                            skipped.push(json!({
3819                                "peer": peer_handle,
3820                                "event_id": event_id,
3821                                "reason": "duplicate",
3822                                "endpoint": endpoint.relay_url,
3823                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
3824                            }));
3825                        } else {
3826                            pushed.push(json!({
3827                                "peer": peer_handle,
3828                                "event_id": event_id,
3829                                "endpoint": endpoint.relay_url,
3830                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
3831                            }));
3832                        }
3833                        delivered = true;
3834                        break;
3835                    }
3836                    Err(e) => {
3837                        // Local-first endpoint failed; record reason and
3838                        // try the next endpoint silently (operator sees
3839                        // the federation success). If every endpoint
3840                        // fails, the last reason is what gets reported.
3841                        last_err_reason = Some(crate::relay_client::format_transport_error(&e));
3842                    }
3843                }
3844            }
3845            if !delivered {
3846                skipped.push(json!({
3847                    "peer": peer_handle,
3848                    "event_id": event_id,
3849                    "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
3850                }));
3851            }
3852        }
3853    }
3854
3855    if as_json {
3856        println!(
3857            "{}",
3858            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
3859        );
3860    } else {
3861        println!(
3862            "pushed {} event(s); skipped {} ({})",
3863            pushed.len(),
3864            skipped.len(),
3865            if skipped.is_empty() {
3866                "none"
3867            } else {
3868                "see --json for detail"
3869            }
3870        );
3871    }
3872    Ok(())
3873}
3874
3875// ---------- pull ----------
3876
3877fn cmd_pull(as_json: bool) -> Result<()> {
3878    let state = config::read_relay_state()?;
3879    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
3880    if self_state.is_null() {
3881        bail!("self slot not bound — run `wire bind-relay <url>` first");
3882    }
3883
3884    // v0.5.17: pull from every endpoint in self.endpoints (federation +
3885    // optional local). Each endpoint has its own per-scope cursor so we
3886    // don't re-pull events we've already seen on that path. Events from
3887    // all endpoints feed into the same inbox JSONL via process_events;
3888    // dedup by event_id is the last line of defense.
3889    // Falls back to a single federation endpoint synthesized from the
3890    // top-level legacy fields when self.endpoints is absent (v0.5.16
3891    // back-compat).
3892    let endpoints = crate::endpoints::self_endpoints(&state);
3893    if endpoints.is_empty() {
3894        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
3895    }
3896
3897    let inbox_dir = config::inbox_dir()?;
3898    config::ensure_dirs()?;
3899
3900    let mut total_seen = 0usize;
3901    let mut all_written: Vec<Value> = Vec::new();
3902    let mut all_rejected: Vec<Value> = Vec::new();
3903    let mut all_blocked = false;
3904    let mut all_advance_cursor_to: Option<String> = None;
3905
3906    for endpoint in &endpoints {
3907        let cursor_key = endpoint_cursor_key(endpoint.scope);
3908        let last_event_id = self_state
3909            .get(&cursor_key)
3910            .and_then(Value::as_str)
3911            .map(str::to_string);
3912        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
3913        let events = match client.list_events(
3914            &endpoint.slot_id,
3915            &endpoint.slot_token,
3916            last_event_id.as_deref(),
3917            Some(1000),
3918        ) {
3919            Ok(ev) => ev,
3920            Err(e) => {
3921                // One endpoint's failure shouldn't kill the whole pull.
3922                // The local-relay-down case in particular needs to
3923                // gracefully continue against federation.
3924                eprintln!(
3925                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
3926                    endpoint.relay_url,
3927                    endpoint.scope,
3928                    crate::relay_client::format_transport_error(&e),
3929                );
3930                continue;
3931            }
3932        };
3933        total_seen += events.len();
3934        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
3935        all_written.extend(result.written.iter().cloned());
3936        all_rejected.extend(result.rejected.iter().cloned());
3937        if result.blocked {
3938            all_blocked = true;
3939        }
3940        // Advance per-endpoint cursor. The cursor key is scope-specific
3941        // so federation and local don't trample each other.
3942        if let Some(eid) = result.advance_cursor_to.clone() {
3943            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
3944                all_advance_cursor_to = Some(eid.clone());
3945            }
3946            let key = cursor_key.clone();
3947            config::update_relay_state(|state| {
3948                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
3949                    self_obj.insert(key, Value::String(eid));
3950                }
3951                Ok(())
3952            })?;
3953        }
3954    }
3955
3956    // Compatibility shim for the legacy single-cursor code paths below:
3957    // `result` used to come from one process_events call; we now have
3958    // per-endpoint results aggregated into the all_* accumulators.
3959    // Reconstruct a synthetic result for the remaining display logic.
3960    let result = crate::pull::PullResult {
3961        written: all_written,
3962        rejected: all_rejected,
3963        blocked: all_blocked,
3964        advance_cursor_to: all_advance_cursor_to,
3965    };
3966    let events_len = total_seen;
3967
3968    // Cursor advance happened per-endpoint above; no aggregate cursor
3969    // write needed here.
3970
3971    if as_json {
3972        println!(
3973            "{}",
3974            serde_json::to_string(&json!({
3975                "written": result.written,
3976                "rejected": result.rejected,
3977                "total_seen": events_len,
3978                "cursor_blocked": result.blocked,
3979                "cursor_advanced_to": result.advance_cursor_to,
3980            }))?
3981        );
3982    } else {
3983        let blocking = result
3984            .rejected
3985            .iter()
3986            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
3987            .count();
3988        if blocking > 0 {
3989            println!(
3990                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
3991                events_len,
3992                result.written.len(),
3993                result.rejected.len(),
3994                blocking,
3995            );
3996        } else {
3997            println!(
3998                "pulled {} event(s); wrote {}; rejected {}",
3999                events_len,
4000                result.written.len(),
4001                result.rejected.len(),
4002            );
4003        }
4004    }
4005    Ok(())
4006}
4007
4008/// v0.5.17: cursor key for an endpoint's per-scope read position.
4009/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
4010/// back-compat with on-disk relay_state files; local uses a
4011/// `_local` suffix.
4012fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4013    match scope {
4014        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4015        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4016        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4017        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4018    }
4019}
4020
4021// ---------- rotate-slot ----------
4022
4023fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4024    if !config::is_initialized()? {
4025        bail!("not initialized — run `wire init <handle>` first");
4026    }
4027    let mut state = config::read_relay_state()?;
4028    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4029    if self_state.is_null() {
4030        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4031    }
4032    let url = self_state["relay_url"]
4033        .as_str()
4034        .ok_or_else(|| anyhow!("self.relay_url missing"))?
4035        .to_string();
4036    let old_slot_id = self_state["slot_id"]
4037        .as_str()
4038        .ok_or_else(|| anyhow!("self.slot_id missing"))?
4039        .to_string();
4040    let old_slot_token = self_state["slot_token"]
4041        .as_str()
4042        .ok_or_else(|| anyhow!("self.slot_token missing"))?
4043        .to_string();
4044
4045    // Read identity to sign the announcement.
4046    let card = config::read_agent_card()?;
4047    let did = card
4048        .get("did")
4049        .and_then(Value::as_str)
4050        .unwrap_or("")
4051        .to_string();
4052    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4053    let pk_b64 = card
4054        .get("verify_keys")
4055        .and_then(Value::as_object)
4056        .and_then(|m| m.values().next())
4057        .and_then(|v| v.get("key"))
4058        .and_then(Value::as_str)
4059        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4060        .to_string();
4061    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4062    let sk_seed = config::read_private_key()?;
4063
4064    // Allocate new slot on the same relay.
4065    let normalized = url.trim_end_matches('/').to_string();
4066    let client = crate::relay_client::RelayClient::new(&normalized);
4067    client
4068        .check_healthz()
4069        .context("aborting rotation; old slot still valid")?;
4070    let alloc = client.allocate_slot(Some(&handle))?;
4071    let new_slot_id = alloc.slot_id.clone();
4072    let new_slot_token = alloc.slot_token.clone();
4073
4074    // Optionally announce the rotation to every paired peer via the OLD slot.
4075    // Each peer's recipient-side `wire pull` will pick up this event before
4076    // their daemon next polls the new slot — but auto-update of peer's
4077    // relay.json from a wire_close event is a v0.2 daemon feature; for now
4078    // peers see the event and an operator must manually `add-peer-slot` the
4079    // new coords, OR re-pair via SAS.
4080    let mut announced: Vec<String> = Vec::new();
4081    if !no_announce {
4082        let now = time::OffsetDateTime::now_utc()
4083            .format(&time::format_description::well_known::Rfc3339)
4084            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4085        let body = json!({
4086            "reason": "operator-initiated slot rotation",
4087            "new_relay_url": url,
4088            "new_slot_id": new_slot_id,
4089            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
4090            // In v0.1 slot tokens are bilateral-shared, so peer can post via
4091            // existing add-peer-slot flow if operator chooses to re-issue.
4092        });
4093        let peers = state["peers"].as_object().cloned().unwrap_or_default();
4094        for (peer_handle, _peer_info) in peers.iter() {
4095            let event = json!({
4096                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4097                "timestamp": now.clone(),
4098                "from": did,
4099                "to": format!("did:wire:{peer_handle}"),
4100                "type": "wire_close",
4101                "kind": 1201,
4102                "body": body.clone(),
4103            });
4104            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4105                Ok(s) => s,
4106                Err(e) => {
4107                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4108                    continue;
4109                }
4110            };
4111            // Post to OUR old slot (we're announcing on our own slot, NOT
4112            // peer's slot — peer reads from us). Wait, this is wrong: peers
4113            // read from THEIR OWN slot via wire pull. To reach peer A, we
4114            // post to peer A's slot. Use the existing per-peer slot mapping.
4115            let peer_info = match state["peers"].get(peer_handle) {
4116                Some(p) => p.clone(),
4117                None => continue,
4118            };
4119            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4120            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4121            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4122            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4123                continue;
4124            }
4125            let peer_client = if peer_url == url {
4126                client.clone()
4127            } else {
4128                crate::relay_client::RelayClient::new(peer_url)
4129            };
4130            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4131                Ok(_) => announced.push(peer_handle.clone()),
4132                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4133            }
4134        }
4135    }
4136
4137    // Swap the self-slot to the new one.
4138    state["self"] = json!({
4139        "relay_url": url,
4140        "slot_id": new_slot_id,
4141        "slot_token": new_slot_token,
4142    });
4143    config::write_relay_state(&state)?;
4144
4145    if as_json {
4146        println!(
4147            "{}",
4148            serde_json::to_string(&json!({
4149                "rotated": true,
4150                "old_slot_id": old_slot_id,
4151                "new_slot_id": new_slot_id,
4152                "relay_url": url,
4153                "announced_to": announced,
4154            }))?
4155        );
4156    } else {
4157        println!("rotated slot on {url}");
4158        println!(
4159            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4160        );
4161        println!("  new slot_id: {new_slot_id}");
4162        if !announced.is_empty() {
4163            println!(
4164                "  announced wire_close (kind=1201) to: {}",
4165                announced.join(", ")
4166            );
4167        }
4168        println!();
4169        println!("next steps:");
4170        println!("  - peers see the wire_close event in their next `wire pull`");
4171        println!(
4172            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4173        );
4174        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
4175        println!("  - until they do, you'll receive but they won't be able to reach you");
4176        // Suppress unused warning
4177        let _ = old_slot_token;
4178    }
4179    Ok(())
4180}
4181
4182// ---------- forget-peer ----------
4183
4184fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4185    let mut trust = config::read_trust()?;
4186    let mut removed_from_trust = false;
4187    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4188        && agents.remove(handle).is_some()
4189    {
4190        removed_from_trust = true;
4191    }
4192    config::write_trust(&trust)?;
4193
4194    let mut state = config::read_relay_state()?;
4195    let mut removed_from_relay = false;
4196    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4197        && peers.remove(handle).is_some()
4198    {
4199        removed_from_relay = true;
4200    }
4201    config::write_relay_state(&state)?;
4202
4203    let mut purged: Vec<String> = Vec::new();
4204    if purge {
4205        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4206            let path = dir.join(format!("{handle}.jsonl"));
4207            if path.exists() {
4208                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4209                purged.push(path.to_string_lossy().into());
4210            }
4211        }
4212    }
4213
4214    if !removed_from_trust && !removed_from_relay {
4215        if as_json {
4216            println!(
4217                "{}",
4218                serde_json::to_string(&json!({
4219                    "removed": false,
4220                    "reason": format!("peer {handle:?} not pinned"),
4221                }))?
4222            );
4223        } else {
4224            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
4225        }
4226        return Ok(());
4227    }
4228
4229    if as_json {
4230        println!(
4231            "{}",
4232            serde_json::to_string(&json!({
4233                "handle": handle,
4234                "removed_from_trust": removed_from_trust,
4235                "removed_from_relay_state": removed_from_relay,
4236                "purged_files": purged,
4237            }))?
4238        );
4239    } else {
4240        println!("forgot peer {handle:?}");
4241        if removed_from_trust {
4242            println!("  - removed from trust.json");
4243        }
4244        if removed_from_relay {
4245            println!("  - removed from relay.json");
4246        }
4247        if !purged.is_empty() {
4248            for p in &purged {
4249                println!("  - deleted {p}");
4250            }
4251        } else if !purge {
4252            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
4253        }
4254    }
4255    Ok(())
4256}
4257
4258// ---------- daemon (long-lived push+pull sync) ----------
4259
4260fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
4261    if !config::is_initialized()? {
4262        bail!("not initialized — run `wire init <handle>` first");
4263    }
4264    let interval = std::time::Duration::from_secs(interval_secs.max(1));
4265
4266    if !as_json {
4267        if once {
4268            eprintln!("wire daemon: single sync cycle, then exit");
4269        } else {
4270            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
4271        }
4272    }
4273
4274    // Recover from prior crash: any pending pair in transient state had its
4275    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
4276    // the relay slots and mark the files so the operator can re-issue.
4277    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
4278        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
4279    }
4280
4281    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
4282    // to our slot, the subscriber signals `wake_rx`; we use it as the
4283    // sleep-or-wake gate of the polling loop. Polling stays as the
4284    // safety net — stream errors fall back transparently to the existing
4285    // interval-based cadence.
4286    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
4287    if !once {
4288        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
4289    }
4290
4291    loop {
4292        let pushed = run_sync_push().unwrap_or_else(|e| {
4293            eprintln!("daemon: push error: {e:#}");
4294            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
4295        });
4296        let pulled = run_sync_pull().unwrap_or_else(|e| {
4297            eprintln!("daemon: pull error: {e:#}");
4298            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
4299        });
4300        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
4301            eprintln!("daemon: pending-pair tick error: {e:#}");
4302            json!({"transitions": []})
4303        });
4304
4305        if as_json {
4306            println!(
4307                "{}",
4308                serde_json::to_string(&json!({
4309                    "ts": time::OffsetDateTime::now_utc()
4310                        .format(&time::format_description::well_known::Rfc3339)
4311                        .unwrap_or_default(),
4312                    "push": pushed,
4313                    "pull": pulled,
4314                    "pairs": pairs,
4315                }))?
4316            );
4317        } else {
4318            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
4319            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
4320            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
4321            let pair_transitions = pairs["transitions"]
4322                .as_array()
4323                .map(|a| a.len())
4324                .unwrap_or(0);
4325            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
4326                eprintln!(
4327                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
4328                );
4329            }
4330            // Loud per-transition logging so operator sees pair progress live.
4331            if let Some(arr) = pairs["transitions"].as_array() {
4332                for t in arr {
4333                    eprintln!(
4334                        "  pair {} : {} → {}",
4335                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
4336                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
4337                        t.get("to").and_then(Value::as_str).unwrap_or("?")
4338                    );
4339                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
4340                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
4341                    {
4342                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
4343                        eprintln!(
4344                            "    Run: wire pair-confirm {} {}",
4345                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
4346                            sas
4347                        );
4348                    }
4349                }
4350            }
4351        }
4352
4353        if once {
4354            return Ok(());
4355        }
4356        // Wait either for the next poll-interval tick OR for a stream
4357        // wake signal — whichever comes first. Drain any additional
4358        // wake-ups that accumulated during the previous cycle since one
4359        // pull catches up everything.
4360        let _ = wake_rx.recv_timeout(interval);
4361        while wake_rx.try_recv().is_ok() {}
4362    }
4363}
4364
4365/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
4366/// shape `wire push --json` emits.
4367fn run_sync_push() -> Result<Value> {
4368    let state = config::read_relay_state()?;
4369    let peers = state["peers"].as_object().cloned().unwrap_or_default();
4370    if peers.is_empty() {
4371        return Ok(json!({"pushed": [], "skipped": []}));
4372    }
4373    let outbox_dir = config::outbox_dir()?;
4374    if !outbox_dir.exists() {
4375        return Ok(json!({"pushed": [], "skipped": []}));
4376    }
4377    let mut pushed = Vec::new();
4378    let mut skipped = Vec::new();
4379    for (peer_handle, slot_info) in peers.iter() {
4380        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4381        if !outbox.exists() {
4382            continue;
4383        }
4384        let url = slot_info["relay_url"].as_str().unwrap_or("");
4385        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
4386        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
4387        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
4388            continue;
4389        }
4390        let client = crate::relay_client::RelayClient::new(url);
4391        let body = std::fs::read_to_string(&outbox)?;
4392        for line in body.lines() {
4393            let event: Value = match serde_json::from_str(line) {
4394                Ok(v) => v,
4395                Err(_) => continue,
4396            };
4397            let event_id = event
4398                .get("event_id")
4399                .and_then(Value::as_str)
4400                .unwrap_or("")
4401                .to_string();
4402            match client.post_event(slot_id, slot_token, &event) {
4403                Ok(resp) => {
4404                    if resp.status == "duplicate" {
4405                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
4406                    } else {
4407                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
4408                    }
4409                }
4410                Err(e) => {
4411                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
4412                    // errors aren't hidden behind the topmost-context URL string.
4413                    // Issue #6 highest-impact silent-fail fix.
4414                    let reason = crate::relay_client::format_transport_error(&e);
4415                    skipped
4416                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
4417                }
4418            }
4419        }
4420    }
4421    Ok(json!({"pushed": pushed, "skipped": skipped}))
4422}
4423
4424/// Programmatic pull. Same shape as `wire pull --json`.
4425fn run_sync_pull() -> Result<Value> {
4426    let state = config::read_relay_state()?;
4427    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4428    if self_state.is_null() {
4429        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
4430    }
4431    let url = self_state["relay_url"].as_str().unwrap_or("");
4432    let slot_id = self_state["slot_id"].as_str().unwrap_or("");
4433    let slot_token = self_state["slot_token"].as_str().unwrap_or("");
4434    let last_event_id = self_state
4435        .get("last_pulled_event_id")
4436        .and_then(Value::as_str)
4437        .map(str::to_string);
4438    if url.is_empty() {
4439        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
4440    }
4441    let client = crate::relay_client::RelayClient::new(url);
4442    let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
4443    let inbox_dir = config::inbox_dir()?;
4444    config::ensure_dirs()?;
4445
4446    // P0.1 (0.5.11): shared cursor-blocking logic. Daemon's --once path
4447    // must match the CLI's `wire pull` semantics or version-skew bugs
4448    // re-emerge by another route.
4449    let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
4450
4451    // P0.3 (0.5.11): same flock-protected RMW as cmd_pull.
4452    if let Some(eid) = &result.advance_cursor_to {
4453        let eid = eid.clone();
4454        config::update_relay_state(|state| {
4455            if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4456                self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
4457            }
4458            Ok(())
4459        })?;
4460    }
4461
4462    Ok(json!({
4463        "written": result.written,
4464        "rejected": result.rejected,
4465        "total_seen": events.len(),
4466        "cursor_blocked": result.blocked,
4467        "cursor_advanced_to": result.advance_cursor_to,
4468    }))
4469}
4470
4471// ---------- pin (manual out-of-band peer pairing) ----------
4472
4473fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
4474    let body =
4475        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
4476    let card: Value =
4477        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
4478    crate::agent_card::verify_agent_card(&card)
4479        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
4480
4481    let mut trust = config::read_trust()?;
4482    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
4483
4484    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4485    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4486    config::write_trust(&trust)?;
4487
4488    if as_json {
4489        println!(
4490            "{}",
4491            serde_json::to_string(&json!({
4492                "handle": handle,
4493                "did": did,
4494                "tier": "VERIFIED",
4495                "pinned": true,
4496            }))?
4497        );
4498    } else {
4499        println!("pinned {handle} ({did}) at tier VERIFIED");
4500    }
4501    Ok(())
4502}
4503
4504// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
4505
4506fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
4507    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
4508}
4509
4510fn cmd_pair_join(
4511    code_phrase: &str,
4512    relay_url: &str,
4513    auto_yes: bool,
4514    timeout_secs: u64,
4515) -> Result<()> {
4516    pair_orchestrate(
4517        relay_url,
4518        Some(code_phrase),
4519        "guest",
4520        auto_yes,
4521        timeout_secs,
4522    )
4523}
4524
4525/// Shared orchestration for both sides of the SAS pairing.
4526///
4527/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
4528/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
4529/// `pair_session_confirm_sas` instead.
4530fn pair_orchestrate(
4531    relay_url: &str,
4532    code_in: Option<&str>,
4533    role: &str,
4534    auto_yes: bool,
4535    timeout_secs: u64,
4536) -> Result<()> {
4537    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
4538
4539    let mut s = pair_session_open(role, relay_url, code_in)?;
4540
4541    if role == "host" {
4542        eprintln!();
4543        eprintln!("share this code phrase with your peer:");
4544        eprintln!();
4545        eprintln!("    {}", s.code);
4546        eprintln!();
4547        eprintln!(
4548            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
4549            s.code
4550        );
4551    } else {
4552        eprintln!();
4553        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
4554    }
4555
4556    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
4557    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
4558    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
4559    // see the process is alive while the other side connects.
4560    const HEARTBEAT_SECS: u64 = 10;
4561    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
4562    let started = std::time::Instant::now();
4563    let mut last_heartbeat = started;
4564    let formatted = loop {
4565        if let Some(sas) = pair_session_try_sas(&mut s)? {
4566            break sas;
4567        }
4568        let now = std::time::Instant::now();
4569        if now >= deadline {
4570            return Err(anyhow!(
4571                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
4572            ));
4573        }
4574        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
4575            let elapsed = now.duration_since(started).as_secs();
4576            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
4577            last_heartbeat = now;
4578        }
4579        std::thread::sleep(std::time::Duration::from_millis(250));
4580    };
4581
4582    eprintln!();
4583    eprintln!("SAS digits (must match peer's terminal):");
4584    eprintln!();
4585    eprintln!("    {formatted}");
4586    eprintln!();
4587
4588    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
4589    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
4590    if !auto_yes {
4591        eprint!("does this match your peer's terminal? [y/N]: ");
4592        use std::io::Write;
4593        std::io::stderr().flush().ok();
4594        let mut input = String::new();
4595        std::io::stdin().read_line(&mut input)?;
4596        let trimmed = input.trim().to_lowercase();
4597        if trimmed != "y" && trimmed != "yes" {
4598            bail!("SAS confirmation declined — aborting pairing");
4599        }
4600    }
4601    s.sas_confirmed = true;
4602
4603    // Stage 4 — seal+exchange bootstrap, pin peer.
4604    let result = pair_session_finalize(&mut s, timeout_secs)?;
4605
4606    let peer_did = result["paired_with"].as_str().unwrap_or("");
4607    let peer_role = if role == "host" { "guest" } else { "host" };
4608    eprintln!("paired with {peer_did} (peer role: {peer_role})");
4609    eprintln!("peer card pinned at tier VERIFIED");
4610    eprintln!(
4611        "peer relay slot saved to {}",
4612        config::relay_state_path()?.display()
4613    );
4614
4615    println!("{}", serde_json::to_string(&result)?);
4616    Ok(())
4617}
4618
4619// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
4620// and pair_session_finalize, both of which inline their own deadline loops.)
4621
4622// ---------- pair — single-shot init + pair-* + setup ----------
4623
4624fn cmd_pair(
4625    handle: &str,
4626    code: Option<&str>,
4627    relay: &str,
4628    auto_yes: bool,
4629    timeout_secs: u64,
4630    no_setup: bool,
4631) -> Result<()> {
4632    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
4633    // bails loudly if a different handle is already set (operator must explicitly delete).
4634    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
4635    let did = init_result
4636        .get("did")
4637        .and_then(|v| v.as_str())
4638        .unwrap_or("(unknown)")
4639        .to_string();
4640    let already = init_result
4641        .get("already_initialized")
4642        .and_then(|v| v.as_bool())
4643        .unwrap_or(false);
4644    if already {
4645        println!("(identity {did} already initialized — reusing)");
4646    } else {
4647        println!("initialized {did}");
4648    }
4649    println!();
4650
4651    // Step 2 — pair-host or pair-join based on code presence.
4652    match code {
4653        None => {
4654            println!("hosting pair on {relay} (no code = host) ...");
4655            cmd_pair_host(relay, auto_yes, timeout_secs)?;
4656        }
4657        Some(c) => {
4658            println!("joining pair with code {c} on {relay} ...");
4659            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
4660        }
4661    }
4662
4663    // Step 3 — register wire as MCP server in detected client configs (idempotent).
4664    if !no_setup {
4665        println!();
4666        println!("registering wire as MCP server in detected client configs ...");
4667        if let Err(e) = cmd_setup(true) {
4668            // Non-fatal — pair succeeded, just print the warning.
4669            eprintln!("warn: setup --apply failed: {e}");
4670            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
4671        }
4672    }
4673
4674    println!();
4675    println!("pair complete. Next steps:");
4676    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
4677    println!("  wire send <peer> claim <msg>   # send your peer something");
4678    println!("  wire tail                      # watch incoming events");
4679    Ok(())
4680}
4681
4682// ---------- detached pair (daemon-orchestrated) ----------
4683
4684/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
4685/// pair-host/-join into a single command. The non-detached variant lives in
4686/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
4687fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
4688    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
4689    let did = init_result
4690        .get("did")
4691        .and_then(|v| v.as_str())
4692        .unwrap_or("(unknown)")
4693        .to_string();
4694    let already = init_result
4695        .get("already_initialized")
4696        .and_then(|v| v.as_bool())
4697        .unwrap_or(false);
4698    if already {
4699        println!("(identity {did} already initialized — reusing)");
4700    } else {
4701        println!("initialized {did}");
4702    }
4703    println!();
4704    match code {
4705        None => cmd_pair_host_detach(relay, false),
4706        Some(c) => cmd_pair_join_detach(c, relay, false),
4707    }
4708}
4709
4710fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
4711    if !config::is_initialized()? {
4712        bail!("not initialized — run `wire init <handle>` first");
4713    }
4714    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
4715        Ok(b) => b,
4716        Err(e) => {
4717            if !as_json {
4718                eprintln!(
4719                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
4720                );
4721            }
4722            false
4723        }
4724    };
4725    let code = crate::sas::generate_code_phrase();
4726    let code_hash = crate::pair_session::derive_code_hash(&code);
4727    let now = time::OffsetDateTime::now_utc()
4728        .format(&time::format_description::well_known::Rfc3339)
4729        .unwrap_or_default();
4730    let p = crate::pending_pair::PendingPair {
4731        code: code.clone(),
4732        code_hash,
4733        role: "host".to_string(),
4734        relay_url: relay_url.to_string(),
4735        status: "request_host".to_string(),
4736        sas: None,
4737        peer_did: None,
4738        created_at: now,
4739        last_error: None,
4740        pair_id: None,
4741        our_slot_id: None,
4742        our_slot_token: None,
4743        spake2_seed_b64: None,
4744    };
4745    crate::pending_pair::write_pending(&p)?;
4746    if as_json {
4747        println!(
4748            "{}",
4749            serde_json::to_string(&json!({
4750                "state": "queued",
4751                "code_phrase": code,
4752                "relay_url": relay_url,
4753                "role": "host",
4754                "daemon_spawned": daemon_spawned,
4755            }))?
4756        );
4757    } else {
4758        if daemon_spawned {
4759            println!("(started wire daemon in background)");
4760        }
4761        println!("detached pair-host queued. Share this code with your peer:\n");
4762        println!("    {code}\n");
4763        println!("Next steps:");
4764        println!("  wire pair-list                                # check status");
4765        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
4766        println!("  wire pair-cancel  {code}            # to abort");
4767    }
4768    Ok(())
4769}
4770
4771fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
4772    if !config::is_initialized()? {
4773        bail!("not initialized — run `wire init <handle>` first");
4774    }
4775    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
4776        Ok(b) => b,
4777        Err(e) => {
4778            if !as_json {
4779                eprintln!(
4780                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
4781                );
4782            }
4783            false
4784        }
4785    };
4786    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
4787    let code_hash = crate::pair_session::derive_code_hash(&code);
4788    let now = time::OffsetDateTime::now_utc()
4789        .format(&time::format_description::well_known::Rfc3339)
4790        .unwrap_or_default();
4791    let p = crate::pending_pair::PendingPair {
4792        code: code.clone(),
4793        code_hash,
4794        role: "guest".to_string(),
4795        relay_url: relay_url.to_string(),
4796        status: "request_guest".to_string(),
4797        sas: None,
4798        peer_did: None,
4799        created_at: now,
4800        last_error: None,
4801        pair_id: None,
4802        our_slot_id: None,
4803        our_slot_token: None,
4804        spake2_seed_b64: None,
4805    };
4806    crate::pending_pair::write_pending(&p)?;
4807    if as_json {
4808        println!(
4809            "{}",
4810            serde_json::to_string(&json!({
4811                "state": "queued",
4812                "code_phrase": code,
4813                "relay_url": relay_url,
4814                "role": "guest",
4815                "daemon_spawned": daemon_spawned,
4816            }))?
4817        );
4818    } else {
4819        if daemon_spawned {
4820            println!("(started wire daemon in background)");
4821        }
4822        println!("detached pair-join queued for code {code}.");
4823        println!(
4824            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
4825        );
4826    }
4827    Ok(())
4828}
4829
4830fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
4831    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
4832    let typed: String = typed_digits
4833        .chars()
4834        .filter(|c| c.is_ascii_digit())
4835        .collect();
4836    if typed.len() != 6 {
4837        bail!(
4838            "expected 6 digits (got {} after stripping non-digits)",
4839            typed.len()
4840        );
4841    }
4842    let mut p = crate::pending_pair::read_pending(&code)?
4843        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
4844    if p.status != "sas_ready" {
4845        bail!(
4846            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
4847            p.status
4848        );
4849    }
4850    let stored = p
4851        .sas
4852        .as_ref()
4853        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
4854        .clone();
4855    if stored == typed {
4856        p.status = "confirmed".to_string();
4857        crate::pending_pair::write_pending(&p)?;
4858        if as_json {
4859            println!(
4860                "{}",
4861                serde_json::to_string(&json!({
4862                    "state": "confirmed",
4863                    "code_phrase": code,
4864                }))?
4865            );
4866        } else {
4867            println!("digits match. Daemon will finalize the handshake on its next tick.");
4868            println!("Run `wire peers` after a few seconds to confirm.");
4869        }
4870    } else {
4871        p.status = "aborted".to_string();
4872        p.last_error = Some(format!(
4873            "SAS digit mismatch (typed {typed}, expected {stored})"
4874        ));
4875        let client = crate::relay_client::RelayClient::new(&p.relay_url);
4876        let _ = client.pair_abandon(&p.code_hash);
4877        crate::pending_pair::write_pending(&p)?;
4878        crate::os_notify::toast(
4879            &format!("wire — pair aborted ({})", p.code),
4880            p.last_error.as_deref().unwrap_or("digits mismatch"),
4881        );
4882        if as_json {
4883            println!(
4884                "{}",
4885                serde_json::to_string(&json!({
4886                    "state": "aborted",
4887                    "code_phrase": code,
4888                    "error": "digits mismatch",
4889                }))?
4890            );
4891        }
4892        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
4893    }
4894    Ok(())
4895}
4896
4897fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
4898    if watch {
4899        return cmd_pair_list_watch(watch_interval_secs);
4900    }
4901    let spake2_items = crate::pending_pair::list_pending()?;
4902    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
4903    if as_json {
4904        // Backwards-compat: flat SPAKE2 array (the shape every existing
4905        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
4906        // surface programmatically via `wire pair-list-inbound --json`
4907        // and via `wire status --json` `pending_pairs.inbound_*` fields.
4908        println!("{}", serde_json::to_string(&spake2_items)?);
4909        return Ok(());
4910    }
4911    if spake2_items.is_empty() && inbound_items.is_empty() {
4912        println!("no pending pair sessions.");
4913        return Ok(());
4914    }
4915    // v0.5.14: inbound section first — these need operator action right now.
4916    // SPAKE2 sessions are typically already mid-flow.
4917    if !inbound_items.is_empty() {
4918        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
4919        println!(
4920            "{:<20} {:<35} {:<25} NEXT STEP",
4921            "PEER", "RELAY", "RECEIVED"
4922        );
4923        for p in &inbound_items {
4924            println!(
4925                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
4926                p.peer_handle,
4927                p.peer_relay_url,
4928                p.received_at,
4929                peer = p.peer_handle,
4930            );
4931        }
4932        println!();
4933    }
4934    if !spake2_items.is_empty() {
4935        println!("SPAKE2 SESSIONS");
4936        println!(
4937            "{:<15} {:<8} {:<18} {:<10} NOTE",
4938            "CODE", "ROLE", "STATUS", "SAS"
4939        );
4940        for p in spake2_items {
4941            let sas = p
4942                .sas
4943                .as_ref()
4944                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
4945                .unwrap_or_else(|| "—".to_string());
4946            let note = p
4947                .last_error
4948                .as_deref()
4949                .or(p.peer_did.as_deref())
4950                .unwrap_or("");
4951            println!(
4952                "{:<15} {:<8} {:<18} {:<10} {}",
4953                p.code, p.role, p.status, sas, note
4954            );
4955        }
4956    }
4957    Ok(())
4958}
4959
4960/// Stream-mode pair-list: never exits. Diffs per-code state every
4961/// `interval_secs` and prints one JSON line per transition (creation,
4962/// status flip, deletion). Useful for shell pipelines:
4963///
4964/// ```text
4965/// wire pair-list --watch | while read line; do
4966///     CODE=$(echo "$line" | jq -r .code)
4967///     STATUS=$(echo "$line" | jq -r .status)
4968///     ...
4969/// done
4970/// ```
4971fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
4972    use std::collections::HashMap;
4973    use std::io::Write;
4974    let interval = std::time::Duration::from_secs(interval_secs.max(1));
4975    // Emit a snapshot synthetic event for every currently-pending pair on
4976    // startup so a consumer that arrives mid-flight sees the current state.
4977    let mut prev: HashMap<String, String> = HashMap::new();
4978    {
4979        let items = crate::pending_pair::list_pending()?;
4980        for p in &items {
4981            println!("{}", serde_json::to_string(&p)?);
4982            prev.insert(p.code.clone(), p.status.clone());
4983        }
4984        // Flush so the consumer's `while read` gets the snapshot promptly.
4985        let _ = std::io::stdout().flush();
4986    }
4987    loop {
4988        std::thread::sleep(interval);
4989        let items = match crate::pending_pair::list_pending() {
4990            Ok(v) => v,
4991            Err(_) => continue,
4992        };
4993        let mut cur: HashMap<String, String> = HashMap::new();
4994        for p in &items {
4995            cur.insert(p.code.clone(), p.status.clone());
4996            match prev.get(&p.code) {
4997                None => {
4998                    // New code appeared.
4999                    println!("{}", serde_json::to_string(&p)?);
5000                }
5001                Some(prev_status) if prev_status != &p.status => {
5002                    // Status flipped.
5003                    println!("{}", serde_json::to_string(&p)?);
5004                }
5005                _ => {}
5006            }
5007        }
5008        for code in prev.keys() {
5009            if !cur.contains_key(code) {
5010                // File disappeared → finalized or cancelled. Emit a synthetic
5011                // "removed" marker so the consumer sees the terminal event.
5012                println!(
5013                    "{}",
5014                    serde_json::to_string(&json!({
5015                        "code": code,
5016                        "status": "removed",
5017                        "_synthetic": true,
5018                    }))?
5019                );
5020            }
5021        }
5022        let _ = std::io::stdout().flush();
5023        prev = cur;
5024    }
5025}
5026
5027/// Block until a pending pair reaches `target_status` or terminates. Process
5028/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
5029/// timeout) so shell scripts can branch directly.
5030fn cmd_pair_watch(
5031    code_phrase: &str,
5032    target_status: &str,
5033    timeout_secs: u64,
5034    as_json: bool,
5035) -> Result<()> {
5036    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5037    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5038    let mut last_seen_status: Option<String> = None;
5039    loop {
5040        let p_opt = crate::pending_pair::read_pending(&code)?;
5041        let now = std::time::Instant::now();
5042        match p_opt {
5043            None => {
5044                // File gone — either finalized (success if target=sas_ready
5045                // since finalization implies it passed sas_ready) or never
5046                // existed. Distinguish by whether we ever saw it.
5047                if last_seen_status.is_some() {
5048                    if as_json {
5049                        println!(
5050                            "{}",
5051                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5052                        );
5053                    } else {
5054                        println!("pair {code} finalized (file removed)");
5055                    }
5056                    return Ok(());
5057                } else {
5058                    if as_json {
5059                        println!(
5060                            "{}",
5061                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5062                        );
5063                    }
5064                    std::process::exit(1);
5065                }
5066            }
5067            Some(p) => {
5068                let cur = p.status.clone();
5069                if Some(cur.clone()) != last_seen_status {
5070                    if as_json {
5071                        // Emit per-transition line so scripts can stream.
5072                        println!("{}", serde_json::to_string(&p)?);
5073                    }
5074                    last_seen_status = Some(cur.clone());
5075                }
5076                if cur == target_status {
5077                    if !as_json {
5078                        let sas_str = p
5079                            .sas
5080                            .as_ref()
5081                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5082                            .unwrap_or_else(|| "—".to_string());
5083                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
5084                    }
5085                    return Ok(());
5086                }
5087                if cur == "aborted" || cur == "aborted_restart" {
5088                    if !as_json {
5089                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
5090                        eprintln!("pair {code} {cur}: {err}");
5091                    }
5092                    std::process::exit(1);
5093                }
5094            }
5095        }
5096        if now >= deadline {
5097            if !as_json {
5098                eprintln!(
5099                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5100                );
5101            }
5102            std::process::exit(2);
5103        }
5104        std::thread::sleep(std::time::Duration::from_millis(250));
5105    }
5106}
5107
5108fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5109    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5110    let p = crate::pending_pair::read_pending(&code)?
5111        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5112    let client = crate::relay_client::RelayClient::new(&p.relay_url);
5113    let _ = client.pair_abandon(&p.code_hash);
5114    crate::pending_pair::delete_pending(&code)?;
5115    if as_json {
5116        println!(
5117            "{}",
5118            serde_json::to_string(&json!({
5119                "state": "cancelled",
5120                "code_phrase": code,
5121            }))?
5122        );
5123    } else {
5124        println!("cancelled pending pair {code} (relay slot released, file removed).");
5125    }
5126    Ok(())
5127}
5128
5129// ---------- pair-abandon — release stuck pair-slot ----------
5130
5131fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5132    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
5133    // typed — normalize via the existing parser.
5134    let code = crate::sas::parse_code_phrase(code_phrase)?;
5135    let code_hash = crate::pair_session::derive_code_hash(code);
5136    let client = crate::relay_client::RelayClient::new(relay_url);
5137    client.pair_abandon(&code_hash)?;
5138    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
5139    println!("host can now issue a fresh code; guest can re-join.");
5140    Ok(())
5141}
5142
5143// ---------- invite / accept — one-paste pair (v0.4.0) ----------
5144
5145fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
5146    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
5147
5148    // If --share, register the invite at the relay's short-URL endpoint and
5149    // build the one-curl onboarding line for the peer to paste.
5150    let share_payload: Option<Value> = if share {
5151        let client = reqwest::blocking::Client::new();
5152        let single_use = if uses == 1 { Some(1u32) } else { None };
5153        let body = json!({
5154            "invite_url": url,
5155            "ttl_seconds": ttl,
5156            "uses": single_use,
5157        });
5158        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
5159        let resp = client.post(&endpoint).json(&body).send()?;
5160        if !resp.status().is_success() {
5161            let code = resp.status();
5162            let txt = resp.text().unwrap_or_default();
5163            bail!("relay {code} on /v1/invite/register: {txt}");
5164        }
5165        let parsed: Value = resp.json()?;
5166        let token = parsed
5167            .get("token")
5168            .and_then(Value::as_str)
5169            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
5170            .to_string();
5171        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
5172        let curl_line = format!("curl -fsSL {share_url} | sh");
5173        Some(json!({
5174            "token": token,
5175            "share_url": share_url,
5176            "curl": curl_line,
5177            "expires_unix": parsed.get("expires_unix"),
5178        }))
5179    } else {
5180        None
5181    };
5182
5183    if as_json {
5184        let mut out = json!({
5185            "invite_url": url,
5186            "ttl_secs": ttl,
5187            "uses": uses,
5188            "relay": relay,
5189        });
5190        if let Some(s) = &share_payload {
5191            out["share"] = s.clone();
5192        }
5193        println!("{}", serde_json::to_string(&out)?);
5194    } else if let Some(s) = share_payload {
5195        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
5196        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
5197        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
5198        println!("{curl}");
5199    } else {
5200        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
5201        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
5202        println!("{url}");
5203    }
5204    Ok(())
5205}
5206
5207fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
5208    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
5209    // resolve it to the underlying wire://pair?... URL via ?format=url before
5210    // accepting. Saves them from having to know which URL shape goes where.
5211    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
5212        let sep = if url.contains('?') { '&' } else { '?' };
5213        let resolve_url = format!("{url}{sep}format=url");
5214        let client = reqwest::blocking::Client::new();
5215        let resp = client
5216            .get(&resolve_url)
5217            .send()
5218            .with_context(|| format!("GET {resolve_url}"))?;
5219        if !resp.status().is_success() {
5220            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
5221        }
5222        let body = resp.text().unwrap_or_default().trim().to_string();
5223        if !body.starts_with("wire://pair?") {
5224            bail!(
5225                "short URL {url} did not resolve to a wire:// invite. \
5226                 (got: {}{})",
5227                body.chars().take(80).collect::<String>(),
5228                if body.chars().count() > 80 { "…" } else { "" }
5229            );
5230        }
5231        body
5232    } else {
5233        url.to_string()
5234    };
5235
5236    let result = crate::pair_invite::accept_invite(&resolved)?;
5237    if as_json {
5238        println!("{}", serde_json::to_string(&result)?);
5239    } else {
5240        let did = result
5241            .get("paired_with")
5242            .and_then(Value::as_str)
5243            .unwrap_or("?");
5244        println!("paired with {did}");
5245        println!(
5246            "you can now: wire send {} <kind> <body>",
5247            crate::agent_card::display_handle_from_did(did)
5248        );
5249    }
5250    Ok(())
5251}
5252
5253// ---------- whois / profile (v0.5) ----------
5254
5255fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
5256    if let Some(h) = handle {
5257        let parsed = crate::pair_profile::parse_handle(h)?;
5258        // Special-case: if the supplied handle matches our own, skip the
5259        // network round-trip and print local.
5260        if config::is_initialized()? {
5261            let card = config::read_agent_card()?;
5262            let local_handle = card
5263                .get("profile")
5264                .and_then(|p| p.get("handle"))
5265                .and_then(Value::as_str)
5266                .map(str::to_string);
5267            if local_handle.as_deref() == Some(h) {
5268                return cmd_whois(None, as_json, None);
5269            }
5270        }
5271        // Remote resolution via .well-known/wire/agent on the handle's domain.
5272        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
5273        if as_json {
5274            println!("{}", serde_json::to_string(&resolved)?);
5275        } else {
5276            print_resolved_profile(&resolved);
5277        }
5278        return Ok(());
5279    }
5280    let card = config::read_agent_card()?;
5281    if as_json {
5282        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
5283        println!(
5284            "{}",
5285            serde_json::to_string(&json!({
5286                "did": card.get("did").cloned().unwrap_or(Value::Null),
5287                "profile": profile,
5288            }))?
5289        );
5290    } else {
5291        print!("{}", crate::pair_profile::render_self_summary()?);
5292    }
5293    Ok(())
5294}
5295
5296fn print_resolved_profile(resolved: &Value) {
5297    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
5298    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
5299    let relay = resolved
5300        .get("relay_url")
5301        .and_then(Value::as_str)
5302        .unwrap_or("");
5303    let slot = resolved
5304        .get("slot_id")
5305        .and_then(Value::as_str)
5306        .unwrap_or("");
5307    let profile = resolved
5308        .get("card")
5309        .and_then(|c| c.get("profile"))
5310        .cloned()
5311        .unwrap_or(Value::Null);
5312    println!("{did}");
5313    println!("  nick:         {nick}");
5314    if !relay.is_empty() {
5315        println!("  relay_url:    {relay}");
5316    }
5317    if !slot.is_empty() {
5318        println!("  slot_id:      {slot}");
5319    }
5320    let pick =
5321        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
5322    if let Some(s) = pick("display_name") {
5323        println!("  display_name: {s}");
5324    }
5325    if let Some(s) = pick("emoji") {
5326        println!("  emoji:        {s}");
5327    }
5328    if let Some(s) = pick("motto") {
5329        println!("  motto:        {s}");
5330    }
5331    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
5332        let joined: Vec<String> = arr
5333            .iter()
5334            .filter_map(|v| v.as_str().map(str::to_string))
5335            .collect();
5336        println!("  vibe:         {}", joined.join(", "));
5337    }
5338    if let Some(s) = pick("pronouns") {
5339        println!("  pronouns:     {s}");
5340    }
5341}
5342
5343/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
5344/// signed pair_drop event with our card + slot coords, deliver via the
5345/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
5346/// Peer's daemon completes the bilateral pin on its next pull and emits a
5347/// pair_drop_ack carrying their slot_token so we can send back.
5348/// Extract just the host portion from `https://host:port/path` → `host`.
5349/// Returns empty string if the URL is malformed.
5350fn host_of_url(url: &str) -> String {
5351    let no_scheme = url
5352        .trim_start_matches("https://")
5353        .trim_start_matches("http://");
5354    no_scheme
5355        .split('/')
5356        .next()
5357        .unwrap_or("")
5358        .split(':')
5359        .next()
5360        .unwrap_or("")
5361        .to_string()
5362}
5363
5364/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
5365/// operator's own relay? Used to suppress the cross-relay phishing
5366/// warning in `wire add` for the happy path.
5367fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
5368    // Hard-coded known-good list. wireup.net is the default relay.
5369    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
5370    let peer_domain = peer_domain.trim().to_ascii_lowercase();
5371    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
5372        return true;
5373    }
5374    // Operator's OWN relay is implicitly trusted — they're already
5375    // bound to it; pairing same-relay peers is the common case.
5376    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
5377    if !our_host.is_empty() && our_host == peer_domain {
5378        return true;
5379    }
5380    false
5381}
5382
5383/// v0.6.6: pair with a sister session on this machine without federation.
5384/// Reads the sister's agent-card + endpoints from disk, pins them into our
5385/// trust + relay_state, builds the same `pair_drop` event the federation
5386/// path would emit, then POSTs it directly to the sister's local-relay slot.
5387/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
5388/// the cwd-derived `wire`) are addressable because the local relay never
5389/// needed a public claim for sister coordination.
5390/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
5391/// to a local sister session.
5392///
5393/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
5394/// either form. Exact session-name matches always win; nickname matches
5395/// are a fallback so operators can type "winter-bay" instead of "wire".
5396/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
5397/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
5398/// with the candidate list so the caller can surface a disambiguation
5399/// hint instead of silently picking one.
5400fn resolve_local_session<'a>(
5401    sessions: &'a [crate::session::SessionInfo],
5402    input: &str,
5403) -> Result<&'a crate::session::SessionInfo, ResolveError> {
5404    // Exact session-name match always wins, even if a nickname elsewhere
5405    // also matches. Predictable for scripts and operator muscle memory.
5406    if let Some(s) = sessions.iter().find(|s| s.name == input) {
5407        return Ok(s);
5408    }
5409    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
5410        .iter()
5411        .filter(|s| {
5412            s.character
5413                .as_ref()
5414                .map(|c| c.nickname == input)
5415                .unwrap_or(false)
5416        })
5417        .collect();
5418    match nick_matches.len() {
5419        0 => Err(ResolveError::NotFound),
5420        1 => Ok(nick_matches[0]),
5421        _ => Err(ResolveError::Ambiguous(
5422            nick_matches.iter().map(|s| s.name.clone()).collect(),
5423        )),
5424    }
5425}
5426
5427#[derive(Debug)]
5428enum ResolveError {
5429    NotFound,
5430    Ambiguous(Vec<String>),
5431}
5432
5433/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
5434/// to a pinned peer's canonical handle.
5435///
5436/// `wire send <peer>` accepts either the handle the peer registered with
5437/// or their character nickname (DID-hash-derived). Exact handle match
5438/// always wins. When a nickname matches multiple peers (theoretically
5439/// possible via DID-hash collision in the (adj, noun) space), returns
5440/// `Ambiguous` so the caller can surface a disambiguation hint instead
5441/// of silently picking one.
5442///
5443/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
5444/// overrides on the peer's side live in their local `display.json` and
5445/// aren't yet published via agent-card. (That's the v0.7+ federation
5446/// lifecycle work — peers publishing overrides so we resolve by what
5447/// they call themselves, not just what their DID hashes to.)
5448fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
5449    let trust = match config::read_trust() {
5450        Ok(t) => t,
5451        Err(_) => return Ok(None),
5452    };
5453    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
5454        Some(a) => a,
5455        None => return Ok(None),
5456    };
5457    if agents.contains_key(input) {
5458        return Ok(Some(input.to_string()));
5459    }
5460    let mut nick_matches: Vec<String> = Vec::new();
5461    for (handle, agent) in agents.iter() {
5462        // v0.7.0-alpha.6: prefer peer's published display nickname over
5463        // auto-derived. Allows `wire send <their-chosen-name>` not just
5464        // `wire send <their-did-hash-derived-name>`.
5465        let character = match agent.get("card") {
5466            Some(card) => crate::character::Character::from_card(card),
5467            None => match agent.get("did").and_then(Value::as_str) {
5468                Some(did) => crate::character::Character::from_did(did),
5469                None => continue,
5470            },
5471        };
5472        if character.nickname == input {
5473            nick_matches.push(handle.clone());
5474        }
5475    }
5476    match nick_matches.len() {
5477        0 => Ok(None),
5478        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
5479        _ => Err(ResolveError::Ambiguous(nick_matches)),
5480    }
5481}
5482
5483fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
5484    // 1. Locate sister session by name OR character nickname.
5485    let sessions = crate::session::list_sessions()?;
5486    let sister = match resolve_local_session(&sessions, sister_name) {
5487        Ok(s) => s,
5488        Err(ResolveError::NotFound) => bail!(
5489            "no sister session named `{sister_name}` (matched by session name or character nickname). \
5490             Run `wire session list` to see what's available."
5491        ),
5492        Err(ResolveError::Ambiguous(candidates)) => bail!(
5493            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
5494             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
5495            candidates.len(),
5496            candidates.join(", ")
5497        ),
5498    };
5499    // If we matched via nickname (not exact name), surface that so the
5500    // operator sees what we resolved to. Quiet when names match exactly.
5501    if sister.name != sister_name {
5502        eprintln!(
5503            "wire add: resolved nickname `{sister_name}` → session `{}`",
5504            sister.name
5505        );
5506    }
5507
5508    // 2. Refuse self-pair — operator owns both sides, but a self-loop
5509    // breaks the bilateral state machine.
5510    let our_card = config::read_agent_card()
5511        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
5512    let our_did = our_card
5513        .get("did")
5514        .and_then(Value::as_str)
5515        .ok_or_else(|| anyhow!("agent-card missing did"))?
5516        .to_string();
5517    if let Some(sister_did) = sister.did.as_deref()
5518        && sister_did == our_did
5519    {
5520        bail!("refusing to add self (`{sister_name}` is this very session)");
5521    }
5522
5523    // 3. Read sister's agent-card + relay state from disk.
5524    let sister_card_path = sister
5525        .home_dir
5526        .join("config")
5527        .join("wire")
5528        .join("agent-card.json");
5529    let sister_card: Value = serde_json::from_slice(
5530        &std::fs::read(&sister_card_path)
5531            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
5532    )
5533    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
5534    let sister_relay_state: Value = std::fs::read(
5535        sister
5536            .home_dir
5537            .join("config")
5538            .join("wire")
5539            .join("relay.json"),
5540    )
5541    .ok()
5542    .and_then(|b| serde_json::from_slice(&b).ok())
5543    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
5544
5545    let sister_did = sister_card
5546        .get("did")
5547        .and_then(Value::as_str)
5548        .ok_or_else(|| anyhow!("sister card missing did"))?
5549        .to_string();
5550    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
5551
5552    // Pull sister's full endpoint set; we want the local one for delivery
5553    // and we'll pin all of them so OUR pushes prefer local-first per the
5554    // existing routing logic.
5555    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
5556    if sister_endpoints.is_empty() {
5557        bail!(
5558            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
5559        );
5560    }
5561    let sister_local = sister_endpoints
5562        .iter()
5563        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
5564    let delivery_endpoint = match sister_local {
5565        Some(e) => e.clone(),
5566        None => sister_endpoints[0].clone(),
5567    };
5568
5569    // 4. Ensure WE have a slot to advertise back. For local-only sessions
5570    // this is the local slot; for dual-slot sessions, federation is fine.
5571    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
5572    // for pure local-only — instead, pick our own existing federation
5573    // endpoint if present, else fall back to whatever's first.
5574    let our_relay_state = config::read_relay_state()?;
5575    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
5576    if our_endpoints.is_empty() {
5577        bail!(
5578            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
5579        );
5580    }
5581    let our_advertised = our_endpoints
5582        .iter()
5583        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
5584        .cloned()
5585        .unwrap_or_else(|| our_endpoints[0].clone());
5586
5587    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
5588    // relay_state.peers with their full endpoint set. slot_token lands
5589    // via pair_drop_ack as usual.
5590    let mut trust = config::read_trust()?;
5591    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
5592    config::write_trust(&trust)?;
5593    let mut relay_state = config::read_relay_state()?;
5594    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
5595    config::write_relay_state(&relay_state)?;
5596
5597    // 6. Build the same pair_drop event the federation path emits, with
5598    // our card + endpoints in the body so the sister can pin us back.
5599    let sk_seed = config::read_private_key()?;
5600    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
5601    let pk_b64 = our_card
5602        .get("verify_keys")
5603        .and_then(Value::as_object)
5604        .and_then(|m| m.values().next())
5605        .and_then(|v| v.get("key"))
5606        .and_then(Value::as_str)
5607        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
5608    let pk_bytes = crate::signing::b64decode(pk_b64)?;
5609    let now = time::OffsetDateTime::now_utc()
5610        .format(&time::format_description::well_known::Rfc3339)
5611        .unwrap_or_default();
5612    let mut body = json!({
5613        "card": our_card,
5614        "relay_url": our_advertised.relay_url,
5615        "slot_id": our_advertised.slot_id,
5616        "slot_token": our_advertised.slot_token,
5617    });
5618    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
5619    let event = json!({
5620        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5621        "timestamp": now,
5622        "from": our_did,
5623        "to": sister_did,
5624        "type": "pair_drop",
5625        "kind": 1100u32,
5626        "body": body,
5627    });
5628    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
5629    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
5630
5631    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
5632    // (the federation handle indexer) — we already know the slot coords
5633    // from disk, so post_event is sufficient.
5634    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
5635    client
5636        .post_event(
5637            &delivery_endpoint.slot_id,
5638            &delivery_endpoint.slot_token,
5639            &signed,
5640        )
5641        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
5642
5643    if as_json {
5644        println!(
5645            "{}",
5646            serde_json::to_string(&json!({
5647                "handle": sister_name,
5648                "paired_with": sister_did,
5649                "peer_handle": sister_handle,
5650                "event_id": event_id,
5651                "delivered_via": match delivery_endpoint.scope {
5652                    crate::endpoints::EndpointScope::Local => "local",
5653                    crate::endpoints::EndpointScope::Lan => "lan",
5654                    crate::endpoints::EndpointScope::Uds => "uds",
5655                    crate::endpoints::EndpointScope::Federation => "federation",
5656                },
5657                "status": "drop_sent",
5658            }))?
5659        );
5660    } else {
5661        let scope = match delivery_endpoint.scope {
5662            crate::endpoints::EndpointScope::Local => "local",
5663            crate::endpoints::EndpointScope::Lan => "lan",
5664            crate::endpoints::EndpointScope::Uds => "uds",
5665            crate::endpoints::EndpointScope::Federation => "federation",
5666        };
5667        println!(
5668            "→ found sister `{sister_name}` (did={sister_did})\n→ pinned peer locally\n→ pair_drop delivered to {scope} slot on {}\nawaiting pair_drop_ack from {sister_handle} to complete bilateral pin.",
5669            delivery_endpoint.relay_url
5670        );
5671    }
5672    Ok(())
5673}
5674
5675fn cmd_add(
5676    handle_arg: &str,
5677    relay_override: Option<&str>,
5678    local_sister: bool,
5679    as_json: bool,
5680) -> Result<()> {
5681    if local_sister {
5682        return cmd_add_local_sister(handle_arg, as_json);
5683    }
5684    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
5685
5686    // 1. Auto-init self if needed + ensure a relay slot.
5687    let (our_did, our_relay, our_slot_id, our_slot_token) =
5688        crate::pair_invite::ensure_self_with_relay(relay_override)?;
5689    if our_did == format!("did:wire:{}", parsed.nick) {
5690        // Lazy guard — actual self-add would also be caught by FCFS later.
5691        bail!("refusing to add self (handle matches own DID)");
5692    }
5693
5694    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
5695    // already sitting in pending-inbound, the operator is now accepting it.
5696    // Pin trust, save relay coords + slot_token from the stored drop, ship
5697    // our own slot_token back via pair_drop_ack, delete the pending record.
5698    //
5699    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
5700    // receiver-side auto-promote was removed there; operator consent flows
5701    // through here. After this branch returns, both sides are bilaterally
5702    // pinned and capability flows in both directions.
5703    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
5704        return cmd_add_accept_pending(
5705            handle_arg,
5706            &parsed.nick,
5707            &pending,
5708            &our_relay,
5709            &our_slot_id,
5710            &our_slot_token,
5711            as_json,
5712        );
5713    }
5714
5715    // v0.5.19 (#9.4): cross-relay phishing guardrail.
5716    //
5717    // Threat: operator wants to add `boss@wireup.net` but types
5718    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
5719    // The .well-known resolution returns whoever claimed the nick on the
5720    // *typo* relay, the bilateral gate still completes (the attacker
5721    // accepts the pair on their side), and the operator pins the
5722    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
5723    // there's no asymmetry to detect when the attacker WANTS to be
5724    // paired.
5725    //
5726    // Mitigation: warn loudly when the peer's relay domain is novel
5727    // (not the operator's own relay, not in a small known-good set).
5728    // Doesn't block — operators have legitimate reasons to pair across
5729    // relays. The signal lands in shell history so a phished operator
5730    // can find it in retrospect.
5731    if !is_known_relay_domain(&parsed.domain, &our_relay) {
5732        eprintln!(
5733            "wire add: WARN unfamiliar relay domain `{}`.",
5734            parsed.domain
5735        );
5736        eprintln!(
5737            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
5738            host_of_url(&our_relay)
5739        );
5740        eprintln!(
5741            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
5742            parsed.nick
5743        );
5744        eprintln!(
5745            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
5746            parsed.nick
5747        );
5748        eprintln!("  peer out-of-band that they actually run a relay at this domain");
5749        eprintln!("  before relying on the pair. (See issue #9.4.)");
5750    }
5751
5752    // 2. Resolve peer via .well-known on their relay.
5753    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
5754    let peer_card = resolved
5755        .get("card")
5756        .cloned()
5757        .ok_or_else(|| anyhow!("resolved missing card"))?;
5758    let peer_did = resolved
5759        .get("did")
5760        .and_then(Value::as_str)
5761        .ok_or_else(|| anyhow!("resolved missing did"))?
5762        .to_string();
5763    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
5764    let peer_slot_id = resolved
5765        .get("slot_id")
5766        .and_then(Value::as_str)
5767        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
5768        .to_string();
5769    let peer_relay = resolved
5770        .get("relay_url")
5771        .and_then(Value::as_str)
5772        .map(str::to_string)
5773        .or_else(|| relay_override.map(str::to_string))
5774        .unwrap_or_else(|| format!("https://{}", parsed.domain));
5775
5776    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
5777    let mut trust = config::read_trust()?;
5778    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
5779    config::write_trust(&trust)?;
5780    let mut relay_state = config::read_relay_state()?;
5781    let existing_token = relay_state
5782        .get("peers")
5783        .and_then(|p| p.get(&peer_handle))
5784        .and_then(|p| p.get("slot_token"))
5785        .and_then(Value::as_str)
5786        .map(str::to_string)
5787        .unwrap_or_default();
5788    relay_state["peers"][&peer_handle] = json!({
5789        "relay_url": peer_relay,
5790        "slot_id": peer_slot_id,
5791        "slot_token": existing_token, // empty until pair_drop_ack lands
5792    });
5793    config::write_relay_state(&relay_state)?;
5794
5795    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
5796    // is the v0.5 zero-paste open-mode path).
5797    let our_card = config::read_agent_card()?;
5798    let sk_seed = config::read_private_key()?;
5799    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
5800    let pk_b64 = our_card
5801        .get("verify_keys")
5802        .and_then(Value::as_object)
5803        .and_then(|m| m.values().next())
5804        .and_then(|v| v.get("key"))
5805        .and_then(Value::as_str)
5806        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
5807    let pk_bytes = crate::signing::b64decode(pk_b64)?;
5808    let now = time::OffsetDateTime::now_utc()
5809        .format(&time::format_description::well_known::Rfc3339)
5810        .unwrap_or_default();
5811    // v0.5.17: advertise all our endpoints (federation + optional local)
5812    // to the peer in the pair_drop body. Back-compat: top-level
5813    // relay_url/slot_id/slot_token still point at the federation
5814    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
5815    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
5816    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
5817    let mut body = json!({
5818        "card": our_card,
5819        "relay_url": our_relay,
5820        "slot_id": our_slot_id,
5821        "slot_token": our_slot_token,
5822    });
5823    if !our_endpoints.is_empty() {
5824        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
5825    }
5826    let event = json!({
5827        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5828        "timestamp": now,
5829        "from": our_did,
5830        "to": peer_did,
5831        "type": "pair_drop",
5832        "kind": 1100u32,
5833        "body": body,
5834    });
5835    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
5836
5837    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
5838    let client = crate::relay_client::RelayClient::new(&peer_relay);
5839    let resp = client.handle_intro(&parsed.nick, &signed)?;
5840    let event_id = signed
5841        .get("event_id")
5842        .and_then(Value::as_str)
5843        .unwrap_or("")
5844        .to_string();
5845
5846    if as_json {
5847        println!(
5848            "{}",
5849            serde_json::to_string(&json!({
5850                "handle": handle_arg,
5851                "paired_with": peer_did,
5852                "peer_handle": peer_handle,
5853                "event_id": event_id,
5854                "drop_response": resp,
5855                "status": "drop_sent",
5856            }))?
5857        );
5858    } else {
5859        println!(
5860            "→ resolved {handle_arg} (did={peer_did})\n→ pinned peer locally\n→ intro dropped to {peer_relay}\nawaiting pair_drop_ack from {peer_handle} to complete bilateral pin."
5861        );
5862    }
5863    Ok(())
5864}
5865
5866/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
5867/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
5868/// coords + slot_token from the stored drop, ship our slot_token back via
5869/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
5870/// invite-URL path (which is already bilateral by virtue of the pre-shared
5871/// nonce).
5872fn cmd_add_accept_pending(
5873    handle_arg: &str,
5874    peer_nick: &str,
5875    pending: &crate::pending_inbound_pair::PendingInboundPair,
5876    _our_relay: &str,
5877    _our_slot_id: &str,
5878    _our_slot_token: &str,
5879    as_json: bool,
5880) -> Result<()> {
5881    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
5882    //    `wire add` against this handle while a drop was waiting.
5883    let mut trust = config::read_trust()?;
5884    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
5885    config::write_trust(&trust)?;
5886
5887    // 2. Record peer's relay coords + slot_token (already shipped to us in
5888    //    the original drop body; held back until now).
5889    // v0.5.17: pin all advertised endpoints (federation + optional local).
5890    // Falls back to a single federation entry when the record was written
5891    // by v0.5.16-era code that didn't carry endpoints[].
5892    let mut relay_state = config::read_relay_state()?;
5893    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
5894        vec![crate::endpoints::Endpoint::federation(
5895            pending.peer_relay_url.clone(),
5896            pending.peer_slot_id.clone(),
5897            pending.peer_slot_token.clone(),
5898        )]
5899    } else {
5900        pending.peer_endpoints.clone()
5901    };
5902    crate::endpoints::pin_peer_endpoints(
5903        &mut relay_state,
5904        &pending.peer_handle,
5905        &endpoints_to_pin,
5906    )?;
5907    config::write_relay_state(&relay_state)?;
5908
5909    // 3. Ship our slot_token to peer via pair_drop_ack so they can write back.
5910    crate::pair_invite::send_pair_drop_ack(
5911        &pending.peer_handle,
5912        &pending.peer_relay_url,
5913        &pending.peer_slot_id,
5914        &pending.peer_slot_token,
5915    )
5916    .with_context(|| {
5917        format!(
5918            "pair_drop_ack send to {} @ {} slot {} failed",
5919            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
5920        )
5921    })?;
5922
5923    // 4. Delete the pending-inbound record now that bilateral is complete.
5924    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
5925
5926    if as_json {
5927        println!(
5928            "{}",
5929            serde_json::to_string(&json!({
5930                "handle": handle_arg,
5931                "paired_with": pending.peer_did,
5932                "peer_handle": pending.peer_handle,
5933                "status": "bilateral_accepted",
5934                "via": "pending_inbound",
5935            }))?
5936        );
5937    } else {
5938        println!(
5939            "→ accepted pending pair from {peer}\n→ pinned VERIFIED, slot_token recorded\n→ shipped our slot_token back via pair_drop_ack\nbilateral pair complete. Send with `wire send {peer} \"...\"`.",
5940            peer = pending.peer_handle,
5941        );
5942    }
5943    Ok(())
5944}
5945
5946/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
5947/// for a pending-inbound pair request. Pin trust, write relay_state from the
5948/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
5949/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
5950/// when a pending-inbound record exists, but without needing to remember
5951/// the peer's relay domain.
5952fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
5953    let nick = crate::agent_card::bare_handle(peer_nick);
5954    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
5955        anyhow!(
5956            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
5957             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
5958        )
5959    })?;
5960    let (_our_did, our_relay, our_slot_id, our_slot_token) =
5961        crate::pair_invite::ensure_self_with_relay(None)?;
5962    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
5963    cmd_add_accept_pending(
5964        &handle_arg,
5965        nick,
5966        &pending,
5967        &our_relay,
5968        &our_slot_id,
5969        &our_slot_token,
5970        as_json,
5971    )
5972}
5973
5974/// v0.5.14: programmatic access to pending-inbound for scripts.
5975/// `wire pair-list-inbound --json` returns a flat array of records.
5976fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
5977    let items = crate::pending_inbound_pair::list_pending_inbound()?;
5978    if as_json {
5979        println!("{}", serde_json::to_string(&items)?);
5980        return Ok(());
5981    }
5982    if items.is_empty() {
5983        println!("no pending inbound pair requests.");
5984        return Ok(());
5985    }
5986    println!("{:<20} {:<35} {:<25} DID", "PEER", "RELAY", "RECEIVED");
5987    for p in items {
5988        println!(
5989            "{:<20} {:<35} {:<25} {}",
5990            p.peer_handle, p.peer_relay_url, p.received_at, p.peer_did,
5991        );
5992    }
5993    println!("→ accept with `wire pair-accept <peer>`; refuse with `wire pair-reject <peer>`.");
5994    Ok(())
5995}
5996
5997/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
5998/// without pairing. No event is sent back to the peer; their side stays
5999/// pending until they time out or the operator-side data ages out.
6000fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
6001    let nick = crate::agent_card::bare_handle(peer_nick);
6002    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
6003    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
6004
6005    if as_json {
6006        println!(
6007            "{}",
6008            serde_json::to_string(&json!({
6009                "peer": nick,
6010                "rejected": existed.is_some(),
6011                "had_pending": existed.is_some(),
6012            }))?
6013        );
6014    } else if existed.is_some() {
6015        println!(
6016            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6017        );
6018    } else {
6019        println!("no pending pair from {nick} — nothing to reject");
6020    }
6021    Ok(())
6022}
6023
6024// ---------- session (v0.5.16) ----------
6025//
6026// Multi-session wire on one machine. See src/session.rs for the storage
6027// layout + naming rules. The CLI dispatcher here orchestrates child
6028// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
6029// each session-local `init` / `claim` / `daemon` runs in its own world
6030// without cross-contamination via env vars in this process.
6031
6032/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
6033/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
6034fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6035    match cmd {
6036        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6037        MeshCommand::Broadcast {
6038            kind,
6039            scope,
6040            exclude,
6041            noreply,
6042            body,
6043            json,
6044        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6045        MeshCommand::Role { action } => cmd_mesh_role(action),
6046        MeshCommand::Route {
6047            role,
6048            strategy,
6049            exclude,
6050            kind,
6051            body,
6052            json,
6053        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
6054    }
6055}
6056
6057/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
6058/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
6059/// picks ONE via the requested strategy, then signs + pushes the event
6060/// to that peer. Pinned-peers-only by construction (same as broadcast).
6061fn cmd_mesh_route(
6062    role: &str,
6063    strategy: &str,
6064    exclude: &[String],
6065    kind: &str,
6066    body_arg: &str,
6067    as_json: bool,
6068) -> Result<()> {
6069    use std::time::Instant;
6070
6071    if !config::is_initialized()? {
6072        bail!("not initialized — run `wire init <handle>` first");
6073    }
6074    let strategy = strategy.to_ascii_lowercase();
6075    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
6076        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
6077    }
6078
6079    // Our pinned-peer set: only these handles are addressable. mesh-route
6080    // refuses to invent a recipient, same posture as broadcast.
6081    let state = config::read_relay_state()?;
6082    let pinned: std::collections::BTreeSet<String> = state["peers"]
6083        .as_object()
6084        .map(|m| m.keys().cloned().collect())
6085        .unwrap_or_default();
6086
6087    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6088
6089    // Enumerate every sister on the box, read each one's role from its
6090    // signed agent-card. Filter: matching role AND pinned AND not
6091    // excluded. `list_sessions` returns the cross-session view (using the
6092    // v0.6.4 inside-session sessions_root fallback).
6093    let sessions = crate::session::list_sessions()?;
6094    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
6095    for s in &sessions {
6096        let handle = match s.handle.as_ref() {
6097            Some(h) => h.clone(),
6098            None => continue,
6099        };
6100        if exclude_set.contains(handle.as_str()) {
6101            continue;
6102        }
6103        if !pinned.contains(&handle) {
6104            continue;
6105        }
6106        let card_path = s
6107            .home_dir
6108            .join("config")
6109            .join("wire")
6110            .join("agent-card.json");
6111        let card_role = std::fs::read(&card_path)
6112            .ok()
6113            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6114            .and_then(|c| {
6115                c.get("profile")
6116                    .and_then(|p| p.get("role"))
6117                    .and_then(Value::as_str)
6118                    .map(str::to_string)
6119            });
6120        if card_role.as_deref() == Some(role) {
6121            candidates.push((handle, s.did.clone()));
6122        }
6123    }
6124
6125    candidates.sort_by(|a, b| a.0.cmp(&b.0));
6126    candidates.dedup_by(|a, b| a.0 == b.0);
6127
6128    if candidates.is_empty() {
6129        bail!(
6130            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
6131        );
6132    }
6133
6134    let chosen = match strategy.as_str() {
6135        "first" => candidates[0].clone(),
6136        "random" => {
6137            use rand::Rng;
6138            let idx = rand::thread_rng().gen_range(0..candidates.len());
6139            candidates[idx].clone()
6140        }
6141        "round-robin" => {
6142            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
6143            // `{role: last_picked_handle}`. Next pick = first candidate
6144            // alphabetically AFTER last_picked, wrapping around when no
6145            // candidate is greater.
6146            let cursor_path = mesh_route_cursor_path()?;
6147            let mut cursors: std::collections::BTreeMap<String, String> =
6148                read_mesh_route_cursors(&cursor_path);
6149            let last = cursors.get(role).cloned();
6150            let pick = match last {
6151                None => candidates[0].clone(),
6152                Some(last_h) => candidates
6153                    .iter()
6154                    .find(|(h, _)| h.as_str() > last_h.as_str())
6155                    .cloned()
6156                    .unwrap_or_else(|| candidates[0].clone()),
6157            };
6158            cursors.insert(role.to_string(), pick.0.clone());
6159            write_mesh_route_cursors(&cursor_path, &cursors)?;
6160            pick
6161        }
6162        _ => unreachable!(),
6163    };
6164
6165    let (chosen_handle, _chosen_did) = chosen;
6166
6167    // Body parsing follows wire send / mesh broadcast.
6168    let body_value: Value = if body_arg == "-" {
6169        use std::io::Read;
6170        let mut raw = String::new();
6171        std::io::stdin()
6172            .read_to_string(&mut raw)
6173            .with_context(|| "reading body from stdin")?;
6174        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6175    } else if let Some(path) = body_arg.strip_prefix('@') {
6176        let raw =
6177            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6178        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6179    } else {
6180        Value::String(body_arg.to_string())
6181    };
6182
6183    let sk_seed = config::read_private_key()?;
6184    let card = config::read_agent_card()?;
6185    let did = card
6186        .get("did")
6187        .and_then(Value::as_str)
6188        .ok_or_else(|| anyhow!("agent-card missing did"))?
6189        .to_string();
6190    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6191    let pk_b64 = card
6192        .get("verify_keys")
6193        .and_then(Value::as_object)
6194        .and_then(|m| m.values().next())
6195        .and_then(|v| v.get("key"))
6196        .and_then(Value::as_str)
6197        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6198    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6199
6200    let kind_id = parse_kind(kind)?;
6201    let now_iso = time::OffsetDateTime::now_utc()
6202        .format(&time::format_description::well_known::Rfc3339)
6203        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6204
6205    let event = json!({
6206        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6207        "timestamp": now_iso,
6208        "from": did,
6209        "to": format!("did:wire:{chosen_handle}"),
6210        "type": kind,
6211        "kind": kind_id,
6212        "body": json!({
6213            "content": body_value,
6214            "routed_via": {
6215                "role": role,
6216                "strategy": strategy,
6217            },
6218        }),
6219    });
6220    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
6221        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
6222    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6223
6224    let line = serde_json::to_vec(&signed)?;
6225    config::append_outbox_record(&chosen_handle, &line)?;
6226
6227    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
6228    if endpoints.is_empty() {
6229        bail!(
6230            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
6231        );
6232    }
6233    let start = Instant::now();
6234    let mut delivered = false;
6235    let mut last_err: Option<String> = None;
6236    let mut via_scope: Option<String> = None;
6237    for ep in &endpoints {
6238        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
6239        // route via uds_request, others via reqwest. Allows peers with
6240        // UDS-tagged endpoints in their agent-card to receive events
6241        // over the local socket instead of loopback HTTP.
6242        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
6243            Ok(_) => {
6244                delivered = true;
6245                via_scope = Some(
6246                    match ep.scope {
6247                        crate::endpoints::EndpointScope::Local => "local",
6248                        crate::endpoints::EndpointScope::Lan => "lan",
6249                        crate::endpoints::EndpointScope::Uds => "uds",
6250                        crate::endpoints::EndpointScope::Federation => "federation",
6251                    }
6252                    .to_string(),
6253                );
6254                break;
6255            }
6256            Err(e) => last_err = Some(format!("{e:#}")),
6257        }
6258    }
6259    let rtt_ms = start.elapsed().as_millis() as u64;
6260
6261    let summary = json!({
6262        "role": role,
6263        "strategy": strategy,
6264        "routed_to": chosen_handle,
6265        "event_id": event_id,
6266        "delivered": delivered,
6267        "delivered_via": via_scope,
6268        "rtt_ms": rtt_ms,
6269        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
6270        "error": last_err,
6271    });
6272
6273    if as_json {
6274        println!("{}", serde_json::to_string(&summary)?);
6275    } else if delivered {
6276        let via = via_scope.as_deref().unwrap_or("?");
6277        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
6278    } else {
6279        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
6280        bail!("delivery to `{chosen_handle}` failed: {err}");
6281    }
6282    Ok(())
6283}
6284
6285fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
6286    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
6287}
6288
6289fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
6290    std::fs::read(path)
6291        .ok()
6292        .and_then(|b| serde_json::from_slice(&b).ok())
6293        .unwrap_or_default()
6294}
6295
6296fn write_mesh_route_cursors(
6297    path: &std::path::Path,
6298    cursors: &std::collections::BTreeMap<String, String>,
6299) -> Result<()> {
6300    if let Some(parent) = path.parent() {
6301        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
6302    }
6303    let body = serde_json::to_vec_pretty(cursors)?;
6304    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
6305    Ok(())
6306}
6307
6308/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
6309/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
6310/// behind a discoverability-friendlier surface, plus cross-session
6311/// enumeration for the list path.
6312fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
6313    match action {
6314        MeshRoleAction::Set { role, json } => {
6315            validate_role_tag(&role)?;
6316            let new_profile =
6317                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
6318            if json {
6319                println!(
6320                    "{}",
6321                    serde_json::to_string(&json!({
6322                        "role": role,
6323                        "profile": new_profile,
6324                    }))?
6325                );
6326            } else {
6327                println!("self role = {role} (signed into agent-card)");
6328            }
6329        }
6330        MeshRoleAction::Get { peer, json } => {
6331            let (who, role) = match peer.as_deref() {
6332                None => {
6333                    let card = config::read_agent_card()?;
6334                    let role = card
6335                        .get("profile")
6336                        .and_then(|p| p.get("role"))
6337                        .and_then(Value::as_str)
6338                        .map(str::to_string);
6339                    let who = card
6340                        .get("did")
6341                        .and_then(Value::as_str)
6342                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
6343                        .unwrap_or_else(|| "self".to_string());
6344                    (who, role)
6345                }
6346                Some(handle) => {
6347                    let bare = crate::agent_card::bare_handle(handle).to_string();
6348                    let trust = config::read_trust()?;
6349                    let role = trust
6350                        .get("agents")
6351                        .and_then(|a| a.get(&bare))
6352                        .and_then(|a| a.get("card"))
6353                        .and_then(|c| c.get("profile"))
6354                        .and_then(|p| p.get("role"))
6355                        .and_then(Value::as_str)
6356                        .map(str::to_string);
6357                    (bare, role)
6358                }
6359            };
6360            if json {
6361                println!(
6362                    "{}",
6363                    serde_json::to_string(&json!({
6364                        "handle": who,
6365                        "role": role,
6366                    }))?
6367                );
6368            } else {
6369                match role {
6370                    Some(r) => println!("{who}: {r}"),
6371                    None => println!("{who}: (unset)"),
6372                }
6373            }
6374        }
6375        MeshRoleAction::List { json } => {
6376            let mut self_did: Option<String> = None;
6377            if let Ok(card) = config::read_agent_card() {
6378                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
6379            }
6380            let sessions = crate::session::list_sessions()?;
6381            let mut rows: Vec<Value> = Vec::new();
6382            for s in &sessions {
6383                let card_path = s
6384                    .home_dir
6385                    .join("config")
6386                    .join("wire")
6387                    .join("agent-card.json");
6388                let role = std::fs::read(&card_path)
6389                    .ok()
6390                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6391                    .and_then(|c| {
6392                        c.get("profile")
6393                            .and_then(|p| p.get("role"))
6394                            .and_then(Value::as_str)
6395                            .map(str::to_string)
6396                    });
6397                let is_self = match (&self_did, &s.did) {
6398                    (Some(a), Some(b)) => a == b,
6399                    _ => false,
6400                };
6401                rows.push(json!({
6402                    "name": s.name,
6403                    "handle": s.handle,
6404                    "role": role,
6405                    "self": is_self,
6406                }));
6407            }
6408            rows.sort_by(|a, b| {
6409                a["name"]
6410                    .as_str()
6411                    .unwrap_or("")
6412                    .cmp(b["name"].as_str().unwrap_or(""))
6413            });
6414            if json {
6415                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
6416            } else if rows.is_empty() {
6417                println!("no sister sessions on this machine.");
6418            } else {
6419                println!("SISTER ROLES (this machine):");
6420                for r in &rows {
6421                    let name = r["name"].as_str().unwrap_or("?");
6422                    let role = r["role"].as_str().unwrap_or("(unset)");
6423                    let marker = if r["self"].as_bool().unwrap_or(false) {
6424                        "    ← you"
6425                    } else {
6426                        ""
6427                    };
6428                    println!("  {name:<24} {role}{marker}");
6429                }
6430            }
6431        }
6432        MeshRoleAction::Clear { json } => {
6433            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
6434            if json {
6435                println!(
6436                    "{}",
6437                    serde_json::to_string(&json!({
6438                        "cleared": true,
6439                        "profile": new_profile,
6440                    }))?
6441                );
6442            } else {
6443                println!("self role cleared");
6444            }
6445        }
6446    }
6447    Ok(())
6448}
6449
6450/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
6451/// No vocabulary check — operators choose the taxonomy (planner /
6452/// reviewer / dispatcher / your-custom-tag). The constraint is purely
6453/// to keep the tag safe for filenames / URLs / shell args.
6454fn validate_role_tag(role: &str) -> Result<()> {
6455    if role.is_empty() {
6456        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
6457    }
6458    if role.len() > 32 {
6459        bail!("role too long ({} chars; max 32)", role.len());
6460    }
6461    for c in role.chars() {
6462        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
6463            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
6464        }
6465    }
6466    Ok(())
6467}
6468
6469/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
6470///
6471/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
6472/// canonical event including `to:`, so per-recipient signing is required;
6473/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
6474/// Per-recipient pushes happen in parallel via `std::thread::scope` so
6475/// broadcast-to-5 takes ~1× RTT, not 5×.
6476///
6477/// **Scope filter.** Default `local` — only peers reachable via a same-
6478/// machine local relay (priority-1 endpoint has `scope=local`). This is
6479/// the lowest-blast-radius default: local-only broadcasts cannot escape
6480/// the operator's machine. `federation` flips to public-relay peers
6481/// only; `both` removes the filter.
6482///
6483/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
6484/// resolution, never trust["agents"] expansion. Closes #8-class
6485/// phonebook-scrape vectors by construction: an attacker pinning a
6486/// hostile handle has to first be pinned bidirectionally by the
6487/// operator, and even then `--exclude` is the loud opt-out.
6488fn cmd_mesh_broadcast(
6489    kind: &str,
6490    scope_str: &str,
6491    exclude: &[String],
6492    _noreply: bool,
6493    body_arg: &str,
6494    as_json: bool,
6495) -> Result<()> {
6496    use std::time::Instant;
6497
6498    if !config::is_initialized()? {
6499        bail!("not initialized — run `wire init <handle>` first");
6500    }
6501
6502    let scope = match scope_str {
6503        "local" => crate::endpoints::EndpointScope::Local,
6504        "federation" => crate::endpoints::EndpointScope::Federation,
6505        "both" => {
6506            // Sentinel: we don't actually have a `Both` variant on the
6507            // scope enum; use a tri-state below. Treat as Local for the
6508            // typed match and special-case it via the bool below.
6509            crate::endpoints::EndpointScope::Local
6510        }
6511        other => bail!("unknown scope `{other}` — use local | federation | both"),
6512    };
6513    let any_scope = scope_str == "both";
6514
6515    let state = config::read_relay_state()?;
6516    let peers = state["peers"].as_object().cloned().unwrap_or_default();
6517    if peers.is_empty() {
6518        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
6519    }
6520
6521    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6522
6523    // Walk the pinned-peer set, filter by scope + exclude. Keep the
6524    // priority-ordered endpoint list for each match so the push can
6525    // try local first then fall through to federation (when scope=both).
6526    struct Target {
6527        handle: String,
6528        endpoints: Vec<crate::endpoints::Endpoint>,
6529    }
6530    let mut targets: Vec<Target> = Vec::new();
6531    let mut skipped_wrong_scope: Vec<String> = Vec::new();
6532    let mut skipped_excluded: Vec<String> = Vec::new();
6533    for handle in peers.keys() {
6534        if exclude_set.contains(handle.as_str()) {
6535            skipped_excluded.push(handle.clone());
6536            continue;
6537        }
6538        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
6539        let filtered: Vec<crate::endpoints::Endpoint> = ordered
6540            .into_iter()
6541            .filter(|ep| any_scope || ep.scope == scope)
6542            .collect();
6543        if filtered.is_empty() {
6544            skipped_wrong_scope.push(handle.clone());
6545            continue;
6546        }
6547        targets.push(Target {
6548            handle: handle.clone(),
6549            endpoints: filtered,
6550        });
6551    }
6552
6553    if targets.is_empty() {
6554        bail!(
6555            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
6556            skipped_excluded.len(),
6557            skipped_wrong_scope.len()
6558        );
6559    }
6560
6561    // Load signing material once; share across per-peer signatures.
6562    let sk_seed = config::read_private_key()?;
6563    let card = config::read_agent_card()?;
6564    let did = card
6565        .get("did")
6566        .and_then(Value::as_str)
6567        .ok_or_else(|| anyhow!("agent-card missing did"))?
6568        .to_string();
6569    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6570    let pk_b64 = card
6571        .get("verify_keys")
6572        .and_then(Value::as_object)
6573        .and_then(|m| m.values().next())
6574        .and_then(|v| v.get("key"))
6575        .and_then(Value::as_str)
6576        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6577    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6578
6579    let body_value: Value = if body_arg == "-" {
6580        use std::io::Read;
6581        let mut raw = String::new();
6582        std::io::stdin()
6583            .read_to_string(&mut raw)
6584            .with_context(|| "reading body from stdin")?;
6585        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6586    } else if let Some(path) = body_arg.strip_prefix('@') {
6587        let raw =
6588            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6589        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6590    } else {
6591        Value::String(body_arg.to_string())
6592    };
6593
6594    let kind_id = parse_kind(kind)?;
6595    let now_iso = time::OffsetDateTime::now_utc()
6596        .format(&time::format_description::well_known::Rfc3339)
6597        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6598
6599    let broadcast_id = generate_broadcast_id();
6600    let target_count = targets.len();
6601
6602    // Build + sign every event up front (sequential, ~50µs/sig). Then
6603    // queue to outbox + push to relay in parallel per-peer. Returns
6604    // a per-peer outcome we then sort by handle for deterministic output.
6605    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
6606        Vec::with_capacity(targets.len());
6607    for t in &targets {
6608        let body = json!({
6609            "content": body_value,
6610            "broadcast_id": broadcast_id,
6611            "broadcast_target_count": target_count,
6612        });
6613        let event = json!({
6614            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6615            "timestamp": now_iso,
6616            "from": did,
6617            "to": format!("did:wire:{}", t.handle),
6618            "type": kind,
6619            "kind": kind_id,
6620            "body": body,
6621        });
6622        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
6623            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
6624        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6625        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
6626    }
6627
6628    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
6629    // holds a per-path mutex; writes are independent across handles but
6630    // we want the side-effect ordering deterministic).
6631    for (peer, _, signed, _) in &signed_per_peer {
6632        let line = serde_json::to_vec(signed)?;
6633        config::append_outbox_record(peer, &line)?;
6634    }
6635
6636    // Per-peer parallel push. Each thread tries the priority-ordered
6637    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
6638    // error_opt) over a channel.
6639    use std::sync::mpsc;
6640    let (tx, rx) = mpsc::channel::<Value>();
6641    std::thread::scope(|s| {
6642        for (peer, endpoints, signed, event_id) in &signed_per_peer {
6643            let tx = tx.clone();
6644            let peer = peer.clone();
6645            let event_id = event_id.clone();
6646            let endpoints = endpoints.clone();
6647            let signed = signed.clone();
6648            s.spawn(move || {
6649                let start = Instant::now();
6650                let mut delivered = false;
6651                let mut last_err: Option<String> = None;
6652                let mut delivered_via: Option<String> = None;
6653                for ep in &endpoints {
6654                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
6655                    // uds_request, else reqwest). Same as cmd_send's
6656                    // single-peer path above; this is the parallel
6657                    // multi-peer broadcast loop.
6658                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
6659                        Ok(_) => {
6660                            delivered = true;
6661                            delivered_via = Some(
6662                                match ep.scope {
6663                                    crate::endpoints::EndpointScope::Local => "local",
6664                                    crate::endpoints::EndpointScope::Lan => "lan",
6665                                    crate::endpoints::EndpointScope::Uds => "uds",
6666                                    crate::endpoints::EndpointScope::Federation => "federation",
6667                                }
6668                                .to_string(),
6669                            );
6670                            break;
6671                        }
6672                        Err(e) => last_err = Some(format!("{e:#}")),
6673                    }
6674                }
6675                let rtt_ms = start.elapsed().as_millis() as u64;
6676                let _ = tx.send(json!({
6677                    "peer": peer,
6678                    "event_id": event_id,
6679                    "delivered": delivered,
6680                    "delivered_via": delivered_via,
6681                    "rtt_ms": rtt_ms,
6682                    "error": last_err,
6683                }));
6684            });
6685        }
6686    });
6687    drop(tx);
6688
6689    let mut results: Vec<Value> = rx.iter().collect();
6690    results.sort_by(|a, b| {
6691        a["peer"]
6692            .as_str()
6693            .unwrap_or("")
6694            .cmp(b["peer"].as_str().unwrap_or(""))
6695    });
6696
6697    let delivered = results
6698        .iter()
6699        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
6700        .count();
6701    let failed = results.len() - delivered;
6702
6703    let summary = json!({
6704        "broadcast_id": broadcast_id,
6705        "kind": kind,
6706        "scope": scope_str,
6707        "target_count": target_count,
6708        "delivered": delivered,
6709        "failed": failed,
6710        "skipped_excluded": skipped_excluded,
6711        "skipped_wrong_scope": skipped_wrong_scope,
6712        "results": results,
6713    });
6714
6715    if as_json {
6716        println!("{}", serde_json::to_string(&summary)?);
6717        return Ok(());
6718    }
6719
6720    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
6721    for r in &results {
6722        let peer = r["peer"].as_str().unwrap_or("?");
6723        let delivered = r["delivered"].as_bool().unwrap_or(false);
6724        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
6725        let via = r["delivered_via"].as_str().unwrap_or("");
6726        if delivered {
6727            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
6728        } else {
6729            let err = r["error"].as_str().unwrap_or("?");
6730            println!("  {peer:<24} ✗ failed — {err}");
6731        }
6732    }
6733    if !skipped_excluded.is_empty() {
6734        println!("  excluded: {}", skipped_excluded.join(", "));
6735    }
6736    if !skipped_wrong_scope.is_empty() {
6737        println!(
6738            "  skipped (wrong scope): {}",
6739            skipped_wrong_scope.join(", ")
6740        );
6741    }
6742    println!("broadcast_id: {broadcast_id}");
6743    Ok(())
6744}
6745
6746/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
6747/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
6748/// correlate by string equality, the shape is for human readability.
6749fn generate_broadcast_id() -> String {
6750    use rand::RngCore;
6751    let mut buf = [0u8; 16];
6752    rand::thread_rng().fill_bytes(&mut buf);
6753    let h = hex::encode(buf);
6754    format!(
6755        "{}-{}-{}-{}-{}",
6756        &h[0..8],
6757        &h[8..12],
6758        &h[12..16],
6759        &h[16..20],
6760        &h[20..32],
6761    )
6762}
6763
6764fn cmd_session(cmd: SessionCommand) -> Result<()> {
6765    match cmd {
6766        SessionCommand::New {
6767            name,
6768            relay,
6769            with_local,
6770            local_relay,
6771            with_lan,
6772            lan_relay,
6773            with_uds,
6774            uds_socket,
6775            no_daemon,
6776            local_only,
6777            json,
6778        } => cmd_session_new(
6779            name.as_deref(),
6780            &relay,
6781            with_local,
6782            &local_relay,
6783            with_lan,
6784            lan_relay.as_deref(),
6785            with_uds,
6786            uds_socket.as_deref(),
6787            no_daemon,
6788            local_only,
6789            json,
6790        ),
6791        SessionCommand::List { json } => cmd_session_list(json),
6792        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
6793        SessionCommand::PairAllLocal {
6794            settle_secs,
6795            federation_relay,
6796            json,
6797        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
6798        SessionCommand::MeshStatus { stale_secs, json } => {
6799            cmd_session_mesh_status(stale_secs, json)
6800        }
6801        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
6802        SessionCommand::Current { json } => cmd_session_current(json),
6803        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
6804        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
6805    }
6806}
6807
6808fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
6809    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6810    let cwd_str = cwd.to_string_lossy().into_owned();
6811
6812    let resolved_name = match name_arg {
6813        Some(n) => crate::session::sanitize_name(n),
6814        None => crate::session::sanitize_name(
6815            cwd.file_name()
6816                .and_then(|s| s.to_str())
6817                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
6818        ),
6819    };
6820
6821    let session_home = crate::session::session_dir(&resolved_name)?;
6822    if !session_home.exists() {
6823        bail!(
6824            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
6825            session_home.display()
6826        );
6827    }
6828
6829    let prior = crate::session::read_registry()
6830        .ok()
6831        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
6832    if prior.as_deref() == Some(resolved_name.as_str()) {
6833        if json {
6834            println!(
6835                "{}",
6836                serde_json::to_string(&json!({
6837                    "cwd": cwd_str,
6838                    "session": resolved_name,
6839                    "changed": false,
6840                }))?
6841            );
6842        } else {
6843            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
6844        }
6845        return Ok(());
6846    }
6847    if let Some(prior_name) = &prior {
6848        eprintln!(
6849            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
6850        );
6851    }
6852
6853    crate::session::update_registry(|reg| {
6854        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
6855        Ok(())
6856    })?;
6857
6858    if json {
6859        println!(
6860            "{}",
6861            serde_json::to_string(&json!({
6862                "cwd": cwd_str,
6863                "session": resolved_name,
6864                "changed": true,
6865                "previous": prior,
6866            }))?
6867        );
6868    } else {
6869        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
6870        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
6871    }
6872    Ok(())
6873}
6874
6875fn resolve_session_name(name: Option<&str>) -> Result<String> {
6876    if let Some(n) = name {
6877        return Ok(crate::session::sanitize_name(n));
6878    }
6879    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6880    let registry = crate::session::read_registry().unwrap_or_default();
6881    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
6882}
6883
6884#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
6885// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
6886// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
6887fn cmd_session_new(
6888    name_arg: Option<&str>,
6889    relay: &str,
6890    with_local: bool,
6891    local_relay: &str,
6892    with_lan: bool,
6893    lan_relay: Option<&str>,
6894    with_uds: bool,
6895    uds_socket: Option<&std::path::Path>,
6896    no_daemon: bool,
6897    local_only: bool,
6898    as_json: bool,
6899) -> Result<()> {
6900    // v0.6.6: --local-only implies --with-local (a federation-free
6901    // session with no endpoints at all would be unaddressable).
6902    let with_local = with_local || local_only;
6903    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
6904    if with_lan && lan_relay.is_none() {
6905        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
6906    }
6907    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
6908    if with_uds && uds_socket.is_none() {
6909        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
6910    }
6911    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6912    let mut registry = crate::session::read_registry().unwrap_or_default();
6913    let name = match name_arg {
6914        Some(n) => crate::session::sanitize_name(n),
6915        None => crate::session::derive_name_from_cwd(&cwd, &registry),
6916    };
6917    let session_home = crate::session::session_dir(&name)?;
6918
6919    let already_exists = session_home.exists()
6920        && session_home
6921            .join("config")
6922            .join("wire")
6923            .join("agent-card.json")
6924            .exists();
6925    if already_exists {
6926        // Idempotent: re-register the cwd (if not already), refresh the
6927        // daemon if requested, surface the env-var line. Do not re-init
6928        // identity — that would clobber the keypair.
6929        registry
6930            .by_cwd
6931            .insert(cwd.to_string_lossy().into_owned(), name.clone());
6932        crate::session::write_registry(&registry)?;
6933        let info = render_session_info(&name, &session_home, &cwd)?;
6934        emit_session_new_result(&info, "already_exists", as_json)?;
6935        if !no_daemon {
6936            ensure_session_daemon(&session_home)?;
6937        }
6938        return Ok(());
6939    }
6940
6941    std::fs::create_dir_all(&session_home)
6942        .with_context(|| format!("creating session dir {session_home:?}"))?;
6943
6944    // Phase 1: init identity in the new session's WIRE_HOME. For
6945    // federation-bound sessions we pass `--relay` so init also
6946    // allocates a federation slot in the same step; for `--local-only`
6947    // we run init without --relay so no federation contact happens.
6948    let init_args: Vec<&str> = if local_only {
6949        vec!["init", &name]
6950    } else {
6951        vec!["init", &name, "--relay", relay]
6952    };
6953    let init_status = run_wire_with_home(&session_home, &init_args)?;
6954    if !init_status.success() {
6955        let how = if local_only {
6956            format!("`wire init {name}` (local-only)")
6957        } else {
6958            format!("`wire init {name} --relay {relay}`")
6959        };
6960        bail!("{how} failed inside session dir {session_home:?}");
6961    }
6962
6963    // Phase 2: claim the handle on the federation relay — SKIPPED when
6964    // `--local-only`. Local-only sessions have no public address and
6965    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
6966    // tries to publish them.
6967    let effective_handle = if local_only {
6968        name.clone()
6969    } else {
6970        let mut claim_attempt = 0u32;
6971        let mut effective = name.clone();
6972        loop {
6973            claim_attempt += 1;
6974            let status =
6975                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
6976            if status.success() {
6977                break;
6978            }
6979            if claim_attempt >= 5 {
6980                bail!(
6981                    "5 failed attempts to claim a handle on {relay} for session {name}. \
6982                     Try `wire session destroy {name} --force` and re-run with a different name, \
6983                     or use `--local-only` if you don't need a federation address."
6984                );
6985            }
6986            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
6987            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
6988            let token = suffix
6989                .rsplit('-')
6990                .next()
6991                .filter(|t| t.len() == 4)
6992                .map(str::to_string)
6993                .unwrap_or_else(|| format!("{claim_attempt}"));
6994            effective = format!("{name}-{token}");
6995        }
6996        effective
6997    };
6998
6999    // Persist the cwd → name mapping NOW so subsequent invocations from
7000    // this directory short-circuit to the "already_exists" branch.
7001    registry
7002        .by_cwd
7003        .insert(cwd.to_string_lossy().into_owned(), name.clone());
7004    crate::session::write_registry(&registry)?;
7005
7006    // v0.5.17: --with-local probes the local relay and, if it's
7007    // reachable, allocates a second slot there. The session's
7008    // relay_state.json grows a `self.endpoints[]` array carrying both
7009    // endpoints; routing layer (cmd_push) prefers local for sister-
7010    // session peers that also have a local slot.
7011    //
7012    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
7013    // allocation; a failed probe leaves the session with no endpoints,
7014    // which we surface as a hard error (the operator asked for local-
7015    // only but the local relay isn't running — fix that first).
7016    if with_local {
7017        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
7018        if local_only {
7019            // Verify the local slot landed. If the local relay was
7020            // unreachable, the session would be unreachable from
7021            // anywhere — surface that loudly instead of leaving an
7022            // orphaned session dir.
7023            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
7024            let state: Value = std::fs::read(&relay_state_path)
7025                .ok()
7026                .and_then(|b| serde_json::from_slice(&b).ok())
7027                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7028            let endpoints = crate::endpoints::self_endpoints(&state);
7029            let has_local = endpoints
7030                .iter()
7031                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
7032            if !has_local {
7033                bail!(
7034                    "--local-only requested but local-relay probe at {local_relay} failed — \
7035                     ensure the local relay is running (`wire service install --local-relay`), \
7036                     then re-run `wire session new {name} --local-only`."
7037                );
7038            }
7039        }
7040    }
7041
7042    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
7043    // Sits AFTER local because cmd_session_new's flow is "add endpoints
7044    // alongside existing self.endpoints[]" — order independent post-init.
7045    if with_lan && let Some(lan_url) = lan_relay {
7046        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
7047    }
7048    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
7049    if with_uds && let Some(socket_path) = uds_socket {
7050        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
7051    }
7052
7053    if !no_daemon {
7054        ensure_session_daemon(&session_home)?;
7055    }
7056
7057    let info = render_session_info(&name, &session_home, &cwd)?;
7058    emit_session_new_result(&info, "created", as_json)
7059}
7060
7061/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
7062/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
7063/// sister sessions can route over the local socket instead of loopback
7064/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
7065/// alpha.17 — reqwest has no UDS support.
7066///
7067/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
7068/// and try_allocate_lan_slot semantics): session stays at existing
7069/// endpoint mix, operator can retry once the UDS relay is up.
7070#[cfg(unix)]
7071fn try_allocate_uds_slot(
7072    session_home: &std::path::Path,
7073    handle: &str,
7074    uds_socket: &std::path::Path,
7075) {
7076    // Probe healthz first so we fail fast with a clear stderr if the
7077    // socket doesn't exist OR isn't a wire relay.
7078    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
7079        Ok((200, _)) => true,
7080        Ok((status, body)) => {
7081            eprintln!(
7082                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
7083                String::from_utf8_lossy(&body)
7084            );
7085            return;
7086        }
7087        Err(e) => {
7088            eprintln!(
7089                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
7090                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
7091            );
7092            return;
7093        }
7094    };
7095    if !healthz {
7096        return;
7097    }
7098
7099    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
7100    let alloc_body = serde_json::json!({"handle": handle}).to_string();
7101    let (status, body) = match crate::relay_client::uds_request(
7102        uds_socket,
7103        "POST",
7104        "/v1/slot/allocate",
7105        &[("Content-Type", "application/json")],
7106        alloc_body.as_bytes(),
7107    ) {
7108        Ok(r) => r,
7109        Err(e) => {
7110            eprintln!(
7111                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
7112            );
7113            return;
7114        }
7115    };
7116    if status >= 300 {
7117        eprintln!(
7118            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
7119            String::from_utf8_lossy(&body)
7120        );
7121        return;
7122    }
7123    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
7124        Ok(a) => a,
7125        Err(e) => {
7126            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
7127            return;
7128        }
7129    };
7130
7131    let state_path = session_home.join("config").join("wire").join("relay.json");
7132    let mut state: serde_json::Value = std::fs::read(&state_path)
7133        .ok()
7134        .and_then(|b| serde_json::from_slice(&b).ok())
7135        .unwrap_or_else(|| serde_json::json!({}));
7136
7137    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7138        .get("self")
7139        .and_then(|s| s.get("endpoints"))
7140        .and_then(|e| e.as_array())
7141        .map(|arr| {
7142            arr.iter()
7143                .filter_map(|v| {
7144                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7145                })
7146                .collect()
7147        })
7148        .unwrap_or_default();
7149    endpoints.push(crate::endpoints::Endpoint::uds(
7150        format!("unix://{}", uds_socket.display()),
7151        alloc.slot_id.clone(),
7152        alloc.slot_token.clone(),
7153    ));
7154
7155    let self_obj = state
7156        .as_object_mut()
7157        .expect("relay_state root is an object")
7158        .entry("self")
7159        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7160    if !self_obj.is_object() {
7161        *self_obj = serde_json::Value::Object(serde_json::Map::new());
7162    }
7163    if let Some(obj) = self_obj.as_object_mut() {
7164        obj.insert(
7165            "endpoints".into(),
7166            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7167        );
7168    }
7169    if let Err(e) = std::fs::write(
7170        &state_path,
7171        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7172    ) {
7173        eprintln!("wire session new: failed to write {state_path:?}: {e}");
7174        return;
7175    }
7176    eprintln!(
7177        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
7178        uds_socket.display(),
7179        alloc.slot_id
7180    );
7181}
7182
7183#[cfg(not(unix))]
7184fn try_allocate_uds_slot(
7185    _session_home: &std::path::Path,
7186    _handle: &str,
7187    _uds_socket: &std::path::Path,
7188) {
7189    eprintln!(
7190        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
7191    );
7192}
7193
7194/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
7195/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
7196/// pulling the agent-card see a third reachable address.
7197///
7198/// Mirrors `try_allocate_local_slot` but tags the endpoint
7199/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
7200/// session stays at whatever endpoint mix it already had — operators
7201/// can retry with `wire session new --with-lan --lan-relay <url>` once
7202/// the LAN relay is up.
7203fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
7204    let probe = match crate::relay_client::build_blocking_client(Some(
7205        std::time::Duration::from_millis(500),
7206    )) {
7207        Ok(c) => c,
7208        Err(e) => {
7209            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
7210            return;
7211        }
7212    };
7213    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
7214    match probe.get(&healthz_url).send() {
7215        Ok(resp) if resp.status().is_success() => {}
7216        Ok(resp) => {
7217            eprintln!(
7218                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
7219                resp.status()
7220            );
7221            return;
7222        }
7223        Err(e) => {
7224            eprintln!(
7225                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
7226                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
7227                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
7228            );
7229            return;
7230        }
7231    };
7232
7233    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
7234    let alloc = match lan_client.allocate_slot(Some(handle)) {
7235        Ok(a) => a,
7236        Err(e) => {
7237            eprintln!(
7238                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
7239            );
7240            return;
7241        }
7242    };
7243
7244    let state_path = session_home.join("config").join("wire").join("relay.json");
7245    let mut state: serde_json::Value = std::fs::read(&state_path)
7246        .ok()
7247        .and_then(|b| serde_json::from_slice(&b).ok())
7248        .unwrap_or_else(|| serde_json::json!({}));
7249
7250    // Read existing endpoints array and add the LAN one. Preserve
7251    // federation / local entries already there.
7252    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7253        .get("self")
7254        .and_then(|s| s.get("endpoints"))
7255        .and_then(|e| e.as_array())
7256        .map(|arr| {
7257            arr.iter()
7258                .filter_map(|v| {
7259                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7260                })
7261                .collect()
7262        })
7263        .unwrap_or_default();
7264    endpoints.push(crate::endpoints::Endpoint::lan(
7265        lan_relay.trim_end_matches('/').to_string(),
7266        alloc.slot_id.clone(),
7267        alloc.slot_token.clone(),
7268    ));
7269
7270    let self_obj = state
7271        .as_object_mut()
7272        .expect("relay_state root is an object")
7273        .entry("self")
7274        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7275    if !self_obj.is_object() {
7276        *self_obj = serde_json::Value::Object(serde_json::Map::new());
7277    }
7278    if let Some(obj) = self_obj.as_object_mut() {
7279        obj.insert(
7280            "endpoints".into(),
7281            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7282        );
7283    }
7284    if let Err(e) = std::fs::write(
7285        &state_path,
7286        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7287    ) {
7288        eprintln!("wire session new: failed to write {state_path:?}: {e}");
7289        return;
7290    }
7291    eprintln!(
7292        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
7293        alloc.slot_id
7294    );
7295}
7296
7297/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
7298/// a short timeout, allocate a slot there and update the session's
7299/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
7300///
7301/// Failure to reach the local relay is NOT fatal — the session stays
7302/// federation-only. Logs to stderr on failure so operators can tell
7303/// the local relay isn't running, but doesn't abort the bootstrap.
7304fn try_allocate_local_slot(
7305    session_home: &std::path::Path,
7306    handle: &str,
7307    _federation_relay: &str,
7308    local_relay: &str,
7309) {
7310    // Probe healthz with a tight timeout. Use a fresh client (don't
7311    // share the daemon-wide one) so the timeout is local to this call.
7312    let probe = match crate::relay_client::build_blocking_client(Some(
7313        std::time::Duration::from_millis(500),
7314    )) {
7315        Ok(c) => c,
7316        Err(e) => {
7317            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
7318            return;
7319        }
7320    };
7321    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
7322    match probe.get(&healthz_url).send() {
7323        Ok(resp) if resp.status().is_success() => {}
7324        Ok(resp) => {
7325            eprintln!(
7326                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
7327                resp.status()
7328            );
7329            return;
7330        }
7331        Err(e) => {
7332            eprintln!(
7333                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
7334                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
7335                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
7336            );
7337            return;
7338        }
7339    };
7340
7341    // Allocate a slot on the local relay.
7342    let local_client = crate::relay_client::RelayClient::new(local_relay);
7343    let alloc = match local_client.allocate_slot(Some(handle)) {
7344        Ok(a) => a,
7345        Err(e) => {
7346            eprintln!(
7347                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
7348            );
7349            return;
7350        }
7351    };
7352
7353    // Merge into the session's relay.json. We invoke wire via
7354    // run_wire_with_home for federation calls (subprocess isolation),
7355    // but relay.json is a simple file we can edit directly
7356    // — and need to, because there's no `wire bind-relay --add-local`
7357    // command yet (could add later; out of scope for v0.5.17 MVP).
7358    //
7359    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
7360    // does not exist (canonical filename is `relay.json` per
7361    // `config::relay_state_path`). The mis-named file write succeeded
7362    // but landed in a sibling path nothing else reads. Every
7363    // `wire session new --with-local` invocation silently degraded to
7364    // federation-only despite the "local slot allocated" stderr line.
7365    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
7366    // session's relay.json — it had only the federation endpoint.
7367    let state_path = session_home.join("config").join("wire").join("relay.json");
7368    let mut state: serde_json::Value = std::fs::read(&state_path)
7369        .ok()
7370        .and_then(|b| serde_json::from_slice(&b).ok())
7371        .unwrap_or_else(|| serde_json::json!({}));
7372    // Read the existing federation self info (already written by
7373    // `wire init` + `wire bind-relay` path during session bootstrap).
7374    let fed_endpoint = state.get("self").and_then(|s| {
7375        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
7376        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
7377        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
7378        Some(crate::endpoints::Endpoint::federation(
7379            url.to_string(),
7380            slot_id.to_string(),
7381            slot_token.to_string(),
7382        ))
7383    });
7384
7385    let local_endpoint = crate::endpoints::Endpoint::local(
7386        local_relay.trim_end_matches('/').to_string(),
7387        alloc.slot_id.clone(),
7388        alloc.slot_token.clone(),
7389    );
7390
7391    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
7392    if let Some(f) = fed_endpoint.clone() {
7393        endpoints.push(f);
7394    }
7395    endpoints.push(local_endpoint);
7396
7397    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
7398    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
7399    // `slot_token` fields must point at the LOCAL endpoint so callers
7400    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
7401    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
7402    // still find a valid slot. Pre-v0.6.6 this branch wrote
7403    // `relay_url: federation_relay` with no slot_id, which produced
7404    // half-populated self state that broke pair-accept on local-only
7405    // sessions.
7406    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
7407        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
7408        None => (
7409            local_relay.trim_end_matches('/').to_string(),
7410            alloc.slot_id.clone(),
7411            alloc.slot_token.clone(),
7412        ),
7413    };
7414    let self_obj = state
7415        .as_object_mut()
7416        .expect("relay_state root is an object")
7417        .entry("self")
7418        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7419    // The entry might be Value::Null (left by read_relay_state's default
7420    // template) — replace with an object before mutating.
7421    if !self_obj.is_object() {
7422        *self_obj = serde_json::Value::Object(serde_json::Map::new());
7423    }
7424    if let Some(obj) = self_obj.as_object_mut() {
7425        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
7426        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
7427        obj.insert(
7428            "slot_token".into(),
7429            serde_json::Value::String(legacy_slot_token),
7430        );
7431        obj.insert(
7432            "endpoints".into(),
7433            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7434        );
7435    }
7436
7437    if let Err(e) = std::fs::write(
7438        &state_path,
7439        serde_json::to_vec_pretty(&state).unwrap_or_default(),
7440    ) {
7441        eprintln!(
7442            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
7443        );
7444        return;
7445    }
7446    eprintln!(
7447        "wire session new: local slot allocated on {local_relay} (slot_id={})",
7448        alloc.slot_id
7449    );
7450}
7451
7452fn render_session_info(
7453    name: &str,
7454    session_home: &std::path::Path,
7455    cwd: &std::path::Path,
7456) -> Result<serde_json::Value> {
7457    let card_path = session_home
7458        .join("config")
7459        .join("wire")
7460        .join("agent-card.json");
7461    let (did, handle) = if card_path.exists() {
7462        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
7463        let did = card
7464            .get("did")
7465            .and_then(Value::as_str)
7466            .unwrap_or("")
7467            .to_string();
7468        let handle = card
7469            .get("handle")
7470            .and_then(Value::as_str)
7471            .map(str::to_string)
7472            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
7473        (did, handle)
7474    } else {
7475        (String::new(), String::new())
7476    };
7477    Ok(json!({
7478        "name": name,
7479        "home_dir": session_home.to_string_lossy(),
7480        "cwd": cwd.to_string_lossy(),
7481        "did": did,
7482        "handle": handle,
7483        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
7484    }))
7485}
7486
7487fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
7488    if as_json {
7489        let mut obj = info.clone();
7490        obj["status"] = json!(status);
7491        println!("{}", serde_json::to_string(&obj)?);
7492    } else {
7493        let name = info["name"].as_str().unwrap_or("?");
7494        let handle = info["handle"].as_str().unwrap_or("?");
7495        let home = info["home_dir"].as_str().unwrap_or("?");
7496        let did = info["did"].as_str().unwrap_or("?");
7497        let export = info["export"].as_str().unwrap_or("?");
7498        let prefix = if status == "already_exists" {
7499            "session already exists (re-registered cwd)"
7500        } else {
7501            "session created"
7502        };
7503        println!(
7504            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
7505        );
7506    }
7507    Ok(())
7508}
7509
7510fn run_wire_with_home(
7511    session_home: &std::path::Path,
7512    args: &[&str],
7513) -> Result<std::process::ExitStatus> {
7514    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
7515    let status = std::process::Command::new(&bin)
7516        .env("WIRE_HOME", session_home)
7517        .env_remove("RUST_LOG")
7518        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
7519        // We already own the session; nested init would clobber state.
7520        .env("WIRE_AUTO_INIT", "0")
7521        .args(args)
7522        .status()
7523        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
7524    Ok(status)
7525}
7526
7527/// v0.7.0-alpha.2: idempotent per-cwd session creation.
7528///
7529/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
7530/// registered session for the current cwd — including via parent-walk —
7531/// this creates one inline so every Claude tab in a fresh project gets
7532/// its own wire identity rather than collapsing onto the machine-wide
7533/// default. Without this, multiple Claudes in unwired cwds all render
7534/// the same character (the default identity's character), defeating the
7535/// "every session looks different" promise.
7536///
7537/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
7538/// `run_wire_with_home` subprocess context).
7539///
7540/// Best-effort: any failure (no home dir, name collision pathology,
7541/// `wire init` subprocess crash) is logged to stderr and we fall back
7542/// to default identity. Must not block MCP startup.
7543///
7544/// MUST be called BEFORE worker thread spawn (env::set_var safety).
7545pub fn maybe_auto_init_cwd_session(label: &str) {
7546    if std::env::var("WIRE_HOME").is_ok() {
7547        return; // explicit override OR auto-detect already won
7548    }
7549    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
7550        return; // operator opt-out
7551    }
7552    let cwd = match std::env::current_dir() {
7553        Ok(c) => c,
7554        Err(_) => return,
7555    };
7556    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
7557    // already runs but we want to be robust to ordering).
7558    if crate::session::detect_session_wire_home(&cwd).is_some() {
7559        return;
7560    }
7561
7562    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
7563    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
7564    // Two different cwds with the same basename (e.g. /a/projx +
7565    // /b/projx) used to race outside the lock: both read empty
7566    // registry, both derived name="projx", per-name lock didn't help
7567    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
7568    //
7569    // Single lock serializes ALL auto-init across the sessions_root.
7570    // Inside the lock: re-read registry, derive_name_from_cwd which
7571    // adds path-hash suffix when basename is occupied by another cwd
7572    // already committed to the registry. Different cwds get DIFFERENT
7573    // names guaranteed.
7574    //
7575    // Cost: parallel auto-inits in different cwds now serialize
7576    // (~hundreds of ms each when local relay is up). Acceptable —
7577    // auto-init runs once per cwd per machine; not a hot path.
7578    use fs2::FileExt;
7579    let sessions_root = match crate::session::sessions_root() {
7580        Ok(r) => r,
7581        Err(_) => return,
7582    };
7583    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
7584        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
7585        return;
7586    }
7587    let lock_path = sessions_root.join(".auto-init.lock");
7588    let lock_file = match std::fs::OpenOptions::new()
7589        .create(true)
7590        .truncate(false)
7591        .read(true)
7592        .write(true)
7593        .open(&lock_path)
7594    {
7595        Ok(f) => f,
7596        Err(e) => {
7597            eprintln!(
7598                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
7599            );
7600            return;
7601        }
7602    };
7603    if let Err(e) = lock_file.lock_exclusive() {
7604        eprintln!(
7605            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
7606        );
7607        return;
7608    }
7609    // Lock acquired. Read registry + derive name now that all parallel
7610    // racers serialize through us — derive_name_from_cwd adds a
7611    // path-hash suffix if the basename is already claimed by another
7612    // cwd in the (now-stable) registry.
7613    let registry = crate::session::read_registry().unwrap_or_default();
7614    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
7615    let session_home = match crate::session::session_dir(&name) {
7616        Ok(h) => h,
7617        Err(_) => {
7618            let _ = fs2::FileExt::unlock(&lock_file);
7619            return;
7620        }
7621    };
7622    let agent_card_path = session_home
7623        .join("config")
7624        .join("wire")
7625        .join("agent-card.json");
7626    let needs_init = !agent_card_path.exists();
7627
7628    if needs_init {
7629        if let Err(e) = std::fs::create_dir_all(&session_home) {
7630            eprintln!(
7631                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
7632            );
7633            let _ = fs2::FileExt::unlock(&lock_file);
7634            return;
7635        }
7636        match run_wire_with_home(&session_home, &["init", &name]) {
7637            Ok(status) if status.success() => {}
7638            Ok(status) => {
7639                eprintln!(
7640                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
7641                );
7642                let _ = fs2::FileExt::unlock(&lock_file);
7643                return;
7644            }
7645            Err(e) => {
7646                eprintln!(
7647                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
7648                );
7649                let _ = fs2::FileExt::unlock(&lock_file);
7650                return;
7651            }
7652        }
7653        // Best-effort: allocate a local-relay slot so this auto-init'd
7654        // session is addressable by sister sessions. Skipped silently when
7655        // the local relay isn't running (the function itself reports to
7656        // stderr). Auto-init'd sessions without endpoints can still
7657        // surface their character but cannot receive pair_drops until the
7658        // operator runs `wire bind-relay` or restarts the local relay.
7659        try_allocate_local_slot(
7660            &session_home,
7661            &name,
7662            "https://wireup.net",
7663            "http://127.0.0.1:8771",
7664        );
7665    } else {
7666        // Race loser path: peer already created the session. Surface
7667        // this honestly so the operator can see we adopted rather than
7668        // double-initialized.
7669        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
7670            eprintln!(
7671                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
7672            );
7673        }
7674    }
7675    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
7676    // BEFORE releasing the auto-init lock. Pre-fix released the lock
7677    // here and committed the registry update afterward — racers in
7678    // OTHER cwds with the same basename would acquire the lock,
7679    // read the registry (still without our entry), and derive the
7680    // SAME name we just claimed. Live regression test caught it:
7681    // two cwds /a/projx + /b/projx both got name "projx", both
7682    // mapped to the same identity. Update the registry WHILE STILL
7683    // holding the auto-init lock so the next racer sees our claim.
7684    let cwd_key = cwd.to_string_lossy().into_owned();
7685    let name_for_reg = name.clone();
7686    if let Err(e) = crate::session::update_registry(|reg| {
7687        reg.by_cwd.insert(cwd_key, name_for_reg);
7688        Ok(())
7689    }) {
7690        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
7691        // proceed — env var still gets set below
7692    }
7693    // NOW release the lock — racers waiting will see our registry
7694    // entry on their re-read.
7695    let _ = fs2::FileExt::unlock(&lock_file);
7696
7697    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
7698        eprintln!(
7699            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
7700            cwd.display(),
7701            session_home.display()
7702        );
7703    }
7704    // SAFETY: caller contract is "before any thread spawn." MCP::run
7705    // calls this immediately after `maybe_adopt_session_wire_home`.
7706    unsafe {
7707        std::env::set_var("WIRE_HOME", &session_home);
7708    }
7709}
7710
7711fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
7712    // Check if a daemon is already alive in this session's WIRE_HOME.
7713    // If so, no-op (let the existing process keep running).
7714    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
7715    if pidfile.exists() {
7716        let bytes = std::fs::read(&pidfile).unwrap_or_default();
7717        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
7718            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
7719        } else {
7720            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
7721        };
7722        if let Some(p) = pid {
7723            let alive = {
7724                #[cfg(target_os = "linux")]
7725                {
7726                    std::path::Path::new(&format!("/proc/{p}")).exists()
7727                }
7728                #[cfg(not(target_os = "linux"))]
7729                {
7730                    std::process::Command::new("kill")
7731                        .args(["-0", &p.to_string()])
7732                        .output()
7733                        .map(|o| o.status.success())
7734                        .unwrap_or(false)
7735                }
7736            };
7737            if alive {
7738                return Ok(());
7739            }
7740        }
7741    }
7742
7743    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
7744    // versioned pidfile; we just kick it off and return.
7745    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
7746    let log_path = session_home.join("state").join("wire").join("daemon.log");
7747    if let Some(parent) = log_path.parent() {
7748        std::fs::create_dir_all(parent).ok();
7749    }
7750    let log_file = std::fs::OpenOptions::new()
7751        .create(true)
7752        .append(true)
7753        .open(&log_path)
7754        .with_context(|| format!("opening daemon log {log_path:?}"))?;
7755    let log_err = log_file.try_clone()?;
7756    std::process::Command::new(&bin)
7757        .env("WIRE_HOME", session_home)
7758        .env_remove("RUST_LOG")
7759        .args(["daemon", "--interval", "5"])
7760        .stdout(log_file)
7761        .stderr(log_err)
7762        .stdin(std::process::Stdio::null())
7763        .spawn()
7764        .with_context(|| "spawning session-local `wire daemon`")?;
7765    Ok(())
7766}
7767
7768fn cmd_session_list(as_json: bool) -> Result<()> {
7769    let items = crate::session::list_sessions()?;
7770    if as_json {
7771        println!("{}", serde_json::to_string(&items)?);
7772        return Ok(());
7773    }
7774    if items.is_empty() {
7775        println!("no sessions on this machine. `wire session new` to create one.");
7776        return Ok(());
7777    }
7778    println!(
7779        "{:<22} {:<24} {:<24} {:<10} CWD",
7780        "CHARACTER", "NAME", "HANDLE", "DAEMON"
7781    );
7782    for s in items {
7783        // ANSI-escape-wrapped character takes more visual width than its
7784        // displayed glyph count; pad based on the plain-text form, then
7785        // wrap in escapes so the column lines up across rows.
7786        let plain = s
7787            .character
7788            .as_ref()
7789            .map(|c| c.short())
7790            .unwrap_or_else(|| "?".to_string());
7791        let colored = s
7792            .character
7793            .as_ref()
7794            .map(|c| c.colored())
7795            .unwrap_or_else(|| "?".to_string());
7796        // Approximate display width: emoji renders as ~2 cells in most
7797        // terminals; the rest are 1 cell each. We pad to 18 displayed
7798        // chars (≈22 byte slots when counting emoji).
7799        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
7800        let pad = 22usize.saturating_sub(displayed_width);
7801        println!(
7802            "{}{}  {:<24} {:<24} {:<10} {}",
7803            colored,
7804            " ".repeat(pad),
7805            s.name,
7806            s.handle.as_deref().unwrap_or("?"),
7807            if s.daemon_running { "running" } else { "down" },
7808            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7809        );
7810    }
7811    Ok(())
7812}
7813
7814/// v0.5.19: `wire session list-local` — sister-session discovery.
7815///
7816/// For each on-disk session, read its `relay-state.json` and surface
7817/// the ones that have a Local-scope endpoint (allocated via
7818/// `wire session new --with-local`). Group by the local-relay URL so
7819/// the operator can see at a glance which sessions are mutually
7820/// reachable over the same loopback relay.
7821///
7822/// Read-only, no daemon contact. Useful as the prelude to teaming /
7823/// pairing same-box sister claudes (see also `wire session
7824/// pair-all-local` once implemented).
7825fn cmd_session_list_local(as_json: bool) -> Result<()> {
7826    let listing = crate::session::list_local_sessions()?;
7827    if as_json {
7828        println!("{}", serde_json::to_string(&listing)?);
7829        return Ok(());
7830    }
7831
7832    if listing.local.is_empty() && listing.federation_only.is_empty() {
7833        println!(
7834            "no sessions on this machine. `wire session new --with-local` to create one \
7835             with a local-relay endpoint (start the relay first: \
7836             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
7837        );
7838        return Ok(());
7839    }
7840
7841    if listing.local.is_empty() {
7842        println!(
7843            "no sister sessions reachable via a local relay. \
7844             Re-run `wire session new --with-local` to add a Local endpoint, or \
7845             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
7846        );
7847    } else {
7848        // Stable iteration order: sort the relay URLs.
7849        let mut keys: Vec<&String> = listing.local.keys().collect();
7850        keys.sort();
7851        for relay_url in keys {
7852            let group = &listing.local[relay_url];
7853            println!("LOCAL RELAY: {relay_url}");
7854            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
7855            for s in group {
7856                println!(
7857                    "  {:<24} {:<32} {:<10} {}",
7858                    s.name,
7859                    s.handle.as_deref().unwrap_or("?"),
7860                    if s.daemon_running { "running" } else { "down" },
7861                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7862                );
7863            }
7864            println!();
7865        }
7866    }
7867
7868    if !listing.federation_only.is_empty() {
7869        println!("federation-only (no local endpoint):");
7870        for s in &listing.federation_only {
7871            println!(
7872                "  {:<24} {:<32} {}",
7873                s.name,
7874                s.handle.as_deref().unwrap_or("?"),
7875                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7876            );
7877        }
7878    }
7879    Ok(())
7880}
7881
7882/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
7883/// session that has a Local-scope endpoint. Skips already-paired
7884/// pairs; reports a per-pair outcome JSON suitable for scripting.
7885///
7886/// Same-uid trust anchor: the caller owns every session enumerated by
7887/// `list_local_sessions`, so the operator running this command IS the
7888/// consent for both sides. The bilateral SAS / network-level handshake
7889/// assumes strangers; same-uid sister sessions are not strangers.
7890///
7891/// Per-pair flow (sequential to keep relay-side load + log clarity):
7892///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
7893///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
7894///   3. sleep settle_secs                       (pair_drop reaches B)
7895///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
7896///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
7897///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
7898///   7. sleep settle_secs                       (ack reaches A)
7899///   8. WIRE_HOME=A wire pull --json            (A pins B)
7900fn cmd_session_pair_all_local(
7901    settle_secs: u64,
7902    federation_relay: &str,
7903    as_json: bool,
7904) -> Result<()> {
7905    use std::collections::BTreeSet;
7906    use std::time::Duration;
7907
7908    let listing = crate::session::list_local_sessions()?;
7909    // Flatten + dedup by session NAME (same session can appear under
7910    // multiple local-relay URLs if it advertises two local endpoints;
7911    // rare, but pair each pair exactly once).
7912    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
7913        Default::default();
7914    for group in listing.local.into_values() {
7915        for s in group {
7916            by_name.entry(s.name.clone()).or_insert(s);
7917        }
7918    }
7919    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
7920
7921    if sessions.len() < 2 {
7922        let msg = format!(
7923            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
7924            sessions.len()
7925        );
7926        if as_json {
7927            println!(
7928                "{}",
7929                serde_json::to_string(&json!({
7930                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
7931                    "pairs_attempted": 0,
7932                    "pairs_succeeded": 0,
7933                    "pairs_skipped_already_paired": 0,
7934                    "pairs_failed": 0,
7935                    "note": msg,
7936                }))?
7937            );
7938        } else {
7939            println!("{msg}");
7940            if let Some(s) = sessions.first() {
7941                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
7942            }
7943            println!("Use `wire session new --with-local` to add more.");
7944        }
7945        return Ok(());
7946    }
7947
7948    let fed_host = host_of_url(federation_relay);
7949    if fed_host.is_empty() {
7950        bail!(
7951            "federation_relay `{federation_relay}` has no parseable host — \
7952             pass a full URL like `https://wireup.net`."
7953        );
7954    }
7955
7956    // Enumerate unordered pairs deterministically by session name.
7957    let mut attempted = 0u32;
7958    let mut succeeded = 0u32;
7959    let mut skipped_already = 0u32;
7960    let mut failed = 0u32;
7961    let mut per_pair: Vec<Value> = Vec::new();
7962
7963    for i in 0..sessions.len() {
7964        for j in (i + 1)..sessions.len() {
7965            let a = &sessions[i];
7966            let b = &sessions[j];
7967            attempted += 1;
7968
7969            // Already-paired check: if A's relay-state has B's nick in
7970            // peers AND vice versa, skip.
7971            let a_pinned_b = session_has_peer(&a.home_dir, &b.name);
7972            let b_pinned_a = session_has_peer(&b.home_dir, &a.name);
7973            if a_pinned_b && b_pinned_a {
7974                skipped_already += 1;
7975                per_pair.push(json!({
7976                    "from": a.name,
7977                    "to": b.name,
7978                    "status": "already_paired",
7979                }));
7980                continue;
7981            }
7982
7983            let pair_result = drive_bilateral_pair(
7984                &a.home_dir,
7985                &a.name,
7986                &b.home_dir,
7987                &b.name,
7988                &fed_host,
7989                federation_relay,
7990                settle_secs,
7991            );
7992
7993            match pair_result {
7994                Ok(()) => {
7995                    succeeded += 1;
7996                    per_pair.push(json!({
7997                        "from": a.name,
7998                        "to": b.name,
7999                        "status": "paired",
8000                    }));
8001                }
8002                Err(e) => {
8003                    failed += 1;
8004                    let detail = format!("{e:#}");
8005                    per_pair.push(json!({
8006                        "from": a.name,
8007                        "to": b.name,
8008                        "status": "failed",
8009                        "error": detail,
8010                    }));
8011                }
8012            }
8013
8014            // Brief settle between pairs so we don't slam the relay
8015            // with N(N-1) parallel requests.
8016            std::thread::sleep(Duration::from_millis(200));
8017        }
8018    }
8019
8020    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
8021    let summary = json!({
8022        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
8023        "pairs_attempted": attempted,
8024        "pairs_succeeded": succeeded,
8025        "pairs_skipped_already_paired": skipped_already,
8026        "pairs_failed": failed,
8027        "results": per_pair,
8028    });
8029    if as_json {
8030        println!("{}", serde_json::to_string(&summary)?);
8031    } else {
8032        println!(
8033            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
8034            sessions.len(),
8035            attempted
8036        );
8037        println!("  paired:                 {succeeded}");
8038        println!("  skipped (already pinned): {skipped_already}");
8039        println!("  failed:                 {failed}");
8040        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
8041            let from = entry["from"].as_str().unwrap_or("?");
8042            let to = entry["to"].as_str().unwrap_or("?");
8043            let status = entry["status"].as_str().unwrap_or("?");
8044            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
8045            if err.is_empty() {
8046                println!("  {from:<24} ↔ {to:<24} {status}");
8047            } else {
8048                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
8049            }
8050        }
8051    }
8052    Ok(())
8053}
8054
8055/// Check whether `session_home`'s `relay.json` already lists `peer_name`
8056/// under `state.peers`. Best-effort — any read/parse error → false.
8057fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
8058    val_session_relay_state(session_home)
8059        .and_then(|v| v.get("peers").cloned())
8060        .and_then(|p| p.get(peer_name).cloned())
8061        .is_some()
8062}
8063
8064/// Read a session's `relay.json` directly without mutating the process'
8065/// WIRE_HOME env (which would race other threads / processes). Returns
8066/// `None` on any read or parse error — callers treat missing state as
8067/// "no peers / no endpoints" rather than aborting.
8068fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
8069    let path = session_home.join("config").join("wire").join("relay.json");
8070    let bytes = std::fs::read(&path).ok()?;
8071    serde_json::from_slice(&bytes).ok()
8072}
8073
8074/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
8075/// One probe per directed edge against the relay backing that edge's
8076/// priority-1 endpoint; output groups by undirected pair.
8077fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
8078    use std::collections::BTreeMap;
8079
8080    // Flatten by session NAME — same dedup logic as pair-all-local so a
8081    // session advertising two local endpoints doesn't get double-counted.
8082    let listing = crate::session::list_local_sessions()?;
8083    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
8084    for group in listing.local.into_values() {
8085        for s in group {
8086            by_name.entry(s.name.clone()).or_insert(s);
8087        }
8088    }
8089    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8090    let federation_only = listing.federation_only;
8091
8092    if sessions.is_empty() {
8093        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
8094        if as_json {
8095            println!(
8096                "{}",
8097                serde_json::to_string(&json!({
8098                    "sessions": [],
8099                    "edges": [],
8100                    "local_relay": null,
8101                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8102                    "summary": {
8103                        "session_count": 0,
8104                        "edge_count": 0,
8105                        "healthy": 0,
8106                        "stale": 0,
8107                        "asymmetric": 0,
8108                    },
8109                    "note": msg,
8110                }))?
8111            );
8112        } else {
8113            println!("{msg}");
8114            println!("Use `wire session new --with-local` to create one.");
8115        }
8116        return Ok(());
8117    }
8118
8119    // Build a name → session-state map: relay_state + reachable handle set.
8120    struct SessionState {
8121        view: crate::session::LocalSessionView,
8122        relay_state: Value,
8123        local_relay_url: Option<String>,
8124    }
8125    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
8126    for s in sessions {
8127        let relay_state = val_session_relay_state(&s.home_dir)
8128            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8129        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
8130        sstates.push(SessionState {
8131            view: s,
8132            relay_state,
8133            local_relay_url,
8134        });
8135    }
8136
8137    // Probe each unique local-relay URL once for healthz so the operator
8138    // sees one liveness line per local relay, not one per edge.
8139    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
8140    for s in &sstates {
8141        if let Some(url) = &s.local_relay_url
8142            && !local_relays.contains_key(url)
8143        {
8144            let healthy = probe_relay_healthz(url);
8145            local_relays.insert(url.clone(), healthy);
8146        }
8147    }
8148
8149    let now = std::time::SystemTime::now()
8150        .duration_since(std::time::UNIX_EPOCH)
8151        .map(|d| d.as_secs())
8152        .unwrap_or(0);
8153
8154    // Edges: walk every unordered pair, surface bilateral state + each
8155    // direction's last_pull. Probe priority-1 endpoint (local preferred
8156    // by `peer_endpoints_in_priority_order`).
8157    let mut edges: Vec<Value> = Vec::new();
8158    let mut healthy_count = 0u32;
8159    let mut stale_count = 0u32;
8160    let mut asymmetric_count = 0u32;
8161
8162    for i in 0..sstates.len() {
8163        for j in (i + 1)..sstates.len() {
8164            let a = &sstates[i];
8165            let b = &sstates[j];
8166            let a_to_b = probe_directed_edge(&a.relay_state, &b.view.name, now);
8167            let b_to_a = probe_directed_edge(&b.relay_state, &a.view.name, now);
8168
8169            let bilateral = a_to_b.pinned && b_to_a.pinned;
8170            // Scope = the most-local scope available in either direction.
8171            // (If a→b is local and b→a is federation, the asymmetric
8172            // detail surfaces below; the headline scope is the better.)
8173            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
8174                (Some("local"), _) | (_, Some("local")) => "local",
8175                (Some("federation"), _) | (_, Some("federation")) => "federation",
8176                _ => "unknown",
8177            };
8178
8179            // Health: stale if either direction's last_pull is older than
8180            // `stale_secs`, or never observed when both sides are pinned.
8181            let mut status = if bilateral { "healthy" } else { "asymmetric" };
8182            if bilateral {
8183                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
8184                    Some(s) => s > stale_secs,
8185                    None => d.probed,
8186                });
8187                if either_stale {
8188                    status = "stale";
8189                }
8190            }
8191
8192            match status {
8193                "healthy" => healthy_count += 1,
8194                "stale" => stale_count += 1,
8195                "asymmetric" => asymmetric_count += 1,
8196                _ => {}
8197            }
8198
8199            edges.push(json!({
8200                "from": a.view.name,
8201                "to": b.view.name,
8202                "bilateral": bilateral,
8203                "scope": scope,
8204                "status": status,
8205                "directions": {
8206                    a.view.name.clone(): direction_summary(&a_to_b),
8207                    b.view.name.clone(): direction_summary(&b_to_a),
8208                },
8209            }));
8210        }
8211    }
8212
8213    let summary = json!({
8214        "sessions": sstates.iter().map(|s| json!({
8215            "name": s.view.name,
8216            "handle": s.view.handle,
8217            "cwd": s.view.cwd,
8218            "daemon_running": s.view.daemon_running,
8219            "local_relay": s.local_relay_url,
8220        })).collect::<Vec<_>>(),
8221        "edges": edges,
8222        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
8223            "url": url,
8224            "healthy": healthy,
8225        })).collect::<Vec<_>>(),
8226        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8227        "summary": {
8228            "session_count": sstates.len(),
8229            "edge_count": edges.len(),
8230            "healthy": healthy_count,
8231            "stale": stale_count,
8232            "asymmetric": asymmetric_count,
8233            "stale_threshold_secs": stale_secs,
8234        },
8235    });
8236
8237    if as_json {
8238        println!("{}", serde_json::to_string(&summary)?);
8239        return Ok(());
8240    }
8241
8242    println!(
8243        "wire mesh: {} session(s), {} edge(s)",
8244        sstates.len(),
8245        edges.len()
8246    );
8247    for (url, healthy) in &local_relays {
8248        let tick = if *healthy { "✓" } else { "✗" };
8249        println!("  local-relay {url} {tick}");
8250    }
8251    if !federation_only.is_empty() {
8252        print!("  federation-only sessions:");
8253        for f in &federation_only {
8254            print!(" {}", f.name);
8255        }
8256        println!();
8257    }
8258
8259    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
8260    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
8261    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
8262    print!("\n{:>col_w$}", "", col_w = col_w);
8263    for n in &names {
8264        print!("{:>col_w$}", n, col_w = col_w);
8265    }
8266    println!();
8267    for (i, row) in names.iter().enumerate() {
8268        print!("{:>col_w$}", row, col_w = col_w);
8269        for (j, col) in names.iter().enumerate() {
8270            let cell = if i == j {
8271                "self".to_string()
8272            } else {
8273                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
8274                match d.scope.as_deref() {
8275                    Some("local") => "local".to_string(),
8276                    Some("federation") => "fed".to_string(),
8277                    _ => "—".to_string(),
8278                }
8279            };
8280            print!("{:>col_w$}", cell, col_w = col_w);
8281        }
8282        println!();
8283    }
8284
8285    println!("\nHealth (stale threshold: {stale_secs}s):");
8286    for e in &edges {
8287        let from = e["from"].as_str().unwrap_or("?");
8288        let to = e["to"].as_str().unwrap_or("?");
8289        let scope = e["scope"].as_str().unwrap_or("?");
8290        let status = e["status"].as_str().unwrap_or("?");
8291        let mark = match status {
8292            "healthy" => "✓",
8293            "stale" => "⚠",
8294            "asymmetric" => "!",
8295            _ => "?",
8296        };
8297        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
8298        let mut details: Vec<String> = Vec::new();
8299        for (who, d) in &dirs {
8300            let silent = d.get("silent_secs").and_then(Value::as_u64);
8301            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
8302            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
8303            let label = match (pinned, probed, silent) {
8304                (false, _, _) => format!("{who} has not pinned"),
8305                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
8306                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
8307                (true, true, Some(s)) => format!("{who} silent {s}s"),
8308                (true, true, None) => format!("{who} never pulled"),
8309            };
8310            details.push(label);
8311        }
8312        println!(
8313            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
8314            details.join(" | ")
8315        );
8316    }
8317    Ok(())
8318}
8319
8320#[derive(Default)]
8321struct DirectedEdge {
8322    pinned: bool,
8323    scope: Option<String>,
8324    last_pull_at_unix: Option<u64>,
8325    silent_secs: Option<u64>,
8326    probed: bool,
8327    event_count: usize,
8328}
8329
8330/// Probe a single directed edge from `from_state`'s view of `to_name`.
8331/// Picks the priority-1 endpoint (local preferred when reachable) and
8332/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
8333/// failure (the function records `probed = true`, `last_pull = None`,
8334/// which the caller treats as "never pulled, route exists" = stale).
8335fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
8336    let pinned = from_state
8337        .get("peers")
8338        .and_then(|p| p.get(to_name))
8339        .is_some();
8340    if !pinned {
8341        return DirectedEdge::default();
8342    }
8343    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
8344    let ep = match endpoints.into_iter().next() {
8345        Some(e) => e,
8346        None => {
8347            return DirectedEdge {
8348                pinned: true,
8349                ..Default::default()
8350            };
8351        }
8352    };
8353    let scope = Some(
8354        match ep.scope {
8355            crate::endpoints::EndpointScope::Local => "local",
8356            crate::endpoints::EndpointScope::Lan => "lan",
8357            crate::endpoints::EndpointScope::Uds => "uds",
8358            crate::endpoints::EndpointScope::Federation => "federation",
8359        }
8360        .to_string(),
8361    );
8362    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
8363    let (count, last) = client
8364        .slot_state(&ep.slot_id, &ep.slot_token)
8365        .unwrap_or((0, None));
8366    let silent = last.map(|t| now.saturating_sub(t));
8367    DirectedEdge {
8368        pinned: true,
8369        scope,
8370        last_pull_at_unix: last,
8371        silent_secs: silent,
8372        probed: true,
8373        event_count: count,
8374    }
8375}
8376
8377fn direction_summary(d: &DirectedEdge) -> Value {
8378    json!({
8379        "pinned": d.pinned,
8380        "scope": d.scope,
8381        "probed": d.probed,
8382        "last_pull_at_unix": d.last_pull_at_unix,
8383        "silent_secs": d.silent_secs,
8384        "event_count": d.event_count,
8385    })
8386}
8387
8388/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
8389fn probe_relay_healthz(url: &str) -> bool {
8390    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
8391    let client = match reqwest::blocking::Client::builder()
8392        .timeout(std::time::Duration::from_millis(500))
8393        .build()
8394    {
8395        Ok(c) => c,
8396        Err(_) => return false,
8397    };
8398    match client.get(&probe_url).send() {
8399        Ok(r) => r.status().is_success(),
8400        Err(_) => false,
8401    }
8402}
8403
8404/// Drive one bilateral pair handshake between two sister sessions
8405/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
8406/// flow so failures bubble up at the offending step, not buried in
8407/// a parallel race. See `cmd_session_pair_all_local` docstring.
8408///
8409/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
8410/// federation `.well-known/wire/agent` resolution. Reads B's card +
8411/// endpoints directly off disk under `b_home` and pins them. This
8412/// makes pair-all-local work for sister sessions whose federation
8413/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
8414/// for sessions created with `wire session new --local-only`
8415/// (no federation slot at all). The `_federation_relay` / `_fed_host`
8416/// parameters are retained for callers that want to log them but
8417/// the handshake itself no longer touches federation.
8418fn drive_bilateral_pair(
8419    a_home: &std::path::Path,
8420    a_name: &str,
8421    b_home: &std::path::Path,
8422    b_name: &str,
8423    _fed_host: &str,
8424    _federation_relay: &str,
8425    settle_secs: u64,
8426) -> Result<()> {
8427    use std::time::Duration;
8428    let bin = std::env::current_exe().context("locating self exe")?;
8429
8430    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
8431        let out = std::process::Command::new(&bin)
8432            .env("WIRE_HOME", home)
8433            .env_remove("RUST_LOG")
8434            .args(args)
8435            .output()
8436            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8437        if !out.status.success() {
8438            bail!(
8439                "`wire {}` failed: stderr={}",
8440                args.join(" "),
8441                String::from_utf8_lossy(&out.stderr).trim()
8442            );
8443        }
8444        Ok(())
8445    };
8446
8447    // 1. A initiates via --local-sister (reads B's card + endpoints
8448    // from disk, pins, delivers pair_drop direct to B's local slot)
8449    // → 2. NO separate push needed — `wire add --local-sister` does
8450    // the slot POST inline. Keeping a no-op push so the step count
8451    // matches the old federation flow for log/error continuity.
8452    run(a_home, &["add", b_name, "--local-sister", "--json"])
8453        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
8454
8455    // 3. settle so pair_drop reaches B's slot
8456    std::thread::sleep(Duration::from_secs(settle_secs));
8457
8458    // 4. B pulls pair_drop → 5. B pair-accept (pins A) → 6. B push pair_drop_ack
8459    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
8460    run(b_home, &["pair-accept", a_name, "--json"])
8461        .with_context(|| format!("step 5/8: {b_name} `wire pair-accept {a_name}`"))?;
8462    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
8463
8464    // 7. settle so ack reaches A's slot
8465    std::thread::sleep(Duration::from_secs(settle_secs));
8466
8467    // 8. A pulls ack (pins B with the slot_token + endpoints[] from the ack)
8468    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
8469
8470    Ok(())
8471}
8472
8473fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
8474    let name = resolve_session_name(name_arg)?;
8475    let session_home = crate::session::session_dir(&name)?;
8476    if !session_home.exists() {
8477        bail!(
8478            "no session named {name:?} on this machine. `wire session list` to enumerate, \
8479             `wire session new {name}` to create."
8480        );
8481    }
8482    if as_json {
8483        println!(
8484            "{}",
8485            serde_json::to_string(&json!({
8486                "name": name,
8487                "home_dir": session_home.to_string_lossy(),
8488                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8489            }))?
8490        );
8491    } else {
8492        println!("export WIRE_HOME={}", session_home.to_string_lossy());
8493    }
8494    Ok(())
8495}
8496
8497fn cmd_session_current(as_json: bool) -> Result<()> {
8498    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8499    let registry = crate::session::read_registry().unwrap_or_default();
8500    let cwd_key = cwd.to_string_lossy().into_owned();
8501    let name = registry.by_cwd.get(&cwd_key).cloned();
8502    if as_json {
8503        println!(
8504            "{}",
8505            serde_json::to_string(&json!({
8506                "cwd": cwd_key,
8507                "session": name,
8508            }))?
8509        );
8510    } else if let Some(n) = name {
8511        println!("{n}");
8512    } else {
8513        println!("(no session registered for this cwd)");
8514    }
8515    Ok(())
8516}
8517
8518fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
8519    let name = crate::session::sanitize_name(name_arg);
8520    let session_home = crate::session::session_dir(&name)?;
8521    if !session_home.exists() {
8522        if as_json {
8523            println!(
8524                "{}",
8525                serde_json::to_string(&json!({
8526                    "name": name,
8527                    "destroyed": false,
8528                    "reason": "no such session",
8529                }))?
8530            );
8531        } else {
8532            println!("no session named {name:?} — nothing to destroy.");
8533        }
8534        return Ok(());
8535    }
8536    if !force {
8537        bail!(
8538            "destroying session {name:?} would delete its keypair + state irrecoverably. \
8539             Pass --force to confirm."
8540        );
8541    }
8542
8543    // Kill the session-local daemon if alive.
8544    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8545    if let Ok(bytes) = std::fs::read(&pidfile) {
8546        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8547            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8548        } else {
8549            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8550        };
8551        if let Some(p) = pid {
8552            let _ = std::process::Command::new("kill")
8553                .args(["-TERM", &p.to_string()])
8554                .output();
8555        }
8556    }
8557
8558    std::fs::remove_dir_all(&session_home)
8559        .with_context(|| format!("removing session dir {session_home:?}"))?;
8560
8561    // Strip from registry.
8562    let mut registry = crate::session::read_registry().unwrap_or_default();
8563    registry.by_cwd.retain(|_, v| v != &name);
8564    crate::session::write_registry(&registry)?;
8565
8566    if as_json {
8567        println!(
8568            "{}",
8569            serde_json::to_string(&json!({
8570                "name": name,
8571                "destroyed": true,
8572            }))?
8573        );
8574    } else {
8575        println!("destroyed session {name:?}.");
8576    }
8577    Ok(())
8578}
8579
8580// ---------- diag (structured trace) ----------
8581
8582fn cmd_diag(action: DiagAction) -> Result<()> {
8583    let state = config::state_dir()?;
8584    let knob = state.join("diag.enabled");
8585    let log_path = state.join("diag.jsonl");
8586    match action {
8587        DiagAction::Tail { limit, json } => {
8588            let entries = crate::diag::tail(limit);
8589            if json {
8590                for e in entries {
8591                    println!("{}", serde_json::to_string(&e)?);
8592                }
8593            } else if entries.is_empty() {
8594                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
8595            } else {
8596                for e in entries {
8597                    let ts = e["ts"].as_u64().unwrap_or(0);
8598                    let ty = e["type"].as_str().unwrap_or("?");
8599                    let pid = e["pid"].as_u64().unwrap_or(0);
8600                    let payload = e["payload"].to_string();
8601                    println!("[{ts}] pid={pid} {ty} {payload}");
8602                }
8603            }
8604        }
8605        DiagAction::Enable => {
8606            config::ensure_dirs()?;
8607            std::fs::write(&knob, "1")?;
8608            println!("wire diag: enabled at {knob:?}");
8609        }
8610        DiagAction::Disable => {
8611            if knob.exists() {
8612                std::fs::remove_file(&knob)?;
8613            }
8614            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
8615        }
8616        DiagAction::Status { json } => {
8617            let enabled = crate::diag::is_enabled();
8618            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
8619            if json {
8620                println!(
8621                    "{}",
8622                    serde_json::to_string(&serde_json::json!({
8623                        "enabled": enabled,
8624                        "log_path": log_path,
8625                        "log_size_bytes": size,
8626                    }))?
8627                );
8628            } else {
8629                println!("wire diag status");
8630                println!("  enabled:    {enabled}");
8631                println!("  log:        {log_path:?}");
8632                println!("  log size:   {size} bytes");
8633            }
8634        }
8635    }
8636    Ok(())
8637}
8638
8639// ---------- service (install / uninstall / status) ----------
8640
8641fn cmd_service(action: ServiceAction) -> Result<()> {
8642    let kind = |local_relay: bool| {
8643        if local_relay {
8644            crate::service::ServiceKind::LocalRelay
8645        } else {
8646            crate::service::ServiceKind::Daemon
8647        }
8648    };
8649    let (report, as_json) = match action {
8650        ServiceAction::Install { local_relay, json } => {
8651            (crate::service::install_kind(kind(local_relay))?, json)
8652        }
8653        ServiceAction::Uninstall { local_relay, json } => {
8654            (crate::service::uninstall_kind(kind(local_relay))?, json)
8655        }
8656        ServiceAction::Status { local_relay, json } => {
8657            (crate::service::status_kind(kind(local_relay))?, json)
8658        }
8659    };
8660    if as_json {
8661        println!("{}", serde_json::to_string(&report)?);
8662    } else {
8663        println!("wire service {}", report.action);
8664        println!("  platform:  {}", report.platform);
8665        println!("  unit:      {}", report.unit_path);
8666        println!("  status:    {}", report.status);
8667        println!("  detail:    {}", report.detail);
8668    }
8669    Ok(())
8670}
8671
8672// ---------- upgrade (atomic daemon swap) ----------
8673
8674/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
8675/// fresh one from the currently-installed binary, write a new versioned
8676/// pidfile. The fix for today's exact failure mode: a daemon process that
8677/// kept running OLD binary text in memory under a symlink that had since
8678/// been repointed at a NEW binary on disk.
8679///
8680/// Idempotent. If no stale daemon is running, just starts a fresh one
8681/// (same as `wire daemon &` but with the wait-until-alive guard from
8682/// ensure_up::ensure_daemon_running).
8683///
8684/// `--check` mode reports drift without acting — lists the processes
8685/// that WOULD be killed and the binary version of each.
8686fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
8687    // 1. Identify all `wire daemon` processes.
8688    let pgrep_out = std::process::Command::new("pgrep")
8689        .args(["-f", "wire daemon"])
8690        .output();
8691    let running_pids: Vec<u32> = match pgrep_out {
8692        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
8693            .split_whitespace()
8694            .filter_map(|s| s.parse::<u32>().ok())
8695            .collect(),
8696        _ => Vec::new(),
8697    };
8698
8699    // 2. Read pidfile to surface what the daemon THINKS it is.
8700    let record = crate::ensure_up::read_pid_record("daemon");
8701    let recorded_version: Option<String> = match &record {
8702        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
8703        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
8704        _ => None,
8705    };
8706    let cli_version = env!("CARGO_PKG_VERSION").to_string();
8707
8708    // 2b. v0.6.9: snapshot which sessions HAD a running daemon BEFORE
8709    // we kill anything. Step 3's pgrep+SIGTERM also kills session-owned
8710    // daemons (they share the `wire daemon` command line), so by the
8711    // time the respawn loop runs, `daemon_running` would always be
8712    // false and zero sessions would respawn. Capture state up front
8713    // and respawn whatever was alive at the start.
8714    let sessions_to_respawn_after_kill: Vec<std::path::PathBuf> = crate::session::list_sessions()
8715        .unwrap_or_default()
8716        .into_iter()
8717        .filter(|s| s.daemon_running)
8718        .map(|s| s.home_dir)
8719        .collect();
8720
8721    if check_only {
8722        // v0.6.8: also surface session-level state + PATH dupes in --check.
8723        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
8724            .unwrap_or_default()
8725            .iter()
8726            .filter(|s| s.daemon_running)
8727            .map(|s| s.name.clone())
8728            .collect();
8729        let mut path_dupes: Vec<String> = Vec::new();
8730        if let Ok(path) = std::env::var("PATH") {
8731            let mut seen: std::collections::HashSet<std::path::PathBuf> =
8732                std::collections::HashSet::new();
8733            for dir in path.split(':') {
8734                let candidate = std::path::PathBuf::from(dir).join("wire");
8735                if candidate.exists() {
8736                    let canon = candidate.canonicalize().unwrap_or(candidate);
8737                    if seen.insert(canon.clone()) {
8738                        path_dupes.push(canon.to_string_lossy().into_owned());
8739                    }
8740                }
8741            }
8742        }
8743        let report = json!({
8744            "running_pids": running_pids,
8745            "pidfile_version": recorded_version,
8746            "cli_version": cli_version,
8747            "would_kill": running_pids,
8748            "session_daemons_running": sessions_with_daemons,
8749            "path_binaries": path_dupes,
8750            "path_duplicate_warning": path_dupes.len() > 1,
8751        });
8752        if as_json {
8753            println!("{}", serde_json::to_string(&report)?);
8754        } else {
8755            println!("wire upgrade --check");
8756            println!("  cli version:      {cli_version}");
8757            println!(
8758                "  pidfile version:  {}",
8759                recorded_version.as_deref().unwrap_or("(missing)")
8760            );
8761            if running_pids.is_empty() {
8762                println!("  running daemons:  none");
8763            } else {
8764                let pids: Vec<String> = running_pids.iter().map(|p| p.to_string()).collect();
8765                println!("  running daemons:  pids {}", pids.join(", "));
8766                println!("  would kill all + spawn fresh");
8767            }
8768            if !sessions_with_daemons.is_empty() {
8769                println!(
8770                    "  session daemons:  {} (would respawn under new binary)",
8771                    sessions_with_daemons.join(", ")
8772                );
8773            }
8774            if path_dupes.len() > 1 {
8775                println!(
8776                    "  PATH warning:     {} distinct `wire` binaries on PATH:",
8777                    path_dupes.len()
8778                );
8779                for b in &path_dupes {
8780                    println!("                      {b}");
8781                }
8782                println!("                    operators should remove the stale ones");
8783            }
8784        }
8785        return Ok(());
8786    }
8787
8788    // 3. Kill every running wire daemon. Use SIGTERM first, then SIGKILL
8789    // after a brief grace period.
8790    let mut killed: Vec<u32> = Vec::new();
8791    for pid in &running_pids {
8792        // SIGTERM (15).
8793        let _ = std::process::Command::new("kill")
8794            .args(["-15", &pid.to_string()])
8795            .status();
8796        killed.push(*pid);
8797    }
8798    // Wait up to ~2s for graceful exit.
8799    if !killed.is_empty() {
8800        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
8801        loop {
8802            let still_alive: Vec<u32> = killed
8803                .iter()
8804                .copied()
8805                .filter(|p| process_alive_pid(*p))
8806                .collect();
8807            if still_alive.is_empty() {
8808                break;
8809            }
8810            if std::time::Instant::now() >= deadline {
8811                // SIGKILL hold-outs.
8812                for pid in still_alive {
8813                    let _ = std::process::Command::new("kill")
8814                        .args(["-9", &pid.to_string()])
8815                        .status();
8816                }
8817                break;
8818            }
8819            std::thread::sleep(std::time::Duration::from_millis(50));
8820        }
8821    }
8822
8823    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
8824    //    old daemon is still owning it.
8825    let pidfile = config::state_dir()?.join("daemon.pid");
8826    if pidfile.exists() {
8827        let _ = std::fs::remove_file(&pidfile);
8828    }
8829
8830    // 4b. v0.6.8/9 stale-cleanup: wipe every session's pidfile (step 3's
8831    // pgrep+SIGTERM has already killed the processes; pidfile tombstones
8832    // would otherwise block ensure_session_daemon's "already running"
8833    // short-circuit). The respawn list comes from the v0.6.9 pre-kill
8834    // snapshot above — checking `daemon_running` here would always
8835    // return false because we just killed them.
8836    if let Ok(sessions) = crate::session::list_sessions() {
8837        for s in &sessions {
8838            let session_pidfile = s.home_dir.join("state").join("wire").join("daemon.pid");
8839            if session_pidfile.exists() {
8840                let _ = std::fs::remove_file(&session_pidfile);
8841            }
8842        }
8843    }
8844    let session_daemons_to_respawn = sessions_to_respawn_after_kill;
8845
8846    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
8847    // multiple distinct files on $PATH, surface the conflict — operators
8848    // get bitten when an old binary at /usr/local/bin shadows a fresh
8849    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
8850    let mut path_dupes: Vec<String> = Vec::new();
8851    if let Ok(path) = std::env::var("PATH") {
8852        let mut seen: std::collections::HashSet<std::path::PathBuf> =
8853            std::collections::HashSet::new();
8854        for dir in path.split(':') {
8855            let candidate = std::path::PathBuf::from(dir).join("wire");
8856            if candidate.exists() {
8857                let canon = candidate.canonicalize().unwrap_or(candidate);
8858                if seen.insert(canon.clone()) {
8859                    path_dupes.push(canon.to_string_lossy().into_owned());
8860                }
8861            }
8862        }
8863    }
8864    let path_warning = if path_dupes.len() > 1 {
8865        Some(format!(
8866            "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n  {}",
8867            path_dupes.len(),
8868            path_dupes.join("\n  ")
8869        ))
8870    } else {
8871        None
8872    };
8873
8874    // 5. Spawn fresh daemon via ensure_up — atomically waits for
8875    //    process_alive + writes the versioned pidfile.
8876    let spawned = crate::ensure_up::ensure_daemon_running()?;
8877
8878    // 5b. v0.6.8: respawn each session daemon under the new binary.
8879    // Reuses `ensure_session_daemon` — same code path `wire session new`
8880    // takes for the initial spawn (writes versioned pidfile, opens log,
8881    // detaches). Best effort: failure of one session's respawn doesn't
8882    // abort the upgrade for the others.
8883    let mut session_respawns: Vec<Value> = Vec::new();
8884    for home in &session_daemons_to_respawn {
8885        match ensure_session_daemon(home) {
8886            Ok(()) => session_respawns.push(json!({
8887                "session_home": home.to_string_lossy(),
8888                "status": "respawned",
8889            })),
8890            Err(e) => session_respawns.push(json!({
8891                "session_home": home.to_string_lossy(),
8892                "status": "failed",
8893                "error": format!("{e:#}"),
8894            })),
8895        }
8896    }
8897
8898    let new_record = crate::ensure_up::read_pid_record("daemon");
8899    let new_pid = new_record.pid();
8900    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
8901        Some(d.version.clone())
8902    } else {
8903        None
8904    };
8905
8906    if as_json {
8907        println!(
8908            "{}",
8909            serde_json::to_string(&json!({
8910                "killed": killed,
8911                "spawned_fresh_daemon": spawned,
8912                "new_pid": new_pid,
8913                "new_version": new_version,
8914                "cli_version": cli_version,
8915                "session_respawns": session_respawns,
8916                "path_binaries": path_dupes,
8917                "path_warning": path_warning,
8918            }))?
8919        );
8920    } else {
8921        if killed.is_empty() {
8922            println!("wire upgrade: no stale daemons running");
8923        } else {
8924            println!(
8925                "wire upgrade: killed {} daemon(s) (pids {})",
8926                killed.len(),
8927                killed
8928                    .iter()
8929                    .map(|p| p.to_string())
8930                    .collect::<Vec<_>>()
8931                    .join(", ")
8932            );
8933        }
8934        if spawned {
8935            println!(
8936                "wire upgrade: spawned fresh daemon (pid {} v{})",
8937                new_pid
8938                    .map(|p| p.to_string())
8939                    .unwrap_or_else(|| "?".to_string()),
8940                new_version.as_deref().unwrap_or(&cli_version),
8941            );
8942        } else {
8943            println!("wire upgrade: daemon was already running on current binary");
8944        }
8945        if !session_respawns.is_empty() {
8946            println!(
8947                "wire upgrade: refreshed {} session daemon(s):",
8948                session_respawns.len()
8949            );
8950            for r in &session_respawns {
8951                let h = r["session_home"].as_str().unwrap_or("?");
8952                let s = r["status"].as_str().unwrap_or("?");
8953                let label = std::path::Path::new(h)
8954                    .file_name()
8955                    .map(|f| f.to_string_lossy().into_owned())
8956                    .unwrap_or_else(|| h.to_string());
8957                println!("  {label:<24} {s}");
8958            }
8959        }
8960        if let Some(msg) = &path_warning {
8961            eprintln!("wire upgrade: {msg}");
8962        }
8963    }
8964    Ok(())
8965}
8966
8967fn process_alive_pid(pid: u32) -> bool {
8968    #[cfg(target_os = "linux")]
8969    {
8970        std::path::Path::new(&format!("/proc/{pid}")).exists()
8971    }
8972    #[cfg(not(target_os = "linux"))]
8973    {
8974        std::process::Command::new("kill")
8975            .args(["-0", &pid.to_string()])
8976            .stdin(std::process::Stdio::null())
8977            .stdout(std::process::Stdio::null())
8978            .stderr(std::process::Stdio::null())
8979            .status()
8980            .map(|s| s.success())
8981            .unwrap_or(false)
8982    }
8983}
8984
8985// ---------- doctor (single-command diagnostic) ----------
8986
8987/// One DoctorCheck = one verdict on one health dimension.
8988#[derive(Clone, Debug, serde::Serialize)]
8989pub struct DoctorCheck {
8990    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
8991    /// Stable across versions for tooling consumption.
8992    pub id: String,
8993    /// PASS / WARN / FAIL.
8994    pub status: String,
8995    /// One-line human summary.
8996    pub detail: String,
8997    /// Optional remediation hint shown after the failing line.
8998    #[serde(skip_serializing_if = "Option::is_none")]
8999    pub fix: Option<String>,
9000}
9001
9002impl DoctorCheck {
9003    fn pass(id: &str, detail: impl Into<String>) -> Self {
9004        Self {
9005            id: id.into(),
9006            status: "PASS".into(),
9007            detail: detail.into(),
9008            fix: None,
9009        }
9010    }
9011    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
9012        Self {
9013            id: id.into(),
9014            status: "WARN".into(),
9015            detail: detail.into(),
9016            fix: Some(fix.into()),
9017        }
9018    }
9019    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
9020        Self {
9021            id: id.into(),
9022            status: "FAIL".into(),
9023            detail: detail.into(),
9024            fix: Some(fix.into()),
9025        }
9026    }
9027}
9028
9029/// `wire doctor` — single-command diagnostic for the silent-fail classes
9030/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
9031/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
9032/// so operators don't have to know where each lives.
9033fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
9034    let checks: Vec<DoctorCheck> = vec![
9035        check_daemon_health(),
9036        check_daemon_pid_consistency(),
9037        check_relay_reachable(),
9038        check_pair_rejections(recent_rejections),
9039        check_cursor_progress(),
9040    ];
9041
9042    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
9043    let warns = checks.iter().filter(|c| c.status == "WARN").count();
9044
9045    if as_json {
9046        println!(
9047            "{}",
9048            serde_json::to_string(&json!({
9049                "checks": checks,
9050                "fail_count": fails,
9051                "warn_count": warns,
9052                "ok": fails == 0,
9053            }))?
9054        );
9055    } else {
9056        println!("wire doctor — {} checks", checks.len());
9057        for c in &checks {
9058            let bullet = match c.status.as_str() {
9059                "PASS" => "✓",
9060                "WARN" => "!",
9061                "FAIL" => "✗",
9062                _ => "?",
9063            };
9064            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
9065            if let Some(fix) = &c.fix {
9066                println!("      fix: {fix}");
9067            }
9068        }
9069        println!();
9070        if fails == 0 && warns == 0 {
9071            println!("ALL GREEN");
9072        } else {
9073            println!("{fails} FAIL, {warns} WARN");
9074        }
9075    }
9076
9077    if fails > 0 {
9078        std::process::exit(1);
9079    }
9080    Ok(())
9081}
9082
9083/// Check: daemon running, exactly one instance, no orphans.
9084///
9085/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
9086/// days, advancing cursor without pinning). `wire status` lied about it.
9087/// `wire doctor` must catch THIS class: multiple daemons running, OR
9088/// pid-file claims daemon down while a process is actually up.
9089fn check_daemon_health() -> DoctorCheck {
9090    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
9091    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
9092    // hardening): every surface routes through ensure_up::daemon_liveness
9093    // so they share one view of the world. No more parallel liveness
9094    // logic to drift out of sync.
9095    let snap = crate::ensure_up::daemon_liveness();
9096    let pgrep_pids = &snap.pgrep_pids;
9097    let pidfile_pid = snap.pidfile_pid;
9098    let pidfile_alive = snap.pidfile_alive;
9099    let orphan_pids = &snap.orphan_pids;
9100
9101    let fmt_pids = |xs: &[u32]| -> String {
9102        xs.iter()
9103            .map(|p| p.to_string())
9104            .collect::<Vec<_>>()
9105            .join(", ")
9106    };
9107
9108    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
9109        (0, _, _) => DoctorCheck::fail(
9110            "daemon",
9111            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
9112            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
9113        ),
9114        // Single daemon AND it matches the pidfile → healthy.
9115        (1, true, true) => DoctorCheck::pass(
9116            "daemon",
9117            format!(
9118                "one daemon running (pid {}, matches pidfile)",
9119                pgrep_pids[0]
9120            ),
9121        ),
9122        // Pidfile is alive but pgrep ALSO sees orphan processes.
9123        (n, true, false) => DoctorCheck::fail(
9124            "daemon",
9125            format!(
9126                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
9127                 The orphans race the relay cursor — they advance past events your current binary can't process. \
9128                 (Issue #2 exact class.)",
9129                fmt_pids(pgrep_pids),
9130                pidfile_pid.unwrap(),
9131                fmt_pids(orphan_pids),
9132            ),
9133            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
9134        ),
9135        // Pidfile is dead but processes ARE running → all are orphans.
9136        (n, false, _) => DoctorCheck::fail(
9137            "daemon",
9138            format!(
9139                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
9140                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
9141                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
9142                fmt_pids(pgrep_pids),
9143                match pidfile_pid {
9144                    Some(p) => format!("claims pid {p} which is dead"),
9145                    None => "is missing".to_string(),
9146                },
9147            ),
9148            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
9149        ),
9150        // Multiple daemons all matching … impossible by construction; fall back to warn.
9151        (n, true, true) => DoctorCheck::warn(
9152            "daemon",
9153            format!(
9154                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
9155                fmt_pids(pgrep_pids)
9156            ),
9157            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
9158        ),
9159    }
9160}
9161
9162/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
9163/// check. Surfaces version mismatch (daemon running old binary text in
9164/// memory under a current symlink — today's exact bug class), schema
9165/// drift (future format bumps), and identity contamination (daemon's
9166/// recorded DID doesn't match this box's configured DID).
9167///
9168/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
9169/// JSON pid record whose recorded `pid` is no longer a live OS process.
9170/// Pre-hardening this check PASSed in that state (it only validated
9171/// content, not liveness), letting `wire status: DOWN` and
9172/// `wire doctor: PASS` disagree for 25 min in incident #2.
9173fn check_daemon_pid_consistency() -> DoctorCheck {
9174    let snap = crate::ensure_up::daemon_liveness();
9175    match &snap.record {
9176        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
9177            "daemon_pid_consistency",
9178            "no daemon.pid yet — fresh box or daemon never started",
9179        ),
9180        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
9181            "daemon_pid_consistency",
9182            format!("daemon.pid is corrupt: {reason}"),
9183            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
9184        ),
9185        crate::ensure_up::PidRecord::LegacyInt(pid) => {
9186            // Legacy pidfile: still surface liveness so a dead legacy pid
9187            // doesn't quietly PASS this check while status says DOWN.
9188            let pid = *pid;
9189            if !crate::ensure_up::pid_is_alive(pid) {
9190                return DoctorCheck::warn(
9191                    "daemon_pid_consistency",
9192                    format!(
9193                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
9194                         Stale pidfile from a crashed pre-0.5.11 daemon. \
9195                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
9196                    ),
9197                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
9198                );
9199            }
9200            DoctorCheck::warn(
9201                "daemon_pid_consistency",
9202                format!(
9203                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
9204                     Daemon was started by a pre-0.5.11 binary."
9205                ),
9206                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
9207            )
9208        }
9209        crate::ensure_up::PidRecord::Json(d) => {
9210            // v0.5.19 liveness gate: if the recorded pid is dead, the
9211            // pidfile is stale and the rest of the content drift checks
9212            // are moot — `wire upgrade` is the answer regardless.
9213            if !snap.pidfile_alive {
9214                return DoctorCheck::warn(
9215                    "daemon_pid_consistency",
9216                    format!(
9217                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
9218                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
9219                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
9220                        pid = d.pid,
9221                        version = d.version,
9222                    ),
9223                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
9224                     (kills any orphan daemon advancing the cursor without coordination)",
9225                );
9226            }
9227            let mut issues: Vec<String> = Vec::new();
9228            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
9229                issues.push(format!(
9230                    "schema={} (expected {})",
9231                    d.schema,
9232                    crate::ensure_up::DAEMON_PID_SCHEMA
9233                ));
9234            }
9235            let cli_version = env!("CARGO_PKG_VERSION");
9236            if d.version != cli_version {
9237                issues.push(format!("version daemon={} cli={cli_version}", d.version));
9238            }
9239            if !std::path::Path::new(&d.bin_path).exists() {
9240                issues.push(format!("bin_path {} missing on disk", d.bin_path));
9241            }
9242            // Cross-check DID + relay against current config (best-effort).
9243            if let Ok(card) = config::read_agent_card()
9244                && let Some(current_did) = card.get("did").and_then(Value::as_str)
9245                && let Some(recorded_did) = &d.did
9246                && recorded_did != current_did
9247            {
9248                issues.push(format!(
9249                    "did daemon={recorded_did} config={current_did} — identity drift"
9250                ));
9251            }
9252            if let Ok(state) = config::read_relay_state()
9253                && let Some(current_relay) = state
9254                    .get("self")
9255                    .and_then(|s| s.get("relay_url"))
9256                    .and_then(Value::as_str)
9257                && let Some(recorded_relay) = &d.relay_url
9258                && recorded_relay != current_relay
9259            {
9260                issues.push(format!(
9261                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
9262                ));
9263            }
9264            if issues.is_empty() {
9265                DoctorCheck::pass(
9266                    "daemon_pid_consistency",
9267                    format!(
9268                        "daemon v{} bound to {} as {}",
9269                        d.version,
9270                        d.relay_url.as_deref().unwrap_or("?"),
9271                        d.did.as_deref().unwrap_or("?")
9272                    ),
9273                )
9274            } else {
9275                DoctorCheck::warn(
9276                    "daemon_pid_consistency",
9277                    format!("daemon pidfile drift: {}", issues.join("; ")),
9278                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
9279                )
9280            }
9281        }
9282    }
9283}
9284
9285/// Check: bound relay's /healthz returns 200.
9286fn check_relay_reachable() -> DoctorCheck {
9287    let state = match config::read_relay_state() {
9288        Ok(s) => s,
9289        Err(e) => {
9290            return DoctorCheck::fail(
9291                "relay",
9292                format!("could not read relay state: {e}"),
9293                "run `wire up <handle>@<relay>` to bootstrap",
9294            );
9295        }
9296    };
9297    let url = state
9298        .get("self")
9299        .and_then(|s| s.get("relay_url"))
9300        .and_then(Value::as_str)
9301        .unwrap_or("");
9302    if url.is_empty() {
9303        return DoctorCheck::warn(
9304            "relay",
9305            "no relay bound — wire send/pull will not work",
9306            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
9307        );
9308    }
9309    let client = crate::relay_client::RelayClient::new(url);
9310    match client.check_healthz() {
9311        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
9312        Err(e) => DoctorCheck::fail(
9313            "relay",
9314            format!("{url} unreachable: {e}"),
9315            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
9316        ),
9317    }
9318}
9319
9320/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
9321/// entry there is a silent failure that, pre-0.5.11, would have left the
9322/// operator wondering why pairing didn't complete.
9323fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
9324    let path = match config::state_dir() {
9325        Ok(d) => d.join("pair-rejected.jsonl"),
9326        Err(e) => {
9327            return DoctorCheck::warn(
9328                "pair_rejections",
9329                format!("could not resolve state dir: {e}"),
9330                "set WIRE_HOME or fix XDG_STATE_HOME",
9331            );
9332        }
9333    };
9334    if !path.exists() {
9335        return DoctorCheck::pass(
9336            "pair_rejections",
9337            "no pair-rejected.jsonl — no recorded pair failures",
9338        );
9339    }
9340    let body = match std::fs::read_to_string(&path) {
9341        Ok(b) => b,
9342        Err(e) => {
9343            return DoctorCheck::warn(
9344                "pair_rejections",
9345                format!("could not read {path:?}: {e}"),
9346                "check file permissions",
9347            );
9348        }
9349    };
9350    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
9351    if lines.is_empty() {
9352        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
9353    }
9354    let total = lines.len();
9355    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
9356    let mut summary: Vec<String> = Vec::new();
9357    for line in &recent {
9358        if let Ok(rec) = serde_json::from_str::<Value>(line) {
9359            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
9360            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
9361            summary.push(format!("{peer}/{code}"));
9362        }
9363    }
9364    DoctorCheck::warn(
9365        "pair_rejections",
9366        format!(
9367            "{total} pair failures recorded. recent: [{}]",
9368            summary.join(", ")
9369        ),
9370        format!(
9371            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
9372        ),
9373    )
9374}
9375
9376/// Check: cursor isn't stuck. We can't tell without polling — but we can
9377/// report the current cursor position so operators see if it changes.
9378/// Real "stuck" detection needs two pulls separated in time; defer that
9379/// behaviour to a `wire doctor --watch` mode.
9380fn check_cursor_progress() -> DoctorCheck {
9381    let state = match config::read_relay_state() {
9382        Ok(s) => s,
9383        Err(e) => {
9384            return DoctorCheck::warn(
9385                "cursor",
9386                format!("could not read relay state: {e}"),
9387                "check ~/Library/Application Support/wire/relay.json",
9388            );
9389        }
9390    };
9391    let cursor = state
9392        .get("self")
9393        .and_then(|s| s.get("last_pulled_event_id"))
9394        .and_then(Value::as_str)
9395        .map(|s| s.chars().take(16).collect::<String>())
9396        .unwrap_or_else(|| "<none>".to_string());
9397    DoctorCheck::pass(
9398        "cursor",
9399        format!(
9400            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
9401        ),
9402    )
9403}
9404
9405#[cfg(test)]
9406mod doctor_tests {
9407    use super::*;
9408
9409    #[test]
9410    fn doctor_check_constructors_set_status_correctly() {
9411        // Silent-fail-prevention rule: pass/warn/fail must be visibly
9412        // distinguishable to operators. If any constructor lets the wrong
9413        // status through, `wire doctor` lies and we're back to today's
9414        // 30-minute debug.
9415        let p = DoctorCheck::pass("x", "ok");
9416        assert_eq!(p.status, "PASS");
9417        assert_eq!(p.fix, None);
9418
9419        let w = DoctorCheck::warn("x", "watch out", "do this");
9420        assert_eq!(w.status, "WARN");
9421        assert_eq!(w.fix, Some("do this".to_string()));
9422
9423        let f = DoctorCheck::fail("x", "broken", "fix it");
9424        assert_eq!(f.status, "FAIL");
9425        assert_eq!(f.fix, Some("fix it".to_string()));
9426    }
9427
9428    #[test]
9429    fn check_pair_rejections_no_file_is_pass() {
9430        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
9431        // as a problem.
9432        config::test_support::with_temp_home(|| {
9433            config::ensure_dirs().unwrap();
9434            let c = check_pair_rejections(5);
9435            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
9436        });
9437    }
9438
9439    #[test]
9440    fn check_pair_rejections_with_entries_warns() {
9441        // Existence of rejections is itself a signal — even if each entry
9442        // is a "known good failure," the operator wants to know they
9443        // happened.
9444        config::test_support::with_temp_home(|| {
9445            config::ensure_dirs().unwrap();
9446            crate::pair_invite::record_pair_rejection(
9447                "willard",
9448                "pair_drop_ack_send_failed",
9449                "POST 502",
9450            );
9451            let c = check_pair_rejections(5);
9452            assert_eq!(c.status, "WARN");
9453            assert!(c.detail.contains("1 pair failures"));
9454            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
9455        });
9456    }
9457}
9458
9459// ---------- up megacommand (full bootstrap) ----------
9460
9461/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
9462/// pair. Composes the steps that today's onboarding walks operators through
9463/// one by one (init / bind-relay / claim / background daemon / arm monitor
9464/// recipe). Idempotent: every step checks current state and skips if done.
9465///
9466/// Argument parsing accepts:
9467///   - `<nick>@<relay-host>` — explicit relay
9468///   - `<nick>`              — defaults to wireup.net (the configured
9469///     public relay)
9470fn cmd_up(handle_arg: &str, name: Option<&str>, as_json: bool) -> Result<()> {
9471    let (nick, relay_url) = match handle_arg.split_once('@') {
9472        Some((n, host)) => {
9473            let url = if host.starts_with("http://") || host.starts_with("https://") {
9474                host.to_string()
9475            } else {
9476                format!("https://{host}")
9477            };
9478            (n.to_string(), url)
9479        }
9480        None => (
9481            handle_arg.to_string(),
9482            crate::pair_invite::DEFAULT_RELAY.to_string(),
9483        ),
9484    };
9485
9486    let mut report: Vec<(String, String)> = Vec::new();
9487    let mut step = |stage: &str, detail: String| {
9488        report.push((stage.to_string(), detail.clone()));
9489        if !as_json {
9490            eprintln!("wire up: {stage} — {detail}");
9491        }
9492    };
9493
9494    // 1. init (or verify existing identity matches the requested nick).
9495    if config::is_initialized()? {
9496        let card = config::read_agent_card()?;
9497        let existing_did = card.get("did").and_then(Value::as_str).unwrap_or("");
9498        let existing_handle = crate::agent_card::display_handle_from_did(existing_did).to_string();
9499        if existing_handle != nick {
9500            bail!(
9501                "wire up: already initialized as {existing_handle:?} but you asked for {nick:?}. \
9502                 Either run with the existing handle (`wire up {existing_handle}@<relay>`) or \
9503                 delete `{:?}` to start fresh.",
9504                config::config_dir()?
9505            );
9506        }
9507        step("init", format!("already initialized as {existing_handle}"));
9508    } else {
9509        cmd_init(&nick, name, Some(&relay_url), /* as_json */ false)?;
9510        step(
9511            "init",
9512            format!("created identity {nick} bound to {relay_url}"),
9513        );
9514    }
9515
9516    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
9517    // already initialized we may need to bind to the requested relay
9518    // separately (operator switched relays).
9519    let relay_state = config::read_relay_state()?;
9520    let bound_relay = relay_state
9521        .get("self")
9522        .and_then(|s| s.get("relay_url"))
9523        .and_then(Value::as_str)
9524        .unwrap_or("")
9525        .to_string();
9526    if bound_relay.is_empty() {
9527        // Identity exists but never bound to a relay — bind now.
9528        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
9529        // Pass `false` so the safety check kicks in if state was non-empty.
9530        cmd_bind_relay(
9531            &relay_url, /* migrate_pinned */ false, /* as_json */ false,
9532        )?;
9533        step("bind-relay", format!("bound to {relay_url}"));
9534    } else if bound_relay != relay_url {
9535        step(
9536            "bind-relay",
9537            format!(
9538                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
9539                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
9540            ),
9541        );
9542    } else {
9543        step("bind-relay", format!("already bound to {bound_relay}"));
9544    }
9545
9546    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
9547    // re-claims are accepted by the relay.
9548    match cmd_claim(
9549        &nick,
9550        Some(&relay_url),
9551        None,
9552        /* hidden */ false,
9553        /* as_json */ false,
9554    ) {
9555        Ok(()) => step(
9556            "claim",
9557            format!("{nick}@{} claimed", strip_proto(&relay_url)),
9558        ),
9559        Err(e) => step(
9560            "claim",
9561            format!("WARNING: claim failed: {e}. You can retry `wire claim {nick}`."),
9562        ),
9563    }
9564
9565    // 4. Background daemon — must be running for pull/push/ack to flow.
9566    match crate::ensure_up::ensure_daemon_running() {
9567        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
9568        Ok(false) => step("daemon", "already running".to_string()),
9569        Err(e) => step(
9570            "daemon",
9571            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
9572        ),
9573    }
9574
9575    // 5. Final summary — point operator at the next commands.
9576    let summary =
9577        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
9578         `wire monitor` to watch incoming events."
9579            .to_string();
9580    step("ready", summary.clone());
9581
9582    if as_json {
9583        let steps_json: Vec<_> = report
9584            .iter()
9585            .map(|(k, v)| json!({"stage": k, "detail": v}))
9586            .collect();
9587        println!(
9588            "{}",
9589            serde_json::to_string(&json!({
9590                "nick": nick,
9591                "relay": relay_url,
9592                "steps": steps_json,
9593            }))?
9594        );
9595    }
9596    Ok(())
9597}
9598
9599/// Strip http:// or https:// prefix for display in `wire up` step output.
9600fn strip_proto(url: &str) -> String {
9601    url.trim_start_matches("https://")
9602        .trim_start_matches("http://")
9603        .to_string()
9604}
9605
9606// ---------- pair megacommand (zero-paste handle-based) ----------
9607
9608/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
9609/// the handle is in `nick@domain` form. Wraps:
9610///
9611///   1. cmd_add — resolve, pin, drop intro
9612///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
9613///      (signalled by `peers.<handle>.slot_token` populating in relay state)
9614///   3. Verify bilateral pin: trust contains peer + relay state has token
9615///   4. Print final state — both sides VERIFIED + can `wire send`
9616///
9617/// On timeout: hard-errors with the specific stuck step so the operator
9618/// knows which side to chase. No silent partial success.
9619fn cmd_pair_megacommand(
9620    handle_arg: &str,
9621    relay_override: Option<&str>,
9622    timeout_secs: u64,
9623    _as_json: bool,
9624) -> Result<()> {
9625    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
9626    let peer_handle = parsed.nick.clone();
9627
9628    eprintln!("wire pair: resolving {handle_arg}...");
9629    cmd_add(
9630        handle_arg,
9631        relay_override,
9632        /* local_sister */ false,
9633        /* as_json */ false,
9634    )?;
9635
9636    eprintln!(
9637        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
9638         to ack (their daemon must be running + pulling)..."
9639    );
9640
9641    // Trigger an immediate daemon-style pull so we don't wait the full daemon
9642    // interval. Best-effort — if it fails, we still fall through to the
9643    // polling loop.
9644    let _ = run_sync_pull();
9645
9646    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
9647    let poll_interval = std::time::Duration::from_millis(500);
9648
9649    loop {
9650        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
9651        let _ = run_sync_pull();
9652        let relay_state = config::read_relay_state()?;
9653        let peer_entry = relay_state
9654            .get("peers")
9655            .and_then(|p| p.get(&peer_handle))
9656            .cloned();
9657        let token = peer_entry
9658            .as_ref()
9659            .and_then(|e| e.get("slot_token"))
9660            .and_then(Value::as_str)
9661            .unwrap_or("");
9662
9663        if !token.is_empty() {
9664            // Bilateral pin complete — we have their slot_token, we can send.
9665            let trust = config::read_trust()?;
9666            let pinned_in_trust = trust
9667                .get("agents")
9668                .and_then(|a| a.get(&peer_handle))
9669                .is_some();
9670            println!(
9671                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
9672                if pinned_in_trust {
9673                    "VERIFIED"
9674                } else {
9675                    "MISSING (bug)"
9676                }
9677            );
9678            return Ok(());
9679        }
9680
9681        if std::time::Instant::now() >= deadline {
9682            // Timeout — surface the EXACT stuck step. Likely culprits:
9683            //   - peer daemon not running on their box
9684            //   - peer's relay slot is offline
9685            //   - their daemon is on an older binary that doesn't know
9686            //     pair_drop kind=1100 (the P0.1 class — now visible via
9687            //     wire pull --json on their side as a blocking rejection)
9688            bail!(
9689                "wire pair: timed out after {timeout_secs}s. \
9690                 peer {peer_handle} never sent pair_drop_ack. \
9691                 likely causes: (a) their daemon is down — ask them to run \
9692                 `wire status` and `wire daemon &`; (b) their binary is older \
9693                 than 0.5.x and doesn't understand pair_drop events — ask \
9694                 them to `wire upgrade`; (c) network / relay blip — re-run \
9695                 `wire pair {handle_arg}` to retry."
9696            );
9697        }
9698
9699        std::thread::sleep(poll_interval);
9700    }
9701}
9702
9703fn cmd_claim(
9704    nick: &str,
9705    relay_override: Option<&str>,
9706    public_url: Option<&str>,
9707    hidden: bool,
9708    as_json: bool,
9709) -> Result<()> {
9710    if !crate::pair_profile::is_valid_nick(nick) {
9711        bail!(
9712            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
9713        );
9714    }
9715    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
9716    // + claim handle. Operator should never have to run init/bind-relay first.
9717    let (_did, relay_url, slot_id, slot_token) =
9718        crate::pair_invite::ensure_self_with_relay(relay_override)?;
9719    let card = config::read_agent_card()?;
9720
9721    let client = crate::relay_client::RelayClient::new(&relay_url);
9722    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
9723    // (back-compat); Some(false) for `--hidden`. Relays older than
9724    // v0.5.19 ignore the field, so this is safe to always send.
9725    let discoverable = if hidden { Some(false) } else { None };
9726    let resp =
9727        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
9728
9729    if as_json {
9730        println!(
9731            "{}",
9732            serde_json::to_string(&json!({
9733                "nick": nick,
9734                "relay": relay_url,
9735                "response": resp,
9736            }))?
9737        );
9738    } else {
9739        // Best-effort: derive the public domain from the relay URL. If
9740        // operator passed --public-url that's the canonical address; else
9741        // the relay URL itself. Falls back to a placeholder if both miss.
9742        let domain = public_url
9743            .unwrap_or(&relay_url)
9744            .trim_start_matches("https://")
9745            .trim_start_matches("http://")
9746            .trim_end_matches('/')
9747            .split('/')
9748            .next()
9749            .unwrap_or("<this-relay-domain>")
9750            .to_string();
9751        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
9752        println!("verify with: wire whois {nick}@{domain}");
9753    }
9754    Ok(())
9755}
9756
9757fn cmd_profile(action: ProfileAction) -> Result<()> {
9758    match action {
9759        ProfileAction::Set { field, value, json } => {
9760            // Try parsing the value as JSON; if that fails, treat it as a
9761            // bare string. Lets operators pass either `42` or `"hello"` or
9762            // `["rust","late-night"]` without quoting hell.
9763            let parsed: Value =
9764                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
9765            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
9766            if json {
9767                println!(
9768                    "{}",
9769                    serde_json::to_string(&json!({
9770                        "field": field,
9771                        "profile": new_profile,
9772                    }))?
9773                );
9774            } else {
9775                println!("profile.{field} set");
9776            }
9777        }
9778        ProfileAction::Get { json } => return cmd_whois(None, json, None),
9779        ProfileAction::Clear { field, json } => {
9780            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
9781            if json {
9782                println!(
9783                    "{}",
9784                    serde_json::to_string(&json!({
9785                        "field": field,
9786                        "cleared": true,
9787                        "profile": new_profile,
9788                    }))?
9789                );
9790            } else {
9791                println!("profile.{field} cleared");
9792            }
9793        }
9794    }
9795    Ok(())
9796}
9797
9798// ---------- setup — one-shot MCP host registration ----------
9799
9800fn cmd_setup(apply: bool) -> Result<()> {
9801    use std::path::PathBuf;
9802
9803    let entry = json!({"command": "wire", "args": ["mcp"]});
9804    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
9805
9806    // Detect probable MCP host config locations. Cross-platform — we only
9807    // touch the file if it already exists OR --apply was passed.
9808    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
9809    if let Some(home) = dirs::home_dir() {
9810        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
9811        // The mcpServers map lives at the top level of that file.
9812        targets.push(("Claude Code", home.join(".claude.json")));
9813        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
9814        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
9815        // Claude Desktop macOS
9816        #[cfg(target_os = "macos")]
9817        targets.push((
9818            "Claude Desktop (macOS)",
9819            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
9820        ));
9821        // Claude Desktop Windows
9822        #[cfg(target_os = "windows")]
9823        if let Ok(appdata) = std::env::var("APPDATA") {
9824            targets.push((
9825                "Claude Desktop (Windows)",
9826                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
9827            ));
9828        }
9829        // Cursor
9830        targets.push(("Cursor", home.join(".cursor/mcp.json")));
9831    }
9832    // Project-local — works for several MCP-aware tools
9833    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
9834
9835    println!("wire setup\n");
9836    println!("MCP server snippet (add this to your client's mcpServers):");
9837    println!();
9838    println!("{entry_pretty}");
9839    println!();
9840
9841    if !apply {
9842        println!("Probable MCP host config locations on this machine:");
9843        for (name, path) in &targets {
9844            let marker = if path.exists() {
9845                "✓ found"
9846            } else {
9847                "  (would create)"
9848            };
9849            println!("  {marker:14}  {name}: {}", path.display());
9850        }
9851        println!();
9852        println!("Run `wire setup --apply` to merge wire into each config above.");
9853        println!(
9854            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
9855        );
9856        return Ok(());
9857    }
9858
9859    let mut modified: Vec<String> = Vec::new();
9860    let mut skipped: Vec<String> = Vec::new();
9861    for (name, path) in &targets {
9862        match upsert_mcp_entry(path, "wire", &entry) {
9863            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
9864            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
9865            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
9866        }
9867    }
9868    if !modified.is_empty() {
9869        println!("Modified:");
9870        for line in &modified {
9871            println!("  {line}");
9872        }
9873        println!();
9874        println!("Restart the app(s) above to load wire MCP.");
9875    }
9876    if !skipped.is_empty() {
9877        println!();
9878        println!("Skipped:");
9879        for line in &skipped {
9880            println!("  {line}");
9881        }
9882    }
9883    Ok(())
9884}
9885
9886/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
9887/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
9888fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
9889    let mut cfg: Value = if path.exists() {
9890        let body = std::fs::read_to_string(path).context("reading config")?;
9891        serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
9892    } else {
9893        json!({})
9894    };
9895    if !cfg.is_object() {
9896        cfg = json!({});
9897    }
9898    let root = cfg.as_object_mut().unwrap();
9899    let servers = root
9900        .entry("mcpServers".to_string())
9901        .or_insert_with(|| json!({}));
9902    if !servers.is_object() {
9903        *servers = json!({});
9904    }
9905    let map = servers.as_object_mut().unwrap();
9906    if map.get(server_name) == Some(entry) {
9907        return Ok(false);
9908    }
9909    map.insert(server_name.to_string(), entry.clone());
9910    if let Some(parent) = path.parent()
9911        && !parent.as_os_str().is_empty()
9912    {
9913        std::fs::create_dir_all(parent).context("creating parent dir")?;
9914    }
9915    let out = serde_json::to_string_pretty(&cfg)? + "\n";
9916    std::fs::write(path, out).context("writing config")?;
9917    Ok(true)
9918}
9919
9920// ---------- reactor — event-handler dispatch loop ----------
9921
9922#[allow(clippy::too_many_arguments)]
9923fn cmd_reactor(
9924    on_event: &str,
9925    peer_filter: Option<&str>,
9926    kind_filter: Option<&str>,
9927    verified_only: bool,
9928    interval_secs: u64,
9929    once: bool,
9930    dry_run: bool,
9931    max_per_minute: u32,
9932    max_chain_depth: u32,
9933) -> Result<()> {
9934    use crate::inbox_watch::{InboxEvent, InboxWatcher};
9935    use std::collections::{HashMap, HashSet, VecDeque};
9936    use std::io::Write;
9937    use std::process::{Command, Stdio};
9938    use std::time::{Duration, Instant};
9939
9940    let cursor_path = config::state_dir()?.join("reactor.cursor");
9941    // event_ids THIS reactor's handler has caused to be sent (via wire send).
9942    // Used by chain-depth check — an incoming `(re:X)` where X is in this set
9943    // means peer is replying to something we just said → don't reply back.
9944    //
9945    // Persisted across restarts so a reactor that crashes mid-conversation
9946    // doesn't re-enter the loop. Reads on startup, writes after each
9947    // outbox-grow detection. Capped at 500 entries (LRU-ish — old entries
9948    // dropped from front of file).
9949    let emitted_path = config::state_dir()?.join("reactor-emitted.log");
9950    let mut emitted_ids: HashSet<String> = HashSet::new();
9951    if emitted_path.exists()
9952        && let Ok(body) = std::fs::read_to_string(&emitted_path)
9953    {
9954        for line in body.lines() {
9955            let t = line.trim();
9956            if !t.is_empty() {
9957                emitted_ids.insert(t.to_string());
9958            }
9959        }
9960    }
9961    // Outbox file paths the reactor watches for new sent-event_ids.
9962    let outbox_dir = config::outbox_dir()?;
9963    // (peer → file size we've already scanned). Lets us notice new outbox
9964    // appends without re-reading the whole file each sweep.
9965    let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
9966
9967    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
9968
9969    let kind_num: Option<u32> = match kind_filter {
9970        Some(k) => Some(parse_kind(k)?),
9971        None => None,
9972    };
9973
9974    // Per-peer sliding window of dispatch instants for rate-limit check.
9975    let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
9976
9977    let dispatch = |ev: &InboxEvent,
9978                    peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
9979                    emitted_ids: &HashSet<String>|
9980     -> Result<bool> {
9981        if let Some(p) = peer_filter
9982            && ev.peer != p
9983        {
9984            return Ok(false);
9985        }
9986        if verified_only && !ev.verified {
9987            return Ok(false);
9988        }
9989        if let Some(want) = kind_num {
9990            let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
9991            if ev_kind != Some(want) {
9992                return Ok(false);
9993            }
9994        }
9995
9996        // Chain-depth check: if the body contains `(re:<event_id>)` and that
9997        // event_id is in our emitted set, this is a reply to one of our
9998        // replies → loop suspected, skip.
9999        if max_chain_depth > 0 {
10000            let body_str = match &ev.raw["body"] {
10001                Value::String(s) => s.clone(),
10002                other => serde_json::to_string(other).unwrap_or_default(),
10003            };
10004            if let Some(referenced) = parse_re_marker(&body_str) {
10005                // Handler scripts usually truncate event_id (e.g. ${ID:0:12}).
10006                // Match emitted set by prefix to catch both full + truncated.
10007                let matched = emitted_ids.contains(&referenced)
10008                    || emitted_ids.iter().any(|full| full.starts_with(&referenced));
10009                if matched {
10010                    eprintln!(
10011                        "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
10012                        ev.event_id, ev.peer, referenced
10013                    );
10014                    return Ok(false);
10015                }
10016            }
10017        }
10018
10019        // Per-peer rate-limit check (sliding 60s window).
10020        if max_per_minute > 0 {
10021            let now = Instant::now();
10022            let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
10023            while let Some(&front) = win.front() {
10024                if now.duration_since(front) > Duration::from_secs(60) {
10025                    win.pop_front();
10026                } else {
10027                    break;
10028                }
10029            }
10030            if win.len() as u32 >= max_per_minute {
10031                eprintln!(
10032                    "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
10033                    ev.event_id, ev.peer, max_per_minute
10034                );
10035                return Ok(false);
10036            }
10037            win.push_back(now);
10038        }
10039
10040        if dry_run {
10041            println!("{}", serde_json::to_string(&ev.raw)?);
10042            return Ok(true);
10043        }
10044
10045        let mut child = Command::new("sh")
10046            .arg("-c")
10047            .arg(on_event)
10048            .stdin(Stdio::piped())
10049            .stdout(Stdio::inherit())
10050            .stderr(Stdio::inherit())
10051            .env("WIRE_EVENT_PEER", &ev.peer)
10052            .env("WIRE_EVENT_ID", &ev.event_id)
10053            .env("WIRE_EVENT_KIND", &ev.kind)
10054            .spawn()
10055            .with_context(|| format!("spawning reactor handler: {on_event}"))?;
10056        if let Some(mut stdin) = child.stdin.take() {
10057            let body = serde_json::to_vec(&ev.raw)?;
10058            let _ = stdin.write_all(&body);
10059            let _ = stdin.write_all(b"\n");
10060        }
10061        std::mem::drop(child);
10062        Ok(true)
10063    };
10064
10065    // Scan outbox files for newly-appended event_ids and add to emitted set.
10066    let scan_outbox = |emitted_ids: &mut HashSet<String>,
10067                       outbox_cursors: &mut HashMap<String, u64>|
10068     -> Result<usize> {
10069        if !outbox_dir.exists() {
10070            return Ok(0);
10071        }
10072        let mut added = 0;
10073        let mut new_ids: Vec<String> = Vec::new();
10074        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
10075            let path = entry.path();
10076            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
10077                continue;
10078            }
10079            let peer = match path.file_stem().and_then(|s| s.to_str()) {
10080                Some(s) => s.to_string(),
10081                None => continue,
10082            };
10083            let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
10084            let start = *outbox_cursors.get(&peer).unwrap_or(&0);
10085            if cur_len <= start {
10086                outbox_cursors.insert(peer, start);
10087                continue;
10088            }
10089            let body = std::fs::read_to_string(&path).unwrap_or_default();
10090            let tail = &body[start as usize..];
10091            for line in tail.lines() {
10092                if let Ok(v) = serde_json::from_str::<Value>(line)
10093                    && let Some(eid) = v.get("event_id").and_then(Value::as_str)
10094                    && emitted_ids.insert(eid.to_string())
10095                {
10096                    new_ids.push(eid.to_string());
10097                    added += 1;
10098                }
10099            }
10100            outbox_cursors.insert(peer, cur_len);
10101        }
10102        if !new_ids.is_empty() {
10103            // Append new ids to disk, cap on-disk file at 500 entries.
10104            let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
10105            if all.len() > 500 {
10106                all.sort();
10107                let drop_n = all.len() - 500;
10108                let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
10109                emitted_ids.retain(|x| !dropped.contains(x));
10110                all = emitted_ids.iter().cloned().collect();
10111            }
10112            let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
10113        }
10114        Ok(added)
10115    };
10116
10117    let sweep = |watcher: &mut InboxWatcher,
10118                 emitted_ids: &mut HashSet<String>,
10119                 outbox_cursors: &mut HashMap<String, u64>,
10120                 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
10121     -> Result<usize> {
10122        // Pick up any event_ids we sent since last sweep.
10123        let _ = scan_outbox(emitted_ids, outbox_cursors);
10124
10125        let events = watcher.poll()?;
10126        let mut fired = 0usize;
10127        for ev in &events {
10128            match dispatch(ev, peer_dispatch_log, emitted_ids) {
10129                Ok(true) => fired += 1,
10130                Ok(false) => {}
10131                Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
10132            }
10133        }
10134        watcher.save_cursors(&cursor_path)?;
10135        Ok(fired)
10136    };
10137
10138    if once {
10139        sweep(
10140            &mut watcher,
10141            &mut emitted_ids,
10142            &mut outbox_cursors,
10143            &mut peer_dispatch_log,
10144        )?;
10145        return Ok(());
10146    }
10147    let interval = std::time::Duration::from_secs(interval_secs.max(1));
10148    loop {
10149        if let Err(e) = sweep(
10150            &mut watcher,
10151            &mut emitted_ids,
10152            &mut outbox_cursors,
10153            &mut peer_dispatch_log,
10154        ) {
10155            eprintln!("wire reactor: sweep error: {e}");
10156        }
10157        std::thread::sleep(interval);
10158    }
10159}
10160
10161/// Parse `(re:<event_id>)` marker out of an event body. Returns the
10162/// referenced event_id (full or prefix) if present. Tolerates spaces.
10163fn parse_re_marker(body: &str) -> Option<String> {
10164    let needle = "(re:";
10165    let i = body.find(needle)?;
10166    let rest = &body[i + needle.len()..];
10167    let end = rest.find(')')?;
10168    let id = rest[..end].trim().to_string();
10169    if id.is_empty() {
10170        return None;
10171    }
10172    Some(id)
10173}
10174
10175// ---------- notify (Goal 2) ----------
10176
10177fn cmd_notify(
10178    interval_secs: u64,
10179    peer_filter: Option<&str>,
10180    once: bool,
10181    as_json: bool,
10182) -> Result<()> {
10183    use crate::inbox_watch::InboxWatcher;
10184    let cursor_path = config::state_dir()?.join("notify.cursor");
10185    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
10186
10187    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
10188        let events = watcher.poll()?;
10189        for ev in events {
10190            if let Some(p) = peer_filter
10191                && ev.peer != p
10192            {
10193                continue;
10194            }
10195            if as_json {
10196                println!("{}", serde_json::to_string(&ev)?);
10197            } else {
10198                os_notify_inbox_event(&ev);
10199            }
10200        }
10201        watcher.save_cursors(&cursor_path)?;
10202        Ok(())
10203    };
10204
10205    if once {
10206        return sweep(&mut watcher);
10207    }
10208
10209    let interval = std::time::Duration::from_secs(interval_secs.max(1));
10210    loop {
10211        if let Err(e) = sweep(&mut watcher) {
10212            eprintln!("wire notify: sweep error: {e}");
10213        }
10214        std::thread::sleep(interval);
10215    }
10216}
10217
10218fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
10219    let title = if ev.verified {
10220        format!("wire ← {}", ev.peer)
10221    } else {
10222        format!("wire ← {} (UNVERIFIED)", ev.peer)
10223    };
10224    let body = format!("{}: {}", ev.kind, ev.body_preview);
10225    crate::os_notify::toast(&title, &body);
10226}
10227
10228#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
10229fn os_toast(title: &str, body: &str) {
10230    eprintln!("[wire notify] {title}\n  {body}");
10231}
10232
10233// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).