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 bind an inbound slot.
38    /// (HUMAN-ONLY — DO NOT exec from agents.)
39    ///
40    /// v0.9: refuses to create a slotless session by default. Pre-v0.9
41    /// the silent slotless state caused the 2026-05-23 silent-fail
42    /// incident — pairing + sending succeeded but peers black-holed
43    /// inbound. Operators must now name how the session is reachable:
44    /// `--relay <url>` (binds a slot inline) or `--offline` (opt into
45    /// slotless, acknowledge `wire bind-relay` is required before any
46    /// pair or send).
47    ///
48    /// v0.13.1: folded into `wire up` and hidden. Your handle is your
49    /// DID-derived persona (one-name rule), so the typed `handle` arg is a
50    /// vestigial seed with no effect on identity. Kept callable for explicit
51    /// offline keygen (`wire init x --offline`); everyone else uses `wire up`.
52    #[command(hide = true)]
53    Init {
54        /// Vestigial seed — ignored; your handle is your DID-derived persona.
55        handle: String,
56        /// Optional display name (defaults to capitalized handle).
57        #[arg(long)]
58        name: Option<String>,
59        /// Relay URL — binds an inbound slot in the same step. Required
60        /// unless `--offline` is passed. Example:
61        /// `--relay http://127.0.0.1:8771` (local), `--relay https://wireup.net`
62        /// (federation).
63        #[arg(long)]
64        relay: Option<String>,
65        /// v0.9: opt into a slotless session — keypair only, no inbound
66        /// mailbox. You MUST run `wire bind-relay <url>` before any
67        /// pair / send / dial; until then peers cannot reach you.
68        /// Useful for offline keypair generation; rare in practice.
69        #[arg(long, conflicts_with = "relay")]
70        offline: bool,
71        /// Emit JSON.
72        #[arg(long)]
73        json: bool,
74    },
75    // (Old `Join` stub removed in iter 11 — superseded by `pair-join` with
76    // `join` alias. See PairJoin below.)
77    /// Print this agent's identity (DID, fingerprint, mailbox slot).
78    Whoami {
79        #[arg(long)]
80        json: bool,
81        /// Print just `<emoji> <nickname>` (e.g. `🦊 foxtrot-meadow`).
82        /// Plain text, no ANSI escapes. Useful for piping into other tools.
83        #[arg(long, conflicts_with = "json")]
84        short: bool,
85        /// Print `<emoji> <nickname>` wrapped in ANSI 256-color escapes.
86        /// Drop into a Claude Code statusline command for live identity display.
87        #[arg(long, conflicts_with_all = ["json", "short"])]
88        colored: bool,
89    },
90    /// List pinned peers with their tiers and capabilities.
91    Peers {
92        #[arg(long)]
93        json: bool,
94    },
95    /// v0.9.5: emit shell completion script to stdout. Pipe to your
96    /// shell's completion dir to enable tab-completion of wire verbs
97    /// + handles + flags.
98    ///
99    /// Example installs:
100    ///   bash:       `wire completions bash > /etc/bash_completion.d/wire`
101    ///   zsh:        `wire completions zsh > ~/.zsh/completions/_wire`
102    ///   fish:       `wire completions fish > ~/.config/fish/completions/wire.fish`
103    ///   pwsh:       `wire completions powershell > $PROFILE` (append)
104    ///   elvish:     `wire completions elvish > ~/.elvish/lib/wire.elv`
105    Completions {
106        /// Shell to generate completions for.
107        #[arg(value_enum)]
108        shell: clap_complete::Shell,
109    },
110    /// v0.9.3: one-screen "you are here" view. Prints the current
111    /// session's character + handle + cwd, plus a short list of
112    /// neighbors (sister sessions on the local relay, pinned peers).
113    /// Designed for the operator's quick "wait which Claude is this,
114    /// and who's around?" question — no `--json` shuffling, no
115    /// remembering `wire whoami` vs `wire peers` vs `wire session
116    /// list-local`.
117    Here {
118        #[arg(long)]
119        json: bool,
120    },
121    /// v0.9 canonical surface: list pending-inbound pair requests waiting
122    /// for your consent. Aliases the legacy `pair-list-inbound` verb
123    /// but with the shorter, intent-first name. Operators reach for
124    /// "what's pending?" not "what's in my pair-list-inbound table?"
125    Pending {
126        #[arg(long)]
127        json: bool,
128    },
129    /// Sign and queue an event to a peer.
130    ///
131    /// Forms (P0.S 0.5.11):
132    ///   wire send <peer> <body>              # kind defaults to "claim"
133    ///   wire send <peer> <kind> <body>       # explicit kind (back-compat)
134    ///   wire send <peer> -                   # body from stdin (kind=claim)
135    ///   wire send <peer> @/path/to/body.json # body from file
136    Send {
137        /// Peer handle (without `did:wire:` prefix).
138        peer: String,
139        /// When `<body>` is omitted, this is the event body (kind defaults
140        /// to `claim`). When both this and `<body>` are given, this is the
141        /// event kind (`decision`, `claim`, etc., or numeric kind id) and
142        /// the next positional is the body.
143        kind_or_body: String,
144        /// Event body — free-form text, `@/path/to/body.json` to load from
145        /// a file, or `-` to read from stdin. Optional; omit to use
146        /// `<kind_or_body>` as the body with kind=`claim`.
147        body: Option<String>,
148        /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
149        #[arg(long)]
150        deadline: Option<String>,
151        /// v0.10: skip the v0.9 auto-pair-on-miss behavior. Send fails
152        /// loudly if the peer isn't pinned yet. Use when you want strict
153        /// "no implicit dialing" semantics — scripts that error vs.
154        /// performing a side-effecting pair as a fallback.
155        #[arg(long)]
156        no_auto_pair: bool,
157        /// Emit JSON.
158        #[arg(long)]
159        json: bool,
160    },
161    /// v0.8 — "go talk to this name." The one verb operators reach for.
162    ///
163    /// `wire dial <name>` accepts a character nickname (`noble-slate`),
164    /// a session name (`slancha-api`), a card handle, or a DID — whichever
165    /// face you happen to know the peer by. Resolution order:
166    ///
167    /// 1. Already-pinned peer? → no-op (or send if a message was passed).
168    /// 2. Local sister session? → bilateral pair via the disk-read
169    ///    `--local-sister` path (no relay round-trip, no .well-known
170    ///    lookup, no SAS digits).
171    /// 3. Otherwise → bail with a clear hint pointing at federation
172    ///    syntax (`wire dial <handle>@<relay>` for cross-machine peers).
173    ///
174    /// With an optional message, `wire dial <name> "<msg>"` also queues
175    /// and pushes the message after the pair completes. Idempotent: re-
176    /// dialling a known peer just sends.
177    Dial {
178        /// Peer name. Character nickname (preferred), session name,
179        /// card handle, or DID — anything that identifies the peer to
180        /// you.
181        name: String,
182        /// Optional first message to send after the pair lands. Same
183        /// semantics as the body argument to `wire send`. Defaults to
184        /// kind=claim.
185        message: Option<String>,
186        /// Emit JSON.
187        #[arg(long)]
188        json: bool,
189    },
190    /// Stream signed events from peers.
191    Tail {
192        /// Optional peer filter; if omitted, tails all peers.
193        peer: Option<String>,
194        /// Emit JSONL (one event per line).
195        #[arg(long)]
196        json: bool,
197        /// Maximum events to read before exiting (0 = stream until SIGINT).
198        #[arg(long, default_value_t = 0)]
199        limit: usize,
200    },
201    /// Live tail of new inbox events across all pinned peers — one line per
202    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
203    /// by default.
204    ///
205    /// Designed to be left running in an agent harness's stream-watcher
206    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
207    /// session as they arrive, not on next manual `wire pull`.
208    ///
209    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
210    /// template.
211    Monitor {
212        /// Only show events from this peer.
213        #[arg(long)]
214        peer: Option<String>,
215        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
216        #[arg(long)]
217        json: bool,
218        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
219        /// Default filters them out as noise.
220        #[arg(long)]
221        include_handshake: bool,
222        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
223        #[arg(long, default_value_t = 500)]
224        interval_ms: u64,
225        /// Replay last N events from history before going live (0 = none).
226        #[arg(long, default_value_t = 0)]
227        replay: usize,
228    },
229    /// Verify a signed event from a JSON file or stdin (`-`).
230    Verify {
231        /// Path to event JSON, or `-` for stdin.
232        path: String,
233        /// Emit JSON.
234        #[arg(long)]
235        json: bool,
236    },
237    /// Run the MCP (Model Context Protocol) server over stdio.
238    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
239    /// `wire_send`, `wire_tail`, etc. as native tools.
240    Mcp,
241    /// Run a relay server on this host.
242    RelayServer {
243        /// Bind address (e.g. `127.0.0.1:8770`).
244        #[arg(long, default_value = "127.0.0.1:8770")]
245        bind: String,
246        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
247        /// skip `.well-known/wire/agent` serving. The relay becomes
248        /// invisible from outside the box — only same-machine processes
249        /// can pair through it. Right call for within-machine agent
250        /// coordination where you don't want metadata leaking to a
251        /// public relay. Pair this with `wire session new` which probes
252        /// `127.0.0.1:8771` and allocates a local slot automatically.
253        #[arg(long)]
254        local_only: bool,
255        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
256        /// When set, --bind is ignored. Implies --local-only semantics
257        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
258        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
259        /// sister sessions. Unix only (Windows refuses).
260        #[arg(long)]
261        uds: Option<std::path::PathBuf>,
262    },
263    /// Allocate a slot on a relay; bind it to this agent's identity.
264    ///
265    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
266    /// current slot, this command refuses by default — silent migration
267    /// silently black-holes their inbound messages. Pass
268    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
269    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
270    /// for safe rotation.
271    BindRelay {
272        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
273        url: String,
274        /// Endpoint scope: `federation` | `local` | `lan` | `uds`.
275        /// Default inferred from the URL (loopback host -> local,
276        /// `unix://` -> uds, otherwise federation). Pass explicitly when
277        /// the inference is ambiguous (e.g. a federation relay on a
278        /// loopback address in tests).
279        #[arg(long)]
280        scope: Option<String>,
281        /// DESTRUCTIVE: drop all existing self slots and bind only this
282        /// relay (the pre-v0.12 single-slot behavior). Default is
283        /// ADDITIVE — the new slot is appended to `self.endpoints[]`,
284        /// keeping any existing slots so pinned peers are not
285        /// black-holed.
286        #[arg(long)]
287        replace: bool,
288        /// Acknowledge that pinned peers will black-hole until they
289        /// re-pin manually. Required for `--replace` (and same-relay
290        /// rotation) when `state.peers` is non-empty; ignored on fresh
291        /// boxes. Use `wire rotate-slot` instead for the supported
292        /// same-relay rotation path.
293        #[arg(long)]
294        migrate_pinned: bool,
295        #[arg(long)]
296        json: bool,
297    },
298    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
299    /// real `wire join` lands in the SPAKE2 iter.)
300    AddPeerSlot {
301        /// Peer handle (becomes did:wire:<handle>).
302        handle: String,
303        /// Peer's relay base URL.
304        url: String,
305        /// Peer's slot id.
306        slot_id: String,
307        /// Slot bearer token (shared between paired peers in v0.1).
308        slot_token: String,
309        #[arg(long)]
310        json: bool,
311    },
312    /// Drain outbox JSONL files to peers' relay slots.
313    Push {
314        /// Optional peer filter; default = all peers with outbox entries.
315        peer: Option<String>,
316        #[arg(long)]
317        json: bool,
318    },
319    /// Pull events from our relay slot, verify, write to inbox.
320    Pull {
321        #[arg(long)]
322        json: bool,
323    },
324    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
325    /// Useful as a single "where am I" check.
326    Status {
327        /// Inspect a paired peer's transport / attention / responder health.
328        #[arg(long)]
329        peer: Option<String>,
330        #[arg(long)]
331        json: bool,
332    },
333    /// Publish or inspect auto-responder health for this slot.
334    Responder {
335        #[command(subcommand)]
336        command: ResponderCommand,
337    },
338    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
339    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
340    Pin {
341        /// Path to peer's signed agent-card JSON.
342        card_file: String,
343        #[arg(long)]
344        json: bool,
345    },
346    /// Allocate a NEW slot on the same relay and abandon the old one.
347    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
348    /// slot announcing the new mailbox before swapping. After rotation,
349    /// peers must re-pair (or operator runs `add-peer-slot` with the new
350    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
351    ///
352    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
353    /// abusive bearer-holder spamming your slot). Rotate → old slot is
354    /// orphaned → attacker's leverage gone. Operator pairs again with
355    /// peers they still want.
356    RotateSlot {
357        /// Skip the wire_close announcement to peers (faster but they won't know
358        /// where you went).
359        #[arg(long)]
360        no_announce: bool,
361        #[arg(long)]
362        json: bool,
363    },
364    /// Remove a peer from trust + relay state. Inbox/outbox files for that
365    /// peer are NOT deleted (operator can grep history); pass --purge to
366    /// also wipe the JSONL files.
367    ForgetPeer {
368        /// Peer handle to forget.
369        handle: String,
370        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
371        #[arg(long)]
372        purge: bool,
373        #[arg(long)]
374        json: bool,
375    },
376    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
377    /// peers' relay slots and pull inbox from our own slot. Foreground process;
378    /// background it with systemd / `&` / tmux as you prefer.
379    Daemon {
380        /// Sync interval in seconds. Default 5.
381        #[arg(long, default_value_t = 5)]
382        interval: u64,
383        /// Run a single sync cycle and exit (useful for cron-driven setups).
384        #[arg(long)]
385        once: bool,
386        #[arg(long)]
387        json: bool,
388    },
389    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
390    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
391    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
392    /// read the SAS digits aloud and confirm.)
393    #[command(hide = true)] // v0.9 deprecated
394    PairHost {
395        /// Relay base URL.
396        #[arg(long)]
397        relay: String,
398        /// Skip the SAS confirmation prompt. ONLY use when piping under
399        /// automated tests or when the SAS has already been verified by
400        /// another channel. Documented as test-only.
401        #[arg(long)]
402        yes: bool,
403        /// How long (seconds) to wait for the peer to join before timing out.
404        #[arg(long, default_value_t = 300)]
405        timeout: u64,
406        /// Detach: write a pending-pair file, print the code phrase, and exit
407        /// immediately. The running `wire daemon` does the handshake in the
408        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
409        /// `wire pair-list` shows pending sessions. Default is foreground
410        /// blocking behavior for backward compat.
411        #[arg(long)]
412        detach: bool,
413        /// Emit JSON instead of text. Currently only meaningful with --detach.
414        #[arg(long)]
415        json: bool,
416    },
417    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
418    ///
419    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
420    #[command(alias = "join")]
421    #[command(hide = true)] // v0.9 deprecated
422    PairJoin {
423        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
424        code_phrase: String,
425        /// Relay base URL (must match the host's relay).
426        #[arg(long)]
427        relay: String,
428        #[arg(long)]
429        yes: bool,
430        #[arg(long, default_value_t = 300)]
431        timeout: u64,
432        /// Detach: see `pair-host --detach`.
433        #[arg(long)]
434        detach: bool,
435        /// Emit JSON instead of text. Currently only meaningful with --detach.
436        #[arg(long)]
437        json: bool,
438    },
439    /// Confirm SAS digits for a detached pending pair. The daemon must be
440    /// running for this to do anything — it picks up the confirmation on its
441    /// next tick. Mismatch aborts the pair.
442    #[command(hide = true)] // v0.9 deprecated
443    PairConfirm {
444        /// The code phrase the original `wire pair-host --detach` printed.
445        code_phrase: String,
446        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
447        digits: String,
448        /// Emit JSON instead of human-readable text.
449        #[arg(long)]
450        json: bool,
451    },
452    /// List all pending detached pair sessions and their state.
453    #[command(hide = true)] // v0.9 deprecated
454    PairList {
455        /// Emit JSON instead of the table.
456        #[arg(long)]
457        json: bool,
458        /// Stream mode: never exit; print one JSON line per status transition
459        /// (creation, status change, deletion) across all pending pairs.
460        /// Compose with bash `while read` to react in shell. Implies --json.
461        #[arg(long)]
462        watch: bool,
463        /// Poll interval in seconds for --watch.
464        #[arg(long, default_value_t = 1)]
465        watch_interval: u64,
466    },
467    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
468    #[command(hide = true)] // v0.9 deprecated
469    PairCancel {
470        code_phrase: String,
471        #[arg(long)]
472        json: bool,
473    },
474    /// Block until a pending pair reaches a target status (default sas_ready),
475    /// or terminates (finalized = file removed, aborted, aborted_restart), or
476    /// the timeout expires. Useful for shell scripts that want to drive the
477    /// detached flow without polling pair-list themselves.
478    ///
479    /// Exit codes:
480    ///   0 — reached target status (or finalized, if target was sas_ready)
481    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
482    ///   2 — timeout
483    #[command(hide = true)] // v0.9 deprecated
484    PairWatch {
485        code_phrase: String,
486        /// Target status to wait for. Default: sas_ready.
487        #[arg(long, default_value = "sas_ready")]
488        status: String,
489        /// Max seconds to wait.
490        #[arg(long, default_value_t = 300)]
491        timeout: u64,
492        /// Emit JSON on each status change (one per line) instead of just on exit.
493        #[arg(long)]
494        json: bool,
495    },
496    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
497    /// pair-join, then registers wire as an MCP server. Single command from
498    /// nothing to paired and ready — no separate init/pair-host/setup steps.
499    /// Operator still must confirm SAS digits.
500    ///
501    /// Examples:
502    ///   wire pair paul                          # host a new pair on default relay
503    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
504    ///
505    /// v0.10: hidden from --help. Federation pair flow is now
506    /// `wire dial <handle>@<relay>` + `wire accept-invite <URL>`.
507    /// `wire pair` stays callable for back-compat scripts; v1.0 removes.
508    #[command(hide = true)] // v0.10 deprecated — use `wire dial <h>@<relay>`
509    Pair {
510        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
511        /// step if no identity exists; ignored if already initialized.
512        handle: String,
513        /// Code phrase from peer's pair-host output. Omit to be the host
514        /// (this command will print one for you to share).
515        #[arg(long)]
516        code: Option<String>,
517        /// Relay base URL. Defaults to the laulpogan public-good relay.
518        #[arg(long, default_value = "https://wireup.net")]
519        relay: String,
520        /// Skip SAS prompt. Test-only.
521        #[arg(long)]
522        yes: bool,
523        /// Pair-step timeout in seconds.
524        #[arg(long, default_value_t = 300)]
525        timeout: u64,
526        /// Skip the post-pair `setup --apply` step (don't register wire as
527        /// an MCP server in detected client configs).
528        #[arg(long)]
529        no_setup: bool,
530        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
531        /// exits immediately, daemon does the handshake). Confirm via
532        /// `wire pair-confirm <code> <digits>` from any terminal. See
533        /// `pair-host --detach` for details.
534        #[arg(long)]
535        detach: bool,
536    },
537    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
538    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
539    /// confirmation, leaving the relay-side slot stuck with "guest already
540    /// registered" or "host already registered" until the 5-minute TTL expires.
541    /// Either side can call. Idempotent.
542    #[command(hide = true)] // v0.9 deprecated
543    PairAbandon {
544        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
545        code_phrase: String,
546        /// Relay base URL.
547        #[arg(long, default_value = "https://wireup.net")]
548        relay: String,
549    },
550    /// Accept a pending-inbound pair request (v0.5.14). Explicit alias for
551    /// the bilateral-completion path that `wire add <peer>@<relay>` also
552    /// drives — but doesn't require remembering the peer's relay domain
553    /// (the relay coords come from the stored pair_drop). Errors if no
554    /// pending-inbound record exists for that peer.
555    #[command(hide = true)] // v0.9 deprecated
556    PairAccept {
557        /// Bare peer handle (without `@<relay>`).
558        peer: String,
559        /// Emit JSON.
560        #[arg(long)]
561        json: bool,
562    },
563    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
564    /// you@<your-relay>` against your handle, their signed pair_drop lands
565    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
566    /// <peer>` to delete the record without pairing. The peer never receives
567    /// our slot_token; from their side the pair stays pending until they
568    /// time out.
569    #[command(hide = true)] // v0.9 deprecated
570    PairReject {
571        /// Bare peer handle (without `@<relay>`).
572        peer: String,
573        /// Emit JSON.
574        #[arg(long)]
575        json: bool,
576    },
577    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
578    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
579    /// `pair-list --json` shape but for inbound). Use this in scripts that
580    /// need to enumerate inbound pair requests without parsing the SPAKE2
581    /// table format from `wire pair-list`.
582    #[command(hide = true)] // v0.9 deprecated
583    PairListInbound {
584        /// Emit JSON.
585        #[arg(long)]
586        json: bool,
587    },
588    /// Manage isolated wire sessions on this machine (v0.5.16).
589    ///
590    /// Each session = its own DID + handle + relay slot + daemon + inbox/
591    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
592    /// in different projects) run on the same machine — without sessions
593    /// they all share one identity and race the inbox cursor.
594    ///
595    /// Names are derived from `basename(cwd)` and cached in a registry,
596    /// so re-entering the same project reuses the same identity.
597    #[command(subcommand)]
598    Session(SessionCommand),
599    /// Manage this session's identity display layer (character override).
600    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
601    /// itself picks a custom nickname + emoji that overrides the
602    /// auto-derived hash-based defaults.
603    Identity {
604        #[command(subcommand)]
605        cmd: IdentityCommand,
606    },
607    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
608    /// sister-session mesh. `wire mesh status` is the live view of every
609    /// paired sister (alias for `wire session mesh-status`); `wire mesh
610    /// broadcast` fans one signed event to every pinned peer.
611    #[command(subcommand)]
612    Mesh(MeshCommand),
613    /// Group chat (v0.13.3): create a named group, add VERIFIED peers, and
614    /// send/tail messages across the whole member set. Membership is a signed
615    /// roster (group-scoped tiers, separate from bilateral peer trust).
616    #[command(subcommand)]
617    Group(GroupCommand),
618    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
619    /// Cursor, project-local) and either print or auto-merge the wire MCP
620    /// server entry. Default prints; pass `--apply` to actually modify config
621    /// files. Idempotent — re-running is safe.
622    Setup {
623        /// Actually write the changes (default = print only).
624        #[arg(long)]
625        apply: bool,
626        /// Install a Claude Code statusLine showing your wire persona
627        /// (liveness dot + emoji + nickname in the persona's accent color +
628        /// cwd) instead of merging the MCP server. Writes a renderer script
629        /// and merges a `statusLine` block into Claude Code's settings.json
630        /// (honors $CLAUDE_CONFIG_DIR). Combine with --apply to write.
631        #[arg(long)]
632        statusline: bool,
633        /// With --statusline: uninstall it (drop the statusLine key + remove
634        /// the renderer script) instead of installing.
635        #[arg(long)]
636        remove: bool,
637    },
638    /// Show an agent's profile. With no arg, prints local self. With a
639    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
640    /// endpoint and verifies the returned signed card before display.
641    Whois {
642        /// Optional handle (`nick@domain`). Omit to show self.
643        handle: Option<String>,
644        #[arg(long)]
645        json: bool,
646        /// Override the relay base URL used for resolution (default:
647        /// `https://<domain>` from the handle).
648        #[arg(long)]
649        relay: Option<String>,
650    },
651    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
652    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
653    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
654    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
655    /// their slot_token so we can `wire send` to them).
656    Add {
657        /// Peer handle (`nick@domain`), OR a bare sister-session name
658        /// when `--local-sister` is set.
659        handle: String,
660        /// Override the relay base URL used for resolution.
661        #[arg(long)]
662        relay: Option<String>,
663        /// v0.6.6: pair with a sister session on this machine without
664        /// touching federation. Looks up `handle` as a session name in
665        /// `wire session list`, reads that session's agent-card +
666        /// endpoints from disk, pins directly, then delivers the
667        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
668        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
669        /// addressable because they don't need a federation claim.
670        #[arg(long)]
671        local_sister: bool,
672        #[arg(long)]
673        json: bool,
674    },
675    /// Come online in one command — `wire up` does what used to take five
676    /// (init + bind-relay + claim your persona + background daemon +
677    /// restart-on-login). Idempotent: re-run on an already-set-up box prints
678    /// state without churn.
679    ///
680    /// There is no name to choose: your handle IS your DID-derived persona
681    /// (one-name rule). The optional argument is just which relay to use.
682    ///
683    /// Examples:
684    ///   wire up                        # default public relay (wireup.net)
685    ///   wire up @wireup.net            # explicit federation relay
686    ///   wire up http://127.0.0.1:8771  # a local / self-hosted relay
687    Up {
688        /// Relay to bind + claim your persona on: `@wireup.net`, `wireup.net`,
689        /// or a full URL. Omit for the default public relay. No nick — your
690        /// handle is your DID-derived persona.
691        relay: Option<String>,
692        /// Optional display name for your profile card (cosmetic; distinct
693        /// from your addressable handle/persona).
694        #[arg(long)]
695        name: Option<String>,
696        /// Also additively dual-bind a LOCAL relay slot for fast same-box
697        /// sister-session routing. Defaults to probing
698        /// `http://127.0.0.1:8771`; pass a URL to override. Local relays
699        /// carry no handle directory, so nothing is claimed there.
700        #[arg(long)]
701        with_local: Option<String>,
702        /// Skip the opportunistic local dual-bind entirely.
703        #[arg(long)]
704        no_local: bool,
705        #[arg(long)]
706        json: bool,
707    },
708    /// Diagnose wire setup health. Single command that surfaces every
709    /// silent-fail class — daemon down or duplicated, relay unreachable,
710    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
711    /// Replaces today's 30-minute manual debug.
712    ///
713    /// Exit code non-zero if any FAIL findings.
714    Doctor {
715        /// Emit JSON.
716        #[arg(long)]
717        json: bool,
718        /// Show last N entries from pair-rejected.jsonl in the report.
719        #[arg(long, default_value_t = 5)]
720        recent_rejections: usize,
721    },
722    /// Update + restart in one step (alias: `wire update`). ALWAYS checks
723    /// crates.io for a newer published wire; if one exists it installs it
724    /// (via `cargo install slancha-wire` when a Rust toolchain is on PATH,
725    /// else by downloading + SHA-256-verifying the prebuilt release binary
726    /// and replacing this one in place), then does the atomic daemon swap —
727    /// kill every `wire daemon`, respawn from the (now-current) binary, write
728    /// a fresh pidfile. No newer version → it skips the install and just
729    /// restarts the daemon. `--check` reports what would happen (available
730    /// update + processes that would be restarted) without doing it;
731    /// `--local` skips the crates.io check and only restarts the daemon
732    /// (offline, or running a local dev build).
733    #[command(visible_alias = "update")]
734    Upgrade {
735        /// Report current vs latest + drift without taking action.
736        #[arg(long)]
737        check: bool,
738        /// Skip the crates.io update check; just restart the daemon from the
739        /// current binary (offline / local dev build).
740        #[arg(long)]
741        local: bool,
742        #[arg(long)]
743        json: bool,
744    },
745    /// Install / inspect / remove a launchd plist (macOS) or systemd
746    /// user unit (linux) that runs `wire daemon` on login + restarts
747    /// on crash. Replaces today's "background it with tmux/&/systemd
748    /// as you prefer" footgun.
749    Service {
750        #[command(subcommand)]
751        action: ServiceAction,
752    },
753    /// Inspect or toggle the structured diagnostic trace
754    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
755    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
756    /// (writes the file knob a running daemon picks up automatically).
757    Diag {
758        #[command(subcommand)]
759        action: DiagAction,
760    },
761    /// Claim your persona on a relay's handle directory. Anyone can then
762    /// reach this agent by `<persona>@<relay-domain>` via the relay's
763    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
764    ///
765    /// ONE-NAME RULE (v0.13.1): the claimed handle is always your DID-derived
766    /// persona. The `nick` arg is vestigial — if it differs it is ignored
767    /// (like the typed name `wire init` / `wire up` already ignore), so your
768    /// phonebook entry can never drift from your agent-card handle.
769    ///
770    /// v0.13.1: hidden — `wire up` claims your persona for you. Kept callable
771    /// (idempotent re-claim) but not a user verb; there is no nick to choose.
772    #[command(hide = true)]
773    Claim {
774        /// Vestigial: ignored if it differs from your DID-derived persona.
775        nick: String,
776        /// Relay to claim the nick on. Default = relay our slot is on.
777        #[arg(long)]
778        relay: Option<String>,
779        /// Public URL the relay should advertise to resolvers (default = relay).
780        #[arg(long)]
781        public_url: Option<String>,
782        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
783        /// directory listing. The handle stays claimed (FCFS still
784        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
785        /// still resolves, so peers you share the handle with out-of-band
786        /// can still pair. Bulk scrapers / phonebook crawlers will not
787        /// see the nick. Use this for handles meant for known-peer
788        /// pairing only — see issue #9.
789        #[arg(long)]
790        hidden: bool,
791        #[arg(long)]
792        json: bool,
793    },
794    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
795    /// avatar_url, handle, now). Re-signs the agent-card atomically.
796    ///
797    /// Examples:
798    ///   wire profile set motto "compiles or dies trying"
799    ///   wire profile set emoji "🦀"
800    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
801    ///   wire profile set handle "coffee-ghost@anthropic.dev"
802    ///   wire profile get
803    Profile {
804        #[command(subcommand)]
805        action: ProfileAction,
806    },
807    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
808    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
809    /// a relay slot on first use. Default TTL 24h, single-use.
810    #[command(hide = true)] // v0.9 deprecated
811    Invite {
812        /// Override the relay URL for first-time auto-allocation.
813        #[arg(long, default_value = "https://wireup.net")]
814        relay: String,
815        /// Invite lifetime in seconds (default 86400 = 24h).
816        #[arg(long, default_value_t = 86_400)]
817        ttl: u64,
818        /// Number of distinct peers that can accept this invite before it's
819        /// consumed (default 1).
820        #[arg(long, default_value_t = 1)]
821        uses: u32,
822        /// Register the invite at the relay's short-URL endpoint and print
823        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
824        /// Installs wire if missing, then accepts the invite, then pairs.
825        #[arg(long)]
826        share: bool,
827        /// Emit JSON.
828        #[arg(long)]
829        json: bool,
830    },
831    /// v0.9: accept a pending-inbound pair request by character
832    /// nickname or card handle. Replaces the verbose `wire pair-accept
833    /// <peer>`.
834    ///
835    /// v0.9.4: the URL-vs-name smart-dispatch from v0.9 is gone. To
836    /// accept a federation invite URL use `wire accept-invite <URL>`
837    /// (split out as an explicit verb to eliminate the input-shape
838    /// ambiguity). `wire accept <URL>` still works for back-compat
839    /// but emits a deprecation banner pointing at `accept-invite`.
840    Accept {
841        /// Pending peer name (character nickname or card handle).
842        target: String,
843        /// Emit JSON.
844        #[arg(long)]
845        json: bool,
846    },
847    /// v0.9.4: accept a federation invite URL minted by `wire invite`.
848    /// Pins issuer, sends signed card to issuer's slot. Auto-inits +
849    /// auto-allocates as needed.
850    ///
851    /// Split out from `wire accept` to eliminate the URL-vs-name
852    /// smart-dispatch ambiguity (peer handles can legitimately collide
853    /// with URL-shaped strings; the explicit verb removes the inference).
854    #[command(alias = "invite-accept")]
855    AcceptInvite {
856        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
857        url: String,
858        /// Emit JSON.
859        #[arg(long)]
860        json: bool,
861    },
862    /// v0.9: refuse a pending-inbound pair request without pairing. Aliases
863    /// the legacy `wire pair-reject <peer>`.
864    Reject {
865        /// Peer name (character nickname or handle) from `wire pending`.
866        peer: String,
867        /// Emit JSON.
868        #[arg(long)]
869        json: bool,
870    },
871    /// Watch the inbox for new verified events and fire an OS notification per
872    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
873    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
874    /// re-emit history.
875    Notify {
876        /// Poll interval in seconds.
877        #[arg(long, default_value_t = 2)]
878        interval: u64,
879        /// Only notify for events from this peer (handle, no did: prefix).
880        #[arg(long)]
881        peer: Option<String>,
882        /// Run a single sweep and exit (useful for cron / tests).
883        #[arg(long)]
884        once: bool,
885        /// Suppress the OS notification call; print one JSON line per event to
886        /// stdout instead (for piping into other tooling or smoke-testing
887        /// without a desktop session).
888        #[arg(long)]
889        json: bool,
890    },
891}
892
893#[derive(Subcommand, Debug)]
894pub enum DiagAction {
895    /// Tail the last N entries from diag.jsonl.
896    Tail {
897        #[arg(long, default_value_t = 20)]
898        limit: usize,
899        #[arg(long)]
900        json: bool,
901    },
902    /// Flip the file-based knob ON. Running daemons pick this up on
903    /// the next emit call without restart.
904    Enable,
905    /// Flip the file-based knob OFF.
906    Disable,
907    /// Report whether diag is currently enabled + the file's size.
908    Status {
909        #[arg(long)]
910        json: bool,
911    },
912}
913
914#[derive(Subcommand, Debug)]
915pub enum IdentityCommand {
916    /// Print the current character (DID-derived, the only name).
917    /// Equivalent to `wire whoami --short` but scoped here for grouping.
918    Show {
919        #[arg(long)]
920        json: bool,
921    },
922    /// List all identities on this machine — one row per session, with
923    /// each session's character, DID, federation handle, and cwd. Same
924    /// shape as `wire session list`, scoped here for the v0.7+ noun-
925    /// CLI surface.
926    List {
927        #[arg(long)]
928        json: bool,
929    },
930    /// Promote this identity to FEDERATION lifecycle: claim your persona on
931    /// the relay so peers can `wire dial <persona>@<relay-domain>` you.
932    /// Re-claims with current display fields so the relay always serves the
933    /// latest signed card. Equivalent to `wire claim`.
934    ///
935    /// v0.13.1: hidden — `wire up` publishes your persona for you, and the
936    /// nick is vestigial (one-name rule). Kept callable for re-publish.
937    #[command(hide = true)]
938    Publish {
939        /// Vestigial: ignored; your handle is your DID-derived persona.
940        nick: String,
941        /// Override the relay URL. Defaults to the session's bound relay
942        /// from `wire init --relay <url>`. Public relay if unset.
943        #[arg(long)]
944        relay: Option<String>,
945        /// Public-facing URL for the agent-card location (when the relay
946        /// is behind a CDN with a different public domain).
947        #[arg(long, alias = "public")]
948        public_url: Option<String>,
949        /// Skip listing in the relay's public phonebook. The card is
950        /// still claimable + reachable; just doesn't appear in
951        /// `wireup.net/phonebook` for stranger-discovery.
952        #[arg(long)]
953        hidden: bool,
954        #[arg(long)]
955        json: bool,
956    },
957    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
958    /// Equivalent to `wire session destroy <name>`, scoped here for the
959    /// noun-CLI surface. Requires `--force` (the underlying command does).
960    Destroy {
961        /// Session name to destroy (use `wire identity list` to see).
962        name: String,
963        /// Bypass the confirmation prompt.
964        #[arg(long)]
965        force: bool,
966        #[arg(long)]
967        json: bool,
968    },
969    /// Create an identity in an EXPLICIT lifecycle state (vs. the
970    /// implicit `wire init` + `wire claim` flow).
971    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
972    ///
973    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
974    /// next reboot). In-memory semantics not yet supported — the
975    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
976    /// For pure-RAM identities, see v1.0 vision.
977    ///
978    /// `--local` is the explicit form of today's default; identity
979    /// persists to the machine-wide sessions root.
980    Create {
981        /// Session name. Defaults to derived from cwd (anonymous mode
982        /// uses a random name).
983        #[arg(long)]
984        name: Option<String>,
985        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
986        /// reboot, no federation). Mutually exclusive with --local.
987        #[arg(long, conflicts_with = "local")]
988        anonymous: bool,
989        /// Create a LOCAL identity (machine-persistent, no federation).
990        /// Default — explicit flag for clarity.
991        #[arg(long)]
992        local: bool,
993        #[arg(long)]
994        json: bool,
995    },
996    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
997    /// the machine-wide sessions root + register in the cwd map.
998    /// After persist, the identity survives reboot.
999    /// v0.7.0-alpha.20.
1000    Persist {
1001        /// The anonymous identity's name (from `wire identity list`).
1002        name: String,
1003        /// Optional rename during persist. Default: keep the anon name.
1004        #[arg(long = "as", value_name = "NEW_NAME")]
1005        as_name: Option<String>,
1006        #[arg(long)]
1007        json: bool,
1008    },
1009    /// Demote an identity ONE level in the lifecycle:
1010    ///   federation → local: removes the relay slot binding but keeps
1011    ///   the keypair + agent-card. Operator can later re-publish with
1012    ///   `wire identity publish`. v0.7.0-alpha.20.
1013    ///
1014    /// (local → anonymous is not exposed; the safer flow is destroy +
1015    /// recreate, since "demoting" a persistent identity to ephemeral
1016    /// has surprising semantics — what about the keypair? what about
1017    /// pinned peers? Better to be explicit with destroy.)
1018    Demote {
1019        /// Session name to demote.
1020        name: String,
1021        #[arg(long)]
1022        json: bool,
1023    },
1024}
1025
1026#[derive(Subcommand, Debug)]
1027pub enum SessionCommand {
1028    /// Bootstrap a new isolated session in this machine's sessions root.
1029    /// With no name, derives one from `basename(cwd)` and caches it in
1030    /// the registry so re-running from the same project reuses it.
1031    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1032    /// the new session's WIRE_HOME. Output includes the `export
1033    /// WIRE_HOME=...` line operators paste into their shell to activate
1034    /// it.
1035    New {
1036        /// Optional session name. Default = derived from `basename(cwd)`.
1037        name: Option<String>,
1038        /// Relay URL for the session's slot allocation + handle claim.
1039        #[arg(long, default_value = "https://wireup.net")]
1040        relay: String,
1041        /// v0.5.17: also allocate a second slot on a same-machine local
1042        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1043        /// sister-session traffic prefers this path: zero round-trip
1044        /// latency, zero metadata exposure to the public relay. Probes
1045        /// `<local-relay>/healthz` first; silently skips if the local
1046        /// relay isn't running.
1047        #[arg(long)]
1048        with_local: bool,
1049        /// v0.5.17: override the local relay URL probed by `--with-local`.
1050        /// Default is `http://127.0.0.1:8771` to match
1051        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1052        #[arg(long, default_value = "http://127.0.0.1:8771")]
1053        local_relay: String,
1054        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1055        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1056        /// Lets other machines on the same network reach this session
1057        /// directly without round-tripping the public federation relay
1058        /// at https://wireup.net. LAN endpoint is published in the
1059        /// agent-card; opt-in per session (default off).
1060        #[arg(long)]
1061        with_lan: bool,
1062        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1063        /// LAN IP — operator must type the address). Example:
1064        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1065        #[arg(long)]
1066        lan_relay: Option<String>,
1067        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1068        /// relay (must be running e.g. via `wire relay-server --uds
1069        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1070        /// bypasses the macOS firewall + Tailscale userspace-netstack
1071        /// class of issues entirely for sister-session traffic. UDS
1072        /// endpoint is published in the agent-card.
1073        #[arg(long)]
1074        with_uds: bool,
1075        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1076        /// is set. Example: `/tmp/wire.sock` or
1077        /// `~/.wire/local.sock`.
1078        #[arg(long)]
1079        uds_socket: Option<std::path::PathBuf>,
1080        /// Skip spawning the session-local daemon. Use when you want
1081        /// to drive sync explicitly from the agent or test rig.
1082        #[arg(long)]
1083        no_daemon: bool,
1084        /// v0.6.6: create a federation-free session — no nick claim on
1085        /// `--relay`, no federation slot allocation. Implies
1086        /// `--with-local`. The session exists only to coordinate with
1087        /// other sister sessions on this machine; it has no public
1088        /// address and cannot be reached from outside. Reserved nicks
1089        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1090        /// to publish them.
1091        #[arg(long)]
1092        local_only: bool,
1093        /// Emit JSON.
1094        #[arg(long)]
1095        json: bool,
1096    },
1097    /// List all sessions on this machine with their handle, DID,
1098    /// daemon liveness, and the cwd they're associated with.
1099    List {
1100        #[arg(long)]
1101        json: bool,
1102    },
1103    /// List sister sessions reachable via a same-machine local relay
1104    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1105    /// share. Sessions without a Local-scope endpoint are listed
1106    /// separately so the operator can tell which are federation-only.
1107    /// Read-only — does not probe any relay or touch daemons.
1108    ListLocal {
1109        #[arg(long)]
1110        json: bool,
1111    },
1112    /// v0.6.0 (issue #12): mesh-pair every sister session against every
1113    /// other in O(N²) handshakes. For each unordered pair (A, B) that
1114    /// is not already paired, drives the bilateral flow end-to-end:
1115    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
1116    /// B's side, then a final pull on A so the ack lands. Idempotent —
1117    /// re-running skips pairs already in `state.peers`.
1118    ///
1119    /// **Trust anchor:** the operator running this command owns every
1120    /// session listed in `wire session list-local` (they all live under
1121    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1122    /// That filesystem-permission boundary IS the consent for both
1123    /// sides — the bilateral SAS / network-level handshake assumes
1124    /// strangers; same-uid sister sessions are by definition not
1125    /// strangers. Cross-uid sister sessions are out of scope; today
1126    /// `wire session list-local` only enumerates this user's sessions.
1127    PairAllLocal {
1128        /// Seconds to wait between handshake stages for pair_drop /
1129        /// pair_drop_ack to propagate over the relay. Default 1s
1130        /// (local-relay is typically <100ms RTT). Bump if you see
1131        /// "pending-inbound never arrived" errors on a slow relay.
1132        #[arg(long, default_value_t = 1)]
1133        settle_secs: u64,
1134        /// Federation relay to bind each `wire add` against. Default
1135        /// `https://wireup.net`. Sister sessions should be bound to
1136        /// the same federation relay; the pair handshake routes through
1137        /// it for the .well-known resolution + pair_drop deposit.
1138        #[arg(long, default_value = "https://wireup.net")]
1139        federation_relay: String,
1140        #[arg(long)]
1141        json: bool,
1142    },
1143    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1144    /// machine. Enumerates every session in `wire session list-local`,
1145    /// walks each session's `relay.json#peers` to find which other sister
1146    /// sessions it has pinned, and probes the local relay for each edge's
1147    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1148    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1149    /// local_relay, summary}` so scripts can scrape.
1150    ///
1151    /// Read-only — does NOT touch peers or daemons, only the relay's
1152    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1153    /// already hold. Silent on any probe failure (degrades to "no
1154    /// signal" rather than abort) so a half-broken mesh is still
1155    /// inspectable.
1156    MeshStatus {
1157        /// Threshold in seconds for "stale" classification on an edge.
1158        /// An edge whose receiver hasn't polled their slot in this long
1159        /// is flagged. Default 300s (5 min) — same as the per-send
1160        /// `phyllis` attentiveness nag.
1161        #[arg(long, default_value_t = 300)]
1162        stale_secs: u64,
1163        #[arg(long)]
1164        json: bool,
1165    },
1166    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1167    /// can `eval $(wire session env <name>)` to activate it. With no
1168    /// name, resolves the cwd through the registry.
1169    Env {
1170        /// Session name. Default = derived from cwd via the registry.
1171        name: Option<String>,
1172        #[arg(long)]
1173        json: bool,
1174    },
1175    /// Identify which session the current cwd maps to in the registry.
1176    /// Prints `(none)` if cwd isn't registered — `wire session new`
1177    /// would create one.
1178    Current {
1179        #[arg(long)]
1180        json: bool,
1181    },
1182    /// Attach an existing session to the current cwd in the registry,
1183    /// so subsequent auto-detect from this cwd resolves to that session
1184    /// instead of walking up to an ancestor's binding. Use when an
1185    /// ancestor dir (e.g. `~/Source`) is already registered and is
1186    /// shadowing per-project identities for cwds beneath it. Idempotent;
1187    /// re-binding to the same name is a no-op. Re-binding to a different
1188    /// name overwrites the prior entry with a stderr warning.
1189    Bind {
1190        /// Session name to bind. Must already exist (run `wire session
1191        /// new <name>` first if not). With no name, auto-derives from
1192        /// `basename(cwd)` and errors if no session of that name exists.
1193        name: Option<String>,
1194        #[arg(long)]
1195        json: bool,
1196    },
1197    /// Tear down a session: kills its daemon (if running), deletes its
1198    /// state directory, and removes it from the registry. Requires
1199    /// `--force` because state loss is unrecoverable (keypair gone).
1200    Destroy {
1201        name: String,
1202        /// Confirm state-deleting operation.
1203        #[arg(long)]
1204        force: bool,
1205        #[arg(long)]
1206        json: bool,
1207    },
1208}
1209
1210/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1211/// session's view of the pinned peer set. `status` is the read-only
1212/// observability primitive (alias for `wire session mesh-status`);
1213/// Group-chat verbs (v0.13.3). Membership is a creator-signed roster
1214/// (`src/group.rs`); send fans a signed message over the member set.
1215#[derive(Subcommand, Debug)]
1216pub enum GroupCommand {
1217    /// Create a new group — you become the creator + sole member, roster signed.
1218    Create {
1219        /// Group name (human label).
1220        name: String,
1221        #[arg(long)]
1222        json: bool,
1223    },
1224    /// Add a bilaterally-VERIFIED pinned peer to a group you created (Member tier).
1225    Add {
1226        /// Group id or name.
1227        group: String,
1228        /// Peer handle (must be a VERIFIED pinned peer).
1229        peer: String,
1230        #[arg(long)]
1231        json: bool,
1232    },
1233    /// Send a message to every other member of a group (signed fan-out).
1234    Send {
1235        /// Group id or name.
1236        group: String,
1237        /// Message text.
1238        message: String,
1239        #[arg(long)]
1240        json: bool,
1241    },
1242    /// Show recent messages received for a group.
1243    Tail {
1244        /// Group id or name.
1245        group: String,
1246        /// Max messages to show.
1247        #[arg(long, default_value_t = 20)]
1248        limit: usize,
1249        #[arg(long)]
1250        json: bool,
1251    },
1252    /// List your groups + their members and tiers.
1253    List {
1254        #[arg(long)]
1255        json: bool,
1256    },
1257    /// Mint a shareable join code for a group (a self-contained token carrying
1258    /// the room coords + signed roster). Anyone you give it to can `wire group
1259    /// join <code>` to enter the room at Introduced tier. The code IS the room
1260    /// key — share it only with people you want in the room.
1261    Invite {
1262        /// Group id or name.
1263        group: String,
1264        #[arg(long)]
1265        json: bool,
1266    },
1267    /// Join a group from a code minted by `wire group invite`. Materializes the
1268    /// room locally, pins the existing members on the creator's vouch, and
1269    /// announces you to the room so members can verify your messages.
1270    Join {
1271        /// The `wire-group:` code (or bare base64 payload).
1272        code: String,
1273        #[arg(long)]
1274        json: bool,
1275    },
1276}
1277
1278/// `broadcast` fans a signed event to every pinned peer in one call.
1279#[derive(Subcommand, Debug)]
1280pub enum MeshCommand {
1281    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1282    /// per-edge health roll-up across every sister session on this machine.
1283    Status {
1284        /// Threshold in seconds for "stale" classification on an edge.
1285        #[arg(long, default_value_t = 300)]
1286        stale_secs: u64,
1287        #[arg(long)]
1288        json: bool,
1289    },
1290    /// Fan one signed event to every pinned peer. Each peer receives a
1291    /// distinct `event_id` but every copy shares the same `broadcast_id`
1292    /// UUID so receivers can correlate them as a single broadcast.
1293    ///
1294    /// `--scope local` (default) only fans to peers reachable via a same-
1295    /// machine local relay. `--scope federation` only to public-relay
1296    /// peers. `--scope both` to every pinned peer.
1297    ///
1298    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1299    /// for "ack-loop" prevention: a peer responding to a broadcast can
1300    /// exclude its own broadcaster when re-broadcasting.
1301    ///
1302    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1303    /// file, `-` reads stdin (JSON if parseable, else literal).
1304    ///
1305    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1306    /// peers — that would re-introduce the phonebook-scrape risk closed
1307    /// in v0.5.14 (T8).
1308    Broadcast {
1309        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1310        /// `heartbeat`. Same vocabulary as `wire send`.
1311        #[arg(long, default_value = "claim")]
1312        kind: String,
1313        /// `local`, `federation`, or `both`. Default `local`.
1314        #[arg(long, default_value = "local")]
1315        scope: String,
1316        /// Skip a specific peer handle. Repeatable.
1317        #[arg(long)]
1318        exclude: Vec<String>,
1319        /// Drop the broadcast event ID from the relay-side attentiveness
1320        /// nag (`phyllis`) — useful when broadcasting to many peers and
1321        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1322        #[arg(long)]
1323        noreply: bool,
1324        /// Body — string, `@/path` for a file, or `-` for stdin.
1325        body: String,
1326        #[arg(long)]
1327        json: bool,
1328    },
1329    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1330    /// capability-aware addressing. Stored as `profile.role` on the
1331    /// signed agent-card — propagates over the existing pair / .well-
1332    /// known plumbing, no new persistence.
1333    ///
1334    /// First slice of the Layer-2 capability metadata umbrella (#13).
1335    /// `wire mesh route` (issue #21) will consume these tags to pick
1336    /// the right sister for a task.
1337    Role {
1338        #[command(subcommand)]
1339        action: MeshRoleAction,
1340    },
1341    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1342    /// to one sister session and deliver an event to that one peer.
1343    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1344    /// can now address "the reviewer" instead of hard-coding a handle.
1345    ///
1346    /// Strategies:
1347    ///   - `round-robin` (default): per-role cursor, persisted at
1348    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1349    ///   - `first`: alphabetically-first matching sister. Deterministic.
1350    ///   - `random`: uniform random among matches. Stateless.
1351    ///
1352    /// Pinned-peers-only by construction (same posture as `broadcast`).
1353    /// Caller must already have the target sister pinned in
1354    /// `state.peers` — otherwise we can't sign + push. Run
1355    /// `wire session pair-all-local` first if the mesh isn't wired.
1356    Route {
1357        /// Role to match (operator-defined tag from `wire mesh role set`).
1358        role: String,
1359        /// `round-robin` (default), `first`, or `random`.
1360        #[arg(long, default_value = "round-robin")]
1361        strategy: String,
1362        /// Skip a specific sister handle. Repeatable.
1363        #[arg(long)]
1364        exclude: Vec<String>,
1365        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1366        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1367        #[arg(long, default_value = "claim")]
1368        kind: String,
1369        /// Body — string, `@/path` for a file, or `-` for stdin.
1370        body: String,
1371        #[arg(long)]
1372        json: bool,
1373    },
1374}
1375
1376/// v0.6.4: subcommands of `wire mesh role`.
1377#[derive(Subcommand, Debug)]
1378pub enum MeshRoleAction {
1379    /// Assign self to a role. Role is a free-form ASCII string
1380    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1381    /// the vocabulary out-of-band — common starters: `planner`,
1382    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1383    Set {
1384        role: String,
1385        #[arg(long)]
1386        json: bool,
1387    },
1388    /// Read self or a peer's role. With no arg, prints self. With a
1389    /// handle, reads from the peer's pinned agent-card.
1390    Get {
1391        peer: Option<String>,
1392        #[arg(long)]
1393        json: bool,
1394    },
1395    /// List roles across every sister session on this machine. Reads
1396    /// each session's agent-card by path — no network, no env mutation.
1397    List {
1398        #[arg(long)]
1399        json: bool,
1400    },
1401    /// Remove self from any assigned role. Re-signs the card with
1402    /// `profile.role: null`.
1403    Clear {
1404        #[arg(long)]
1405        json: bool,
1406    },
1407}
1408
1409#[derive(Subcommand, Debug)]
1410pub enum ServiceAction {
1411    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1412    /// load it. Idempotent — re-running re-bootstraps an existing service.
1413    ///
1414    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1415    /// process). Pass `--local-relay` to install the loopback relay
1416    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1417    /// transport sister-Claudes use to coordinate on the same machine
1418    /// (v0.5.17 dual-slot). The two services have distinct labels +
1419    /// log files, so you can install both.
1420    Install {
1421        /// Install the local-relay service instead of the daemon.
1422        #[arg(long)]
1423        local_relay: bool,
1424        #[arg(long)]
1425        json: bool,
1426    },
1427    /// Unload + delete the service unit. Daemon keeps running until the
1428    /// next reboot or `wire upgrade`; this only changes the boot-time
1429    /// behaviour.
1430    Uninstall {
1431        /// Uninstall the local-relay service instead of the daemon.
1432        #[arg(long)]
1433        local_relay: bool,
1434        #[arg(long)]
1435        json: bool,
1436    },
1437    /// Report whether the unit is installed + active.
1438    Status {
1439        /// Show status of the local-relay service instead of the daemon.
1440        #[arg(long)]
1441        local_relay: bool,
1442        #[arg(long)]
1443        json: bool,
1444    },
1445}
1446
1447#[derive(Subcommand, Debug)]
1448pub enum ResponderCommand {
1449    /// Publish this agent's auto-responder health.
1450    Set {
1451        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1452        status: String,
1453        /// Optional operator-facing reason.
1454        #[arg(long)]
1455        reason: Option<String>,
1456        /// Emit JSON.
1457        #[arg(long)]
1458        json: bool,
1459    },
1460    /// Read responder health for self, or for a paired peer.
1461    Get {
1462        /// Optional peer handle; omitted means this agent's own slot.
1463        peer: Option<String>,
1464        /// Emit JSON.
1465        #[arg(long)]
1466        json: bool,
1467    },
1468}
1469
1470#[derive(Subcommand, Debug)]
1471pub enum ProfileAction {
1472    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1473    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1474    /// (JSON array) and `now` (JSON object).
1475    Set {
1476        field: String,
1477        value: String,
1478        #[arg(long)]
1479        json: bool,
1480    },
1481    /// Show all profile fields. Equivalent to `wire whois`.
1482    Get {
1483        #[arg(long)]
1484        json: bool,
1485    },
1486    /// Clear a profile field.
1487    Clear {
1488        field: String,
1489        #[arg(long)]
1490        json: bool,
1491    },
1492}
1493
1494/// Entry point — parse and dispatch.
1495pub fn run() -> Result<()> {
1496    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1497    // the session registry and adopt that session's home for this
1498    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1499    // detect — `wire whoami` / `wire monitor` from a project cwd now
1500    // resolve to that project's session identity, not the machine
1501    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1502    //
1503    // MUST run before any thread spawn — call it FIRST, before
1504    // `Cli::parse` (which uses clap internals only) and before any
1505    // command dispatch (which may spawn workers).
1506    crate::session::maybe_adopt_session_wire_home("cli");
1507    let cli = Cli::parse();
1508    match cli.command {
1509        Command::Init {
1510            handle,
1511            name,
1512            relay,
1513            offline,
1514            json,
1515        } => cmd_init(
1516            Some(&handle),
1517            name.as_deref(),
1518            relay.as_deref(),
1519            offline,
1520            json,
1521        ),
1522        Command::Status { peer, json } => {
1523            if let Some(peer) = peer {
1524                cmd_status_peer(&peer, json)
1525            } else {
1526                cmd_status(json)
1527            }
1528        }
1529        Command::Whoami {
1530            json,
1531            short,
1532            colored,
1533        } => cmd_whoami(json_default(json), short, colored),
1534        Command::Peers { json } => cmd_peers(json_default(json)),
1535        Command::Here { json } => cmd_here(json_default(json)),
1536        Command::Completions { shell } => {
1537            // v0.9.5: print shell completion script to stdout. Operator
1538            // pipes into their shell's completion dir; tab completion
1539            // covers verbs (dial, send, pending, accept, etc.) AND
1540            // their flags. Peer-name dynamic completion is a future
1541            // shell-side enhancement; clap_complete only ships the
1542            // static grammar.
1543            use clap::CommandFactory;
1544            let mut cmd = Cli::command();
1545            clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1546            Ok(())
1547        }
1548        Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1549        Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1550        Command::Send {
1551            peer,
1552            kind_or_body,
1553            body,
1554            deadline,
1555            no_auto_pair,
1556            json,
1557        } => {
1558            // P0.S: smart-positional API. `wire send peer body` =
1559            // kind=claim. `wire send peer kind body` = explicit kind.
1560            let (kind, body) = match body {
1561                Some(real_body) => (kind_or_body, real_body),
1562                None => ("claim".to_string(), kind_or_body),
1563            };
1564            cmd_send(
1565                &peer,
1566                &kind,
1567                &body,
1568                deadline.as_deref(),
1569                no_auto_pair,
1570                json_default(json),
1571            )
1572        }
1573        Command::Dial {
1574            name,
1575            message,
1576            json,
1577        } => cmd_dial(&name, message.as_deref(), json_default(json)),
1578        Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1579        Command::Monitor {
1580            peer,
1581            json,
1582            include_handshake,
1583            interval_ms,
1584            replay,
1585        } => cmd_monitor(
1586            peer.as_deref(),
1587            json,
1588            include_handshake,
1589            interval_ms,
1590            replay,
1591        ),
1592        Command::Verify { path, json } => cmd_verify(&path, json),
1593        Command::Responder { command } => match command {
1594            ResponderCommand::Set {
1595                status,
1596                reason,
1597                json,
1598            } => cmd_responder_set(&status, reason.as_deref(), json),
1599            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1600        },
1601        Command::Mcp => cmd_mcp(),
1602        Command::RelayServer {
1603            bind,
1604            local_only,
1605            uds,
1606        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1607        Command::BindRelay {
1608            url,
1609            scope,
1610            replace,
1611            migrate_pinned,
1612            json,
1613        } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1614        Command::AddPeerSlot {
1615            handle,
1616            url,
1617            slot_id,
1618            slot_token,
1619            json,
1620        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1621        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1622        Command::Pull { json } => cmd_pull(json),
1623        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1624        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1625        Command::ForgetPeer {
1626            handle,
1627            purge,
1628            json,
1629        } => cmd_forget_peer(&handle, purge, json),
1630        Command::Daemon {
1631            interval,
1632            once,
1633            json,
1634        } => cmd_daemon(interval, once, json),
1635        Command::PairHost {
1636            relay,
1637            yes,
1638            timeout,
1639            detach,
1640            json,
1641        } => {
1642            if detach {
1643                cmd_pair_host_detach(&relay, json)
1644            } else {
1645                cmd_pair_host(&relay, yes, timeout)
1646            }
1647        }
1648        Command::PairJoin {
1649            code_phrase,
1650            relay,
1651            yes,
1652            timeout,
1653            detach,
1654            json,
1655        } => {
1656            if detach {
1657                cmd_pair_join_detach(&code_phrase, &relay, json)
1658            } else {
1659                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1660            }
1661        }
1662        Command::PairConfirm {
1663            code_phrase,
1664            digits,
1665            json,
1666        } => cmd_pair_confirm(&code_phrase, &digits, json),
1667        Command::PairList {
1668            json,
1669            watch,
1670            watch_interval,
1671        } => cmd_pair_list(json, watch, watch_interval),
1672        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1673        Command::PairWatch {
1674            code_phrase,
1675            status,
1676            timeout,
1677            json,
1678        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1679        Command::Pair {
1680            handle,
1681            code,
1682            relay,
1683            yes,
1684            timeout,
1685            no_setup,
1686            detach,
1687        } => {
1688            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1689            // the zero-paste megacommand path — `wire pair slancha-spark@
1690            // wireup.net` does add + poll-for-ack + verify in one shot. The
1691            // SAS / code-based pair flow stays available for handles without
1692            // `@` (bootstrap pairing between two boxes that don't yet share a
1693            // relay directory).
1694            if handle.contains('@') && code.is_none() {
1695                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1696            } else if detach {
1697                cmd_pair_detach(&handle, code.as_deref(), &relay)
1698            } else {
1699                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1700            }
1701        }
1702        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1703        Command::PairAccept { peer, json } => {
1704            let j = json_default(json);
1705            deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1706            cmd_pair_accept(&peer, j)
1707        }
1708        Command::PairReject { peer, json } => {
1709            let j = json_default(json);
1710            deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1711            cmd_pair_reject(&peer, j)
1712        }
1713        Command::PairListInbound { json } => {
1714            let j = json_default(json);
1715            deprecation_warn("pair-list-inbound", "pending", j);
1716            cmd_pair_list_inbound(j)
1717        }
1718        Command::Session(cmd) => cmd_session(cmd),
1719        Command::Identity { cmd } => cmd_identity(cmd),
1720        Command::Mesh(cmd) => cmd_mesh(cmd),
1721        Command::Group(cmd) => cmd_group(cmd),
1722        Command::Invite {
1723            relay,
1724            ttl,
1725            uses,
1726            share,
1727            json,
1728        } => cmd_invite(&relay, ttl, uses, share, json),
1729        Command::Accept { target, json } => {
1730            // v0.9.4: smart-dispatch retired. `wire accept` always means
1731            // pair-accept by name. URL-shaped input gets a deprecation
1732            // banner pointing at `wire accept-invite <URL>` and then
1733            // (for back-compat with v0.9 scripts) routes to the invite
1734            // accept path one last time. v1.0 will reject URLs here.
1735            let j = json_default(json);
1736            if target.starts_with("wire://pair?") {
1737                deprecation_warn("accept-url", "accept-invite <url>", j);
1738                cmd_accept(&target, j)
1739            } else {
1740                cmd_pair_accept(&target, j)
1741            }
1742        }
1743        Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1744        Command::Whois {
1745            handle,
1746            json,
1747            relay,
1748        } => {
1749            // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1750            // resolves through the local identity layer (pinned peers
1751            // + local sister sessions). `wire whois <nick>@<relay>`
1752            // keeps the existing federation `.well-known/wire/agent`
1753            // path. `wire whois` (no arg) prints self via the original
1754            // path. The character nickname is the canonical operator-
1755            // facing name as of v0.8 — most callers should hit the
1756            // local route.
1757            match handle.as_deref() {
1758                Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1759                other => cmd_whois(other, json, relay.as_deref()),
1760            }
1761        }
1762        Command::Add {
1763            handle,
1764            relay,
1765            local_sister,
1766            json,
1767        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1768        Command::Up {
1769            relay,
1770            name,
1771            with_local,
1772            no_local,
1773            json,
1774        } => cmd_up(
1775            relay.as_deref(),
1776            name.as_deref(),
1777            with_local.as_deref(),
1778            no_local,
1779            json,
1780        ),
1781        Command::Doctor {
1782            json,
1783            recent_rejections,
1784        } => cmd_doctor(json, recent_rejections),
1785        Command::Upgrade { check, local, json } => cmd_upgrade(check, local, json),
1786        Command::Service { action } => cmd_service(action),
1787        Command::Diag { action } => cmd_diag(action),
1788        Command::Claim {
1789            nick,
1790            relay,
1791            public_url,
1792            hidden,
1793            json,
1794        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1795        Command::Profile { action } => cmd_profile(action),
1796        Command::Setup {
1797            apply,
1798            statusline,
1799            remove,
1800        } => {
1801            if statusline {
1802                cmd_setup_statusline(apply, remove)
1803            } else {
1804                cmd_setup(apply)
1805            }
1806        }
1807        Command::Notify {
1808            interval,
1809            peer,
1810            once,
1811            json,
1812        } => cmd_notify(interval, peer.as_deref(), once, json),
1813    }
1814}
1815
1816// ---------- init ----------
1817
1818fn cmd_init(
1819    handle: Option<&str>,
1820    name: Option<&str>,
1821    relay: Option<&str>,
1822    offline: bool,
1823    as_json: bool,
1824) -> Result<()> {
1825    // One-name rule: a typed handle (if any) is only a vanity seed — the
1826    // persona is derived from the keypair fingerprint, so it has no effect
1827    // on the resulting identity. `wire up` passes None (there is no name to
1828    // type); an explicit `wire init <handle>` passes Some and we surface the
1829    // "ignored in favor of persona" notice for transparency.
1830    if let Some(h) = handle
1831        && !h
1832            .chars()
1833            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1834    {
1835        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
1836    }
1837    if config::is_initialized()? {
1838        bail!(
1839            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1840            config::config_dir()?
1841        );
1842    }
1843    // v0.9.1 smart-default reachability. If the operator passed neither
1844    // --relay nor --offline, probe the conventional local relay at
1845    // http://127.0.0.1:8771 and auto-attach if healthy. Closes the
1846    // silent-slotless footgun WITHOUT the v0.9 rejection wall, which
1847    // forced operators through a three-flag decision tree on first
1848    // invocation. Bare `wire init <handle>` is now ergonomic again
1849    // whenever a local relay is running (the common dev setup).
1850    //
1851    // Probe order:
1852    //   1. --relay <url>          → use it
1853    //   2. --offline               → skip slot allocation (rare power-user)
1854    //   3. local relay reachable  → auto-attach + log to stderr
1855    //   4. otherwise               → bail with actionable options
1856    let mut resolved_relay: Option<String> = relay.map(str::to_string);
1857    if resolved_relay.is_none() && !offline {
1858        let default_local = "http://127.0.0.1:8771";
1859        let client = crate::relay_client::RelayClient::new(default_local);
1860        if client.check_healthz().is_ok() {
1861            eprintln!(
1862                "wire init: local relay at {default_local} reachable — auto-attaching. \
1863                 Use --relay <url> to pick a different relay, --offline to skip."
1864            );
1865            resolved_relay = Some(default_local.to_string());
1866        } else {
1867            // v0.9.5: interactive prompt for first-time operators
1868            // when the smart-default can't auto-attach. Detect TTY on
1869            // stdin AND stderr — only prompt for humans. CI / agents
1870            // / non-interactive shells fall through to the explicit
1871            // error wall (unchanged behavior since v0.9.1).
1872            use std::io::{BufRead, IsTerminal, Write};
1873            let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1874            if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1875                eprintln!("wire init: no local relay reachable at {default_local}.");
1876                eprint!(
1877                    "  Bind to public federation relay https://wireup.net instead? \
1878                     [Y/n/offline/url]: "
1879                );
1880                let _ = std::io::stderr().flush();
1881                let mut input = String::new();
1882                let _ = std::io::stdin().lock().read_line(&mut input);
1883                let answer = input.trim();
1884                match answer {
1885                    "" | "y" | "Y" | "yes" | "YES" => {
1886                        eprintln!("wire init: binding to https://wireup.net");
1887                        resolved_relay = Some("https://wireup.net".to_string());
1888                    }
1889                    "n" | "N" | "no" | "NO" => {
1890                        bail!(
1891                            "wire init: declined federation default; re-run with --relay <url> or --offline."
1892                        );
1893                    }
1894                    "offline" | "OFFLINE" => {
1895                        eprintln!(
1896                            "wire init: proceeding offline. \
1897                             Run `wire bind-relay <url>` before pairing."
1898                        );
1899                        // Fall through with resolved_relay still None;
1900                        // the `offline` flag is conceptually set but
1901                        // the caller's local doesn't need updating —
1902                        // resolved_relay = None + offline behavior
1903                        // is identical for the rest of cmd_init.
1904                    }
1905                    url if url.starts_with("http://") || url.starts_with("https://") => {
1906                        eprintln!("wire init: binding to {url}");
1907                        resolved_relay = Some(url.to_string());
1908                    }
1909                    other => {
1910                        bail!(
1911                            "wire init: unrecognized answer `{other}` — \
1912                             expected Y/n/offline/<url>. Re-run with --relay or --offline."
1913                        );
1914                    }
1915                }
1916            } else {
1917                bail!(
1918                    "wire init: no relay specified and no local relay reachable at \
1919                     http://127.0.0.1:8771.\n\
1920                     Pick one (or just run `wire up`):\n\
1921                     • `wire service install --local-relay` — start the local relay, then re-run\n\
1922                     • `wire up @wireup.net` — bind to public federation in one command\n\
1923                     • `wire init --offline` — generate keypair only \
1924                     (peers cannot reach you until you `wire bind-relay <url>` later)"
1925                );
1926            }
1927        }
1928    }
1929    let relay = resolved_relay.as_deref();
1930
1931    config::ensure_dirs()?;
1932    let (sk_seed, pk_bytes) = generate_keypair();
1933    config::write_private_key(&sk_seed)?;
1934
1935    // v0.11 ONE-NAME: derive the character nickname from a synthetic DID
1936    // using the freshly-generated pubkey, then USE THE CHARACTER as the
1937    // canonical handle. The operator-typed `handle` arg becomes either:
1938    //   - identical to character (already-canonical input — no-op), OR
1939    //   - overridden in favor of character (operator-typed name was a
1940    //     vanity layer that would never have been federation-reachable).
1941    // Either way, agent-card.handle ends up == character, and every
1942    // downstream surface (relay phonebook, .well-known, dial/send) keys
1943    // on the same name an operator sees in their statusline.
1944    //
1945    // Per the v0.11 directive: "If you can't call someone via a name,
1946    // don't let them have it as a name." Operator-typed handles violated
1947    // that rule because the character was the displayed name but the
1948    // handle was the addressable one. Now they're the same string.
1949    // The seed string only fills the (immediately-discarded) handle portion
1950    // of a synthetic DID; the persona derives from the fp suffix regardless,
1951    // so any seed yields the same identity.
1952    let seed = handle.unwrap_or("agent");
1953    let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
1954    let character = crate::character::Character::from_did(&synth_did);
1955    let canonical_handle: &str = &character.nickname;
1956    if let Some(typed) = handle
1957        && typed != canonical_handle
1958    {
1959        eprintln!(
1960            "wire init: one-name rule — typed `{typed}` ignored in favor of \
1961             DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
1962        );
1963    }
1964
1965    let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
1966    let signed = sign_agent_card(&card, &sk_seed);
1967    config::write_agent_card(&signed)?;
1968
1969    let mut trust = empty_trust();
1970    add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
1971    config::write_trust(&trust)?;
1972
1973    let fp = fingerprint(&pk_bytes);
1974    let key_id = make_key_id(canonical_handle, &pk_bytes);
1975    // Rebind `handle` for the rest of cmd_init so downstream prints,
1976    // relay-state writes, etc. all reference the canonical name.
1977    let handle = canonical_handle;
1978
1979    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
1980    let mut relay_info: Option<(String, String)> = None;
1981    if let Some(url) = relay {
1982        let normalized = url.trim_end_matches('/');
1983        let client = crate::relay_client::RelayClient::new(normalized);
1984        client.check_healthz()?;
1985        let alloc = client.allocate_slot(Some(handle))?;
1986        let mut state = config::read_relay_state()?;
1987        state["self"] = json!({
1988            "relay_url": normalized,
1989            "slot_id": alloc.slot_id.clone(),
1990            "slot_token": alloc.slot_token,
1991        });
1992        config::write_relay_state(&state)?;
1993        relay_info = Some((normalized.to_string(), alloc.slot_id));
1994    }
1995
1996    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1997    if as_json {
1998        let mut out = json!({
1999            "did": did_str.clone(),
2000            "fingerprint": fp,
2001            "key_id": key_id,
2002            "config_dir": config::config_dir()?.to_string_lossy(),
2003        });
2004        if let Some((url, slot_id)) = &relay_info {
2005            out["relay_url"] = json!(url);
2006            out["slot_id"] = json!(slot_id);
2007        }
2008        println!("{}", serde_json::to_string(&out)?);
2009    } else {
2010        println!("generated {did_str} (ed25519:{key_id})");
2011        println!(
2012            "config written to {}",
2013            config::config_dir()?.to_string_lossy()
2014        );
2015        if let Some((url, slot_id)) = &relay_info {
2016            println!("bound to relay {url} (slot {slot_id})");
2017            println!();
2018            println!(
2019                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
2020            );
2021        } else {
2022            println!();
2023            println!(
2024                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
2025            );
2026        }
2027    }
2028    Ok(())
2029}
2030
2031// ---------- status ----------
2032
2033fn cmd_status(as_json: bool) -> Result<()> {
2034    let initialized = config::is_initialized()?;
2035
2036    let mut summary = json!({
2037        "initialized": initialized,
2038    });
2039
2040    if initialized {
2041        let card = config::read_agent_card()?;
2042        let did = card
2043            .get("did")
2044            .and_then(Value::as_str)
2045            .unwrap_or("")
2046            .to_string();
2047        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
2048        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
2049        // legacy cards.
2050        let handle = card
2051            .get("handle")
2052            .and_then(Value::as_str)
2053            .map(str::to_string)
2054            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2055        let pk_b64 = card
2056            .get("verify_keys")
2057            .and_then(Value::as_object)
2058            .and_then(|m| m.values().next())
2059            .and_then(|v| v.get("key"))
2060            .and_then(Value::as_str)
2061            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2062        let pk_bytes = crate::signing::b64decode(pk_b64)?;
2063        summary["did"] = json!(did);
2064        summary["handle"] = json!(handle);
2065        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2066        summary["capabilities"] = card
2067            .get("capabilities")
2068            .cloned()
2069            .unwrap_or_else(|| json!([]));
2070
2071        let trust = config::read_trust()?;
2072        let relay_state_for_tier =
2073            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2074        let mut peers = Vec::new();
2075        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2076            for (peer_handle, _agent) in agents {
2077                if peer_handle == &handle {
2078                    continue; // self
2079                }
2080                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
2081                // for peers we've pinned but never received a pair_drop_ack
2082                // from, so the operator sees the "we can't send to them yet"
2083                // state instead of seeing a misleading VERIFIED.
2084                peers.push(json!({
2085                    "handle": peer_handle,
2086                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2087                }));
2088            }
2089        }
2090        summary["peers"] = json!(peers);
2091
2092        let relay_state = config::read_relay_state()?;
2093        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2094        if !summary["self_relay"].is_null() {
2095            // Hide slot_token from default view.
2096            if let Some(obj) = summary["self_relay"].as_object_mut() {
2097                obj.remove("slot_token");
2098            }
2099        }
2100        summary["peer_slots_count"] = json!(
2101            relay_state
2102                .get("peers")
2103                .and_then(Value::as_object)
2104                .map(|m| m.len())
2105                .unwrap_or(0)
2106        );
2107
2108        // Outbox / inbox queue depth (file count + total events)
2109        let outbox = config::outbox_dir()?;
2110        let inbox = config::inbox_dir()?;
2111        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2112        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2113
2114        // v0.5.19: liveness snapshot through a single helper so this
2115        // surface and `wire doctor` agree by construction. Issue #2:
2116        // doctor PASSed while status said DOWN for 25 min because each
2117        // computed liveness independently. ensure_up::daemon_liveness
2118        // is the only path now.
2119        let snap = crate::ensure_up::daemon_liveness();
2120        let mut daemon = json!({
2121            "running": snap.pidfile_alive,
2122            "pid": snap.pidfile_pid,
2123            "all_running_pids": snap.pgrep_pids,
2124            "orphans": snap.orphan_pids,
2125        });
2126        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2127            daemon["version"] = json!(d.version);
2128            daemon["bin_path"] = json!(d.bin_path);
2129            daemon["did"] = json!(d.did);
2130            daemon["relay_url"] = json!(d.relay_url);
2131            daemon["started_at"] = json!(d.started_at);
2132            daemon["schema"] = json!(d.schema);
2133            if d.version != env!("CARGO_PKG_VERSION") {
2134                daemon["version_mismatch"] = json!({
2135                    "daemon": d.version.clone(),
2136                    "cli": env!("CARGO_PKG_VERSION"),
2137                });
2138            }
2139        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2140            daemon["pidfile_form"] = json!("legacy-int");
2141            daemon["version_mismatch"] = json!({
2142                "daemon": "<pre-0.5.11>",
2143                "cli": env!("CARGO_PKG_VERSION"),
2144            });
2145        }
2146        summary["daemon"] = daemon;
2147
2148        // Pending pair sessions — counts by status.
2149        let pending = crate::pending_pair::list_pending().unwrap_or_default();
2150        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2151        for p in &pending {
2152            *counts.entry(p.status.clone()).or_default() += 1;
2153        }
2154        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
2155        let pending_inbound =
2156            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2157        let inbound_handles: Vec<&str> = pending_inbound
2158            .iter()
2159            .map(|p| p.peer_handle.as_str())
2160            .collect();
2161        summary["pending_pairs"] = json!({
2162            "total": pending.len(),
2163            "by_status": counts,
2164            "inbound_count": pending_inbound.len(),
2165            "inbound_handles": inbound_handles,
2166        });
2167    }
2168
2169    if as_json {
2170        println!("{}", serde_json::to_string(&summary)?);
2171    } else if !initialized {
2172        println!("not initialized — run `wire init <handle>` first");
2173    } else {
2174        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
2175        println!(
2176            "fingerprint:   {}",
2177            summary["fingerprint"].as_str().unwrap_or("?")
2178        );
2179        println!("capabilities:  {}", summary["capabilities"]);
2180        if !summary["self_relay"].is_null() {
2181            println!(
2182                "self relay:    {} (slot {})",
2183                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2184                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2185            );
2186        } else {
2187            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
2188        }
2189        println!(
2190            "peers:         {}",
2191            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2192        );
2193        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2194            println!(
2195                "  - {:<20} tier={}",
2196                p["handle"].as_str().unwrap_or(""),
2197                p["tier"].as_str().unwrap_or("?")
2198            );
2199        }
2200        println!(
2201            "outbox:        {} file(s), {} event(s) queued",
2202            summary["outbox"]["files"].as_u64().unwrap_or(0),
2203            summary["outbox"]["events"].as_u64().unwrap_or(0)
2204        );
2205        println!(
2206            "inbox:         {} file(s), {} event(s) received",
2207            summary["inbox"]["files"].as_u64().unwrap_or(0),
2208            summary["inbox"]["events"].as_u64().unwrap_or(0)
2209        );
2210        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2211        let daemon_pid = summary["daemon"]["pid"]
2212            .as_u64()
2213            .map(|p| p.to_string())
2214            .unwrap_or_else(|| "—".to_string());
2215        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2216        let version_suffix = if !daemon_version.is_empty() {
2217            format!(" v{daemon_version}")
2218        } else {
2219            String::new()
2220        };
2221        println!(
2222            "daemon:        {} (pid {}{})",
2223            if daemon_running { "running" } else { "DOWN" },
2224            daemon_pid,
2225            version_suffix,
2226        );
2227        // P1.7: surface version mismatch + orphan procs loudly.
2228        if let Some(mm) = summary["daemon"].get("version_mismatch") {
2229            println!(
2230                "               !! version mismatch: daemon={} CLI={}. \
2231                 run `wire upgrade` to swap atomically.",
2232                mm["daemon"].as_str().unwrap_or("?"),
2233                mm["cli"].as_str().unwrap_or("?"),
2234            );
2235        }
2236        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2237            && !orphans.is_empty()
2238        {
2239            let pids: Vec<String> = orphans
2240                .iter()
2241                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2242                .collect();
2243            println!(
2244                "               !! orphan daemon process(es): pids {}. \
2245                 pgrep saw them but pidfile didn't — likely stale process from \
2246                 prior install. Multiple daemons race the relay cursor.",
2247                pids.join(", ")
2248            );
2249        }
2250        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2251        let inbound_count = summary["pending_pairs"]["inbound_count"]
2252            .as_u64()
2253            .unwrap_or(0);
2254        if pending_total > 0 {
2255            print!("pending pairs: {pending_total}");
2256            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2257                let parts: Vec<String> = obj
2258                    .iter()
2259                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2260                    .collect();
2261                if !parts.is_empty() {
2262                    print!(" ({})", parts.join(", "));
2263                }
2264            }
2265            println!();
2266        } else if inbound_count == 0 {
2267            println!("pending pairs: none");
2268        }
2269        // v0.5.14: separate line for pending-inbound zero-paste requests.
2270        // Loud because each one is awaiting an operator gesture and the
2271        // capability hasn't flowed yet.
2272        if inbound_count > 0 {
2273            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2274                .as_array()
2275                .map(|a| {
2276                    a.iter()
2277                        .filter_map(|v| v.as_str().map(str::to_string))
2278                        .collect()
2279                })
2280                .unwrap_or_default();
2281            println!(
2282                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2283                handles.join(", "),
2284            );
2285        }
2286    }
2287    Ok(())
2288}
2289
2290fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2291    if !dir.exists() {
2292        return Ok(json!({"files": 0, "events": 0}));
2293    }
2294    let mut files = 0usize;
2295    let mut events = 0usize;
2296    for entry in std::fs::read_dir(dir)? {
2297        let path = entry?.path();
2298        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2299            files += 1;
2300            if let Ok(body) = std::fs::read_to_string(&path) {
2301                events += body.lines().filter(|l| !l.trim().is_empty()).count();
2302            }
2303        }
2304    }
2305    Ok(json!({"files": files, "events": events}))
2306}
2307
2308// ---------- responder health ----------
2309
2310fn responder_status_allowed(status: &str) -> bool {
2311    matches!(
2312        status,
2313        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2314    )
2315}
2316
2317fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2318    let state = config::read_relay_state()?;
2319    let (label, slot_info) = match peer {
2320        Some(peer) => (
2321            peer.to_string(),
2322            state
2323                .get("peers")
2324                .and_then(|p| p.get(peer))
2325                .ok_or_else(|| {
2326                    anyhow!(
2327                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
2328                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
2329                         (`wire peers` lists who you've already paired with.)"
2330                    )
2331                })?,
2332        ),
2333        None => (
2334            "self".to_string(),
2335            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2336                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2337            })?,
2338        ),
2339    };
2340    let relay_url = slot_info["relay_url"]
2341        .as_str()
2342        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2343        .to_string();
2344    let slot_id = slot_info["slot_id"]
2345        .as_str()
2346        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2347        .to_string();
2348    let slot_token = slot_info["slot_token"]
2349        .as_str()
2350        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2351        .to_string();
2352    Ok((label, relay_url, slot_id, slot_token))
2353}
2354
2355fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2356    if !responder_status_allowed(status) {
2357        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2358    }
2359    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2360    let now = time::OffsetDateTime::now_utc()
2361        .format(&time::format_description::well_known::Rfc3339)
2362        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2363    let mut record = json!({
2364        "status": status,
2365        "set_at": now,
2366    });
2367    if let Some(reason) = reason {
2368        record["reason"] = json!(reason);
2369    }
2370    if status == "online" {
2371        record["last_success_at"] = json!(now);
2372    }
2373    let client = crate::relay_client::RelayClient::new(&relay_url);
2374    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2375    if as_json {
2376        println!("{}", serde_json::to_string(&saved)?);
2377    } else {
2378        let reason = saved
2379            .get("reason")
2380            .and_then(Value::as_str)
2381            .map(|r| format!(" — {r}"))
2382            .unwrap_or_default();
2383        println!(
2384            "responder {}{}",
2385            saved
2386                .get("status")
2387                .and_then(Value::as_str)
2388                .unwrap_or(status),
2389            reason
2390        );
2391    }
2392    Ok(())
2393}
2394
2395fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2396    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2397    let client = crate::relay_client::RelayClient::new(&relay_url);
2398    let health = client.responder_health_get(&slot_id, &slot_token)?;
2399    if as_json {
2400        println!(
2401            "{}",
2402            serde_json::to_string(&json!({
2403                "target": label,
2404                "responder_health": health,
2405            }))?
2406        );
2407    } else if health.is_null() {
2408        println!("{label}: responder health not reported");
2409    } else {
2410        let status = health
2411            .get("status")
2412            .and_then(Value::as_str)
2413            .unwrap_or("unknown");
2414        let reason = health
2415            .get("reason")
2416            .and_then(Value::as_str)
2417            .map(|r| format!(" — {r}"))
2418            .unwrap_or_default();
2419        let last_success = health
2420            .get("last_success_at")
2421            .and_then(Value::as_str)
2422            .map(|t| format!(" (last_success: {t})"))
2423            .unwrap_or_default();
2424        println!("{label}: {status}{reason}{last_success}");
2425    }
2426    Ok(())
2427}
2428
2429fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2430    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2431    let client = crate::relay_client::RelayClient::new(&relay_url);
2432
2433    let started = std::time::Instant::now();
2434    let transport_ok = client.healthz().unwrap_or(false);
2435    let latency_ms = started.elapsed().as_millis() as u64;
2436
2437    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2438    let now = std::time::SystemTime::now()
2439        .duration_since(std::time::UNIX_EPOCH)
2440        .map(|d| d.as_secs())
2441        .unwrap_or(0);
2442    let attention = match last_pull_at_unix {
2443        Some(last) if now.saturating_sub(last) <= 300 => json!({
2444            "status": "ok",
2445            "last_pull_at_unix": last,
2446            "age_seconds": now.saturating_sub(last),
2447            "event_count": event_count,
2448        }),
2449        Some(last) => json!({
2450            "status": "stale",
2451            "last_pull_at_unix": last,
2452            "age_seconds": now.saturating_sub(last),
2453            "event_count": event_count,
2454        }),
2455        None => json!({
2456            "status": "never_pulled",
2457            "last_pull_at_unix": Value::Null,
2458            "event_count": event_count,
2459        }),
2460    };
2461
2462    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2463    let responder = if responder_health.is_null() {
2464        json!({"status": "not_reported", "record": Value::Null})
2465    } else {
2466        json!({
2467            "status": responder_health
2468                .get("status")
2469                .and_then(Value::as_str)
2470                .unwrap_or("unknown"),
2471            "record": responder_health,
2472        })
2473    };
2474
2475    let report = json!({
2476        "peer": peer,
2477        "transport": {
2478            "status": if transport_ok { "ok" } else { "error" },
2479            "relay_url": relay_url,
2480            "latency_ms": latency_ms,
2481        },
2482        "attention": attention,
2483        "responder": responder,
2484    });
2485
2486    if as_json {
2487        println!("{}", serde_json::to_string(&report)?);
2488    } else {
2489        let transport_line = if transport_ok {
2490            format!("ok relay reachable ({latency_ms}ms)")
2491        } else {
2492            "error relay unreachable".to_string()
2493        };
2494        println!("transport      {transport_line}");
2495        match report["attention"]["status"].as_str().unwrap_or("unknown") {
2496            "ok" => println!(
2497                "attention      ok last pull {}s ago",
2498                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2499            ),
2500            "stale" => println!(
2501                "attention      stale last pull {}m ago",
2502                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2503            ),
2504            "never_pulled" => println!("attention      never pulled since relay reset"),
2505            other => println!("attention      {other}"),
2506        }
2507        if report["responder"]["status"] == "not_reported" {
2508            println!("auto-responder not reported");
2509        } else {
2510            let record = &report["responder"]["record"];
2511            let status = record
2512                .get("status")
2513                .and_then(Value::as_str)
2514                .unwrap_or("unknown");
2515            let reason = record
2516                .get("reason")
2517                .and_then(Value::as_str)
2518                .map(|r| format!(" — {r}"))
2519                .unwrap_or_default();
2520            println!("auto-responder {status}{reason}");
2521        }
2522    }
2523    Ok(())
2524}
2525
2526// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
2527
2528// ---------- whoami ----------
2529
2530/// Return the current cwd with the user's home dir abbreviated to `~/`.
2531/// Used in whoami `--short` / `--colored` output so multi-window operators
2532/// see *what project* each Claude is working in alongside the character.
2533fn current_cwd_display() -> String {
2534    let cwd = match std::env::current_dir() {
2535        Ok(c) => c,
2536        Err(_) => return String::from("?"),
2537    };
2538    if let Some(home) = dirs::home_dir()
2539        && let Ok(rel) = cwd.strip_prefix(&home)
2540    {
2541        // strip_prefix returns "" for cwd == home itself; show "~" then.
2542        let rel_str = rel.to_string_lossy();
2543        if rel_str.is_empty() {
2544            return String::from("~");
2545        }
2546        return format!("~/{}", rel_str);
2547    }
2548    cwd.to_string_lossy().into_owned()
2549}
2550
2551fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2552    if !config::is_initialized()? {
2553        bail!("not initialized — run `wire init <handle>` first");
2554    }
2555    let card = config::read_agent_card()?;
2556    let did = card
2557        .get("did")
2558        .and_then(Value::as_str)
2559        .unwrap_or("")
2560        .to_string();
2561    let handle = card
2562        .get("handle")
2563        .and_then(Value::as_str)
2564        .map(str::to_string)
2565        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2566    // v0.11: character is purely DID-derived. No overrides — the
2567    // operator-rename verb is gone and display.json reads are stripped
2568    // because they introduced a second name that peers couldn't find.
2569    let character = crate::character::Character::from_did(&did);
2570
2571    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
2572    // so operators tab-flipping between multiple Claude windows see both
2573    // *who* this session is (character) and *what* it's working on (cwd).
2574    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
2575    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
2576    let cwd_display = current_cwd_display();
2577
2578    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
2579    // beyond did — these calls are hot (statusline polls ~300ms).
2580    if short {
2581        println!("{} · {}", character.short(), cwd_display);
2582        return Ok(());
2583    }
2584    if colored {
2585        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2586        return Ok(());
2587    }
2588
2589    let pk_b64 = card
2590        .get("verify_keys")
2591        .and_then(Value::as_object)
2592        .and_then(|m| m.values().next())
2593        .and_then(|v| v.get("key"))
2594        .and_then(Value::as_str)
2595        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2596    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2597    let fp = fingerprint(&pk_bytes);
2598    let key_id = make_key_id(&handle, &pk_bytes);
2599    let capabilities = card
2600        .get("capabilities")
2601        .cloned()
2602        .unwrap_or_else(|| json!(["wire/v3.1"]));
2603
2604    if as_json {
2605        // v0.11: character_override is always false now (no rename verb,
2606        // no display.json reads). Field stays for back-compat with v0.10
2607        // JSON consumers that key off it.
2608        let has_override = false;
2609        println!(
2610            "{}",
2611            serde_json::to_string(&json!({
2612                "did": did,
2613                "handle": handle,
2614                "fingerprint": fp,
2615                "key_id": key_id,
2616                "public_key_b64": pk_b64,
2617                "capabilities": capabilities,
2618                "config_dir": config::config_dir()?.to_string_lossy(),
2619                "persona": character,
2620                "persona_override": has_override,
2621            }))?
2622        );
2623    } else {
2624        println!("{}", character.colored());
2625        println!("{did} (ed25519:{key_id})");
2626        println!("fingerprint: {fp}");
2627        println!("capabilities: {capabilities}");
2628    }
2629    Ok(())
2630}
2631
2632// ---------- identity (v0.7.0-alpha.3) ----------
2633
2634fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2635    match cmd {
2636        // v0.11: IdentityCommand::Rename deleted. The character is the
2637        // one canonical name (DID-derived); a local-display rename
2638        // would create a second name peers can't find, violating the
2639        // "names must be findable" invariant. Aliases (if needed
2640        // later) become relay-claimed entries that ARE findable —
2641        // a different architectural shape from rename.
2642        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2643        IdentityCommand::List { json } => cmd_session_list(json),
2644        IdentityCommand::Publish {
2645            nick,
2646            relay,
2647            public_url,
2648            hidden,
2649            json,
2650        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2651        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2652        IdentityCommand::Create {
2653            name,
2654            anonymous,
2655            local: _,
2656            json,
2657        } => cmd_identity_create(name.as_deref(), anonymous, json),
2658        IdentityCommand::Persist {
2659            name,
2660            as_name,
2661            json,
2662        } => cmd_identity_persist(&name, as_name.as_deref(), json),
2663        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2664    }
2665}
2666
2667/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
2668/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
2669/// paste into their shell; the identity lives there until reboot
2670/// clears /tmp. Persist promotes it to the real sessions root.
2671fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2672    if anonymous {
2673        // Generate a unique tmpdir for this anonymous identity.
2674        let rand_suffix = format!("{:08x}", rand::random::<u32>());
2675        let anon_name = name
2676            .map(crate::session::sanitize_name)
2677            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2678        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2679        std::fs::create_dir_all(&anon_root)
2680            .with_context(|| format!("creating anon root {anon_root:?}"))?;
2681        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
2682        let session_home = anon_root.join("sessions").join(&anon_name);
2683        std::fs::create_dir_all(&session_home)?;
2684        let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2685        if !status.success() {
2686            bail!("anonymous identity init failed: {status}");
2687        }
2688        // Register the anonymous name in a SIDE registry so persist
2689        // can find it later. Stored at <anon_root>/anon-marker.json.
2690        let marker = anon_root.join("anon-marker.json");
2691        std::fs::write(
2692            &marker,
2693            serde_json::to_vec_pretty(&serde_json::json!({
2694                "name": anon_name,
2695                "session_home": session_home.to_string_lossy(),
2696                "created_at": time::OffsetDateTime::now_utc()
2697                    .format(&time::format_description::well_known::Rfc3339)
2698                    .unwrap_or_default(),
2699                "kind": "anonymous",
2700            }))?,
2701        )?;
2702        let card = serde_json::from_slice::<Value>(&std::fs::read(
2703            session_home
2704                .join("config")
2705                .join("wire")
2706                .join("agent-card.json"),
2707        )?)?;
2708        let did = card
2709            .get("did")
2710            .and_then(Value::as_str)
2711            .unwrap_or("")
2712            .to_string();
2713        if as_json {
2714            println!(
2715                "{}",
2716                serde_json::to_string(&json!({
2717                    "kind": "anonymous",
2718                    "name": anon_name,
2719                    "did": did,
2720                    "session_home": session_home.to_string_lossy(),
2721                    "anon_root": anon_root.to_string_lossy(),
2722                }))?
2723            );
2724        } else {
2725            println!("created anonymous identity `{anon_name}` ({did})");
2726            println!(
2727                "  session_home: {} (dies on reboot — /tmp)",
2728                session_home.display()
2729            );
2730            println!();
2731            println!("activate in this shell:");
2732            println!("  export WIRE_HOME={}", session_home.display());
2733            println!();
2734            println!("promote to persistent later with:");
2735            println!("  wire identity persist {anon_name}");
2736        }
2737        return Ok(());
2738    }
2739    // --local (or default): delegate to existing session new flow.
2740    let name_arg = name.map(|s| s.to_string());
2741    cmd_session_new(
2742        name_arg.as_deref(),
2743        "https://wireup.net",
2744        false,
2745        "http://127.0.0.1:8771",
2746        false,
2747        None,
2748        false,
2749        None,
2750        true, // no_daemon: identity create just allocates the identity, no daemon
2751        true, // local_only: explicit lifecycle
2752        as_json,
2753    )
2754}
2755
2756/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
2757/// tmpdir to the persistent sessions root + registers in the cwd map.
2758fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2759    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
2760    let temp = std::env::temp_dir();
2761    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2762    for entry in std::fs::read_dir(&temp)?.flatten() {
2763        let path = entry.path();
2764        if !path
2765            .file_name()
2766            .and_then(|s| s.to_str())
2767            .map(|s| s.starts_with("wire-anon-"))
2768            .unwrap_or(false)
2769        {
2770            continue;
2771        }
2772        let marker = path.join("anon-marker.json");
2773        if let Ok(bytes) = std::fs::read(&marker)
2774            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2775            && json.get("name").and_then(Value::as_str) == Some(name)
2776        {
2777            let session_home = json
2778                .get("session_home")
2779                .and_then(Value::as_str)
2780                .map(std::path::PathBuf::from)
2781                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2782            found = Some((path, session_home));
2783            break;
2784        }
2785    }
2786    let (anon_root, anon_session_home) = found.ok_or_else(|| {
2787        anyhow!(
2788            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2789             run `wire identity list` to see available identities"
2790        )
2791    })?;
2792
2793    let new_name = as_name.unwrap_or(name);
2794    let new_session_home = crate::session::session_dir(new_name)?;
2795    if new_session_home.exists() {
2796        bail!(
2797            "target session `{new_name}` already exists at {new_session_home:?} — \
2798             pick a different name with --as <new-name>"
2799        );
2800    }
2801
2802    // Move the session dir from tmpdir to persistent root.
2803    if let Some(parent) = new_session_home.parent() {
2804        std::fs::create_dir_all(parent)?;
2805    }
2806    std::fs::rename(&anon_session_home, &new_session_home)
2807        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2808
2809    // Clean up the (now-empty) anon root + marker.
2810    let _ = std::fs::remove_dir_all(&anon_root);
2811
2812    // Register cwd → new_name (operator may have cd'd elsewhere; use the
2813    // session_home's grandparent as the conceptual "cwd" if no other).
2814    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2815    let cwd_key = cwd.to_string_lossy().into_owned();
2816    let new_name_for_reg = new_name.to_string();
2817    if let Err(e) = crate::session::update_registry(|reg| {
2818        reg.by_cwd.insert(cwd_key, new_name_for_reg);
2819        Ok(())
2820    }) {
2821        eprintln!("wire identity persist: failed to update registry: {e:#}");
2822    }
2823
2824    if as_json {
2825        println!(
2826            "{}",
2827            serde_json::to_string(&json!({
2828                "kind": "persisted",
2829                "from_name": name,
2830                "to_name": new_name,
2831                "session_home": new_session_home.to_string_lossy(),
2832            }))?
2833        );
2834    } else {
2835        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2836        println!(
2837            "  session_home: {} (survives reboot)",
2838            new_session_home.display()
2839        );
2840        println!("  registered cwd: {}", cwd.display());
2841    }
2842    Ok(())
2843}
2844
2845/// v0.7.0-alpha.20: demote federation → local. Removes the federation
2846/// slot binding from relay.json (and the legacy top-level fields). Keeps
2847/// the keypair + agent-card so re-publish later just calls `wire identity
2848/// publish` again. local → anonymous is NOT supported; destroy + recreate
2849/// is the safer path for that step-down.
2850fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2851    let sessions = crate::session::list_sessions()?;
2852    let session = sessions
2853        .iter()
2854        .find(|s| s.name == name)
2855        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2856    let relay_state_path = session
2857        .home_dir
2858        .join("config")
2859        .join("wire")
2860        .join("relay.json");
2861    if !relay_state_path.exists() {
2862        bail!("session `{name}` has no relay state — already demoted?");
2863    }
2864    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2865    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2866    let had_fed = self_obj
2867        .get("relay_url")
2868        .and_then(Value::as_str)
2869        .map(|u| {
2870            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2871        })
2872        .unwrap_or(false);
2873    if !had_fed {
2874        if as_json {
2875            println!(
2876                "{}",
2877                serde_json::to_string(
2878                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2879                )?
2880            );
2881        } else {
2882            println!("session `{name}` has no federation slot — nothing to demote");
2883        }
2884        return Ok(());
2885    }
2886    // Strip federation: remove top-level relay_url/slot_id/slot_token,
2887    // remove federation-scope entries from endpoints[].
2888    if let Some(self_mut) = state
2889        .as_object_mut()
2890        .and_then(|m| m.get_mut("self"))
2891        .and_then(|s| s.as_object_mut())
2892    {
2893        self_mut.remove("relay_url");
2894        self_mut.remove("slot_id");
2895        self_mut.remove("slot_token");
2896        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2897            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2898        }
2899    }
2900    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2901
2902    if as_json {
2903        println!(
2904            "{}",
2905            serde_json::to_string(
2906                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2907            )?
2908        );
2909    } else {
2910        println!("demoted `{name}` from federation → local");
2911        println!("  relay slot binding removed; keypair + agent-card retained");
2912        println!("  re-publish with `wire identity publish <nick>`");
2913    }
2914    Ok(())
2915}
2916
2917fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2918    let raw = crate::trust::get_tier(trust, handle);
2919    if raw != "VERIFIED" {
2920        return raw.to_string();
2921    }
2922    let token = relay_state
2923        .get("peers")
2924        .and_then(|p| p.get(handle))
2925        .and_then(|p| p.get("slot_token"))
2926        .and_then(Value::as_str)
2927        .unwrap_or("");
2928    if token.is_empty() {
2929        "PENDING_ACK".to_string()
2930    } else {
2931        raw.to_string()
2932    }
2933}
2934
2935fn cmd_peers(as_json: bool) -> Result<()> {
2936    let trust = config::read_trust()?;
2937    let agents = trust
2938        .get("agents")
2939        .and_then(Value::as_object)
2940        .cloned()
2941        .unwrap_or_default();
2942    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2943
2944    let mut self_did: Option<String> = None;
2945    if let Ok(card) = config::read_agent_card() {
2946        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2947    }
2948
2949    let mut peers = Vec::new();
2950    for (handle, agent) in agents.iter() {
2951        let did = agent
2952            .get("did")
2953            .and_then(Value::as_str)
2954            .unwrap_or("")
2955            .to_string();
2956        if Some(did.as_str()) == self_did.as_deref() {
2957            continue; // skip self-attestation
2958        }
2959        let tier = effective_peer_tier(&trust, &relay_state, handle);
2960        let capabilities = agent
2961            .get("card")
2962            .and_then(|c| c.get("capabilities"))
2963            .cloned()
2964            .unwrap_or_else(|| json!([]));
2965        // v0.7.0-alpha.6: prefer peer's published character override
2966        // (display.nickname / display.emoji on their pinned agent-card).
2967        // Falls back to auto-derived if peer hasn't renamed themselves
2968        // OR runs an older wire that doesn't publish the field.
2969        let character = if did.is_empty() {
2970            None
2971        } else {
2972            let card_obj = agent.get("card");
2973            Some(match card_obj {
2974                Some(card) => crate::character::Character::from_card(card),
2975                None => crate::character::Character::from_did(&did),
2976            })
2977        };
2978        peers.push(json!({
2979            "handle": handle,
2980            "did": did,
2981            "tier": tier,
2982            "capabilities": capabilities,
2983            "persona": character,
2984        }));
2985    }
2986
2987    if as_json {
2988        println!("{}", serde_json::to_string(&peers)?);
2989    } else if peers.is_empty() {
2990        println!("no peers pinned (run `wire join <code>` to pair)");
2991    } else {
2992        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
2993        // computed above (from peer's agent-card, honoring override) so
2994        // text and JSON output never diverge. Pre-alpha.8 the text loop
2995        // recomputed via Character::from_did (no override) — operators
2996        // saw different identities depending on --json flag.
2997        for p in &peers {
2998            let char_json = &p["persona"];
2999            let (colored_char, plain_len): (String, usize) = match char_json {
3000                serde_json::Value::Null => ("?".to_string(), 1),
3001                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3002                    Ok(c) => {
3003                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
3004                        (c.colored(), plain)
3005                    }
3006                    Err(_) => ("?".to_string(), 1),
3007                },
3008            };
3009            let pad = 22usize.saturating_sub(plain_len);
3010            println!(
3011                "{}{}  {:<20} {:<10} {}",
3012                colored_char,
3013                " ".repeat(pad),
3014                p["handle"].as_str().unwrap_or(""),
3015                p["tier"].as_str().unwrap_or(""),
3016                p["did"].as_str().unwrap_or(""),
3017            );
3018        }
3019    }
3020    Ok(())
3021}
3022
3023// ---------- send ----------
3024
3025/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
3026///
3027/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
3028/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
3029/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
3030/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
3031/// hasn't crossed two heartbeats means probably degraded.
3032fn maybe_warn_peer_attentiveness(peer: &str) {
3033    let state = match config::read_relay_state() {
3034        Ok(s) => s,
3035        Err(_) => return,
3036    };
3037    let p = state.get("peers").and_then(|p| p.get(peer));
3038    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3039        Some(s) if !s.is_empty() => s,
3040        _ => return,
3041    };
3042    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3043        Some(s) if !s.is_empty() => s,
3044        _ => return,
3045    };
3046    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3047        Some(s) if !s.is_empty() => s.to_string(),
3048        _ => match state
3049            .get("self")
3050            .and_then(|s| s.get("relay_url"))
3051            .and_then(Value::as_str)
3052        {
3053            Some(s) if !s.is_empty() => s.to_string(),
3054            _ => return,
3055        },
3056    };
3057    let client = crate::relay_client::RelayClient::new(&relay_url);
3058    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3059        Ok(t) => t,
3060        Err(_) => return,
3061    };
3062    let now = std::time::SystemTime::now()
3063        .duration_since(std::time::UNIX_EPOCH)
3064        .map(|d| d.as_secs())
3065        .unwrap_or(0);
3066    match last_pull {
3067        None => {
3068            eprintln!(
3069                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3070            );
3071        }
3072        Some(t) if now.saturating_sub(t) > 300 => {
3073            let mins = now.saturating_sub(t) / 60;
3074            eprintln!(
3075                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3076            );
3077        }
3078        _ => {}
3079    }
3080}
3081
3082pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3083    let trimmed = input.trim();
3084    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3085    {
3086        return Ok(trimmed.to_string());
3087    }
3088    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3089    let n: i64 = amount
3090        .parse()
3091        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3092    if n <= 0 {
3093        bail!("deadline duration must be positive: {input:?}");
3094    }
3095    let duration = match unit {
3096        "m" => time::Duration::minutes(n),
3097        "h" => time::Duration::hours(n),
3098        "d" => time::Duration::days(n),
3099        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3100    };
3101    Ok((time::OffsetDateTime::now_utc() + duration)
3102        .format(&time::format_description::well_known::Rfc3339)
3103        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3104}
3105
3106fn cmd_send(
3107    peer: &str,
3108    kind: &str,
3109    body_arg: &str,
3110    deadline: Option<&str>,
3111    // v0.10: when true, refuse to auto-pair on miss; fail loudly so
3112    // scripts can branch on the error instead of accepting an implicit
3113    // side effect.
3114    no_auto_pair: bool,
3115    as_json: bool,
3116) -> Result<()> {
3117    if !config::is_initialized()? {
3118        bail!("not initialized — run `wire init <handle>` first");
3119    }
3120    let peer_in = crate::agent_card::bare_handle(peer).to_string();
3121    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
3122    // match wins; nickname (DID-hash auto-derived) is the fallback.
3123    // Ambiguous nicknames (two pinned peers DID-hash to the same
3124    // adj-noun pair) fail loudly with disambiguation; unknown handles
3125    // pass through (matches existing `wire send` semantics — queue
3126    // first, deliver best-effort).
3127    let peer = match resolve_peer_handle(&peer_in) {
3128        Ok(Some(resolved)) if resolved != peer_in => {
3129            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3130            resolved
3131        }
3132        Ok(Some(canonical)) => canonical, // exact handle match
3133        Ok(None) => peer_in,              // unknown — pass through, downstream errors
3134        Err(ResolveError::Ambiguous(candidates)) => bail!(
3135            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3136             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3137            candidates.len(),
3138            candidates.join(", ")
3139        ),
3140        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
3141    };
3142
3143    // v0.9 auto-pair-on-miss: if the resolved peer isn't pinned yet but
3144    // matches a local sister session, pair first (disk-read --local-sister
3145    // path) then continue. Closes the "wire send returns queued but
3146    // peer never receives because we were never paired" silent-fail
3147    // class. Equivalent to `wire dial <name>` followed by `wire send
3148    // <name> ...` in one step.
3149    let peer_is_pinned = config::read_relay_state()
3150        .ok()
3151        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3152        .map(|peers| peers.contains_key(&peer))
3153        .unwrap_or(false);
3154    if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3155        if no_auto_pair {
3156            bail!(
3157                "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3158                 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3159                 then re-run send."
3160            );
3161        }
3162        eprintln!(
3163            "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3164             Pass --no-auto-pair to refuse implicit dialing."
3165        );
3166        cmd_add_local_sister(&sister_name, true).map_err(|e| {
3167            anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3168        })?;
3169    }
3170
3171    let peer = peer.as_str();
3172    let sk_seed = config::read_private_key()?;
3173    let card = config::read_agent_card()?;
3174    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3175    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3176    let pk_b64 = card
3177        .get("verify_keys")
3178        .and_then(Value::as_object)
3179        .and_then(|m| m.values().next())
3180        .and_then(|v| v.get("key"))
3181        .and_then(Value::as_str)
3182        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3183    let pk_bytes = crate::signing::b64decode(pk_b64)?;
3184
3185    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
3186    // P0.S (0.5.11): stdin support lets shells pipe in long content
3187    // without quoting/escaping ceremony, and supports heredocs naturally:
3188    //   wire send peer - <<EOF ... EOF
3189    let body_value: Value = if body_arg == "-" {
3190        use std::io::Read;
3191        let mut raw = String::new();
3192        std::io::stdin()
3193            .read_to_string(&mut raw)
3194            .with_context(|| "reading body from stdin")?;
3195        // Try parsing as JSON first; fall back to string literal for
3196        // plain-text bodies.
3197        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3198    } else if let Some(path) = body_arg.strip_prefix('@') {
3199        let raw =
3200            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3201        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3202    } else {
3203        Value::String(body_arg.to_string())
3204    };
3205
3206    let kind_id = parse_kind(kind)?;
3207
3208    let now = time::OffsetDateTime::now_utc()
3209        .format(&time::format_description::well_known::Rfc3339)
3210        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3211
3212    let mut event = json!({
3213        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3214        "timestamp": now,
3215        "from": did,
3216        "to": format!("did:wire:{peer}"),
3217        "type": kind,
3218        "kind": kind_id,
3219        "body": body_value,
3220    });
3221    if let Some(deadline) = deadline {
3222        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3223    }
3224    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3225    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3226
3227    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
3228    // coords in relay-state and ask the relay how recently the peer pulled.
3229    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
3230    // Never blocks the send — the event still queues to outbox.
3231    maybe_warn_peer_attentiveness(peer);
3232
3233    // For now we append to outbox JSONL and rely on a future daemon to push
3234    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
3235    // Append goes through `config::append_outbox_record` which holds a per-
3236    // path mutex so concurrent senders cannot interleave bytes mid-line.
3237    let line = serde_json::to_vec(&signed)?;
3238    let outbox = config::append_outbox_record(peer, &line)?;
3239
3240    if as_json {
3241        println!(
3242            "{}",
3243            serde_json::to_string(&json!({
3244                "event_id": event_id,
3245                "status": "queued",
3246                "peer": peer,
3247                "outbox": outbox.to_string_lossy(),
3248            }))?
3249        );
3250    } else {
3251        println!(
3252            "queued event {event_id} → {peer} (outbox: {})",
3253            outbox.display()
3254        );
3255    }
3256    Ok(())
3257}
3258
3259fn parse_kind(s: &str) -> Result<u32> {
3260    if let Ok(n) = s.parse::<u32>() {
3261        return Ok(n);
3262    }
3263    for (id, name) in crate::signing::kinds() {
3264        if *name == s {
3265            return Ok(*id);
3266        }
3267    }
3268    // Unknown name — default to kind 1 (decision) for v0.1.
3269    Ok(1)
3270}
3271
3272// ---------- here (v0.9.3 you-are-here view) ----------
3273
3274/// `wire here` — one-screen "you are this session, your neighbors are
3275/// these." Combines what `wire whoami`, `wire peers`, and `wire session
3276/// list-local` would otherwise force the operator to call separately.
3277fn cmd_here(as_json: bool) -> Result<()> {
3278    let initialized = config::is_initialized().unwrap_or(false);
3279
3280    // Self identity.
3281    let (self_did, self_handle, self_character) = if initialized {
3282        let card = config::read_agent_card().ok();
3283        let did = card
3284            .as_ref()
3285            .and_then(|c| c.get("did").and_then(Value::as_str))
3286            .unwrap_or("")
3287            .to_string();
3288        let handle = if did.is_empty() {
3289            String::new()
3290        } else {
3291            crate::agent_card::display_handle_from_did(&did).to_string()
3292        };
3293        let character = if did.is_empty() {
3294            None
3295        } else {
3296            // v0.11: DID-derived only. No display.json overrides.
3297            Some(crate::character::Character::from_did(&did))
3298        };
3299        (did, handle, character)
3300    } else {
3301        (String::new(), String::new(), None)
3302    };
3303
3304    let cwd = std::env::current_dir()
3305        .map(|p| p.to_string_lossy().into_owned())
3306        .unwrap_or_default();
3307    let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3308
3309    // Sister sessions (same-machine).
3310    let mut sisters: Vec<Value> = Vec::new();
3311    if let Ok(listing) = crate::session::list_local_sessions() {
3312        for group in listing.local.values() {
3313            for s in group {
3314                if s.handle.as_deref() == Some(self_handle.as_str()) {
3315                    continue; // skip self
3316                }
3317                let ch = s.did.as_deref().map(crate::character::Character::from_did);
3318                sisters.push(json!({
3319                    "session": s.name,
3320                    "handle": s.handle,
3321                    "persona": ch,
3322                }));
3323            }
3324        }
3325    }
3326
3327    // Pinned peers (trust ring agents).
3328    let mut peers: Vec<Value> = Vec::new();
3329    if initialized
3330        && let Ok(trust) = config::read_trust()
3331        && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3332    {
3333        for (handle, agent) in agents {
3334            if handle == &self_handle {
3335                continue; // skip self
3336            }
3337            let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3338            let ch = if did.is_empty() {
3339                None
3340            } else {
3341                Some(crate::character::Character::from_did(did))
3342            };
3343            peers.push(json!({
3344                "handle": handle,
3345                "did": did,
3346                "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3347                "persona": ch,
3348            }));
3349        }
3350    }
3351
3352    if as_json {
3353        println!(
3354            "{}",
3355            serde_json::to_string(&json!({
3356                "self": {
3357                    "handle": self_handle,
3358                    "did": self_did,
3359                    "persona": self_character,
3360                    "cwd": cwd,
3361                    "wire_home": wire_home,
3362                },
3363                "sister_sessions": sisters,
3364                "pinned_peers": peers,
3365            }))?
3366        );
3367        return Ok(());
3368    }
3369
3370    // Human format.
3371    if !initialized {
3372        println!("not initialized — run `wire init <handle>` to bootstrap.");
3373        return Ok(());
3374    }
3375    let glyph = self_character
3376        .as_ref()
3377        .map(crate::character::emoji_with_fallback)
3378        .unwrap_or_else(|| "?".to_string());
3379    let nick = self_character
3380        .as_ref()
3381        .map(|c| c.nickname.clone())
3382        .unwrap_or_default();
3383    println!("you are {glyph} {nick}  ({self_handle})");
3384    if !cwd.is_empty() {
3385        println!("  cwd:    {cwd}");
3386    }
3387    // Helper closure that mirrors emoji_with_fallback over a JSON-encoded
3388    // character object (because we already collected sisters/peers into
3389    // Value rows above). Looks up the canonical emoji-name and falls
3390    // back to that — never repeats the nickname inside the brackets.
3391    let render_glyph = |character: &Value| -> String {
3392        let emoji = character
3393            .get("emoji")
3394            .and_then(Value::as_str)
3395            .unwrap_or("?");
3396        let nickname = character
3397            .get("nickname")
3398            .and_then(Value::as_str)
3399            .unwrap_or("?");
3400        if crate::character::terminal_supports_emoji() {
3401            return emoji.to_string();
3402        }
3403        // Synthesize a minimal Character so emoji_with_fallback's
3404        // lookup table picks the right ASCII tag.
3405        let synth = crate::character::Character {
3406            nickname: nickname.to_string(),
3407            emoji: emoji.to_string(),
3408            palette: crate::character::Palette {
3409                primary_hex: String::new(),
3410                accent_hex: String::new(),
3411                ansi256_primary: 0,
3412                ansi256_accent: 0,
3413            },
3414        };
3415        crate::character::emoji_with_fallback(&synth)
3416    };
3417    if !sisters.is_empty() {
3418        println!();
3419        println!("sister sessions on this machine:");
3420        for s in &sisters {
3421            let session = s["session"].as_str().unwrap_or("?");
3422            let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3423            let glyph = render_glyph(&s["persona"]);
3424            println!("  {glyph} {ch_nick}  ({session})");
3425        }
3426    }
3427    if !peers.is_empty() {
3428        println!();
3429        println!("pinned peers:");
3430        for p in &peers {
3431            let handle = p["handle"].as_str().unwrap_or("?");
3432            let tier = p["tier"].as_str().unwrap_or("");
3433            let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3434            let glyph = render_glyph(&p["persona"]);
3435            println!("  {glyph} {ch_nick}  ({handle})  [{tier}]");
3436        }
3437    }
3438    if sisters.is_empty() && peers.is_empty() {
3439        println!();
3440        println!(
3441            "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3442        );
3443    }
3444    Ok(())
3445}
3446
3447// ---------- dial / whois (v0.8 canonical addressing) ----------
3448
3449/// `wire dial <name> [message]` — the one verb operators reach for.
3450/// Resolves any name (nickname/handle/session/DID) to a peer and
3451/// drives the right pair flow + optional first message. See the
3452/// `Command::Dial` doc for the resolution ladder.
3453///
3454/// v0.9: when `name` contains `@<relay>`, route through the federation
3455/// `wire add <handle>@<relay>` path (`.well-known/wire/agent` resolution
3456/// plus cross-machine pair_drop). No more bail with "federation isn't
3457/// implemented yet" — one verb across both orbits.
3458fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3459    if name.contains('@') {
3460        // Federation path. cmd_add already auto-detects (per v0.7.4)
3461        // when input has `@` and routes through the .well-known
3462        // resolver + pair_drop deposit. After it returns, the peer
3463        // is in pending-outbound; bilateral completes when the peer
3464        // accepts. Optionally send the first message after the add.
3465        cmd_add(name, None, false, true)
3466            .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3467        if let Some(msg) = message {
3468            // Peer handle for send = the nick part before the `@`.
3469            let bare = name.split('@').next().unwrap_or(name);
3470            cmd_send(bare, "claim", msg, None, false, as_json)?;
3471        }
3472        return Ok(());
3473    }
3474
3475    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3476    // success with `{found: false, candidates: [...]}` instead of
3477    // erroring. Agents can branch on `found` without wrapping in a
3478    // try/catch.
3479    let resolution = match resolve_name_to_target(name) {
3480        Ok(r) => r,
3481        Err(e) if as_json => {
3482            let pool = known_local_names();
3483            let suggestions = closest_candidates(name, &pool, 3, 3);
3484            println!(
3485                "{}",
3486                serde_json::to_string(&json!({
3487                    "name_input": name,
3488                    "found": false,
3489                    "candidates": suggestions,
3490                    "error": format!("{e:#}"),
3491                }))?
3492            );
3493            return Ok(());
3494        }
3495        Err(e) => return Err(e),
3496    };
3497    let mut steps: Vec<Value> = Vec::new();
3498
3499    match &resolution {
3500        DialTarget::PinnedPeer { handle, .. } => {
3501            steps.push(json!({
3502                "step": "resolved",
3503                "kind": "already_pinned",
3504                "handle": handle,
3505            }));
3506        }
3507        DialTarget::LocalSister { session_name, .. } => {
3508            steps.push(json!({
3509                "step": "resolved",
3510                "kind": "local_sister",
3511                "session": session_name,
3512            }));
3513            // Drive the bilateral pair via the disk-read sister path.
3514            // cmd_add_local_sister already handles "already paired"
3515            // gracefully (its internal state.peers check returns the
3516            // existing pin instead of re-issuing a pair_drop), so
3517            // re-dialling is idempotent.
3518            cmd_add_local_sister(session_name, true).map_err(|e| {
3519                anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3520            })?;
3521            steps.push(json!({
3522                "step": "paired",
3523                "via": "local_sister",
3524            }));
3525        }
3526    }
3527
3528    let send_handle = match &resolution {
3529        DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3530        DialTarget::LocalSister { handle, .. } => handle.clone(),
3531    };
3532
3533    let send_result = if let Some(msg) = message {
3534        let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3535        match &r {
3536            Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3537            Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3538        }
3539        Some(r)
3540    } else {
3541        None
3542    };
3543
3544    if as_json {
3545        println!(
3546            "{}",
3547            serde_json::to_string(&json!({
3548                "name_input": name,
3549                "resolved_handle": send_handle,
3550                "steps": steps,
3551            }))?
3552        );
3553    } else {
3554        println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3555        for s in &steps {
3556            let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3557            println!("  - {step}");
3558        }
3559        if message.is_some() {
3560            println!("  (use `wire tail {send_handle}` to read replies)");
3561        }
3562    }
3563    if let Some(Err(e)) = send_result {
3564        return Err(e);
3565    }
3566    Ok(())
3567}
3568
3569/// `wire whois <name>` — resolve any local name (nickname/session/
3570/// handle/DID) to the full identity row. The inspector for the
3571/// canonical addressing layer. For federation `handle@relay-domain`
3572/// resolution see `cmd_whois` (line 5536+) — the dispatcher chooses
3573/// based on whether the input contains `@`.
3574fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3575    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3576    // success (exit 0) with `{found: false, candidates: [...]}` so
3577    // agents don't need try/catch around `wire whois <name>`. In
3578    // human mode, the bail's did-you-mean line points at the
3579    // closest candidate.
3580    let resolution = match resolve_name_to_target(name) {
3581        Ok(r) => r,
3582        Err(e) if as_json => {
3583            let pool = known_local_names();
3584            let suggestions = closest_candidates(name, &pool, 3, 3);
3585            println!(
3586                "{}",
3587                serde_json::to_string(&json!({
3588                    "name_input": name,
3589                    "found": false,
3590                    "candidates": suggestions,
3591                    "error": format!("{e:#}"),
3592                }))?
3593            );
3594            return Ok(());
3595        }
3596        Err(e) => return Err(e),
3597    };
3598    match resolution {
3599        DialTarget::PinnedPeer {
3600            handle,
3601            did,
3602            nickname,
3603            emoji,
3604            tier,
3605        } => {
3606            if as_json {
3607                println!(
3608                    "{}",
3609                    serde_json::to_string(&json!({
3610                        "kind": "pinned_peer",
3611                        "handle": handle,
3612                        "did": did,
3613                        "nickname": nickname,
3614                        "emoji": emoji,
3615                        "tier": tier,
3616                    }))?
3617                );
3618            } else {
3619                let n = nickname.as_deref().unwrap_or("(no character)");
3620                let e = emoji.as_deref().unwrap_or("?");
3621                println!("{e} {n}");
3622                println!("  handle:   {handle}");
3623                println!("  did:      {did}");
3624                println!("  tier:     {tier}");
3625                println!("  reach:    pinned peer (already in trust ring + slot pinned)");
3626            }
3627        }
3628        DialTarget::LocalSister {
3629            session_name,
3630            handle,
3631            did,
3632            nickname,
3633            emoji,
3634        } => {
3635            if as_json {
3636                println!(
3637                    "{}",
3638                    serde_json::to_string(&json!({
3639                        "kind": "local_sister",
3640                        "session_name": session_name,
3641                        "handle": handle,
3642                        "did": did,
3643                        "nickname": nickname,
3644                        "emoji": emoji,
3645                    }))?
3646                );
3647            } else {
3648                let n = nickname.as_deref().unwrap_or("(no character)");
3649                let e = emoji.as_deref().unwrap_or("?");
3650                println!("{e} {n}");
3651                println!("  session:  {session_name}");
3652                println!("  handle:   {handle}");
3653                println!(
3654                    "  did:      {}",
3655                    did.as_deref().unwrap_or("(card unreadable)")
3656                );
3657                println!("  reach:    local sister on this machine — `wire dial {n}` pairs us");
3658            }
3659        }
3660    }
3661    Ok(())
3662}
3663
3664enum DialTarget {
3665    PinnedPeer {
3666        handle: String,
3667        did: String,
3668        nickname: Option<String>,
3669        emoji: Option<String>,
3670        tier: String,
3671    },
3672    LocalSister {
3673        session_name: String,
3674        handle: String,
3675        did: Option<String>,
3676        nickname: Option<String>,
3677        emoji: Option<String>,
3678    },
3679}
3680
3681/// Resolution order: pinned peers first (already in our trust ring),
3682/// then local sister sessions (on-disk discovery). Case-insensitive
3683/// match against handle, character nickname, session name, or DID.
3684fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3685    let needle = name.trim();
3686    if needle.is_empty() {
3687        bail!("empty name");
3688    }
3689
3690    // 1. Pinned peers — `wire peers` data. trust.agents is an object
3691    // keyed by handle (not an array); iterate as a map.
3692    if config::is_initialized().unwrap_or(false) {
3693        let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3694        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3695            for (handle_key, agent) in agents {
3696                let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3697                if did.is_empty() {
3698                    continue;
3699                }
3700                let handle = handle_key.clone();
3701                let character = crate::character::Character::from_did(did);
3702                let tier = agent
3703                    .get("tier")
3704                    .and_then(Value::as_str)
3705                    .unwrap_or("UNKNOWN")
3706                    .to_string();
3707                let matches = handle.eq_ignore_ascii_case(needle)
3708                    || did.eq_ignore_ascii_case(needle)
3709                    || character.nickname.eq_ignore_ascii_case(needle);
3710                if matches {
3711                    return Ok(DialTarget::PinnedPeer {
3712                        handle,
3713                        did: did.to_string(),
3714                        nickname: Some(character.nickname),
3715                        emoji: Some(character.emoji.to_string()),
3716                        tier,
3717                    });
3718                }
3719            }
3720        }
3721    }
3722
3723    // 2. Local sister sessions.
3724    if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3725        let sessions = crate::session::list_sessions().unwrap_or_default();
3726        let s = sessions.iter().find(|s| s.name == session_name);
3727        if let Some(s) = s {
3728            return Ok(DialTarget::LocalSister {
3729                session_name: s.name.clone(),
3730                handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3731                did: s.did.clone(),
3732                nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3733                emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3734            });
3735        }
3736    }
3737
3738    // v0.9.2: fuzzy did-you-mean suggestion on resolution miss. Walks
3739    // the union of pinned-peer handles + character nicknames + sister
3740    // session names + sister character nicknames, returns up to 3 names
3741    // within Levenshtein distance 3 of the operator's typed name.
3742    let pool = known_local_names();
3743    let suggestions = closest_candidates(name, &pool, 3, 3);
3744    if suggestions.is_empty() {
3745        bail!(
3746            "no peer matched `{name}`.\n\
3747             Tried: pinned peers (`wire peers`) + local sister sessions \
3748             (`wire session list-local`).\n\
3749             For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3750        );
3751    }
3752    bail!(
3753        "no peer matched `{name}`.\n\
3754         Did you mean: {}?\n\
3755         List all: `wire peers`, `wire session list-local`.",
3756        suggestions
3757            .iter()
3758            .map(|s| format!("`{s}`"))
3759            .collect::<Vec<_>>()
3760            .join(", ")
3761    );
3762}
3763
3764// ---------- tail ----------
3765
3766fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3767    let inbox = config::inbox_dir()?;
3768    if !inbox.exists() {
3769        if !as_json {
3770            eprintln!("no inbox yet — daemon hasn't run, or no events received");
3771        }
3772        return Ok(());
3773    }
3774    let trust = config::read_trust()?;
3775    let mut count = 0usize;
3776
3777    let entries: Vec<_> = std::fs::read_dir(&inbox)?
3778        .filter_map(|e| e.ok())
3779        .map(|e| e.path())
3780        .filter(|p| {
3781            p.extension().map(|x| x == "jsonl").unwrap_or(false)
3782                && match peer {
3783                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3784                    None => true,
3785                }
3786        })
3787        .collect();
3788
3789    for path in entries {
3790        let body = std::fs::read_to_string(&path)?;
3791        for line in body.lines() {
3792            let event: Value = match serde_json::from_str(line) {
3793                Ok(v) => v,
3794                Err(_) => continue,
3795            };
3796            let verified = verify_message_v31(&event, &trust).is_ok();
3797            if as_json {
3798                let mut event_with_meta = event.clone();
3799                if let Some(obj) = event_with_meta.as_object_mut() {
3800                    obj.insert("verified".into(), json!(verified));
3801                }
3802                println!("{}", serde_json::to_string(&event_with_meta)?);
3803            } else {
3804                let ts = event
3805                    .get("timestamp")
3806                    .and_then(Value::as_str)
3807                    .unwrap_or("?");
3808                let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3809                let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3810                let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3811                let summary = event
3812                    .get("body")
3813                    .map(|b| match b {
3814                        Value::String(s) => s.clone(),
3815                        _ => b.to_string(),
3816                    })
3817                    .unwrap_or_default();
3818                let mark = if verified { "✓" } else { "✗" };
3819                let deadline = event
3820                    .get("time_sensitive_until")
3821                    .and_then(Value::as_str)
3822                    .map(|d| format!(" deadline: {d}"))
3823                    .unwrap_or_default();
3824                println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3825            }
3826            count += 1;
3827            if limit > 0 && count >= limit {
3828                return Ok(());
3829            }
3830        }
3831    }
3832    Ok(())
3833}
3834
3835// ---------- monitor (live-tail across all peers, harness-friendly) ----------
3836
3837/// Events filtered out of `wire monitor` by default — pair handshake +
3838/// liveness pings. Operators almost never want these surfaced; an explicit
3839/// `--include-handshake` brings them back.
3840fn monitor_is_noise_kind(kind: &str) -> bool {
3841    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3842}
3843
3844/// Resolve a pinned peer's persona (the DID-derived nickname + emoji,
3845/// respecting an advertised override on their card). `None` if the peer
3846/// isn't in trust or can't be resolved — callers fall back to the handle.
3847fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
3848    let trust = config::read_trust().ok()?;
3849    let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
3850    if let Some(card) = agent.get("card") {
3851        Some(crate::character::Character::from_card(card))
3852    } else {
3853        let did = agent.get("did").and_then(Value::as_str)?;
3854        Some(crate::character::Character::from_did(did))
3855    }
3856}
3857
3858/// "emoji nickname" label for a peer, falling back to the raw handle.
3859fn persona_label(peer_handle: &str) -> String {
3860    match resolve_persona(peer_handle) {
3861        Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
3862        None => peer_handle.to_string(),
3863    }
3864}
3865
3866/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
3867/// full structured event for tooling consumption; the plain form is a tight
3868/// one-line summary suitable as a harness stream-watcher notification.
3869///
3870/// Kept PURE (no trust I/O) so it stays deterministic and cheap per event.
3871/// Persona enrichment for `--json` belongs at InboxEvent construction in
3872/// `inbox_watch` (a follow-up), not here.
3873fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3874    if as_json {
3875        Ok(serde_json::to_string(e)?)
3876    } else {
3877        let eid_short: String = e.event_id.chars().take(12).collect();
3878        let body = e.body_preview.replace('\n', " ");
3879        let ts: String = e.timestamp.chars().take(19).collect();
3880        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3881    }
3882}
3883
3884/// `wire monitor` — long-running line-per-event stream of new inbox events.
3885///
3886/// Built for agent harnesses that have an "every stdout line is a chat
3887/// notification" stream watcher (Claude Code Monitor tool, etc.). One
3888/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
3889/// | python parse | grep -v pair_drop` pipeline operators improvise on day
3890/// one of every wire session.
3891///
3892/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
3893/// pure handshake / liveness noise that operators almost never want
3894/// surfaced. Pass `--include-handshake` if you do.
3895///
3896/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
3897/// doesn't drown the operator in replay), with optional `--replay N` to
3898/// emit the last N events first.
3899fn cmd_monitor(
3900    peer_filter: Option<&str>,
3901    as_json: bool,
3902    include_handshake: bool,
3903    interval_ms: u64,
3904    replay: usize,
3905) -> Result<()> {
3906    let inbox_dir = config::inbox_dir()?;
3907    if !inbox_dir.exists() && !as_json {
3908        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3909    }
3910    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
3911
3912    // Optional replay — read existing files and emit the last `replay` events
3913    // (post-filter) before going live. Useful when the harness restarts and
3914    // wants recent context.
3915    if replay > 0 && inbox_dir.exists() {
3916        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3917        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3918            let path = entry.path();
3919            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3920                continue;
3921            }
3922            let peer = match path.file_stem().and_then(|s| s.to_str()) {
3923                Some(s) => s.to_string(),
3924                None => continue,
3925            };
3926            if let Some(filter) = peer_filter
3927                && peer != filter
3928            {
3929                continue;
3930            }
3931            let body = std::fs::read_to_string(&path).unwrap_or_default();
3932            for line in body.lines() {
3933                let line = line.trim();
3934                if line.is_empty() {
3935                    continue;
3936                }
3937                let signed: Value = match serde_json::from_str(line) {
3938                    Ok(v) => v,
3939                    Err(_) => continue,
3940                };
3941                let ev = crate::inbox_watch::InboxEvent::from_signed(
3942                    &peer, signed, /* verified */ true,
3943                );
3944                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3945                    continue;
3946                }
3947                all.push(ev);
3948            }
3949        }
3950        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
3951        // chronological for same-zoned timestamps).
3952        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3953        let start = all.len().saturating_sub(replay);
3954        for ev in &all[start..] {
3955            println!("{}", monitor_render(ev, as_json)?);
3956        }
3957        use std::io::Write;
3958        std::io::stdout().flush().ok();
3959    }
3960
3961    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
3962    // the first poll only returns events that arrived AFTER startup.
3963    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3964    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3965
3966    loop {
3967        // Never die silently. wisp-blossom (Win10) saw `wire monitor` exit 1
3968        // with ZERO bytes on stdout+stderr when a cursor-block (untrusted
3969        // signer's pair event) tripped the watcher — a silent death looks
3970        // identical to "still watching" and breaks the sister-collab model.
3971        // Surface the reason and KEEP watching instead of propagating a fatal
3972        // `?` that some callers swallow.
3973        let events = match w.poll() {
3974            Ok(evs) => evs,
3975            Err(e) => {
3976                eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
3977                std::thread::sleep(sleep_dur);
3978                continue;
3979            }
3980        };
3981        let mut wrote = false;
3982        for ev in events {
3983            if let Some(filter) = peer_filter
3984                && ev.peer != filter
3985            {
3986                continue;
3987            }
3988            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3989                continue;
3990            }
3991            println!("{}", monitor_render(&ev, as_json)?);
3992            wrote = true;
3993        }
3994        if wrote {
3995            use std::io::Write;
3996            std::io::stdout().flush().ok();
3997        }
3998        std::thread::sleep(sleep_dur);
3999    }
4000}
4001
4002#[cfg(test)]
4003mod tier_tests {
4004    use super::*;
4005    use serde_json::json;
4006
4007    fn trust_with(handle: &str, tier: &str) -> Value {
4008        json!({
4009            "version": 1,
4010            "agents": {
4011                handle: {
4012                    "tier": tier,
4013                    "did": format!("did:wire:{handle}"),
4014                    "card": {"capabilities": ["wire/v3.1"]}
4015                }
4016            }
4017        })
4018    }
4019
4020    #[test]
4021    fn pending_ack_when_verified_but_no_slot_token() {
4022        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
4023        // slot_token hasn't arrived yet. Display PENDING_ACK so the
4024        // operator knows wire send won't work yet.
4025        let trust = trust_with("willard", "VERIFIED");
4026        let relay_state = json!({
4027            "peers": {
4028                "willard": {
4029                    "relay_url": "https://relay",
4030                    "slot_id": "abc",
4031                    "slot_token": "",
4032                }
4033            }
4034        });
4035        assert_eq!(
4036            effective_peer_tier(&trust, &relay_state, "willard"),
4037            "PENDING_ACK"
4038        );
4039    }
4040
4041    #[test]
4042    fn verified_when_slot_token_present() {
4043        let trust = trust_with("willard", "VERIFIED");
4044        let relay_state = json!({
4045            "peers": {
4046                "willard": {
4047                    "relay_url": "https://relay",
4048                    "slot_id": "abc",
4049                    "slot_token": "tok123",
4050                }
4051            }
4052        });
4053        assert_eq!(
4054            effective_peer_tier(&trust, &relay_state, "willard"),
4055            "VERIFIED"
4056        );
4057    }
4058
4059    #[test]
4060    fn raw_tier_passes_through_for_non_verified() {
4061        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
4062        // UNTRUSTED regardless of slot_token state.
4063        let trust = trust_with("willard", "UNTRUSTED");
4064        let relay_state = json!({
4065            "peers": {"willard": {"slot_token": ""}}
4066        });
4067        assert_eq!(
4068            effective_peer_tier(&trust, &relay_state, "willard"),
4069            "UNTRUSTED"
4070        );
4071    }
4072
4073    #[test]
4074    fn pending_ack_when_relay_state_missing_peer() {
4075        // After wire add, trust gets updated BEFORE relay_state.peers does.
4076        // If relay_state has no entry for the peer at all, the operator
4077        // still hasn't completed the bilateral pin — show PENDING_ACK.
4078        let trust = trust_with("willard", "VERIFIED");
4079        let relay_state = json!({"peers": {}});
4080        assert_eq!(
4081            effective_peer_tier(&trust, &relay_state, "willard"),
4082            "PENDING_ACK"
4083        );
4084    }
4085}
4086
4087#[cfg(test)]
4088mod monitor_tests {
4089    use super::*;
4090    use crate::inbox_watch::InboxEvent;
4091    use serde_json::Value;
4092
4093    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4094        InboxEvent {
4095            peer: peer.to_string(),
4096            event_id: "abcd1234567890ef".to_string(),
4097            kind: kind.to_string(),
4098            body_preview: body.to_string(),
4099            verified: true,
4100            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4101            raw: Value::Null,
4102        }
4103    }
4104
4105    #[test]
4106    fn monitor_filter_drops_handshake_kinds_by_default() {
4107        // The whole point: pair_drop / pair_drop_ack / heartbeat are
4108        // protocol noise. If they leak into the operator's chat stream by
4109        // default, the recipe is useless ("wire monitor talks too much,
4110        // disabled it"). Burn this rule in.
4111        assert!(monitor_is_noise_kind("pair_drop"));
4112        assert!(monitor_is_noise_kind("pair_drop_ack"));
4113        assert!(monitor_is_noise_kind("heartbeat"));
4114
4115        // Real-payload kinds — operator wants every one.
4116        assert!(!monitor_is_noise_kind("claim"));
4117        assert!(!monitor_is_noise_kind("decision"));
4118        assert!(!monitor_is_noise_kind("ack"));
4119        assert!(!monitor_is_noise_kind("request"));
4120        assert!(!monitor_is_noise_kind("note"));
4121        // Unknown future kinds shouldn't be filtered as noise either —
4122        // operator probably wants to see something they don't recognise,
4123        // not have it silently dropped (the P0.1 lesson at the UX layer).
4124        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4125    }
4126
4127    #[test]
4128    fn monitor_render_plain_is_one_short_line() {
4129        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4130        let line = monitor_render(&e, false).unwrap();
4131        // Must be single-line.
4132        assert!(!line.contains('\n'), "render must be one line: {line}");
4133        // Must include peer, kind, body fragment, short event_id.
4134        assert!(line.contains("willard"));
4135        assert!(line.contains("claim"));
4136        assert!(line.contains("real v8 train"));
4137        // Short event id (first 12 chars).
4138        assert!(line.contains("abcd12345678"));
4139        assert!(
4140            !line.contains("abcd1234567890ef"),
4141            "should truncate full id"
4142        );
4143        // RFC3339-ish second precision.
4144        assert!(line.contains("2026-05-15T23:14:07"));
4145    }
4146
4147    #[test]
4148    fn monitor_render_strips_newlines_from_body() {
4149        // Multi-line bodies (markdown lists, code, etc.) must collapse to
4150        // one line — otherwise a single message produces multiple
4151        // notifications in the harness, ruining the "one event = one line"
4152        // contract the Monitor tool relies on.
4153        let e = ev("spark", "claim", "line one\nline two\nline three");
4154        let line = monitor_render(&e, false).unwrap();
4155        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4156        assert!(line.contains("line one line two line three"));
4157    }
4158
4159    #[test]
4160    fn monitor_render_json_is_valid_jsonl() {
4161        let e = ev("spark", "claim", "hi");
4162        let line = monitor_render(&e, true).unwrap();
4163        assert!(!line.contains('\n'));
4164        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4165        assert_eq!(parsed["peer"], "spark");
4166        assert_eq!(parsed["kind"], "claim");
4167        assert_eq!(parsed["body_preview"], "hi");
4168    }
4169
4170    #[test]
4171    fn monitor_does_not_drop_on_verified_null() {
4172        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
4173        // `select(.verified == true)` against inbox JSONL. Daemon writes
4174        // events with verified=null (verification happens at tail-time, not
4175        // write-time), so the filter silently rejected everything — same
4176        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
4177        // never surfaced for ~30min.
4178        //
4179        // wire monitor's render path must NOT consult `.verified` for any
4180        // filter decision. Lock that in here so a future "be conservative,
4181        // only emit verified" patch can't quietly land.
4182        let mut e = ev("spark", "claim", "from disk with verified=null");
4183        e.verified = false; // worst case — even if disk says unverified, emit
4184        let line = monitor_render(&e, false).unwrap();
4185        assert!(line.contains("from disk with verified=null"));
4186        // Noise filter operates purely on kind, never on verified.
4187        assert!(!monitor_is_noise_kind("claim"));
4188    }
4189}
4190
4191// ---------- verify ----------
4192
4193fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4194    let body = if path == "-" {
4195        let mut buf = String::new();
4196        use std::io::Read;
4197        std::io::stdin().read_to_string(&mut buf)?;
4198        buf
4199    } else {
4200        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4201    };
4202    let event: Value = serde_json::from_str(&body)?;
4203    let trust = config::read_trust()?;
4204    match verify_message_v31(&event, &trust) {
4205        Ok(()) => {
4206            if as_json {
4207                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4208            } else {
4209                println!("verified ✓");
4210            }
4211            Ok(())
4212        }
4213        Err(e) => {
4214            let reason = e.to_string();
4215            if as_json {
4216                println!(
4217                    "{}",
4218                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4219                );
4220            } else {
4221                eprintln!("FAILED: {reason}");
4222            }
4223            std::process::exit(1);
4224        }
4225    }
4226}
4227
4228// ---------- mcp / relay-server stubs ----------
4229
4230fn cmd_mcp() -> Result<()> {
4231    crate::mcp::run()
4232}
4233
4234fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4235    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
4236    // overriding --bind. Implies --local-only semantics. Routed to a
4237    // separate serve_uds entry point with a manual hyper accept loop
4238    // (axum 0.7's `serve` is TcpListener-only).
4239    if let Some(socket_path) = uds {
4240        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4241            std::path::PathBuf::from(home)
4242                .join("state")
4243                .join("wire-relay")
4244                .join("uds")
4245        } else {
4246            dirs::state_dir()
4247                .or_else(dirs::data_local_dir)
4248                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4249                .join("wire-relay")
4250                .join("uds")
4251        };
4252        let runtime = tokio::runtime::Builder::new_multi_thread()
4253            .enable_all()
4254            .build()?;
4255        return runtime.block_on(crate::relay_server::serve_uds(
4256            socket_path.to_path_buf(),
4257            base,
4258        ));
4259    }
4260    // v0.5.17: --local-only refuses non-loopback binds. Catches the
4261    // "wait did I just bind a publicly-reachable local-only relay" mistake
4262    // at startup rather than discovering it via an empty phonebook later.
4263    if local_only {
4264        validate_loopback_bind(bind)?;
4265    }
4266    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
4267    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
4268    // so a single user can run both client and server on one machine.
4269    // For --local-only, suffix with /local so a single operator can run
4270    // both a federation relay and a local-only relay without state collision.
4271    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4272        std::path::PathBuf::from(home)
4273            .join("state")
4274            .join("wire-relay")
4275    } else {
4276        dirs::state_dir()
4277            .or_else(dirs::data_local_dir)
4278            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4279            .join("wire-relay")
4280    };
4281    let state_dir = if local_only { base.join("local") } else { base };
4282    let runtime = tokio::runtime::Builder::new_multi_thread()
4283        .enable_all()
4284        .build()?;
4285    runtime.block_on(crate::relay_server::serve_with_mode(
4286        bind,
4287        state_dir,
4288        crate::relay_server::ServerMode { local_only },
4289    ))
4290}
4291
4292/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
4293/// resolves to something outside `127.0.0.0/8` or `::1`.
4294///
4295/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
4296/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
4297/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
4298///
4299/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
4300/// is the IP range Tailscale uses for tailnet addresses. Lets operators
4301/// pair wire across machines using their tailnet IPs (e.g. Mac at
4302/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
4303/// auth + encryption + NAT traversal, wire handles protocol + identity.
4304/// Sidesteps host firewall config entirely (utun interface bypass).
4305///
4306/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
4307/// multicast, broadcast. Those would publish a "local-only" relay to
4308/// the global internet — the v0.5.17 security gate's whole point.
4309fn validate_loopback_bind(bind: &str) -> Result<()> {
4310    // Split host:port. IPv6 literals use `[::]:port` form.
4311    let host = if let Some(stripped) = bind.strip_prefix('[') {
4312        let close = stripped
4313            .find(']')
4314            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4315        stripped[..close].to_string()
4316    } else {
4317        bind.rsplit_once(':')
4318            .map(|(h, _)| h.to_string())
4319            .unwrap_or_else(|| bind.to_string())
4320    };
4321    use std::net::{IpAddr, ToSocketAddrs};
4322    let probe = format!("{host}:0");
4323    let resolved: Vec<_> = probe
4324        .to_socket_addrs()
4325        .with_context(|| format!("resolving bind host {host:?}"))?
4326        .collect();
4327    if resolved.is_empty() {
4328        bail!("--local-only: bind host {host:?} resolved to no addresses");
4329    }
4330    for addr in &resolved {
4331        let ip = addr.ip();
4332        let is_acceptable = match ip {
4333            IpAddr::V4(v4) => {
4334                v4.is_loopback() || v4.is_private() || {
4335                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
4336                    let octets = v4.octets();
4337                    octets[0] == 100 && (64..=127).contains(&octets[1])
4338                }
4339            }
4340            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
4341        };
4342        if !is_acceptable {
4343            bail!(
4344                "--local-only refuses non-private bind: {host:?} resolves to {} \
4345                 which is not loopback (127/8, ::1), RFC 1918 private \
4346                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4347                 (100.64.0.0/10). Remove --local-only to bind publicly.",
4348                ip
4349            );
4350        }
4351    }
4352    Ok(())
4353}
4354
4355// ---------- bind-relay ----------
4356
4357fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4358    use crate::endpoints::EndpointScope;
4359    match s.to_lowercase().as_str() {
4360        "federation" | "fed" => Ok(EndpointScope::Federation),
4361        "local" => Ok(EndpointScope::Local),
4362        "lan" => Ok(EndpointScope::Lan),
4363        "uds" => Ok(EndpointScope::Uds),
4364        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4365    }
4366}
4367
4368/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
4369/// appended to `self.endpoints[]`, keeping any existing slots so an agent
4370/// can hold a local relay AND a federation relay simultaneously without
4371/// black-holing pinned peers. `--replace` restores the pre-v0.12
4372/// destructive single-slot behavior (guarded by issue #7).
4373fn cmd_bind_relay(
4374    url: &str,
4375    scope: Option<&str>,
4376    replace: bool,
4377    migrate_pinned: bool,
4378    as_json: bool,
4379) -> Result<()> {
4380    use crate::endpoints::{Endpoint, self_endpoints};
4381
4382    if !config::is_initialized()? {
4383        bail!("not initialized — run `wire init <handle>` first");
4384    }
4385    let card = config::read_agent_card()?;
4386    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4387    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4388
4389    let normalized = url.trim_end_matches('/');
4390    let new_scope = match scope {
4391        Some(s) => parse_scope(s)?,
4392        None => crate::endpoints::infer_scope_from_url(normalized),
4393    };
4394
4395    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4396    let pinned: Vec<String> = existing
4397        .get("peers")
4398        .and_then(|p| p.as_object())
4399        .map(|o| o.keys().cloned().collect())
4400        .unwrap_or_default();
4401
4402    let existing_eps = self_endpoints(&existing);
4403    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4404
4405    // Destructive paths that black-hole pinned peers (issue #7):
4406    //   • `--replace` drops every other slot.
4407    //   • re-binding the SAME relay rotates that slot in place.
4408    // An additive bind of a NEW relay keeps existing slots, so peers stay
4409    // reachable — no acknowledgement required. This is the v0.12 default
4410    // that unblocks simultaneous local + remote.
4411    let destructive = replace || is_rebind_same;
4412    if destructive && !pinned.is_empty() && !migrate_pinned {
4413        let list = pinned.join(", ");
4414        let why = if replace {
4415            "`--replace` drops your other slot(s)"
4416        } else {
4417            "re-binding the same relay rotates its slot"
4418        };
4419        bail!(
4420            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4421             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4422             read.\n\n\
4423             SAFE PATHS:\n\
4424             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4425             slots — no black-hole.\n\
4426             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4427             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4428             peer out-of-band.\n\n\
4429             Issue #7 (silent black-hole on relay change) caught this.",
4430            n = pinned.len(),
4431        );
4432    }
4433
4434    let client = crate::relay_client::RelayClient::new(normalized);
4435    client.check_healthz()?;
4436    let alloc = client.allocate_slot(Some(&handle))?;
4437
4438    if destructive && !pinned.is_empty() {
4439        eprintln!(
4440            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4441             until they re-pin: {peers}",
4442            mode = if replace { "replacing" } else { "rotating" },
4443            n = pinned.len(),
4444            peers = pinned.join(", "),
4445        );
4446    }
4447
4448    // Write the new slot via the single source of truth for the self-slot
4449    // shape. Additive by default; --replace starts from an empty self so
4450    // only this slot remains.
4451    let mut state = existing;
4452    if replace {
4453        state["self"] = Value::Null;
4454    }
4455    crate::endpoints::upsert_self_endpoint(
4456        &mut state,
4457        Endpoint {
4458            relay_url: normalized.to_string(),
4459            slot_id: alloc.slot_id.clone(),
4460            slot_token: alloc.slot_token.clone(),
4461            scope: new_scope,
4462        },
4463    );
4464    config::write_relay_state(&state)?;
4465    let eps = self_endpoints(&state);
4466
4467    let scope_str = format!("{new_scope:?}").to_lowercase();
4468    if as_json {
4469        println!(
4470            "{}",
4471            serde_json::to_string(&json!({
4472                "relay_url": normalized,
4473                "slot_id": alloc.slot_id,
4474                "scope": scope_str,
4475                "endpoints": eps.len(),
4476                "additive": !replace,
4477                "slot_token_present": true,
4478            }))?
4479        );
4480    } else {
4481        println!(
4482            "bound {scope_str} slot on {normalized} (slot {})",
4483            alloc.slot_id
4484        );
4485        println!(
4486            "self now has {n} endpoint(s): {list}",
4487            n = eps.len(),
4488            list = eps
4489                .iter()
4490                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4491                .collect::<Vec<_>>()
4492                .join(", "),
4493        );
4494    }
4495    Ok(())
4496}
4497
4498// ---------- add-peer-slot ----------
4499
4500fn cmd_add_peer_slot(
4501    handle: &str,
4502    url: &str,
4503    slot_id: &str,
4504    slot_token: &str,
4505    as_json: bool,
4506) -> Result<()> {
4507    use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
4508    let mut state = config::read_relay_state()?;
4509
4510    // E3 (v0.13.2): ADD this slot to the peer's endpoint set — don't REPLACE
4511    // the whole entry. The old flat `peers.insert` clobbered an existing
4512    // peer's federation endpoint when pinning a local slot, silently dropping
4513    // the federation route (glossy-magnolia + wisp-blossom repro: pinning a
4514    // loopback slot made the peer flat loopback-only). Mirror bind-relay's
4515    // additive semantics: upsert by relay_url into the peer's endpoints[].
4516    let new_ep = Endpoint {
4517        relay_url: url.to_string(),
4518        slot_id: slot_id.to_string(),
4519        slot_token: slot_token.to_string(),
4520        scope: infer_scope_from_url(url),
4521    };
4522    let mut endpoints: Vec<Endpoint> = state
4523        .get("peers")
4524        .and_then(|p| p.get(handle))
4525        .and_then(|e| e.get("endpoints"))
4526        .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
4527        .unwrap_or_default();
4528    // Back-compat: seed from legacy flat fields when the peer predates endpoints[].
4529    if endpoints.is_empty()
4530        && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
4531        && let (Some(ru), Some(si), Some(st)) = (
4532            peer.get("relay_url").and_then(Value::as_str),
4533            peer.get("slot_id").and_then(Value::as_str),
4534            peer.get("slot_token").and_then(Value::as_str),
4535        )
4536    {
4537        endpoints.push(Endpoint {
4538            relay_url: ru.to_string(),
4539            slot_id: si.to_string(),
4540            slot_token: st.to_string(),
4541            scope: infer_scope_from_url(ru),
4542        });
4543    }
4544    // Upsert by relay_url: refresh in place if already pinned, else append.
4545    if let Some(existing) = endpoints
4546        .iter_mut()
4547        .find(|e| e.relay_url == new_ep.relay_url)
4548    {
4549        *existing = new_ep;
4550    } else {
4551        endpoints.push(new_ep);
4552    }
4553    let n = endpoints.len();
4554    pin_peer_endpoints(&mut state, handle, &endpoints)?;
4555    config::write_relay_state(&state)?;
4556    if as_json {
4557        println!(
4558            "{}",
4559            serde_json::to_string(&json!({
4560                "handle": handle,
4561                "relay_url": url,
4562                "slot_id": slot_id,
4563                "added": true,
4564                "endpoint_count": n,
4565            }))?
4566        );
4567    } else {
4568        println!(
4569            "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
4570        );
4571    }
4572    Ok(())
4573}
4574
4575// ---------- push ----------
4576
4577fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4578    let state = config::read_relay_state()?;
4579    let peers = state["peers"].as_object().cloned().unwrap_or_default();
4580    if peers.is_empty() {
4581        bail!(
4582            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4583        );
4584    }
4585    let outbox_dir = config::outbox_dir()?;
4586    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
4587    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
4588    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
4589    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
4590    if outbox_dir.exists() {
4591        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4592        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4593            let path = entry.path();
4594            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4595                continue;
4596            }
4597            let stem = match path.file_stem().and_then(|s| s.to_str()) {
4598                Some(s) => s.to_string(),
4599                None => continue,
4600            };
4601            if pinned.contains(&stem) {
4602                continue;
4603            }
4604            // Try the bare-handle of the orphaned stem — if THAT matches a
4605            // pinned peer, the stem is a stale FQDN-suffixed file.
4606            let bare = crate::agent_card::bare_handle(&stem);
4607            if pinned.contains(bare) {
4608                eprintln!(
4609                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4610                     Merge with: `cat {} >> {}` then delete the FQDN file.",
4611                    stem,
4612                    path.display(),
4613                    outbox_dir.join(format!("{bare}.jsonl")).display(),
4614                );
4615            }
4616        }
4617    }
4618    if !outbox_dir.exists() {
4619        if as_json {
4620            println!(
4621                "{}",
4622                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4623            );
4624        } else {
4625            println!("phyllis: nothing to dial out — write a message first with `wire send`");
4626        }
4627        return Ok(());
4628    }
4629
4630    let mut pushed = Vec::new();
4631    let mut skipped = Vec::new();
4632
4633    // v0.5.17: walk each peer's pinned endpoints in priority order (local
4634    // first if we share a local relay, federation second). Try POST on the
4635    // first endpoint; on transport failure, fall through to the next.
4636    // Falls back to the v0.5.16 legacy single-endpoint code path when the
4637    // peer record carries no `endpoints[]` array (back-compat).
4638    for (peer_handle, _) in peers.iter() {
4639        if let Some(want) = peer_filter
4640            && peer_handle != want
4641        {
4642            continue;
4643        }
4644        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4645        if !outbox.exists() {
4646            continue;
4647        }
4648        let ordered_endpoints =
4649            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4650        if ordered_endpoints.is_empty() {
4651            // Unreachable peer (no federation endpoint AND our local
4652            // relay doesn't match the peer's). Skip with a loud reason
4653            // rather than silently dropping events.
4654            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4655                let event: Value = match serde_json::from_str(line) {
4656                    Ok(v) => v,
4657                    Err(_) => continue,
4658                };
4659                let event_id = event
4660                    .get("event_id")
4661                    .and_then(Value::as_str)
4662                    .unwrap_or("")
4663                    .to_string();
4664                skipped.push(json!({
4665                    "peer": peer_handle,
4666                    "event_id": event_id,
4667                    "reason": "no reachable endpoint pinned for peer",
4668                }));
4669            }
4670            continue;
4671        }
4672        let body = std::fs::read_to_string(&outbox)?;
4673        for line in body.lines() {
4674            let event: Value = match serde_json::from_str(line) {
4675                Ok(v) => v,
4676                Err(_) => continue,
4677            };
4678            let event_id = event
4679                .get("event_id")
4680                .and_then(Value::as_str)
4681                .unwrap_or("")
4682                .to_string();
4683
4684            let mut delivered = false;
4685            let mut last_err_reason: Option<String> = None;
4686            for endpoint in &ordered_endpoints {
4687                let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4688                match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
4689                    Ok(resp) => {
4690                        if resp.status == "duplicate" {
4691                            skipped.push(json!({
4692                                "peer": peer_handle,
4693                                "event_id": event_id,
4694                                "reason": "duplicate",
4695                                "endpoint": endpoint.relay_url,
4696                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4697                            }));
4698                        } else {
4699                            pushed.push(json!({
4700                                "peer": peer_handle,
4701                                "event_id": event_id,
4702                                "endpoint": endpoint.relay_url,
4703                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4704                            }));
4705                        }
4706                        delivered = true;
4707                        break;
4708                    }
4709                    Err(e) => {
4710                        // Local-first endpoint failed; record reason and
4711                        // try the next endpoint silently (operator sees
4712                        // the federation success). If every endpoint
4713                        // fails, the last reason is what gets reported.
4714                        last_err_reason = Some(crate::relay_client::format_transport_error(&e));
4715                    }
4716                }
4717            }
4718            if !delivered {
4719                skipped.push(json!({
4720                    "peer": peer_handle,
4721                    "event_id": event_id,
4722                    "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
4723                }));
4724            }
4725        }
4726    }
4727
4728    if as_json {
4729        println!(
4730            "{}",
4731            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
4732        );
4733    } else {
4734        println!(
4735            "pushed {} event(s); skipped {} ({})",
4736            pushed.len(),
4737            skipped.len(),
4738            if skipped.is_empty() {
4739                "none"
4740            } else {
4741                "see --json for detail"
4742            }
4743        );
4744    }
4745    Ok(())
4746}
4747
4748// ---------- pull ----------
4749
4750fn cmd_pull(as_json: bool) -> Result<()> {
4751    let state = config::read_relay_state()?;
4752    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4753    if self_state.is_null() {
4754        bail!("self slot not bound — run `wire bind-relay <url>` first");
4755    }
4756
4757    // v0.5.17: pull from every endpoint in self.endpoints (federation +
4758    // optional local). Each endpoint has its own per-scope cursor so we
4759    // don't re-pull events we've already seen on that path. Events from
4760    // all endpoints feed into the same inbox JSONL via process_events;
4761    // dedup by event_id is the last line of defense.
4762    // Falls back to a single federation endpoint synthesized from the
4763    // top-level legacy fields when self.endpoints is absent (v0.5.16
4764    // back-compat).
4765    let endpoints = crate::endpoints::self_endpoints(&state);
4766    if endpoints.is_empty() {
4767        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
4768    }
4769
4770    let inbox_dir = config::inbox_dir()?;
4771    config::ensure_dirs()?;
4772
4773    let mut total_seen = 0usize;
4774    let mut all_written: Vec<Value> = Vec::new();
4775    let mut all_rejected: Vec<Value> = Vec::new();
4776    let mut all_blocked = false;
4777    let mut all_advance_cursor_to: Option<String> = None;
4778
4779    for endpoint in &endpoints {
4780        let cursor_key = endpoint_cursor_key(endpoint.scope);
4781        let last_event_id = self_state
4782            .get(&cursor_key)
4783            .and_then(Value::as_str)
4784            .map(str::to_string);
4785        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4786        let events = match client.list_events(
4787            &endpoint.slot_id,
4788            &endpoint.slot_token,
4789            last_event_id.as_deref(),
4790            Some(1000),
4791        ) {
4792            Ok(ev) => ev,
4793            Err(e) => {
4794                // One endpoint's failure shouldn't kill the whole pull.
4795                // The local-relay-down case in particular needs to
4796                // gracefully continue against federation.
4797                eprintln!(
4798                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
4799                    endpoint.relay_url,
4800                    endpoint.scope,
4801                    crate::relay_client::format_transport_error(&e),
4802                );
4803                continue;
4804            }
4805        };
4806        total_seen += events.len();
4807        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
4808        all_written.extend(result.written.iter().cloned());
4809        all_rejected.extend(result.rejected.iter().cloned());
4810        if result.blocked {
4811            all_blocked = true;
4812        }
4813        // Advance per-endpoint cursor. The cursor key is scope-specific
4814        // so federation and local don't trample each other.
4815        if let Some(eid) = result.advance_cursor_to.clone() {
4816            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
4817                all_advance_cursor_to = Some(eid.clone());
4818            }
4819            let key = cursor_key.clone();
4820            config::update_relay_state(|state| {
4821                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4822                    self_obj.insert(key, Value::String(eid));
4823                }
4824                Ok(())
4825            })?;
4826        }
4827    }
4828
4829    // Compatibility shim for the legacy single-cursor code paths below:
4830    // `result` used to come from one process_events call; we now have
4831    // per-endpoint results aggregated into the all_* accumulators.
4832    // Reconstruct a synthetic result for the remaining display logic.
4833    let result = crate::pull::PullResult {
4834        written: all_written,
4835        rejected: all_rejected,
4836        blocked: all_blocked,
4837        advance_cursor_to: all_advance_cursor_to,
4838    };
4839    let events_len = total_seen;
4840
4841    // Cursor advance happened per-endpoint above; no aggregate cursor
4842    // write needed here.
4843
4844    if as_json {
4845        println!(
4846            "{}",
4847            serde_json::to_string(&json!({
4848                "written": result.written,
4849                "rejected": result.rejected,
4850                "total_seen": events_len,
4851                "cursor_blocked": result.blocked,
4852                "cursor_advanced_to": result.advance_cursor_to,
4853            }))?
4854        );
4855    } else {
4856        let blocking = result
4857            .rejected
4858            .iter()
4859            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
4860            .count();
4861        if blocking > 0 {
4862            println!(
4863                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
4864                events_len,
4865                result.written.len(),
4866                result.rejected.len(),
4867                blocking,
4868            );
4869        } else {
4870            println!(
4871                "pulled {} event(s); wrote {}; rejected {}",
4872                events_len,
4873                result.written.len(),
4874                result.rejected.len(),
4875            );
4876        }
4877    }
4878    Ok(())
4879}
4880
4881/// v0.5.17: cursor key for an endpoint's per-scope read position.
4882/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
4883/// back-compat with on-disk relay_state files; local uses a
4884/// `_local` suffix.
4885fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4886    match scope {
4887        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4888        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4889        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4890        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4891    }
4892}
4893
4894// ---------- rotate-slot ----------
4895
4896fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4897    if !config::is_initialized()? {
4898        bail!("not initialized — run `wire init <handle>` first");
4899    }
4900    let mut state = config::read_relay_state()?;
4901    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4902    if self_state.is_null() {
4903        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4904    }
4905    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
4906    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
4907    // top-level legacy fields directly and bailed for those sessions.
4908    let primary = crate::endpoints::self_primary_endpoint(&state)
4909        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
4910    let url = primary.relay_url.clone();
4911    let old_slot_id = primary.slot_id.clone();
4912    let old_slot_token = primary.slot_token.clone();
4913
4914    // Read identity to sign the announcement.
4915    let card = config::read_agent_card()?;
4916    let did = card
4917        .get("did")
4918        .and_then(Value::as_str)
4919        .unwrap_or("")
4920        .to_string();
4921    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4922    let pk_b64 = card
4923        .get("verify_keys")
4924        .and_then(Value::as_object)
4925        .and_then(|m| m.values().next())
4926        .and_then(|v| v.get("key"))
4927        .and_then(Value::as_str)
4928        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4929        .to_string();
4930    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4931    let sk_seed = config::read_private_key()?;
4932
4933    // Allocate new slot on the same relay.
4934    let normalized = url.trim_end_matches('/').to_string();
4935    let client = crate::relay_client::RelayClient::new(&normalized);
4936    client
4937        .check_healthz()
4938        .context("aborting rotation; old slot still valid")?;
4939    let alloc = client.allocate_slot(Some(&handle))?;
4940    let new_slot_id = alloc.slot_id.clone();
4941    let new_slot_token = alloc.slot_token.clone();
4942
4943    // Optionally announce the rotation to every paired peer via the OLD slot.
4944    // Each peer's recipient-side `wire pull` will pick up this event before
4945    // their daemon next polls the new slot — but auto-update of peer's
4946    // relay.json from a wire_close event is a v0.2 daemon feature; for now
4947    // peers see the event and an operator must manually `add-peer-slot` the
4948    // new coords, OR re-pair via SAS.
4949    let mut announced: Vec<String> = Vec::new();
4950    if !no_announce {
4951        let now = time::OffsetDateTime::now_utc()
4952            .format(&time::format_description::well_known::Rfc3339)
4953            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4954        let body = json!({
4955            "reason": "operator-initiated slot rotation",
4956            "new_relay_url": url,
4957            "new_slot_id": new_slot_id,
4958            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
4959            // In v0.1 slot tokens are bilateral-shared, so peer can post via
4960            // existing add-peer-slot flow if operator chooses to re-issue.
4961        });
4962        let peers = state["peers"].as_object().cloned().unwrap_or_default();
4963        for (peer_handle, _peer_info) in peers.iter() {
4964            let event = json!({
4965                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4966                "timestamp": now.clone(),
4967                "from": did,
4968                "to": format!("did:wire:{peer_handle}"),
4969                "type": "wire_close",
4970                "kind": 1201,
4971                "body": body.clone(),
4972            });
4973            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4974                Ok(s) => s,
4975                Err(e) => {
4976                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4977                    continue;
4978                }
4979            };
4980            // Post to OUR old slot (we're announcing on our own slot, NOT
4981            // peer's slot — peer reads from us). Wait, this is wrong: peers
4982            // read from THEIR OWN slot via wire pull. To reach peer A, we
4983            // post to peer A's slot. Use the existing per-peer slot mapping.
4984            let peer_info = match state["peers"].get(peer_handle) {
4985                Some(p) => p.clone(),
4986                None => continue,
4987            };
4988            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4989            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4990            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4991            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4992                continue;
4993            }
4994            let peer_client = if peer_url == url {
4995                client.clone()
4996            } else {
4997                crate::relay_client::RelayClient::new(peer_url)
4998            };
4999            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
5000                Ok(_) => announced.push(peer_handle.clone()),
5001                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
5002            }
5003        }
5004    }
5005
5006    // Swap the self-slot to the new one.
5007    state["self"] = json!({
5008        "relay_url": url,
5009        "slot_id": new_slot_id,
5010        "slot_token": new_slot_token,
5011    });
5012    config::write_relay_state(&state)?;
5013
5014    if as_json {
5015        println!(
5016            "{}",
5017            serde_json::to_string(&json!({
5018                "rotated": true,
5019                "old_slot_id": old_slot_id,
5020                "new_slot_id": new_slot_id,
5021                "relay_url": url,
5022                "announced_to": announced,
5023            }))?
5024        );
5025    } else {
5026        println!("rotated slot on {url}");
5027        println!(
5028            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
5029        );
5030        println!("  new slot_id: {new_slot_id}");
5031        if !announced.is_empty() {
5032            println!(
5033                "  announced wire_close (kind=1201) to: {}",
5034                announced.join(", ")
5035            );
5036        }
5037        println!();
5038        println!("next steps:");
5039        println!("  - peers see the wire_close event in their next `wire pull`");
5040        println!(
5041            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
5042        );
5043        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
5044        println!("  - until they do, you'll receive but they won't be able to reach you");
5045        // Suppress unused warning
5046        let _ = old_slot_token;
5047    }
5048    Ok(())
5049}
5050
5051// ---------- forget-peer ----------
5052
5053fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
5054    let mut trust = config::read_trust()?;
5055    let mut removed_from_trust = false;
5056    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
5057        && agents.remove(handle).is_some()
5058    {
5059        removed_from_trust = true;
5060    }
5061    config::write_trust(&trust)?;
5062
5063    let mut state = config::read_relay_state()?;
5064    let mut removed_from_relay = false;
5065    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
5066        && peers.remove(handle).is_some()
5067    {
5068        removed_from_relay = true;
5069    }
5070    config::write_relay_state(&state)?;
5071
5072    let mut purged: Vec<String> = Vec::new();
5073    if purge {
5074        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
5075            let path = dir.join(format!("{handle}.jsonl"));
5076            if path.exists() {
5077                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
5078                purged.push(path.to_string_lossy().into());
5079            }
5080        }
5081    }
5082
5083    if !removed_from_trust && !removed_from_relay {
5084        if as_json {
5085            println!(
5086                "{}",
5087                serde_json::to_string(&json!({
5088                    "removed": false,
5089                    "reason": format!("peer {handle:?} not pinned"),
5090                }))?
5091            );
5092        } else {
5093            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
5094        }
5095        return Ok(());
5096    }
5097
5098    if as_json {
5099        println!(
5100            "{}",
5101            serde_json::to_string(&json!({
5102                "handle": handle,
5103                "removed_from_trust": removed_from_trust,
5104                "removed_from_relay_state": removed_from_relay,
5105                "purged_files": purged,
5106            }))?
5107        );
5108    } else {
5109        println!("forgot peer {handle:?}");
5110        if removed_from_trust {
5111            println!("  - removed from trust.json");
5112        }
5113        if removed_from_relay {
5114            println!("  - removed from relay.json");
5115        }
5116        if !purged.is_empty() {
5117            for p in &purged {
5118                println!("  - deleted {p}");
5119            }
5120        } else if !purge {
5121            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
5122        }
5123    }
5124    Ok(())
5125}
5126
5127// ---------- daemon (long-lived push+pull sync) ----------
5128
5129fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5130    if !config::is_initialized()? {
5131        bail!("not initialized — run `wire init <handle>` first");
5132    }
5133    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5134
5135    if !as_json {
5136        if once {
5137            eprintln!("wire daemon: single sync cycle, then exit");
5138        } else {
5139            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5140        }
5141    }
5142
5143    // Recover from prior crash: any pending pair in transient state had its
5144    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
5145    // the relay slots and mark the files so the operator can re-issue.
5146    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5147        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5148    }
5149
5150    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
5151    // to our slot, the subscriber signals `wake_rx`; we use it as the
5152    // sleep-or-wake gate of the polling loop. Polling stays as the
5153    // safety net — stream errors fall back transparently to the existing
5154    // interval-based cadence.
5155    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5156    if !once {
5157        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5158    }
5159
5160    loop {
5161        let pushed = run_sync_push().unwrap_or_else(|e| {
5162            eprintln!("daemon: push error: {e:#}");
5163            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5164        });
5165        let pulled = run_sync_pull().unwrap_or_else(|e| {
5166            eprintln!("daemon: pull error: {e:#}");
5167            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5168        });
5169        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5170            eprintln!("daemon: pending-pair tick error: {e:#}");
5171            json!({"transitions": []})
5172        });
5173
5174        if as_json {
5175            println!(
5176                "{}",
5177                serde_json::to_string(&json!({
5178                    "ts": time::OffsetDateTime::now_utc()
5179                        .format(&time::format_description::well_known::Rfc3339)
5180                        .unwrap_or_default(),
5181                    "push": pushed,
5182                    "pull": pulled,
5183                    "pairs": pairs,
5184                }))?
5185            );
5186        } else {
5187            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5188            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5189            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5190            let pair_transitions = pairs["transitions"]
5191                .as_array()
5192                .map(|a| a.len())
5193                .unwrap_or(0);
5194            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5195                eprintln!(
5196                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5197                );
5198            }
5199            // Loud per-transition logging so operator sees pair progress live.
5200            if let Some(arr) = pairs["transitions"].as_array() {
5201                for t in arr {
5202                    eprintln!(
5203                        "  pair {} : {} → {}",
5204                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
5205                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
5206                        t.get("to").and_then(Value::as_str).unwrap_or("?")
5207                    );
5208                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
5209                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5210                    {
5211                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
5212                        eprintln!(
5213                            "    Run: wire pair-confirm {} {}",
5214                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
5215                            sas
5216                        );
5217                    }
5218                }
5219            }
5220        }
5221
5222        if once {
5223            return Ok(());
5224        }
5225        // Wait either for the next poll-interval tick OR for a stream
5226        // wake signal — whichever comes first. Drain any additional
5227        // wake-ups that accumulated during the previous cycle since one
5228        // pull catches up everything.
5229        //
5230        // v0.13.2 (wisp-blossom): if the stream subscriber thread has gone
5231        // away, `wake_rx` is Disconnected and `recv_timeout` returns
5232        // INSTANTLY — which would busy-spin the sync loop (hammering push/pull
5233        // + the relay with zero delay). Fall back to a plain sleep so a dead
5234        // stream degrades to normal polling and never kills or pegs the
5235        // daemon. (Realizes the "decouple stream from sync" hardening — a
5236        // stream failure must never affect the push/pull loop.)
5237        match wake_rx.recv_timeout(interval) {
5238            Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
5239            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
5240                std::thread::sleep(interval);
5241            }
5242        }
5243        while wake_rx.try_recv().is_ok() {}
5244    }
5245}
5246
5247/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
5248/// shape `wire push --json` emits.
5249fn run_sync_push() -> Result<Value> {
5250    let state = config::read_relay_state()?;
5251    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5252    if peers.is_empty() {
5253        return Ok(json!({"pushed": [], "skipped": []}));
5254    }
5255    let outbox_dir = config::outbox_dir()?;
5256    if !outbox_dir.exists() {
5257        return Ok(json!({"pushed": [], "skipped": []}));
5258    }
5259    let mut pushed = Vec::new();
5260    let mut skipped = Vec::new();
5261    for (peer_handle, slot_info) in peers.iter() {
5262        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5263        if !outbox.exists() {
5264            continue;
5265        }
5266        let url = slot_info["relay_url"].as_str().unwrap_or("");
5267        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5268        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5269        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5270            continue;
5271        }
5272        let client = crate::relay_client::RelayClient::new(url);
5273        let body = std::fs::read_to_string(&outbox)?;
5274        for line in body.lines() {
5275            let event: Value = match serde_json::from_str(line) {
5276                Ok(v) => v,
5277                Err(_) => continue,
5278            };
5279            let event_id = event
5280                .get("event_id")
5281                .and_then(Value::as_str)
5282                .unwrap_or("")
5283                .to_string();
5284            match client.post_event(slot_id, slot_token, &event) {
5285                Ok(resp) => {
5286                    if resp.status == "duplicate" {
5287                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5288                    } else {
5289                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5290                    }
5291                }
5292                Err(e) => {
5293                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
5294                    // errors aren't hidden behind the topmost-context URL string.
5295                    // Issue #6 highest-impact silent-fail fix.
5296                    let reason = crate::relay_client::format_transport_error(&e);
5297                    skipped
5298                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5299                }
5300            }
5301        }
5302    }
5303    Ok(json!({"pushed": pushed, "skipped": skipped}))
5304}
5305
5306/// Programmatic pull. Same shape as `wire pull --json`.
5307///
5308/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
5309/// created via `wire session new --with-local` (which only writes
5310/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
5311/// Pre-v0.9 this function read only the top-level fields and silently
5312/// returned `{}` for any v0.5.17+ session.
5313fn run_sync_pull() -> Result<Value> {
5314    let state = config::read_relay_state()?;
5315    if state.get("self").map(Value::is_null).unwrap_or(true) {
5316        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5317    }
5318    // E2 (v0.13.2): pull EVERY self endpoint, not just the primary. A session
5319    // that bound a local slot (additive) alongside its federation slot used to
5320    // have the daemon pull ONLY the primary (federation) endpoint — the local
5321    // slot was never serviced, so same-box loopback delivery silently never
5322    // happened until a manual restart re-seeded the (startup-only) stream
5323    // subscriber. Now each endpoint is pulled with its OWN cursor.
5324    let endpoints = crate::endpoints::self_endpoints(&state);
5325    if endpoints.is_empty() {
5326        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5327    }
5328    let inbox_dir = config::inbox_dir()?;
5329    config::ensure_dirs()?;
5330
5331    // Per-slot cursors live at `self.cursors.<slot_id>`. The legacy global
5332    // `self.last_pulled_event_id` is migrated as the cursor for the PRIMARY
5333    // slot only (a federation event id won't match a local slot's log); other
5334    // slots start from None and `process_events` dedups against the inbox.
5335    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
5336    let legacy_cursor = self_obj
5337        .get("last_pulled_event_id")
5338        .and_then(Value::as_str)
5339        .map(str::to_string);
5340    let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
5341    let mut cursors: serde_json::Map<String, Value> = self_obj
5342        .get("cursors")
5343        .and_then(Value::as_object)
5344        .cloned()
5345        .unwrap_or_default();
5346
5347    let mut all_written: Vec<Value> = Vec::new();
5348    let mut all_rejected: Vec<Value> = Vec::new();
5349    let mut total_seen = 0usize;
5350    let mut blocked_any = false;
5351
5352    for ep in &endpoints {
5353        if ep.relay_url.is_empty() {
5354            continue;
5355        }
5356        let cursor = cursors
5357            .get(&ep.slot_id)
5358            .and_then(Value::as_str)
5359            .map(str::to_string)
5360            .or_else(|| {
5361                if Some(&ep.slot_id) == primary_slot.as_ref() {
5362                    legacy_cursor.clone()
5363                } else {
5364                    None
5365                }
5366            });
5367        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
5368        // One endpoint erroring (relay down, slot gone) must NOT stop the
5369        // others — a dead local relay shouldn't black-hole federation pulls.
5370        let events =
5371            match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
5372                Ok(e) => e,
5373                Err(e) => {
5374                    eprintln!(
5375                        "daemon: pull error on {} slot {} (continuing): {e:#}",
5376                        ep.relay_url, ep.slot_id
5377                    );
5378                    continue;
5379                }
5380            };
5381        total_seen += events.len();
5382        // P0.1 shared cursor-blocking logic (matches `wire pull`). A block on
5383        // one slot only stalls THAT slot's cursor; other slots keep flowing.
5384        let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
5385        if let Some(eid) = &result.advance_cursor_to {
5386            cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
5387        }
5388        blocked_any |= result.blocked;
5389        all_written.extend(result.written);
5390        all_rejected.extend(result.rejected);
5391    }
5392
5393    // P0.3 flock-protected RMW: persist per-slot cursors + keep the legacy
5394    // global cursor in sync with the primary slot for back-compat with older
5395    // binaries that only read `last_pulled_event_id`.
5396    let primary_cursor = primary_slot
5397        .as_ref()
5398        .and_then(|s| cursors.get(s))
5399        .and_then(Value::as_str)
5400        .map(str::to_string);
5401    config::update_relay_state(|state| {
5402        if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5403            self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
5404            if let Some(pc) = &primary_cursor {
5405                self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
5406            }
5407        }
5408        Ok(())
5409    })?;
5410
5411    Ok(json!({
5412        "written": all_written,
5413        "rejected": all_rejected,
5414        "total_seen": total_seen,
5415        "cursor_blocked": blocked_any,
5416        "endpoints_pulled": endpoints.len(),
5417    }))
5418}
5419
5420// ---------- pin (manual out-of-band peer pairing) ----------
5421
5422fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5423    let body =
5424        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5425    let card: Value =
5426        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5427    crate::agent_card::verify_agent_card(&card)
5428        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5429
5430    let mut trust = config::read_trust()?;
5431    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5432
5433    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5434    let handle = crate::agent_card::display_handle_from_did(did).to_string();
5435    config::write_trust(&trust)?;
5436
5437    if as_json {
5438        println!(
5439            "{}",
5440            serde_json::to_string(&json!({
5441                "handle": handle,
5442                "did": did,
5443                "tier": "VERIFIED",
5444                "pinned": true,
5445            }))?
5446        );
5447    } else {
5448        println!("pinned {handle} ({did}) at tier VERIFIED");
5449    }
5450    Ok(())
5451}
5452
5453// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
5454
5455fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5456    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5457}
5458
5459fn cmd_pair_join(
5460    code_phrase: &str,
5461    relay_url: &str,
5462    auto_yes: bool,
5463    timeout_secs: u64,
5464) -> Result<()> {
5465    pair_orchestrate(
5466        relay_url,
5467        Some(code_phrase),
5468        "guest",
5469        auto_yes,
5470        timeout_secs,
5471    )
5472}
5473
5474/// Shared orchestration for both sides of the SAS pairing.
5475///
5476/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
5477/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
5478/// `pair_session_confirm_sas` instead.
5479fn pair_orchestrate(
5480    relay_url: &str,
5481    code_in: Option<&str>,
5482    role: &str,
5483    auto_yes: bool,
5484    timeout_secs: u64,
5485) -> Result<()> {
5486    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5487
5488    let mut s = pair_session_open(role, relay_url, code_in)?;
5489
5490    if role == "host" {
5491        eprintln!();
5492        eprintln!("share this code phrase with your peer:");
5493        eprintln!();
5494        eprintln!("    {}", s.code);
5495        eprintln!();
5496        eprintln!(
5497            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5498            s.code
5499        );
5500    } else {
5501        eprintln!();
5502        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5503    }
5504
5505    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
5506    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
5507    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
5508    // see the process is alive while the other side connects.
5509    const HEARTBEAT_SECS: u64 = 10;
5510    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5511    let started = std::time::Instant::now();
5512    let mut last_heartbeat = started;
5513    let formatted = loop {
5514        if let Some(sas) = pair_session_try_sas(&mut s)? {
5515            break sas;
5516        }
5517        let now = std::time::Instant::now();
5518        if now >= deadline {
5519            return Err(anyhow!(
5520                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5521            ));
5522        }
5523        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5524            let elapsed = now.duration_since(started).as_secs();
5525            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
5526            last_heartbeat = now;
5527        }
5528        std::thread::sleep(std::time::Duration::from_millis(250));
5529    };
5530
5531    eprintln!();
5532    eprintln!("SAS digits (must match peer's terminal):");
5533    eprintln!();
5534    eprintln!("    {formatted}");
5535    eprintln!();
5536
5537    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
5538    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
5539    if !auto_yes {
5540        eprint!("does this match your peer's terminal? [y/N]: ");
5541        use std::io::Write;
5542        std::io::stderr().flush().ok();
5543        let mut input = String::new();
5544        std::io::stdin().read_line(&mut input)?;
5545        let trimmed = input.trim().to_lowercase();
5546        if trimmed != "y" && trimmed != "yes" {
5547            bail!("SAS confirmation declined — aborting pairing");
5548        }
5549    }
5550    s.sas_confirmed = true;
5551
5552    // Stage 4 — seal+exchange bootstrap, pin peer.
5553    let result = pair_session_finalize(&mut s, timeout_secs)?;
5554
5555    let peer_did = result["paired_with"].as_str().unwrap_or("");
5556    let peer_role = if role == "host" { "guest" } else { "host" };
5557    eprintln!("paired with {peer_did} (peer role: {peer_role})");
5558    eprintln!("peer card pinned at tier VERIFIED");
5559    eprintln!(
5560        "peer relay slot saved to {}",
5561        config::relay_state_path()?.display()
5562    );
5563
5564    println!("{}", serde_json::to_string(&result)?);
5565    Ok(())
5566}
5567
5568// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
5569// and pair_session_finalize, both of which inline their own deadline loops.)
5570
5571// ---------- pair — single-shot init + pair-* + setup ----------
5572
5573fn cmd_pair(
5574    handle: &str,
5575    code: Option<&str>,
5576    relay: &str,
5577    auto_yes: bool,
5578    timeout_secs: u64,
5579    no_setup: bool,
5580) -> Result<()> {
5581    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
5582    // bails loudly if a different handle is already set (operator must explicitly delete).
5583    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5584    let did = init_result
5585        .get("did")
5586        .and_then(|v| v.as_str())
5587        .unwrap_or("(unknown)")
5588        .to_string();
5589    let already = init_result
5590        .get("already_initialized")
5591        .and_then(|v| v.as_bool())
5592        .unwrap_or(false);
5593    if already {
5594        println!("(identity {did} already initialized — reusing)");
5595    } else {
5596        println!("initialized {did}");
5597    }
5598    println!();
5599
5600    // Step 2 — pair-host or pair-join based on code presence.
5601    match code {
5602        None => {
5603            println!("hosting pair on {relay} (no code = host) ...");
5604            cmd_pair_host(relay, auto_yes, timeout_secs)?;
5605        }
5606        Some(c) => {
5607            println!("joining pair with code {c} on {relay} ...");
5608            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5609        }
5610    }
5611
5612    // Step 3 — register wire as MCP server in detected client configs (idempotent).
5613    if !no_setup {
5614        println!();
5615        println!("registering wire as MCP server in detected client configs ...");
5616        if let Err(e) = cmd_setup(true) {
5617            // Non-fatal — pair succeeded, just print the warning.
5618            eprintln!("warn: setup --apply failed: {e}");
5619            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
5620        }
5621    }
5622
5623    println!();
5624    println!("pair complete. Next steps:");
5625    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
5626    println!("  wire send <peer> claim <msg>   # send your peer something");
5627    println!("  wire tail                      # watch incoming events");
5628    Ok(())
5629}
5630
5631// ---------- detached pair (daemon-orchestrated) ----------
5632
5633/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
5634/// pair-host/-join into a single command. The non-detached variant lives in
5635/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
5636fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5637    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5638    let did = init_result
5639        .get("did")
5640        .and_then(|v| v.as_str())
5641        .unwrap_or("(unknown)")
5642        .to_string();
5643    let already = init_result
5644        .get("already_initialized")
5645        .and_then(|v| v.as_bool())
5646        .unwrap_or(false);
5647    if already {
5648        println!("(identity {did} already initialized — reusing)");
5649    } else {
5650        println!("initialized {did}");
5651    }
5652    println!();
5653    match code {
5654        None => cmd_pair_host_detach(relay, false),
5655        Some(c) => cmd_pair_join_detach(c, relay, false),
5656    }
5657}
5658
5659fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5660    if !config::is_initialized()? {
5661        bail!("not initialized — run `wire init <handle>` first");
5662    }
5663    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5664        Ok(b) => b,
5665        Err(e) => {
5666            if !as_json {
5667                eprintln!(
5668                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5669                );
5670            }
5671            false
5672        }
5673    };
5674    let code = crate::sas::generate_code_phrase();
5675    let code_hash = crate::pair_session::derive_code_hash(&code);
5676    let now = time::OffsetDateTime::now_utc()
5677        .format(&time::format_description::well_known::Rfc3339)
5678        .unwrap_or_default();
5679    let p = crate::pending_pair::PendingPair {
5680        code: code.clone(),
5681        code_hash,
5682        role: "host".to_string(),
5683        relay_url: relay_url.to_string(),
5684        status: "request_host".to_string(),
5685        sas: None,
5686        peer_did: None,
5687        created_at: now,
5688        last_error: None,
5689        pair_id: None,
5690        our_slot_id: None,
5691        our_slot_token: None,
5692        spake2_seed_b64: None,
5693    };
5694    crate::pending_pair::write_pending(&p)?;
5695    if as_json {
5696        println!(
5697            "{}",
5698            serde_json::to_string(&json!({
5699                "state": "queued",
5700                "code_phrase": code,
5701                "relay_url": relay_url,
5702                "role": "host",
5703                "daemon_spawned": daemon_spawned,
5704            }))?
5705        );
5706    } else {
5707        if daemon_spawned {
5708            println!("(started wire daemon in background)");
5709        }
5710        println!("detached pair-host queued. Share this code with your peer:\n");
5711        println!("    {code}\n");
5712        println!("Next steps:");
5713        println!("  wire pair-list                                # check status");
5714        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
5715        println!("  wire pair-cancel  {code}            # to abort");
5716    }
5717    Ok(())
5718}
5719
5720fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
5721    if !config::is_initialized()? {
5722        bail!("not initialized — run `wire init <handle>` first");
5723    }
5724    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5725        Ok(b) => b,
5726        Err(e) => {
5727            if !as_json {
5728                eprintln!(
5729                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5730                );
5731            }
5732            false
5733        }
5734    };
5735    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5736    let code_hash = crate::pair_session::derive_code_hash(&code);
5737    let now = time::OffsetDateTime::now_utc()
5738        .format(&time::format_description::well_known::Rfc3339)
5739        .unwrap_or_default();
5740    let p = crate::pending_pair::PendingPair {
5741        code: code.clone(),
5742        code_hash,
5743        role: "guest".to_string(),
5744        relay_url: relay_url.to_string(),
5745        status: "request_guest".to_string(),
5746        sas: None,
5747        peer_did: None,
5748        created_at: now,
5749        last_error: None,
5750        pair_id: None,
5751        our_slot_id: None,
5752        our_slot_token: None,
5753        spake2_seed_b64: None,
5754    };
5755    crate::pending_pair::write_pending(&p)?;
5756    if as_json {
5757        println!(
5758            "{}",
5759            serde_json::to_string(&json!({
5760                "state": "queued",
5761                "code_phrase": code,
5762                "relay_url": relay_url,
5763                "role": "guest",
5764                "daemon_spawned": daemon_spawned,
5765            }))?
5766        );
5767    } else {
5768        if daemon_spawned {
5769            println!("(started wire daemon in background)");
5770        }
5771        println!("detached pair-join queued for code {code}.");
5772        println!(
5773            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
5774        );
5775    }
5776    Ok(())
5777}
5778
5779fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
5780    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5781    let typed: String = typed_digits
5782        .chars()
5783        .filter(|c| c.is_ascii_digit())
5784        .collect();
5785    if typed.len() != 6 {
5786        bail!(
5787            "expected 6 digits (got {} after stripping non-digits)",
5788            typed.len()
5789        );
5790    }
5791    let mut p = crate::pending_pair::read_pending(&code)?
5792        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
5793    if p.status != "sas_ready" {
5794        bail!(
5795            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
5796            p.status
5797        );
5798    }
5799    let stored = p
5800        .sas
5801        .as_ref()
5802        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
5803        .clone();
5804    if stored == typed {
5805        p.status = "confirmed".to_string();
5806        crate::pending_pair::write_pending(&p)?;
5807        if as_json {
5808            println!(
5809                "{}",
5810                serde_json::to_string(&json!({
5811                    "state": "confirmed",
5812                    "code_phrase": code,
5813                }))?
5814            );
5815        } else {
5816            println!("digits match. Daemon will finalize the handshake on its next tick.");
5817            println!("Run `wire peers` after a few seconds to confirm.");
5818        }
5819    } else {
5820        p.status = "aborted".to_string();
5821        p.last_error = Some(format!(
5822            "SAS digit mismatch (typed {typed}, expected {stored})"
5823        ));
5824        let client = crate::relay_client::RelayClient::new(&p.relay_url);
5825        let _ = client.pair_abandon(&p.code_hash);
5826        crate::pending_pair::write_pending(&p)?;
5827        crate::os_notify::toast(
5828            &format!("wire — pair aborted ({})", p.code),
5829            p.last_error.as_deref().unwrap_or("digits mismatch"),
5830        );
5831        if as_json {
5832            println!(
5833                "{}",
5834                serde_json::to_string(&json!({
5835                    "state": "aborted",
5836                    "code_phrase": code,
5837                    "error": "digits mismatch",
5838                }))?
5839            );
5840        }
5841        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
5842    }
5843    Ok(())
5844}
5845
5846fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
5847    if watch {
5848        return cmd_pair_list_watch(watch_interval_secs);
5849    }
5850    let spake2_items = crate::pending_pair::list_pending()?;
5851    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
5852    if as_json {
5853        // Backwards-compat: flat SPAKE2 array (the shape every existing
5854        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
5855        // surface programmatically via `wire pair-list-inbound --json`
5856        // and via `wire status --json` `pending_pairs.inbound_*` fields.
5857        println!("{}", serde_json::to_string(&spake2_items)?);
5858        return Ok(());
5859    }
5860    if spake2_items.is_empty() && inbound_items.is_empty() {
5861        println!("no pending pair sessions.");
5862        return Ok(());
5863    }
5864    // v0.5.14: inbound section first — these need operator action right now.
5865    // SPAKE2 sessions are typically already mid-flow.
5866    if !inbound_items.is_empty() {
5867        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
5868        println!(
5869            "{:<20} {:<35} {:<25} NEXT STEP",
5870            "PEER", "RELAY", "RECEIVED"
5871        );
5872        for p in &inbound_items {
5873            println!(
5874                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
5875                p.peer_handle,
5876                p.peer_relay_url,
5877                p.received_at,
5878                peer = p.peer_handle,
5879            );
5880        }
5881        println!();
5882    }
5883    if !spake2_items.is_empty() {
5884        println!("SPAKE2 SESSIONS");
5885        println!(
5886            "{:<15} {:<8} {:<18} {:<10} NOTE",
5887            "CODE", "ROLE", "STATUS", "SAS"
5888        );
5889        for p in spake2_items {
5890            let sas = p
5891                .sas
5892                .as_ref()
5893                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
5894                .unwrap_or_else(|| "—".to_string());
5895            let note = p
5896                .last_error
5897                .as_deref()
5898                .or(p.peer_did.as_deref())
5899                .unwrap_or("");
5900            println!(
5901                "{:<15} {:<8} {:<18} {:<10} {}",
5902                p.code, p.role, p.status, sas, note
5903            );
5904        }
5905    }
5906    Ok(())
5907}
5908
5909/// Stream-mode pair-list: never exits. Diffs per-code state every
5910/// `interval_secs` and prints one JSON line per transition (creation,
5911/// status flip, deletion). Useful for shell pipelines:
5912///
5913/// ```text
5914/// wire pair-list --watch | while read line; do
5915///     CODE=$(echo "$line" | jq -r .code)
5916///     STATUS=$(echo "$line" | jq -r .status)
5917///     ...
5918/// done
5919/// ```
5920fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
5921    use std::collections::HashMap;
5922    use std::io::Write;
5923    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5924    // Emit a snapshot synthetic event for every currently-pending pair on
5925    // startup so a consumer that arrives mid-flight sees the current state.
5926    let mut prev: HashMap<String, String> = HashMap::new();
5927    {
5928        let items = crate::pending_pair::list_pending()?;
5929        for p in &items {
5930            println!("{}", serde_json::to_string(&p)?);
5931            prev.insert(p.code.clone(), p.status.clone());
5932        }
5933        // Flush so the consumer's `while read` gets the snapshot promptly.
5934        let _ = std::io::stdout().flush();
5935    }
5936    loop {
5937        std::thread::sleep(interval);
5938        let items = match crate::pending_pair::list_pending() {
5939            Ok(v) => v,
5940            Err(_) => continue,
5941        };
5942        let mut cur: HashMap<String, String> = HashMap::new();
5943        for p in &items {
5944            cur.insert(p.code.clone(), p.status.clone());
5945            match prev.get(&p.code) {
5946                None => {
5947                    // New code appeared.
5948                    println!("{}", serde_json::to_string(&p)?);
5949                }
5950                Some(prev_status) if prev_status != &p.status => {
5951                    // Status flipped.
5952                    println!("{}", serde_json::to_string(&p)?);
5953                }
5954                _ => {}
5955            }
5956        }
5957        for code in prev.keys() {
5958            if !cur.contains_key(code) {
5959                // File disappeared → finalized or cancelled. Emit a synthetic
5960                // "removed" marker so the consumer sees the terminal event.
5961                println!(
5962                    "{}",
5963                    serde_json::to_string(&json!({
5964                        "code": code,
5965                        "status": "removed",
5966                        "_synthetic": true,
5967                    }))?
5968                );
5969            }
5970        }
5971        let _ = std::io::stdout().flush();
5972        prev = cur;
5973    }
5974}
5975
5976/// Block until a pending pair reaches `target_status` or terminates. Process
5977/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
5978/// timeout) so shell scripts can branch directly.
5979fn cmd_pair_watch(
5980    code_phrase: &str,
5981    target_status: &str,
5982    timeout_secs: u64,
5983    as_json: bool,
5984) -> Result<()> {
5985    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5986    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5987    let mut last_seen_status: Option<String> = None;
5988    loop {
5989        let p_opt = crate::pending_pair::read_pending(&code)?;
5990        let now = std::time::Instant::now();
5991        match p_opt {
5992            None => {
5993                // File gone — either finalized (success if target=sas_ready
5994                // since finalization implies it passed sas_ready) or never
5995                // existed. Distinguish by whether we ever saw it.
5996                if last_seen_status.is_some() {
5997                    if as_json {
5998                        println!(
5999                            "{}",
6000                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
6001                        );
6002                    } else {
6003                        println!("pair {code} finalized (file removed)");
6004                    }
6005                    return Ok(());
6006                } else {
6007                    if as_json {
6008                        println!(
6009                            "{}",
6010                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
6011                        );
6012                    }
6013                    std::process::exit(1);
6014                }
6015            }
6016            Some(p) => {
6017                let cur = p.status.clone();
6018                if Some(cur.clone()) != last_seen_status {
6019                    if as_json {
6020                        // Emit per-transition line so scripts can stream.
6021                        println!("{}", serde_json::to_string(&p)?);
6022                    }
6023                    last_seen_status = Some(cur.clone());
6024                }
6025                if cur == target_status {
6026                    if !as_json {
6027                        let sas_str = p
6028                            .sas
6029                            .as_ref()
6030                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
6031                            .unwrap_or_else(|| "—".to_string());
6032                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
6033                    }
6034                    return Ok(());
6035                }
6036                if cur == "aborted" || cur == "aborted_restart" {
6037                    if !as_json {
6038                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
6039                        eprintln!("pair {code} {cur}: {err}");
6040                    }
6041                    std::process::exit(1);
6042                }
6043            }
6044        }
6045        if now >= deadline {
6046            if !as_json {
6047                eprintln!(
6048                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
6049                );
6050            }
6051            std::process::exit(2);
6052        }
6053        std::thread::sleep(std::time::Duration::from_millis(250));
6054    }
6055}
6056
6057fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
6058    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6059    let p = crate::pending_pair::read_pending(&code)?
6060        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
6061    let client = crate::relay_client::RelayClient::new(&p.relay_url);
6062    let _ = client.pair_abandon(&p.code_hash);
6063    crate::pending_pair::delete_pending(&code)?;
6064    if as_json {
6065        println!(
6066            "{}",
6067            serde_json::to_string(&json!({
6068                "state": "cancelled",
6069                "code_phrase": code,
6070            }))?
6071        );
6072    } else {
6073        println!("cancelled pending pair {code} (relay slot released, file removed).");
6074    }
6075    Ok(())
6076}
6077
6078// ---------- pair-abandon — release stuck pair-slot ----------
6079
6080fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
6081    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
6082    // typed — normalize via the existing parser.
6083    let code = crate::sas::parse_code_phrase(code_phrase)?;
6084    let code_hash = crate::pair_session::derive_code_hash(code);
6085    let client = crate::relay_client::RelayClient::new(relay_url);
6086    client.pair_abandon(&code_hash)?;
6087    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
6088    println!("host can now issue a fresh code; guest can re-join.");
6089    Ok(())
6090}
6091
6092// ---------- invite / accept — one-paste pair (v0.4.0) ----------
6093
6094fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6095    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6096
6097    // If --share, register the invite at the relay's short-URL endpoint and
6098    // build the one-curl onboarding line for the peer to paste.
6099    let share_payload: Option<Value> = if share {
6100        let client = reqwest::blocking::Client::new();
6101        let single_use = if uses == 1 { Some(1u32) } else { None };
6102        let body = json!({
6103            "invite_url": url,
6104            "ttl_seconds": ttl,
6105            "uses": single_use,
6106        });
6107        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6108        let resp = client.post(&endpoint).json(&body).send()?;
6109        if !resp.status().is_success() {
6110            let code = resp.status();
6111            let txt = resp.text().unwrap_or_default();
6112            bail!("relay {code} on /v1/invite/register: {txt}");
6113        }
6114        let parsed: Value = resp.json()?;
6115        let token = parsed
6116            .get("token")
6117            .and_then(Value::as_str)
6118            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6119            .to_string();
6120        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6121        let curl_line = format!("curl -fsSL {share_url} | sh");
6122        Some(json!({
6123            "token": token,
6124            "share_url": share_url,
6125            "curl": curl_line,
6126            "expires_unix": parsed.get("expires_unix"),
6127        }))
6128    } else {
6129        None
6130    };
6131
6132    if as_json {
6133        let mut out = json!({
6134            "invite_url": url,
6135            "ttl_secs": ttl,
6136            "uses": uses,
6137            "relay": relay,
6138        });
6139        if let Some(s) = &share_payload {
6140            out["share"] = s.clone();
6141        }
6142        println!("{}", serde_json::to_string(&out)?);
6143    } else if let Some(s) = share_payload {
6144        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6145        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6146        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6147        println!("{curl}");
6148    } else {
6149        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6150        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6151        println!("{url}");
6152    }
6153    Ok(())
6154}
6155
6156fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6157    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
6158    // resolve it to the underlying wire://pair?... URL via ?format=url before
6159    // accepting. Saves them from having to know which URL shape goes where.
6160    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6161        let sep = if url.contains('?') { '&' } else { '?' };
6162        let resolve_url = format!("{url}{sep}format=url");
6163        let client = reqwest::blocking::Client::new();
6164        let resp = client
6165            .get(&resolve_url)
6166            .send()
6167            .with_context(|| format!("GET {resolve_url}"))?;
6168        if !resp.status().is_success() {
6169            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6170        }
6171        let body = resp.text().unwrap_or_default().trim().to_string();
6172        if !body.starts_with("wire://pair?") {
6173            bail!(
6174                "short URL {url} did not resolve to a wire:// invite. \
6175                 (got: {}{})",
6176                body.chars().take(80).collect::<String>(),
6177                if body.chars().count() > 80 { "…" } else { "" }
6178            );
6179        }
6180        body
6181    } else {
6182        url.to_string()
6183    };
6184
6185    let result = crate::pair_invite::accept_invite(&resolved)?;
6186    if as_json {
6187        println!("{}", serde_json::to_string(&result)?);
6188    } else {
6189        let did = result
6190            .get("paired_with")
6191            .and_then(Value::as_str)
6192            .unwrap_or("?");
6193        println!("paired with {did}");
6194        println!(
6195            "you can now: wire send {} <kind> <body>",
6196            crate::agent_card::display_handle_from_did(did)
6197        );
6198    }
6199    Ok(())
6200}
6201
6202// ---------- whois / profile (v0.5) ----------
6203
6204fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6205    if let Some(h) = handle {
6206        let parsed = crate::pair_profile::parse_handle(h)?;
6207        // Special-case: if the supplied handle matches our own, skip the
6208        // network round-trip and print local.
6209        if config::is_initialized()? {
6210            let card = config::read_agent_card()?;
6211            let local_handle = card
6212                .get("profile")
6213                .and_then(|p| p.get("handle"))
6214                .and_then(Value::as_str)
6215                .map(str::to_string);
6216            if local_handle.as_deref() == Some(h) {
6217                return cmd_whois(None, as_json, None);
6218            }
6219        }
6220        // Remote resolution via .well-known/wire/agent on the handle's domain.
6221        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6222        if as_json {
6223            println!("{}", serde_json::to_string(&resolved)?);
6224        } else {
6225            print_resolved_profile(&resolved);
6226        }
6227        return Ok(());
6228    }
6229    let card = config::read_agent_card()?;
6230    if as_json {
6231        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6232        println!(
6233            "{}",
6234            serde_json::to_string(&json!({
6235                "did": card.get("did").cloned().unwrap_or(Value::Null),
6236                "profile": profile,
6237            }))?
6238        );
6239    } else {
6240        print!("{}", crate::pair_profile::render_self_summary()?);
6241    }
6242    Ok(())
6243}
6244
6245fn print_resolved_profile(resolved: &Value) {
6246    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6247    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6248    let relay = resolved
6249        .get("relay_url")
6250        .and_then(Value::as_str)
6251        .unwrap_or("");
6252    let slot = resolved
6253        .get("slot_id")
6254        .and_then(Value::as_str)
6255        .unwrap_or("");
6256    let profile = resolved
6257        .get("card")
6258        .and_then(|c| c.get("profile"))
6259        .cloned()
6260        .unwrap_or(Value::Null);
6261    println!("{did}");
6262    println!("  nick:         {nick}");
6263    if !relay.is_empty() {
6264        println!("  relay_url:    {relay}");
6265    }
6266    if !slot.is_empty() {
6267        println!("  slot_id:      {slot}");
6268    }
6269    let pick =
6270        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6271    if let Some(s) = pick("display_name") {
6272        println!("  display_name: {s}");
6273    }
6274    if let Some(s) = pick("emoji") {
6275        println!("  emoji:        {s}");
6276    }
6277    if let Some(s) = pick("motto") {
6278        println!("  motto:        {s}");
6279    }
6280    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6281        let joined: Vec<String> = arr
6282            .iter()
6283            .filter_map(|v| v.as_str().map(str::to_string))
6284            .collect();
6285        println!("  vibe:         {}", joined.join(", "));
6286    }
6287    if let Some(s) = pick("pronouns") {
6288        println!("  pronouns:     {s}");
6289    }
6290}
6291
6292/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
6293/// signed pair_drop event with our card + slot coords, deliver via the
6294/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
6295/// Peer's daemon completes the bilateral pin on its next pull and emits a
6296/// pair_drop_ack carrying their slot_token so we can send back.
6297/// Extract just the host portion from `https://host:port/path` → `host`.
6298/// Returns empty string if the URL is malformed.
6299fn host_of_url(url: &str) -> String {
6300    let no_scheme = url
6301        .trim_start_matches("https://")
6302        .trim_start_matches("http://");
6303    no_scheme
6304        .split('/')
6305        .next()
6306        .unwrap_or("")
6307        .split(':')
6308        .next()
6309        .unwrap_or("")
6310        .to_string()
6311}
6312
6313/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
6314/// operator's own relay? Used to suppress the cross-relay phishing
6315/// warning in `wire add` for the happy path.
6316fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6317    // Hard-coded known-good list. wireup.net is the default relay.
6318    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6319    let peer_domain = peer_domain.trim().to_ascii_lowercase();
6320    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6321        return true;
6322    }
6323    // Operator's OWN relay is implicitly trusted — they're already
6324    // bound to it; pairing same-relay peers is the common case.
6325    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6326    if !our_host.is_empty() && our_host == peer_domain {
6327        return true;
6328    }
6329    false
6330}
6331
6332/// v0.6.6: pair with a sister session on this machine without federation.
6333/// Reads the sister's agent-card + endpoints from disk, pins them into our
6334/// trust + relay_state, builds the same `pair_drop` event the federation
6335/// path would emit, then POSTs it directly to the sister's local-relay slot.
6336/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
6337/// the cwd-derived `wire`) are addressable because the local relay never
6338/// needed a public claim for sister coordination.
6339/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
6340/// to a local sister session.
6341///
6342/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
6343/// either form. Exact session-name matches always win; nickname matches
6344/// are a fallback so operators can type "winter-bay" instead of "wire".
6345/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
6346/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
6347/// with the candidate list so the caller can surface a disambiguation
6348/// hint instead of silently picking one.
6349fn resolve_local_session<'a>(
6350    sessions: &'a [crate::session::SessionInfo],
6351    input: &str,
6352) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6353    // Exact session-name match always wins, even if a nickname elsewhere
6354    // also matches. Predictable for scripts and operator muscle memory.
6355    if let Some(s) = sessions.iter().find(|s| s.name == input) {
6356        return Ok(s);
6357    }
6358    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6359        .iter()
6360        .filter(|s| {
6361            s.character
6362                .as_ref()
6363                .map(|c| c.nickname == input)
6364                .unwrap_or(false)
6365        })
6366        .collect();
6367    match nick_matches.len() {
6368        0 => Err(ResolveError::NotFound),
6369        1 => Ok(nick_matches[0]),
6370        _ => Err(ResolveError::Ambiguous(
6371            nick_matches.iter().map(|s| s.name.clone()).collect(),
6372        )),
6373    }
6374}
6375
6376#[derive(Debug)]
6377enum ResolveError {
6378    NotFound,
6379    Ambiguous(Vec<String>),
6380}
6381
6382/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
6383/// to a pinned peer's canonical handle.
6384///
6385/// `wire send <peer>` accepts either the handle the peer registered with
6386/// or their character nickname (DID-hash-derived). Exact handle match
6387/// always wins. When a nickname matches multiple peers (theoretically
6388/// possible via DID-hash collision in the (adj, noun) space), returns
6389/// `Ambiguous` so the caller can surface a disambiguation hint instead
6390/// of silently picking one.
6391///
6392/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
6393/// overrides on the peer's side live in their local `display.json` and
6394/// aren't yet published via agent-card. (That's the v0.7+ federation
6395/// lifecycle work — peers publishing overrides so we resolve by what
6396/// they call themselves, not just what their DID hashes to.)
6397fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6398    let trust = match config::read_trust() {
6399        Ok(t) => t,
6400        Err(_) => return Ok(None),
6401    };
6402    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6403        Some(a) => a,
6404        None => return Ok(None),
6405    };
6406    if agents.contains_key(input) {
6407        return Ok(Some(input.to_string()));
6408    }
6409    let mut nick_matches: Vec<String> = Vec::new();
6410    for (handle, agent) in agents.iter() {
6411        // v0.7.0-alpha.6: prefer peer's published display nickname over
6412        // auto-derived. Allows `wire send <their-chosen-name>` not just
6413        // `wire send <their-did-hash-derived-name>`.
6414        let character = match agent.get("card") {
6415            Some(card) => crate::character::Character::from_card(card),
6416            None => match agent.get("did").and_then(Value::as_str) {
6417                Some(did) => crate::character::Character::from_did(did),
6418                None => continue,
6419            },
6420        };
6421        if character.nickname == input {
6422            nick_matches.push(handle.clone());
6423        }
6424    }
6425    match nick_matches.len() {
6426        0 => Ok(None),
6427        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6428        _ => Err(ResolveError::Ambiguous(nick_matches)),
6429    }
6430}
6431
6432fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6433    // 1. Locate sister session by name OR character nickname.
6434    let sessions = crate::session::list_sessions()?;
6435    let sister = match resolve_local_session(&sessions, sister_name) {
6436        Ok(s) => s,
6437        Err(ResolveError::NotFound) => bail!(
6438            "no sister session named `{sister_name}` (matched by session name or character nickname). \
6439             Run `wire session list` to see what's available."
6440        ),
6441        Err(ResolveError::Ambiguous(candidates)) => bail!(
6442            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6443             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6444            candidates.len(),
6445            candidates.join(", ")
6446        ),
6447    };
6448    // If we matched via nickname (not exact name), surface that so the
6449    // operator sees what we resolved to. Quiet when names match exactly.
6450    if sister.name != sister_name {
6451        eprintln!(
6452            "wire add: resolved nickname `{sister_name}` → session `{}`",
6453            sister.name
6454        );
6455    }
6456
6457    // 2. Refuse self-pair — operator owns both sides, but a self-loop
6458    // breaks the bilateral state machine.
6459    let our_card = config::read_agent_card()
6460        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6461    let our_did = our_card
6462        .get("did")
6463        .and_then(Value::as_str)
6464        .ok_or_else(|| anyhow!("agent-card missing did"))?
6465        .to_string();
6466    if let Some(sister_did) = sister.did.as_deref()
6467        && sister_did == our_did
6468    {
6469        bail!("refusing to add self (`{sister_name}` is this very session)");
6470    }
6471
6472    // 3. Read sister's agent-card + relay state from disk.
6473    let sister_card_path = sister
6474        .home_dir
6475        .join("config")
6476        .join("wire")
6477        .join("agent-card.json");
6478    let sister_card: Value = serde_json::from_slice(
6479        &std::fs::read(&sister_card_path)
6480            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6481    )
6482    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6483    let sister_relay_state: Value = std::fs::read(
6484        sister
6485            .home_dir
6486            .join("config")
6487            .join("wire")
6488            .join("relay.json"),
6489    )
6490    .ok()
6491    .and_then(|b| serde_json::from_slice(&b).ok())
6492    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6493
6494    let sister_did = sister_card
6495        .get("did")
6496        .and_then(Value::as_str)
6497        .ok_or_else(|| anyhow!("sister card missing did"))?
6498        .to_string();
6499    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6500
6501    // Pull sister's full endpoint set; we want the local one for delivery
6502    // and we'll pin all of them so OUR pushes prefer local-first per the
6503    // existing routing logic.
6504    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6505    if sister_endpoints.is_empty() {
6506        bail!(
6507            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6508        );
6509    }
6510    let sister_local = sister_endpoints
6511        .iter()
6512        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6513    let delivery_endpoint = match sister_local {
6514        Some(e) => e.clone(),
6515        None => sister_endpoints[0].clone(),
6516    };
6517
6518    // 4. Ensure WE have a slot to advertise back. For local-only sessions
6519    // this is the local slot; for dual-slot sessions, federation is fine.
6520    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
6521    // for pure local-only — instead, pick our own existing federation
6522    // endpoint if present, else fall back to whatever's first.
6523    let our_relay_state = config::read_relay_state()?;
6524    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6525    if our_endpoints.is_empty() {
6526        bail!(
6527            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6528        );
6529    }
6530    let our_advertised = our_endpoints
6531        .iter()
6532        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6533        .cloned()
6534        .unwrap_or_else(|| our_endpoints[0].clone());
6535
6536    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
6537    // relay_state.peers with their full endpoint set. slot_token lands
6538    // via pair_drop_ack as usual.
6539    let mut trust = config::read_trust()?;
6540    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6541    config::write_trust(&trust)?;
6542    let mut relay_state = config::read_relay_state()?;
6543    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6544    config::write_relay_state(&relay_state)?;
6545
6546    // 6. Build the same pair_drop event the federation path emits, with
6547    // our card + endpoints in the body so the sister can pin us back.
6548    let sk_seed = config::read_private_key()?;
6549    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6550    let pk_b64 = our_card
6551        .get("verify_keys")
6552        .and_then(Value::as_object)
6553        .and_then(|m| m.values().next())
6554        .and_then(|v| v.get("key"))
6555        .and_then(Value::as_str)
6556        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6557    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6558    let now = time::OffsetDateTime::now_utc()
6559        .format(&time::format_description::well_known::Rfc3339)
6560        .unwrap_or_default();
6561    let mut body = json!({
6562        "card": our_card,
6563        "relay_url": our_advertised.relay_url,
6564        "slot_id": our_advertised.slot_id,
6565        "slot_token": our_advertised.slot_token,
6566    });
6567    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6568    let event = json!({
6569        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6570        "timestamp": now,
6571        "from": our_did,
6572        "to": sister_did,
6573        "type": "pair_drop",
6574        "kind": 1100u32,
6575        "body": body,
6576    });
6577    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6578    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6579
6580    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
6581    // (the federation handle indexer) — we already know the slot coords
6582    // from disk, so post_event is sufficient.
6583    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6584    client
6585        .post_event(
6586            &delivery_endpoint.slot_id,
6587            &delivery_endpoint.slot_token,
6588            &signed,
6589        )
6590        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6591
6592    if as_json {
6593        println!(
6594            "{}",
6595            serde_json::to_string(&json!({
6596                "handle": sister_name,
6597                "paired_with": sister_did,
6598                "peer_handle": sister_handle,
6599                "event_id": event_id,
6600                "delivered_via": match delivery_endpoint.scope {
6601                    crate::endpoints::EndpointScope::Local => "local",
6602                    crate::endpoints::EndpointScope::Lan => "lan",
6603                    crate::endpoints::EndpointScope::Uds => "uds",
6604                    crate::endpoints::EndpointScope::Federation => "federation",
6605                },
6606                "status": "drop_sent",
6607            }))?
6608        );
6609    } else {
6610        let scope = match delivery_endpoint.scope {
6611            crate::endpoints::EndpointScope::Local => "local",
6612            crate::endpoints::EndpointScope::Lan => "lan",
6613            crate::endpoints::EndpointScope::Uds => "uds",
6614            crate::endpoints::EndpointScope::Federation => "federation",
6615        };
6616        println!(
6617            "→ 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.",
6618            delivery_endpoint.relay_url
6619        );
6620    }
6621    Ok(())
6622}
6623
6624fn cmd_add(
6625    handle_arg: &str,
6626    relay_override: Option<&str>,
6627    local_sister: bool,
6628    as_json: bool,
6629) -> Result<()> {
6630    // v0.7.4: nickname-friendly local-sister resolution. Whether the
6631    // operator passed `--local-sister` explicitly OR just typed a bare
6632    // name (no `@<relay>`), try to resolve through the local sessions
6633    // registry so character nicknames AND session names AND card
6634    // handles all work as input. Closes the "I only know this peer by
6635    // its character name" ergonomic gap that forced operators into
6636    // `wire session list-local | grep <nick> | awk` dances.
6637    if local_sister {
6638        let resolved = crate::session::resolve_local_sister(handle_arg)
6639            .unwrap_or_else(|| handle_arg.to_string());
6640        return cmd_add_local_sister(&resolved, as_json);
6641    }
6642    if !handle_arg.contains('@')
6643        && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6644    {
6645        eprintln!(
6646            "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6647             — routing via --local-sister (disk-read card, no relay lookup)."
6648        );
6649        return cmd_add_local_sister(&resolved, as_json);
6650    }
6651    if !handle_arg.contains('@') {
6652        bail!(
6653            "`{handle_arg}` doesn't match any local sister session and has no \
6654             @<relay> suffix for federation.\n\
6655             — Local sisters: `wire session list-local` (operator types name OR \
6656             character nickname)\n\
6657             — Federation:    `wire add <handle>@<relay-domain>` (e.g. \
6658             `wire add alice@wireup.net`)"
6659        );
6660    }
6661    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6662
6663    // 1. Auto-init self if needed + ensure a relay slot.
6664    let (our_did, our_relay, our_slot_id, our_slot_token) =
6665        crate::pair_invite::ensure_self_with_relay(relay_override)?;
6666    if our_did == format!("did:wire:{}", parsed.nick) {
6667        // Lazy guard — actual self-add would also be caught by FCFS later.
6668        bail!("refusing to add self (handle matches own DID)");
6669    }
6670
6671    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
6672    // already sitting in pending-inbound, the operator is now accepting it.
6673    // Pin trust, save relay coords + slot_token from the stored drop, ship
6674    // our own slot_token back via pair_drop_ack, delete the pending record.
6675    //
6676    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
6677    // receiver-side auto-promote was removed there; operator consent flows
6678    // through here. After this branch returns, both sides are bilaterally
6679    // pinned and capability flows in both directions.
6680    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6681        return cmd_add_accept_pending(
6682            handle_arg,
6683            &parsed.nick,
6684            &pending,
6685            &our_relay,
6686            &our_slot_id,
6687            &our_slot_token,
6688            as_json,
6689        );
6690    }
6691
6692    // v0.5.19 (#9.4): cross-relay phishing guardrail.
6693    //
6694    // Threat: operator wants to add `boss@wireup.net` but types
6695    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
6696    // The .well-known resolution returns whoever claimed the nick on the
6697    // *typo* relay, the bilateral gate still completes (the attacker
6698    // accepts the pair on their side), and the operator pins the
6699    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
6700    // there's no asymmetry to detect when the attacker WANTS to be
6701    // paired.
6702    //
6703    // Mitigation: warn loudly when the peer's relay domain is novel
6704    // (not the operator's own relay, not in a small known-good set).
6705    // Doesn't block — operators have legitimate reasons to pair across
6706    // relays. The signal lands in shell history so a phished operator
6707    // can find it in retrospect.
6708    if !is_known_relay_domain(&parsed.domain, &our_relay) {
6709        eprintln!(
6710            "wire add: WARN unfamiliar relay domain `{}`.",
6711            parsed.domain
6712        );
6713        eprintln!(
6714            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
6715            host_of_url(&our_relay)
6716        );
6717        eprintln!(
6718            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
6719            parsed.nick
6720        );
6721        eprintln!(
6722            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
6723            parsed.nick
6724        );
6725        eprintln!("  peer out-of-band that they actually run a relay at this domain");
6726        eprintln!("  before relying on the pair. (See issue #9.4.)");
6727    }
6728
6729    // 2. Resolve peer via .well-known on their relay.
6730    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6731    let peer_card = resolved
6732        .get("card")
6733        .cloned()
6734        .ok_or_else(|| anyhow!("resolved missing card"))?;
6735    let peer_did = resolved
6736        .get("did")
6737        .and_then(Value::as_str)
6738        .ok_or_else(|| anyhow!("resolved missing did"))?
6739        .to_string();
6740    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
6741    let peer_slot_id = resolved
6742        .get("slot_id")
6743        .and_then(Value::as_str)
6744        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
6745        .to_string();
6746    let peer_relay = resolved
6747        .get("relay_url")
6748        .and_then(Value::as_str)
6749        .map(str::to_string)
6750        .or_else(|| relay_override.map(str::to_string))
6751        .unwrap_or_else(|| format!("https://{}", parsed.domain));
6752
6753    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
6754    let mut trust = config::read_trust()?;
6755    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
6756    config::write_trust(&trust)?;
6757    let mut relay_state = config::read_relay_state()?;
6758    // Additive re-pin (v0.13.2, E3 token-bleed fix). The old code REPLACED the
6759    // whole peer entry with a flat federation-only one, seeding the token from
6760    // the entry's TOP-LEVEL `slot_token`. Two bugs (glossy-magnolia repro):
6761    //   1. re-dialing a peer that had a local endpoint (from add-peer-slot)
6762    //      CLOBBERED that local endpoint.
6763    //   2. after a local add-peer-slot the top-level token was the LOCAL token,
6764    //      so the federation endpoint inherited a stale LOCAL bearer →
6765    //      federation delivery would 401.
6766    // Fix: merge the federation endpoint into the peer's endpoints[] (preserve
6767    // the local one), and seed its token ONLY from a prior FEDERATION endpoint
6768    // on the same relay (re-dialing an already-acked peer), never a local one —
6769    // empty until the pair_drop_ack lands otherwise.
6770    let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
6771        .get("peers")
6772        .and_then(|p| p.get(&peer_handle))
6773        .and_then(|e| e.get("endpoints"))
6774        .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
6775        .unwrap_or_default();
6776    let fed_token = endpoints
6777        .iter()
6778        .find(|e| {
6779            e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
6780        })
6781        .map(|e| e.slot_token.clone())
6782        .unwrap_or_default();
6783    let fed_ep = crate::endpoints::Endpoint {
6784        relay_url: peer_relay.clone(),
6785        slot_id: peer_slot_id.clone(),
6786        slot_token: fed_token, // empty until pair_drop_ack lands
6787        scope: crate::endpoints::EndpointScope::Federation,
6788    };
6789    if let Some(existing) = endpoints
6790        .iter_mut()
6791        .find(|e| e.relay_url == fed_ep.relay_url)
6792    {
6793        *existing = fed_ep;
6794    } else {
6795        endpoints.push(fed_ep);
6796    }
6797    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
6798    config::write_relay_state(&relay_state)?;
6799
6800    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
6801    // is the v0.5 zero-paste open-mode path).
6802    let our_card = config::read_agent_card()?;
6803    let sk_seed = config::read_private_key()?;
6804    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6805    let pk_b64 = our_card
6806        .get("verify_keys")
6807        .and_then(Value::as_object)
6808        .and_then(|m| m.values().next())
6809        .and_then(|v| v.get("key"))
6810        .and_then(Value::as_str)
6811        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6812    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6813    let now = time::OffsetDateTime::now_utc()
6814        .format(&time::format_description::well_known::Rfc3339)
6815        .unwrap_or_default();
6816    // v0.5.17: advertise all our endpoints (federation + optional local)
6817    // to the peer in the pair_drop body. Back-compat: top-level
6818    // relay_url/slot_id/slot_token still point at the federation
6819    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
6820    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
6821    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6822    let mut body = json!({
6823        "card": our_card,
6824        "relay_url": our_relay,
6825        "slot_id": our_slot_id,
6826        "slot_token": our_slot_token,
6827    });
6828    if !our_endpoints.is_empty() {
6829        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6830    }
6831    let event = json!({
6832        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6833        "timestamp": now,
6834        "from": our_did,
6835        "to": peer_did,
6836        "type": "pair_drop",
6837        "kind": 1100u32,
6838        "body": body,
6839    });
6840    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6841
6842    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
6843    let client = crate::relay_client::RelayClient::new(&peer_relay);
6844    let resp = client.handle_intro(&parsed.nick, &signed)?;
6845    let event_id = signed
6846        .get("event_id")
6847        .and_then(Value::as_str)
6848        .unwrap_or("")
6849        .to_string();
6850
6851    if as_json {
6852        println!(
6853            "{}",
6854            serde_json::to_string(&json!({
6855                "handle": handle_arg,
6856                "paired_with": peer_did,
6857                "peer_handle": peer_handle,
6858                "event_id": event_id,
6859                "drop_response": resp,
6860                "status": "drop_sent",
6861            }))?
6862        );
6863    } else {
6864        println!(
6865            "→ 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."
6866        );
6867    }
6868    Ok(())
6869}
6870
6871/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
6872/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
6873/// coords + slot_token from the stored drop, ship our slot_token back via
6874/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
6875/// invite-URL path (which is already bilateral by virtue of the pre-shared
6876/// nonce).
6877fn cmd_add_accept_pending(
6878    handle_arg: &str,
6879    peer_nick: &str,
6880    pending: &crate::pending_inbound_pair::PendingInboundPair,
6881    _our_relay: &str,
6882    _our_slot_id: &str,
6883    _our_slot_token: &str,
6884    as_json: bool,
6885) -> Result<()> {
6886    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
6887    //    `wire add` against this handle while a drop was waiting.
6888    let mut trust = config::read_trust()?;
6889    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
6890    config::write_trust(&trust)?;
6891
6892    // 2. Record peer's relay coords + slot_token (already shipped to us in
6893    //    the original drop body; held back until now).
6894    // v0.5.17: pin all advertised endpoints (federation + optional local).
6895    // Falls back to a single federation entry when the record was written
6896    // by v0.5.16-era code that didn't carry endpoints[].
6897    let mut relay_state = config::read_relay_state()?;
6898    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
6899        vec![crate::endpoints::Endpoint::federation(
6900            pending.peer_relay_url.clone(),
6901            pending.peer_slot_id.clone(),
6902            pending.peer_slot_token.clone(),
6903        )]
6904    } else {
6905        pending.peer_endpoints.clone()
6906    };
6907    crate::endpoints::pin_peer_endpoints(
6908        &mut relay_state,
6909        &pending.peer_handle,
6910        &endpoints_to_pin,
6911    )?;
6912    config::write_relay_state(&relay_state)?;
6913
6914    // 3. Ship our slot_token to peer via pair_drop_ack so they can write back.
6915    crate::pair_invite::send_pair_drop_ack(
6916        &pending.peer_handle,
6917        &pending.peer_relay_url,
6918        &pending.peer_slot_id,
6919        &pending.peer_slot_token,
6920    )
6921    .with_context(|| {
6922        format!(
6923            "pair_drop_ack send to {} @ {} slot {} failed",
6924            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
6925        )
6926    })?;
6927
6928    // 4. Delete the pending-inbound record now that bilateral is complete.
6929    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
6930
6931    if as_json {
6932        println!(
6933            "{}",
6934            serde_json::to_string(&json!({
6935                "handle": handle_arg,
6936                "paired_with": pending.peer_did,
6937                "peer_handle": pending.peer_handle,
6938                "status": "bilateral_accepted",
6939                "via": "pending_inbound",
6940            }))?
6941        );
6942    } else {
6943        println!(
6944            "→ 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} \"...\"`.",
6945            peer = pending.peer_handle,
6946        );
6947    }
6948    Ok(())
6949}
6950
6951/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
6952/// for a pending-inbound pair request. Pin trust, write relay_state from the
6953/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
6954/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
6955/// when a pending-inbound record exists, but without needing to remember
6956/// the peer's relay domain.
6957fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
6958    let nick = crate::agent_card::bare_handle(peer_nick);
6959    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
6960        anyhow!(
6961            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
6962             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
6963        )
6964    })?;
6965    let (_our_did, our_relay, our_slot_id, our_slot_token) =
6966        crate::pair_invite::ensure_self_with_relay(None)?;
6967    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
6968    cmd_add_accept_pending(
6969        &handle_arg,
6970        nick,
6971        &pending,
6972        &our_relay,
6973        &our_slot_id,
6974        &our_slot_token,
6975        as_json,
6976    )
6977}
6978
6979/// v0.5.14: programmatic access to pending-inbound for scripts.
6980/// `wire pair-list-inbound --json` returns a flat array of records.
6981fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
6982    let items = crate::pending_inbound_pair::list_pending_inbound()?;
6983    if as_json {
6984        println!("{}", serde_json::to_string(&items)?);
6985        return Ok(());
6986    }
6987    if items.is_empty() {
6988        println!("no pending pair requests — your inbox is clear.");
6989        return Ok(());
6990    }
6991    // v0.9.3: conversational output. Tabular data is for --json. Humans
6992    // get one short sentence per pending peer, each rendered with the
6993    // peer's character (DID-derived emoji + nickname) so they can match
6994    // the speaker against their statusline / mesh-status view at a
6995    // glance. The "next step" sentence at the bottom names the exact
6996    // verbs to run.
6997    let plural = if items.len() == 1 { "" } else { "s" };
6998    println!("{} pending pair request{plural}:\n", items.len());
6999    for p in &items {
7000        let ch = crate::character::Character::from_did(&p.peer_did);
7001        let glyph = crate::character::emoji_with_fallback(&ch);
7002        // ASCII-friendly arrow if the operator's terminal can't render
7003        // emoji (the same routine drives the fallback).
7004        println!(
7005            "  {glyph} {nick}  ({handle})  wants to pair with you",
7006            nick = ch.nickname,
7007            handle = p.peer_handle,
7008        );
7009    }
7010    println!();
7011    println!(
7012        "→ to accept any: `wire accept <name>`  (e.g. `wire accept {first}`)",
7013        first = items
7014            .first()
7015            .map(|p| {
7016                let ch = crate::character::Character::from_did(&p.peer_did);
7017                ch.nickname
7018            })
7019            .unwrap_or_else(|| "<name>".to_string())
7020    );
7021    println!("→ to refuse:    `wire reject <name>`");
7022    Ok(())
7023}
7024
7025/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
7026/// without pairing. No event is sent back to the peer; their side stays
7027/// pending until they time out or the operator-side data ages out.
7028fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
7029    let nick = crate::agent_card::bare_handle(peer_nick);
7030    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
7031    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
7032
7033    if as_json {
7034        println!(
7035            "{}",
7036            serde_json::to_string(&json!({
7037                "peer": nick,
7038                "rejected": existed.is_some(),
7039                "had_pending": existed.is_some(),
7040            }))?
7041        );
7042    } else if existed.is_some() {
7043        println!(
7044            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
7045        );
7046    } else {
7047        println!("no pending pair from {nick} — nothing to reject");
7048    }
7049    Ok(())
7050}
7051
7052// ---------- session (v0.5.16) ----------
7053//
7054// Multi-session wire on one machine. See src/session.rs for the storage
7055// layout + naming rules. The CLI dispatcher here orchestrates child
7056// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
7057// each session-local `init` / `claim` / `daemon` runs in its own world
7058// without cross-contamination via env vars in this process.
7059
7060// ---------- group chat (v0.13.3) ----------
7061
7062fn cmd_group(cmd: GroupCommand) -> Result<()> {
7063    match cmd {
7064        GroupCommand::Create { name, json } => cmd_group_create(&name, json),
7065        GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
7066        GroupCommand::Send {
7067            group,
7068            message,
7069            json,
7070        } => cmd_group_send(&group, &message, json),
7071        GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
7072        GroupCommand::List { json } => cmd_group_list(json),
7073        GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
7074        GroupCommand::Join { code, json } => cmd_group_join(&code, json),
7075    }
7076}
7077
7078/// This agent's (did, handle) from its signed card.
7079/// This agent's signing identity for group ops: (did, handle, key_id, pk_b64).
7080fn group_self() -> Result<(String, String, String, String)> {
7081    let card = config::read_agent_card()?;
7082    let did = card
7083        .get("did")
7084        .and_then(Value::as_str)
7085        .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
7086        .to_string();
7087    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7088    let pk_b64 = card
7089        .get("verify_keys")
7090        .and_then(Value::as_object)
7091        .and_then(|m| m.values().next())
7092        .and_then(|v| v.get("key"))
7093        .and_then(Value::as_str)
7094        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
7095        .to_string();
7096    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7097    let key_id = make_key_id(&handle, &pk_bytes);
7098    Ok((did, handle, key_id, pk_b64))
7099}
7100
7101/// Relay to host a group room on — prefer the federation endpoint (remote
7102/// members can reach it), fall back to LAN, then local, then any.
7103fn group_room_relay_url() -> Result<String> {
7104    use crate::endpoints::EndpointScope;
7105    let state = config::read_relay_state()?;
7106    let eps = crate::endpoints::self_endpoints(&state);
7107    let pick = eps
7108        .iter()
7109        .find(|e| e.scope == EndpointScope::Federation)
7110        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
7111        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
7112        .or_else(|| eps.first());
7113    match pick {
7114        Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
7115        _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
7116    }
7117}
7118
7119/// Sign a `group_invite` (carrying the full creator-signed Group) and queue it
7120/// to every other member's outbox. The daemon/push delivers; the recipient's
7121/// `ingest_group_invites` materializes the room + introduce-pins members.
7122fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
7123    let (_, self_handle, _, pk_b64) = group_self()?;
7124    let sk_seed = config::read_private_key()?;
7125    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7126    let now_iso = time::OffsetDateTime::now_utc()
7127        .format(&time::format_description::well_known::Rfc3339)
7128        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7129    let group_json = serde_json::to_value(group)?;
7130    let mut delivered = 0usize;
7131    for handle in group.other_member_handles(self_did) {
7132        let event = json!({
7133            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7134            "timestamp": now_iso,
7135            "from": self_did,
7136            "to": format!("did:wire:{handle}"),
7137            "type": "group_invite",
7138            "kind": parse_kind("group_invite")?,
7139            "body": group_json,
7140        });
7141        let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7142            .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
7143        let line = serde_json::to_vec(&signed)?;
7144        if config::append_outbox_record(&handle, &line).is_ok() {
7145            delivered += 1;
7146        }
7147    }
7148    Ok(delivered)
7149}
7150
7151/// Introduce-pin a member's key on the creator's vouch: ensure
7152/// `trust.agents[handle]` carries this key so the member's group messages
7153/// verify, WITHOUT granting bilateral trust. Never lowers an existing tier
7154/// (a directly-VERIFIED peer stays VERIFIED); only adds the key if missing.
7155/// Returns `true` iff it actually changed `trust` (new entry or added key) —
7156/// callers use this to decide whether to persist.
7157fn introduce_pin(
7158    trust: &mut Value,
7159    handle: &str,
7160    did: &str,
7161    key_id: &str,
7162    key: &str,
7163    group_id: &str,
7164) -> bool {
7165    let now = time::OffsetDateTime::now_utc()
7166        .format(&time::format_description::well_known::Rfc3339)
7167        .unwrap_or_default();
7168    let agents = trust
7169        .as_object_mut()
7170        .expect("trust is an object")
7171        .entry("agents")
7172        .or_insert_with(|| json!({}));
7173    let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
7174    match agents.get_mut(handle) {
7175        Some(existing) => {
7176            // Already pinned (maybe at a higher bilateral tier) — just ensure
7177            // the key is present. Do NOT touch the tier.
7178            let keys = existing
7179                .as_object_mut()
7180                .and_then(|o| o.get_mut("public_keys"))
7181                .and_then(Value::as_array_mut);
7182            if let Some(keys) = keys {
7183                let have = keys
7184                    .iter()
7185                    .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
7186                if !have {
7187                    keys.push(key_rec);
7188                    return true;
7189                }
7190            }
7191            false
7192        }
7193        None => {
7194            // First sight — pin at bilateral UNTRUSTED (disjoint from GroupTier).
7195            agents[handle] = json!({
7196                "tier": "UNTRUSTED",
7197                "did": did,
7198                "public_keys": [key_rec],
7199                "introduced_via": group_id,
7200                "pinned_at": now,
7201            });
7202            true
7203        }
7204    }
7205}
7206
7207/// Scan the inbox for `group_invite` events from pinned creators, verify them
7208/// (event signature + roster `creator_sig`), materialize/refresh the local
7209/// group at its highest epoch, and introduce-pin every other member. Lazy:
7210/// runs at the top of group send/tail/list so a member just-pulled an invite
7211/// is immediately usable. Skips groups this agent created.
7212fn ingest_group_invites() -> Result<()> {
7213    let inbox = config::inbox_dir()?;
7214    if !inbox.exists() {
7215        return Ok(());
7216    }
7217    let (self_did, ..) = group_self()?;
7218    let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7219    // group_id -> highest-epoch verified roster seen in the inbox.
7220    let mut best: std::collections::HashMap<String, crate::group::Group> =
7221        std::collections::HashMap::new();
7222
7223    for entry in std::fs::read_dir(&inbox)?.flatten() {
7224        let path = entry.path();
7225        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7226            continue;
7227        }
7228        for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
7229            let event: Value = match serde_json::from_str(line) {
7230                Ok(v) => v,
7231                Err(_) => continue,
7232            };
7233            if event.get("type").and_then(Value::as_str) != Some("group_invite") {
7234                continue;
7235            }
7236            // Event-level: the invite must be from a pinned peer (the creator)
7237            // with a valid signature.
7238            if verify_message_v31(&event, &trust_now).is_err() {
7239                continue;
7240            }
7241            let Some(body) = event.get("body") else {
7242                continue;
7243            };
7244            let group: crate::group::Group = match serde_json::from_value(body.clone()) {
7245                Ok(g) => g,
7246                Err(_) => continue,
7247            };
7248            if group.creator_did == self_did {
7249                continue; // never overwrite a group I created
7250            }
7251            // The invite's sender must be the group's creator.
7252            let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7253            if from_did != group.creator_did {
7254                continue;
7255            }
7256            // Roster integrity: creator_sig must verify against the creator's
7257            // independently-pinned key (we paired with the creator → have it).
7258            let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
7259            let creator_key = trust_now
7260                .get("agents")
7261                .and_then(|a| a.get(creator_handle))
7262                .and_then(|a| a.get("public_keys"))
7263                .and_then(Value::as_array)
7264                .and_then(|ks| ks.first())
7265                .and_then(|k| k.get("key"))
7266                .and_then(Value::as_str)
7267                .and_then(|b| crate::signing::b64decode(b).ok());
7268            let Some(creator_key) = creator_key else {
7269                continue;
7270            };
7271            if !group.verify(&creator_key) {
7272                continue;
7273            }
7274            match best.get(&group.id) {
7275                Some(prev) if prev.epoch >= group.epoch => {}
7276                _ => {
7277                    best.insert(group.id.clone(), group);
7278                }
7279            }
7280        }
7281    }
7282
7283    if best.is_empty() {
7284        return Ok(());
7285    }
7286    let mut trust = config::read_trust()?;
7287    for group in best.values() {
7288        // Don't regress a locally-known group to a stale epoch.
7289        if let Ok(local) = crate::group::load_group(&group.id)
7290            && local.epoch >= group.epoch
7291        {
7292            continue;
7293        }
7294        crate::group::save_group(group)?;
7295        for m in &group.members {
7296            if m.did == self_did || m.key.is_empty() {
7297                continue;
7298            }
7299            introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7300        }
7301    }
7302    config::write_trust(&trust)?;
7303    Ok(())
7304}
7305
7306fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
7307    if !config::is_initialized()? {
7308        bail!("not initialized — run `wire up` first");
7309    }
7310    let (did, handle, key_id, pk_b64) = group_self()?;
7311    let relay_url = group_room_relay_url()?;
7312    // Allocate the shared group-room slot on the relay.
7313    let client = crate::relay_client::RelayClient::new(&relay_url);
7314    let room = client
7315        .allocate_slot(Some(&format!("group:{name}")))
7316        .with_context(|| format!("allocating group room on {relay_url}"))?;
7317    let id = format!("g{:016x}", rand::random::<u64>());
7318    let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
7319    group.set_room(relay_url, room.slot_id, room.slot_token);
7320    group.set_member_keys(&did, key_id, pk_b64)?;
7321    let sk = config::read_private_key()?;
7322    group.sign(&sk)?;
7323    crate::group::save_group(&group)?;
7324    if as_json {
7325        println!(
7326            "{}",
7327            serde_json::to_string(&json!({
7328                "id": id, "name": name, "members": 1, "relay_url": group.relay_url
7329            }))?
7330        );
7331    } else {
7332        println!(
7333            "created group `{name}` (id {id}) — room on {}. You are the creator.",
7334            group.relay_url
7335        );
7336        println!("  add peers: `wire group add {id} <peer>`   talk: `wire group send {id} \"hi\"`");
7337    }
7338    Ok(())
7339}
7340
7341fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
7342    let (self_did, ..) = group_self()?;
7343    let mut group = crate::group::resolve_group(group_ref)?;
7344    if group.creator_did != self_did {
7345        bail!("only the group creator can add members (the creator signs the roster)");
7346    }
7347    // T22 consent: a Member must be a peer you bilaterally VERIFIED.
7348    let bare = crate::agent_card::bare_handle(peer).to_string();
7349    let trust = config::read_trust()?;
7350    let agent = trust
7351        .get("agents")
7352        .and_then(|a| a.get(&bare))
7353        .ok_or_else(|| {
7354            anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
7355        })?;
7356    let tier = agent
7357        .get("tier")
7358        .and_then(Value::as_str)
7359        .unwrap_or("UNTRUSTED");
7360    if tier != "VERIFIED" {
7361        bail!(
7362            "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
7363        );
7364    }
7365    let peer_did = agent
7366        .get("did")
7367        .and_then(Value::as_str)
7368        .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
7369        .to_string();
7370    // Capture the peer's signing key from trust so the creator can vouch for it
7371    // in the signed roster (members introduce-pin it to verify this peer).
7372    let key = agent
7373        .get("public_keys")
7374        .and_then(Value::as_array)
7375        .and_then(|ks| {
7376            ks.iter()
7377                .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
7378        })
7379        .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
7380    let peer_key_id = key
7381        .get("key_id")
7382        .and_then(Value::as_str)
7383        .unwrap_or_default()
7384        .to_string();
7385    let peer_pk = key
7386        .get("key")
7387        .and_then(Value::as_str)
7388        .unwrap_or_default()
7389        .to_string();
7390
7391    group.add_member(
7392        bare.clone(),
7393        peer_did.clone(),
7394        crate::group::GroupTier::Member,
7395    )?;
7396    group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
7397    let sk = config::read_private_key()?;
7398    group.sign(&sk)?;
7399    crate::group::save_group(&group)?;
7400    // Distribute the refreshed signed roster (room coords + everyone's keys) to
7401    // ALL members so each can post + verify the others.
7402    let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
7403    if as_json {
7404        println!(
7405            "{}",
7406            serde_json::to_string(&json!({
7407                "group": group.id, "added": bare, "epoch": group.epoch,
7408                "members": group.members.len(), "invites_queued": delivered
7409            }))?
7410        );
7411    } else {
7412        println!(
7413            "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
7414            group.name,
7415            group.members.len(),
7416            group.epoch
7417        );
7418    }
7419    Ok(())
7420}
7421
7422fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
7423    if !config::is_initialized()? {
7424        bail!("not initialized — run `wire up` first");
7425    }
7426    ingest_group_invites()?;
7427    let (self_did, self_handle, _, pk_b64) = group_self()?;
7428    let group = crate::group::resolve_group(group_ref)?;
7429    // Membership for SEND is room-token possession: having the group locally
7430    // (with its slot_token) is the capability. The signed roster gates who you
7431    // can VERIFY, not whether you may post — a code-redeemed joiner isn't in the
7432    // creator-signed roster but legitimately holds the room key.
7433    if group.slot_id.is_empty() || group.relay_url.is_empty() {
7434        bail!(
7435            "group `{}` has no room slot (legacy/partial group)",
7436            group.name
7437        );
7438    }
7439    let sk_seed = config::read_private_key()?;
7440    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7441    let now_iso = time::OffsetDateTime::now_utc()
7442        .format(&time::format_description::well_known::Rfc3339)
7443        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7444    let event = json!({
7445        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7446        "timestamp": now_iso,
7447        "from": self_did,
7448        "to": format!("did:wire:group:{}", group.id),
7449        "type": "group_msg",
7450        "kind": parse_kind("group_msg")?,
7451        "body": {
7452            "group_id": group.id,
7453            "group_name": group.name,
7454            "epoch": group.epoch,
7455            "text": message,
7456        },
7457    });
7458    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7459        .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
7460    // Post the one message to the shared group slot.
7461    let client = crate::relay_client::RelayClient::new(&group.relay_url);
7462    client
7463        .post_event(&group.slot_id, &group.slot_token, &signed)
7464        .with_context(|| {
7465            format!(
7466                "posting to group room {} on {}",
7467                group.slot_id, group.relay_url
7468            )
7469        })?;
7470    if as_json {
7471        println!(
7472            "{}",
7473            serde_json::to_string(&json!({
7474                "group": group.id, "epoch": group.epoch, "status": "posted",
7475                "members": group.members.len()
7476            }))?
7477        );
7478    } else {
7479        println!(
7480            "group `{}`: posted to the room ({} member(s))",
7481            group.name,
7482            group.members.len()
7483        );
7484    }
7485    Ok(())
7486}
7487
7488fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
7489    ingest_group_invites()?;
7490    let group = crate::group::resolve_group(group_ref)?;
7491    if group.slot_id.is_empty() || group.relay_url.is_empty() {
7492        bail!(
7493            "group `{}` has no room slot (legacy/partial group)",
7494            group.name
7495        );
7496    }
7497    let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7498    let client = crate::relay_client::RelayClient::new(&group.relay_url);
7499    // Pull the shared room; cap generously then show the last `limit`.
7500    let fetch = if limit == 0 {
7501        1000
7502    } else {
7503        (limit * 4).min(1000)
7504    };
7505    let events = client
7506        .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
7507        .with_context(|| {
7508            format!(
7509                "pulling group room {} on {}",
7510                group.slot_id, group.relay_url
7511            )
7512        })?;
7513
7514    // Pass 1: introduce-pin anyone who announced a join. A `group_join` carries
7515    // the joiner's card and must self-consistently sign under it; posting to the
7516    // room requires the room token, so possession is the authorization (pinned
7517    // at bilateral UNTRUSTED, group tier Introduced). This lets their later
7518    // group messages verify even though they're not in the creator-signed roster.
7519    let mut trust_changed = false;
7520    for event in &events {
7521        if event.get("type").and_then(Value::as_str) != Some("group_join") {
7522            continue;
7523        }
7524        if let Some((h, did, kid, key)) = group_join_pin_material(event)
7525            && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
7526        {
7527            trust_changed = true;
7528        }
7529    }
7530    if trust_changed {
7531        let _ = config::write_trust(&trust);
7532    }
7533
7534    // Pass 2: build the timeline — group messages (verified against the
7535    // now-augmented trust) interleaved with join notices.
7536    enum Line {
7537        Msg {
7538            from: String,
7539            text: String,
7540            verified: bool,
7541        },
7542        Join {
7543            who: String,
7544        },
7545    }
7546    let mut timeline: Vec<(String, Line)> = Vec::new();
7547    for event in &events {
7548        let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
7549        let body = match event.get("body") {
7550            Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
7551            Some(v) => Some(v.clone()),
7552            None => None,
7553        };
7554        let Some(body) = body else { continue };
7555        if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
7556            continue;
7557        }
7558        let ts = event
7559            .get("timestamp")
7560            .and_then(Value::as_str)
7561            .unwrap_or("")
7562            .to_string();
7563        let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7564        let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
7565        match ty {
7566            "group_msg" => {
7567                let text = body
7568                    .get("text")
7569                    .and_then(Value::as_str)
7570                    .unwrap_or("")
7571                    .to_string();
7572                let verified = verify_message_v31(event, &trust).is_ok();
7573                timeline.push((
7574                    ts,
7575                    Line::Msg {
7576                        from: from_handle,
7577                        text,
7578                        verified,
7579                    },
7580                ));
7581            }
7582            "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
7583            _ => {}
7584        }
7585    }
7586    timeline.sort_by(|a, b| a.0.cmp(&b.0));
7587    let start = if limit > 0 {
7588        timeline.len().saturating_sub(limit)
7589    } else {
7590        0
7591    };
7592    let recent = &timeline[start..];
7593    if as_json {
7594        let arr: Vec<Value> = recent
7595            .iter()
7596            .map(|(ts, l)| match l {
7597                Line::Msg {
7598                    from,
7599                    text,
7600                    verified,
7601                } => {
7602                    json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
7603                }
7604                Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
7605            })
7606            .collect();
7607        println!(
7608            "{}",
7609            serde_json::to_string(
7610                &json!({"group": group.id, "name": group.name, "messages": arr})
7611            )?
7612        );
7613    } else if recent.is_empty() {
7614        println!("group `{}`: no messages yet", group.name);
7615    } else {
7616        for (ts, l) in recent {
7617            let short_ts: String = ts.chars().take(19).collect();
7618            match l {
7619                Line::Msg {
7620                    from,
7621                    text,
7622                    verified,
7623                } => {
7624                    let mark = if *verified { "✓" } else { "✗" };
7625                    println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
7626                }
7627                Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
7628            }
7629        }
7630    }
7631    Ok(())
7632}
7633
7634/// Validate a `group_join` room event and extract the joiner's pin material:
7635/// (handle, did, key_id, key_b64). The event MUST self-consistently sign under
7636/// the key in the card it carries — so a forged join (card A, signed by key B)
7637/// is rejected. Authorization to be in the room is proven by the post itself
7638/// (it required the room token).
7639fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
7640    let body = match event.get("body") {
7641        Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
7642        Some(v) => v.clone(),
7643        None => return None,
7644    };
7645    let card = body.get("joiner_card")?;
7646    // Verify the event signs under the card it carries (one-entry trust).
7647    let mut tmp = json!({"agents": {}});
7648    crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
7649    if verify_message_v31(event, &tmp).is_err() {
7650        return None;
7651    }
7652    let did = card.get("did").and_then(Value::as_str)?.to_string();
7653    let handle = card
7654        .get("handle")
7655        .and_then(Value::as_str)
7656        .map(str::to_string)
7657        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
7658    let (kid_full, krec) = card
7659        .get("verify_keys")
7660        .and_then(Value::as_object)
7661        .and_then(|m| m.iter().next())?;
7662    let key_id = kid_full
7663        .strip_prefix("ed25519:")
7664        .unwrap_or(kid_full)
7665        .to_string();
7666    let key = krec.get("key").and_then(Value::as_str)?.to_string();
7667    Some((handle, did, key_id, key))
7668}
7669
7670/// `wire group invite <group>` — mint a self-contained join code (the serialized
7671/// signed group: room coords + roster + member keys). The code IS the room key.
7672fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
7673    let group = crate::group::resolve_group(group_ref)?;
7674    if group.slot_id.is_empty() || group.relay_url.is_empty() {
7675        bail!(
7676            "group `{}` has no room slot — nothing to invite into",
7677            group.name
7678        );
7679    }
7680    if group.creator_sig.is_empty() {
7681        bail!(
7682            "group `{}` roster is unsigned — add a member or recreate before inviting",
7683            group.name
7684        );
7685    }
7686    let payload = serde_json::to_vec(&group)?;
7687    let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
7688    if as_json {
7689        println!(
7690            "{}",
7691            serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
7692        );
7693    } else {
7694        println!(
7695            "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
7696            group.name
7697        );
7698        println!("{code}\n");
7699        println!("they run:  wire group join <code>");
7700    }
7701    Ok(())
7702}
7703
7704/// `wire group join <code>` — redeem a join code: verify the roster, materialize
7705/// the room locally, introduce-pin existing members, and announce ourselves to
7706/// the room so members verify our messages. Lands at group tier Introduced.
7707fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
7708    if !config::is_initialized()? {
7709        bail!("not initialized — run `wire up` first");
7710    }
7711    let raw = code.trim();
7712    let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
7713    let payload =
7714        crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
7715    let group: crate::group::Group = serde_json::from_slice(&payload)
7716        .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
7717    if group.slot_id.is_empty() || group.relay_url.is_empty() {
7718        bail!("join code carries no room coords");
7719    }
7720    // Verify the roster against the creator's key carried IN the roster (TOFU on
7721    // the code — you obtained it over a trusted channel). Rejects a tampered code.
7722    let creator_key = group
7723        .members
7724        .iter()
7725        .find(|m| m.did == group.creator_did)
7726        .map(|m| m.key.clone())
7727        .filter(|k| !k.is_empty())
7728        .and_then(|k| crate::signing::b64decode(&k).ok())
7729        .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
7730    if !group.verify(&creator_key) {
7731        bail!("join code failed its signature check (tampered or corrupt)");
7732    }
7733    let (self_did, self_handle, _, _) = group_self()?;
7734    if group.creator_did == self_did {
7735        bail!("you created group `{}` — you're already in it", group.name);
7736    }
7737
7738    // Materialize locally + introduce-pin existing members so we can verify them.
7739    crate::group::save_group(&group)?;
7740    let mut trust = config::read_trust()?;
7741    for m in &group.members {
7742        if m.did == self_did || m.key.is_empty() {
7743            continue;
7744        }
7745        introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7746    }
7747    config::write_trust(&trust)?;
7748
7749    // Announce ourselves to the room (carry our card) so members introduce-pin us.
7750    let card = config::read_agent_card()?;
7751    let sk_seed = config::read_private_key()?;
7752    let pk_b64 = card
7753        .get("verify_keys")
7754        .and_then(Value::as_object)
7755        .and_then(|m| m.values().next())
7756        .and_then(|v| v.get("key"))
7757        .and_then(Value::as_str)
7758        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7759    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7760    let now_iso = time::OffsetDateTime::now_utc()
7761        .format(&time::format_description::well_known::Rfc3339)
7762        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7763    let event = json!({
7764        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7765        "timestamp": now_iso,
7766        "from": self_did,
7767        "to": format!("did:wire:group:{}", group.id),
7768        "type": "group_join",
7769        "kind": parse_kind("group_join")?,
7770        "body": {
7771            "group_id": group.id,
7772            "group_name": group.name,
7773            "epoch": group.epoch,
7774            "joiner_card": card,
7775            "text": "joined",
7776        },
7777    });
7778    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7779        .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
7780    let client = crate::relay_client::RelayClient::new(&group.relay_url);
7781    let announced = client
7782        .post_event(&group.slot_id, &group.slot_token, &signed)
7783        .is_ok();
7784
7785    if as_json {
7786        println!(
7787            "{}",
7788            serde_json::to_string(&json!({
7789                "group": group.id, "name": group.name, "joined": true,
7790                "members": group.members.len(), "announced": announced
7791            }))?
7792        );
7793    } else {
7794        println!(
7795            "joined group `{}` ({} member(s)) at Introduced tier.",
7796            group.name,
7797            group.members.len()
7798        );
7799        if announced {
7800            println!("  announced to the room — members will verify your messages.");
7801        } else {
7802            println!(
7803                "  ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
7804            );
7805        }
7806        println!(
7807            "  read: `wire group tail {}`   talk: `wire group send {} \"hi\"`",
7808            group.id, group.id
7809        );
7810    }
7811    Ok(())
7812}
7813
7814fn cmd_group_list(as_json: bool) -> Result<()> {
7815    let groups = crate::group::list_groups()?;
7816    if as_json {
7817        let arr: Vec<Value> = groups
7818            .iter()
7819            .map(|g| {
7820                json!({
7821                    "id": g.id,
7822                    "name": g.name,
7823                    "epoch": g.epoch,
7824                    "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
7825                })
7826            })
7827            .collect();
7828        println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
7829    } else if groups.is_empty() {
7830        println!("no groups yet — create one with `wire group create <name>`");
7831    } else {
7832        for g in &groups {
7833            println!(
7834                "{} ({}) — {} member(s), epoch {}",
7835                g.name,
7836                g.id,
7837                g.members.len(),
7838                g.epoch
7839            );
7840            for m in &g.members {
7841                println!("    {} [{}]", m.handle, m.tier.as_str());
7842            }
7843        }
7844    }
7845    Ok(())
7846}
7847
7848/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
7849/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
7850fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
7851    match cmd {
7852        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
7853        MeshCommand::Broadcast {
7854            kind,
7855            scope,
7856            exclude,
7857            noreply,
7858            body,
7859            json,
7860        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
7861        MeshCommand::Role { action } => cmd_mesh_role(action),
7862        MeshCommand::Route {
7863            role,
7864            strategy,
7865            exclude,
7866            kind,
7867            body,
7868            json,
7869        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
7870    }
7871}
7872
7873/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
7874/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
7875/// picks ONE via the requested strategy, then signs + pushes the event
7876/// to that peer. Pinned-peers-only by construction (same as broadcast).
7877fn cmd_mesh_route(
7878    role: &str,
7879    strategy: &str,
7880    exclude: &[String],
7881    kind: &str,
7882    body_arg: &str,
7883    as_json: bool,
7884) -> Result<()> {
7885    use std::time::Instant;
7886
7887    if !config::is_initialized()? {
7888        bail!("not initialized — run `wire init <handle>` first");
7889    }
7890    let strategy = strategy.to_ascii_lowercase();
7891    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
7892        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
7893    }
7894
7895    // Our pinned-peer set: only these handles are addressable. mesh-route
7896    // refuses to invent a recipient, same posture as broadcast.
7897    let state = config::read_relay_state()?;
7898    let pinned: std::collections::BTreeSet<String> = state["peers"]
7899        .as_object()
7900        .map(|m| m.keys().cloned().collect())
7901        .unwrap_or_default();
7902
7903    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7904
7905    // Enumerate every sister on the box, read each one's role from its
7906    // signed agent-card. Filter: matching role AND pinned AND not
7907    // excluded. `list_sessions` returns the cross-session view (using the
7908    // v0.6.4 inside-session sessions_root fallback).
7909    let sessions = crate::session::list_sessions()?;
7910    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
7911    for s in &sessions {
7912        let handle = match s.handle.as_ref() {
7913            Some(h) => h.clone(),
7914            None => continue,
7915        };
7916        if exclude_set.contains(handle.as_str()) {
7917            continue;
7918        }
7919        if !pinned.contains(&handle) {
7920            continue;
7921        }
7922        let card_path = s
7923            .home_dir
7924            .join("config")
7925            .join("wire")
7926            .join("agent-card.json");
7927        let card_role = std::fs::read(&card_path)
7928            .ok()
7929            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7930            .and_then(|c| {
7931                c.get("profile")
7932                    .and_then(|p| p.get("role"))
7933                    .and_then(Value::as_str)
7934                    .map(str::to_string)
7935            });
7936        if card_role.as_deref() == Some(role) {
7937            candidates.push((handle, s.did.clone()));
7938        }
7939    }
7940
7941    candidates.sort_by(|a, b| a.0.cmp(&b.0));
7942    candidates.dedup_by(|a, b| a.0 == b.0);
7943
7944    if candidates.is_empty() {
7945        bail!(
7946            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
7947        );
7948    }
7949
7950    let chosen = match strategy.as_str() {
7951        "first" => candidates[0].clone(),
7952        "random" => {
7953            use rand::Rng;
7954            let idx = rand::thread_rng().gen_range(0..candidates.len());
7955            candidates[idx].clone()
7956        }
7957        "round-robin" => {
7958            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
7959            // `{role: last_picked_handle}`. Next pick = first candidate
7960            // alphabetically AFTER last_picked, wrapping around when no
7961            // candidate is greater.
7962            let cursor_path = mesh_route_cursor_path()?;
7963            let mut cursors: std::collections::BTreeMap<String, String> =
7964                read_mesh_route_cursors(&cursor_path);
7965            let last = cursors.get(role).cloned();
7966            let pick = match last {
7967                None => candidates[0].clone(),
7968                Some(last_h) => candidates
7969                    .iter()
7970                    .find(|(h, _)| h.as_str() > last_h.as_str())
7971                    .cloned()
7972                    .unwrap_or_else(|| candidates[0].clone()),
7973            };
7974            cursors.insert(role.to_string(), pick.0.clone());
7975            write_mesh_route_cursors(&cursor_path, &cursors)?;
7976            pick
7977        }
7978        _ => unreachable!(),
7979    };
7980
7981    let (chosen_handle, _chosen_did) = chosen;
7982
7983    // Body parsing follows wire send / mesh broadcast.
7984    let body_value: Value = if body_arg == "-" {
7985        use std::io::Read;
7986        let mut raw = String::new();
7987        std::io::stdin()
7988            .read_to_string(&mut raw)
7989            .with_context(|| "reading body from stdin")?;
7990        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7991    } else if let Some(path) = body_arg.strip_prefix('@') {
7992        let raw =
7993            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7994        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7995    } else {
7996        Value::String(body_arg.to_string())
7997    };
7998
7999    let sk_seed = config::read_private_key()?;
8000    let card = config::read_agent_card()?;
8001    let did = card
8002        .get("did")
8003        .and_then(Value::as_str)
8004        .ok_or_else(|| anyhow!("agent-card missing did"))?
8005        .to_string();
8006    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8007    let pk_b64 = card
8008        .get("verify_keys")
8009        .and_then(Value::as_object)
8010        .and_then(|m| m.values().next())
8011        .and_then(|v| v.get("key"))
8012        .and_then(Value::as_str)
8013        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8014    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8015
8016    let kind_id = parse_kind(kind)?;
8017    let now_iso = time::OffsetDateTime::now_utc()
8018        .format(&time::format_description::well_known::Rfc3339)
8019        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8020
8021    let event = json!({
8022        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8023        "timestamp": now_iso,
8024        "from": did,
8025        "to": format!("did:wire:{chosen_handle}"),
8026        "type": kind,
8027        "kind": kind_id,
8028        "body": json!({
8029            "content": body_value,
8030            "routed_via": {
8031                "role": role,
8032                "strategy": strategy,
8033            },
8034        }),
8035    });
8036    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8037        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
8038    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8039
8040    let line = serde_json::to_vec(&signed)?;
8041    config::append_outbox_record(&chosen_handle, &line)?;
8042
8043    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
8044    if endpoints.is_empty() {
8045        bail!(
8046            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
8047        );
8048    }
8049    let start = Instant::now();
8050    let mut delivered = false;
8051    let mut last_err: Option<String> = None;
8052    let mut via_scope: Option<String> = None;
8053    for ep in &endpoints {
8054        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
8055        // route via uds_request, others via reqwest. Allows peers with
8056        // UDS-tagged endpoints in their agent-card to receive events
8057        // over the local socket instead of loopback HTTP.
8058        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8059            Ok(_) => {
8060                delivered = true;
8061                via_scope = Some(
8062                    match ep.scope {
8063                        crate::endpoints::EndpointScope::Local => "local",
8064                        crate::endpoints::EndpointScope::Lan => "lan",
8065                        crate::endpoints::EndpointScope::Uds => "uds",
8066                        crate::endpoints::EndpointScope::Federation => "federation",
8067                    }
8068                    .to_string(),
8069                );
8070                break;
8071            }
8072            Err(e) => last_err = Some(format!("{e:#}")),
8073        }
8074    }
8075    let rtt_ms = start.elapsed().as_millis() as u64;
8076
8077    let summary = json!({
8078        "role": role,
8079        "strategy": strategy,
8080        "routed_to": chosen_handle,
8081        "event_id": event_id,
8082        "delivered": delivered,
8083        "delivered_via": via_scope,
8084        "rtt_ms": rtt_ms,
8085        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
8086        "error": last_err,
8087    });
8088
8089    if as_json {
8090        println!("{}", serde_json::to_string(&summary)?);
8091    } else if delivered {
8092        let via = via_scope.as_deref().unwrap_or("?");
8093        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
8094    } else {
8095        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
8096        bail!("delivery to `{chosen_handle}` failed: {err}");
8097    }
8098    Ok(())
8099}
8100
8101fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
8102    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
8103}
8104
8105fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
8106    std::fs::read(path)
8107        .ok()
8108        .and_then(|b| serde_json::from_slice(&b).ok())
8109        .unwrap_or_default()
8110}
8111
8112fn write_mesh_route_cursors(
8113    path: &std::path::Path,
8114    cursors: &std::collections::BTreeMap<String, String>,
8115) -> Result<()> {
8116    if let Some(parent) = path.parent() {
8117        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
8118    }
8119    let body = serde_json::to_vec_pretty(cursors)?;
8120    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
8121    Ok(())
8122}
8123
8124/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
8125/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
8126/// behind a discoverability-friendlier surface, plus cross-session
8127/// enumeration for the list path.
8128fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
8129    match action {
8130        MeshRoleAction::Set { role, json } => {
8131            validate_role_tag(&role)?;
8132            let new_profile =
8133                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
8134            if json {
8135                println!(
8136                    "{}",
8137                    serde_json::to_string(&json!({
8138                        "role": role,
8139                        "profile": new_profile,
8140                    }))?
8141                );
8142            } else {
8143                println!("self role = {role} (signed into agent-card)");
8144            }
8145        }
8146        MeshRoleAction::Get { peer, json } => {
8147            let (who, role) = match peer.as_deref() {
8148                None => {
8149                    let card = config::read_agent_card()?;
8150                    let role = card
8151                        .get("profile")
8152                        .and_then(|p| p.get("role"))
8153                        .and_then(Value::as_str)
8154                        .map(str::to_string);
8155                    let who = card
8156                        .get("did")
8157                        .and_then(Value::as_str)
8158                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
8159                        .unwrap_or_else(|| "self".to_string());
8160                    (who, role)
8161                }
8162                Some(handle) => {
8163                    let bare = crate::agent_card::bare_handle(handle).to_string();
8164                    let trust = config::read_trust()?;
8165                    let role = trust
8166                        .get("agents")
8167                        .and_then(|a| a.get(&bare))
8168                        .and_then(|a| a.get("card"))
8169                        .and_then(|c| c.get("profile"))
8170                        .and_then(|p| p.get("role"))
8171                        .and_then(Value::as_str)
8172                        .map(str::to_string);
8173                    (bare, role)
8174                }
8175            };
8176            if json {
8177                println!(
8178                    "{}",
8179                    serde_json::to_string(&json!({
8180                        "handle": who,
8181                        "role": role,
8182                    }))?
8183                );
8184            } else {
8185                match role {
8186                    Some(r) => println!("{who}: {r}"),
8187                    None => println!("{who}: (unset)"),
8188                }
8189            }
8190        }
8191        MeshRoleAction::List { json } => {
8192            let mut self_did: Option<String> = None;
8193            if let Ok(card) = config::read_agent_card() {
8194                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
8195            }
8196            let sessions = crate::session::list_sessions()?;
8197            let mut rows: Vec<Value> = Vec::new();
8198            for s in &sessions {
8199                let card_path = s
8200                    .home_dir
8201                    .join("config")
8202                    .join("wire")
8203                    .join("agent-card.json");
8204                let role = std::fs::read(&card_path)
8205                    .ok()
8206                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8207                    .and_then(|c| {
8208                        c.get("profile")
8209                            .and_then(|p| p.get("role"))
8210                            .and_then(Value::as_str)
8211                            .map(str::to_string)
8212                    });
8213                let is_self = match (&self_did, &s.did) {
8214                    (Some(a), Some(b)) => a == b,
8215                    _ => false,
8216                };
8217                rows.push(json!({
8218                    "name": s.name,
8219                    "handle": s.handle,
8220                    "role": role,
8221                    "self": is_self,
8222                }));
8223            }
8224            rows.sort_by(|a, b| {
8225                a["name"]
8226                    .as_str()
8227                    .unwrap_or("")
8228                    .cmp(b["name"].as_str().unwrap_or(""))
8229            });
8230            if json {
8231                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
8232            } else if rows.is_empty() {
8233                println!("no sister sessions on this machine.");
8234            } else {
8235                println!("SISTER ROLES (this machine):");
8236                for r in &rows {
8237                    let name = r["name"].as_str().unwrap_or("?");
8238                    let role = r["role"].as_str().unwrap_or("(unset)");
8239                    let marker = if r["self"].as_bool().unwrap_or(false) {
8240                        "    ← you"
8241                    } else {
8242                        ""
8243                    };
8244                    println!("  {name:<24} {role}{marker}");
8245                }
8246            }
8247        }
8248        MeshRoleAction::Clear { json } => {
8249            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
8250            if json {
8251                println!(
8252                    "{}",
8253                    serde_json::to_string(&json!({
8254                        "cleared": true,
8255                        "profile": new_profile,
8256                    }))?
8257                );
8258            } else {
8259                println!("self role cleared");
8260            }
8261        }
8262    }
8263    Ok(())
8264}
8265
8266/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
8267/// No vocabulary check — operators choose the taxonomy (planner /
8268/// reviewer / dispatcher / your-custom-tag). The constraint is purely
8269/// to keep the tag safe for filenames / URLs / shell args.
8270fn validate_role_tag(role: &str) -> Result<()> {
8271    if role.is_empty() {
8272        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
8273    }
8274    if role.len() > 32 {
8275        bail!("role too long ({} chars; max 32)", role.len());
8276    }
8277    for c in role.chars() {
8278        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
8279            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
8280        }
8281    }
8282    Ok(())
8283}
8284
8285/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
8286///
8287/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
8288/// canonical event including `to:`, so per-recipient signing is required;
8289/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
8290/// Per-recipient pushes happen in parallel via `std::thread::scope` so
8291/// broadcast-to-5 takes ~1× RTT, not 5×.
8292///
8293/// **Scope filter.** Default `local` — only peers reachable via a same-
8294/// machine local relay (priority-1 endpoint has `scope=local`). This is
8295/// the lowest-blast-radius default: local-only broadcasts cannot escape
8296/// the operator's machine. `federation` flips to public-relay peers
8297/// only; `both` removes the filter.
8298///
8299/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
8300/// resolution, never trust["agents"] expansion. Closes #8-class
8301/// phonebook-scrape vectors by construction: an attacker pinning a
8302/// hostile handle has to first be pinned bidirectionally by the
8303/// operator, and even then `--exclude` is the loud opt-out.
8304fn cmd_mesh_broadcast(
8305    kind: &str,
8306    scope_str: &str,
8307    exclude: &[String],
8308    _noreply: bool,
8309    body_arg: &str,
8310    as_json: bool,
8311) -> Result<()> {
8312    use std::time::Instant;
8313
8314    if !config::is_initialized()? {
8315        bail!("not initialized — run `wire init <handle>` first");
8316    }
8317
8318    let scope = match scope_str {
8319        "local" => crate::endpoints::EndpointScope::Local,
8320        "federation" => crate::endpoints::EndpointScope::Federation,
8321        "both" => {
8322            // Sentinel: we don't actually have a `Both` variant on the
8323            // scope enum; use a tri-state below. Treat as Local for the
8324            // typed match and special-case it via the bool below.
8325            crate::endpoints::EndpointScope::Local
8326        }
8327        other => bail!("unknown scope `{other}` — use local | federation | both"),
8328    };
8329    let any_scope = scope_str == "both";
8330
8331    let state = config::read_relay_state()?;
8332    let peers = state["peers"].as_object().cloned().unwrap_or_default();
8333    if peers.is_empty() {
8334        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
8335    }
8336
8337    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8338
8339    // Walk the pinned-peer set, filter by scope + exclude. Keep the
8340    // priority-ordered endpoint list for each match so the push can
8341    // try local first then fall through to federation (when scope=both).
8342    struct Target {
8343        handle: String,
8344        endpoints: Vec<crate::endpoints::Endpoint>,
8345    }
8346    let mut targets: Vec<Target> = Vec::new();
8347    let mut skipped_wrong_scope: Vec<String> = Vec::new();
8348    let mut skipped_excluded: Vec<String> = Vec::new();
8349    for handle in peers.keys() {
8350        if exclude_set.contains(handle.as_str()) {
8351            skipped_excluded.push(handle.clone());
8352            continue;
8353        }
8354        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
8355        let filtered: Vec<crate::endpoints::Endpoint> = ordered
8356            .into_iter()
8357            .filter(|ep| any_scope || ep.scope == scope)
8358            .collect();
8359        if filtered.is_empty() {
8360            skipped_wrong_scope.push(handle.clone());
8361            continue;
8362        }
8363        targets.push(Target {
8364            handle: handle.clone(),
8365            endpoints: filtered,
8366        });
8367    }
8368
8369    if targets.is_empty() {
8370        bail!(
8371            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
8372            skipped_excluded.len(),
8373            skipped_wrong_scope.len()
8374        );
8375    }
8376
8377    // Load signing material once; share across per-peer signatures.
8378    let sk_seed = config::read_private_key()?;
8379    let card = config::read_agent_card()?;
8380    let did = card
8381        .get("did")
8382        .and_then(Value::as_str)
8383        .ok_or_else(|| anyhow!("agent-card missing did"))?
8384        .to_string();
8385    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8386    let pk_b64 = card
8387        .get("verify_keys")
8388        .and_then(Value::as_object)
8389        .and_then(|m| m.values().next())
8390        .and_then(|v| v.get("key"))
8391        .and_then(Value::as_str)
8392        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8393    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8394
8395    let body_value: Value = if body_arg == "-" {
8396        use std::io::Read;
8397        let mut raw = String::new();
8398        std::io::stdin()
8399            .read_to_string(&mut raw)
8400            .with_context(|| "reading body from stdin")?;
8401        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8402    } else if let Some(path) = body_arg.strip_prefix('@') {
8403        let raw =
8404            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8405        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8406    } else {
8407        Value::String(body_arg.to_string())
8408    };
8409
8410    let kind_id = parse_kind(kind)?;
8411    let now_iso = time::OffsetDateTime::now_utc()
8412        .format(&time::format_description::well_known::Rfc3339)
8413        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8414
8415    let broadcast_id = generate_broadcast_id();
8416    let target_count = targets.len();
8417
8418    // Build + sign every event up front (sequential, ~50µs/sig). Then
8419    // queue to outbox + push to relay in parallel per-peer. Returns
8420    // a per-peer outcome we then sort by handle for deterministic output.
8421    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
8422        Vec::with_capacity(targets.len());
8423    for t in &targets {
8424        let body = json!({
8425            "content": body_value,
8426            "broadcast_id": broadcast_id,
8427            "broadcast_target_count": target_count,
8428        });
8429        let event = json!({
8430            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8431            "timestamp": now_iso,
8432            "from": did,
8433            "to": format!("did:wire:{}", t.handle),
8434            "type": kind,
8435            "kind": kind_id,
8436            "body": body,
8437        });
8438        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8439            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
8440        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8441        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
8442    }
8443
8444    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
8445    // holds a per-path mutex; writes are independent across handles but
8446    // we want the side-effect ordering deterministic).
8447    for (peer, _, signed, _) in &signed_per_peer {
8448        let line = serde_json::to_vec(signed)?;
8449        config::append_outbox_record(peer, &line)?;
8450    }
8451
8452    // Per-peer parallel push. Each thread tries the priority-ordered
8453    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
8454    // error_opt) over a channel.
8455    use std::sync::mpsc;
8456    let (tx, rx) = mpsc::channel::<Value>();
8457    std::thread::scope(|s| {
8458        for (peer, endpoints, signed, event_id) in &signed_per_peer {
8459            let tx = tx.clone();
8460            let peer = peer.clone();
8461            let event_id = event_id.clone();
8462            let endpoints = endpoints.clone();
8463            let signed = signed.clone();
8464            s.spawn(move || {
8465                let start = Instant::now();
8466                let mut delivered = false;
8467                let mut last_err: Option<String> = None;
8468                let mut delivered_via: Option<String> = None;
8469                for ep in &endpoints {
8470                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
8471                    // uds_request, else reqwest). Same as cmd_send's
8472                    // single-peer path above; this is the parallel
8473                    // multi-peer broadcast loop.
8474                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8475                        Ok(_) => {
8476                            delivered = true;
8477                            delivered_via = Some(
8478                                match ep.scope {
8479                                    crate::endpoints::EndpointScope::Local => "local",
8480                                    crate::endpoints::EndpointScope::Lan => "lan",
8481                                    crate::endpoints::EndpointScope::Uds => "uds",
8482                                    crate::endpoints::EndpointScope::Federation => "federation",
8483                                }
8484                                .to_string(),
8485                            );
8486                            break;
8487                        }
8488                        Err(e) => last_err = Some(format!("{e:#}")),
8489                    }
8490                }
8491                let rtt_ms = start.elapsed().as_millis() as u64;
8492                let _ = tx.send(json!({
8493                    "peer": peer,
8494                    "event_id": event_id,
8495                    "delivered": delivered,
8496                    "delivered_via": delivered_via,
8497                    "rtt_ms": rtt_ms,
8498                    "error": last_err,
8499                }));
8500            });
8501        }
8502    });
8503    drop(tx);
8504
8505    let mut results: Vec<Value> = rx.iter().collect();
8506    results.sort_by(|a, b| {
8507        a["peer"]
8508            .as_str()
8509            .unwrap_or("")
8510            .cmp(b["peer"].as_str().unwrap_or(""))
8511    });
8512
8513    let delivered = results
8514        .iter()
8515        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
8516        .count();
8517    let failed = results.len() - delivered;
8518
8519    let summary = json!({
8520        "broadcast_id": broadcast_id,
8521        "kind": kind,
8522        "scope": scope_str,
8523        "target_count": target_count,
8524        "delivered": delivered,
8525        "failed": failed,
8526        "skipped_excluded": skipped_excluded,
8527        "skipped_wrong_scope": skipped_wrong_scope,
8528        "results": results,
8529    });
8530
8531    if as_json {
8532        println!("{}", serde_json::to_string(&summary)?);
8533        return Ok(());
8534    }
8535
8536    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
8537    for r in &results {
8538        let peer = r["peer"].as_str().unwrap_or("?");
8539        let delivered = r["delivered"].as_bool().unwrap_or(false);
8540        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
8541        let via = r["delivered_via"].as_str().unwrap_or("");
8542        if delivered {
8543            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
8544        } else {
8545            let err = r["error"].as_str().unwrap_or("?");
8546            println!("  {peer:<24} ✗ failed — {err}");
8547        }
8548    }
8549    if !skipped_excluded.is_empty() {
8550        println!("  excluded: {}", skipped_excluded.join(", "));
8551    }
8552    if !skipped_wrong_scope.is_empty() {
8553        println!(
8554            "  skipped (wrong scope): {}",
8555            skipped_wrong_scope.join(", ")
8556        );
8557    }
8558    println!("broadcast_id: {broadcast_id}");
8559    Ok(())
8560}
8561
8562/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
8563/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
8564/// correlate by string equality, the shape is for human readability.
8565fn generate_broadcast_id() -> String {
8566    use rand::RngCore;
8567    let mut buf = [0u8; 16];
8568    rand::thread_rng().fill_bytes(&mut buf);
8569    let h = hex::encode(buf);
8570    format!(
8571        "{}-{}-{}-{}-{}",
8572        &h[0..8],
8573        &h[8..12],
8574        &h[12..16],
8575        &h[16..20],
8576        &h[20..32],
8577    )
8578}
8579
8580fn cmd_session(cmd: SessionCommand) -> Result<()> {
8581    match cmd {
8582        SessionCommand::New {
8583            name,
8584            relay,
8585            with_local,
8586            local_relay,
8587            with_lan,
8588            lan_relay,
8589            with_uds,
8590            uds_socket,
8591            no_daemon,
8592            local_only,
8593            json,
8594        } => cmd_session_new(
8595            name.as_deref(),
8596            &relay,
8597            with_local,
8598            &local_relay,
8599            with_lan,
8600            lan_relay.as_deref(),
8601            with_uds,
8602            uds_socket.as_deref(),
8603            no_daemon,
8604            local_only,
8605            json,
8606        ),
8607        SessionCommand::List { json } => cmd_session_list(json),
8608        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
8609        SessionCommand::PairAllLocal {
8610            settle_secs,
8611            federation_relay,
8612            json,
8613        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
8614        SessionCommand::MeshStatus { stale_secs, json } => {
8615            cmd_session_mesh_status(stale_secs, json)
8616        }
8617        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
8618        SessionCommand::Current { json } => cmd_session_current(json),
8619        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
8620        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
8621    }
8622}
8623
8624fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
8625    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8626    let cwd_str = cwd.to_string_lossy().into_owned();
8627
8628    let resolved_name = match name_arg {
8629        Some(n) => crate::session::sanitize_name(n),
8630        None => crate::session::sanitize_name(
8631            cwd.file_name()
8632                .and_then(|s| s.to_str())
8633                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
8634        ),
8635    };
8636
8637    let session_home = crate::session::session_dir(&resolved_name)?;
8638    if !session_home.exists() {
8639        bail!(
8640            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
8641            session_home.display()
8642        );
8643    }
8644
8645    let prior = crate::session::read_registry()
8646        .ok()
8647        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
8648    if prior.as_deref() == Some(resolved_name.as_str()) {
8649        if json {
8650            println!(
8651                "{}",
8652                serde_json::to_string(&json!({
8653                    "cwd": cwd_str,
8654                    "session": resolved_name,
8655                    "changed": false,
8656                }))?
8657            );
8658        } else {
8659            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
8660        }
8661        return Ok(());
8662    }
8663    if let Some(prior_name) = &prior {
8664        eprintln!(
8665            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
8666        );
8667    }
8668
8669    crate::session::update_registry(|reg| {
8670        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
8671        Ok(())
8672    })?;
8673
8674    if json {
8675        println!(
8676            "{}",
8677            serde_json::to_string(&json!({
8678                "cwd": cwd_str,
8679                "session": resolved_name,
8680                "changed": true,
8681                "previous": prior,
8682            }))?
8683        );
8684    } else {
8685        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
8686        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
8687    }
8688    Ok(())
8689}
8690
8691fn resolve_session_name(name: Option<&str>) -> Result<String> {
8692    if let Some(n) = name {
8693        return Ok(crate::session::sanitize_name(n));
8694    }
8695    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8696    let registry = crate::session::read_registry().unwrap_or_default();
8697    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
8698}
8699
8700#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
8701// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
8702// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
8703fn cmd_session_new(
8704    name_arg: Option<&str>,
8705    relay: &str,
8706    with_local: bool,
8707    local_relay: &str,
8708    with_lan: bool,
8709    lan_relay: Option<&str>,
8710    with_uds: bool,
8711    uds_socket: Option<&std::path::Path>,
8712    no_daemon: bool,
8713    local_only: bool,
8714    as_json: bool,
8715) -> Result<()> {
8716    // v0.6.6: --local-only implies --with-local (a federation-free
8717    // session with no endpoints at all would be unaddressable).
8718    let with_local = with_local || local_only;
8719    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
8720    if with_lan && lan_relay.is_none() {
8721        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
8722    }
8723    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
8724    if with_uds && uds_socket.is_none() {
8725        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
8726    }
8727    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8728    let mut registry = crate::session::read_registry().unwrap_or_default();
8729    let name = match name_arg {
8730        Some(n) => crate::session::sanitize_name(n),
8731        None => crate::session::derive_name_from_cwd(&cwd, &registry),
8732    };
8733    let session_home = crate::session::session_dir(&name)?;
8734
8735    let already_exists = session_home.exists()
8736        && session_home
8737            .join("config")
8738            .join("wire")
8739            .join("agent-card.json")
8740            .exists();
8741    if already_exists {
8742        // Idempotent: re-register the cwd (if not already), refresh the
8743        // daemon if requested, surface the env-var line. Do not re-init
8744        // identity — that would clobber the keypair.
8745        registry
8746            .by_cwd
8747            .insert(cwd.to_string_lossy().into_owned(), name.clone());
8748        crate::session::write_registry(&registry)?;
8749        let info = render_session_info(&name, &session_home, &cwd)?;
8750        emit_session_new_result(&info, "already_exists", as_json)?;
8751        if !no_daemon {
8752            ensure_session_daemon(&session_home)?;
8753        }
8754        return Ok(());
8755    }
8756
8757    std::fs::create_dir_all(&session_home)
8758        .with_context(|| format!("creating session dir {session_home:?}"))?;
8759
8760    // Phase 1: init identity in the new session's WIRE_HOME. For
8761    // federation-bound sessions we pass `--relay` so init also
8762    // allocates a federation slot in the same step; for `--local-only`
8763    // we run init with `--offline` (v0.9 requires explicit reachability
8764    // acknowledgement at init time) because cmd_session_new allocates
8765    // the local-relay slot itself via try_allocate_local_slot below.
8766    // The session is not actually slotless — init is just deferred to
8767    // the subsequent allocation pass.
8768    let init_args: Vec<&str> = if local_only {
8769        vec!["init", &name, "--offline"]
8770    } else {
8771        vec!["init", &name, "--relay", relay]
8772    };
8773    let init_status = run_wire_with_home(&session_home, &init_args)?;
8774    if !init_status.success() {
8775        let how = if local_only {
8776            format!("`wire init {name}` (local-only)")
8777        } else {
8778            format!("`wire init {name} --relay {relay}`")
8779        };
8780        bail!("{how} failed inside session dir {session_home:?}");
8781    }
8782
8783    // Phase 2: claim the handle on the federation relay — SKIPPED when
8784    // `--local-only`. Local-only sessions have no public address and
8785    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
8786    // tries to publish them.
8787    let effective_handle = if local_only {
8788        name.clone()
8789    } else {
8790        let mut claim_attempt = 0u32;
8791        let mut effective = name.clone();
8792        loop {
8793            claim_attempt += 1;
8794            let status =
8795                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
8796            if status.success() {
8797                break;
8798            }
8799            if claim_attempt >= 5 {
8800                bail!(
8801                    "5 failed attempts to claim a handle on {relay} for session {name}. \
8802                     Try `wire session destroy {name} --force` and re-run with a different name, \
8803                     or use `--local-only` if you don't need a federation address."
8804                );
8805            }
8806            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
8807            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
8808            let token = suffix
8809                .rsplit('-')
8810                .next()
8811                .filter(|t| t.len() == 4)
8812                .map(str::to_string)
8813                .unwrap_or_else(|| format!("{claim_attempt}"));
8814            effective = format!("{name}-{token}");
8815        }
8816        effective
8817    };
8818
8819    // Persist the cwd → name mapping NOW so subsequent invocations from
8820    // this directory short-circuit to the "already_exists" branch.
8821    registry
8822        .by_cwd
8823        .insert(cwd.to_string_lossy().into_owned(), name.clone());
8824    crate::session::write_registry(&registry)?;
8825
8826    // v0.5.17: --with-local probes the local relay and, if it's
8827    // reachable, allocates a second slot there. The session's
8828    // relay_state.json grows a `self.endpoints[]` array carrying both
8829    // endpoints; routing layer (cmd_push) prefers local for sister-
8830    // session peers that also have a local slot.
8831    //
8832    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
8833    // allocation; a failed probe leaves the session with no endpoints,
8834    // which we surface as a hard error (the operator asked for local-
8835    // only but the local relay isn't running — fix that first).
8836    if with_local {
8837        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
8838        if local_only {
8839            // Verify the local slot landed. If the local relay was
8840            // unreachable, the session would be unreachable from
8841            // anywhere — surface that loudly instead of leaving an
8842            // orphaned session dir.
8843            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
8844            let state: Value = std::fs::read(&relay_state_path)
8845                .ok()
8846                .and_then(|b| serde_json::from_slice(&b).ok())
8847                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8848            let endpoints = crate::endpoints::self_endpoints(&state);
8849            let has_local = endpoints
8850                .iter()
8851                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
8852            if !has_local {
8853                bail!(
8854                    "--local-only requested but local-relay probe at {local_relay} failed — \
8855                     ensure the local relay is running (`wire service install --local-relay`), \
8856                     then re-run `wire session new {name} --local-only`."
8857                );
8858            }
8859        }
8860    }
8861
8862    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
8863    // Sits AFTER local because cmd_session_new's flow is "add endpoints
8864    // alongside existing self.endpoints[]" — order independent post-init.
8865    if with_lan && let Some(lan_url) = lan_relay {
8866        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
8867    }
8868    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
8869    if with_uds && let Some(socket_path) = uds_socket {
8870        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
8871    }
8872
8873    if !no_daemon {
8874        ensure_session_daemon(&session_home)?;
8875    }
8876
8877    let info = render_session_info(&name, &session_home, &cwd)?;
8878    emit_session_new_result(&info, "created", as_json)
8879}
8880
8881/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
8882/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
8883/// sister sessions can route over the local socket instead of loopback
8884/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
8885/// alpha.17 — reqwest has no UDS support.
8886///
8887/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
8888/// and try_allocate_lan_slot semantics): session stays at existing
8889/// endpoint mix, operator can retry once the UDS relay is up.
8890#[cfg(unix)]
8891fn try_allocate_uds_slot(
8892    session_home: &std::path::Path,
8893    handle: &str,
8894    uds_socket: &std::path::Path,
8895) {
8896    // Probe healthz first so we fail fast with a clear stderr if the
8897    // socket doesn't exist OR isn't a wire relay.
8898    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
8899        Ok((200, _)) => true,
8900        Ok((status, body)) => {
8901            eprintln!(
8902                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
8903                String::from_utf8_lossy(&body)
8904            );
8905            return;
8906        }
8907        Err(e) => {
8908            eprintln!(
8909                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
8910                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
8911            );
8912            return;
8913        }
8914    };
8915    if !healthz {
8916        return;
8917    }
8918
8919    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
8920    let alloc_body = serde_json::json!({"handle": handle}).to_string();
8921    let (status, body) = match crate::relay_client::uds_request(
8922        uds_socket,
8923        "POST",
8924        "/v1/slot/allocate",
8925        &[("Content-Type", "application/json")],
8926        alloc_body.as_bytes(),
8927    ) {
8928        Ok(r) => r,
8929        Err(e) => {
8930            eprintln!(
8931                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
8932            );
8933            return;
8934        }
8935    };
8936    if status >= 300 {
8937        eprintln!(
8938            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
8939            String::from_utf8_lossy(&body)
8940        );
8941        return;
8942    }
8943    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
8944        Ok(a) => a,
8945        Err(e) => {
8946            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
8947            return;
8948        }
8949    };
8950
8951    let state_path = session_home.join("config").join("wire").join("relay.json");
8952    let mut state: serde_json::Value = std::fs::read(&state_path)
8953        .ok()
8954        .and_then(|b| serde_json::from_slice(&b).ok())
8955        .unwrap_or_else(|| serde_json::json!({}));
8956
8957    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8958        .get("self")
8959        .and_then(|s| s.get("endpoints"))
8960        .and_then(|e| e.as_array())
8961        .map(|arr| {
8962            arr.iter()
8963                .filter_map(|v| {
8964                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8965                })
8966                .collect()
8967        })
8968        .unwrap_or_default();
8969    endpoints.push(crate::endpoints::Endpoint::uds(
8970        format!("unix://{}", uds_socket.display()),
8971        alloc.slot_id.clone(),
8972        alloc.slot_token.clone(),
8973    ));
8974
8975    let self_obj = state
8976        .as_object_mut()
8977        .expect("relay_state root is an object")
8978        .entry("self")
8979        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8980    if !self_obj.is_object() {
8981        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8982    }
8983    if let Some(obj) = self_obj.as_object_mut() {
8984        obj.insert(
8985            "endpoints".into(),
8986            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8987        );
8988    }
8989    if let Err(e) = std::fs::write(
8990        &state_path,
8991        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8992    ) {
8993        eprintln!("wire session new: failed to write {state_path:?}: {e}");
8994        return;
8995    }
8996    eprintln!(
8997        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
8998        uds_socket.display(),
8999        alloc.slot_id
9000    );
9001}
9002
9003#[cfg(not(unix))]
9004fn try_allocate_uds_slot(
9005    _session_home: &std::path::Path,
9006    _handle: &str,
9007    _uds_socket: &std::path::Path,
9008) {
9009    eprintln!(
9010        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
9011    );
9012}
9013
9014/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
9015/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
9016/// pulling the agent-card see a third reachable address.
9017///
9018/// Mirrors `try_allocate_local_slot` but tags the endpoint
9019/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
9020/// session stays at whatever endpoint mix it already had — operators
9021/// can retry with `wire session new --with-lan --lan-relay <url>` once
9022/// the LAN relay is up.
9023fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
9024    let probe = match crate::relay_client::build_blocking_client(Some(
9025        std::time::Duration::from_millis(500),
9026    )) {
9027        Ok(c) => c,
9028        Err(e) => {
9029            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
9030            return;
9031        }
9032    };
9033    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
9034    match probe.get(&healthz_url).send() {
9035        Ok(resp) if resp.status().is_success() => {}
9036        Ok(resp) => {
9037            eprintln!(
9038                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
9039                resp.status()
9040            );
9041            return;
9042        }
9043        Err(e) => {
9044            eprintln!(
9045                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
9046                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
9047                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9048            );
9049            return;
9050        }
9051    };
9052
9053    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
9054    let alloc = match lan_client.allocate_slot(Some(handle)) {
9055        Ok(a) => a,
9056        Err(e) => {
9057            eprintln!(
9058                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
9059            );
9060            return;
9061        }
9062    };
9063
9064    let state_path = session_home.join("config").join("wire").join("relay.json");
9065    let mut state: serde_json::Value = std::fs::read(&state_path)
9066        .ok()
9067        .and_then(|b| serde_json::from_slice(&b).ok())
9068        .unwrap_or_else(|| serde_json::json!({}));
9069
9070    // Read existing endpoints array and add the LAN one. Preserve
9071    // federation / local entries already there.
9072    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9073        .get("self")
9074        .and_then(|s| s.get("endpoints"))
9075        .and_then(|e| e.as_array())
9076        .map(|arr| {
9077            arr.iter()
9078                .filter_map(|v| {
9079                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9080                })
9081                .collect()
9082        })
9083        .unwrap_or_default();
9084    endpoints.push(crate::endpoints::Endpoint::lan(
9085        lan_relay.trim_end_matches('/').to_string(),
9086        alloc.slot_id.clone(),
9087        alloc.slot_token.clone(),
9088    ));
9089
9090    let self_obj = state
9091        .as_object_mut()
9092        .expect("relay_state root is an object")
9093        .entry("self")
9094        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9095    if !self_obj.is_object() {
9096        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9097    }
9098    if let Some(obj) = self_obj.as_object_mut() {
9099        obj.insert(
9100            "endpoints".into(),
9101            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9102        );
9103    }
9104    if let Err(e) = std::fs::write(
9105        &state_path,
9106        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9107    ) {
9108        eprintln!("wire session new: failed to write {state_path:?}: {e}");
9109        return;
9110    }
9111    eprintln!(
9112        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
9113        alloc.slot_id
9114    );
9115}
9116
9117/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
9118/// a short timeout, allocate a slot there and update the session's
9119/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
9120///
9121/// Failure to reach the local relay is NOT fatal — the session stays
9122/// federation-only. Logs to stderr on failure so operators can tell
9123/// the local relay isn't running, but doesn't abort the bootstrap.
9124fn try_allocate_local_slot(
9125    session_home: &std::path::Path,
9126    handle: &str,
9127    _federation_relay: &str,
9128    local_relay: &str,
9129) {
9130    // Probe healthz with a tight timeout. Use a fresh client (don't
9131    // share the daemon-wide one) so the timeout is local to this call.
9132    let probe = match crate::relay_client::build_blocking_client(Some(
9133        std::time::Duration::from_millis(500),
9134    )) {
9135        Ok(c) => c,
9136        Err(e) => {
9137            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
9138            return;
9139        }
9140    };
9141    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
9142    match probe.get(&healthz_url).send() {
9143        Ok(resp) if resp.status().is_success() => {}
9144        Ok(resp) => {
9145            eprintln!(
9146                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
9147                resp.status()
9148            );
9149            return;
9150        }
9151        Err(e) => {
9152            eprintln!(
9153                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
9154                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
9155                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9156            );
9157            return;
9158        }
9159    };
9160
9161    // Allocate a slot on the local relay.
9162    let local_client = crate::relay_client::RelayClient::new(local_relay);
9163    let alloc = match local_client.allocate_slot(Some(handle)) {
9164        Ok(a) => a,
9165        Err(e) => {
9166            eprintln!(
9167                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
9168            );
9169            return;
9170        }
9171    };
9172
9173    // Merge into the session's relay.json. We invoke wire via
9174    // run_wire_with_home for federation calls (subprocess isolation),
9175    // but relay.json is a simple file we can edit directly
9176    // — and need to, because there's no `wire bind-relay --add-local`
9177    // command yet (could add later; out of scope for v0.5.17 MVP).
9178    //
9179    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
9180    // does not exist (canonical filename is `relay.json` per
9181    // `config::relay_state_path`). The mis-named file write succeeded
9182    // but landed in a sibling path nothing else reads. Every
9183    // `wire session new --with-local` invocation silently degraded to
9184    // federation-only despite the "local slot allocated" stderr line.
9185    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
9186    // session's relay.json — it had only the federation endpoint.
9187    let state_path = session_home.join("config").join("wire").join("relay.json");
9188    let mut state: serde_json::Value = std::fs::read(&state_path)
9189        .ok()
9190        .and_then(|b| serde_json::from_slice(&b).ok())
9191        .unwrap_or_else(|| serde_json::json!({}));
9192    // Read the existing federation self info (already written by
9193    // `wire init` + `wire bind-relay` path during session bootstrap).
9194    let fed_endpoint = state.get("self").and_then(|s| {
9195        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
9196        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
9197        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
9198        Some(crate::endpoints::Endpoint::federation(
9199            url.to_string(),
9200            slot_id.to_string(),
9201            slot_token.to_string(),
9202        ))
9203    });
9204
9205    let local_endpoint = crate::endpoints::Endpoint::local(
9206        local_relay.trim_end_matches('/').to_string(),
9207        alloc.slot_id.clone(),
9208        alloc.slot_token.clone(),
9209    );
9210
9211    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
9212    if let Some(f) = fed_endpoint.clone() {
9213        endpoints.push(f);
9214    }
9215    endpoints.push(local_endpoint);
9216
9217    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
9218    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
9219    // `slot_token` fields must point at the LOCAL endpoint so callers
9220    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
9221    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
9222    // still find a valid slot. Pre-v0.6.6 this branch wrote
9223    // `relay_url: federation_relay` with no slot_id, which produced
9224    // half-populated self state that broke pair-accept on local-only
9225    // sessions.
9226    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
9227        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
9228        None => (
9229            local_relay.trim_end_matches('/').to_string(),
9230            alloc.slot_id.clone(),
9231            alloc.slot_token.clone(),
9232        ),
9233    };
9234    let self_obj = state
9235        .as_object_mut()
9236        .expect("relay_state root is an object")
9237        .entry("self")
9238        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9239    // The entry might be Value::Null (left by read_relay_state's default
9240    // template) — replace with an object before mutating.
9241    if !self_obj.is_object() {
9242        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9243    }
9244    if let Some(obj) = self_obj.as_object_mut() {
9245        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
9246        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
9247        obj.insert(
9248            "slot_token".into(),
9249            serde_json::Value::String(legacy_slot_token),
9250        );
9251        obj.insert(
9252            "endpoints".into(),
9253            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9254        );
9255    }
9256
9257    if let Err(e) = std::fs::write(
9258        &state_path,
9259        serde_json::to_vec_pretty(&state).unwrap_or_default(),
9260    ) {
9261        eprintln!(
9262            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
9263        );
9264        return;
9265    }
9266    eprintln!(
9267        "wire session new: local slot allocated on {local_relay} (slot_id={})",
9268        alloc.slot_id
9269    );
9270}
9271
9272fn render_session_info(
9273    name: &str,
9274    session_home: &std::path::Path,
9275    cwd: &std::path::Path,
9276) -> Result<serde_json::Value> {
9277    let card_path = session_home
9278        .join("config")
9279        .join("wire")
9280        .join("agent-card.json");
9281    let (did, handle) = if card_path.exists() {
9282        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
9283        let did = card
9284            .get("did")
9285            .and_then(Value::as_str)
9286            .unwrap_or("")
9287            .to_string();
9288        let handle = card
9289            .get("handle")
9290            .and_then(Value::as_str)
9291            .map(str::to_string)
9292            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9293        (did, handle)
9294    } else {
9295        (String::new(), String::new())
9296    };
9297    Ok(json!({
9298        "name": name,
9299        "home_dir": session_home.to_string_lossy(),
9300        "cwd": cwd.to_string_lossy(),
9301        "did": did,
9302        "handle": handle,
9303        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9304    }))
9305}
9306
9307fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
9308    if as_json {
9309        let mut obj = info.clone();
9310        obj["status"] = json!(status);
9311        println!("{}", serde_json::to_string(&obj)?);
9312    } else {
9313        let name = info["name"].as_str().unwrap_or("?");
9314        let handle = info["handle"].as_str().unwrap_or("?");
9315        let home = info["home_dir"].as_str().unwrap_or("?");
9316        let did = info["did"].as_str().unwrap_or("?");
9317        let export = info["export"].as_str().unwrap_or("?");
9318        let prefix = if status == "already_exists" {
9319            "session already exists (re-registered cwd)"
9320        } else {
9321            "session created"
9322        };
9323        println!(
9324            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
9325        );
9326    }
9327    Ok(())
9328}
9329
9330fn run_wire_with_home(
9331    session_home: &std::path::Path,
9332    args: &[&str],
9333) -> Result<std::process::ExitStatus> {
9334    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9335    let status = std::process::Command::new(&bin)
9336        .env("WIRE_HOME", session_home)
9337        .env_remove("RUST_LOG")
9338        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
9339        // We already own the session; nested init would clobber state.
9340        .env("WIRE_AUTO_INIT", "0")
9341        .args(args)
9342        .status()
9343        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9344    Ok(status)
9345}
9346
9347/// v0.7.0-alpha.2: idempotent per-cwd session creation.
9348///
9349/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
9350/// registered session for the current cwd — including via parent-walk —
9351/// this creates one inline so every Claude tab in a fresh project gets
9352/// its own wire identity rather than collapsing onto the machine-wide
9353/// default. Without this, multiple Claudes in unwired cwds all render
9354/// the same character (the default identity's character), defeating the
9355/// "every session looks different" promise.
9356///
9357/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
9358/// `run_wire_with_home` subprocess context).
9359///
9360/// Best-effort: any failure (no home dir, name collision pathology,
9361/// `wire init` subprocess crash) is logged to stderr and we fall back
9362/// to default identity. Must not block MCP startup.
9363///
9364/// MUST be called BEFORE worker thread spawn (env::set_var safety).
9365pub fn maybe_auto_init_cwd_session(label: &str) {
9366    if std::env::var("WIRE_HOME").is_ok() {
9367        return; // explicit override OR auto-detect already won
9368    }
9369    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
9370        return; // operator opt-out
9371    }
9372    let cwd = match std::env::current_dir() {
9373        Ok(c) => c,
9374        Err(_) => return,
9375    };
9376    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
9377    // already runs but we want to be robust to ordering).
9378    if crate::session::detect_session_wire_home(&cwd).is_some() {
9379        return;
9380    }
9381
9382    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
9383    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
9384    // Two different cwds with the same basename (e.g. /a/projx +
9385    // /b/projx) used to race outside the lock: both read empty
9386    // registry, both derived name="projx", per-name lock didn't help
9387    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
9388    //
9389    // Single lock serializes ALL auto-init across the sessions_root.
9390    // Inside the lock: re-read registry, derive_name_from_cwd which
9391    // adds path-hash suffix when basename is occupied by another cwd
9392    // already committed to the registry. Different cwds get DIFFERENT
9393    // names guaranteed.
9394    //
9395    // Cost: parallel auto-inits in different cwds now serialize
9396    // (~hundreds of ms each when local relay is up). Acceptable —
9397    // auto-init runs once per cwd per machine; not a hot path.
9398    use fs2::FileExt;
9399    let sessions_root = match crate::session::sessions_root() {
9400        Ok(r) => r,
9401        Err(_) => return,
9402    };
9403    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
9404        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
9405        return;
9406    }
9407    let lock_path = sessions_root.join(".auto-init.lock");
9408    let lock_file = match std::fs::OpenOptions::new()
9409        .create(true)
9410        .truncate(false)
9411        .read(true)
9412        .write(true)
9413        .open(&lock_path)
9414    {
9415        Ok(f) => f,
9416        Err(e) => {
9417            eprintln!(
9418                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
9419            );
9420            return;
9421        }
9422    };
9423    if let Err(e) = lock_file.lock_exclusive() {
9424        eprintln!(
9425            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
9426        );
9427        return;
9428    }
9429    // Lock acquired. Read registry + derive name now that all parallel
9430    // racers serialize through us — derive_name_from_cwd adds a
9431    // path-hash suffix if the basename is already claimed by another
9432    // cwd in the (now-stable) registry.
9433    let registry = crate::session::read_registry().unwrap_or_default();
9434    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
9435    let session_home = match crate::session::session_dir(&name) {
9436        Ok(h) => h,
9437        Err(_) => {
9438            let _ = fs2::FileExt::unlock(&lock_file);
9439            return;
9440        }
9441    };
9442    let agent_card_path = session_home
9443        .join("config")
9444        .join("wire")
9445        .join("agent-card.json");
9446    let needs_init = !agent_card_path.exists();
9447
9448    if needs_init {
9449        if let Err(e) = std::fs::create_dir_all(&session_home) {
9450            eprintln!(
9451                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
9452            );
9453            let _ = fs2::FileExt::unlock(&lock_file);
9454            return;
9455        }
9456        // v0.9: --offline; the surrounding session-spawn path runs
9457        // try_allocate_local_slot afterward to attach an inbound slot
9458        // when a local relay is available. Init itself stays slotless
9459        // because it's a precursor step, not the final state.
9460        match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
9461            Ok(status) if status.success() => {}
9462            Ok(status) => {
9463                eprintln!(
9464                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
9465                );
9466                let _ = fs2::FileExt::unlock(&lock_file);
9467                return;
9468            }
9469            Err(e) => {
9470                eprintln!(
9471                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
9472                );
9473                let _ = fs2::FileExt::unlock(&lock_file);
9474                return;
9475            }
9476        }
9477        // Best-effort: allocate a local-relay slot so this auto-init'd
9478        // session is addressable by sister sessions. Skipped silently when
9479        // the local relay isn't running (the function itself reports to
9480        // stderr). Auto-init'd sessions without endpoints can still
9481        // surface their character but cannot receive pair_drops until the
9482        // operator runs `wire bind-relay` or restarts the local relay.
9483        try_allocate_local_slot(
9484            &session_home,
9485            &name,
9486            "https://wireup.net",
9487            "http://127.0.0.1:8771",
9488        );
9489    } else {
9490        // Race loser path: peer already created the session. Surface
9491        // this honestly so the operator can see we adopted rather than
9492        // double-initialized.
9493        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
9494            eprintln!(
9495                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
9496            );
9497        }
9498    }
9499    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
9500    // BEFORE releasing the auto-init lock. Pre-fix released the lock
9501    // here and committed the registry update afterward — racers in
9502    // OTHER cwds with the same basename would acquire the lock,
9503    // read the registry (still without our entry), and derive the
9504    // SAME name we just claimed. Live regression test caught it:
9505    // two cwds /a/projx + /b/projx both got name "projx", both
9506    // mapped to the same identity. Update the registry WHILE STILL
9507    // holding the auto-init lock so the next racer sees our claim.
9508    let cwd_key = cwd.to_string_lossy().into_owned();
9509    let name_for_reg = name.clone();
9510    if let Err(e) = crate::session::update_registry(|reg| {
9511        reg.by_cwd.insert(cwd_key, name_for_reg);
9512        Ok(())
9513    }) {
9514        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
9515        // proceed — env var still gets set below
9516    }
9517    // NOW release the lock — racers waiting will see our registry
9518    // entry on their re-read.
9519    let _ = fs2::FileExt::unlock(&lock_file);
9520
9521    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
9522        eprintln!(
9523            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
9524            cwd.display(),
9525            session_home.display()
9526        );
9527    }
9528    // SAFETY: caller contract is "before any thread spawn." MCP::run
9529    // calls this immediately after `maybe_adopt_session_wire_home`.
9530    unsafe {
9531        std::env::set_var("WIRE_HOME", &session_home);
9532    }
9533}
9534
9535fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
9536    // Check if a daemon is already alive in this session's WIRE_HOME.
9537    // If so, no-op (let the existing process keep running).
9538    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9539    if pidfile.exists() {
9540        let bytes = std::fs::read(&pidfile).unwrap_or_default();
9541        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9542            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9543        } else {
9544            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9545        };
9546        if let Some(p) = pid {
9547            let alive = {
9548                #[cfg(target_os = "linux")]
9549                {
9550                    std::path::Path::new(&format!("/proc/{p}")).exists()
9551                }
9552                #[cfg(not(target_os = "linux"))]
9553                {
9554                    std::process::Command::new("kill")
9555                        .args(["-0", &p.to_string()])
9556                        .output()
9557                        .map(|o| o.status.success())
9558                        .unwrap_or(false)
9559                }
9560            };
9561            if alive {
9562                return Ok(());
9563            }
9564        }
9565    }
9566
9567    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
9568    // versioned pidfile; we just kick it off and return.
9569    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9570    let log_path = session_home.join("state").join("wire").join("daemon.log");
9571    if let Some(parent) = log_path.parent() {
9572        std::fs::create_dir_all(parent).ok();
9573    }
9574    let log_file = std::fs::OpenOptions::new()
9575        .create(true)
9576        .append(true)
9577        .open(&log_path)
9578        .with_context(|| format!("opening daemon log {log_path:?}"))?;
9579    let log_err = log_file.try_clone()?;
9580    std::process::Command::new(&bin)
9581        .env("WIRE_HOME", session_home)
9582        .env_remove("RUST_LOG")
9583        .args(["daemon", "--interval", "5"])
9584        .stdout(log_file)
9585        .stderr(log_err)
9586        .stdin(std::process::Stdio::null())
9587        .spawn()
9588        .with_context(|| "spawning session-local `wire daemon`")?;
9589    Ok(())
9590}
9591
9592fn cmd_session_list(as_json: bool) -> Result<()> {
9593    let items = crate::session::list_sessions()?;
9594    if as_json {
9595        println!("{}", serde_json::to_string(&items)?);
9596        return Ok(());
9597    }
9598    if items.is_empty() {
9599        println!("no sessions on this machine. `wire session new` to create one.");
9600        return Ok(());
9601    }
9602    println!(
9603        "{:<22} {:<24} {:<24} {:<10} CWD",
9604        "PERSONA", "NAME", "HANDLE", "DAEMON"
9605    );
9606    for s in items {
9607        // ANSI-escape-wrapped character takes more visual width than its
9608        // displayed glyph count; pad based on the plain-text form, then
9609        // wrap in escapes so the column lines up across rows.
9610        let plain = s
9611            .character
9612            .as_ref()
9613            .map(|c| c.short())
9614            .unwrap_or_else(|| "?".to_string());
9615        let colored = s
9616            .character
9617            .as_ref()
9618            .map(|c| c.colored())
9619            .unwrap_or_else(|| "?".to_string());
9620        // Approximate display width: emoji renders as ~2 cells in most
9621        // terminals; the rest are 1 cell each. We pad to 18 displayed
9622        // chars (≈22 byte slots when counting emoji).
9623        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
9624        let pad = 22usize.saturating_sub(displayed_width);
9625        println!(
9626            "{}{}  {:<24} {:<24} {:<10} {}",
9627            colored,
9628            " ".repeat(pad),
9629            s.name,
9630            s.handle.as_deref().unwrap_or("?"),
9631            if s.daemon_running { "running" } else { "down" },
9632            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
9633        );
9634    }
9635    Ok(())
9636}
9637
9638/// v0.5.19: `wire session list-local` — sister-session discovery.
9639///
9640/// For each on-disk session, read its `relay-state.json` and surface
9641/// the ones that have a Local-scope endpoint (allocated via
9642/// `wire session new --with-local`). Group by the local-relay URL so
9643/// the operator can see at a glance which sessions are mutually
9644/// reachable over the same loopback relay.
9645///
9646/// Read-only, no daemon contact. Useful as the prelude to teaming /
9647/// pairing same-box sister claudes (see also `wire session
9648/// pair-all-local` once implemented).
9649fn cmd_session_list_local(as_json: bool) -> Result<()> {
9650    let listing = crate::session::list_local_sessions()?;
9651    if as_json {
9652        println!("{}", serde_json::to_string(&listing)?);
9653        return Ok(());
9654    }
9655
9656    if listing.local.is_empty() && listing.federation_only.is_empty() {
9657        println!(
9658            "no sessions on this machine. `wire session new --with-local` to create one \
9659             with a local-relay endpoint (start the relay first: \
9660             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
9661        );
9662        return Ok(());
9663    }
9664
9665    if listing.local.is_empty() {
9666        println!(
9667            "no sister sessions reachable via a local relay. \
9668             Re-run `wire session new --with-local` to add a Local endpoint, or \
9669             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
9670        );
9671    } else {
9672        // Stable iteration order: sort the relay URLs.
9673        let mut keys: Vec<&String> = listing.local.keys().collect();
9674        keys.sort();
9675        for relay_url in keys {
9676            let group = &listing.local[relay_url];
9677            println!("LOCAL RELAY: {relay_url}");
9678            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
9679            for s in group {
9680                println!(
9681                    "  {:<24} {:<32} {:<10} {}",
9682                    s.name,
9683                    s.handle.as_deref().unwrap_or("?"),
9684                    if s.daemon_running { "running" } else { "down" },
9685                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
9686                );
9687            }
9688            println!();
9689        }
9690    }
9691
9692    if !listing.federation_only.is_empty() {
9693        println!("federation-only (no local endpoint):");
9694        for s in &listing.federation_only {
9695            println!(
9696                "  {:<24} {:<32} {}",
9697                s.name,
9698                s.handle.as_deref().unwrap_or("?"),
9699                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
9700            );
9701        }
9702    }
9703    Ok(())
9704}
9705
9706/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
9707/// session that has a Local-scope endpoint. Skips already-paired
9708/// pairs; reports a per-pair outcome JSON suitable for scripting.
9709///
9710/// Same-uid trust anchor: the caller owns every session enumerated by
9711/// `list_local_sessions`, so the operator running this command IS the
9712/// consent for both sides. The bilateral SAS / network-level handshake
9713/// assumes strangers; same-uid sister sessions are not strangers.
9714///
9715/// Per-pair flow (sequential to keep relay-side load + log clarity):
9716///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
9717///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
9718///   3. sleep settle_secs                       (pair_drop reaches B)
9719///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
9720///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
9721///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
9722///   7. sleep settle_secs                       (ack reaches A)
9723///   8. WIRE_HOME=A wire pull --json            (A pins B)
9724fn cmd_session_pair_all_local(
9725    settle_secs: u64,
9726    federation_relay: &str,
9727    as_json: bool,
9728) -> Result<()> {
9729    use std::collections::BTreeSet;
9730    use std::time::Duration;
9731
9732    let listing = crate::session::list_local_sessions()?;
9733    // Flatten + dedup by session NAME (same session can appear under
9734    // multiple local-relay URLs if it advertises two local endpoints;
9735    // rare, but pair each pair exactly once).
9736    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
9737        Default::default();
9738    for group in listing.local.into_values() {
9739        for s in group {
9740            by_name.entry(s.name.clone()).or_insert(s);
9741        }
9742    }
9743    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
9744
9745    if sessions.len() < 2 {
9746        let msg = format!(
9747            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
9748            sessions.len()
9749        );
9750        if as_json {
9751            println!(
9752                "{}",
9753                serde_json::to_string(&json!({
9754                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
9755                    "pairs_attempted": 0,
9756                    "pairs_succeeded": 0,
9757                    "pairs_skipped_already_paired": 0,
9758                    "pairs_failed": 0,
9759                    "note": msg,
9760                }))?
9761            );
9762        } else {
9763            println!("{msg}");
9764            if let Some(s) = sessions.first() {
9765                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
9766            }
9767            println!("Use `wire session new --with-local` to add more.");
9768        }
9769        return Ok(());
9770    }
9771
9772    let fed_host = host_of_url(federation_relay);
9773    if fed_host.is_empty() {
9774        bail!(
9775            "federation_relay `{federation_relay}` has no parseable host — \
9776             pass a full URL like `https://wireup.net`."
9777        );
9778    }
9779
9780    // Enumerate unordered pairs deterministically by session name.
9781    let mut attempted = 0u32;
9782    let mut succeeded = 0u32;
9783    let mut skipped_already = 0u32;
9784    let mut failed = 0u32;
9785    let mut per_pair: Vec<Value> = Vec::new();
9786
9787    for i in 0..sessions.len() {
9788        for j in (i + 1)..sessions.len() {
9789            let a = &sessions[i];
9790            let b = &sessions[j];
9791            attempted += 1;
9792
9793            // Already-paired check: if A's relay-state has B's CARD
9794            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
9795            // are character handles (not session names), so we use
9796            // each side's handle field (already on the LocalSessionView)
9797            // for the lookup rather than the session name.
9798            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
9799            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
9800            let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
9801            let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
9802            if a_pinned_b && b_pinned_a {
9803                skipped_already += 1;
9804                per_pair.push(json!({
9805                    "from": a.name,
9806                    "to": b.name,
9807                    "status": "already_paired",
9808                }));
9809                continue;
9810            }
9811
9812            let pair_result = drive_bilateral_pair(
9813                &a.home_dir,
9814                &a.name,
9815                &b.home_dir,
9816                &b.name,
9817                &fed_host,
9818                federation_relay,
9819                settle_secs,
9820            );
9821
9822            match pair_result {
9823                Ok(()) => {
9824                    succeeded += 1;
9825                    per_pair.push(json!({
9826                        "from": a.name,
9827                        "to": b.name,
9828                        "status": "paired",
9829                    }));
9830                }
9831                Err(e) => {
9832                    failed += 1;
9833                    let detail = format!("{e:#}");
9834                    per_pair.push(json!({
9835                        "from": a.name,
9836                        "to": b.name,
9837                        "status": "failed",
9838                        "error": detail,
9839                    }));
9840                }
9841            }
9842
9843            // Brief settle between pairs so we don't slam the relay
9844            // with N(N-1) parallel requests.
9845            std::thread::sleep(Duration::from_millis(200));
9846        }
9847    }
9848
9849    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
9850    let summary = json!({
9851        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
9852        "pairs_attempted": attempted,
9853        "pairs_succeeded": succeeded,
9854        "pairs_skipped_already_paired": skipped_already,
9855        "pairs_failed": failed,
9856        "results": per_pair,
9857    });
9858    if as_json {
9859        println!("{}", serde_json::to_string(&summary)?);
9860    } else {
9861        println!(
9862            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
9863            sessions.len(),
9864            attempted
9865        );
9866        println!("  paired:                 {succeeded}");
9867        println!("  skipped (already pinned): {skipped_already}");
9868        println!("  failed:                 {failed}");
9869        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
9870            let from = entry["from"].as_str().unwrap_or("?");
9871            let to = entry["to"].as_str().unwrap_or("?");
9872            let status = entry["status"].as_str().unwrap_or("?");
9873            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
9874            if err.is_empty() {
9875                println!("  {from:<24} ↔ {to:<24} {status}");
9876            } else {
9877                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
9878            }
9879        }
9880    }
9881    Ok(())
9882}
9883
9884/// Check whether `session_home`'s `relay.json` already lists `peer_name`
9885/// under `state.peers`. Best-effort — any read/parse error → false.
9886fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
9887    val_session_relay_state(session_home)
9888        .and_then(|v| v.get("peers").cloned())
9889        .and_then(|p| p.get(peer_name).cloned())
9890        .is_some()
9891}
9892
9893/// Read a session's `relay.json` directly without mutating the process'
9894/// WIRE_HOME env (which would race other threads / processes). Returns
9895/// `None` on any read or parse error — callers treat missing state as
9896/// "no peers / no endpoints" rather than aborting.
9897fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
9898    let path = session_home.join("config").join("wire").join("relay.json");
9899    let bytes = std::fs::read(&path).ok()?;
9900    serde_json::from_slice(&bytes).ok()
9901}
9902
9903/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
9904/// One probe per directed edge against the relay backing that edge's
9905/// priority-1 endpoint; output groups by undirected pair.
9906fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
9907    use std::collections::BTreeMap;
9908
9909    // Flatten by session NAME — same dedup logic as pair-all-local so a
9910    // session advertising two local endpoints doesn't get double-counted.
9911    let listing = crate::session::list_local_sessions()?;
9912    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
9913    for group in listing.local.into_values() {
9914        for s in group {
9915            by_name.entry(s.name.clone()).or_insert(s);
9916        }
9917    }
9918    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
9919    let federation_only = listing.federation_only;
9920
9921    if sessions.is_empty() {
9922        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
9923        if as_json {
9924            println!(
9925                "{}",
9926                serde_json::to_string(&json!({
9927                    "sessions": [],
9928                    "edges": [],
9929                    "local_relay": null,
9930                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9931                    "summary": {
9932                        "session_count": 0,
9933                        "edge_count": 0,
9934                        "healthy": 0,
9935                        "stale": 0,
9936                        "asymmetric": 0,
9937                    },
9938                    "note": msg,
9939                }))?
9940            );
9941        } else {
9942            println!("{msg}");
9943            println!("Use `wire session new --with-local` to create one.");
9944        }
9945        return Ok(());
9946    }
9947
9948    // Build a name → session-state map: relay_state + reachable handle set.
9949    struct SessionState {
9950        view: crate::session::LocalSessionView,
9951        relay_state: Value,
9952        local_relay_url: Option<String>,
9953    }
9954    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
9955    for s in sessions {
9956        let relay_state = val_session_relay_state(&s.home_dir)
9957            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9958        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
9959        sstates.push(SessionState {
9960            view: s,
9961            relay_state,
9962            local_relay_url,
9963        });
9964    }
9965
9966    // Probe each unique local-relay URL once for healthz so the operator
9967    // sees one liveness line per local relay, not one per edge.
9968    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
9969    for s in &sstates {
9970        if let Some(url) = &s.local_relay_url
9971            && !local_relays.contains_key(url)
9972        {
9973            let healthy = probe_relay_healthz(url);
9974            local_relays.insert(url.clone(), healthy);
9975        }
9976    }
9977
9978    let now = std::time::SystemTime::now()
9979        .duration_since(std::time::UNIX_EPOCH)
9980        .map(|d| d.as_secs())
9981        .unwrap_or(0);
9982
9983    // Edges: walk every unordered pair, surface bilateral state + each
9984    // direction's last_pull. Probe priority-1 endpoint (local preferred
9985    // by `peer_endpoints_in_priority_order`).
9986    let mut edges: Vec<Value> = Vec::new();
9987    let mut healthy_count = 0u32;
9988    let mut stale_count = 0u32;
9989    let mut asymmetric_count = 0u32;
9990
9991    for i in 0..sstates.len() {
9992        for j in (i + 1)..sstates.len() {
9993            let a = &sstates[i];
9994            let b = &sstates[j];
9995            // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
9996            // (DID-derived character), not the session name. Look the
9997            // peer up by its handle (with a session-name fallback for
9998            // pre-v0.11 sessions that haven't re-init'd yet).
9999            let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
10000            let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
10001            let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
10002            let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
10003
10004            let bilateral = a_to_b.pinned && b_to_a.pinned;
10005            // Scope = the most-local scope available in either direction.
10006            // (If a→b is local and b→a is federation, the asymmetric
10007            // detail surfaces below; the headline scope is the better.)
10008            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
10009                (Some("local"), _) | (_, Some("local")) => "local",
10010                (Some("federation"), _) | (_, Some("federation")) => "federation",
10011                _ => "unknown",
10012            };
10013
10014            // Health: stale if either direction's last_pull is older than
10015            // `stale_secs`, or never observed when both sides are pinned.
10016            let mut status = if bilateral { "healthy" } else { "asymmetric" };
10017            if bilateral {
10018                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
10019                    Some(s) => s > stale_secs,
10020                    None => d.probed,
10021                });
10022                if either_stale {
10023                    status = "stale";
10024                }
10025            }
10026
10027            match status {
10028                "healthy" => healthy_count += 1,
10029                "stale" => stale_count += 1,
10030                "asymmetric" => asymmetric_count += 1,
10031                _ => {}
10032            }
10033
10034            edges.push(json!({
10035                "from": a.view.name,
10036                "to": b.view.name,
10037                "bilateral": bilateral,
10038                "scope": scope,
10039                "status": status,
10040                "directions": {
10041                    a.view.name.clone(): direction_summary(&a_to_b),
10042                    b.view.name.clone(): direction_summary(&b_to_a),
10043                },
10044            }));
10045        }
10046    }
10047
10048    let summary = json!({
10049        "sessions": sstates.iter().map(|s| json!({
10050            "name": s.view.name,
10051            "handle": s.view.handle,
10052            "cwd": s.view.cwd,
10053            "daemon_running": s.view.daemon_running,
10054            "local_relay": s.local_relay_url,
10055        })).collect::<Vec<_>>(),
10056        "edges": edges,
10057        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
10058            "url": url,
10059            "healthy": healthy,
10060        })).collect::<Vec<_>>(),
10061        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10062        "summary": {
10063            "session_count": sstates.len(),
10064            "edge_count": edges.len(),
10065            "healthy": healthy_count,
10066            "stale": stale_count,
10067            "asymmetric": asymmetric_count,
10068            "stale_threshold_secs": stale_secs,
10069        },
10070    });
10071
10072    if as_json {
10073        println!("{}", serde_json::to_string(&summary)?);
10074        return Ok(());
10075    }
10076
10077    println!(
10078        "wire mesh: {} session(s), {} edge(s)",
10079        sstates.len(),
10080        edges.len()
10081    );
10082    for (url, healthy) in &local_relays {
10083        let tick = if *healthy { "✓" } else { "✗" };
10084        println!("  local-relay {url} {tick}");
10085    }
10086    if !federation_only.is_empty() {
10087        print!("  federation-only sessions:");
10088        for f in &federation_only {
10089            print!(" {}", f.name);
10090        }
10091        println!();
10092    }
10093
10094    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
10095    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
10096    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
10097    print!("\n{:>col_w$}", "", col_w = col_w);
10098    for n in &names {
10099        print!("{:>col_w$}", n, col_w = col_w);
10100    }
10101    println!();
10102    for (i, row) in names.iter().enumerate() {
10103        print!("{:>col_w$}", row, col_w = col_w);
10104        for (j, col) in names.iter().enumerate() {
10105            let cell = if i == j {
10106                "self".to_string()
10107            } else {
10108                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
10109                match d.scope.as_deref() {
10110                    Some("local") => "local".to_string(),
10111                    Some("federation") => "fed".to_string(),
10112                    _ => "—".to_string(),
10113                }
10114            };
10115            print!("{:>col_w$}", cell, col_w = col_w);
10116        }
10117        println!();
10118    }
10119
10120    println!("\nHealth (stale threshold: {stale_secs}s):");
10121    for e in &edges {
10122        let from = e["from"].as_str().unwrap_or("?");
10123        let to = e["to"].as_str().unwrap_or("?");
10124        let scope = e["scope"].as_str().unwrap_or("?");
10125        let status = e["status"].as_str().unwrap_or("?");
10126        let mark = match status {
10127            "healthy" => "✓",
10128            "stale" => "⚠",
10129            "asymmetric" => "!",
10130            _ => "?",
10131        };
10132        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
10133        let mut details: Vec<String> = Vec::new();
10134        for (who, d) in &dirs {
10135            let silent = d.get("silent_secs").and_then(Value::as_u64);
10136            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
10137            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
10138            let label = match (pinned, probed, silent) {
10139                (false, _, _) => format!("{who} has not pinned"),
10140                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
10141                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
10142                (true, true, Some(s)) => format!("{who} silent {s}s"),
10143                (true, true, None) => format!("{who} never pulled"),
10144            };
10145            details.push(label);
10146        }
10147        println!(
10148            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
10149            details.join(" | ")
10150        );
10151    }
10152    Ok(())
10153}
10154
10155#[derive(Default)]
10156struct DirectedEdge {
10157    pinned: bool,
10158    scope: Option<String>,
10159    last_pull_at_unix: Option<u64>,
10160    silent_secs: Option<u64>,
10161    probed: bool,
10162    event_count: usize,
10163}
10164
10165/// Probe a single directed edge from `from_state`'s view of `to_name`.
10166/// Picks the priority-1 endpoint (local preferred when reachable) and
10167/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
10168/// failure (the function records `probed = true`, `last_pull = None`,
10169/// which the caller treats as "never pulled, route exists" = stale).
10170fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
10171    let pinned = from_state
10172        .get("peers")
10173        .and_then(|p| p.get(to_name))
10174        .is_some();
10175    if !pinned {
10176        return DirectedEdge::default();
10177    }
10178    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
10179    let ep = match endpoints.into_iter().next() {
10180        Some(e) => e,
10181        None => {
10182            return DirectedEdge {
10183                pinned: true,
10184                ..Default::default()
10185            };
10186        }
10187    };
10188    let scope = Some(
10189        match ep.scope {
10190            crate::endpoints::EndpointScope::Local => "local",
10191            crate::endpoints::EndpointScope::Lan => "lan",
10192            crate::endpoints::EndpointScope::Uds => "uds",
10193            crate::endpoints::EndpointScope::Federation => "federation",
10194        }
10195        .to_string(),
10196    );
10197    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
10198    let (count, last) = client
10199        .slot_state(&ep.slot_id, &ep.slot_token)
10200        .unwrap_or((0, None));
10201    let silent = last.map(|t| now.saturating_sub(t));
10202    DirectedEdge {
10203        pinned: true,
10204        scope,
10205        last_pull_at_unix: last,
10206        silent_secs: silent,
10207        probed: true,
10208        event_count: count,
10209    }
10210}
10211
10212fn direction_summary(d: &DirectedEdge) -> Value {
10213    json!({
10214        "pinned": d.pinned,
10215        "scope": d.scope,
10216        "probed": d.probed,
10217        "last_pull_at_unix": d.last_pull_at_unix,
10218        "silent_secs": d.silent_secs,
10219        "event_count": d.event_count,
10220    })
10221}
10222
10223/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
10224fn probe_relay_healthz(url: &str) -> bool {
10225    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
10226    let client = match reqwest::blocking::Client::builder()
10227        .timeout(std::time::Duration::from_millis(500))
10228        .build()
10229    {
10230        Ok(c) => c,
10231        Err(_) => return false,
10232    };
10233    match client.get(&probe_url).send() {
10234        Ok(r) => r.status().is_success(),
10235        Err(_) => false,
10236    }
10237}
10238
10239/// Drive one bilateral pair handshake between two sister sessions
10240/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
10241/// flow so failures bubble up at the offending step, not buried in
10242/// a parallel race. See `cmd_session_pair_all_local` docstring.
10243///
10244/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
10245/// federation `.well-known/wire/agent` resolution. Reads B's card +
10246/// endpoints directly off disk under `b_home` and pins them. This
10247/// makes pair-all-local work for sister sessions whose federation
10248/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
10249/// for sessions created with `wire session new --local-only`
10250/// (no federation slot at all). The `_federation_relay` / `_fed_host`
10251/// parameters are retained for callers that want to log them but
10252/// the handshake itself no longer touches federation.
10253fn drive_bilateral_pair(
10254    a_home: &std::path::Path,
10255    a_name: &str,
10256    b_home: &std::path::Path,
10257    b_name: &str,
10258    _fed_host: &str,
10259    _federation_relay: &str,
10260    settle_secs: u64,
10261) -> Result<()> {
10262    use std::time::Duration;
10263    let bin = std::env::current_exe().context("locating self exe")?;
10264
10265    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
10266        let out = std::process::Command::new(&bin)
10267            .env("WIRE_HOME", home)
10268            .env_remove("RUST_LOG")
10269            .args(args)
10270            .output()
10271            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10272        if !out.status.success() {
10273            bail!(
10274                "`wire {}` failed: stderr={}",
10275                args.join(" "),
10276                String::from_utf8_lossy(&out.stderr).trim()
10277            );
10278        }
10279        Ok(())
10280    };
10281
10282    // v0.11: each session's agent-card.handle is the DID-derived
10283    // character, not the session name. pair-accept lookups key on the
10284    // CARD HANDLE, so we discover each side's canonical handle from
10285    // its agent-card on disk before driving the pair flow.
10286    let read_card_handle = |home: &std::path::Path| -> Result<String> {
10287        let card_path = home.join("config").join("wire").join("agent-card.json");
10288        let bytes = std::fs::read(&card_path)
10289            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
10290        let card: Value = serde_json::from_slice(&bytes)?;
10291        card.get("handle")
10292            .and_then(Value::as_str)
10293            .map(str::to_string)
10294            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
10295    };
10296    let a_handle = read_card_handle(a_home)
10297        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
10298    let b_handle = read_card_handle(b_home)
10299        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
10300
10301    // 1. A initiates via --local-sister (uses the session NAME for
10302    // the registry lookup; cmd_add_local_sister auto-resolves
10303    // session→handle internally).
10304    run(a_home, &["add", b_name, "--local-sister", "--json"])
10305        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
10306
10307    // 3. settle so pair_drop reaches B's slot
10308    std::thread::sleep(Duration::from_secs(settle_secs));
10309
10310    // 4. B pulls pair_drop → 5. B pair-accept (pins A by CARD HANDLE,
10311    // not by session name — under v0.11 these differ) → 6. B push ack
10312    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
10313    run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
10314        format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
10315    })?;
10316    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
10317
10318    // 7. settle so ack reaches A's slot
10319    std::thread::sleep(Duration::from_secs(settle_secs));
10320
10321    // 8. A pulls ack (pins B by CARD HANDLE)
10322    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
10323    // suppress unused warning when both handles are consumed
10324    let _ = &b_handle;
10325
10326    Ok(())
10327}
10328
10329fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
10330    let name = resolve_session_name(name_arg)?;
10331    let session_home = crate::session::session_dir(&name)?;
10332    if !session_home.exists() {
10333        bail!(
10334            "no session named {name:?} on this machine. `wire session list` to enumerate, \
10335             `wire session new {name}` to create."
10336        );
10337    }
10338    if as_json {
10339        println!(
10340            "{}",
10341            serde_json::to_string(&json!({
10342                "name": name,
10343                "home_dir": session_home.to_string_lossy(),
10344                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
10345            }))?
10346        );
10347    } else {
10348        println!("export WIRE_HOME={}", session_home.to_string_lossy());
10349    }
10350    Ok(())
10351}
10352
10353fn cmd_session_current(as_json: bool) -> Result<()> {
10354    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10355    let registry = crate::session::read_registry().unwrap_or_default();
10356    let cwd_key = cwd.to_string_lossy().into_owned();
10357    let name = registry.by_cwd.get(&cwd_key).cloned();
10358    if as_json {
10359        println!(
10360            "{}",
10361            serde_json::to_string(&json!({
10362                "cwd": cwd_key,
10363                "session": name,
10364            }))?
10365        );
10366    } else if let Some(n) = name {
10367        println!("{n}");
10368    } else {
10369        println!("(no session registered for this cwd)");
10370    }
10371    Ok(())
10372}
10373
10374fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
10375    let name = crate::session::sanitize_name(name_arg);
10376    let session_home = crate::session::session_dir(&name)?;
10377    if !session_home.exists() {
10378        if as_json {
10379            println!(
10380                "{}",
10381                serde_json::to_string(&json!({
10382                    "name": name,
10383                    "destroyed": false,
10384                    "reason": "no such session",
10385                }))?
10386            );
10387        } else {
10388            println!("no session named {name:?} — nothing to destroy.");
10389        }
10390        return Ok(());
10391    }
10392    if !force {
10393        bail!(
10394            "destroying session {name:?} would delete its keypair + state irrecoverably. \
10395             Pass --force to confirm."
10396        );
10397    }
10398
10399    // Kill the session-local daemon if alive.
10400    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10401    if let Ok(bytes) = std::fs::read(&pidfile) {
10402        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10403            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10404        } else {
10405            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10406        };
10407        if let Some(p) = pid {
10408            let _ = std::process::Command::new("kill")
10409                .args(["-TERM", &p.to_string()])
10410                .output();
10411        }
10412    }
10413
10414    std::fs::remove_dir_all(&session_home)
10415        .with_context(|| format!("removing session dir {session_home:?}"))?;
10416
10417    // Strip from registry.
10418    let mut registry = crate::session::read_registry().unwrap_or_default();
10419    registry.by_cwd.retain(|_, v| v != &name);
10420    crate::session::write_registry(&registry)?;
10421
10422    if as_json {
10423        println!(
10424            "{}",
10425            serde_json::to_string(&json!({
10426                "name": name,
10427                "destroyed": true,
10428            }))?
10429        );
10430    } else {
10431        println!("destroyed session {name:?}.");
10432    }
10433    Ok(())
10434}
10435
10436// ---------- diag (structured trace) ----------
10437
10438fn cmd_diag(action: DiagAction) -> Result<()> {
10439    let state = config::state_dir()?;
10440    let knob = state.join("diag.enabled");
10441    let log_path = state.join("diag.jsonl");
10442    match action {
10443        DiagAction::Tail { limit, json } => {
10444            let entries = crate::diag::tail(limit);
10445            if json {
10446                for e in entries {
10447                    println!("{}", serde_json::to_string(&e)?);
10448                }
10449            } else if entries.is_empty() {
10450                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
10451            } else {
10452                for e in entries {
10453                    let ts = e["ts"].as_u64().unwrap_or(0);
10454                    let ty = e["type"].as_str().unwrap_or("?");
10455                    let pid = e["pid"].as_u64().unwrap_or(0);
10456                    let payload = e["payload"].to_string();
10457                    println!("[{ts}] pid={pid} {ty} {payload}");
10458                }
10459            }
10460        }
10461        DiagAction::Enable => {
10462            config::ensure_dirs()?;
10463            std::fs::write(&knob, "1")?;
10464            println!("wire diag: enabled at {knob:?}");
10465        }
10466        DiagAction::Disable => {
10467            if knob.exists() {
10468                std::fs::remove_file(&knob)?;
10469            }
10470            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
10471        }
10472        DiagAction::Status { json } => {
10473            let enabled = crate::diag::is_enabled();
10474            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
10475            if json {
10476                println!(
10477                    "{}",
10478                    serde_json::to_string(&serde_json::json!({
10479                        "enabled": enabled,
10480                        "log_path": log_path,
10481                        "log_size_bytes": size,
10482                    }))?
10483                );
10484            } else {
10485                println!("wire diag status");
10486                println!("  enabled:    {enabled}");
10487                println!("  log:        {log_path:?}");
10488                println!("  log size:   {size} bytes");
10489            }
10490        }
10491    }
10492    Ok(())
10493}
10494
10495// ---------- service (install / uninstall / status) ----------
10496
10497fn cmd_service(action: ServiceAction) -> Result<()> {
10498    let kind = |local_relay: bool| {
10499        if local_relay {
10500            crate::service::ServiceKind::LocalRelay
10501        } else {
10502            crate::service::ServiceKind::Daemon
10503        }
10504    };
10505    let (report, as_json) = match action {
10506        ServiceAction::Install { local_relay, json } => {
10507            (crate::service::install_kind(kind(local_relay))?, json)
10508        }
10509        ServiceAction::Uninstall { local_relay, json } => {
10510            (crate::service::uninstall_kind(kind(local_relay))?, json)
10511        }
10512        ServiceAction::Status { local_relay, json } => {
10513            (crate::service::status_kind(kind(local_relay))?, json)
10514        }
10515    };
10516    if as_json {
10517        println!("{}", serde_json::to_string(&report)?);
10518    } else {
10519        println!("wire service {}", report.action);
10520        println!("  platform:  {}", report.platform);
10521        println!("  unit:      {}", report.unit_path);
10522        println!("  status:    {}", report.status);
10523        println!("  detail:    {}", report.detail);
10524    }
10525    Ok(())
10526}
10527
10528// ---------- update (self-update from crates.io / prebuilt release) ----------
10529
10530const CRATE_NAME: &str = "slancha-wire";
10531
10532/// (target-triple, binary-extension) of the GitHub release asset for THIS
10533/// platform — names mirror `.github/workflows/release.yml`. `None` if no
10534/// prebuilt is published for this target.
10535fn release_asset_triple() -> Option<(&'static str, &'static str)> {
10536    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
10537    {
10538        return Some(("x86_64-pc-windows-msvc", ".exe"));
10539    }
10540    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
10541    {
10542        return Some(("aarch64-apple-darwin", ""));
10543    }
10544    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
10545    {
10546        return Some(("x86_64-apple-darwin", ""));
10547    }
10548    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
10549    {
10550        return Some(("x86_64-unknown-linux-musl", ""));
10551    }
10552    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
10553    {
10554        return Some(("aarch64-unknown-linux-musl", ""));
10555    }
10556    #[allow(unreachable_code)]
10557    None
10558}
10559
10560/// Latest stable version published on crates.io.
10561fn fetch_latest_published_version() -> Result<String> {
10562    let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
10563    let client = reqwest::blocking::Client::builder()
10564        .timeout(std::time::Duration::from_secs(20))
10565        .build()?;
10566    let resp = client
10567        .get(&url)
10568        // crates.io rejects requests without a descriptive User-Agent (403).
10569        .header(
10570            "User-Agent",
10571            format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
10572        )
10573        .send()?;
10574    if !resp.status().is_success() {
10575        bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
10576    }
10577    let v: Value = resp.json()?;
10578    v.get("crate")
10579        .and_then(|c| {
10580            c.get("max_stable_version")
10581                .or_else(|| c.get("newest_version"))
10582        })
10583        .and_then(Value::as_str)
10584        .map(str::to_string)
10585        .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
10586}
10587
10588/// True iff `latest` is strictly newer than `current` (numeric major.minor.patch;
10589/// pre-release suffixes ignored).
10590fn version_is_newer(latest: &str, current: &str) -> bool {
10591    let parse = |s: &str| -> (u64, u64, u64) {
10592        let core = s.split('-').next().unwrap_or(s);
10593        let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
10594        (
10595            it.next().unwrap_or(0),
10596            it.next().unwrap_or(0),
10597            it.next().unwrap_or(0),
10598        )
10599    };
10600    parse(latest) > parse(current)
10601}
10602
10603fn cargo_on_path() -> bool {
10604    std::process::Command::new("cargo")
10605        .arg("--version")
10606        .stdout(std::process::Stdio::null())
10607        .stderr(std::process::Stdio::null())
10608        .status()
10609        .map(|s| s.success())
10610        .unwrap_or(false)
10611}
10612
10613/// Download the prebuilt release binary for `latest` and replace THIS binary
10614/// in place — the toolchain-free update path (for boxes with no `cargo`).
10615fn self_update_from_release(latest: &str) -> Result<()> {
10616    let (triple, ext) = release_asset_triple().ok_or_else(|| {
10617        anyhow!(
10618            "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
10619             or `cargo install {CRATE_NAME}`"
10620        )
10621    })?;
10622    let base =
10623        format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
10624    let client = reqwest::blocking::Client::builder()
10625        .timeout(std::time::Duration::from_secs(120))
10626        .build()?;
10627    let resp = client
10628        .get(&base)
10629        .header("User-Agent", "wire-self-update")
10630        .send()?;
10631    if !resp.status().is_success() {
10632        bail!("downloading {base} returned {}", resp.status());
10633    }
10634    let bytes = resp.bytes()?;
10635
10636    // Verify the SHA-256 sidecar if present (best-effort; absence is non-fatal).
10637    if let Ok(sha) = client
10638        .get(format!("{base}.sha256"))
10639        .header("User-Agent", "wire-self-update")
10640        .send()
10641        && sha.status().is_success()
10642    {
10643        let expected = sha
10644            .text()?
10645            .split_whitespace()
10646            .next()
10647            .unwrap_or("")
10648            .to_string();
10649        if !expected.is_empty() {
10650            use sha2::{Digest, Sha256};
10651            let mut h = Sha256::new();
10652            h.update(&bytes);
10653            let actual = hex::encode(h.finalize());
10654            if expected != actual {
10655                bail!(
10656                    "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
10657                );
10658            }
10659        }
10660    }
10661
10662    let exe = std::env::current_exe().context("locating current exe")?;
10663    let dir = exe
10664        .parent()
10665        .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
10666    let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
10667    std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
10668    #[cfg(unix)]
10669    {
10670        use std::os::unix::fs::PermissionsExt;
10671        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
10672        // Unix: rename over the running binary — the running process keeps the
10673        // old inode; the new file takes the path for the next invocation.
10674        std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
10675    }
10676    #[cfg(windows)]
10677    {
10678        // Windows can't overwrite a running .exe — rename it aside first
10679        // (allowed even while running), then move the new one into place.
10680        let old = exe.with_extension("old");
10681        let _ = std::fs::remove_file(&old);
10682        std::fs::rename(&exe, &old)
10683            .with_context(|| format!("renaming running exe {exe:?} aside"))?;
10684        std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
10685    }
10686    Ok(())
10687}
10688
10689/// Outcome of the crates.io self-update step (the front half of `wire upgrade`).
10690struct UpdateOutcome {
10691    current: String,
10692    latest: String,
10693    /// A newer stable version is published.
10694    available: bool,
10695    /// We actually installed it this run.
10696    installed: bool,
10697    /// How it was installed ("cargo install" / "prebuilt release binary").
10698    via: Option<&'static str>,
10699}
10700
10701/// Check crates.io for a newer published wire and, when `install` is true,
10702/// self-install it (cargo if a toolchain is on PATH, else the prebuilt release
10703/// binary). The front half of `wire upgrade`; `install=false` is check-only.
10704fn self_update_step(install: bool) -> Result<UpdateOutcome> {
10705    let current = env!("CARGO_PKG_VERSION").to_string();
10706    let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
10707    let available = version_is_newer(&latest, &current);
10708    if !install || !available {
10709        return Ok(UpdateOutcome {
10710            current,
10711            latest,
10712            available,
10713            installed: false,
10714            via: None,
10715        });
10716    }
10717    let via = if cargo_on_path() {
10718        eprintln!(
10719            "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
10720        );
10721        let status = std::process::Command::new("cargo")
10722            .args([
10723                "install",
10724                CRATE_NAME,
10725                "--version",
10726                &latest,
10727                "--force",
10728                "--locked",
10729            ])
10730            .status()
10731            .context("running cargo install")?;
10732        if !status.success() {
10733            bail!("`cargo install {CRATE_NAME}` failed");
10734        }
10735        "cargo install"
10736    } else {
10737        eprintln!(
10738            "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
10739        );
10740        self_update_from_release(&latest)?;
10741        "prebuilt release binary"
10742    };
10743    Ok(UpdateOutcome {
10744        current,
10745        latest,
10746        available,
10747        installed: true,
10748        via: Some(via),
10749    })
10750}
10751
10752// ---------- upgrade (atomic daemon swap) ----------
10753
10754/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
10755/// fresh one from the currently-installed binary, write a new versioned
10756/// pidfile. The fix for today's exact failure mode: a daemon process that
10757/// kept running OLD binary text in memory under a symlink that had since
10758/// been repointed at a NEW binary on disk.
10759///
10760/// Idempotent. If no stale daemon is running, just starts a fresh one
10761/// (same as `wire daemon &` but with the wait-until-alive guard from
10762/// ensure_up::ensure_daemon_running).
10763///
10764/// `--check` mode reports drift without acting — lists the processes
10765/// that WOULD be killed and the binary version of each.
10766///
10767/// Session-scoped upgrade kill set (v0.13.2, B fix): THIS session's own daemon
10768/// (`my_pid`, from its pidfile — reliable even when the OS process scan can't
10769/// see it, as on Windows) plus TRUE orphans (found `wire daemon` pids owned by
10770/// no session), EXCLUDING sibling sessions' daemons. Pure + unit-tested so the
10771/// session-scoping is locked — the box-wide predecessor accumulated daemons.
10772fn upgrade_kill_set(
10773    my_pid: Option<u32>,
10774    found_daemon_pids: &[u32],
10775    owned_session_pids: &std::collections::HashSet<u32>,
10776) -> Vec<u32> {
10777    let mut k: Vec<u32> = Vec::new();
10778    if let Some(p) = my_pid {
10779        k.push(p);
10780    }
10781    for &p in found_daemon_pids {
10782        if !owned_session_pids.contains(&p) && Some(p) != my_pid {
10783            k.push(p); // true orphan — owned by no session
10784        }
10785    }
10786    k.sort_unstable();
10787    k.dedup();
10788    k
10789}
10790
10791#[cfg(test)]
10792mod upgrade_tests {
10793    use super::*;
10794    use std::collections::HashSet;
10795
10796    #[test]
10797    fn upgrade_kill_set_is_session_scoped() {
10798        // owned: my daemon 100, sibling session daemon 200.
10799        let owned: HashSet<u32> = [100, 200].into_iter().collect();
10800        // found by the process scan: mine (100), sibling (200), a true orphan (999).
10801        let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
10802        assert!(k.contains(&100), "must kill my own daemon (to replace it)");
10803        assert!(k.contains(&999), "must sweep a true orphan");
10804        assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
10805
10806        // CRITICAL: even when the process scan returns EMPTY (Windows CIM can't
10807        // match the quoted command line), my own daemon is still killed via its
10808        // pidfile pid — this is the B-accumulation fix.
10809        assert_eq!(
10810            upgrade_kill_set(Some(100), &[], &owned),
10811            vec![100],
10812            "own daemon killed even when the process scan is empty"
10813        );
10814
10815        // Uninitialized session (no own daemon): only true orphans.
10816        assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
10817    }
10818}
10819
10820fn cmd_upgrade(check_only: bool, local: bool, as_json: bool) -> Result<()> {
10821    // 0. (v0.13.3 — merged `update`) ALWAYS check crates.io first and, unless
10822    // this is a --check or --local run, self-install a newer release BEFORE the
10823    // daemon swap below — the respawn then picks up the new on-disk binary. A
10824    // crates.io/network failure must NOT block the restart, so it degrades to a
10825    // warning. `--local` skips it entirely (offline / local dev build).
10826    let update: Option<UpdateOutcome> = if local {
10827        None
10828    } else {
10829        match self_update_step(!check_only) {
10830            Ok(o) => Some(o),
10831            Err(e) => {
10832                if !check_only {
10833                    eprintln!("wire upgrade: update check skipped — {e:#}");
10834                }
10835                None
10836            }
10837        }
10838    };
10839    if let Some(o) = &update
10840        && o.installed
10841    {
10842        eprintln!(
10843            "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
10844            o.latest,
10845            o.current,
10846            o.via.unwrap_or("self-update")
10847        );
10848    }
10849
10850    // 1. Identify all running wire processes. v0.7.3: walks `pgrep -f`
10851    // on unix / `Get-CimInstance Win32_Process` on Windows via the
10852    // shared `platform::find_processes_by_cmdline`. Covers both the
10853    // long-lived sync `wire daemon` *and* the `wire relay-server`
10854    // local-only loopback — the pre-v0.7.3 upgrade only swept daemons
10855    // and left stale relay-server children pinned on the old binary,
10856    // forcing operators to `pkill -f relay-server` manually after
10857    // every version bump.
10858    let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
10859    let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
10860    let running_pids: Vec<u32> = daemon_pids
10861        .iter()
10862        .chain(relay_pids.iter())
10863        .copied()
10864        .collect();
10865
10866    // 2. Read pidfile to surface what the daemon THINKS it is.
10867    let record = crate::ensure_up::read_pid_record("daemon");
10868    let recorded_version: Option<String> = match &record {
10869        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
10870        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
10871        _ => None,
10872    };
10873    let cli_version = env!("CARGO_PKG_VERSION").to_string();
10874
10875    // 2b. v0.13.2 (B fix — session-scoped upgrade). `wire upgrade` now
10876    // refreshes THIS session's daemon, not the whole box. The old box-wide
10877    // design (kill every `wire daemon` process, wipe every session's pidfile,
10878    // respawn every session) was wrong for a multi-session / shared-relay box
10879    // AND broke on Windows: the CIM scan can't match the quoted
10880    // `"...\wire.exe" daemon` command line (no contiguous `wire daemon`), so it
10881    // found nothing to kill, then the respawn loop ACCUMULATED daemons
10882    // (glossy-magnolia: 2->5->8->11). The kill set is now:
10883    //   (a) THIS session's own daemon, via its pidfile pid — reliable and
10884    //       CIM-independent; plus
10885    //   (b) TRUE orphans: `wire daemon` pids owned by NO session.
10886    // It SPARES sibling sessions' daemons AND the shared loopback relay-server
10887    // (killing it would break every same-box session's routing).
10888    let my_daemon_pid = record.pid();
10889    let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
10890        .unwrap_or_default()
10891        .iter()
10892        .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
10893        .collect();
10894    let kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
10895    // relay_pids are intentionally NOT killed — the local relay is shared.
10896
10897    if check_only {
10898        // v0.6.8: also surface session-level state + PATH dupes in --check.
10899        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
10900            .unwrap_or_default()
10901            .iter()
10902            .filter(|s| s.daemon_running)
10903            .map(|s| s.name.clone())
10904            .collect();
10905        let mut path_dupes: Vec<String> = Vec::new();
10906        if let Ok(path) = std::env::var("PATH") {
10907            let mut seen: std::collections::HashSet<std::path::PathBuf> =
10908                std::collections::HashSet::new();
10909            for dir in path.split(':') {
10910                let candidate = std::path::PathBuf::from(dir).join("wire");
10911                if candidate.exists() {
10912                    let canon = candidate.canonicalize().unwrap_or(candidate);
10913                    if seen.insert(canon.clone()) {
10914                        path_dupes.push(canon.to_string_lossy().into_owned());
10915                    }
10916                }
10917            }
10918        }
10919        // v0.7.3: enumerate which service units WOULD be refreshed.
10920        // Read-only — `status_kind` doesn't touch anything.
10921        let installed_service_kinds: Vec<&'static str> = [
10922            (crate::service::ServiceKind::Daemon, "daemon"),
10923            (crate::service::ServiceKind::LocalRelay, "local-relay"),
10924        ]
10925        .into_iter()
10926        .filter_map(|(k, label)| {
10927            crate::service::status_kind(k)
10928                .ok()
10929                .filter(|r| r.status != "absent")
10930                .map(|_| label)
10931        })
10932        .collect();
10933        let (update_latest, update_available) = match &update {
10934            Some(o) => (Some(o.latest.clone()), o.available),
10935            None => (None, false),
10936        };
10937        let report = json!({
10938            "running_pids": running_pids,
10939            "running_daemons": daemon_pids,
10940            "running_relay_servers": relay_pids,
10941            "pidfile_version": recorded_version,
10942            "cli_version": cli_version,
10943            "latest_published": update_latest,
10944            "update_available": update_available,
10945            "would_kill": kill_set,
10946            "would_refresh_services": installed_service_kinds,
10947            "session_daemons_running": sessions_with_daemons,
10948            "path_binaries": path_dupes,
10949            "path_duplicate_warning": path_dupes.len() > 1,
10950        });
10951        if as_json {
10952            println!("{}", serde_json::to_string(&report)?);
10953        } else {
10954            println!("wire upgrade --check");
10955            println!("  cli version:      {cli_version}");
10956            match (&update_latest, update_available) {
10957                (Some(l), true) => println!("  latest published: {l}  (UPDATE AVAILABLE)"),
10958                (Some(l), false) => println!("  latest published: {l}  (up to date)"),
10959                (None, _) => println!("  latest published: (crates.io check skipped)"),
10960            }
10961            println!(
10962                "  pidfile version:  {}",
10963                recorded_version.as_deref().unwrap_or("(missing)")
10964            );
10965            if running_pids.is_empty() {
10966                println!("  running daemons:  none");
10967                println!("  running relays:   none");
10968            } else {
10969                if daemon_pids.is_empty() {
10970                    println!("  running daemons:  none");
10971                } else {
10972                    let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
10973                    println!("  running daemons:  pids {}", p.join(", "));
10974                }
10975                if relay_pids.is_empty() {
10976                    println!("  running relays:   none");
10977                } else {
10978                    let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
10979                    println!("  running relays:   pids {}", p.join(", "));
10980                }
10981                println!("  would kill all + spawn fresh");
10982            }
10983            if !installed_service_kinds.is_empty() {
10984                println!(
10985                    "  would refresh:    {} installed service unit(s) → new binary path",
10986                    installed_service_kinds.join(", ")
10987                );
10988            }
10989            if !sessions_with_daemons.is_empty() {
10990                println!(
10991                    "  session daemons:  {} (would respawn under new binary)",
10992                    sessions_with_daemons.join(", ")
10993                );
10994            }
10995            if path_dupes.len() > 1 {
10996                println!(
10997                    "  PATH warning:     {} distinct `wire` binaries on PATH:",
10998                    path_dupes.len()
10999                );
11000                for b in &path_dupes {
11001                    println!("                      {b}");
11002                }
11003                println!("                    operators should remove the stale ones");
11004            }
11005        }
11006        return Ok(());
11007    }
11008
11009    // 3. Terminate the kill set. Graceful first, then FORCE-kill any survivor.
11010    //
11011    // v0.13.2 (B fix #2): the force-kill must NOT be gated on graceful having
11012    // "succeeded". On Windows, `taskkill /PID /T` WITHOUT `/F` is a no-op for a
11013    // windowless daemon (it returns failure), so the rc9 logic — which only
11014    // force-killed pids that graceful had reported killing — force-killed
11015    // NOTHING, and the daemon survived every `wire upgrade` (glossy: pidfile
11016    // pids 3676/25236/24660 all survived → accumulation). Now we attempt
11017    // graceful best-effort, grace-wait, then force-kill EVERY pid still alive
11018    // regardless of the graceful result. Force-kill (`taskkill /F /T` /
11019    // SIGKILL) is the load-bearing step.
11020    for pid in &kill_set {
11021        let _ = crate::platform::kill_process(*pid, false); // best-effort graceful
11022    }
11023    if !kill_set.is_empty() {
11024        // Brief grace for platforms where graceful works (Unix SIGTERM).
11025        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
11026        while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
11027        {
11028            std::thread::sleep(std::time::Duration::from_millis(50));
11029        }
11030        // Force-kill every survivor — this is what actually kills the
11031        // windowless daemon on Windows.
11032        for pid in &kill_set {
11033            if process_alive_pid(*pid) {
11034                let _ = crate::platform::kill_process(*pid, true);
11035            }
11036        }
11037        std::thread::sleep(std::time::Duration::from_millis(200)); // settle
11038    }
11039    // Report what's actually gone (drives the "no stale" message + JSON).
11040    let killed: Vec<u32> = kill_set
11041        .iter()
11042        .copied()
11043        .filter(|p| !process_alive_pid(*p))
11044        .collect();
11045
11046    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
11047    //    old daemon is still owning it.
11048    let pidfile = config::state_dir()?.join("daemon.pid");
11049    if pidfile.exists() {
11050        let _ = std::fs::remove_file(&pidfile);
11051    }
11052
11053    // 4b. v0.13.2: session-scoped — only THIS session's pidfile is wiped
11054    // (already removed at step 4 above). We deliberately DO NOT touch sibling
11055    // sessions' pidfiles: their daemons were spared, so wiping their pidfiles
11056    // would make them look down and the old box-wide respawn would spawn
11057    // duplicates (the accumulation bug). Each sibling refreshes itself on its
11058    // own `wire upgrade`.
11059
11060    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
11061    // multiple distinct files on $PATH, surface the conflict — operators
11062    // get bitten when an old binary at /usr/local/bin shadows a fresh
11063    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
11064    let mut path_dupes: Vec<String> = Vec::new();
11065    if let Ok(path) = std::env::var("PATH") {
11066        let mut seen: std::collections::HashSet<std::path::PathBuf> =
11067            std::collections::HashSet::new();
11068        for dir in path.split(':') {
11069            let candidate = std::path::PathBuf::from(dir).join("wire");
11070            if candidate.exists() {
11071                let canon = candidate.canonicalize().unwrap_or(candidate);
11072                if seen.insert(canon.clone()) {
11073                    path_dupes.push(canon.to_string_lossy().into_owned());
11074                }
11075            }
11076        }
11077    }
11078    let path_warning = if path_dupes.len() > 1 {
11079        Some(format!(
11080            "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n  {}",
11081            path_dupes.len(),
11082            path_dupes.join("\n  ")
11083        ))
11084    } else {
11085        None
11086    };
11087
11088    // 4d. v0.7.3 NEW: refresh installed service units so they point at
11089    // the freshly-installed binary path. Without this step, an upgrade
11090    // would: kill the old daemon, leave the launchd plist /
11091    // systemd unit / Windows scheduled task pointing at the OLD
11092    // binary path (or, worse, an old binary location that's been
11093    // unlinked), and then the OS's auto-respawn would either fail or
11094    // bring the OLD binary back from the dead. Reinstalling rewrites
11095    // the unit with `std::env::current_exe()` (the freshly-resolved
11096    // path of the running upgrade-driver process) and re-bootstraps /
11097    // re-enables / re-registers so the next OS-driven start uses it.
11098    //
11099    // Only refreshes units that are already installed — does NOT
11100    // install services the operator never opted into.
11101    let mut service_refreshes: Vec<Value> = Vec::new();
11102    for kind in [
11103        crate::service::ServiceKind::Daemon,
11104        crate::service::ServiceKind::LocalRelay,
11105    ] {
11106        let already_installed = crate::service::status_kind(kind)
11107            .map(|r| r.status != "absent")
11108            .unwrap_or(false);
11109        if !already_installed {
11110            continue;
11111        }
11112        match crate::service::install_kind(kind) {
11113            Ok(rep) => service_refreshes.push(json!({
11114                "kind": rep.kind,
11115                "platform": rep.platform,
11116                "status": rep.status,
11117                "unit_path": rep.unit_path,
11118                "action": "refreshed",
11119            })),
11120            Err(e) => service_refreshes.push(json!({
11121                "kind": format!("{kind:?}"),
11122                "action": "refresh_failed",
11123                "error": format!("{e:#}"),
11124            })),
11125        }
11126    }
11127
11128    // 5. Spawn fresh daemon via ensure_up — atomically waits for
11129    //    process_alive + writes the versioned pidfile. (If the Daemon
11130    //    service was refreshed above, it has already started a fresh
11131    //    process under the new binary; ensure_daemon_running notices
11132    //    and short-circuits to "already running".)
11133    let spawned = crate::ensure_up::ensure_daemon_running()?;
11134
11135    // 5b. v0.13.2: session-scoped — no sibling respawn. `ensure_daemon_running`
11136    // above already respawned THIS session's daemon; sibling sessions were
11137    // spared (never killed), so there is nothing to respawn for them. Each
11138    // refreshes itself on its own `wire upgrade`.
11139    let session_respawns: Vec<Value> = Vec::new();
11140
11141    let new_record = crate::ensure_up::read_pid_record("daemon");
11142    let new_pid = new_record.pid();
11143    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
11144        Some(d.version.clone())
11145    } else {
11146        None
11147    };
11148
11149    if as_json {
11150        println!(
11151            "{}",
11152            serde_json::to_string(&json!({
11153                "killed": killed,
11154                "found_daemons": daemon_pids,
11155                "spared_relay_servers": relay_pids,
11156                "service_refreshes": service_refreshes,
11157                "spawned_fresh_daemon": spawned,
11158                "new_pid": new_pid,
11159                "new_version": new_version,
11160                "cli_version": cli_version,
11161                "session_respawns": session_respawns,
11162                "path_binaries": path_dupes,
11163                "path_warning": path_warning,
11164            }))?
11165        );
11166    } else {
11167        if killed.is_empty() {
11168            println!("wire upgrade: no stale wire processes running");
11169        } else {
11170            let killed_list = killed
11171                .iter()
11172                .map(|p| p.to_string())
11173                .collect::<Vec<_>>()
11174                .join(", ");
11175            // Session-scoped: report what was actually killed, and that the
11176            // shared relay-server was SPARED (not killed) — the old wording
11177            // lumped the spared relay into the killed count and read like it
11178            // had been terminated (glossy-magnolia nit).
11179            if relay_pids.is_empty() {
11180                println!(
11181                    "wire upgrade: killed {} daemon(s) [{killed_list}]",
11182                    killed.len()
11183                );
11184            } else {
11185                let relay_list = relay_pids
11186                    .iter()
11187                    .map(|p| p.to_string())
11188                    .collect::<Vec<_>>()
11189                    .join(", ");
11190                println!(
11191                    "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
11192                    killed.len(),
11193                    relay_pids.len()
11194                );
11195            }
11196        }
11197        if !service_refreshes.is_empty() {
11198            println!(
11199                "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
11200                service_refreshes.len()
11201            );
11202            for r in &service_refreshes {
11203                let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
11204                let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
11205                let status = r.get("status").and_then(Value::as_str).unwrap_or("");
11206                let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
11207                if action == "refreshed" {
11208                    println!("                    - {kind}: {action} ({status}, {platform})");
11209                } else {
11210                    let err = r.get("error").and_then(Value::as_str).unwrap_or("");
11211                    println!("                    - {kind}: {action} ({err})");
11212                }
11213            }
11214        }
11215        if spawned {
11216            println!(
11217                "wire upgrade: spawned fresh daemon (pid {} v{})",
11218                new_pid
11219                    .map(|p| p.to_string())
11220                    .unwrap_or_else(|| "?".to_string()),
11221                new_version.as_deref().unwrap_or(&cli_version),
11222            );
11223        } else {
11224            println!("wire upgrade: daemon was already running on current binary");
11225        }
11226        if !session_respawns.is_empty() {
11227            println!(
11228                "wire upgrade: refreshed {} session daemon(s):",
11229                session_respawns.len()
11230            );
11231            for r in &session_respawns {
11232                let h = r["session_home"].as_str().unwrap_or("?");
11233                let s = r["status"].as_str().unwrap_or("?");
11234                let label = std::path::Path::new(h)
11235                    .file_name()
11236                    .map(|f| f.to_string_lossy().into_owned())
11237                    .unwrap_or_else(|| h.to_string());
11238                println!("  {label:<24} {s}");
11239            }
11240        }
11241        if let Some(msg) = &path_warning {
11242            eprintln!("wire upgrade: {msg}");
11243        }
11244    }
11245    Ok(())
11246}
11247
11248/// v0.9.1: should this command emit JSON by default?
11249///
11250/// - `explicit=true` → operator passed `--json`, always JSON.
11251/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
11252///   captured output parses cleanly without operators remembering to
11253///   append `--json`. Mirrors `gh`, `kubectl`, etc.
11254/// - interactive TTY → human format (false).
11255/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
11256///   that parsed the human text by accident).
11257fn json_default(explicit: bool) -> bool {
11258    if explicit {
11259        return true;
11260    }
11261    if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
11262        return false;
11263    }
11264    use std::io::IsTerminal;
11265    !std::io::stdout().is_terminal()
11266}
11267
11268fn process_alive_pid(pid: u32) -> bool {
11269    // v0.7.3: delegate to the cross-platform helper. See
11270    // `platform::process_alive` for the per-OS dispatch — Windows now
11271    // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
11272    // gave a hard-coded false on Windows pre-v0.7.3.
11273    crate::platform::process_alive(pid)
11274}
11275
11276// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
11277
11278/// Iterative Levenshtein distance between two strings, case-insensitive.
11279/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
11280/// resolves against (typically <30 chars).
11281fn levenshtein_ci(a: &str, b: &str) -> usize {
11282    let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
11283    let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
11284    let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
11285    let (m, n) = (a.len(), b.len());
11286    if m == 0 {
11287        return n;
11288    }
11289    let mut prev: Vec<usize> = (0..=m).collect();
11290    let mut curr = vec![0usize; m + 1];
11291    for j in 1..=n {
11292        curr[0] = j;
11293        for i in 1..=m {
11294            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
11295            curr[i] = std::cmp::min(
11296                std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
11297                prev[i - 1] + cost,
11298            );
11299        }
11300        std::mem::swap(&mut prev, &mut curr);
11301    }
11302    prev[m]
11303}
11304
11305/// Return up to `max_results` names from `pool` whose edit distance to
11306/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
11307/// "did you mean" suggestions on resolution miss.
11308pub fn closest_candidates(
11309    needle: &str,
11310    pool: &[String],
11311    max_distance: usize,
11312    max_results: usize,
11313) -> Vec<String> {
11314    let mut scored: Vec<(usize, &String)> = pool
11315        .iter()
11316        .map(|c| (levenshtein_ci(needle, c), c))
11317        .filter(|(d, _)| *d <= max_distance)
11318        .collect();
11319    scored.sort_by_key(|(d, _)| *d);
11320    scored
11321        .into_iter()
11322        .take(max_results)
11323        .map(|(_, c)| c.clone())
11324        .collect()
11325}
11326
11327/// Collect every name that `resolve_name_to_target` would currently
11328/// match: pinned-peer handles, pinned-peer character nicknames, sister
11329/// session names, sister character nicknames, sister handles. Used for
11330/// the `did_you_mean` pool on resolution miss.
11331fn known_local_names() -> Vec<String> {
11332    let mut names: Vec<String> = Vec::new();
11333    if let Ok(trust) = config::read_trust() {
11334        // (debug eprintln removed; left bug-trail in commit message)
11335        // trust.agents is an object keyed by handle, NOT an array —
11336        // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
11337        // the object's keys (which ARE the handles) plus each entry's
11338        // did for the DID-derived character nickname.
11339        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
11340            for (handle, agent) in agents {
11341                names.push(handle.clone());
11342                if let Some(did) = agent.get("did").and_then(Value::as_str) {
11343                    let ch = crate::character::Character::from_did(did);
11344                    names.push(ch.nickname);
11345                }
11346            }
11347        }
11348    }
11349    if let Ok(sessions) = crate::session::list_sessions() {
11350        for s in sessions {
11351            names.push(s.name.clone());
11352            if let Some(h) = &s.handle {
11353                names.push(h.clone());
11354            }
11355            if let Some(ch) = &s.character {
11356                names.push(ch.nickname.clone());
11357            }
11358        }
11359    }
11360    names.sort();
11361    names.dedup();
11362    names
11363}
11364
11365/// v0.9.2 deprecation banner with two ergonomic guards:
11366/// 1. Suppress in JSON mode (the caller is expected to fold the
11367///    deprecation note into its JSON output instead).
11368/// 2. Cache once-per-shell-session via a marker env var; subsequent
11369///    invocations in the same shell stay silent.
11370///
11371/// `verb` is the legacy verb name, `replacement` is the canonical one.
11372fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
11373    if json_mode {
11374        return;
11375    }
11376    // Pull a marker from environment of THIS process. Persistent across
11377    // multiple wire invocations only when the shell sets and exports
11378    // WIRE_DEPRECATION_NAGGED — operators rarely do, so practically
11379    // this nags once per `wire foo` invocation. The single-process
11380    // dedup matters most for scripts that call multiple deprecated
11381    // verbs in one wire run, which is currently impossible (one verb
11382    // per process) but documented for future loop-style wire shells.
11383    let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
11384    if std::env::var(&key).is_ok() {
11385        return;
11386    }
11387    // SAFETY: deprecation_warn is called from sync dispatcher code paths
11388    // before any worker thread spawns; env::set_var in Rust 2024 is
11389    // safe at that point. Pattern matches maybe_adopt_session_wire_home.
11390    unsafe {
11391        std::env::set_var(&key, "1");
11392    }
11393    eprintln!(
11394        "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
11395         Will be removed in v1.0 (target 2026-Q3). \
11396         Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
11397        verb.replace('-', "_")
11398    );
11399}
11400
11401// ---------- doctor (single-command diagnostic) ----------
11402
11403/// One DoctorCheck = one verdict on one health dimension.
11404#[derive(Clone, Debug, serde::Serialize)]
11405pub struct DoctorCheck {
11406    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
11407    /// Stable across versions for tooling consumption.
11408    pub id: String,
11409    /// PASS / WARN / FAIL.
11410    pub status: String,
11411    /// One-line human summary.
11412    pub detail: String,
11413    /// Optional remediation hint shown after the failing line.
11414    #[serde(skip_serializing_if = "Option::is_none")]
11415    pub fix: Option<String>,
11416}
11417
11418impl DoctorCheck {
11419    fn pass(id: &str, detail: impl Into<String>) -> Self {
11420        Self {
11421            id: id.into(),
11422            status: "PASS".into(),
11423            detail: detail.into(),
11424            fix: None,
11425        }
11426    }
11427    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
11428        Self {
11429            id: id.into(),
11430            status: "WARN".into(),
11431            detail: detail.into(),
11432            fix: Some(fix.into()),
11433        }
11434    }
11435    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
11436        Self {
11437            id: id.into(),
11438            status: "FAIL".into(),
11439            detail: detail.into(),
11440            fix: Some(fix.into()),
11441        }
11442    }
11443}
11444
11445/// `wire doctor` — single-command diagnostic for the silent-fail classes
11446/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
11447/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
11448/// so operators don't have to know where each lives.
11449fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
11450    let checks: Vec<DoctorCheck> = vec![
11451        check_daemon_health(),
11452        check_daemon_pid_consistency(),
11453        check_relay_reachable(),
11454        check_pair_rejections(recent_rejections),
11455        check_cursor_progress(),
11456    ];
11457
11458    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
11459    let warns = checks.iter().filter(|c| c.status == "WARN").count();
11460
11461    if as_json {
11462        println!(
11463            "{}",
11464            serde_json::to_string(&json!({
11465                "checks": checks,
11466                "fail_count": fails,
11467                "warn_count": warns,
11468                "ok": fails == 0,
11469            }))?
11470        );
11471    } else {
11472        println!("wire doctor — {} checks", checks.len());
11473        for c in &checks {
11474            let bullet = match c.status.as_str() {
11475                "PASS" => "✓",
11476                "WARN" => "!",
11477                "FAIL" => "✗",
11478                _ => "?",
11479            };
11480            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
11481            if let Some(fix) = &c.fix {
11482                println!("      fix: {fix}");
11483            }
11484        }
11485        println!();
11486        if fails == 0 && warns == 0 {
11487            println!("ALL GREEN");
11488        } else {
11489            println!("{fails} FAIL, {warns} WARN");
11490        }
11491    }
11492
11493    if fails > 0 {
11494        std::process::exit(1);
11495    }
11496    Ok(())
11497}
11498
11499/// Check: daemon running, exactly one instance, no orphans.
11500///
11501/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
11502/// days, advancing cursor without pinning). `wire status` lied about it.
11503/// `wire doctor` must catch THIS class: multiple daemons running, OR
11504/// pid-file claims daemon down while a process is actually up.
11505fn check_daemon_health() -> DoctorCheck {
11506    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
11507    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
11508    // hardening): every surface routes through ensure_up::daemon_liveness
11509    // so they share one view of the world. No more parallel liveness
11510    // logic to drift out of sync.
11511    let snap = crate::ensure_up::daemon_liveness();
11512    let pgrep_pids = &snap.pgrep_pids;
11513    let pidfile_pid = snap.pidfile_pid;
11514    let pidfile_alive = snap.pidfile_alive;
11515    let orphan_pids = &snap.orphan_pids;
11516
11517    let fmt_pids = |xs: &[u32]| -> String {
11518        xs.iter()
11519            .map(|p| p.to_string())
11520            .collect::<Vec<_>>()
11521            .join(", ")
11522    };
11523
11524    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
11525        (0, _, _) => DoctorCheck::fail(
11526            "daemon",
11527            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
11528            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
11529        ),
11530        // Single daemon AND it matches the pidfile → healthy.
11531        (1, true, true) => DoctorCheck::pass(
11532            "daemon",
11533            format!(
11534                "one daemon running (pid {}, matches pidfile)",
11535                pgrep_pids[0]
11536            ),
11537        ),
11538        // Pidfile is alive but pgrep ALSO sees orphan processes.
11539        (n, true, false) => DoctorCheck::fail(
11540            "daemon",
11541            format!(
11542                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
11543                 The orphans race the relay cursor — they advance past events your current binary can't process. \
11544                 (Issue #2 exact class.)",
11545                fmt_pids(pgrep_pids),
11546                pidfile_pid.unwrap(),
11547                fmt_pids(orphan_pids),
11548            ),
11549            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
11550        ),
11551        // Pidfile is dead but processes ARE running → all are orphans.
11552        (n, false, _) => DoctorCheck::fail(
11553            "daemon",
11554            format!(
11555                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
11556                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
11557                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
11558                fmt_pids(pgrep_pids),
11559                match pidfile_pid {
11560                    Some(p) => format!("claims pid {p} which is dead"),
11561                    None => "is missing".to_string(),
11562                },
11563            ),
11564            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
11565        ),
11566        // Multiple daemons all matching … impossible by construction; fall back to warn.
11567        (n, true, true) => DoctorCheck::warn(
11568            "daemon",
11569            format!(
11570                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
11571                fmt_pids(pgrep_pids)
11572            ),
11573            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
11574        ),
11575    }
11576}
11577
11578/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
11579/// check. Surfaces version mismatch (daemon running old binary text in
11580/// memory under a current symlink — today's exact bug class), schema
11581/// drift (future format bumps), and identity contamination (daemon's
11582/// recorded DID doesn't match this box's configured DID).
11583///
11584/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
11585/// JSON pid record whose recorded `pid` is no longer a live OS process.
11586/// Pre-hardening this check PASSed in that state (it only validated
11587/// content, not liveness), letting `wire status: DOWN` and
11588/// `wire doctor: PASS` disagree for 25 min in incident #2.
11589fn check_daemon_pid_consistency() -> DoctorCheck {
11590    let snap = crate::ensure_up::daemon_liveness();
11591    match &snap.record {
11592        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
11593            "daemon_pid_consistency",
11594            "no daemon.pid yet — fresh box or daemon never started",
11595        ),
11596        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
11597            "daemon_pid_consistency",
11598            format!("daemon.pid is corrupt: {reason}"),
11599            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
11600        ),
11601        crate::ensure_up::PidRecord::LegacyInt(pid) => {
11602            // Legacy pidfile: still surface liveness so a dead legacy pid
11603            // doesn't quietly PASS this check while status says DOWN.
11604            let pid = *pid;
11605            if !crate::ensure_up::pid_is_alive(pid) {
11606                return DoctorCheck::warn(
11607                    "daemon_pid_consistency",
11608                    format!(
11609                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
11610                         Stale pidfile from a crashed pre-0.5.11 daemon. \
11611                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
11612                    ),
11613                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
11614                );
11615            }
11616            DoctorCheck::warn(
11617                "daemon_pid_consistency",
11618                format!(
11619                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
11620                     Daemon was started by a pre-0.5.11 binary."
11621                ),
11622                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
11623            )
11624        }
11625        crate::ensure_up::PidRecord::Json(d) => {
11626            // v0.5.19 liveness gate: if the recorded pid is dead, the
11627            // pidfile is stale and the rest of the content drift checks
11628            // are moot — `wire upgrade` is the answer regardless.
11629            if !snap.pidfile_alive {
11630                return DoctorCheck::warn(
11631                    "daemon_pid_consistency",
11632                    format!(
11633                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
11634                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
11635                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
11636                        pid = d.pid,
11637                        version = d.version,
11638                    ),
11639                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
11640                     (kills any orphan daemon advancing the cursor without coordination)",
11641                );
11642            }
11643            let mut issues: Vec<String> = Vec::new();
11644            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
11645                issues.push(format!(
11646                    "schema={} (expected {})",
11647                    d.schema,
11648                    crate::ensure_up::DAEMON_PID_SCHEMA
11649                ));
11650            }
11651            let cli_version = env!("CARGO_PKG_VERSION");
11652            if d.version != cli_version {
11653                issues.push(format!("version daemon={} cli={cli_version}", d.version));
11654            }
11655            if !std::path::Path::new(&d.bin_path).exists() {
11656                issues.push(format!("bin_path {} missing on disk", d.bin_path));
11657            }
11658            // Cross-check DID + relay against current config (best-effort).
11659            if let Ok(card) = config::read_agent_card()
11660                && let Some(current_did) = card.get("did").and_then(Value::as_str)
11661                && let Some(recorded_did) = &d.did
11662                && recorded_did != current_did
11663            {
11664                issues.push(format!(
11665                    "did daemon={recorded_did} config={current_did} — identity drift"
11666                ));
11667            }
11668            if let Ok(state) = config::read_relay_state()
11669                && let Some(current_relay) = state
11670                    .get("self")
11671                    .and_then(|s| s.get("relay_url"))
11672                    .and_then(Value::as_str)
11673                && let Some(recorded_relay) = &d.relay_url
11674                && recorded_relay != current_relay
11675            {
11676                issues.push(format!(
11677                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
11678                ));
11679            }
11680            if issues.is_empty() {
11681                DoctorCheck::pass(
11682                    "daemon_pid_consistency",
11683                    format!(
11684                        "daemon v{} bound to {} as {}",
11685                        d.version,
11686                        d.relay_url.as_deref().unwrap_or("?"),
11687                        d.did.as_deref().unwrap_or("?")
11688                    ),
11689                )
11690            } else {
11691                DoctorCheck::warn(
11692                    "daemon_pid_consistency",
11693                    format!("daemon pidfile drift: {}", issues.join("; ")),
11694                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
11695                )
11696            }
11697        }
11698    }
11699}
11700
11701/// Check: bound relay's /healthz returns 200.
11702fn check_relay_reachable() -> DoctorCheck {
11703    let state = match config::read_relay_state() {
11704        Ok(s) => s,
11705        Err(e) => {
11706            return DoctorCheck::fail(
11707                "relay",
11708                format!("could not read relay state: {e}"),
11709                "run `wire up <handle>@<relay>` to bootstrap",
11710            );
11711        }
11712    };
11713    let url = state
11714        .get("self")
11715        .and_then(|s| s.get("relay_url"))
11716        .and_then(Value::as_str)
11717        .unwrap_or("");
11718    if url.is_empty() {
11719        return DoctorCheck::warn(
11720            "relay",
11721            "no relay bound — wire send/pull will not work",
11722            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
11723        );
11724    }
11725    let client = crate::relay_client::RelayClient::new(url);
11726    match client.check_healthz() {
11727        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
11728        Err(e) => DoctorCheck::fail(
11729            "relay",
11730            format!("{url} unreachable: {e}"),
11731            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
11732        ),
11733    }
11734}
11735
11736/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
11737/// entry there is a silent failure that, pre-0.5.11, would have left the
11738/// operator wondering why pairing didn't complete.
11739fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
11740    let path = match config::state_dir() {
11741        Ok(d) => d.join("pair-rejected.jsonl"),
11742        Err(e) => {
11743            return DoctorCheck::warn(
11744                "pair_rejections",
11745                format!("could not resolve state dir: {e}"),
11746                "set WIRE_HOME or fix XDG_STATE_HOME",
11747            );
11748        }
11749    };
11750    if !path.exists() {
11751        return DoctorCheck::pass(
11752            "pair_rejections",
11753            "no pair-rejected.jsonl — no recorded pair failures",
11754        );
11755    }
11756    let body = match std::fs::read_to_string(&path) {
11757        Ok(b) => b,
11758        Err(e) => {
11759            return DoctorCheck::warn(
11760                "pair_rejections",
11761                format!("could not read {path:?}: {e}"),
11762                "check file permissions",
11763            );
11764        }
11765    };
11766    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
11767    if lines.is_empty() {
11768        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
11769    }
11770    let total = lines.len();
11771    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
11772    let mut summary: Vec<String> = Vec::new();
11773    for line in &recent {
11774        if let Ok(rec) = serde_json::from_str::<Value>(line) {
11775            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
11776            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
11777            summary.push(format!("{peer}/{code}"));
11778        }
11779    }
11780    DoctorCheck::warn(
11781        "pair_rejections",
11782        format!(
11783            "{total} pair failures recorded. recent: [{}]",
11784            summary.join(", ")
11785        ),
11786        format!(
11787            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
11788        ),
11789    )
11790}
11791
11792/// Check: cursor isn't stuck. We can't tell without polling — but we can
11793/// report the current cursor position so operators see if it changes.
11794/// Real "stuck" detection needs two pulls separated in time; defer that
11795/// behaviour to a `wire doctor --watch` mode.
11796fn check_cursor_progress() -> DoctorCheck {
11797    let state = match config::read_relay_state() {
11798        Ok(s) => s,
11799        Err(e) => {
11800            return DoctorCheck::warn(
11801                "cursor",
11802                format!("could not read relay state: {e}"),
11803                "check ~/Library/Application Support/wire/relay.json",
11804            );
11805        }
11806    };
11807    let cursor = state
11808        .get("self")
11809        .and_then(|s| s.get("last_pulled_event_id"))
11810        .and_then(Value::as_str)
11811        .map(|s| s.chars().take(16).collect::<String>())
11812        .unwrap_or_else(|| "<none>".to_string());
11813    DoctorCheck::pass(
11814        "cursor",
11815        format!(
11816            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
11817        ),
11818    )
11819}
11820
11821#[cfg(test)]
11822mod doctor_tests {
11823    use super::*;
11824
11825    #[test]
11826    fn doctor_check_constructors_set_status_correctly() {
11827        // Silent-fail-prevention rule: pass/warn/fail must be visibly
11828        // distinguishable to operators. If any constructor lets the wrong
11829        // status through, `wire doctor` lies and we're back to today's
11830        // 30-minute debug.
11831        let p = DoctorCheck::pass("x", "ok");
11832        assert_eq!(p.status, "PASS");
11833        assert_eq!(p.fix, None);
11834
11835        let w = DoctorCheck::warn("x", "watch out", "do this");
11836        assert_eq!(w.status, "WARN");
11837        assert_eq!(w.fix, Some("do this".to_string()));
11838
11839        let f = DoctorCheck::fail("x", "broken", "fix it");
11840        assert_eq!(f.status, "FAIL");
11841        assert_eq!(f.fix, Some("fix it".to_string()));
11842    }
11843
11844    #[test]
11845    fn check_pair_rejections_no_file_is_pass() {
11846        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
11847        // as a problem.
11848        config::test_support::with_temp_home(|| {
11849            config::ensure_dirs().unwrap();
11850            let c = check_pair_rejections(5);
11851            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
11852        });
11853    }
11854
11855    #[test]
11856    fn check_pair_rejections_with_entries_warns() {
11857        // Existence of rejections is itself a signal — even if each entry
11858        // is a "known good failure," the operator wants to know they
11859        // happened.
11860        config::test_support::with_temp_home(|| {
11861            config::ensure_dirs().unwrap();
11862            crate::pair_invite::record_pair_rejection(
11863                "willard",
11864                "pair_drop_ack_send_failed",
11865                "POST 502",
11866            );
11867            let c = check_pair_rejections(5);
11868            assert_eq!(c.status, "WARN");
11869            assert!(c.detail.contains("1 pair failures"));
11870            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
11871        });
11872    }
11873}
11874
11875// ---------- up megacommand (full bootstrap) ----------
11876
11877/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
11878/// pair. Composes the steps that today's onboarding walks operators through
11879/// one by one (init / bind-relay / claim / background daemon / arm monitor
11880/// recipe). Idempotent: every step checks current state and skips if done.
11881///
11882/// Argument parsing accepts:
11883///   - `<nick>@<relay-host>` — explicit relay
11884///   - `<nick>`              — defaults to wireup.net (the configured
11885///     public relay)
11886fn cmd_up(
11887    relay_arg: Option<&str>,
11888    name: Option<&str>,
11889    with_local: Option<&str>,
11890    no_local: bool,
11891    as_json: bool,
11892) -> Result<()> {
11893    // No nick to parse — your handle is your DID-derived persona (one-name
11894    // rule). The optional arg is only which relay to bind/claim on. Accepts
11895    // `@host`, bare `host`, or a full URL; defaults to the public relay.
11896    let relay_url = match relay_arg {
11897        Some(r) => {
11898            let r = r.trim_start_matches('@');
11899            if r.starts_with("http://") || r.starts_with("https://") {
11900                r.to_string()
11901            } else {
11902                format!("https://{r}")
11903            }
11904        }
11905        None => crate::pair_invite::DEFAULT_RELAY.to_string(),
11906    };
11907
11908    let mut report: Vec<(String, String)> = Vec::new();
11909    let mut step = |stage: &str, detail: String| {
11910        report.push((stage.to_string(), detail.clone()));
11911        if !as_json {
11912            eprintln!("wire up: {stage} — {detail}");
11913        }
11914    };
11915
11916    // 1. init (or note existing identity). No typed name — cmd_init(None)
11917    // generates the persona from the freshly-minted keypair (one-name rule).
11918    if config::is_initialized()? {
11919        step("init", "already initialized".to_string());
11920    } else {
11921        cmd_init(
11922            None,
11923            name,
11924            Some(&relay_url),
11925            false,
11926            /* as_json */ false,
11927        )?;
11928        step("init", format!("created identity bound to {relay_url}"));
11929    }
11930
11931    // Canonical persona handle — the one name we claim and are addressed by.
11932    let canonical = {
11933        let card = config::read_agent_card()?;
11934        let did = card.get("did").and_then(Value::as_str).unwrap_or("");
11935        crate::agent_card::display_handle_from_did(did).to_string()
11936    };
11937    step("identity", format!("persona is `{canonical}`"));
11938
11939    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
11940    // already initialized we may need to bind to the requested relay
11941    // separately (operator switched relays).
11942    let relay_state = config::read_relay_state()?;
11943    let bound_relay = relay_state
11944        .get("self")
11945        .and_then(|s| s.get("relay_url"))
11946        .and_then(Value::as_str)
11947        .unwrap_or("")
11948        .to_string();
11949    if bound_relay.is_empty() {
11950        // Identity exists but never bound to a relay — bind now.
11951        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
11952        // Pass `false` so the safety check kicks in if state was non-empty.
11953        cmd_bind_relay(
11954            &relay_url, /* scope */ None, // infer from URL (federation for wireup.net)
11955            /* replace */ false, /* migrate_pinned */ false, /* as_json */ false,
11956        )?;
11957        step("bind-relay", format!("bound to {relay_url}"));
11958    } else if bound_relay != relay_url {
11959        step(
11960            "bind-relay",
11961            format!(
11962                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
11963                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
11964            ),
11965        );
11966    } else {
11967        step("bind-relay", format!("already bound to {bound_relay}"));
11968    }
11969
11970    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
11971    // re-claims are accepted by the relay.
11972    match cmd_claim(
11973        &canonical,
11974        Some(&relay_url),
11975        None,
11976        /* hidden */ false,
11977        /* as_json */ false,
11978    ) {
11979        Ok(()) => step(
11980            "claim",
11981            format!("{canonical}@{} claimed", strip_proto(&relay_url)),
11982        ),
11983        Err(e) => step(
11984            "claim",
11985            format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
11986        ),
11987    }
11988
11989    // 3b. Opportunistic local dual-slot (additive). Gives same-box sister
11990    // sessions sub-millisecond loopback routing alongside the federation
11991    // slot. Local relays carry no handle directory — nothing to claim
11992    // there; sister discovery is via `wire session list-local`.
11993    if no_local {
11994        step("local-slot", "skipped (--no-local)".to_string());
11995    } else {
11996        let local_url = with_local
11997            .unwrap_or("http://127.0.0.1:8771")
11998            .trim_end_matches('/');
11999        let already_local = crate::endpoints::self_endpoints(
12000            &config::read_relay_state().unwrap_or_else(|_| json!({})),
12001        )
12002        .iter()
12003        .any(|e| e.relay_url == local_url);
12004        if relay_url.trim_end_matches('/') == local_url || already_local {
12005            step("local-slot", "already covered".to_string());
12006        } else if crate::relay_client::RelayClient::new(local_url)
12007            .check_healthz()
12008            .is_ok()
12009        {
12010            match cmd_bind_relay(
12011                local_url,
12012                Some("local"),
12013                /* replace */ false,
12014                /* migrate_pinned */ false,
12015                /* as_json */ false,
12016            ) {
12017                Ok(()) => step(
12018                    "local-slot",
12019                    format!("dual-bound local relay {local_url} for sister routing"),
12020                ),
12021                Err(e) => step("local-slot", format!("skipped local relay: {e}")),
12022            }
12023        } else {
12024            step(
12025                "local-slot",
12026                format!(
12027                    "no local relay reachable at {local_url} — federation only \
12028                     (sisters resolve via session-list)"
12029                ),
12030            );
12031        }
12032    }
12033
12034    // 4. Background daemon — must be running for pull/push/ack to flow.
12035    match crate::ensure_up::ensure_daemon_running() {
12036        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
12037        Ok(false) => step("daemon", "already running".to_string()),
12038        Err(e) => step(
12039            "daemon",
12040            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
12041        ),
12042    }
12043
12044    // 5. Final summary — point operator at the next commands.
12045    let summary =
12046        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
12047         `wire monitor` to watch incoming events."
12048            .to_string();
12049    step("ready", summary.clone());
12050
12051    if as_json {
12052        let steps_json: Vec<_> = report
12053            .iter()
12054            .map(|(k, v)| json!({"stage": k, "detail": v}))
12055            .collect();
12056        println!(
12057            "{}",
12058            serde_json::to_string(&json!({
12059                "nick": canonical,
12060                "relay": relay_url,
12061                "steps": steps_json,
12062            }))?
12063        );
12064    }
12065    Ok(())
12066}
12067
12068/// Strip http:// or https:// prefix for display in `wire up` step output.
12069fn strip_proto(url: &str) -> String {
12070    url.trim_start_matches("https://")
12071        .trim_start_matches("http://")
12072        .to_string()
12073}
12074
12075// ---------- pair megacommand (zero-paste handle-based) ----------
12076
12077/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
12078/// the handle is in `nick@domain` form. Wraps:
12079///
12080///   1. cmd_add — resolve, pin, drop intro
12081///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
12082///      (signalled by `peers.<handle>.slot_token` populating in relay state)
12083///   3. Verify bilateral pin: trust contains peer + relay state has token
12084///   4. Print final state — both sides VERIFIED + can `wire send`
12085///
12086/// On timeout: hard-errors with the specific stuck step so the operator
12087/// knows which side to chase. No silent partial success.
12088fn cmd_pair_megacommand(
12089    handle_arg: &str,
12090    relay_override: Option<&str>,
12091    timeout_secs: u64,
12092    _as_json: bool,
12093) -> Result<()> {
12094    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
12095    let peer_handle = parsed.nick.clone();
12096
12097    eprintln!("wire pair: resolving {handle_arg}...");
12098    cmd_add(
12099        handle_arg,
12100        relay_override,
12101        /* local_sister */ false,
12102        /* as_json */ false,
12103    )?;
12104
12105    eprintln!(
12106        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
12107         to ack (their daemon must be running + pulling)..."
12108    );
12109
12110    // Trigger an immediate daemon-style pull so we don't wait the full daemon
12111    // interval. Best-effort — if it fails, we still fall through to the
12112    // polling loop.
12113    let _ = run_sync_pull();
12114
12115    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
12116    let poll_interval = std::time::Duration::from_millis(500);
12117
12118    loop {
12119        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
12120        let _ = run_sync_pull();
12121        let relay_state = config::read_relay_state()?;
12122        let peer_entry = relay_state
12123            .get("peers")
12124            .and_then(|p| p.get(&peer_handle))
12125            .cloned();
12126        let token = peer_entry
12127            .as_ref()
12128            .and_then(|e| e.get("slot_token"))
12129            .and_then(Value::as_str)
12130            .unwrap_or("");
12131
12132        if !token.is_empty() {
12133            // Bilateral pin complete — we have their slot_token, we can send.
12134            let trust = config::read_trust()?;
12135            let pinned_in_trust = trust
12136                .get("agents")
12137                .and_then(|a| a.get(&peer_handle))
12138                .is_some();
12139            println!(
12140                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
12141                if pinned_in_trust {
12142                    "VERIFIED"
12143                } else {
12144                    "MISSING (bug)"
12145                }
12146            );
12147            return Ok(());
12148        }
12149
12150        if std::time::Instant::now() >= deadline {
12151            // Timeout — surface the EXACT stuck step. Likely culprits:
12152            //   - peer daemon not running on their box
12153            //   - peer's relay slot is offline
12154            //   - their daemon is on an older binary that doesn't know
12155            //     pair_drop kind=1100 (the P0.1 class — now visible via
12156            //     wire pull --json on their side as a blocking rejection)
12157            bail!(
12158                "wire pair: timed out after {timeout_secs}s. \
12159                 peer {peer_handle} never sent pair_drop_ack. \
12160                 likely causes: (a) their daemon is down — ask them to run \
12161                 `wire status` and `wire daemon &`; (b) their binary is older \
12162                 than 0.5.x and doesn't understand pair_drop events — ask \
12163                 them to `wire upgrade`; (c) network / relay blip — re-run \
12164                 `wire pair {handle_arg}` to retry."
12165            );
12166        }
12167
12168        std::thread::sleep(poll_interval);
12169    }
12170}
12171
12172fn cmd_claim(
12173    nick: &str,
12174    relay_override: Option<&str>,
12175    public_url: Option<&str>,
12176    hidden: bool,
12177    as_json: bool,
12178) -> Result<()> {
12179    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
12180    // + claim handle. Operator should never have to run init/bind-relay first.
12181    let (_did, relay_url, slot_id, slot_token) =
12182        crate::pair_invite::ensure_self_with_relay(relay_override)?;
12183    let card = config::read_agent_card()?;
12184
12185    // v0.13.1 one-name enforcement: the handle you claim in the phonebook
12186    // MUST equal your DID-derived persona, so the directory entry can never
12187    // drift from your agent-card handle. A typed nick that differs is ignored
12188    // (mirrors how `wire init` coerces the typed name). This closes the
12189    // claim-path reopening of the v0.11 "two names" footgun — before this,
12190    // `wire claim coffee-ghost` published coffee-ghost@relay -> your DID while
12191    // your card said e.g. outback-sandpiper. The typed `nick` arg is now
12192    // vestigial, exactly like the one `wire init` / `wire up` already accept.
12193    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
12194    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
12195    if !canonical.is_empty() && nick != canonical && !as_json {
12196        eprintln!(
12197            "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
12198        );
12199    }
12200    let nick = if canonical.is_empty() {
12201        nick
12202    } else {
12203        canonical.as_str()
12204    };
12205    if !crate::pair_profile::is_valid_nick(nick) {
12206        bail!(
12207            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
12208        );
12209    }
12210
12211    let client = crate::relay_client::RelayClient::new(&relay_url);
12212    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
12213    // (back-compat); Some(false) for `--hidden`. Relays older than
12214    // v0.5.19 ignore the field, so this is safe to always send.
12215    let discoverable = if hidden { Some(false) } else { None };
12216    let resp =
12217        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
12218
12219    if as_json {
12220        println!(
12221            "{}",
12222            serde_json::to_string(&json!({
12223                "nick": nick,
12224                "relay": relay_url,
12225                "response": resp,
12226            }))?
12227        );
12228    } else {
12229        // Best-effort: derive the public domain from the relay URL. If
12230        // operator passed --public-url that's the canonical address; else
12231        // the relay URL itself. Falls back to a placeholder if both miss.
12232        let domain = public_url
12233            .unwrap_or(&relay_url)
12234            .trim_start_matches("https://")
12235            .trim_start_matches("http://")
12236            .trim_end_matches('/')
12237            .split('/')
12238            .next()
12239            .unwrap_or("<this-relay-domain>")
12240            .to_string();
12241        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
12242        println!("verify with: wire whois {nick}@{domain}");
12243    }
12244    Ok(())
12245}
12246
12247fn cmd_profile(action: ProfileAction) -> Result<()> {
12248    match action {
12249        ProfileAction::Set { field, value, json } => {
12250            // Try parsing the value as JSON; if that fails, treat it as a
12251            // bare string. Lets operators pass either `42` or `"hello"` or
12252            // `["rust","late-night"]` without quoting hell.
12253            let parsed: Value =
12254                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
12255            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
12256            if json {
12257                println!(
12258                    "{}",
12259                    serde_json::to_string(&json!({
12260                        "field": field,
12261                        "profile": new_profile,
12262                    }))?
12263                );
12264            } else {
12265                println!("profile.{field} set");
12266            }
12267        }
12268        ProfileAction::Get { json } => return cmd_whois(None, json, None),
12269        ProfileAction::Clear { field, json } => {
12270            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
12271            if json {
12272                println!(
12273                    "{}",
12274                    serde_json::to_string(&json!({
12275                        "field": field,
12276                        "cleared": true,
12277                        "profile": new_profile,
12278                    }))?
12279                );
12280            } else {
12281                println!("profile.{field} cleared");
12282            }
12283        }
12284    }
12285    Ok(())
12286}
12287
12288// ---------- setup — one-shot MCP host registration ----------
12289
12290fn cmd_setup(apply: bool) -> Result<()> {
12291    use std::path::PathBuf;
12292
12293    // The `env` mapping forwards Claude Code's per-session id into the MCP
12294    // server. CRITICAL for per-session identity: the MCP server process does
12295    // NOT inherit CLAUDE_CODE_SESSION_ID (Claude Code sets it for Bash-tool
12296    // subprocesses only), and the MCP `initialize` handshake carries no session
12297    // id — so without this, the server can't tell sessions apart, falls back to
12298    // cwd-detection, and every Claude session under a shared parent dir
12299    // collapses onto ONE identity. Claude Code expands `${CLAUDE_CODE_SESSION_ID}`
12300    // from its own env at MCP launch; wire's `resolve_session_key` reads
12301    // WIRE_SESSION_ID first, so each session becomes its own `by-key/<hash>`.
12302    let entry = json!({
12303        "command": "wire",
12304        "args": ["mcp"],
12305        "env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}
12306    });
12307    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
12308
12309    // Detect probable MCP host config locations. Cross-platform — we only
12310    // touch the file if it already exists OR --apply was passed.
12311    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
12312    if let Some(home) = dirs::home_dir() {
12313        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
12314        // The mcpServers map lives at the top level of that file.
12315        targets.push(("Claude Code", home.join(".claude.json")));
12316        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
12317        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
12318        // Claude Desktop macOS
12319        #[cfg(target_os = "macos")]
12320        targets.push((
12321            "Claude Desktop (macOS)",
12322            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
12323        ));
12324        // Claude Desktop Windows
12325        #[cfg(target_os = "windows")]
12326        if let Ok(appdata) = std::env::var("APPDATA") {
12327            targets.push((
12328                "Claude Desktop (Windows)",
12329                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
12330            ));
12331        }
12332        // Cursor
12333        targets.push(("Cursor", home.join(".cursor/mcp.json")));
12334    }
12335    // Project-local — works for several MCP-aware tools
12336    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
12337
12338    println!("wire setup\n");
12339    println!("MCP server snippet (add this to your client's mcpServers):");
12340    println!();
12341    println!("{entry_pretty}");
12342    println!();
12343
12344    if !apply {
12345        println!("Probable MCP host config locations on this machine:");
12346        for (name, path) in &targets {
12347            let marker = if path.exists() {
12348                "✓ found"
12349            } else {
12350                "  (would create)"
12351            };
12352            println!("  {marker:14}  {name}: {}", path.display());
12353        }
12354        println!();
12355        println!("Run `wire setup --apply` to merge wire into each config above.");
12356        println!(
12357            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
12358        );
12359        return Ok(());
12360    }
12361
12362    let mut modified: Vec<String> = Vec::new();
12363    let mut skipped: Vec<String> = Vec::new();
12364    for (name, path) in &targets {
12365        match upsert_mcp_entry(path, "wire", &entry) {
12366            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
12367            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
12368            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
12369        }
12370    }
12371    if !modified.is_empty() {
12372        println!("Modified:");
12373        for line in &modified {
12374            println!("  {line}");
12375        }
12376        println!();
12377        println!("Restart the app(s) above to load wire MCP.");
12378    }
12379    if !skipped.is_empty() {
12380        println!();
12381        println!("Skipped:");
12382        for line in &skipped {
12383            println!("  {line}");
12384        }
12385    }
12386    Ok(())
12387}
12388
12389/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
12390/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
12391fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
12392    let mut cfg: Value = if path.exists() {
12393        let body = std::fs::read_to_string(path).context("reading config")?;
12394        serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
12395    } else {
12396        json!({})
12397    };
12398    if !cfg.is_object() {
12399        cfg = json!({});
12400    }
12401    let root = cfg.as_object_mut().unwrap();
12402    let servers = root
12403        .entry("mcpServers".to_string())
12404        .or_insert_with(|| json!({}));
12405    if !servers.is_object() {
12406        *servers = json!({});
12407    }
12408    let map = servers.as_object_mut().unwrap();
12409    if map.get(server_name) == Some(entry) {
12410        return Ok(false);
12411    }
12412    map.insert(server_name.to_string(), entry.clone());
12413    if let Some(parent) = path.parent()
12414        && !parent.as_os_str().is_empty()
12415    {
12416        std::fs::create_dir_all(parent).context("creating parent dir")?;
12417    }
12418    let out = serde_json::to_string_pretty(&cfg)? + "\n";
12419    std::fs::write(path, out).context("writing config")?;
12420    Ok(true)
12421}
12422
12423// ---------- setup --statusline ----------
12424
12425/// Bundled Claude Code statusLine renderer (persona emoji + nickname + cwd,
12426/// pidfile+tasklist liveness). Embedded at compile time; written to the
12427/// Claude config dir on `wire setup --statusline --apply`.
12428const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
12429
12430/// `wire setup --statusline [--apply] [--remove]` — install/remove a Claude
12431/// Code statusLine that renders this session's wire persona. Honors
12432/// `$CLAUDE_CONFIG_DIR` (default `~/.claude`). Writes the renderer script and
12433/// merges a `statusLine` block into settings.json, preserving existing keys
12434/// and refusing to clobber a settings.json that exists but isn't valid JSON.
12435fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
12436    use std::path::PathBuf;
12437    let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
12438        .map(PathBuf::from)
12439        .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
12440        .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
12441    let settings_path = cfg_dir.join("settings.json");
12442    let script_path = cfg_dir.join("wire-statusline.sh");
12443    // Resolve the shell invocation. On Windows a bare `bash` resolves to
12444    // System32\bash.exe (WSL) — wrong environment, Windows paths invalid,
12445    // statusline breaks — so we emit the absolute git-bash path. On Unix a
12446    // bare `bash <script>` is correct. Script path is quoted for spaces.
12447    let (command, command_warn) = statusline_command(&script_path);
12448
12449    println!("wire setup --statusline\n");
12450    println!("Claude config dir: {}", cfg_dir.display());
12451    println!("  renderer:  {}", script_path.display());
12452    println!("  settings:  {}", settings_path.display());
12453    if let Some(w) = &command_warn {
12454        println!("  ⚠ {w}");
12455    }
12456    println!();
12457
12458    if remove {
12459        if !apply {
12460            println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
12461            println!("Run `wire setup --statusline --remove --apply` to do it.");
12462            return Ok(());
12463        }
12464        let dropped = remove_statusline_entry(&settings_path)?;
12465        let script_gone = if script_path.exists() {
12466            std::fs::remove_file(&script_path).is_ok()
12467        } else {
12468            false
12469        };
12470        println!(
12471            "Removed: statusLine key {} · renderer {}",
12472            if dropped { "dropped" } else { "absent" },
12473            if script_gone { "deleted" } else { "absent" }
12474        );
12475        return Ok(());
12476    }
12477
12478    if !apply {
12479        println!("Would write the renderer above and merge into settings.json:");
12480        println!();
12481        println!("  \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
12482        println!();
12483        println!("Resulting statusline:  ● <emoji> <nickname> · <cwd>");
12484        println!("Run `wire setup --statusline --apply` to install.");
12485        println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
12486        return Ok(());
12487    }
12488
12489    if let Some(parent) = script_path.parent() {
12490        std::fs::create_dir_all(parent).context("creating Claude config dir")?;
12491    }
12492    std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
12493    #[cfg(unix)]
12494    {
12495        use std::os::unix::fs::PermissionsExt;
12496        if let Ok(meta) = std::fs::metadata(&script_path) {
12497            let mut perms = meta.permissions();
12498            perms.set_mode(0o755);
12499            let _ = std::fs::set_permissions(&script_path, perms);
12500        }
12501    }
12502    let changed = upsert_statusline_entry(&settings_path, &command)?;
12503    println!("✓ renderer written: {}", script_path.display());
12504    if changed {
12505        println!("✓ merged statusLine into: {}", settings_path.display());
12506    } else {
12507        println!(
12508            "  settings.json already configured: {}",
12509            settings_path.display()
12510        );
12511    }
12512    println!();
12513    println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
12514    Ok(())
12515}
12516
12517/// Merge a `statusLine` command block into a Claude settings.json, preserving
12518/// all other keys. Returns Ok(true) if changed. Refuses to clobber a file that
12519/// exists but is not valid JSON.
12520fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
12521    let mut cfg: Value = if path.exists() {
12522        let body = std::fs::read_to_string(path).context("reading settings.json")?;
12523        if body.trim().is_empty() {
12524            json!({})
12525        } else {
12526            serde_json::from_str(&body).context(
12527                "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
12528            )?
12529        }
12530    } else {
12531        json!({})
12532    };
12533    if !cfg.is_object() {
12534        bail!("settings.json root is not a JSON object — refusing to clobber");
12535    }
12536    let desired = json!({"type": "command", "command": command});
12537    let root = cfg.as_object_mut().unwrap();
12538    if root.get("statusLine") == Some(&desired) {
12539        return Ok(false);
12540    }
12541    root.insert("statusLine".to_string(), desired);
12542    if let Some(parent) = path.parent()
12543        && !parent.as_os_str().is_empty()
12544    {
12545        std::fs::create_dir_all(parent).context("creating parent dir")?;
12546    }
12547    let out = serde_json::to_string_pretty(&cfg)? + "\n";
12548    std::fs::write(path, out).context("writing settings.json")?;
12549    Ok(true)
12550}
12551
12552/// Drop the `statusLine` key from settings.json. Ok(true) if a key was removed,
12553/// Ok(false) if file/key absent. Refuses to edit invalid JSON.
12554fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
12555    if !path.exists() {
12556        return Ok(false);
12557    }
12558    let body = std::fs::read_to_string(path).context("reading settings.json")?;
12559    if body.trim().is_empty() {
12560        return Ok(false);
12561    }
12562    let mut cfg: Value = serde_json::from_str(&body)
12563        .context("settings.json is not valid JSON — refusing to edit")?;
12564    let Some(root) = cfg.as_object_mut() else {
12565        return Ok(false);
12566    };
12567    if root.remove("statusLine").is_none() {
12568        return Ok(false);
12569    }
12570    let out = serde_json::to_string_pretty(&cfg)? + "\n";
12571    std::fs::write(path, out).context("writing settings.json")?;
12572    Ok(true)
12573}
12574
12575/// Build the `statusLine.command` string for this platform. Returns the
12576/// command plus an optional warning to surface to the operator.
12577fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
12578    #[cfg(windows)]
12579    {
12580        match resolve_git_bash() {
12581            Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
12582            None => (
12583                format!("bash \"{}\"", script_path.display()),
12584                Some(
12585                    "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
12586                     WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
12587                     Windows or set statusLine.command to your git-bash bash.exe path."
12588                        .to_string(),
12589                ),
12590            ),
12591        }
12592    }
12593    #[cfg(unix)]
12594    {
12595        (format!("bash \"{}\"", script_path.display()), None)
12596    }
12597}
12598
12599/// Locate the git-bash `bash.exe` on Windows, avoiding the WSL launcher at
12600/// `System32\bash.exe`. Claude Code's statusLine command needs the real
12601/// git-bash so the renderer runs in a POSIX-ish env with valid paths.
12602#[cfg(windows)]
12603fn resolve_git_bash() -> Option<String> {
12604    use std::path::PathBuf;
12605    // 1. `where.exe bash` — take the first hit that is NOT under System32
12606    //    (that one is the WSL `bash.exe` launcher).
12607    if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
12608        && out.status.success()
12609    {
12610        for line in String::from_utf8_lossy(&out.stdout).lines() {
12611            let p = line.trim();
12612            if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
12613                return Some(p.to_string());
12614            }
12615        }
12616    }
12617    // 2. Common Git-for-Windows install locations.
12618    let candidates = [
12619        std::env::var("ProgramFiles")
12620            .ok()
12621            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
12622        std::env::var("ProgramFiles(x86)")
12623            .ok()
12624            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
12625        std::env::var("LocalAppData")
12626            .ok()
12627            .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
12628    ];
12629    candidates
12630        .into_iter()
12631        .flatten()
12632        .find(|c| PathBuf::from(c).exists())
12633}
12634
12635#[cfg(test)]
12636mod statusline_tests {
12637    use super::*;
12638
12639    #[test]
12640    fn statusline_merge_preserves_keys_and_is_idempotent() {
12641        let dir = tempfile::tempdir().unwrap();
12642        let path = dir.path().join("settings.json");
12643        std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
12644        // First merge changes the file but keeps existing keys.
12645        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
12646        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12647        assert_eq!(v["theme"], "dark");
12648        assert_eq!(v["model"], "opus");
12649        assert_eq!(v["statusLine"]["type"], "command");
12650        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
12651        // Identical re-merge = no change.
12652        assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
12653        // Remove drops ONLY statusLine.
12654        assert!(remove_statusline_entry(&path).unwrap());
12655        let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12656        assert_eq!(v2["theme"], "dark");
12657        assert!(v2.get("statusLine").is_none());
12658        // Remove again = no-op.
12659        assert!(!remove_statusline_entry(&path).unwrap());
12660    }
12661
12662    #[test]
12663    fn statusline_merge_refuses_to_clobber_invalid_json() {
12664        let dir = tempfile::tempdir().unwrap();
12665        let path = dir.path().join("settings.json");
12666        std::fs::write(&path, "this is not json {").unwrap();
12667        let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
12668        assert!(
12669            format!("{err:#}").contains("not valid JSON"),
12670            "err: {err:#}"
12671        );
12672        // File left untouched.
12673        assert_eq!(
12674            std::fs::read_to_string(&path).unwrap(),
12675            "this is not json {"
12676        );
12677    }
12678
12679    #[test]
12680    fn statusline_creates_settings_when_absent() {
12681        let dir = tempfile::tempdir().unwrap();
12682        let path = dir.path().join("settings.json");
12683        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
12684        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12685        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
12686    }
12687}
12688
12689// ---------- notify (Goal 2) ----------
12690
12691fn cmd_notify(
12692    interval_secs: u64,
12693    peer_filter: Option<&str>,
12694    once: bool,
12695    as_json: bool,
12696) -> Result<()> {
12697    use crate::inbox_watch::InboxWatcher;
12698    let cursor_path = config::state_dir()?.join("notify.cursor");
12699    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
12700
12701    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
12702        let events = watcher.poll()?;
12703        for ev in events {
12704            if let Some(p) = peer_filter
12705                && ev.peer != p
12706            {
12707                continue;
12708            }
12709            if as_json {
12710                println!("{}", serde_json::to_string(&ev)?);
12711            } else {
12712                os_notify_inbox_event(&ev);
12713            }
12714        }
12715        watcher.save_cursors(&cursor_path)?;
12716        Ok(())
12717    };
12718
12719    if once {
12720        return sweep(&mut watcher);
12721    }
12722
12723    let interval = std::time::Duration::from_secs(interval_secs.max(1));
12724    loop {
12725        if let Err(e) = sweep(&mut watcher) {
12726            eprintln!("wire notify: sweep error: {e}");
12727        }
12728        std::thread::sleep(interval);
12729    }
12730}
12731
12732fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
12733    let who = persona_label(&ev.peer);
12734    let title = if ev.verified {
12735        format!("wire ← {who}")
12736    } else {
12737        format!("wire ← {who} (UNVERIFIED)")
12738    };
12739    let body = format!("{}: {}", ev.kind, ev.body_preview);
12740    crate::os_notify::toast(&title, &body);
12741}
12742
12743#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
12744fn os_toast(title: &str, body: &str) {
12745    eprintln!("[wire notify] {title}\n  {body}");
12746}
12747
12748// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).