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    Init {
48        /// Short handle for this agent (becomes did:wire:<handle>).
49        handle: String,
50        /// Optional display name (defaults to capitalized handle).
51        #[arg(long)]
52        name: Option<String>,
53        /// Relay URL — binds an inbound slot in the same step. Required
54        /// unless `--offline` is passed. Example:
55        /// `--relay http://127.0.0.1:8771` (local), `--relay https://wireup.net`
56        /// (federation).
57        #[arg(long)]
58        relay: Option<String>,
59        /// v0.9: opt into a slotless session — keypair only, no inbound
60        /// mailbox. You MUST run `wire bind-relay <url>` before any
61        /// pair / send / dial; until then peers cannot reach you.
62        /// Useful for offline keypair generation; rare in practice.
63        #[arg(long, conflicts_with = "relay")]
64        offline: bool,
65        /// Emit JSON.
66        #[arg(long)]
67        json: bool,
68    },
69    // (Old `Join` stub removed in iter 11 — superseded by `pair-join` with
70    // `join` alias. See PairJoin below.)
71    /// Print this agent's identity (DID, fingerprint, mailbox slot).
72    Whoami {
73        #[arg(long)]
74        json: bool,
75        /// Print just `<emoji> <nickname>` (e.g. `🦊 foxtrot-meadow`).
76        /// Plain text, no ANSI escapes. Useful for piping into other tools.
77        #[arg(long, conflicts_with = "json")]
78        short: bool,
79        /// Print `<emoji> <nickname>` wrapped in ANSI 256-color escapes.
80        /// Drop into a Claude Code statusline command for live identity display.
81        #[arg(long, conflicts_with_all = ["json", "short"])]
82        colored: bool,
83    },
84    /// List pinned peers with their tiers and capabilities.
85    Peers {
86        #[arg(long)]
87        json: bool,
88    },
89    /// v0.9.5: emit shell completion script to stdout. Pipe to your
90    /// shell's completion dir to enable tab-completion of wire verbs
91    /// + handles + flags.
92    ///
93    /// Example installs:
94    ///   bash:       `wire completions bash > /etc/bash_completion.d/wire`
95    ///   zsh:        `wire completions zsh > ~/.zsh/completions/_wire`
96    ///   fish:       `wire completions fish > ~/.config/fish/completions/wire.fish`
97    ///   pwsh:       `wire completions powershell > $PROFILE` (append)
98    ///   elvish:     `wire completions elvish > ~/.elvish/lib/wire.elv`
99    Completions {
100        /// Shell to generate completions for.
101        #[arg(value_enum)]
102        shell: clap_complete::Shell,
103    },
104    /// v0.9.3: one-screen "you are here" view. Prints the current
105    /// session's character + handle + cwd, plus a short list of
106    /// neighbors (sister sessions on the local relay, pinned peers).
107    /// Designed for the operator's quick "wait which Claude is this,
108    /// and who's around?" question — no `--json` shuffling, no
109    /// remembering `wire whoami` vs `wire peers` vs `wire session
110    /// list-local`.
111    Here {
112        #[arg(long)]
113        json: bool,
114    },
115    /// v0.9 canonical surface: list pending-inbound pair requests waiting
116    /// for your consent. Aliases the legacy `pair-list-inbound` verb
117    /// but with the shorter, intent-first name. Operators reach for
118    /// "what's pending?" not "what's in my pair-list-inbound table?"
119    Pending {
120        #[arg(long)]
121        json: bool,
122    },
123    /// Sign and queue an event to a peer.
124    ///
125    /// Forms (P0.S 0.5.11):
126    ///   wire send <peer> <body>              # kind defaults to "claim"
127    ///   wire send <peer> <kind> <body>       # explicit kind (back-compat)
128    ///   wire send <peer> -                   # body from stdin (kind=claim)
129    ///   wire send <peer> @/path/to/body.json # body from file
130    Send {
131        /// Peer handle (without `did:wire:` prefix).
132        peer: String,
133        /// When `<body>` is omitted, this is the event body (kind defaults
134        /// to `claim`). When both this and `<body>` are given, this is the
135        /// event kind (`decision`, `claim`, etc., or numeric kind id) and
136        /// the next positional is the body.
137        kind_or_body: String,
138        /// Event body — free-form text, `@/path/to/body.json` to load from
139        /// a file, or `-` to read from stdin. Optional; omit to use
140        /// `<kind_or_body>` as the body with kind=`claim`.
141        body: Option<String>,
142        /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
143        #[arg(long)]
144        deadline: Option<String>,
145        /// v0.10: skip the v0.9 auto-pair-on-miss behavior. Send fails
146        /// loudly if the peer isn't pinned yet. Use when you want strict
147        /// "no implicit dialing" semantics — scripts that error vs.
148        /// performing a side-effecting pair as a fallback.
149        #[arg(long)]
150        no_auto_pair: bool,
151        /// Emit JSON.
152        #[arg(long)]
153        json: bool,
154    },
155    /// v0.8 — "go talk to this name." The one verb operators reach for.
156    ///
157    /// `wire dial <name>` accepts a character nickname (`noble-slate`),
158    /// a session name (`slancha-api`), a card handle, or a DID — whichever
159    /// face you happen to know the peer by. Resolution order:
160    ///
161    /// 1. Already-pinned peer? → no-op (or send if a message was passed).
162    /// 2. Local sister session? → bilateral pair via the disk-read
163    ///    `--local-sister` path (no relay round-trip, no .well-known
164    ///    lookup, no SAS digits).
165    /// 3. Otherwise → bail with a clear hint pointing at federation
166    ///    syntax (`wire dial <handle>@<relay>` for cross-machine peers).
167    ///
168    /// With an optional message, `wire dial <name> "<msg>"` also queues
169    /// and pushes the message after the pair completes. Idempotent: re-
170    /// dialling a known peer just sends.
171    Dial {
172        /// Peer name. Character nickname (preferred), session name,
173        /// card handle, or DID — anything that identifies the peer to
174        /// you.
175        name: String,
176        /// Optional first message to send after the pair lands. Same
177        /// semantics as the body argument to `wire send`. Defaults to
178        /// kind=claim.
179        message: Option<String>,
180        /// Emit JSON.
181        #[arg(long)]
182        json: bool,
183    },
184    /// Stream signed events from peers.
185    Tail {
186        /// Optional peer filter; if omitted, tails all peers.
187        peer: Option<String>,
188        /// Emit JSONL (one event per line).
189        #[arg(long)]
190        json: bool,
191        /// Maximum events to read before exiting (0 = stream until SIGINT).
192        #[arg(long, default_value_t = 0)]
193        limit: usize,
194    },
195    /// Live tail of new inbox events across all pinned peers — one line per
196    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
197    /// by default.
198    ///
199    /// Designed to be left running in an agent harness's stream-watcher
200    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
201    /// session as they arrive, not on next manual `wire pull`.
202    ///
203    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
204    /// template.
205    Monitor {
206        /// Only show events from this peer.
207        #[arg(long)]
208        peer: Option<String>,
209        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
210        #[arg(long)]
211        json: bool,
212        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
213        /// Default filters them out as noise.
214        #[arg(long)]
215        include_handshake: bool,
216        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
217        #[arg(long, default_value_t = 500)]
218        interval_ms: u64,
219        /// Replay last N events from history before going live (0 = none).
220        #[arg(long, default_value_t = 0)]
221        replay: usize,
222    },
223    /// Verify a signed event from a JSON file or stdin (`-`).
224    Verify {
225        /// Path to event JSON, or `-` for stdin.
226        path: String,
227        /// Emit JSON.
228        #[arg(long)]
229        json: bool,
230    },
231    /// Run the MCP (Model Context Protocol) server over stdio.
232    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
233    /// `wire_send`, `wire_tail`, etc. as native tools.
234    Mcp,
235    /// Run a relay server on this host.
236    RelayServer {
237        /// Bind address (e.g. `127.0.0.1:8770`).
238        #[arg(long, default_value = "127.0.0.1:8770")]
239        bind: String,
240        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
241        /// skip `.well-known/wire/agent` serving. The relay becomes
242        /// invisible from outside the box — only same-machine processes
243        /// can pair through it. Right call for within-machine agent
244        /// coordination where you don't want metadata leaking to a
245        /// public relay. Pair this with `wire session new` which probes
246        /// `127.0.0.1:8771` and allocates a local slot automatically.
247        #[arg(long)]
248        local_only: bool,
249        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
250        /// When set, --bind is ignored. Implies --local-only semantics
251        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
252        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
253        /// sister sessions. Unix only (Windows refuses).
254        #[arg(long)]
255        uds: Option<std::path::PathBuf>,
256    },
257    /// Allocate a slot on a relay; bind it to this agent's identity.
258    ///
259    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
260    /// current slot, this command refuses by default — silent migration
261    /// silently black-holes their inbound messages. Pass
262    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
263    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
264    /// for safe rotation.
265    BindRelay {
266        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
267        url: String,
268        /// Endpoint scope: `federation` | `local` | `lan` | `uds`.
269        /// Default inferred from the URL (loopback host -> local,
270        /// `unix://` -> uds, otherwise federation). Pass explicitly when
271        /// the inference is ambiguous (e.g. a federation relay on a
272        /// loopback address in tests).
273        #[arg(long)]
274        scope: Option<String>,
275        /// DESTRUCTIVE: drop all existing self slots and bind only this
276        /// relay (the pre-v0.12 single-slot behavior). Default is
277        /// ADDITIVE — the new slot is appended to `self.endpoints[]`,
278        /// keeping any existing slots so pinned peers are not
279        /// black-holed.
280        #[arg(long)]
281        replace: bool,
282        /// Acknowledge that pinned peers will black-hole until they
283        /// re-pin manually. Required for `--replace` (and same-relay
284        /// rotation) when `state.peers` is non-empty; ignored on fresh
285        /// boxes. Use `wire rotate-slot` instead for the supported
286        /// same-relay rotation path.
287        #[arg(long)]
288        migrate_pinned: bool,
289        #[arg(long)]
290        json: bool,
291    },
292    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
293    /// real `wire join` lands in the SPAKE2 iter.)
294    AddPeerSlot {
295        /// Peer handle (becomes did:wire:<handle>).
296        handle: String,
297        /// Peer's relay base URL.
298        url: String,
299        /// Peer's slot id.
300        slot_id: String,
301        /// Slot bearer token (shared between paired peers in v0.1).
302        slot_token: String,
303        #[arg(long)]
304        json: bool,
305    },
306    /// Drain outbox JSONL files to peers' relay slots.
307    Push {
308        /// Optional peer filter; default = all peers with outbox entries.
309        peer: Option<String>,
310        #[arg(long)]
311        json: bool,
312    },
313    /// Pull events from our relay slot, verify, write to inbox.
314    Pull {
315        #[arg(long)]
316        json: bool,
317    },
318    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
319    /// Useful as a single "where am I" check.
320    Status {
321        /// Inspect a paired peer's transport / attention / responder health.
322        #[arg(long)]
323        peer: Option<String>,
324        #[arg(long)]
325        json: bool,
326    },
327    /// Publish or inspect auto-responder health for this slot.
328    Responder {
329        #[command(subcommand)]
330        command: ResponderCommand,
331    },
332    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
333    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
334    Pin {
335        /// Path to peer's signed agent-card JSON.
336        card_file: String,
337        #[arg(long)]
338        json: bool,
339    },
340    /// Allocate a NEW slot on the same relay and abandon the old one.
341    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
342    /// slot announcing the new mailbox before swapping. After rotation,
343    /// peers must re-pair (or operator runs `add-peer-slot` with the new
344    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
345    ///
346    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
347    /// abusive bearer-holder spamming your slot). Rotate → old slot is
348    /// orphaned → attacker's leverage gone. Operator pairs again with
349    /// peers they still want.
350    RotateSlot {
351        /// Skip the wire_close announcement to peers (faster but they won't know
352        /// where you went).
353        #[arg(long)]
354        no_announce: bool,
355        #[arg(long)]
356        json: bool,
357    },
358    /// Remove a peer from trust + relay state. Inbox/outbox files for that
359    /// peer are NOT deleted (operator can grep history); pass --purge to
360    /// also wipe the JSONL files.
361    ForgetPeer {
362        /// Peer handle to forget.
363        handle: String,
364        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
365        #[arg(long)]
366        purge: bool,
367        #[arg(long)]
368        json: bool,
369    },
370    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
371    /// peers' relay slots and pull inbox from our own slot. Foreground process;
372    /// background it with systemd / `&` / tmux as you prefer.
373    Daemon {
374        /// Sync interval in seconds. Default 5.
375        #[arg(long, default_value_t = 5)]
376        interval: u64,
377        /// Run a single sync cycle and exit (useful for cron-driven setups).
378        #[arg(long)]
379        once: bool,
380        #[arg(long)]
381        json: bool,
382    },
383    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
384    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
385    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
386    /// read the SAS digits aloud and confirm.)
387    #[command(hide = true)] // v0.9 deprecated
388    PairHost {
389        /// Relay base URL.
390        #[arg(long)]
391        relay: String,
392        /// Skip the SAS confirmation prompt. ONLY use when piping under
393        /// automated tests or when the SAS has already been verified by
394        /// another channel. Documented as test-only.
395        #[arg(long)]
396        yes: bool,
397        /// How long (seconds) to wait for the peer to join before timing out.
398        #[arg(long, default_value_t = 300)]
399        timeout: u64,
400        /// Detach: write a pending-pair file, print the code phrase, and exit
401        /// immediately. The running `wire daemon` does the handshake in the
402        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
403        /// `wire pair-list` shows pending sessions. Default is foreground
404        /// blocking behavior for backward compat.
405        #[arg(long)]
406        detach: bool,
407        /// Emit JSON instead of text. Currently only meaningful with --detach.
408        #[arg(long)]
409        json: bool,
410    },
411    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
412    ///
413    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
414    #[command(alias = "join")]
415    #[command(hide = true)] // v0.9 deprecated
416    PairJoin {
417        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
418        code_phrase: String,
419        /// Relay base URL (must match the host's relay).
420        #[arg(long)]
421        relay: String,
422        #[arg(long)]
423        yes: bool,
424        #[arg(long, default_value_t = 300)]
425        timeout: u64,
426        /// Detach: see `pair-host --detach`.
427        #[arg(long)]
428        detach: bool,
429        /// Emit JSON instead of text. Currently only meaningful with --detach.
430        #[arg(long)]
431        json: bool,
432    },
433    /// Confirm SAS digits for a detached pending pair. The daemon must be
434    /// running for this to do anything — it picks up the confirmation on its
435    /// next tick. Mismatch aborts the pair.
436    #[command(hide = true)] // v0.9 deprecated
437    PairConfirm {
438        /// The code phrase the original `wire pair-host --detach` printed.
439        code_phrase: String,
440        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
441        digits: String,
442        /// Emit JSON instead of human-readable text.
443        #[arg(long)]
444        json: bool,
445    },
446    /// List all pending detached pair sessions and their state.
447    #[command(hide = true)] // v0.9 deprecated
448    PairList {
449        /// Emit JSON instead of the table.
450        #[arg(long)]
451        json: bool,
452        /// Stream mode: never exit; print one JSON line per status transition
453        /// (creation, status change, deletion) across all pending pairs.
454        /// Compose with bash `while read` to react in shell. Implies --json.
455        #[arg(long)]
456        watch: bool,
457        /// Poll interval in seconds for --watch.
458        #[arg(long, default_value_t = 1)]
459        watch_interval: u64,
460    },
461    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
462    #[command(hide = true)] // v0.9 deprecated
463    PairCancel {
464        code_phrase: String,
465        #[arg(long)]
466        json: bool,
467    },
468    /// Block until a pending pair reaches a target status (default sas_ready),
469    /// or terminates (finalized = file removed, aborted, aborted_restart), or
470    /// the timeout expires. Useful for shell scripts that want to drive the
471    /// detached flow without polling pair-list themselves.
472    ///
473    /// Exit codes:
474    ///   0 — reached target status (or finalized, if target was sas_ready)
475    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
476    ///   2 — timeout
477    #[command(hide = true)] // v0.9 deprecated
478    PairWatch {
479        code_phrase: String,
480        /// Target status to wait for. Default: sas_ready.
481        #[arg(long, default_value = "sas_ready")]
482        status: String,
483        /// Max seconds to wait.
484        #[arg(long, default_value_t = 300)]
485        timeout: u64,
486        /// Emit JSON on each status change (one per line) instead of just on exit.
487        #[arg(long)]
488        json: bool,
489    },
490    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
491    /// pair-join, then registers wire as an MCP server. Single command from
492    /// nothing to paired and ready — no separate init/pair-host/setup steps.
493    /// Operator still must confirm SAS digits.
494    ///
495    /// Examples:
496    ///   wire pair paul                          # host a new pair on default relay
497    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
498    ///
499    /// v0.10: hidden from --help. Federation pair flow is now
500    /// `wire dial <handle>@<relay>` + `wire accept-invite <URL>`.
501    /// `wire pair` stays callable for back-compat scripts; v1.0 removes.
502    #[command(hide = true)] // v0.10 deprecated — use `wire dial <h>@<relay>`
503    Pair {
504        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
505        /// step if no identity exists; ignored if already initialized.
506        handle: String,
507        /// Code phrase from peer's pair-host output. Omit to be the host
508        /// (this command will print one for you to share).
509        #[arg(long)]
510        code: Option<String>,
511        /// Relay base URL. Defaults to the laulpogan public-good relay.
512        #[arg(long, default_value = "https://wireup.net")]
513        relay: String,
514        /// Skip SAS prompt. Test-only.
515        #[arg(long)]
516        yes: bool,
517        /// Pair-step timeout in seconds.
518        #[arg(long, default_value_t = 300)]
519        timeout: u64,
520        /// Skip the post-pair `setup --apply` step (don't register wire as
521        /// an MCP server in detected client configs).
522        #[arg(long)]
523        no_setup: bool,
524        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
525        /// exits immediately, daemon does the handshake). Confirm via
526        /// `wire pair-confirm <code> <digits>` from any terminal. See
527        /// `pair-host --detach` for details.
528        #[arg(long)]
529        detach: bool,
530    },
531    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
532    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
533    /// confirmation, leaving the relay-side slot stuck with "guest already
534    /// registered" or "host already registered" until the 5-minute TTL expires.
535    /// Either side can call. Idempotent.
536    #[command(hide = true)] // v0.9 deprecated
537    PairAbandon {
538        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
539        code_phrase: String,
540        /// Relay base URL.
541        #[arg(long, default_value = "https://wireup.net")]
542        relay: String,
543    },
544    /// Accept a pending-inbound pair request (v0.5.14). Explicit alias for
545    /// the bilateral-completion path that `wire add <peer>@<relay>` also
546    /// drives — but doesn't require remembering the peer's relay domain
547    /// (the relay coords come from the stored pair_drop). Errors if no
548    /// pending-inbound record exists for that peer.
549    #[command(hide = true)] // v0.9 deprecated
550    PairAccept {
551        /// Bare peer handle (without `@<relay>`).
552        peer: String,
553        /// Emit JSON.
554        #[arg(long)]
555        json: bool,
556    },
557    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
558    /// you@<your-relay>` against your handle, their signed pair_drop lands
559    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
560    /// <peer>` to delete the record without pairing. The peer never receives
561    /// our slot_token; from their side the pair stays pending until they
562    /// time out.
563    #[command(hide = true)] // v0.9 deprecated
564    PairReject {
565        /// Bare peer handle (without `@<relay>`).
566        peer: String,
567        /// Emit JSON.
568        #[arg(long)]
569        json: bool,
570    },
571    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
572    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
573    /// `pair-list --json` shape but for inbound). Use this in scripts that
574    /// need to enumerate inbound pair requests without parsing the SPAKE2
575    /// table format from `wire pair-list`.
576    #[command(hide = true)] // v0.9 deprecated
577    PairListInbound {
578        /// Emit JSON.
579        #[arg(long)]
580        json: bool,
581    },
582    /// Manage isolated wire sessions on this machine (v0.5.16).
583    ///
584    /// Each session = its own DID + handle + relay slot + daemon + inbox/
585    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
586    /// in different projects) run on the same machine — without sessions
587    /// they all share one identity and race the inbox cursor.
588    ///
589    /// Names are derived from `basename(cwd)` and cached in a registry,
590    /// so re-entering the same project reuses the same identity.
591    #[command(subcommand)]
592    Session(SessionCommand),
593    /// Manage this session's identity display layer (character override).
594    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
595    /// itself picks a custom nickname + emoji that overrides the
596    /// auto-derived hash-based defaults.
597    Identity {
598        #[command(subcommand)]
599        cmd: IdentityCommand,
600    },
601    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
602    /// sister-session mesh. `wire mesh status` is the live view of every
603    /// paired sister (alias for `wire session mesh-status`); `wire mesh
604    /// broadcast` fans one signed event to every pinned peer.
605    #[command(subcommand)]
606    Mesh(MeshCommand),
607    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
608    /// Cursor, project-local) and either print or auto-merge the wire MCP
609    /// server entry. Default prints; pass `--apply` to actually modify config
610    /// files. Idempotent — re-running is safe.
611    Setup {
612        /// Actually write the changes (default = print only).
613        #[arg(long)]
614        apply: bool,
615    },
616    /// Show an agent's profile. With no arg, prints local self. With a
617    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
618    /// endpoint and verifies the returned signed card before display.
619    Whois {
620        /// Optional handle (`nick@domain`). Omit to show self.
621        handle: Option<String>,
622        #[arg(long)]
623        json: bool,
624        /// Override the relay base URL used for resolution (default:
625        /// `https://<domain>` from the handle).
626        #[arg(long)]
627        relay: Option<String>,
628    },
629    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
630    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
631    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
632    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
633    /// their slot_token so we can `wire send` to them).
634    Add {
635        /// Peer handle (`nick@domain`), OR a bare sister-session name
636        /// when `--local-sister` is set.
637        handle: String,
638        /// Override the relay base URL used for resolution.
639        #[arg(long)]
640        relay: Option<String>,
641        /// v0.6.6: pair with a sister session on this machine without
642        /// touching federation. Looks up `handle` as a session name in
643        /// `wire session list`, reads that session's agent-card +
644        /// endpoints from disk, pins directly, then delivers the
645        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
646        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
647        /// addressable because they don't need a federation claim.
648        #[arg(long)]
649        local_sister: bool,
650        #[arg(long)]
651        json: bool,
652    },
653    /// One-shot full bootstrap — `wire up <nick@relay-host>` does in one
654    /// command what 0.5.10 took five (init + bind-relay + claim + daemon-
655    /// background + remember-to-restart-on-login). Idempotent: re-run on
656    /// an already-set-up box prints state without churn.
657    ///
658    /// Examples:
659    ///   wire up paul@wireup.net           # full bootstrap
660    ///   wire up paul-mac@wireup.net       # ditto, nick = paul-mac
661    ///   wire up paul                      # bootstrap, default relay
662    Up {
663        /// Full handle in `nick@relay-host` form, or just `nick` (defaults
664        /// to the configured public relay wireup.net).
665        handle: String,
666        /// Optional display name (defaults to capitalized nick).
667        #[arg(long)]
668        name: Option<String>,
669        /// Also additively dual-bind a LOCAL relay slot for fast same-box
670        /// sister-session routing. Defaults to probing
671        /// `http://127.0.0.1:8771`; pass a URL to override. Local relays
672        /// carry no handle directory, so nothing is claimed there.
673        #[arg(long)]
674        with_local: Option<String>,
675        /// Skip the opportunistic local dual-bind entirely.
676        #[arg(long)]
677        no_local: bool,
678        #[arg(long)]
679        json: bool,
680    },
681    /// Diagnose wire setup health. Single command that surfaces every
682    /// silent-fail class — daemon down or duplicated, relay unreachable,
683    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
684    /// Replaces today's 30-minute manual debug.
685    ///
686    /// Exit code non-zero if any FAIL findings.
687    Doctor {
688        /// Emit JSON.
689        #[arg(long)]
690        json: bool,
691        /// Show last N entries from pair-rejected.jsonl in the report.
692        #[arg(long, default_value_t = 5)]
693        recent_rejections: usize,
694    },
695    /// Atomic upgrade: kill every `wire daemon` process, spawn a fresh
696    /// one from the current binary, write a new pidfile. Eliminates the
697    /// "stale binary text in memory under a fresh symlink" bug class that
698    /// burned 30 minutes today.
699    Upgrade {
700        /// Report drift without taking action (lists processes that would
701        /// be killed + the version of each).
702        #[arg(long)]
703        check: bool,
704        #[arg(long)]
705        json: bool,
706    },
707    /// Install / inspect / remove a launchd plist (macOS) or systemd
708    /// user unit (linux) that runs `wire daemon` on login + restarts
709    /// on crash. Replaces today's "background it with tmux/&/systemd
710    /// as you prefer" footgun.
711    Service {
712        #[command(subcommand)]
713        action: ServiceAction,
714    },
715    /// Inspect or toggle the structured diagnostic trace
716    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
717    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
718    /// (writes the file knob a running daemon picks up automatically).
719    Diag {
720        #[command(subcommand)]
721        action: DiagAction,
722    },
723    /// Claim a nick on a relay's handle directory. Anyone can then reach
724    /// this agent by `<nick>@<relay-domain>` via the relay's
725    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
726    Claim {
727        nick: String,
728        /// Relay to claim the nick on. Default = relay our slot is on.
729        #[arg(long)]
730        relay: Option<String>,
731        /// Public URL the relay should advertise to resolvers (default = relay).
732        #[arg(long)]
733        public_url: Option<String>,
734        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
735        /// directory listing. The handle stays claimed (FCFS still
736        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
737        /// still resolves, so peers you share the handle with out-of-band
738        /// can still pair. Bulk scrapers / phonebook crawlers will not
739        /// see the nick. Use this for handles meant for known-peer
740        /// pairing only — see issue #9.
741        #[arg(long)]
742        hidden: bool,
743        #[arg(long)]
744        json: bool,
745    },
746    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
747    /// avatar_url, handle, now). Re-signs the agent-card atomically.
748    ///
749    /// Examples:
750    ///   wire profile set motto "compiles or dies trying"
751    ///   wire profile set emoji "🦀"
752    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
753    ///   wire profile set handle "coffee-ghost@anthropic.dev"
754    ///   wire profile get
755    Profile {
756        #[command(subcommand)]
757        action: ProfileAction,
758    },
759    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
760    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
761    /// a relay slot on first use. Default TTL 24h, single-use.
762    #[command(hide = true)] // v0.9 deprecated
763    Invite {
764        /// Override the relay URL for first-time auto-allocation.
765        #[arg(long, default_value = "https://wireup.net")]
766        relay: String,
767        /// Invite lifetime in seconds (default 86400 = 24h).
768        #[arg(long, default_value_t = 86_400)]
769        ttl: u64,
770        /// Number of distinct peers that can accept this invite before it's
771        /// consumed (default 1).
772        #[arg(long, default_value_t = 1)]
773        uses: u32,
774        /// Register the invite at the relay's short-URL endpoint and print
775        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
776        /// Installs wire if missing, then accepts the invite, then pairs.
777        #[arg(long)]
778        share: bool,
779        /// Emit JSON.
780        #[arg(long)]
781        json: bool,
782    },
783    /// v0.9: accept a pending-inbound pair request by character
784    /// nickname or card handle. Replaces the verbose `wire pair-accept
785    /// <peer>`.
786    ///
787    /// v0.9.4: the URL-vs-name smart-dispatch from v0.9 is gone. To
788    /// accept a federation invite URL use `wire accept-invite <URL>`
789    /// (split out as an explicit verb to eliminate the input-shape
790    /// ambiguity). `wire accept <URL>` still works for back-compat
791    /// but emits a deprecation banner pointing at `accept-invite`.
792    Accept {
793        /// Pending peer name (character nickname or card handle).
794        target: String,
795        /// Emit JSON.
796        #[arg(long)]
797        json: bool,
798    },
799    /// v0.9.4: accept a federation invite URL minted by `wire invite`.
800    /// Pins issuer, sends signed card to issuer's slot. Auto-inits +
801    /// auto-allocates as needed.
802    ///
803    /// Split out from `wire accept` to eliminate the URL-vs-name
804    /// smart-dispatch ambiguity (peer handles can legitimately collide
805    /// with URL-shaped strings; the explicit verb removes the inference).
806    #[command(alias = "invite-accept")]
807    AcceptInvite {
808        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
809        url: String,
810        /// Emit JSON.
811        #[arg(long)]
812        json: bool,
813    },
814    /// v0.9: refuse a pending-inbound pair request without pairing. Aliases
815    /// the legacy `wire pair-reject <peer>`.
816    Reject {
817        /// Peer name (character nickname or handle) from `wire pending`.
818        peer: String,
819        /// Emit JSON.
820        #[arg(long)]
821        json: bool,
822    },
823    /// Long-running event dispatcher. Watches inbox for new verified events
824    /// and spawns the given shell command per event, passing the event JSON
825    /// on stdin. Use to wire up autonomous reply loops:
826    ///   wire reactor --on-event 'claude -p "respond via wire send"'
827    /// Cursor persisted to `$WIRE_HOME/state/wire/reactor.cursor`.
828    Reactor {
829        /// Shell command to spawn per event. Event JSON written to its stdin.
830        #[arg(long)]
831        on_event: String,
832        /// Only fire for events from this peer.
833        #[arg(long)]
834        peer: Option<String>,
835        /// Only fire for events of this kind (numeric or name, e.g. 1 / decision).
836        #[arg(long)]
837        kind: Option<String>,
838        /// Skip events whose verified flag is false (default true).
839        #[arg(long, default_value_t = true)]
840        verified_only: bool,
841        /// Poll interval in seconds.
842        #[arg(long, default_value_t = 2)]
843        interval: u64,
844        /// Process one sweep and exit.
845        #[arg(long)]
846        once: bool,
847        /// Don't actually spawn — print one JSONL line per event for smoke-testing.
848        #[arg(long)]
849        dry_run: bool,
850        /// Hard rate-limit: max events handler is fired for per peer per minute.
851        /// 0 = unlimited. Default 6 — covers normal conversational tempo, kills
852        /// LLM-vs-LLM feedback loops (which fire 10+/sec).
853        #[arg(long, default_value_t = 6)]
854        max_per_minute: u32,
855        /// Anti-loop chain depth. Track event_ids this reactor emitted; if an
856        /// incoming event body contains `(re:X)` where X is in our emitted log,
857        /// skip — that's a reply-to-our-reply, depth ≥ 2. Disable with 0.
858        #[arg(long, default_value_t = 1)]
859        max_chain_depth: u32,
860    },
861    /// Watch the inbox for new verified events and fire an OS notification per
862    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
863    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
864    /// re-emit history.
865    Notify {
866        /// Poll interval in seconds.
867        #[arg(long, default_value_t = 2)]
868        interval: u64,
869        /// Only notify for events from this peer (handle, no did: prefix).
870        #[arg(long)]
871        peer: Option<String>,
872        /// Run a single sweep and exit (useful for cron / tests).
873        #[arg(long)]
874        once: bool,
875        /// Suppress the OS notification call; print one JSON line per event to
876        /// stdout instead (for piping into other tooling or smoke-testing
877        /// without a desktop session).
878        #[arg(long)]
879        json: bool,
880    },
881}
882
883#[derive(Subcommand, Debug)]
884pub enum DiagAction {
885    /// Tail the last N entries from diag.jsonl.
886    Tail {
887        #[arg(long, default_value_t = 20)]
888        limit: usize,
889        #[arg(long)]
890        json: bool,
891    },
892    /// Flip the file-based knob ON. Running daemons pick this up on
893    /// the next emit call without restart.
894    Enable,
895    /// Flip the file-based knob OFF.
896    Disable,
897    /// Report whether diag is currently enabled + the file's size.
898    Status {
899        #[arg(long)]
900        json: bool,
901    },
902}
903
904#[derive(Subcommand, Debug)]
905pub enum IdentityCommand {
906    /// Print the current character (DID-derived, the only name).
907    /// Equivalent to `wire whoami --short` but scoped here for grouping.
908    Show {
909        #[arg(long)]
910        json: bool,
911    },
912    /// List all identities on this machine — one row per session, with
913    /// each session's character, DID, federation handle, and cwd. Same
914    /// shape as `wire session list`, scoped here for the v0.7+ noun-
915    /// CLI surface.
916    List {
917        #[arg(long)]
918        json: bool,
919    },
920    /// Promote this identity to FEDERATION lifecycle: claim a handle on
921    /// the relay so peers can `wire add <name>@<relay-domain>` you.
922    /// Re-claims with current display fields (after `wire identity rename`)
923    /// so the relay always serves the latest signed card. Equivalent to
924    /// `wire claim` but scoped here for the v0.7+ noun-CLI surface.
925    Publish {
926        /// The handle to claim on the relay (e.g. `coffee-ghost`).
927        nick: String,
928        /// Override the relay URL. Defaults to the session's bound relay
929        /// from `wire init --relay <url>`. Public relay if unset.
930        #[arg(long)]
931        relay: Option<String>,
932        /// Public-facing URL for the agent-card location (when the relay
933        /// is behind a CDN with a different public domain).
934        #[arg(long, alias = "public")]
935        public_url: Option<String>,
936        /// Skip listing in the relay's public phonebook. The card is
937        /// still claimable + reachable; just doesn't appear in
938        /// `wireup.net/phonebook` for stranger-discovery.
939        #[arg(long)]
940        hidden: bool,
941        #[arg(long)]
942        json: bool,
943    },
944    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
945    /// Equivalent to `wire session destroy <name>`, scoped here for the
946    /// noun-CLI surface. Requires `--force` (the underlying command does).
947    Destroy {
948        /// Session name to destroy (use `wire identity list` to see).
949        name: String,
950        /// Bypass the confirmation prompt.
951        #[arg(long)]
952        force: bool,
953        #[arg(long)]
954        json: bool,
955    },
956    /// Create an identity in an EXPLICIT lifecycle state (vs. the
957    /// implicit `wire init` + `wire claim` flow).
958    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
959    ///
960    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
961    /// next reboot). In-memory semantics not yet supported — the
962    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
963    /// For pure-RAM identities, see v1.0 vision.
964    ///
965    /// `--local` is the explicit form of today's default; identity
966    /// persists to the machine-wide sessions root.
967    Create {
968        /// Session name. Defaults to derived from cwd (anonymous mode
969        /// uses a random name).
970        #[arg(long)]
971        name: Option<String>,
972        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
973        /// reboot, no federation). Mutually exclusive with --local.
974        #[arg(long, conflicts_with = "local")]
975        anonymous: bool,
976        /// Create a LOCAL identity (machine-persistent, no federation).
977        /// Default — explicit flag for clarity.
978        #[arg(long)]
979        local: bool,
980        #[arg(long)]
981        json: bool,
982    },
983    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
984    /// the machine-wide sessions root + register in the cwd map.
985    /// After persist, the identity survives reboot.
986    /// v0.7.0-alpha.20.
987    Persist {
988        /// The anonymous identity's name (from `wire identity list`).
989        name: String,
990        /// Optional rename during persist. Default: keep the anon name.
991        #[arg(long = "as", value_name = "NEW_NAME")]
992        as_name: Option<String>,
993        #[arg(long)]
994        json: bool,
995    },
996    /// Demote an identity ONE level in the lifecycle:
997    ///   federation → local: removes the relay slot binding but keeps
998    ///   the keypair + agent-card. Operator can later re-publish with
999    ///   `wire identity publish`. v0.7.0-alpha.20.
1000    ///
1001    /// (local → anonymous is not exposed; the safer flow is destroy +
1002    /// recreate, since "demoting" a persistent identity to ephemeral
1003    /// has surprising semantics — what about the keypair? what about
1004    /// pinned peers? Better to be explicit with destroy.)
1005    Demote {
1006        /// Session name to demote.
1007        name: String,
1008        #[arg(long)]
1009        json: bool,
1010    },
1011}
1012
1013#[derive(Subcommand, Debug)]
1014pub enum SessionCommand {
1015    /// Bootstrap a new isolated session in this machine's sessions root.
1016    /// With no name, derives one from `basename(cwd)` and caches it in
1017    /// the registry so re-running from the same project reuses it.
1018    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1019    /// the new session's WIRE_HOME. Output includes the `export
1020    /// WIRE_HOME=...` line operators paste into their shell to activate
1021    /// it.
1022    New {
1023        /// Optional session name. Default = derived from `basename(cwd)`.
1024        name: Option<String>,
1025        /// Relay URL for the session's slot allocation + handle claim.
1026        #[arg(long, default_value = "https://wireup.net")]
1027        relay: String,
1028        /// v0.5.17: also allocate a second slot on a same-machine local
1029        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1030        /// sister-session traffic prefers this path: zero round-trip
1031        /// latency, zero metadata exposure to the public relay. Probes
1032        /// `<local-relay>/healthz` first; silently skips if the local
1033        /// relay isn't running.
1034        #[arg(long)]
1035        with_local: bool,
1036        /// v0.5.17: override the local relay URL probed by `--with-local`.
1037        /// Default is `http://127.0.0.1:8771` to match
1038        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1039        #[arg(long, default_value = "http://127.0.0.1:8771")]
1040        local_relay: String,
1041        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1042        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1043        /// Lets other machines on the same network reach this session
1044        /// directly without round-tripping the public federation relay
1045        /// at https://wireup.net. LAN endpoint is published in the
1046        /// agent-card; opt-in per session (default off).
1047        #[arg(long)]
1048        with_lan: bool,
1049        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1050        /// LAN IP — operator must type the address). Example:
1051        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1052        #[arg(long)]
1053        lan_relay: Option<String>,
1054        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1055        /// relay (must be running e.g. via `wire relay-server --uds
1056        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1057        /// bypasses the macOS firewall + Tailscale userspace-netstack
1058        /// class of issues entirely for sister-session traffic. UDS
1059        /// endpoint is published in the agent-card.
1060        #[arg(long)]
1061        with_uds: bool,
1062        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1063        /// is set. Example: `/tmp/wire.sock` or
1064        /// `~/.wire/local.sock`.
1065        #[arg(long)]
1066        uds_socket: Option<std::path::PathBuf>,
1067        /// Skip spawning the session-local daemon. Use when you want
1068        /// to drive sync explicitly from the agent or test rig.
1069        #[arg(long)]
1070        no_daemon: bool,
1071        /// v0.6.6: create a federation-free session — no nick claim on
1072        /// `--relay`, no federation slot allocation. Implies
1073        /// `--with-local`. The session exists only to coordinate with
1074        /// other sister sessions on this machine; it has no public
1075        /// address and cannot be reached from outside. Reserved nicks
1076        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1077        /// to publish them.
1078        #[arg(long)]
1079        local_only: bool,
1080        /// Emit JSON.
1081        #[arg(long)]
1082        json: bool,
1083    },
1084    /// List all sessions on this machine with their handle, DID,
1085    /// daemon liveness, and the cwd they're associated with.
1086    List {
1087        #[arg(long)]
1088        json: bool,
1089    },
1090    /// List sister sessions reachable via a same-machine local relay
1091    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1092    /// share. Sessions without a Local-scope endpoint are listed
1093    /// separately so the operator can tell which are federation-only.
1094    /// Read-only — does not probe any relay or touch daemons.
1095    ListLocal {
1096        #[arg(long)]
1097        json: bool,
1098    },
1099    /// v0.6.0 (issue #12): mesh-pair every sister session against every
1100    /// other in O(N²) handshakes. For each unordered pair (A, B) that
1101    /// is not already paired, drives the bilateral flow end-to-end:
1102    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
1103    /// B's side, then a final pull on A so the ack lands. Idempotent —
1104    /// re-running skips pairs already in `state.peers`.
1105    ///
1106    /// **Trust anchor:** the operator running this command owns every
1107    /// session listed in `wire session list-local` (they all live under
1108    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1109    /// That filesystem-permission boundary IS the consent for both
1110    /// sides — the bilateral SAS / network-level handshake assumes
1111    /// strangers; same-uid sister sessions are by definition not
1112    /// strangers. Cross-uid sister sessions are out of scope; today
1113    /// `wire session list-local` only enumerates this user's sessions.
1114    PairAllLocal {
1115        /// Seconds to wait between handshake stages for pair_drop /
1116        /// pair_drop_ack to propagate over the relay. Default 1s
1117        /// (local-relay is typically <100ms RTT). Bump if you see
1118        /// "pending-inbound never arrived" errors on a slow relay.
1119        #[arg(long, default_value_t = 1)]
1120        settle_secs: u64,
1121        /// Federation relay to bind each `wire add` against. Default
1122        /// `https://wireup.net`. Sister sessions should be bound to
1123        /// the same federation relay; the pair handshake routes through
1124        /// it for the .well-known resolution + pair_drop deposit.
1125        #[arg(long, default_value = "https://wireup.net")]
1126        federation_relay: String,
1127        #[arg(long)]
1128        json: bool,
1129    },
1130    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1131    /// machine. Enumerates every session in `wire session list-local`,
1132    /// walks each session's `relay.json#peers` to find which other sister
1133    /// sessions it has pinned, and probes the local relay for each edge's
1134    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1135    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1136    /// local_relay, summary}` so scripts can scrape.
1137    ///
1138    /// Read-only — does NOT touch peers or daemons, only the relay's
1139    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1140    /// already hold. Silent on any probe failure (degrades to "no
1141    /// signal" rather than abort) so a half-broken mesh is still
1142    /// inspectable.
1143    MeshStatus {
1144        /// Threshold in seconds for "stale" classification on an edge.
1145        /// An edge whose receiver hasn't polled their slot in this long
1146        /// is flagged. Default 300s (5 min) — same as the per-send
1147        /// `phyllis` attentiveness nag.
1148        #[arg(long, default_value_t = 300)]
1149        stale_secs: u64,
1150        #[arg(long)]
1151        json: bool,
1152    },
1153    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1154    /// can `eval $(wire session env <name>)` to activate it. With no
1155    /// name, resolves the cwd through the registry.
1156    Env {
1157        /// Session name. Default = derived from cwd via the registry.
1158        name: Option<String>,
1159        #[arg(long)]
1160        json: bool,
1161    },
1162    /// Identify which session the current cwd maps to in the registry.
1163    /// Prints `(none)` if cwd isn't registered — `wire session new`
1164    /// would create one.
1165    Current {
1166        #[arg(long)]
1167        json: bool,
1168    },
1169    /// Attach an existing session to the current cwd in the registry,
1170    /// so subsequent auto-detect from this cwd resolves to that session
1171    /// instead of walking up to an ancestor's binding. Use when an
1172    /// ancestor dir (e.g. `~/Source`) is already registered and is
1173    /// shadowing per-project identities for cwds beneath it. Idempotent;
1174    /// re-binding to the same name is a no-op. Re-binding to a different
1175    /// name overwrites the prior entry with a stderr warning.
1176    Bind {
1177        /// Session name to bind. Must already exist (run `wire session
1178        /// new <name>` first if not). With no name, auto-derives from
1179        /// `basename(cwd)` and errors if no session of that name exists.
1180        name: Option<String>,
1181        #[arg(long)]
1182        json: bool,
1183    },
1184    /// Tear down a session: kills its daemon (if running), deletes its
1185    /// state directory, and removes it from the registry. Requires
1186    /// `--force` because state loss is unrecoverable (keypair gone).
1187    Destroy {
1188        name: String,
1189        /// Confirm state-deleting operation.
1190        #[arg(long)]
1191        force: bool,
1192        #[arg(long)]
1193        json: bool,
1194    },
1195}
1196
1197/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1198/// session's view of the pinned peer set. `status` is the read-only
1199/// observability primitive (alias for `wire session mesh-status`);
1200/// `broadcast` fans a signed event to every pinned peer in one call.
1201#[derive(Subcommand, Debug)]
1202pub enum MeshCommand {
1203    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1204    /// per-edge health roll-up across every sister session on this machine.
1205    Status {
1206        /// Threshold in seconds for "stale" classification on an edge.
1207        #[arg(long, default_value_t = 300)]
1208        stale_secs: u64,
1209        #[arg(long)]
1210        json: bool,
1211    },
1212    /// Fan one signed event to every pinned peer. Each peer receives a
1213    /// distinct `event_id` but every copy shares the same `broadcast_id`
1214    /// UUID so receivers can correlate them as a single broadcast.
1215    ///
1216    /// `--scope local` (default) only fans to peers reachable via a same-
1217    /// machine local relay. `--scope federation` only to public-relay
1218    /// peers. `--scope both` to every pinned peer.
1219    ///
1220    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1221    /// for "ack-loop" prevention: a peer responding to a broadcast can
1222    /// exclude its own broadcaster when re-broadcasting.
1223    ///
1224    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1225    /// file, `-` reads stdin (JSON if parseable, else literal).
1226    ///
1227    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1228    /// peers — that would re-introduce the phonebook-scrape risk closed
1229    /// in v0.5.14 (T8).
1230    Broadcast {
1231        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1232        /// `heartbeat`. Same vocabulary as `wire send`.
1233        #[arg(long, default_value = "claim")]
1234        kind: String,
1235        /// `local`, `federation`, or `both`. Default `local`.
1236        #[arg(long, default_value = "local")]
1237        scope: String,
1238        /// Skip a specific peer handle. Repeatable.
1239        #[arg(long)]
1240        exclude: Vec<String>,
1241        /// Drop the broadcast event ID from the relay-side attentiveness
1242        /// nag (`phyllis`) — useful when broadcasting to many peers and
1243        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1244        #[arg(long)]
1245        noreply: bool,
1246        /// Body — string, `@/path` for a file, or `-` for stdin.
1247        body: String,
1248        #[arg(long)]
1249        json: bool,
1250    },
1251    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1252    /// capability-aware addressing. Stored as `profile.role` on the
1253    /// signed agent-card — propagates over the existing pair / .well-
1254    /// known plumbing, no new persistence.
1255    ///
1256    /// First slice of the Layer-2 capability metadata umbrella (#13).
1257    /// `wire mesh route` (issue #21) will consume these tags to pick
1258    /// the right sister for a task.
1259    Role {
1260        #[command(subcommand)]
1261        action: MeshRoleAction,
1262    },
1263    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1264    /// to one sister session and deliver an event to that one peer.
1265    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1266    /// can now address "the reviewer" instead of hard-coding a handle.
1267    ///
1268    /// Strategies:
1269    ///   - `round-robin` (default): per-role cursor, persisted at
1270    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1271    ///   - `first`: alphabetically-first matching sister. Deterministic.
1272    ///   - `random`: uniform random among matches. Stateless.
1273    ///
1274    /// Pinned-peers-only by construction (same posture as `broadcast`).
1275    /// Caller must already have the target sister pinned in
1276    /// `state.peers` — otherwise we can't sign + push. Run
1277    /// `wire session pair-all-local` first if the mesh isn't wired.
1278    Route {
1279        /// Role to match (operator-defined tag from `wire mesh role set`).
1280        role: String,
1281        /// `round-robin` (default), `first`, or `random`.
1282        #[arg(long, default_value = "round-robin")]
1283        strategy: String,
1284        /// Skip a specific sister handle. Repeatable.
1285        #[arg(long)]
1286        exclude: Vec<String>,
1287        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1288        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1289        #[arg(long, default_value = "claim")]
1290        kind: String,
1291        /// Body — string, `@/path` for a file, or `-` for stdin.
1292        body: String,
1293        #[arg(long)]
1294        json: bool,
1295    },
1296}
1297
1298/// v0.6.4: subcommands of `wire mesh role`.
1299#[derive(Subcommand, Debug)]
1300pub enum MeshRoleAction {
1301    /// Assign self to a role. Role is a free-form ASCII string
1302    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1303    /// the vocabulary out-of-band — common starters: `planner`,
1304    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1305    Set {
1306        role: String,
1307        #[arg(long)]
1308        json: bool,
1309    },
1310    /// Read self or a peer's role. With no arg, prints self. With a
1311    /// handle, reads from the peer's pinned agent-card.
1312    Get {
1313        peer: Option<String>,
1314        #[arg(long)]
1315        json: bool,
1316    },
1317    /// List roles across every sister session on this machine. Reads
1318    /// each session's agent-card by path — no network, no env mutation.
1319    List {
1320        #[arg(long)]
1321        json: bool,
1322    },
1323    /// Remove self from any assigned role. Re-signs the card with
1324    /// `profile.role: null`.
1325    Clear {
1326        #[arg(long)]
1327        json: bool,
1328    },
1329}
1330
1331#[derive(Subcommand, Debug)]
1332pub enum ServiceAction {
1333    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1334    /// load it. Idempotent — re-running re-bootstraps an existing service.
1335    ///
1336    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1337    /// process). Pass `--local-relay` to install the loopback relay
1338    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1339    /// transport sister-Claudes use to coordinate on the same machine
1340    /// (v0.5.17 dual-slot). The two services have distinct labels +
1341    /// log files, so you can install both.
1342    Install {
1343        /// Install the local-relay service instead of the daemon.
1344        #[arg(long)]
1345        local_relay: bool,
1346        #[arg(long)]
1347        json: bool,
1348    },
1349    /// Unload + delete the service unit. Daemon keeps running until the
1350    /// next reboot or `wire upgrade`; this only changes the boot-time
1351    /// behaviour.
1352    Uninstall {
1353        /// Uninstall the local-relay service instead of the daemon.
1354        #[arg(long)]
1355        local_relay: bool,
1356        #[arg(long)]
1357        json: bool,
1358    },
1359    /// Report whether the unit is installed + active.
1360    Status {
1361        /// Show status of the local-relay service instead of the daemon.
1362        #[arg(long)]
1363        local_relay: bool,
1364        #[arg(long)]
1365        json: bool,
1366    },
1367}
1368
1369#[derive(Subcommand, Debug)]
1370pub enum ResponderCommand {
1371    /// Publish this agent's auto-responder health.
1372    Set {
1373        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1374        status: String,
1375        /// Optional operator-facing reason.
1376        #[arg(long)]
1377        reason: Option<String>,
1378        /// Emit JSON.
1379        #[arg(long)]
1380        json: bool,
1381    },
1382    /// Read responder health for self, or for a paired peer.
1383    Get {
1384        /// Optional peer handle; omitted means this agent's own slot.
1385        peer: Option<String>,
1386        /// Emit JSON.
1387        #[arg(long)]
1388        json: bool,
1389    },
1390}
1391
1392#[derive(Subcommand, Debug)]
1393pub enum ProfileAction {
1394    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1395    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1396    /// (JSON array) and `now` (JSON object).
1397    Set {
1398        field: String,
1399        value: String,
1400        #[arg(long)]
1401        json: bool,
1402    },
1403    /// Show all profile fields. Equivalent to `wire whois`.
1404    Get {
1405        #[arg(long)]
1406        json: bool,
1407    },
1408    /// Clear a profile field.
1409    Clear {
1410        field: String,
1411        #[arg(long)]
1412        json: bool,
1413    },
1414}
1415
1416/// Entry point — parse and dispatch.
1417pub fn run() -> Result<()> {
1418    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1419    // the session registry and adopt that session's home for this
1420    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1421    // detect — `wire whoami` / `wire monitor` from a project cwd now
1422    // resolve to that project's session identity, not the machine
1423    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1424    //
1425    // MUST run before any thread spawn — call it FIRST, before
1426    // `Cli::parse` (which uses clap internals only) and before any
1427    // command dispatch (which may spawn workers).
1428    crate::session::maybe_adopt_session_wire_home("cli");
1429    let cli = Cli::parse();
1430    match cli.command {
1431        Command::Init {
1432            handle,
1433            name,
1434            relay,
1435            offline,
1436            json,
1437        } => cmd_init(&handle, name.as_deref(), relay.as_deref(), offline, json),
1438        Command::Status { peer, json } => {
1439            if let Some(peer) = peer {
1440                cmd_status_peer(&peer, json)
1441            } else {
1442                cmd_status(json)
1443            }
1444        }
1445        Command::Whoami {
1446            json,
1447            short,
1448            colored,
1449        } => cmd_whoami(json_default(json), short, colored),
1450        Command::Peers { json } => cmd_peers(json_default(json)),
1451        Command::Here { json } => cmd_here(json_default(json)),
1452        Command::Completions { shell } => {
1453            // v0.9.5: print shell completion script to stdout. Operator
1454            // pipes into their shell's completion dir; tab completion
1455            // covers verbs (dial, send, pending, accept, etc.) AND
1456            // their flags. Peer-name dynamic completion is a future
1457            // shell-side enhancement; clap_complete only ships the
1458            // static grammar.
1459            use clap::CommandFactory;
1460            let mut cmd = Cli::command();
1461            clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1462            Ok(())
1463        }
1464        Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1465        Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1466        Command::Send {
1467            peer,
1468            kind_or_body,
1469            body,
1470            deadline,
1471            no_auto_pair,
1472            json,
1473        } => {
1474            // P0.S: smart-positional API. `wire send peer body` =
1475            // kind=claim. `wire send peer kind body` = explicit kind.
1476            let (kind, body) = match body {
1477                Some(real_body) => (kind_or_body, real_body),
1478                None => ("claim".to_string(), kind_or_body),
1479            };
1480            cmd_send(
1481                &peer,
1482                &kind,
1483                &body,
1484                deadline.as_deref(),
1485                no_auto_pair,
1486                json_default(json),
1487            )
1488        }
1489        Command::Dial {
1490            name,
1491            message,
1492            json,
1493        } => cmd_dial(&name, message.as_deref(), json_default(json)),
1494        Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1495        Command::Monitor {
1496            peer,
1497            json,
1498            include_handshake,
1499            interval_ms,
1500            replay,
1501        } => cmd_monitor(
1502            peer.as_deref(),
1503            json,
1504            include_handshake,
1505            interval_ms,
1506            replay,
1507        ),
1508        Command::Verify { path, json } => cmd_verify(&path, json),
1509        Command::Responder { command } => match command {
1510            ResponderCommand::Set {
1511                status,
1512                reason,
1513                json,
1514            } => cmd_responder_set(&status, reason.as_deref(), json),
1515            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1516        },
1517        Command::Mcp => cmd_mcp(),
1518        Command::RelayServer {
1519            bind,
1520            local_only,
1521            uds,
1522        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1523        Command::BindRelay {
1524            url,
1525            scope,
1526            replace,
1527            migrate_pinned,
1528            json,
1529        } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1530        Command::AddPeerSlot {
1531            handle,
1532            url,
1533            slot_id,
1534            slot_token,
1535            json,
1536        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1537        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1538        Command::Pull { json } => cmd_pull(json),
1539        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1540        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1541        Command::ForgetPeer {
1542            handle,
1543            purge,
1544            json,
1545        } => cmd_forget_peer(&handle, purge, json),
1546        Command::Daemon {
1547            interval,
1548            once,
1549            json,
1550        } => cmd_daemon(interval, once, json),
1551        Command::PairHost {
1552            relay,
1553            yes,
1554            timeout,
1555            detach,
1556            json,
1557        } => {
1558            if detach {
1559                cmd_pair_host_detach(&relay, json)
1560            } else {
1561                cmd_pair_host(&relay, yes, timeout)
1562            }
1563        }
1564        Command::PairJoin {
1565            code_phrase,
1566            relay,
1567            yes,
1568            timeout,
1569            detach,
1570            json,
1571        } => {
1572            if detach {
1573                cmd_pair_join_detach(&code_phrase, &relay, json)
1574            } else {
1575                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1576            }
1577        }
1578        Command::PairConfirm {
1579            code_phrase,
1580            digits,
1581            json,
1582        } => cmd_pair_confirm(&code_phrase, &digits, json),
1583        Command::PairList {
1584            json,
1585            watch,
1586            watch_interval,
1587        } => cmd_pair_list(json, watch, watch_interval),
1588        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1589        Command::PairWatch {
1590            code_phrase,
1591            status,
1592            timeout,
1593            json,
1594        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1595        Command::Pair {
1596            handle,
1597            code,
1598            relay,
1599            yes,
1600            timeout,
1601            no_setup,
1602            detach,
1603        } => {
1604            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1605            // the zero-paste megacommand path — `wire pair slancha-spark@
1606            // wireup.net` does add + poll-for-ack + verify in one shot. The
1607            // SAS / code-based pair flow stays available for handles without
1608            // `@` (bootstrap pairing between two boxes that don't yet share a
1609            // relay directory).
1610            if handle.contains('@') && code.is_none() {
1611                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1612            } else if detach {
1613                cmd_pair_detach(&handle, code.as_deref(), &relay)
1614            } else {
1615                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1616            }
1617        }
1618        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1619        Command::PairAccept { peer, json } => {
1620            let j = json_default(json);
1621            deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1622            cmd_pair_accept(&peer, j)
1623        }
1624        Command::PairReject { peer, json } => {
1625            let j = json_default(json);
1626            deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1627            cmd_pair_reject(&peer, j)
1628        }
1629        Command::PairListInbound { json } => {
1630            let j = json_default(json);
1631            deprecation_warn("pair-list-inbound", "pending", j);
1632            cmd_pair_list_inbound(j)
1633        }
1634        Command::Session(cmd) => cmd_session(cmd),
1635        Command::Identity { cmd } => cmd_identity(cmd),
1636        Command::Mesh(cmd) => cmd_mesh(cmd),
1637        Command::Invite {
1638            relay,
1639            ttl,
1640            uses,
1641            share,
1642            json,
1643        } => cmd_invite(&relay, ttl, uses, share, json),
1644        Command::Accept { target, json } => {
1645            // v0.9.4: smart-dispatch retired. `wire accept` always means
1646            // pair-accept by name. URL-shaped input gets a deprecation
1647            // banner pointing at `wire accept-invite <URL>` and then
1648            // (for back-compat with v0.9 scripts) routes to the invite
1649            // accept path one last time. v1.0 will reject URLs here.
1650            let j = json_default(json);
1651            if target.starts_with("wire://pair?") {
1652                deprecation_warn("accept-url", "accept-invite <url>", j);
1653                cmd_accept(&target, j)
1654            } else {
1655                cmd_pair_accept(&target, j)
1656            }
1657        }
1658        Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1659        Command::Whois {
1660            handle,
1661            json,
1662            relay,
1663        } => {
1664            // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1665            // resolves through the local identity layer (pinned peers
1666            // + local sister sessions). `wire whois <nick>@<relay>`
1667            // keeps the existing federation `.well-known/wire/agent`
1668            // path. `wire whois` (no arg) prints self via the original
1669            // path. The character nickname is the canonical operator-
1670            // facing name as of v0.8 — most callers should hit the
1671            // local route.
1672            match handle.as_deref() {
1673                Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1674                other => cmd_whois(other, json, relay.as_deref()),
1675            }
1676        }
1677        Command::Add {
1678            handle,
1679            relay,
1680            local_sister,
1681            json,
1682        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1683        Command::Up {
1684            handle,
1685            name,
1686            with_local,
1687            no_local,
1688            json,
1689        } => cmd_up(
1690            &handle,
1691            name.as_deref(),
1692            with_local.as_deref(),
1693            no_local,
1694            json,
1695        ),
1696        Command::Doctor {
1697            json,
1698            recent_rejections,
1699        } => cmd_doctor(json, recent_rejections),
1700        Command::Upgrade { check, json } => cmd_upgrade(check, json),
1701        Command::Service { action } => cmd_service(action),
1702        Command::Diag { action } => cmd_diag(action),
1703        Command::Claim {
1704            nick,
1705            relay,
1706            public_url,
1707            hidden,
1708            json,
1709        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1710        Command::Profile { action } => cmd_profile(action),
1711        Command::Setup { apply } => cmd_setup(apply),
1712        Command::Reactor {
1713            on_event,
1714            peer,
1715            kind,
1716            verified_only,
1717            interval,
1718            once,
1719            dry_run,
1720            max_per_minute,
1721            max_chain_depth,
1722        } => cmd_reactor(
1723            &on_event,
1724            peer.as_deref(),
1725            kind.as_deref(),
1726            verified_only,
1727            interval,
1728            once,
1729            dry_run,
1730            max_per_minute,
1731            max_chain_depth,
1732        ),
1733        Command::Notify {
1734            interval,
1735            peer,
1736            once,
1737            json,
1738        } => cmd_notify(interval, peer.as_deref(), once, json),
1739    }
1740}
1741
1742// ---------- init ----------
1743
1744fn cmd_init(
1745    handle: &str,
1746    name: Option<&str>,
1747    relay: Option<&str>,
1748    offline: bool,
1749    as_json: bool,
1750) -> Result<()> {
1751    if !handle
1752        .chars()
1753        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1754    {
1755        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
1756    }
1757    if config::is_initialized()? {
1758        bail!(
1759            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1760            config::config_dir()?
1761        );
1762    }
1763    // v0.9.1 smart-default reachability. If the operator passed neither
1764    // --relay nor --offline, probe the conventional local relay at
1765    // http://127.0.0.1:8771 and auto-attach if healthy. Closes the
1766    // silent-slotless footgun WITHOUT the v0.9 rejection wall, which
1767    // forced operators through a three-flag decision tree on first
1768    // invocation. Bare `wire init <handle>` is now ergonomic again
1769    // whenever a local relay is running (the common dev setup).
1770    //
1771    // Probe order:
1772    //   1. --relay <url>          → use it
1773    //   2. --offline               → skip slot allocation (rare power-user)
1774    //   3. local relay reachable  → auto-attach + log to stderr
1775    //   4. otherwise               → bail with actionable options
1776    let mut resolved_relay: Option<String> = relay.map(str::to_string);
1777    if resolved_relay.is_none() && !offline {
1778        let default_local = "http://127.0.0.1:8771";
1779        let client = crate::relay_client::RelayClient::new(default_local);
1780        if client.check_healthz().is_ok() {
1781            eprintln!(
1782                "wire init: local relay at {default_local} reachable — auto-attaching. \
1783                 Use --relay <url> to pick a different relay, --offline to skip."
1784            );
1785            resolved_relay = Some(default_local.to_string());
1786        } else {
1787            // v0.9.5: interactive prompt for first-time operators
1788            // when the smart-default can't auto-attach. Detect TTY on
1789            // stdin AND stderr — only prompt for humans. CI / agents
1790            // / non-interactive shells fall through to the explicit
1791            // error wall (unchanged behavior since v0.9.1).
1792            use std::io::{BufRead, IsTerminal, Write};
1793            let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1794            if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1795                eprintln!("wire init: no local relay reachable at {default_local}.");
1796                eprint!(
1797                    "  Bind to public federation relay https://wireup.net instead? \
1798                     [Y/n/offline/url]: "
1799                );
1800                let _ = std::io::stderr().flush();
1801                let mut input = String::new();
1802                let _ = std::io::stdin().lock().read_line(&mut input);
1803                let answer = input.trim();
1804                match answer {
1805                    "" | "y" | "Y" | "yes" | "YES" => {
1806                        eprintln!("wire init: binding to https://wireup.net");
1807                        resolved_relay = Some("https://wireup.net".to_string());
1808                    }
1809                    "n" | "N" | "no" | "NO" => {
1810                        bail!(
1811                            "wire init: declined federation default; re-run with --relay <url> or --offline."
1812                        );
1813                    }
1814                    "offline" | "OFFLINE" => {
1815                        eprintln!(
1816                            "wire init: proceeding offline. \
1817                             Run `wire bind-relay <url>` before pairing."
1818                        );
1819                        // Fall through with resolved_relay still None;
1820                        // the `offline` flag is conceptually set but
1821                        // the caller's local doesn't need updating —
1822                        // resolved_relay = None + offline behavior
1823                        // is identical for the rest of cmd_init.
1824                    }
1825                    url if url.starts_with("http://") || url.starts_with("https://") => {
1826                        eprintln!("wire init: binding to {url}");
1827                        resolved_relay = Some(url.to_string());
1828                    }
1829                    other => {
1830                        bail!(
1831                            "wire init: unrecognized answer `{other}` — \
1832                             expected Y/n/offline/<url>. Re-run with --relay or --offline."
1833                        );
1834                    }
1835                }
1836            } else {
1837                bail!(
1838                    "wire init: no relay specified and no local relay reachable at \
1839                     http://127.0.0.1:8771.\n\
1840                     Pick one:\n\
1841                     • `wire service install --local-relay` — start the local relay, then re-run\n\
1842                     • `wire init {handle} --relay https://wireup.net` — bind to public federation\n\
1843                     • `wire init {handle} --offline` — generate keypair only \
1844                     (peers cannot reach you until you `wire bind-relay <url>` later)"
1845                );
1846            }
1847        }
1848    }
1849    let relay = resolved_relay.as_deref();
1850
1851    config::ensure_dirs()?;
1852    let (sk_seed, pk_bytes) = generate_keypair();
1853    config::write_private_key(&sk_seed)?;
1854
1855    // v0.11 ONE-NAME: derive the character nickname from a synthetic DID
1856    // using the freshly-generated pubkey, then USE THE CHARACTER as the
1857    // canonical handle. The operator-typed `handle` arg becomes either:
1858    //   - identical to character (already-canonical input — no-op), OR
1859    //   - overridden in favor of character (operator-typed name was a
1860    //     vanity layer that would never have been federation-reachable).
1861    // Either way, agent-card.handle ends up == character, and every
1862    // downstream surface (relay phonebook, .well-known, dial/send) keys
1863    // on the same name an operator sees in their statusline.
1864    //
1865    // Per the v0.11 directive: "If you can't call someone via a name,
1866    // don't let them have it as a name." Operator-typed handles violated
1867    // that rule because the character was the displayed name but the
1868    // handle was the addressable one. Now they're the same string.
1869    let synth_did = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1870    let character = crate::character::Character::from_did(&synth_did);
1871    let canonical_handle: &str = &character.nickname;
1872    if canonical_handle != handle {
1873        eprintln!(
1874            "wire init: v0.11 one-name rule — operator-typed `{handle}` ignored in favor of \
1875             DID-derived character `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
1876        );
1877    }
1878
1879    let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
1880    let signed = sign_agent_card(&card, &sk_seed);
1881    config::write_agent_card(&signed)?;
1882
1883    let mut trust = empty_trust();
1884    add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
1885    config::write_trust(&trust)?;
1886
1887    let fp = fingerprint(&pk_bytes);
1888    let key_id = make_key_id(canonical_handle, &pk_bytes);
1889    // Rebind `handle` for the rest of cmd_init so downstream prints,
1890    // relay-state writes, etc. all reference the canonical name.
1891    let handle = canonical_handle;
1892
1893    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
1894    let mut relay_info: Option<(String, String)> = None;
1895    if let Some(url) = relay {
1896        let normalized = url.trim_end_matches('/');
1897        let client = crate::relay_client::RelayClient::new(normalized);
1898        client.check_healthz()?;
1899        let alloc = client.allocate_slot(Some(handle))?;
1900        let mut state = config::read_relay_state()?;
1901        state["self"] = json!({
1902            "relay_url": normalized,
1903            "slot_id": alloc.slot_id.clone(),
1904            "slot_token": alloc.slot_token,
1905        });
1906        config::write_relay_state(&state)?;
1907        relay_info = Some((normalized.to_string(), alloc.slot_id));
1908    }
1909
1910    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1911    if as_json {
1912        let mut out = json!({
1913            "did": did_str.clone(),
1914            "fingerprint": fp,
1915            "key_id": key_id,
1916            "config_dir": config::config_dir()?.to_string_lossy(),
1917        });
1918        if let Some((url, slot_id)) = &relay_info {
1919            out["relay_url"] = json!(url);
1920            out["slot_id"] = json!(slot_id);
1921        }
1922        println!("{}", serde_json::to_string(&out)?);
1923    } else {
1924        println!("generated {did_str} (ed25519:{key_id})");
1925        println!(
1926            "config written to {}",
1927            config::config_dir()?.to_string_lossy()
1928        );
1929        if let Some((url, slot_id)) = &relay_info {
1930            println!("bound to relay {url} (slot {slot_id})");
1931            println!();
1932            println!(
1933                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1934            );
1935        } else {
1936            println!();
1937            println!(
1938                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1939            );
1940        }
1941    }
1942    Ok(())
1943}
1944
1945// ---------- status ----------
1946
1947fn cmd_status(as_json: bool) -> Result<()> {
1948    let initialized = config::is_initialized()?;
1949
1950    let mut summary = json!({
1951        "initialized": initialized,
1952    });
1953
1954    if initialized {
1955        let card = config::read_agent_card()?;
1956        let did = card
1957            .get("did")
1958            .and_then(Value::as_str)
1959            .unwrap_or("")
1960            .to_string();
1961        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
1962        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
1963        // legacy cards.
1964        let handle = card
1965            .get("handle")
1966            .and_then(Value::as_str)
1967            .map(str::to_string)
1968            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1969        let pk_b64 = card
1970            .get("verify_keys")
1971            .and_then(Value::as_object)
1972            .and_then(|m| m.values().next())
1973            .and_then(|v| v.get("key"))
1974            .and_then(Value::as_str)
1975            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1976        let pk_bytes = crate::signing::b64decode(pk_b64)?;
1977        summary["did"] = json!(did);
1978        summary["handle"] = json!(handle);
1979        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1980        summary["capabilities"] = card
1981            .get("capabilities")
1982            .cloned()
1983            .unwrap_or_else(|| json!([]));
1984
1985        let trust = config::read_trust()?;
1986        let relay_state_for_tier =
1987            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1988        let mut peers = Vec::new();
1989        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1990            for (peer_handle, _agent) in agents {
1991                if peer_handle == &handle {
1992                    continue; // self
1993                }
1994                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
1995                // for peers we've pinned but never received a pair_drop_ack
1996                // from, so the operator sees the "we can't send to them yet"
1997                // state instead of seeing a misleading VERIFIED.
1998                peers.push(json!({
1999                    "handle": peer_handle,
2000                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2001                }));
2002            }
2003        }
2004        summary["peers"] = json!(peers);
2005
2006        let relay_state = config::read_relay_state()?;
2007        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2008        if !summary["self_relay"].is_null() {
2009            // Hide slot_token from default view.
2010            if let Some(obj) = summary["self_relay"].as_object_mut() {
2011                obj.remove("slot_token");
2012            }
2013        }
2014        summary["peer_slots_count"] = json!(
2015            relay_state
2016                .get("peers")
2017                .and_then(Value::as_object)
2018                .map(|m| m.len())
2019                .unwrap_or(0)
2020        );
2021
2022        // Outbox / inbox queue depth (file count + total events)
2023        let outbox = config::outbox_dir()?;
2024        let inbox = config::inbox_dir()?;
2025        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2026        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2027
2028        // v0.5.19: liveness snapshot through a single helper so this
2029        // surface and `wire doctor` agree by construction. Issue #2:
2030        // doctor PASSed while status said DOWN for 25 min because each
2031        // computed liveness independently. ensure_up::daemon_liveness
2032        // is the only path now.
2033        let snap = crate::ensure_up::daemon_liveness();
2034        let mut daemon = json!({
2035            "running": snap.pidfile_alive,
2036            "pid": snap.pidfile_pid,
2037            "all_running_pids": snap.pgrep_pids,
2038            "orphans": snap.orphan_pids,
2039        });
2040        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2041            daemon["version"] = json!(d.version);
2042            daemon["bin_path"] = json!(d.bin_path);
2043            daemon["did"] = json!(d.did);
2044            daemon["relay_url"] = json!(d.relay_url);
2045            daemon["started_at"] = json!(d.started_at);
2046            daemon["schema"] = json!(d.schema);
2047            if d.version != env!("CARGO_PKG_VERSION") {
2048                daemon["version_mismatch"] = json!({
2049                    "daemon": d.version.clone(),
2050                    "cli": env!("CARGO_PKG_VERSION"),
2051                });
2052            }
2053        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2054            daemon["pidfile_form"] = json!("legacy-int");
2055            daemon["version_mismatch"] = json!({
2056                "daemon": "<pre-0.5.11>",
2057                "cli": env!("CARGO_PKG_VERSION"),
2058            });
2059        }
2060        summary["daemon"] = daemon;
2061
2062        // Pending pair sessions — counts by status.
2063        let pending = crate::pending_pair::list_pending().unwrap_or_default();
2064        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2065        for p in &pending {
2066            *counts.entry(p.status.clone()).or_default() += 1;
2067        }
2068        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
2069        let pending_inbound =
2070            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2071        let inbound_handles: Vec<&str> = pending_inbound
2072            .iter()
2073            .map(|p| p.peer_handle.as_str())
2074            .collect();
2075        summary["pending_pairs"] = json!({
2076            "total": pending.len(),
2077            "by_status": counts,
2078            "inbound_count": pending_inbound.len(),
2079            "inbound_handles": inbound_handles,
2080        });
2081    }
2082
2083    if as_json {
2084        println!("{}", serde_json::to_string(&summary)?);
2085    } else if !initialized {
2086        println!("not initialized — run `wire init <handle>` first");
2087    } else {
2088        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
2089        println!(
2090            "fingerprint:   {}",
2091            summary["fingerprint"].as_str().unwrap_or("?")
2092        );
2093        println!("capabilities:  {}", summary["capabilities"]);
2094        if !summary["self_relay"].is_null() {
2095            println!(
2096                "self relay:    {} (slot {})",
2097                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2098                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2099            );
2100        } else {
2101            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
2102        }
2103        println!(
2104            "peers:         {}",
2105            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2106        );
2107        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2108            println!(
2109                "  - {:<20} tier={}",
2110                p["handle"].as_str().unwrap_or(""),
2111                p["tier"].as_str().unwrap_or("?")
2112            );
2113        }
2114        println!(
2115            "outbox:        {} file(s), {} event(s) queued",
2116            summary["outbox"]["files"].as_u64().unwrap_or(0),
2117            summary["outbox"]["events"].as_u64().unwrap_or(0)
2118        );
2119        println!(
2120            "inbox:         {} file(s), {} event(s) received",
2121            summary["inbox"]["files"].as_u64().unwrap_or(0),
2122            summary["inbox"]["events"].as_u64().unwrap_or(0)
2123        );
2124        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2125        let daemon_pid = summary["daemon"]["pid"]
2126            .as_u64()
2127            .map(|p| p.to_string())
2128            .unwrap_or_else(|| "—".to_string());
2129        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2130        let version_suffix = if !daemon_version.is_empty() {
2131            format!(" v{daemon_version}")
2132        } else {
2133            String::new()
2134        };
2135        println!(
2136            "daemon:        {} (pid {}{})",
2137            if daemon_running { "running" } else { "DOWN" },
2138            daemon_pid,
2139            version_suffix,
2140        );
2141        // P1.7: surface version mismatch + orphan procs loudly.
2142        if let Some(mm) = summary["daemon"].get("version_mismatch") {
2143            println!(
2144                "               !! version mismatch: daemon={} CLI={}. \
2145                 run `wire upgrade` to swap atomically.",
2146                mm["daemon"].as_str().unwrap_or("?"),
2147                mm["cli"].as_str().unwrap_or("?"),
2148            );
2149        }
2150        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2151            && !orphans.is_empty()
2152        {
2153            let pids: Vec<String> = orphans
2154                .iter()
2155                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2156                .collect();
2157            println!(
2158                "               !! orphan daemon process(es): pids {}. \
2159                 pgrep saw them but pidfile didn't — likely stale process from \
2160                 prior install. Multiple daemons race the relay cursor.",
2161                pids.join(", ")
2162            );
2163        }
2164        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2165        let inbound_count = summary["pending_pairs"]["inbound_count"]
2166            .as_u64()
2167            .unwrap_or(0);
2168        if pending_total > 0 {
2169            print!("pending pairs: {pending_total}");
2170            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2171                let parts: Vec<String> = obj
2172                    .iter()
2173                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2174                    .collect();
2175                if !parts.is_empty() {
2176                    print!(" ({})", parts.join(", "));
2177                }
2178            }
2179            println!();
2180        } else if inbound_count == 0 {
2181            println!("pending pairs: none");
2182        }
2183        // v0.5.14: separate line for pending-inbound zero-paste requests.
2184        // Loud because each one is awaiting an operator gesture and the
2185        // capability hasn't flowed yet.
2186        if inbound_count > 0 {
2187            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2188                .as_array()
2189                .map(|a| {
2190                    a.iter()
2191                        .filter_map(|v| v.as_str().map(str::to_string))
2192                        .collect()
2193                })
2194                .unwrap_or_default();
2195            println!(
2196                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2197                handles.join(", "),
2198            );
2199        }
2200    }
2201    Ok(())
2202}
2203
2204fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2205    if !dir.exists() {
2206        return Ok(json!({"files": 0, "events": 0}));
2207    }
2208    let mut files = 0usize;
2209    let mut events = 0usize;
2210    for entry in std::fs::read_dir(dir)? {
2211        let path = entry?.path();
2212        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2213            files += 1;
2214            if let Ok(body) = std::fs::read_to_string(&path) {
2215                events += body.lines().filter(|l| !l.trim().is_empty()).count();
2216            }
2217        }
2218    }
2219    Ok(json!({"files": files, "events": events}))
2220}
2221
2222// ---------- responder health ----------
2223
2224fn responder_status_allowed(status: &str) -> bool {
2225    matches!(
2226        status,
2227        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2228    )
2229}
2230
2231fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2232    let state = config::read_relay_state()?;
2233    let (label, slot_info) = match peer {
2234        Some(peer) => (
2235            peer.to_string(),
2236            state
2237                .get("peers")
2238                .and_then(|p| p.get(peer))
2239                .ok_or_else(|| {
2240                    anyhow!(
2241                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
2242                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
2243                         (`wire peers` lists who you've already paired with.)"
2244                    )
2245                })?,
2246        ),
2247        None => (
2248            "self".to_string(),
2249            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2250                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2251            })?,
2252        ),
2253    };
2254    let relay_url = slot_info["relay_url"]
2255        .as_str()
2256        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2257        .to_string();
2258    let slot_id = slot_info["slot_id"]
2259        .as_str()
2260        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2261        .to_string();
2262    let slot_token = slot_info["slot_token"]
2263        .as_str()
2264        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2265        .to_string();
2266    Ok((label, relay_url, slot_id, slot_token))
2267}
2268
2269fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2270    if !responder_status_allowed(status) {
2271        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2272    }
2273    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2274    let now = time::OffsetDateTime::now_utc()
2275        .format(&time::format_description::well_known::Rfc3339)
2276        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2277    let mut record = json!({
2278        "status": status,
2279        "set_at": now,
2280    });
2281    if let Some(reason) = reason {
2282        record["reason"] = json!(reason);
2283    }
2284    if status == "online" {
2285        record["last_success_at"] = json!(now);
2286    }
2287    let client = crate::relay_client::RelayClient::new(&relay_url);
2288    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2289    if as_json {
2290        println!("{}", serde_json::to_string(&saved)?);
2291    } else {
2292        let reason = saved
2293            .get("reason")
2294            .and_then(Value::as_str)
2295            .map(|r| format!(" — {r}"))
2296            .unwrap_or_default();
2297        println!(
2298            "responder {}{}",
2299            saved
2300                .get("status")
2301                .and_then(Value::as_str)
2302                .unwrap_or(status),
2303            reason
2304        );
2305    }
2306    Ok(())
2307}
2308
2309fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2310    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2311    let client = crate::relay_client::RelayClient::new(&relay_url);
2312    let health = client.responder_health_get(&slot_id, &slot_token)?;
2313    if as_json {
2314        println!(
2315            "{}",
2316            serde_json::to_string(&json!({
2317                "target": label,
2318                "responder_health": health,
2319            }))?
2320        );
2321    } else if health.is_null() {
2322        println!("{label}: responder health not reported");
2323    } else {
2324        let status = health
2325            .get("status")
2326            .and_then(Value::as_str)
2327            .unwrap_or("unknown");
2328        let reason = health
2329            .get("reason")
2330            .and_then(Value::as_str)
2331            .map(|r| format!(" — {r}"))
2332            .unwrap_or_default();
2333        let last_success = health
2334            .get("last_success_at")
2335            .and_then(Value::as_str)
2336            .map(|t| format!(" (last_success: {t})"))
2337            .unwrap_or_default();
2338        println!("{label}: {status}{reason}{last_success}");
2339    }
2340    Ok(())
2341}
2342
2343fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2344    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2345    let client = crate::relay_client::RelayClient::new(&relay_url);
2346
2347    let started = std::time::Instant::now();
2348    let transport_ok = client.healthz().unwrap_or(false);
2349    let latency_ms = started.elapsed().as_millis() as u64;
2350
2351    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2352    let now = std::time::SystemTime::now()
2353        .duration_since(std::time::UNIX_EPOCH)
2354        .map(|d| d.as_secs())
2355        .unwrap_or(0);
2356    let attention = match last_pull_at_unix {
2357        Some(last) if now.saturating_sub(last) <= 300 => json!({
2358            "status": "ok",
2359            "last_pull_at_unix": last,
2360            "age_seconds": now.saturating_sub(last),
2361            "event_count": event_count,
2362        }),
2363        Some(last) => json!({
2364            "status": "stale",
2365            "last_pull_at_unix": last,
2366            "age_seconds": now.saturating_sub(last),
2367            "event_count": event_count,
2368        }),
2369        None => json!({
2370            "status": "never_pulled",
2371            "last_pull_at_unix": Value::Null,
2372            "event_count": event_count,
2373        }),
2374    };
2375
2376    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2377    let responder = if responder_health.is_null() {
2378        json!({"status": "not_reported", "record": Value::Null})
2379    } else {
2380        json!({
2381            "status": responder_health
2382                .get("status")
2383                .and_then(Value::as_str)
2384                .unwrap_or("unknown"),
2385            "record": responder_health,
2386        })
2387    };
2388
2389    let report = json!({
2390        "peer": peer,
2391        "transport": {
2392            "status": if transport_ok { "ok" } else { "error" },
2393            "relay_url": relay_url,
2394            "latency_ms": latency_ms,
2395        },
2396        "attention": attention,
2397        "responder": responder,
2398    });
2399
2400    if as_json {
2401        println!("{}", serde_json::to_string(&report)?);
2402    } else {
2403        let transport_line = if transport_ok {
2404            format!("ok relay reachable ({latency_ms}ms)")
2405        } else {
2406            "error relay unreachable".to_string()
2407        };
2408        println!("transport      {transport_line}");
2409        match report["attention"]["status"].as_str().unwrap_or("unknown") {
2410            "ok" => println!(
2411                "attention      ok last pull {}s ago",
2412                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2413            ),
2414            "stale" => println!(
2415                "attention      stale last pull {}m ago",
2416                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2417            ),
2418            "never_pulled" => println!("attention      never pulled since relay reset"),
2419            other => println!("attention      {other}"),
2420        }
2421        if report["responder"]["status"] == "not_reported" {
2422            println!("auto-responder not reported");
2423        } else {
2424            let record = &report["responder"]["record"];
2425            let status = record
2426                .get("status")
2427                .and_then(Value::as_str)
2428                .unwrap_or("unknown");
2429            let reason = record
2430                .get("reason")
2431                .and_then(Value::as_str)
2432                .map(|r| format!(" — {r}"))
2433                .unwrap_or_default();
2434            println!("auto-responder {status}{reason}");
2435        }
2436    }
2437    Ok(())
2438}
2439
2440// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
2441
2442// ---------- whoami ----------
2443
2444/// Return the current cwd with the user's home dir abbreviated to `~/`.
2445/// Used in whoami `--short` / `--colored` output so multi-window operators
2446/// see *what project* each Claude is working in alongside the character.
2447fn current_cwd_display() -> String {
2448    let cwd = match std::env::current_dir() {
2449        Ok(c) => c,
2450        Err(_) => return String::from("?"),
2451    };
2452    if let Some(home) = dirs::home_dir()
2453        && let Ok(rel) = cwd.strip_prefix(&home)
2454    {
2455        // strip_prefix returns "" for cwd == home itself; show "~" then.
2456        let rel_str = rel.to_string_lossy();
2457        if rel_str.is_empty() {
2458            return String::from("~");
2459        }
2460        return format!("~/{}", rel_str);
2461    }
2462    cwd.to_string_lossy().into_owned()
2463}
2464
2465fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2466    if !config::is_initialized()? {
2467        bail!("not initialized — run `wire init <handle>` first");
2468    }
2469    let card = config::read_agent_card()?;
2470    let did = card
2471        .get("did")
2472        .and_then(Value::as_str)
2473        .unwrap_or("")
2474        .to_string();
2475    let handle = card
2476        .get("handle")
2477        .and_then(Value::as_str)
2478        .map(str::to_string)
2479        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2480    // v0.11: character is purely DID-derived. No overrides — the
2481    // operator-rename verb is gone and display.json reads are stripped
2482    // because they introduced a second name that peers couldn't find.
2483    let character = crate::character::Character::from_did(&did);
2484
2485    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
2486    // so operators tab-flipping between multiple Claude windows see both
2487    // *who* this session is (character) and *what* it's working on (cwd).
2488    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
2489    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
2490    let cwd_display = current_cwd_display();
2491
2492    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
2493    // beyond did — these calls are hot (statusline polls ~300ms).
2494    if short {
2495        println!("{} · {}", character.short(), cwd_display);
2496        return Ok(());
2497    }
2498    if colored {
2499        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2500        return Ok(());
2501    }
2502
2503    let pk_b64 = card
2504        .get("verify_keys")
2505        .and_then(Value::as_object)
2506        .and_then(|m| m.values().next())
2507        .and_then(|v| v.get("key"))
2508        .and_then(Value::as_str)
2509        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2510    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2511    let fp = fingerprint(&pk_bytes);
2512    let key_id = make_key_id(&handle, &pk_bytes);
2513    let capabilities = card
2514        .get("capabilities")
2515        .cloned()
2516        .unwrap_or_else(|| json!(["wire/v3.1"]));
2517
2518    if as_json {
2519        // v0.11: character_override is always false now (no rename verb,
2520        // no display.json reads). Field stays for back-compat with v0.10
2521        // JSON consumers that key off it.
2522        let has_override = false;
2523        println!(
2524            "{}",
2525            serde_json::to_string(&json!({
2526                "did": did,
2527                "handle": handle,
2528                "fingerprint": fp,
2529                "key_id": key_id,
2530                "public_key_b64": pk_b64,
2531                "capabilities": capabilities,
2532                "config_dir": config::config_dir()?.to_string_lossy(),
2533                "persona": character,
2534                "persona_override": has_override,
2535            }))?
2536        );
2537    } else {
2538        println!("{}", character.colored());
2539        println!("{did} (ed25519:{key_id})");
2540        println!("fingerprint: {fp}");
2541        println!("capabilities: {capabilities}");
2542    }
2543    Ok(())
2544}
2545
2546// ---------- identity (v0.7.0-alpha.3) ----------
2547
2548fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2549    match cmd {
2550        // v0.11: IdentityCommand::Rename deleted. The character is the
2551        // one canonical name (DID-derived); a local-display rename
2552        // would create a second name peers can't find, violating the
2553        // "names must be findable" invariant. Aliases (if needed
2554        // later) become relay-claimed entries that ARE findable —
2555        // a different architectural shape from rename.
2556        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2557        IdentityCommand::List { json } => cmd_session_list(json),
2558        IdentityCommand::Publish {
2559            nick,
2560            relay,
2561            public_url,
2562            hidden,
2563            json,
2564        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2565        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2566        IdentityCommand::Create {
2567            name,
2568            anonymous,
2569            local: _,
2570            json,
2571        } => cmd_identity_create(name.as_deref(), anonymous, json),
2572        IdentityCommand::Persist {
2573            name,
2574            as_name,
2575            json,
2576        } => cmd_identity_persist(&name, as_name.as_deref(), json),
2577        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2578    }
2579}
2580
2581/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
2582/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
2583/// paste into their shell; the identity lives there until reboot
2584/// clears /tmp. Persist promotes it to the real sessions root.
2585fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2586    if anonymous {
2587        // Generate a unique tmpdir for this anonymous identity.
2588        let rand_suffix = format!("{:08x}", rand::random::<u32>());
2589        let anon_name = name
2590            .map(crate::session::sanitize_name)
2591            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2592        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2593        std::fs::create_dir_all(&anon_root)
2594            .with_context(|| format!("creating anon root {anon_root:?}"))?;
2595        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
2596        let session_home = anon_root.join("sessions").join(&anon_name);
2597        std::fs::create_dir_all(&session_home)?;
2598        let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2599        if !status.success() {
2600            bail!("anonymous identity init failed: {status}");
2601        }
2602        // Register the anonymous name in a SIDE registry so persist
2603        // can find it later. Stored at <anon_root>/anon-marker.json.
2604        let marker = anon_root.join("anon-marker.json");
2605        std::fs::write(
2606            &marker,
2607            serde_json::to_vec_pretty(&serde_json::json!({
2608                "name": anon_name,
2609                "session_home": session_home.to_string_lossy(),
2610                "created_at": time::OffsetDateTime::now_utc()
2611                    .format(&time::format_description::well_known::Rfc3339)
2612                    .unwrap_or_default(),
2613                "kind": "anonymous",
2614            }))?,
2615        )?;
2616        let card = serde_json::from_slice::<Value>(&std::fs::read(
2617            session_home
2618                .join("config")
2619                .join("wire")
2620                .join("agent-card.json"),
2621        )?)?;
2622        let did = card
2623            .get("did")
2624            .and_then(Value::as_str)
2625            .unwrap_or("")
2626            .to_string();
2627        if as_json {
2628            println!(
2629                "{}",
2630                serde_json::to_string(&json!({
2631                    "kind": "anonymous",
2632                    "name": anon_name,
2633                    "did": did,
2634                    "session_home": session_home.to_string_lossy(),
2635                    "anon_root": anon_root.to_string_lossy(),
2636                }))?
2637            );
2638        } else {
2639            println!("created anonymous identity `{anon_name}` ({did})");
2640            println!(
2641                "  session_home: {} (dies on reboot — /tmp)",
2642                session_home.display()
2643            );
2644            println!();
2645            println!("activate in this shell:");
2646            println!("  export WIRE_HOME={}", session_home.display());
2647            println!();
2648            println!("promote to persistent later with:");
2649            println!("  wire identity persist {anon_name}");
2650        }
2651        return Ok(());
2652    }
2653    // --local (or default): delegate to existing session new flow.
2654    let name_arg = name.map(|s| s.to_string());
2655    cmd_session_new(
2656        name_arg.as_deref(),
2657        "https://wireup.net",
2658        false,
2659        "http://127.0.0.1:8771",
2660        false,
2661        None,
2662        false,
2663        None,
2664        true, // no_daemon: identity create just allocates the identity, no daemon
2665        true, // local_only: explicit lifecycle
2666        as_json,
2667    )
2668}
2669
2670/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
2671/// tmpdir to the persistent sessions root + registers in the cwd map.
2672fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2673    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
2674    let temp = std::env::temp_dir();
2675    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2676    for entry in std::fs::read_dir(&temp)?.flatten() {
2677        let path = entry.path();
2678        if !path
2679            .file_name()
2680            .and_then(|s| s.to_str())
2681            .map(|s| s.starts_with("wire-anon-"))
2682            .unwrap_or(false)
2683        {
2684            continue;
2685        }
2686        let marker = path.join("anon-marker.json");
2687        if let Ok(bytes) = std::fs::read(&marker)
2688            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2689            && json.get("name").and_then(Value::as_str) == Some(name)
2690        {
2691            let session_home = json
2692                .get("session_home")
2693                .and_then(Value::as_str)
2694                .map(std::path::PathBuf::from)
2695                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2696            found = Some((path, session_home));
2697            break;
2698        }
2699    }
2700    let (anon_root, anon_session_home) = found.ok_or_else(|| {
2701        anyhow!(
2702            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2703             run `wire identity list` to see available identities"
2704        )
2705    })?;
2706
2707    let new_name = as_name.unwrap_or(name);
2708    let new_session_home = crate::session::session_dir(new_name)?;
2709    if new_session_home.exists() {
2710        bail!(
2711            "target session `{new_name}` already exists at {new_session_home:?} — \
2712             pick a different name with --as <new-name>"
2713        );
2714    }
2715
2716    // Move the session dir from tmpdir to persistent root.
2717    if let Some(parent) = new_session_home.parent() {
2718        std::fs::create_dir_all(parent)?;
2719    }
2720    std::fs::rename(&anon_session_home, &new_session_home)
2721        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2722
2723    // Clean up the (now-empty) anon root + marker.
2724    let _ = std::fs::remove_dir_all(&anon_root);
2725
2726    // Register cwd → new_name (operator may have cd'd elsewhere; use the
2727    // session_home's grandparent as the conceptual "cwd" if no other).
2728    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2729    let cwd_key = cwd.to_string_lossy().into_owned();
2730    let new_name_for_reg = new_name.to_string();
2731    if let Err(e) = crate::session::update_registry(|reg| {
2732        reg.by_cwd.insert(cwd_key, new_name_for_reg);
2733        Ok(())
2734    }) {
2735        eprintln!("wire identity persist: failed to update registry: {e:#}");
2736    }
2737
2738    if as_json {
2739        println!(
2740            "{}",
2741            serde_json::to_string(&json!({
2742                "kind": "persisted",
2743                "from_name": name,
2744                "to_name": new_name,
2745                "session_home": new_session_home.to_string_lossy(),
2746            }))?
2747        );
2748    } else {
2749        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2750        println!(
2751            "  session_home: {} (survives reboot)",
2752            new_session_home.display()
2753        );
2754        println!("  registered cwd: {}", cwd.display());
2755    }
2756    Ok(())
2757}
2758
2759/// v0.7.0-alpha.20: demote federation → local. Removes the federation
2760/// slot binding from relay.json (and the legacy top-level fields). Keeps
2761/// the keypair + agent-card so re-publish later just calls `wire identity
2762/// publish` again. local → anonymous is NOT supported; destroy + recreate
2763/// is the safer path for that step-down.
2764fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2765    let sessions = crate::session::list_sessions()?;
2766    let session = sessions
2767        .iter()
2768        .find(|s| s.name == name)
2769        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2770    let relay_state_path = session
2771        .home_dir
2772        .join("config")
2773        .join("wire")
2774        .join("relay.json");
2775    if !relay_state_path.exists() {
2776        bail!("session `{name}` has no relay state — already demoted?");
2777    }
2778    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2779    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2780    let had_fed = self_obj
2781        .get("relay_url")
2782        .and_then(Value::as_str)
2783        .map(|u| {
2784            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2785        })
2786        .unwrap_or(false);
2787    if !had_fed {
2788        if as_json {
2789            println!(
2790                "{}",
2791                serde_json::to_string(
2792                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2793                )?
2794            );
2795        } else {
2796            println!("session `{name}` has no federation slot — nothing to demote");
2797        }
2798        return Ok(());
2799    }
2800    // Strip federation: remove top-level relay_url/slot_id/slot_token,
2801    // remove federation-scope entries from endpoints[].
2802    if let Some(self_mut) = state
2803        .as_object_mut()
2804        .and_then(|m| m.get_mut("self"))
2805        .and_then(|s| s.as_object_mut())
2806    {
2807        self_mut.remove("relay_url");
2808        self_mut.remove("slot_id");
2809        self_mut.remove("slot_token");
2810        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2811            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2812        }
2813    }
2814    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2815
2816    if as_json {
2817        println!(
2818            "{}",
2819            serde_json::to_string(
2820                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2821            )?
2822        );
2823    } else {
2824        println!("demoted `{name}` from federation → local");
2825        println!("  relay slot binding removed; keypair + agent-card retained");
2826        println!("  re-publish with `wire identity publish <nick>`");
2827    }
2828    Ok(())
2829}
2830
2831fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2832    let raw = crate::trust::get_tier(trust, handle);
2833    if raw != "VERIFIED" {
2834        return raw.to_string();
2835    }
2836    let token = relay_state
2837        .get("peers")
2838        .and_then(|p| p.get(handle))
2839        .and_then(|p| p.get("slot_token"))
2840        .and_then(Value::as_str)
2841        .unwrap_or("");
2842    if token.is_empty() {
2843        "PENDING_ACK".to_string()
2844    } else {
2845        raw.to_string()
2846    }
2847}
2848
2849fn cmd_peers(as_json: bool) -> Result<()> {
2850    let trust = config::read_trust()?;
2851    let agents = trust
2852        .get("agents")
2853        .and_then(Value::as_object)
2854        .cloned()
2855        .unwrap_or_default();
2856    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2857
2858    let mut self_did: Option<String> = None;
2859    if let Ok(card) = config::read_agent_card() {
2860        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2861    }
2862
2863    let mut peers = Vec::new();
2864    for (handle, agent) in agents.iter() {
2865        let did = agent
2866            .get("did")
2867            .and_then(Value::as_str)
2868            .unwrap_or("")
2869            .to_string();
2870        if Some(did.as_str()) == self_did.as_deref() {
2871            continue; // skip self-attestation
2872        }
2873        let tier = effective_peer_tier(&trust, &relay_state, handle);
2874        let capabilities = agent
2875            .get("card")
2876            .and_then(|c| c.get("capabilities"))
2877            .cloned()
2878            .unwrap_or_else(|| json!([]));
2879        // v0.7.0-alpha.6: prefer peer's published character override
2880        // (display.nickname / display.emoji on their pinned agent-card).
2881        // Falls back to auto-derived if peer hasn't renamed themselves
2882        // OR runs an older wire that doesn't publish the field.
2883        let character = if did.is_empty() {
2884            None
2885        } else {
2886            let card_obj = agent.get("card");
2887            Some(match card_obj {
2888                Some(card) => crate::character::Character::from_card(card),
2889                None => crate::character::Character::from_did(&did),
2890            })
2891        };
2892        peers.push(json!({
2893            "handle": handle,
2894            "did": did,
2895            "tier": tier,
2896            "capabilities": capabilities,
2897            "persona": character,
2898        }));
2899    }
2900
2901    if as_json {
2902        println!("{}", serde_json::to_string(&peers)?);
2903    } else if peers.is_empty() {
2904        println!("no peers pinned (run `wire join <code>` to pair)");
2905    } else {
2906        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
2907        // computed above (from peer's agent-card, honoring override) so
2908        // text and JSON output never diverge. Pre-alpha.8 the text loop
2909        // recomputed via Character::from_did (no override) — operators
2910        // saw different identities depending on --json flag.
2911        for p in &peers {
2912            let char_json = &p["persona"];
2913            let (colored_char, plain_len): (String, usize) = match char_json {
2914                serde_json::Value::Null => ("?".to_string(), 1),
2915                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2916                    Ok(c) => {
2917                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
2918                        (c.colored(), plain)
2919                    }
2920                    Err(_) => ("?".to_string(), 1),
2921                },
2922            };
2923            let pad = 22usize.saturating_sub(plain_len);
2924            println!(
2925                "{}{}  {:<20} {:<10} {}",
2926                colored_char,
2927                " ".repeat(pad),
2928                p["handle"].as_str().unwrap_or(""),
2929                p["tier"].as_str().unwrap_or(""),
2930                p["did"].as_str().unwrap_or(""),
2931            );
2932        }
2933    }
2934    Ok(())
2935}
2936
2937// ---------- send ----------
2938
2939/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
2940///
2941/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
2942/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
2943/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
2944/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
2945/// hasn't crossed two heartbeats means probably degraded.
2946fn maybe_warn_peer_attentiveness(peer: &str) {
2947    let state = match config::read_relay_state() {
2948        Ok(s) => s,
2949        Err(_) => return,
2950    };
2951    let p = state.get("peers").and_then(|p| p.get(peer));
2952    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2953        Some(s) if !s.is_empty() => s,
2954        _ => return,
2955    };
2956    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2957        Some(s) if !s.is_empty() => s,
2958        _ => return,
2959    };
2960    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
2961        Some(s) if !s.is_empty() => s.to_string(),
2962        _ => match state
2963            .get("self")
2964            .and_then(|s| s.get("relay_url"))
2965            .and_then(Value::as_str)
2966        {
2967            Some(s) if !s.is_empty() => s.to_string(),
2968            _ => return,
2969        },
2970    };
2971    let client = crate::relay_client::RelayClient::new(&relay_url);
2972    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
2973        Ok(t) => t,
2974        Err(_) => return,
2975    };
2976    let now = std::time::SystemTime::now()
2977        .duration_since(std::time::UNIX_EPOCH)
2978        .map(|d| d.as_secs())
2979        .unwrap_or(0);
2980    match last_pull {
2981        None => {
2982            eprintln!(
2983                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
2984            );
2985        }
2986        Some(t) if now.saturating_sub(t) > 300 => {
2987            let mins = now.saturating_sub(t) / 60;
2988            eprintln!(
2989                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
2990            );
2991        }
2992        _ => {}
2993    }
2994}
2995
2996pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
2997    let trimmed = input.trim();
2998    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
2999    {
3000        return Ok(trimmed.to_string());
3001    }
3002    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3003    let n: i64 = amount
3004        .parse()
3005        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3006    if n <= 0 {
3007        bail!("deadline duration must be positive: {input:?}");
3008    }
3009    let duration = match unit {
3010        "m" => time::Duration::minutes(n),
3011        "h" => time::Duration::hours(n),
3012        "d" => time::Duration::days(n),
3013        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3014    };
3015    Ok((time::OffsetDateTime::now_utc() + duration)
3016        .format(&time::format_description::well_known::Rfc3339)
3017        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3018}
3019
3020fn cmd_send(
3021    peer: &str,
3022    kind: &str,
3023    body_arg: &str,
3024    deadline: Option<&str>,
3025    // v0.10: when true, refuse to auto-pair on miss; fail loudly so
3026    // scripts can branch on the error instead of accepting an implicit
3027    // side effect.
3028    no_auto_pair: bool,
3029    as_json: bool,
3030) -> Result<()> {
3031    if !config::is_initialized()? {
3032        bail!("not initialized — run `wire init <handle>` first");
3033    }
3034    let peer_in = crate::agent_card::bare_handle(peer).to_string();
3035    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
3036    // match wins; nickname (DID-hash auto-derived) is the fallback.
3037    // Ambiguous nicknames (two pinned peers DID-hash to the same
3038    // adj-noun pair) fail loudly with disambiguation; unknown handles
3039    // pass through (matches existing `wire send` semantics — queue
3040    // first, deliver best-effort).
3041    let peer = match resolve_peer_handle(&peer_in) {
3042        Ok(Some(resolved)) if resolved != peer_in => {
3043            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3044            resolved
3045        }
3046        Ok(Some(canonical)) => canonical, // exact handle match
3047        Ok(None) => peer_in,              // unknown — pass through, downstream errors
3048        Err(ResolveError::Ambiguous(candidates)) => bail!(
3049            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3050             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3051            candidates.len(),
3052            candidates.join(", ")
3053        ),
3054        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
3055    };
3056
3057    // v0.9 auto-pair-on-miss: if the resolved peer isn't pinned yet but
3058    // matches a local sister session, pair first (disk-read --local-sister
3059    // path) then continue. Closes the "wire send returns queued but
3060    // peer never receives because we were never paired" silent-fail
3061    // class. Equivalent to `wire dial <name>` followed by `wire send
3062    // <name> ...` in one step.
3063    let peer_is_pinned = config::read_relay_state()
3064        .ok()
3065        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3066        .map(|peers| peers.contains_key(&peer))
3067        .unwrap_or(false);
3068    if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3069        if no_auto_pair {
3070            bail!(
3071                "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3072                 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3073                 then re-run send."
3074            );
3075        }
3076        eprintln!(
3077            "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3078             Pass --no-auto-pair to refuse implicit dialing."
3079        );
3080        cmd_add_local_sister(&sister_name, true).map_err(|e| {
3081            anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3082        })?;
3083    }
3084
3085    let peer = peer.as_str();
3086    let sk_seed = config::read_private_key()?;
3087    let card = config::read_agent_card()?;
3088    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3089    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3090    let pk_b64 = card
3091        .get("verify_keys")
3092        .and_then(Value::as_object)
3093        .and_then(|m| m.values().next())
3094        .and_then(|v| v.get("key"))
3095        .and_then(Value::as_str)
3096        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3097    let pk_bytes = crate::signing::b64decode(pk_b64)?;
3098
3099    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
3100    // P0.S (0.5.11): stdin support lets shells pipe in long content
3101    // without quoting/escaping ceremony, and supports heredocs naturally:
3102    //   wire send peer - <<EOF ... EOF
3103    let body_value: Value = if body_arg == "-" {
3104        use std::io::Read;
3105        let mut raw = String::new();
3106        std::io::stdin()
3107            .read_to_string(&mut raw)
3108            .with_context(|| "reading body from stdin")?;
3109        // Try parsing as JSON first; fall back to string literal for
3110        // plain-text bodies.
3111        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3112    } else if let Some(path) = body_arg.strip_prefix('@') {
3113        let raw =
3114            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3115        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3116    } else {
3117        Value::String(body_arg.to_string())
3118    };
3119
3120    let kind_id = parse_kind(kind)?;
3121
3122    let now = time::OffsetDateTime::now_utc()
3123        .format(&time::format_description::well_known::Rfc3339)
3124        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3125
3126    let mut event = json!({
3127        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3128        "timestamp": now,
3129        "from": did,
3130        "to": format!("did:wire:{peer}"),
3131        "type": kind,
3132        "kind": kind_id,
3133        "body": body_value,
3134    });
3135    if let Some(deadline) = deadline {
3136        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3137    }
3138    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3139    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3140
3141    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
3142    // coords in relay-state and ask the relay how recently the peer pulled.
3143    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
3144    // Never blocks the send — the event still queues to outbox.
3145    maybe_warn_peer_attentiveness(peer);
3146
3147    // For now we append to outbox JSONL and rely on a future daemon to push
3148    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
3149    // Append goes through `config::append_outbox_record` which holds a per-
3150    // path mutex so concurrent senders cannot interleave bytes mid-line.
3151    let line = serde_json::to_vec(&signed)?;
3152    let outbox = config::append_outbox_record(peer, &line)?;
3153
3154    if as_json {
3155        println!(
3156            "{}",
3157            serde_json::to_string(&json!({
3158                "event_id": event_id,
3159                "status": "queued",
3160                "peer": peer,
3161                "outbox": outbox.to_string_lossy(),
3162            }))?
3163        );
3164    } else {
3165        println!(
3166            "queued event {event_id} → {peer} (outbox: {})",
3167            outbox.display()
3168        );
3169    }
3170    Ok(())
3171}
3172
3173fn parse_kind(s: &str) -> Result<u32> {
3174    if let Ok(n) = s.parse::<u32>() {
3175        return Ok(n);
3176    }
3177    for (id, name) in crate::signing::kinds() {
3178        if *name == s {
3179            return Ok(*id);
3180        }
3181    }
3182    // Unknown name — default to kind 1 (decision) for v0.1.
3183    Ok(1)
3184}
3185
3186// ---------- here (v0.9.3 you-are-here view) ----------
3187
3188/// `wire here` — one-screen "you are this session, your neighbors are
3189/// these." Combines what `wire whoami`, `wire peers`, and `wire session
3190/// list-local` would otherwise force the operator to call separately.
3191fn cmd_here(as_json: bool) -> Result<()> {
3192    let initialized = config::is_initialized().unwrap_or(false);
3193
3194    // Self identity.
3195    let (self_did, self_handle, self_character) = if initialized {
3196        let card = config::read_agent_card().ok();
3197        let did = card
3198            .as_ref()
3199            .and_then(|c| c.get("did").and_then(Value::as_str))
3200            .unwrap_or("")
3201            .to_string();
3202        let handle = if did.is_empty() {
3203            String::new()
3204        } else {
3205            crate::agent_card::display_handle_from_did(&did).to_string()
3206        };
3207        let character = if did.is_empty() {
3208            None
3209        } else {
3210            // v0.11: DID-derived only. No display.json overrides.
3211            Some(crate::character::Character::from_did(&did))
3212        };
3213        (did, handle, character)
3214    } else {
3215        (String::new(), String::new(), None)
3216    };
3217
3218    let cwd = std::env::current_dir()
3219        .map(|p| p.to_string_lossy().into_owned())
3220        .unwrap_or_default();
3221    let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3222
3223    // Sister sessions (same-machine).
3224    let mut sisters: Vec<Value> = Vec::new();
3225    if let Ok(listing) = crate::session::list_local_sessions() {
3226        for group in listing.local.values() {
3227            for s in group {
3228                if s.handle.as_deref() == Some(self_handle.as_str()) {
3229                    continue; // skip self
3230                }
3231                let ch = s.did.as_deref().map(crate::character::Character::from_did);
3232                sisters.push(json!({
3233                    "session": s.name,
3234                    "handle": s.handle,
3235                    "persona": ch,
3236                }));
3237            }
3238        }
3239    }
3240
3241    // Pinned peers (trust ring agents).
3242    let mut peers: Vec<Value> = Vec::new();
3243    if initialized
3244        && let Ok(trust) = config::read_trust()
3245        && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3246    {
3247        for (handle, agent) in agents {
3248            if handle == &self_handle {
3249                continue; // skip self
3250            }
3251            let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3252            let ch = if did.is_empty() {
3253                None
3254            } else {
3255                Some(crate::character::Character::from_did(did))
3256            };
3257            peers.push(json!({
3258                "handle": handle,
3259                "did": did,
3260                "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3261                "persona": ch,
3262            }));
3263        }
3264    }
3265
3266    if as_json {
3267        println!(
3268            "{}",
3269            serde_json::to_string(&json!({
3270                "self": {
3271                    "handle": self_handle,
3272                    "did": self_did,
3273                    "persona": self_character,
3274                    "cwd": cwd,
3275                    "wire_home": wire_home,
3276                },
3277                "sister_sessions": sisters,
3278                "pinned_peers": peers,
3279            }))?
3280        );
3281        return Ok(());
3282    }
3283
3284    // Human format.
3285    if !initialized {
3286        println!("not initialized — run `wire init <handle>` to bootstrap.");
3287        return Ok(());
3288    }
3289    let glyph = self_character
3290        .as_ref()
3291        .map(crate::character::emoji_with_fallback)
3292        .unwrap_or_else(|| "?".to_string());
3293    let nick = self_character
3294        .as_ref()
3295        .map(|c| c.nickname.clone())
3296        .unwrap_or_default();
3297    println!("you are {glyph} {nick}  ({self_handle})");
3298    if !cwd.is_empty() {
3299        println!("  cwd:    {cwd}");
3300    }
3301    // Helper closure that mirrors emoji_with_fallback over a JSON-encoded
3302    // character object (because we already collected sisters/peers into
3303    // Value rows above). Looks up the canonical emoji-name and falls
3304    // back to that — never repeats the nickname inside the brackets.
3305    let render_glyph = |character: &Value| -> String {
3306        let emoji = character
3307            .get("emoji")
3308            .and_then(Value::as_str)
3309            .unwrap_or("?");
3310        let nickname = character
3311            .get("nickname")
3312            .and_then(Value::as_str)
3313            .unwrap_or("?");
3314        if crate::character::terminal_supports_emoji() {
3315            return emoji.to_string();
3316        }
3317        // Synthesize a minimal Character so emoji_with_fallback's
3318        // lookup table picks the right ASCII tag.
3319        let synth = crate::character::Character {
3320            nickname: nickname.to_string(),
3321            emoji: emoji.to_string(),
3322            palette: crate::character::Palette {
3323                primary_hex: String::new(),
3324                accent_hex: String::new(),
3325                ansi256_primary: 0,
3326                ansi256_accent: 0,
3327            },
3328        };
3329        crate::character::emoji_with_fallback(&synth)
3330    };
3331    if !sisters.is_empty() {
3332        println!();
3333        println!("sister sessions on this machine:");
3334        for s in &sisters {
3335            let session = s["session"].as_str().unwrap_or("?");
3336            let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3337            let glyph = render_glyph(&s["persona"]);
3338            println!("  {glyph} {ch_nick}  ({session})");
3339        }
3340    }
3341    if !peers.is_empty() {
3342        println!();
3343        println!("pinned peers:");
3344        for p in &peers {
3345            let handle = p["handle"].as_str().unwrap_or("?");
3346            let tier = p["tier"].as_str().unwrap_or("");
3347            let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3348            let glyph = render_glyph(&p["persona"]);
3349            println!("  {glyph} {ch_nick}  ({handle})  [{tier}]");
3350        }
3351    }
3352    if sisters.is_empty() && peers.is_empty() {
3353        println!();
3354        println!(
3355            "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3356        );
3357    }
3358    Ok(())
3359}
3360
3361// ---------- dial / whois (v0.8 canonical addressing) ----------
3362
3363/// `wire dial <name> [message]` — the one verb operators reach for.
3364/// Resolves any name (nickname/handle/session/DID) to a peer and
3365/// drives the right pair flow + optional first message. See the
3366/// `Command::Dial` doc for the resolution ladder.
3367///
3368/// v0.9: when `name` contains `@<relay>`, route through the federation
3369/// `wire add <handle>@<relay>` path (`.well-known/wire/agent` resolution
3370/// plus cross-machine pair_drop). No more bail with "federation isn't
3371/// implemented yet" — one verb across both orbits.
3372fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3373    if name.contains('@') {
3374        // Federation path. cmd_add already auto-detects (per v0.7.4)
3375        // when input has `@` and routes through the .well-known
3376        // resolver + pair_drop deposit. After it returns, the peer
3377        // is in pending-outbound; bilateral completes when the peer
3378        // accepts. Optionally send the first message after the add.
3379        cmd_add(name, None, false, true)
3380            .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3381        if let Some(msg) = message {
3382            // Peer handle for send = the nick part before the `@`.
3383            let bare = name.split('@').next().unwrap_or(name);
3384            cmd_send(bare, "claim", msg, None, false, as_json)?;
3385        }
3386        return Ok(());
3387    }
3388
3389    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3390    // success with `{found: false, candidates: [...]}` instead of
3391    // erroring. Agents can branch on `found` without wrapping in a
3392    // try/catch.
3393    let resolution = match resolve_name_to_target(name) {
3394        Ok(r) => r,
3395        Err(e) if as_json => {
3396            let pool = known_local_names();
3397            let suggestions = closest_candidates(name, &pool, 3, 3);
3398            println!(
3399                "{}",
3400                serde_json::to_string(&json!({
3401                    "name_input": name,
3402                    "found": false,
3403                    "candidates": suggestions,
3404                    "error": format!("{e:#}"),
3405                }))?
3406            );
3407            return Ok(());
3408        }
3409        Err(e) => return Err(e),
3410    };
3411    let mut steps: Vec<Value> = Vec::new();
3412
3413    match &resolution {
3414        DialTarget::PinnedPeer { handle, .. } => {
3415            steps.push(json!({
3416                "step": "resolved",
3417                "kind": "already_pinned",
3418                "handle": handle,
3419            }));
3420        }
3421        DialTarget::LocalSister { session_name, .. } => {
3422            steps.push(json!({
3423                "step": "resolved",
3424                "kind": "local_sister",
3425                "session": session_name,
3426            }));
3427            // Drive the bilateral pair via the disk-read sister path.
3428            // cmd_add_local_sister already handles "already paired"
3429            // gracefully (its internal state.peers check returns the
3430            // existing pin instead of re-issuing a pair_drop), so
3431            // re-dialling is idempotent.
3432            cmd_add_local_sister(session_name, true).map_err(|e| {
3433                anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3434            })?;
3435            steps.push(json!({
3436                "step": "paired",
3437                "via": "local_sister",
3438            }));
3439        }
3440    }
3441
3442    let send_handle = match &resolution {
3443        DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3444        DialTarget::LocalSister { handle, .. } => handle.clone(),
3445    };
3446
3447    let send_result = if let Some(msg) = message {
3448        let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3449        match &r {
3450            Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3451            Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3452        }
3453        Some(r)
3454    } else {
3455        None
3456    };
3457
3458    if as_json {
3459        println!(
3460            "{}",
3461            serde_json::to_string(&json!({
3462                "name_input": name,
3463                "resolved_handle": send_handle,
3464                "steps": steps,
3465            }))?
3466        );
3467    } else {
3468        println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3469        for s in &steps {
3470            let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3471            println!("  - {step}");
3472        }
3473        if message.is_some() {
3474            println!("  (use `wire tail {send_handle}` to read replies)");
3475        }
3476    }
3477    if let Some(Err(e)) = send_result {
3478        return Err(e);
3479    }
3480    Ok(())
3481}
3482
3483/// `wire whois <name>` — resolve any local name (nickname/session/
3484/// handle/DID) to the full identity row. The inspector for the
3485/// canonical addressing layer. For federation `handle@relay-domain`
3486/// resolution see `cmd_whois` (line 5536+) — the dispatcher chooses
3487/// based on whether the input contains `@`.
3488fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3489    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3490    // success (exit 0) with `{found: false, candidates: [...]}` so
3491    // agents don't need try/catch around `wire whois <name>`. In
3492    // human mode, the bail's did-you-mean line points at the
3493    // closest candidate.
3494    let resolution = match resolve_name_to_target(name) {
3495        Ok(r) => r,
3496        Err(e) if as_json => {
3497            let pool = known_local_names();
3498            let suggestions = closest_candidates(name, &pool, 3, 3);
3499            println!(
3500                "{}",
3501                serde_json::to_string(&json!({
3502                    "name_input": name,
3503                    "found": false,
3504                    "candidates": suggestions,
3505                    "error": format!("{e:#}"),
3506                }))?
3507            );
3508            return Ok(());
3509        }
3510        Err(e) => return Err(e),
3511    };
3512    match resolution {
3513        DialTarget::PinnedPeer {
3514            handle,
3515            did,
3516            nickname,
3517            emoji,
3518            tier,
3519        } => {
3520            if as_json {
3521                println!(
3522                    "{}",
3523                    serde_json::to_string(&json!({
3524                        "kind": "pinned_peer",
3525                        "handle": handle,
3526                        "did": did,
3527                        "nickname": nickname,
3528                        "emoji": emoji,
3529                        "tier": tier,
3530                    }))?
3531                );
3532            } else {
3533                let n = nickname.as_deref().unwrap_or("(no character)");
3534                let e = emoji.as_deref().unwrap_or("?");
3535                println!("{e} {n}");
3536                println!("  handle:   {handle}");
3537                println!("  did:      {did}");
3538                println!("  tier:     {tier}");
3539                println!("  reach:    pinned peer (already in trust ring + slot pinned)");
3540            }
3541        }
3542        DialTarget::LocalSister {
3543            session_name,
3544            handle,
3545            did,
3546            nickname,
3547            emoji,
3548        } => {
3549            if as_json {
3550                println!(
3551                    "{}",
3552                    serde_json::to_string(&json!({
3553                        "kind": "local_sister",
3554                        "session_name": session_name,
3555                        "handle": handle,
3556                        "did": did,
3557                        "nickname": nickname,
3558                        "emoji": emoji,
3559                    }))?
3560                );
3561            } else {
3562                let n = nickname.as_deref().unwrap_or("(no character)");
3563                let e = emoji.as_deref().unwrap_or("?");
3564                println!("{e} {n}");
3565                println!("  session:  {session_name}");
3566                println!("  handle:   {handle}");
3567                println!(
3568                    "  did:      {}",
3569                    did.as_deref().unwrap_or("(card unreadable)")
3570                );
3571                println!("  reach:    local sister on this machine — `wire dial {n}` pairs us");
3572            }
3573        }
3574    }
3575    Ok(())
3576}
3577
3578enum DialTarget {
3579    PinnedPeer {
3580        handle: String,
3581        did: String,
3582        nickname: Option<String>,
3583        emoji: Option<String>,
3584        tier: String,
3585    },
3586    LocalSister {
3587        session_name: String,
3588        handle: String,
3589        did: Option<String>,
3590        nickname: Option<String>,
3591        emoji: Option<String>,
3592    },
3593}
3594
3595/// Resolution order: pinned peers first (already in our trust ring),
3596/// then local sister sessions (on-disk discovery). Case-insensitive
3597/// match against handle, character nickname, session name, or DID.
3598fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3599    let needle = name.trim();
3600    if needle.is_empty() {
3601        bail!("empty name");
3602    }
3603
3604    // 1. Pinned peers — `wire peers` data. trust.agents is an object
3605    // keyed by handle (not an array); iterate as a map.
3606    if config::is_initialized().unwrap_or(false) {
3607        let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3608        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3609            for (handle_key, agent) in agents {
3610                let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3611                if did.is_empty() {
3612                    continue;
3613                }
3614                let handle = handle_key.clone();
3615                let character = crate::character::Character::from_did(did);
3616                let tier = agent
3617                    .get("tier")
3618                    .and_then(Value::as_str)
3619                    .unwrap_or("UNKNOWN")
3620                    .to_string();
3621                let matches = handle.eq_ignore_ascii_case(needle)
3622                    || did.eq_ignore_ascii_case(needle)
3623                    || character.nickname.eq_ignore_ascii_case(needle);
3624                if matches {
3625                    return Ok(DialTarget::PinnedPeer {
3626                        handle,
3627                        did: did.to_string(),
3628                        nickname: Some(character.nickname),
3629                        emoji: Some(character.emoji.to_string()),
3630                        tier,
3631                    });
3632                }
3633            }
3634        }
3635    }
3636
3637    // 2. Local sister sessions.
3638    if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3639        let sessions = crate::session::list_sessions().unwrap_or_default();
3640        let s = sessions.iter().find(|s| s.name == session_name);
3641        if let Some(s) = s {
3642            return Ok(DialTarget::LocalSister {
3643                session_name: s.name.clone(),
3644                handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3645                did: s.did.clone(),
3646                nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3647                emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3648            });
3649        }
3650    }
3651
3652    // v0.9.2: fuzzy did-you-mean suggestion on resolution miss. Walks
3653    // the union of pinned-peer handles + character nicknames + sister
3654    // session names + sister character nicknames, returns up to 3 names
3655    // within Levenshtein distance 3 of the operator's typed name.
3656    let pool = known_local_names();
3657    let suggestions = closest_candidates(name, &pool, 3, 3);
3658    if suggestions.is_empty() {
3659        bail!(
3660            "no peer matched `{name}`.\n\
3661             Tried: pinned peers (`wire peers`) + local sister sessions \
3662             (`wire session list-local`).\n\
3663             For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3664        );
3665    }
3666    bail!(
3667        "no peer matched `{name}`.\n\
3668         Did you mean: {}?\n\
3669         List all: `wire peers`, `wire session list-local`.",
3670        suggestions
3671            .iter()
3672            .map(|s| format!("`{s}`"))
3673            .collect::<Vec<_>>()
3674            .join(", ")
3675    );
3676}
3677
3678// ---------- tail ----------
3679
3680fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3681    let inbox = config::inbox_dir()?;
3682    if !inbox.exists() {
3683        if !as_json {
3684            eprintln!("no inbox yet — daemon hasn't run, or no events received");
3685        }
3686        return Ok(());
3687    }
3688    let trust = config::read_trust()?;
3689    let mut count = 0usize;
3690
3691    let entries: Vec<_> = std::fs::read_dir(&inbox)?
3692        .filter_map(|e| e.ok())
3693        .map(|e| e.path())
3694        .filter(|p| {
3695            p.extension().map(|x| x == "jsonl").unwrap_or(false)
3696                && match peer {
3697                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3698                    None => true,
3699                }
3700        })
3701        .collect();
3702
3703    for path in entries {
3704        let body = std::fs::read_to_string(&path)?;
3705        for line in body.lines() {
3706            let event: Value = match serde_json::from_str(line) {
3707                Ok(v) => v,
3708                Err(_) => continue,
3709            };
3710            let verified = verify_message_v31(&event, &trust).is_ok();
3711            if as_json {
3712                let mut event_with_meta = event.clone();
3713                if let Some(obj) = event_with_meta.as_object_mut() {
3714                    obj.insert("verified".into(), json!(verified));
3715                }
3716                println!("{}", serde_json::to_string(&event_with_meta)?);
3717            } else {
3718                let ts = event
3719                    .get("timestamp")
3720                    .and_then(Value::as_str)
3721                    .unwrap_or("?");
3722                let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3723                let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3724                let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3725                let summary = event
3726                    .get("body")
3727                    .map(|b| match b {
3728                        Value::String(s) => s.clone(),
3729                        _ => b.to_string(),
3730                    })
3731                    .unwrap_or_default();
3732                let mark = if verified { "✓" } else { "✗" };
3733                let deadline = event
3734                    .get("time_sensitive_until")
3735                    .and_then(Value::as_str)
3736                    .map(|d| format!(" deadline: {d}"))
3737                    .unwrap_or_default();
3738                println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3739            }
3740            count += 1;
3741            if limit > 0 && count >= limit {
3742                return Ok(());
3743            }
3744        }
3745    }
3746    Ok(())
3747}
3748
3749// ---------- monitor (live-tail across all peers, harness-friendly) ----------
3750
3751/// Events filtered out of `wire monitor` by default — pair handshake +
3752/// liveness pings. Operators almost never want these surfaced; an explicit
3753/// `--include-handshake` brings them back.
3754fn monitor_is_noise_kind(kind: &str) -> bool {
3755    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3756}
3757
3758/// Resolve a pinned peer's persona (the DID-derived nickname + emoji,
3759/// respecting an advertised override on their card). `None` if the peer
3760/// isn't in trust or can't be resolved — callers fall back to the handle.
3761fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
3762    let trust = config::read_trust().ok()?;
3763    let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
3764    if let Some(card) = agent.get("card") {
3765        Some(crate::character::Character::from_card(card))
3766    } else {
3767        let did = agent.get("did").and_then(Value::as_str)?;
3768        Some(crate::character::Character::from_did(did))
3769    }
3770}
3771
3772/// "emoji nickname" label for a peer, falling back to the raw handle.
3773fn persona_label(peer_handle: &str) -> String {
3774    match resolve_persona(peer_handle) {
3775        Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
3776        None => peer_handle.to_string(),
3777    }
3778}
3779
3780/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
3781/// full structured event for tooling consumption; the plain form is a tight
3782/// one-line summary suitable as a harness stream-watcher notification.
3783///
3784/// Kept PURE (no trust I/O) so it stays deterministic and cheap per event.
3785/// Persona enrichment for `--json` belongs at InboxEvent construction in
3786/// `inbox_watch` (a follow-up), not here.
3787fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3788    if as_json {
3789        Ok(serde_json::to_string(e)?)
3790    } else {
3791        let eid_short: String = e.event_id.chars().take(12).collect();
3792        let body = e.body_preview.replace('\n', " ");
3793        let ts: String = e.timestamp.chars().take(19).collect();
3794        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3795    }
3796}
3797
3798/// `wire monitor` — long-running line-per-event stream of new inbox events.
3799///
3800/// Built for agent harnesses that have an "every stdout line is a chat
3801/// notification" stream watcher (Claude Code Monitor tool, etc.). One
3802/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
3803/// | python parse | grep -v pair_drop` pipeline operators improvise on day
3804/// one of every wire session.
3805///
3806/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
3807/// pure handshake / liveness noise that operators almost never want
3808/// surfaced. Pass `--include-handshake` if you do.
3809///
3810/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
3811/// doesn't drown the operator in replay), with optional `--replay N` to
3812/// emit the last N events first.
3813fn cmd_monitor(
3814    peer_filter: Option<&str>,
3815    as_json: bool,
3816    include_handshake: bool,
3817    interval_ms: u64,
3818    replay: usize,
3819) -> Result<()> {
3820    let inbox_dir = config::inbox_dir()?;
3821    if !inbox_dir.exists() && !as_json {
3822        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3823    }
3824    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
3825
3826    // Optional replay — read existing files and emit the last `replay` events
3827    // (post-filter) before going live. Useful when the harness restarts and
3828    // wants recent context.
3829    if replay > 0 && inbox_dir.exists() {
3830        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3831        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3832            let path = entry.path();
3833            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3834                continue;
3835            }
3836            let peer = match path.file_stem().and_then(|s| s.to_str()) {
3837                Some(s) => s.to_string(),
3838                None => continue,
3839            };
3840            if let Some(filter) = peer_filter
3841                && peer != filter
3842            {
3843                continue;
3844            }
3845            let body = std::fs::read_to_string(&path).unwrap_or_default();
3846            for line in body.lines() {
3847                let line = line.trim();
3848                if line.is_empty() {
3849                    continue;
3850                }
3851                let signed: Value = match serde_json::from_str(line) {
3852                    Ok(v) => v,
3853                    Err(_) => continue,
3854                };
3855                let ev = crate::inbox_watch::InboxEvent::from_signed(
3856                    &peer, signed, /* verified */ true,
3857                );
3858                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3859                    continue;
3860                }
3861                all.push(ev);
3862            }
3863        }
3864        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
3865        // chronological for same-zoned timestamps).
3866        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3867        let start = all.len().saturating_sub(replay);
3868        for ev in &all[start..] {
3869            println!("{}", monitor_render(ev, as_json)?);
3870        }
3871        use std::io::Write;
3872        std::io::stdout().flush().ok();
3873    }
3874
3875    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
3876    // the first poll only returns events that arrived AFTER startup.
3877    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3878    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3879
3880    loop {
3881        let events = w.poll()?;
3882        let mut wrote = false;
3883        for ev in events {
3884            if let Some(filter) = peer_filter
3885                && ev.peer != filter
3886            {
3887                continue;
3888            }
3889            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3890                continue;
3891            }
3892            println!("{}", monitor_render(&ev, as_json)?);
3893            wrote = true;
3894        }
3895        if wrote {
3896            use std::io::Write;
3897            std::io::stdout().flush().ok();
3898        }
3899        std::thread::sleep(sleep_dur);
3900    }
3901}
3902
3903#[cfg(test)]
3904mod tier_tests {
3905    use super::*;
3906    use serde_json::json;
3907
3908    fn trust_with(handle: &str, tier: &str) -> Value {
3909        json!({
3910            "version": 1,
3911            "agents": {
3912                handle: {
3913                    "tier": tier,
3914                    "did": format!("did:wire:{handle}"),
3915                    "card": {"capabilities": ["wire/v3.1"]}
3916                }
3917            }
3918        })
3919    }
3920
3921    #[test]
3922    fn pending_ack_when_verified_but_no_slot_token() {
3923        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
3924        // slot_token hasn't arrived yet. Display PENDING_ACK so the
3925        // operator knows wire send won't work yet.
3926        let trust = trust_with("willard", "VERIFIED");
3927        let relay_state = json!({
3928            "peers": {
3929                "willard": {
3930                    "relay_url": "https://relay",
3931                    "slot_id": "abc",
3932                    "slot_token": "",
3933                }
3934            }
3935        });
3936        assert_eq!(
3937            effective_peer_tier(&trust, &relay_state, "willard"),
3938            "PENDING_ACK"
3939        );
3940    }
3941
3942    #[test]
3943    fn verified_when_slot_token_present() {
3944        let trust = trust_with("willard", "VERIFIED");
3945        let relay_state = json!({
3946            "peers": {
3947                "willard": {
3948                    "relay_url": "https://relay",
3949                    "slot_id": "abc",
3950                    "slot_token": "tok123",
3951                }
3952            }
3953        });
3954        assert_eq!(
3955            effective_peer_tier(&trust, &relay_state, "willard"),
3956            "VERIFIED"
3957        );
3958    }
3959
3960    #[test]
3961    fn raw_tier_passes_through_for_non_verified() {
3962        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
3963        // UNTRUSTED regardless of slot_token state.
3964        let trust = trust_with("willard", "UNTRUSTED");
3965        let relay_state = json!({
3966            "peers": {"willard": {"slot_token": ""}}
3967        });
3968        assert_eq!(
3969            effective_peer_tier(&trust, &relay_state, "willard"),
3970            "UNTRUSTED"
3971        );
3972    }
3973
3974    #[test]
3975    fn pending_ack_when_relay_state_missing_peer() {
3976        // After wire add, trust gets updated BEFORE relay_state.peers does.
3977        // If relay_state has no entry for the peer at all, the operator
3978        // still hasn't completed the bilateral pin — show PENDING_ACK.
3979        let trust = trust_with("willard", "VERIFIED");
3980        let relay_state = json!({"peers": {}});
3981        assert_eq!(
3982            effective_peer_tier(&trust, &relay_state, "willard"),
3983            "PENDING_ACK"
3984        );
3985    }
3986}
3987
3988#[cfg(test)]
3989mod monitor_tests {
3990    use super::*;
3991    use crate::inbox_watch::InboxEvent;
3992    use serde_json::Value;
3993
3994    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
3995        InboxEvent {
3996            peer: peer.to_string(),
3997            event_id: "abcd1234567890ef".to_string(),
3998            kind: kind.to_string(),
3999            body_preview: body.to_string(),
4000            verified: true,
4001            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4002            raw: Value::Null,
4003        }
4004    }
4005
4006    #[test]
4007    fn monitor_filter_drops_handshake_kinds_by_default() {
4008        // The whole point: pair_drop / pair_drop_ack / heartbeat are
4009        // protocol noise. If they leak into the operator's chat stream by
4010        // default, the recipe is useless ("wire monitor talks too much,
4011        // disabled it"). Burn this rule in.
4012        assert!(monitor_is_noise_kind("pair_drop"));
4013        assert!(monitor_is_noise_kind("pair_drop_ack"));
4014        assert!(monitor_is_noise_kind("heartbeat"));
4015
4016        // Real-payload kinds — operator wants every one.
4017        assert!(!monitor_is_noise_kind("claim"));
4018        assert!(!monitor_is_noise_kind("decision"));
4019        assert!(!monitor_is_noise_kind("ack"));
4020        assert!(!monitor_is_noise_kind("request"));
4021        assert!(!monitor_is_noise_kind("note"));
4022        // Unknown future kinds shouldn't be filtered as noise either —
4023        // operator probably wants to see something they don't recognise,
4024        // not have it silently dropped (the P0.1 lesson at the UX layer).
4025        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4026    }
4027
4028    #[test]
4029    fn monitor_render_plain_is_one_short_line() {
4030        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4031        let line = monitor_render(&e, false).unwrap();
4032        // Must be single-line.
4033        assert!(!line.contains('\n'), "render must be one line: {line}");
4034        // Must include peer, kind, body fragment, short event_id.
4035        assert!(line.contains("willard"));
4036        assert!(line.contains("claim"));
4037        assert!(line.contains("real v8 train"));
4038        // Short event id (first 12 chars).
4039        assert!(line.contains("abcd12345678"));
4040        assert!(
4041            !line.contains("abcd1234567890ef"),
4042            "should truncate full id"
4043        );
4044        // RFC3339-ish second precision.
4045        assert!(line.contains("2026-05-15T23:14:07"));
4046    }
4047
4048    #[test]
4049    fn monitor_render_strips_newlines_from_body() {
4050        // Multi-line bodies (markdown lists, code, etc.) must collapse to
4051        // one line — otherwise a single message produces multiple
4052        // notifications in the harness, ruining the "one event = one line"
4053        // contract the Monitor tool relies on.
4054        let e = ev("spark", "claim", "line one\nline two\nline three");
4055        let line = monitor_render(&e, false).unwrap();
4056        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4057        assert!(line.contains("line one line two line three"));
4058    }
4059
4060    #[test]
4061    fn monitor_render_json_is_valid_jsonl() {
4062        let e = ev("spark", "claim", "hi");
4063        let line = monitor_render(&e, true).unwrap();
4064        assert!(!line.contains('\n'));
4065        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4066        assert_eq!(parsed["peer"], "spark");
4067        assert_eq!(parsed["kind"], "claim");
4068        assert_eq!(parsed["body_preview"], "hi");
4069    }
4070
4071    #[test]
4072    fn monitor_does_not_drop_on_verified_null() {
4073        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
4074        // `select(.verified == true)` against inbox JSONL. Daemon writes
4075        // events with verified=null (verification happens at tail-time, not
4076        // write-time), so the filter silently rejected everything — same
4077        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
4078        // never surfaced for ~30min.
4079        //
4080        // wire monitor's render path must NOT consult `.verified` for any
4081        // filter decision. Lock that in here so a future "be conservative,
4082        // only emit verified" patch can't quietly land.
4083        let mut e = ev("spark", "claim", "from disk with verified=null");
4084        e.verified = false; // worst case — even if disk says unverified, emit
4085        let line = monitor_render(&e, false).unwrap();
4086        assert!(line.contains("from disk with verified=null"));
4087        // Noise filter operates purely on kind, never on verified.
4088        assert!(!monitor_is_noise_kind("claim"));
4089    }
4090}
4091
4092// ---------- verify ----------
4093
4094fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4095    let body = if path == "-" {
4096        let mut buf = String::new();
4097        use std::io::Read;
4098        std::io::stdin().read_to_string(&mut buf)?;
4099        buf
4100    } else {
4101        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4102    };
4103    let event: Value = serde_json::from_str(&body)?;
4104    let trust = config::read_trust()?;
4105    match verify_message_v31(&event, &trust) {
4106        Ok(()) => {
4107            if as_json {
4108                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4109            } else {
4110                println!("verified ✓");
4111            }
4112            Ok(())
4113        }
4114        Err(e) => {
4115            let reason = e.to_string();
4116            if as_json {
4117                println!(
4118                    "{}",
4119                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4120                );
4121            } else {
4122                eprintln!("FAILED: {reason}");
4123            }
4124            std::process::exit(1);
4125        }
4126    }
4127}
4128
4129// ---------- mcp / relay-server stubs ----------
4130
4131fn cmd_mcp() -> Result<()> {
4132    crate::mcp::run()
4133}
4134
4135fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4136    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
4137    // overriding --bind. Implies --local-only semantics. Routed to a
4138    // separate serve_uds entry point with a manual hyper accept loop
4139    // (axum 0.7's `serve` is TcpListener-only).
4140    if let Some(socket_path) = uds {
4141        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4142            std::path::PathBuf::from(home)
4143                .join("state")
4144                .join("wire-relay")
4145                .join("uds")
4146        } else {
4147            dirs::state_dir()
4148                .or_else(dirs::data_local_dir)
4149                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4150                .join("wire-relay")
4151                .join("uds")
4152        };
4153        let runtime = tokio::runtime::Builder::new_multi_thread()
4154            .enable_all()
4155            .build()?;
4156        return runtime.block_on(crate::relay_server::serve_uds(
4157            socket_path.to_path_buf(),
4158            base,
4159        ));
4160    }
4161    // v0.5.17: --local-only refuses non-loopback binds. Catches the
4162    // "wait did I just bind a publicly-reachable local-only relay" mistake
4163    // at startup rather than discovering it via an empty phonebook later.
4164    if local_only {
4165        validate_loopback_bind(bind)?;
4166    }
4167    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
4168    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
4169    // so a single user can run both client and server on one machine.
4170    // For --local-only, suffix with /local so a single operator can run
4171    // both a federation relay and a local-only relay without state collision.
4172    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4173        std::path::PathBuf::from(home)
4174            .join("state")
4175            .join("wire-relay")
4176    } else {
4177        dirs::state_dir()
4178            .or_else(dirs::data_local_dir)
4179            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4180            .join("wire-relay")
4181    };
4182    let state_dir = if local_only { base.join("local") } else { base };
4183    let runtime = tokio::runtime::Builder::new_multi_thread()
4184        .enable_all()
4185        .build()?;
4186    runtime.block_on(crate::relay_server::serve_with_mode(
4187        bind,
4188        state_dir,
4189        crate::relay_server::ServerMode { local_only },
4190    ))
4191}
4192
4193/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
4194/// resolves to something outside `127.0.0.0/8` or `::1`.
4195///
4196/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
4197/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
4198/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
4199///
4200/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
4201/// is the IP range Tailscale uses for tailnet addresses. Lets operators
4202/// pair wire across machines using their tailnet IPs (e.g. Mac at
4203/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
4204/// auth + encryption + NAT traversal, wire handles protocol + identity.
4205/// Sidesteps host firewall config entirely (utun interface bypass).
4206///
4207/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
4208/// multicast, broadcast. Those would publish a "local-only" relay to
4209/// the global internet — the v0.5.17 security gate's whole point.
4210fn validate_loopback_bind(bind: &str) -> Result<()> {
4211    // Split host:port. IPv6 literals use `[::]:port` form.
4212    let host = if let Some(stripped) = bind.strip_prefix('[') {
4213        let close = stripped
4214            .find(']')
4215            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4216        stripped[..close].to_string()
4217    } else {
4218        bind.rsplit_once(':')
4219            .map(|(h, _)| h.to_string())
4220            .unwrap_or_else(|| bind.to_string())
4221    };
4222    use std::net::{IpAddr, ToSocketAddrs};
4223    let probe = format!("{host}:0");
4224    let resolved: Vec<_> = probe
4225        .to_socket_addrs()
4226        .with_context(|| format!("resolving bind host {host:?}"))?
4227        .collect();
4228    if resolved.is_empty() {
4229        bail!("--local-only: bind host {host:?} resolved to no addresses");
4230    }
4231    for addr in &resolved {
4232        let ip = addr.ip();
4233        let is_acceptable = match ip {
4234            IpAddr::V4(v4) => {
4235                v4.is_loopback() || v4.is_private() || {
4236                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
4237                    let octets = v4.octets();
4238                    octets[0] == 100 && (64..=127).contains(&octets[1])
4239                }
4240            }
4241            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
4242        };
4243        if !is_acceptable {
4244            bail!(
4245                "--local-only refuses non-private bind: {host:?} resolves to {} \
4246                 which is not loopback (127/8, ::1), RFC 1918 private \
4247                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4248                 (100.64.0.0/10). Remove --local-only to bind publicly.",
4249                ip
4250            );
4251        }
4252    }
4253    Ok(())
4254}
4255
4256// ---------- bind-relay ----------
4257
4258fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4259    use crate::endpoints::EndpointScope;
4260    match s.to_lowercase().as_str() {
4261        "federation" | "fed" => Ok(EndpointScope::Federation),
4262        "local" => Ok(EndpointScope::Local),
4263        "lan" => Ok(EndpointScope::Lan),
4264        "uds" => Ok(EndpointScope::Uds),
4265        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4266    }
4267}
4268
4269/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
4270/// appended to `self.endpoints[]`, keeping any existing slots so an agent
4271/// can hold a local relay AND a federation relay simultaneously without
4272/// black-holing pinned peers. `--replace` restores the pre-v0.12
4273/// destructive single-slot behavior (guarded by issue #7).
4274fn cmd_bind_relay(
4275    url: &str,
4276    scope: Option<&str>,
4277    replace: bool,
4278    migrate_pinned: bool,
4279    as_json: bool,
4280) -> Result<()> {
4281    use crate::endpoints::{self_endpoints, Endpoint};
4282
4283    if !config::is_initialized()? {
4284        bail!("not initialized — run `wire init <handle>` first");
4285    }
4286    let card = config::read_agent_card()?;
4287    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4288    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4289
4290    let normalized = url.trim_end_matches('/');
4291    let new_scope = match scope {
4292        Some(s) => parse_scope(s)?,
4293        None => crate::endpoints::infer_scope_from_url(normalized),
4294    };
4295
4296    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4297    let pinned: Vec<String> = existing
4298        .get("peers")
4299        .and_then(|p| p.as_object())
4300        .map(|o| o.keys().cloned().collect())
4301        .unwrap_or_default();
4302
4303    let existing_eps = self_endpoints(&existing);
4304    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4305
4306    // Destructive paths that black-hole pinned peers (issue #7):
4307    //   • `--replace` drops every other slot.
4308    //   • re-binding the SAME relay rotates that slot in place.
4309    // An additive bind of a NEW relay keeps existing slots, so peers stay
4310    // reachable — no acknowledgement required. This is the v0.12 default
4311    // that unblocks simultaneous local + remote.
4312    let destructive = replace || is_rebind_same;
4313    if destructive && !pinned.is_empty() && !migrate_pinned {
4314        let list = pinned.join(", ");
4315        let why = if replace {
4316            "`--replace` drops your other slot(s)"
4317        } else {
4318            "re-binding the same relay rotates its slot"
4319        };
4320        bail!(
4321            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4322             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4323             read.\n\n\
4324             SAFE PATHS:\n\
4325             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4326             slots — no black-hole.\n\
4327             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4328             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4329             peer out-of-band.\n\n\
4330             Issue #7 (silent black-hole on relay change) caught this.",
4331            n = pinned.len(),
4332        );
4333    }
4334
4335    let client = crate::relay_client::RelayClient::new(normalized);
4336    client.check_healthz()?;
4337    let alloc = client.allocate_slot(Some(&handle))?;
4338
4339    if destructive && !pinned.is_empty() {
4340        eprintln!(
4341            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4342             until they re-pin: {peers}",
4343            mode = if replace { "replacing" } else { "rotating" },
4344            n = pinned.len(),
4345            peers = pinned.join(", "),
4346        );
4347    }
4348
4349    // Write the new slot via the single source of truth for the self-slot
4350    // shape. Additive by default; --replace starts from an empty self so
4351    // only this slot remains.
4352    let mut state = existing;
4353    if replace {
4354        state["self"] = Value::Null;
4355    }
4356    crate::endpoints::upsert_self_endpoint(
4357        &mut state,
4358        Endpoint {
4359            relay_url: normalized.to_string(),
4360            slot_id: alloc.slot_id.clone(),
4361            slot_token: alloc.slot_token.clone(),
4362            scope: new_scope,
4363        },
4364    );
4365    config::write_relay_state(&state)?;
4366    let eps = self_endpoints(&state);
4367
4368    let scope_str = format!("{new_scope:?}").to_lowercase();
4369    if as_json {
4370        println!(
4371            "{}",
4372            serde_json::to_string(&json!({
4373                "relay_url": normalized,
4374                "slot_id": alloc.slot_id,
4375                "scope": scope_str,
4376                "endpoints": eps.len(),
4377                "additive": !replace,
4378                "slot_token_present": true,
4379            }))?
4380        );
4381    } else {
4382        println!("bound {scope_str} slot on {normalized} (slot {})", alloc.slot_id);
4383        println!(
4384            "self now has {n} endpoint(s): {list}",
4385            n = eps.len(),
4386            list = eps
4387                .iter()
4388                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4389                .collect::<Vec<_>>()
4390                .join(", "),
4391        );
4392    }
4393    Ok(())
4394}
4395
4396// ---------- add-peer-slot ----------
4397
4398fn cmd_add_peer_slot(
4399    handle: &str,
4400    url: &str,
4401    slot_id: &str,
4402    slot_token: &str,
4403    as_json: bool,
4404) -> Result<()> {
4405    let mut state = config::read_relay_state()?;
4406    let peers = state["peers"]
4407        .as_object_mut()
4408        .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
4409    peers.insert(
4410        handle.to_string(),
4411        json!({
4412            "relay_url": url,
4413            "slot_id": slot_id,
4414            "slot_token": slot_token,
4415        }),
4416    );
4417    config::write_relay_state(&state)?;
4418    if as_json {
4419        println!(
4420            "{}",
4421            serde_json::to_string(&json!({
4422                "handle": handle,
4423                "relay_url": url,
4424                "slot_id": slot_id,
4425                "added": true,
4426            }))?
4427        );
4428    } else {
4429        println!("pinned peer slot for {handle} at {url} ({slot_id})");
4430    }
4431    Ok(())
4432}
4433
4434// ---------- push ----------
4435
4436fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4437    let state = config::read_relay_state()?;
4438    let peers = state["peers"].as_object().cloned().unwrap_or_default();
4439    if peers.is_empty() {
4440        bail!(
4441            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4442        );
4443    }
4444    let outbox_dir = config::outbox_dir()?;
4445    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
4446    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
4447    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
4448    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
4449    if outbox_dir.exists() {
4450        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4451        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4452            let path = entry.path();
4453            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4454                continue;
4455            }
4456            let stem = match path.file_stem().and_then(|s| s.to_str()) {
4457                Some(s) => s.to_string(),
4458                None => continue,
4459            };
4460            if pinned.contains(&stem) {
4461                continue;
4462            }
4463            // Try the bare-handle of the orphaned stem — if THAT matches a
4464            // pinned peer, the stem is a stale FQDN-suffixed file.
4465            let bare = crate::agent_card::bare_handle(&stem);
4466            if pinned.contains(bare) {
4467                eprintln!(
4468                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4469                     Merge with: `cat {} >> {}` then delete the FQDN file.",
4470                    stem,
4471                    path.display(),
4472                    outbox_dir.join(format!("{bare}.jsonl")).display(),
4473                );
4474            }
4475        }
4476    }
4477    if !outbox_dir.exists() {
4478        if as_json {
4479            println!(
4480                "{}",
4481                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4482            );
4483        } else {
4484            println!("phyllis: nothing to dial out — write a message first with `wire send`");
4485        }
4486        return Ok(());
4487    }
4488
4489    let mut pushed = Vec::new();
4490    let mut skipped = Vec::new();
4491
4492    // v0.5.17: walk each peer's pinned endpoints in priority order (local
4493    // first if we share a local relay, federation second). Try POST on the
4494    // first endpoint; on transport failure, fall through to the next.
4495    // Falls back to the v0.5.16 legacy single-endpoint code path when the
4496    // peer record carries no `endpoints[]` array (back-compat).
4497    for (peer_handle, _) in peers.iter() {
4498        if let Some(want) = peer_filter
4499            && peer_handle != want
4500        {
4501            continue;
4502        }
4503        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4504        if !outbox.exists() {
4505            continue;
4506        }
4507        let ordered_endpoints =
4508            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4509        if ordered_endpoints.is_empty() {
4510            // Unreachable peer (no federation endpoint AND our local
4511            // relay doesn't match the peer's). Skip with a loud reason
4512            // rather than silently dropping events.
4513            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4514                let event: Value = match serde_json::from_str(line) {
4515                    Ok(v) => v,
4516                    Err(_) => continue,
4517                };
4518                let event_id = event
4519                    .get("event_id")
4520                    .and_then(Value::as_str)
4521                    .unwrap_or("")
4522                    .to_string();
4523                skipped.push(json!({
4524                    "peer": peer_handle,
4525                    "event_id": event_id,
4526                    "reason": "no reachable endpoint pinned for peer",
4527                }));
4528            }
4529            continue;
4530        }
4531        let body = std::fs::read_to_string(&outbox)?;
4532        for line in body.lines() {
4533            let event: Value = match serde_json::from_str(line) {
4534                Ok(v) => v,
4535                Err(_) => continue,
4536            };
4537            let event_id = event
4538                .get("event_id")
4539                .and_then(Value::as_str)
4540                .unwrap_or("")
4541                .to_string();
4542
4543            let mut delivered = false;
4544            let mut last_err_reason: Option<String> = None;
4545            for endpoint in &ordered_endpoints {
4546                let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4547                match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
4548                    Ok(resp) => {
4549                        if resp.status == "duplicate" {
4550                            skipped.push(json!({
4551                                "peer": peer_handle,
4552                                "event_id": event_id,
4553                                "reason": "duplicate",
4554                                "endpoint": endpoint.relay_url,
4555                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4556                            }));
4557                        } else {
4558                            pushed.push(json!({
4559                                "peer": peer_handle,
4560                                "event_id": event_id,
4561                                "endpoint": endpoint.relay_url,
4562                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4563                            }));
4564                        }
4565                        delivered = true;
4566                        break;
4567                    }
4568                    Err(e) => {
4569                        // Local-first endpoint failed; record reason and
4570                        // try the next endpoint silently (operator sees
4571                        // the federation success). If every endpoint
4572                        // fails, the last reason is what gets reported.
4573                        last_err_reason = Some(crate::relay_client::format_transport_error(&e));
4574                    }
4575                }
4576            }
4577            if !delivered {
4578                skipped.push(json!({
4579                    "peer": peer_handle,
4580                    "event_id": event_id,
4581                    "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
4582                }));
4583            }
4584        }
4585    }
4586
4587    if as_json {
4588        println!(
4589            "{}",
4590            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
4591        );
4592    } else {
4593        println!(
4594            "pushed {} event(s); skipped {} ({})",
4595            pushed.len(),
4596            skipped.len(),
4597            if skipped.is_empty() {
4598                "none"
4599            } else {
4600                "see --json for detail"
4601            }
4602        );
4603    }
4604    Ok(())
4605}
4606
4607// ---------- pull ----------
4608
4609fn cmd_pull(as_json: bool) -> Result<()> {
4610    let state = config::read_relay_state()?;
4611    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4612    if self_state.is_null() {
4613        bail!("self slot not bound — run `wire bind-relay <url>` first");
4614    }
4615
4616    // v0.5.17: pull from every endpoint in self.endpoints (federation +
4617    // optional local). Each endpoint has its own per-scope cursor so we
4618    // don't re-pull events we've already seen on that path. Events from
4619    // all endpoints feed into the same inbox JSONL via process_events;
4620    // dedup by event_id is the last line of defense.
4621    // Falls back to a single federation endpoint synthesized from the
4622    // top-level legacy fields when self.endpoints is absent (v0.5.16
4623    // back-compat).
4624    let endpoints = crate::endpoints::self_endpoints(&state);
4625    if endpoints.is_empty() {
4626        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
4627    }
4628
4629    let inbox_dir = config::inbox_dir()?;
4630    config::ensure_dirs()?;
4631
4632    let mut total_seen = 0usize;
4633    let mut all_written: Vec<Value> = Vec::new();
4634    let mut all_rejected: Vec<Value> = Vec::new();
4635    let mut all_blocked = false;
4636    let mut all_advance_cursor_to: Option<String> = None;
4637
4638    for endpoint in &endpoints {
4639        let cursor_key = endpoint_cursor_key(endpoint.scope);
4640        let last_event_id = self_state
4641            .get(&cursor_key)
4642            .and_then(Value::as_str)
4643            .map(str::to_string);
4644        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4645        let events = match client.list_events(
4646            &endpoint.slot_id,
4647            &endpoint.slot_token,
4648            last_event_id.as_deref(),
4649            Some(1000),
4650        ) {
4651            Ok(ev) => ev,
4652            Err(e) => {
4653                // One endpoint's failure shouldn't kill the whole pull.
4654                // The local-relay-down case in particular needs to
4655                // gracefully continue against federation.
4656                eprintln!(
4657                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
4658                    endpoint.relay_url,
4659                    endpoint.scope,
4660                    crate::relay_client::format_transport_error(&e),
4661                );
4662                continue;
4663            }
4664        };
4665        total_seen += events.len();
4666        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
4667        all_written.extend(result.written.iter().cloned());
4668        all_rejected.extend(result.rejected.iter().cloned());
4669        if result.blocked {
4670            all_blocked = true;
4671        }
4672        // Advance per-endpoint cursor. The cursor key is scope-specific
4673        // so federation and local don't trample each other.
4674        if let Some(eid) = result.advance_cursor_to.clone() {
4675            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
4676                all_advance_cursor_to = Some(eid.clone());
4677            }
4678            let key = cursor_key.clone();
4679            config::update_relay_state(|state| {
4680                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4681                    self_obj.insert(key, Value::String(eid));
4682                }
4683                Ok(())
4684            })?;
4685        }
4686    }
4687
4688    // Compatibility shim for the legacy single-cursor code paths below:
4689    // `result` used to come from one process_events call; we now have
4690    // per-endpoint results aggregated into the all_* accumulators.
4691    // Reconstruct a synthetic result for the remaining display logic.
4692    let result = crate::pull::PullResult {
4693        written: all_written,
4694        rejected: all_rejected,
4695        blocked: all_blocked,
4696        advance_cursor_to: all_advance_cursor_to,
4697    };
4698    let events_len = total_seen;
4699
4700    // Cursor advance happened per-endpoint above; no aggregate cursor
4701    // write needed here.
4702
4703    if as_json {
4704        println!(
4705            "{}",
4706            serde_json::to_string(&json!({
4707                "written": result.written,
4708                "rejected": result.rejected,
4709                "total_seen": events_len,
4710                "cursor_blocked": result.blocked,
4711                "cursor_advanced_to": result.advance_cursor_to,
4712            }))?
4713        );
4714    } else {
4715        let blocking = result
4716            .rejected
4717            .iter()
4718            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
4719            .count();
4720        if blocking > 0 {
4721            println!(
4722                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
4723                events_len,
4724                result.written.len(),
4725                result.rejected.len(),
4726                blocking,
4727            );
4728        } else {
4729            println!(
4730                "pulled {} event(s); wrote {}; rejected {}",
4731                events_len,
4732                result.written.len(),
4733                result.rejected.len(),
4734            );
4735        }
4736    }
4737    Ok(())
4738}
4739
4740/// v0.5.17: cursor key for an endpoint's per-scope read position.
4741/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
4742/// back-compat with on-disk relay_state files; local uses a
4743/// `_local` suffix.
4744fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4745    match scope {
4746        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4747        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4748        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4749        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4750    }
4751}
4752
4753// ---------- rotate-slot ----------
4754
4755fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4756    if !config::is_initialized()? {
4757        bail!("not initialized — run `wire init <handle>` first");
4758    }
4759    let mut state = config::read_relay_state()?;
4760    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4761    if self_state.is_null() {
4762        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4763    }
4764    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
4765    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
4766    // top-level legacy fields directly and bailed for those sessions.
4767    let primary = crate::endpoints::self_primary_endpoint(&state)
4768        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
4769    let url = primary.relay_url.clone();
4770    let old_slot_id = primary.slot_id.clone();
4771    let old_slot_token = primary.slot_token.clone();
4772
4773    // Read identity to sign the announcement.
4774    let card = config::read_agent_card()?;
4775    let did = card
4776        .get("did")
4777        .and_then(Value::as_str)
4778        .unwrap_or("")
4779        .to_string();
4780    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4781    let pk_b64 = card
4782        .get("verify_keys")
4783        .and_then(Value::as_object)
4784        .and_then(|m| m.values().next())
4785        .and_then(|v| v.get("key"))
4786        .and_then(Value::as_str)
4787        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4788        .to_string();
4789    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4790    let sk_seed = config::read_private_key()?;
4791
4792    // Allocate new slot on the same relay.
4793    let normalized = url.trim_end_matches('/').to_string();
4794    let client = crate::relay_client::RelayClient::new(&normalized);
4795    client
4796        .check_healthz()
4797        .context("aborting rotation; old slot still valid")?;
4798    let alloc = client.allocate_slot(Some(&handle))?;
4799    let new_slot_id = alloc.slot_id.clone();
4800    let new_slot_token = alloc.slot_token.clone();
4801
4802    // Optionally announce the rotation to every paired peer via the OLD slot.
4803    // Each peer's recipient-side `wire pull` will pick up this event before
4804    // their daemon next polls the new slot — but auto-update of peer's
4805    // relay.json from a wire_close event is a v0.2 daemon feature; for now
4806    // peers see the event and an operator must manually `add-peer-slot` the
4807    // new coords, OR re-pair via SAS.
4808    let mut announced: Vec<String> = Vec::new();
4809    if !no_announce {
4810        let now = time::OffsetDateTime::now_utc()
4811            .format(&time::format_description::well_known::Rfc3339)
4812            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4813        let body = json!({
4814            "reason": "operator-initiated slot rotation",
4815            "new_relay_url": url,
4816            "new_slot_id": new_slot_id,
4817            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
4818            // In v0.1 slot tokens are bilateral-shared, so peer can post via
4819            // existing add-peer-slot flow if operator chooses to re-issue.
4820        });
4821        let peers = state["peers"].as_object().cloned().unwrap_or_default();
4822        for (peer_handle, _peer_info) in peers.iter() {
4823            let event = json!({
4824                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4825                "timestamp": now.clone(),
4826                "from": did,
4827                "to": format!("did:wire:{peer_handle}"),
4828                "type": "wire_close",
4829                "kind": 1201,
4830                "body": body.clone(),
4831            });
4832            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4833                Ok(s) => s,
4834                Err(e) => {
4835                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4836                    continue;
4837                }
4838            };
4839            // Post to OUR old slot (we're announcing on our own slot, NOT
4840            // peer's slot — peer reads from us). Wait, this is wrong: peers
4841            // read from THEIR OWN slot via wire pull. To reach peer A, we
4842            // post to peer A's slot. Use the existing per-peer slot mapping.
4843            let peer_info = match state["peers"].get(peer_handle) {
4844                Some(p) => p.clone(),
4845                None => continue,
4846            };
4847            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4848            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4849            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4850            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4851                continue;
4852            }
4853            let peer_client = if peer_url == url {
4854                client.clone()
4855            } else {
4856                crate::relay_client::RelayClient::new(peer_url)
4857            };
4858            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4859                Ok(_) => announced.push(peer_handle.clone()),
4860                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4861            }
4862        }
4863    }
4864
4865    // Swap the self-slot to the new one.
4866    state["self"] = json!({
4867        "relay_url": url,
4868        "slot_id": new_slot_id,
4869        "slot_token": new_slot_token,
4870    });
4871    config::write_relay_state(&state)?;
4872
4873    if as_json {
4874        println!(
4875            "{}",
4876            serde_json::to_string(&json!({
4877                "rotated": true,
4878                "old_slot_id": old_slot_id,
4879                "new_slot_id": new_slot_id,
4880                "relay_url": url,
4881                "announced_to": announced,
4882            }))?
4883        );
4884    } else {
4885        println!("rotated slot on {url}");
4886        println!(
4887            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4888        );
4889        println!("  new slot_id: {new_slot_id}");
4890        if !announced.is_empty() {
4891            println!(
4892                "  announced wire_close (kind=1201) to: {}",
4893                announced.join(", ")
4894            );
4895        }
4896        println!();
4897        println!("next steps:");
4898        println!("  - peers see the wire_close event in their next `wire pull`");
4899        println!(
4900            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4901        );
4902        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
4903        println!("  - until they do, you'll receive but they won't be able to reach you");
4904        // Suppress unused warning
4905        let _ = old_slot_token;
4906    }
4907    Ok(())
4908}
4909
4910// ---------- forget-peer ----------
4911
4912fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4913    let mut trust = config::read_trust()?;
4914    let mut removed_from_trust = false;
4915    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4916        && agents.remove(handle).is_some()
4917    {
4918        removed_from_trust = true;
4919    }
4920    config::write_trust(&trust)?;
4921
4922    let mut state = config::read_relay_state()?;
4923    let mut removed_from_relay = false;
4924    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4925        && peers.remove(handle).is_some()
4926    {
4927        removed_from_relay = true;
4928    }
4929    config::write_relay_state(&state)?;
4930
4931    let mut purged: Vec<String> = Vec::new();
4932    if purge {
4933        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4934            let path = dir.join(format!("{handle}.jsonl"));
4935            if path.exists() {
4936                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4937                purged.push(path.to_string_lossy().into());
4938            }
4939        }
4940    }
4941
4942    if !removed_from_trust && !removed_from_relay {
4943        if as_json {
4944            println!(
4945                "{}",
4946                serde_json::to_string(&json!({
4947                    "removed": false,
4948                    "reason": format!("peer {handle:?} not pinned"),
4949                }))?
4950            );
4951        } else {
4952            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
4953        }
4954        return Ok(());
4955    }
4956
4957    if as_json {
4958        println!(
4959            "{}",
4960            serde_json::to_string(&json!({
4961                "handle": handle,
4962                "removed_from_trust": removed_from_trust,
4963                "removed_from_relay_state": removed_from_relay,
4964                "purged_files": purged,
4965            }))?
4966        );
4967    } else {
4968        println!("forgot peer {handle:?}");
4969        if removed_from_trust {
4970            println!("  - removed from trust.json");
4971        }
4972        if removed_from_relay {
4973            println!("  - removed from relay.json");
4974        }
4975        if !purged.is_empty() {
4976            for p in &purged {
4977                println!("  - deleted {p}");
4978            }
4979        } else if !purge {
4980            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
4981        }
4982    }
4983    Ok(())
4984}
4985
4986// ---------- daemon (long-lived push+pull sync) ----------
4987
4988fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
4989    if !config::is_initialized()? {
4990        bail!("not initialized — run `wire init <handle>` first");
4991    }
4992    let interval = std::time::Duration::from_secs(interval_secs.max(1));
4993
4994    if !as_json {
4995        if once {
4996            eprintln!("wire daemon: single sync cycle, then exit");
4997        } else {
4998            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
4999        }
5000    }
5001
5002    // Recover from prior crash: any pending pair in transient state had its
5003    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
5004    // the relay slots and mark the files so the operator can re-issue.
5005    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5006        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5007    }
5008
5009    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
5010    // to our slot, the subscriber signals `wake_rx`; we use it as the
5011    // sleep-or-wake gate of the polling loop. Polling stays as the
5012    // safety net — stream errors fall back transparently to the existing
5013    // interval-based cadence.
5014    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5015    if !once {
5016        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5017    }
5018
5019    loop {
5020        let pushed = run_sync_push().unwrap_or_else(|e| {
5021            eprintln!("daemon: push error: {e:#}");
5022            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5023        });
5024        let pulled = run_sync_pull().unwrap_or_else(|e| {
5025            eprintln!("daemon: pull error: {e:#}");
5026            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5027        });
5028        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5029            eprintln!("daemon: pending-pair tick error: {e:#}");
5030            json!({"transitions": []})
5031        });
5032
5033        if as_json {
5034            println!(
5035                "{}",
5036                serde_json::to_string(&json!({
5037                    "ts": time::OffsetDateTime::now_utc()
5038                        .format(&time::format_description::well_known::Rfc3339)
5039                        .unwrap_or_default(),
5040                    "push": pushed,
5041                    "pull": pulled,
5042                    "pairs": pairs,
5043                }))?
5044            );
5045        } else {
5046            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5047            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5048            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5049            let pair_transitions = pairs["transitions"]
5050                .as_array()
5051                .map(|a| a.len())
5052                .unwrap_or(0);
5053            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5054                eprintln!(
5055                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5056                );
5057            }
5058            // Loud per-transition logging so operator sees pair progress live.
5059            if let Some(arr) = pairs["transitions"].as_array() {
5060                for t in arr {
5061                    eprintln!(
5062                        "  pair {} : {} → {}",
5063                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
5064                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
5065                        t.get("to").and_then(Value::as_str).unwrap_or("?")
5066                    );
5067                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
5068                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5069                    {
5070                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
5071                        eprintln!(
5072                            "    Run: wire pair-confirm {} {}",
5073                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
5074                            sas
5075                        );
5076                    }
5077                }
5078            }
5079        }
5080
5081        if once {
5082            return Ok(());
5083        }
5084        // Wait either for the next poll-interval tick OR for a stream
5085        // wake signal — whichever comes first. Drain any additional
5086        // wake-ups that accumulated during the previous cycle since one
5087        // pull catches up everything.
5088        let _ = wake_rx.recv_timeout(interval);
5089        while wake_rx.try_recv().is_ok() {}
5090    }
5091}
5092
5093/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
5094/// shape `wire push --json` emits.
5095fn run_sync_push() -> Result<Value> {
5096    let state = config::read_relay_state()?;
5097    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5098    if peers.is_empty() {
5099        return Ok(json!({"pushed": [], "skipped": []}));
5100    }
5101    let outbox_dir = config::outbox_dir()?;
5102    if !outbox_dir.exists() {
5103        return Ok(json!({"pushed": [], "skipped": []}));
5104    }
5105    let mut pushed = Vec::new();
5106    let mut skipped = Vec::new();
5107    for (peer_handle, slot_info) in peers.iter() {
5108        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5109        if !outbox.exists() {
5110            continue;
5111        }
5112        let url = slot_info["relay_url"].as_str().unwrap_or("");
5113        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5114        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5115        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5116            continue;
5117        }
5118        let client = crate::relay_client::RelayClient::new(url);
5119        let body = std::fs::read_to_string(&outbox)?;
5120        for line in body.lines() {
5121            let event: Value = match serde_json::from_str(line) {
5122                Ok(v) => v,
5123                Err(_) => continue,
5124            };
5125            let event_id = event
5126                .get("event_id")
5127                .and_then(Value::as_str)
5128                .unwrap_or("")
5129                .to_string();
5130            match client.post_event(slot_id, slot_token, &event) {
5131                Ok(resp) => {
5132                    if resp.status == "duplicate" {
5133                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5134                    } else {
5135                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5136                    }
5137                }
5138                Err(e) => {
5139                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
5140                    // errors aren't hidden behind the topmost-context URL string.
5141                    // Issue #6 highest-impact silent-fail fix.
5142                    let reason = crate::relay_client::format_transport_error(&e);
5143                    skipped
5144                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5145                }
5146            }
5147        }
5148    }
5149    Ok(json!({"pushed": pushed, "skipped": skipped}))
5150}
5151
5152/// Programmatic pull. Same shape as `wire pull --json`.
5153///
5154/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
5155/// created via `wire session new --with-local` (which only writes
5156/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
5157/// Pre-v0.9 this function read only the top-level fields and silently
5158/// returned `{}` for any v0.5.17+ session.
5159fn run_sync_pull() -> Result<Value> {
5160    let state = config::read_relay_state()?;
5161    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5162    if self_state.is_null() {
5163        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5164    }
5165    let ep = match crate::endpoints::self_primary_endpoint(&state) {
5166        Some(e) => e,
5167        None => return Ok(json!({"written": [], "rejected": [], "total_seen": 0})),
5168    };
5169    let url = ep.relay_url.as_str();
5170    let slot_id = ep.slot_id.as_str();
5171    let slot_token = ep.slot_token.as_str();
5172    let last_event_id = self_state
5173        .get("last_pulled_event_id")
5174        .and_then(Value::as_str)
5175        .map(str::to_string);
5176    if url.is_empty() {
5177        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5178    }
5179    let client = crate::relay_client::RelayClient::new(url);
5180    let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
5181    let inbox_dir = config::inbox_dir()?;
5182    config::ensure_dirs()?;
5183
5184    // P0.1 (0.5.11): shared cursor-blocking logic. Daemon's --once path
5185    // must match the CLI's `wire pull` semantics or version-skew bugs
5186    // re-emerge by another route.
5187    let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
5188
5189    // P0.3 (0.5.11): same flock-protected RMW as cmd_pull.
5190    if let Some(eid) = &result.advance_cursor_to {
5191        let eid = eid.clone();
5192        config::update_relay_state(|state| {
5193            if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5194                self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
5195            }
5196            Ok(())
5197        })?;
5198    }
5199
5200    Ok(json!({
5201        "written": result.written,
5202        "rejected": result.rejected,
5203        "total_seen": events.len(),
5204        "cursor_blocked": result.blocked,
5205        "cursor_advanced_to": result.advance_cursor_to,
5206    }))
5207}
5208
5209// ---------- pin (manual out-of-band peer pairing) ----------
5210
5211fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5212    let body =
5213        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5214    let card: Value =
5215        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5216    crate::agent_card::verify_agent_card(&card)
5217        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5218
5219    let mut trust = config::read_trust()?;
5220    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5221
5222    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5223    let handle = crate::agent_card::display_handle_from_did(did).to_string();
5224    config::write_trust(&trust)?;
5225
5226    if as_json {
5227        println!(
5228            "{}",
5229            serde_json::to_string(&json!({
5230                "handle": handle,
5231                "did": did,
5232                "tier": "VERIFIED",
5233                "pinned": true,
5234            }))?
5235        );
5236    } else {
5237        println!("pinned {handle} ({did}) at tier VERIFIED");
5238    }
5239    Ok(())
5240}
5241
5242// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
5243
5244fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5245    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5246}
5247
5248fn cmd_pair_join(
5249    code_phrase: &str,
5250    relay_url: &str,
5251    auto_yes: bool,
5252    timeout_secs: u64,
5253) -> Result<()> {
5254    pair_orchestrate(
5255        relay_url,
5256        Some(code_phrase),
5257        "guest",
5258        auto_yes,
5259        timeout_secs,
5260    )
5261}
5262
5263/// Shared orchestration for both sides of the SAS pairing.
5264///
5265/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
5266/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
5267/// `pair_session_confirm_sas` instead.
5268fn pair_orchestrate(
5269    relay_url: &str,
5270    code_in: Option<&str>,
5271    role: &str,
5272    auto_yes: bool,
5273    timeout_secs: u64,
5274) -> Result<()> {
5275    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5276
5277    let mut s = pair_session_open(role, relay_url, code_in)?;
5278
5279    if role == "host" {
5280        eprintln!();
5281        eprintln!("share this code phrase with your peer:");
5282        eprintln!();
5283        eprintln!("    {}", s.code);
5284        eprintln!();
5285        eprintln!(
5286            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5287            s.code
5288        );
5289    } else {
5290        eprintln!();
5291        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5292    }
5293
5294    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
5295    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
5296    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
5297    // see the process is alive while the other side connects.
5298    const HEARTBEAT_SECS: u64 = 10;
5299    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5300    let started = std::time::Instant::now();
5301    let mut last_heartbeat = started;
5302    let formatted = loop {
5303        if let Some(sas) = pair_session_try_sas(&mut s)? {
5304            break sas;
5305        }
5306        let now = std::time::Instant::now();
5307        if now >= deadline {
5308            return Err(anyhow!(
5309                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5310            ));
5311        }
5312        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5313            let elapsed = now.duration_since(started).as_secs();
5314            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
5315            last_heartbeat = now;
5316        }
5317        std::thread::sleep(std::time::Duration::from_millis(250));
5318    };
5319
5320    eprintln!();
5321    eprintln!("SAS digits (must match peer's terminal):");
5322    eprintln!();
5323    eprintln!("    {formatted}");
5324    eprintln!();
5325
5326    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
5327    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
5328    if !auto_yes {
5329        eprint!("does this match your peer's terminal? [y/N]: ");
5330        use std::io::Write;
5331        std::io::stderr().flush().ok();
5332        let mut input = String::new();
5333        std::io::stdin().read_line(&mut input)?;
5334        let trimmed = input.trim().to_lowercase();
5335        if trimmed != "y" && trimmed != "yes" {
5336            bail!("SAS confirmation declined — aborting pairing");
5337        }
5338    }
5339    s.sas_confirmed = true;
5340
5341    // Stage 4 — seal+exchange bootstrap, pin peer.
5342    let result = pair_session_finalize(&mut s, timeout_secs)?;
5343
5344    let peer_did = result["paired_with"].as_str().unwrap_or("");
5345    let peer_role = if role == "host" { "guest" } else { "host" };
5346    eprintln!("paired with {peer_did} (peer role: {peer_role})");
5347    eprintln!("peer card pinned at tier VERIFIED");
5348    eprintln!(
5349        "peer relay slot saved to {}",
5350        config::relay_state_path()?.display()
5351    );
5352
5353    println!("{}", serde_json::to_string(&result)?);
5354    Ok(())
5355}
5356
5357// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
5358// and pair_session_finalize, both of which inline their own deadline loops.)
5359
5360// ---------- pair — single-shot init + pair-* + setup ----------
5361
5362fn cmd_pair(
5363    handle: &str,
5364    code: Option<&str>,
5365    relay: &str,
5366    auto_yes: bool,
5367    timeout_secs: u64,
5368    no_setup: bool,
5369) -> Result<()> {
5370    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
5371    // bails loudly if a different handle is already set (operator must explicitly delete).
5372    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5373    let did = init_result
5374        .get("did")
5375        .and_then(|v| v.as_str())
5376        .unwrap_or("(unknown)")
5377        .to_string();
5378    let already = init_result
5379        .get("already_initialized")
5380        .and_then(|v| v.as_bool())
5381        .unwrap_or(false);
5382    if already {
5383        println!("(identity {did} already initialized — reusing)");
5384    } else {
5385        println!("initialized {did}");
5386    }
5387    println!();
5388
5389    // Step 2 — pair-host or pair-join based on code presence.
5390    match code {
5391        None => {
5392            println!("hosting pair on {relay} (no code = host) ...");
5393            cmd_pair_host(relay, auto_yes, timeout_secs)?;
5394        }
5395        Some(c) => {
5396            println!("joining pair with code {c} on {relay} ...");
5397            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5398        }
5399    }
5400
5401    // Step 3 — register wire as MCP server in detected client configs (idempotent).
5402    if !no_setup {
5403        println!();
5404        println!("registering wire as MCP server in detected client configs ...");
5405        if let Err(e) = cmd_setup(true) {
5406            // Non-fatal — pair succeeded, just print the warning.
5407            eprintln!("warn: setup --apply failed: {e}");
5408            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
5409        }
5410    }
5411
5412    println!();
5413    println!("pair complete. Next steps:");
5414    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
5415    println!("  wire send <peer> claim <msg>   # send your peer something");
5416    println!("  wire tail                      # watch incoming events");
5417    Ok(())
5418}
5419
5420// ---------- detached pair (daemon-orchestrated) ----------
5421
5422/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
5423/// pair-host/-join into a single command. The non-detached variant lives in
5424/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
5425fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5426    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5427    let did = init_result
5428        .get("did")
5429        .and_then(|v| v.as_str())
5430        .unwrap_or("(unknown)")
5431        .to_string();
5432    let already = init_result
5433        .get("already_initialized")
5434        .and_then(|v| v.as_bool())
5435        .unwrap_or(false);
5436    if already {
5437        println!("(identity {did} already initialized — reusing)");
5438    } else {
5439        println!("initialized {did}");
5440    }
5441    println!();
5442    match code {
5443        None => cmd_pair_host_detach(relay, false),
5444        Some(c) => cmd_pair_join_detach(c, relay, false),
5445    }
5446}
5447
5448fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5449    if !config::is_initialized()? {
5450        bail!("not initialized — run `wire init <handle>` first");
5451    }
5452    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5453        Ok(b) => b,
5454        Err(e) => {
5455            if !as_json {
5456                eprintln!(
5457                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5458                );
5459            }
5460            false
5461        }
5462    };
5463    let code = crate::sas::generate_code_phrase();
5464    let code_hash = crate::pair_session::derive_code_hash(&code);
5465    let now = time::OffsetDateTime::now_utc()
5466        .format(&time::format_description::well_known::Rfc3339)
5467        .unwrap_or_default();
5468    let p = crate::pending_pair::PendingPair {
5469        code: code.clone(),
5470        code_hash,
5471        role: "host".to_string(),
5472        relay_url: relay_url.to_string(),
5473        status: "request_host".to_string(),
5474        sas: None,
5475        peer_did: None,
5476        created_at: now,
5477        last_error: None,
5478        pair_id: None,
5479        our_slot_id: None,
5480        our_slot_token: None,
5481        spake2_seed_b64: None,
5482    };
5483    crate::pending_pair::write_pending(&p)?;
5484    if as_json {
5485        println!(
5486            "{}",
5487            serde_json::to_string(&json!({
5488                "state": "queued",
5489                "code_phrase": code,
5490                "relay_url": relay_url,
5491                "role": "host",
5492                "daemon_spawned": daemon_spawned,
5493            }))?
5494        );
5495    } else {
5496        if daemon_spawned {
5497            println!("(started wire daemon in background)");
5498        }
5499        println!("detached pair-host queued. Share this code with your peer:\n");
5500        println!("    {code}\n");
5501        println!("Next steps:");
5502        println!("  wire pair-list                                # check status");
5503        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
5504        println!("  wire pair-cancel  {code}            # to abort");
5505    }
5506    Ok(())
5507}
5508
5509fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
5510    if !config::is_initialized()? {
5511        bail!("not initialized — run `wire init <handle>` first");
5512    }
5513    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5514        Ok(b) => b,
5515        Err(e) => {
5516            if !as_json {
5517                eprintln!(
5518                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5519                );
5520            }
5521            false
5522        }
5523    };
5524    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5525    let code_hash = crate::pair_session::derive_code_hash(&code);
5526    let now = time::OffsetDateTime::now_utc()
5527        .format(&time::format_description::well_known::Rfc3339)
5528        .unwrap_or_default();
5529    let p = crate::pending_pair::PendingPair {
5530        code: code.clone(),
5531        code_hash,
5532        role: "guest".to_string(),
5533        relay_url: relay_url.to_string(),
5534        status: "request_guest".to_string(),
5535        sas: None,
5536        peer_did: None,
5537        created_at: now,
5538        last_error: None,
5539        pair_id: None,
5540        our_slot_id: None,
5541        our_slot_token: None,
5542        spake2_seed_b64: None,
5543    };
5544    crate::pending_pair::write_pending(&p)?;
5545    if as_json {
5546        println!(
5547            "{}",
5548            serde_json::to_string(&json!({
5549                "state": "queued",
5550                "code_phrase": code,
5551                "relay_url": relay_url,
5552                "role": "guest",
5553                "daemon_spawned": daemon_spawned,
5554            }))?
5555        );
5556    } else {
5557        if daemon_spawned {
5558            println!("(started wire daemon in background)");
5559        }
5560        println!("detached pair-join queued for code {code}.");
5561        println!(
5562            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
5563        );
5564    }
5565    Ok(())
5566}
5567
5568fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
5569    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5570    let typed: String = typed_digits
5571        .chars()
5572        .filter(|c| c.is_ascii_digit())
5573        .collect();
5574    if typed.len() != 6 {
5575        bail!(
5576            "expected 6 digits (got {} after stripping non-digits)",
5577            typed.len()
5578        );
5579    }
5580    let mut p = crate::pending_pair::read_pending(&code)?
5581        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
5582    if p.status != "sas_ready" {
5583        bail!(
5584            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
5585            p.status
5586        );
5587    }
5588    let stored = p
5589        .sas
5590        .as_ref()
5591        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
5592        .clone();
5593    if stored == typed {
5594        p.status = "confirmed".to_string();
5595        crate::pending_pair::write_pending(&p)?;
5596        if as_json {
5597            println!(
5598                "{}",
5599                serde_json::to_string(&json!({
5600                    "state": "confirmed",
5601                    "code_phrase": code,
5602                }))?
5603            );
5604        } else {
5605            println!("digits match. Daemon will finalize the handshake on its next tick.");
5606            println!("Run `wire peers` after a few seconds to confirm.");
5607        }
5608    } else {
5609        p.status = "aborted".to_string();
5610        p.last_error = Some(format!(
5611            "SAS digit mismatch (typed {typed}, expected {stored})"
5612        ));
5613        let client = crate::relay_client::RelayClient::new(&p.relay_url);
5614        let _ = client.pair_abandon(&p.code_hash);
5615        crate::pending_pair::write_pending(&p)?;
5616        crate::os_notify::toast(
5617            &format!("wire — pair aborted ({})", p.code),
5618            p.last_error.as_deref().unwrap_or("digits mismatch"),
5619        );
5620        if as_json {
5621            println!(
5622                "{}",
5623                serde_json::to_string(&json!({
5624                    "state": "aborted",
5625                    "code_phrase": code,
5626                    "error": "digits mismatch",
5627                }))?
5628            );
5629        }
5630        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
5631    }
5632    Ok(())
5633}
5634
5635fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
5636    if watch {
5637        return cmd_pair_list_watch(watch_interval_secs);
5638    }
5639    let spake2_items = crate::pending_pair::list_pending()?;
5640    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
5641    if as_json {
5642        // Backwards-compat: flat SPAKE2 array (the shape every existing
5643        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
5644        // surface programmatically via `wire pair-list-inbound --json`
5645        // and via `wire status --json` `pending_pairs.inbound_*` fields.
5646        println!("{}", serde_json::to_string(&spake2_items)?);
5647        return Ok(());
5648    }
5649    if spake2_items.is_empty() && inbound_items.is_empty() {
5650        println!("no pending pair sessions.");
5651        return Ok(());
5652    }
5653    // v0.5.14: inbound section first — these need operator action right now.
5654    // SPAKE2 sessions are typically already mid-flow.
5655    if !inbound_items.is_empty() {
5656        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
5657        println!(
5658            "{:<20} {:<35} {:<25} NEXT STEP",
5659            "PEER", "RELAY", "RECEIVED"
5660        );
5661        for p in &inbound_items {
5662            println!(
5663                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
5664                p.peer_handle,
5665                p.peer_relay_url,
5666                p.received_at,
5667                peer = p.peer_handle,
5668            );
5669        }
5670        println!();
5671    }
5672    if !spake2_items.is_empty() {
5673        println!("SPAKE2 SESSIONS");
5674        println!(
5675            "{:<15} {:<8} {:<18} {:<10} NOTE",
5676            "CODE", "ROLE", "STATUS", "SAS"
5677        );
5678        for p in spake2_items {
5679            let sas = p
5680                .sas
5681                .as_ref()
5682                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
5683                .unwrap_or_else(|| "—".to_string());
5684            let note = p
5685                .last_error
5686                .as_deref()
5687                .or(p.peer_did.as_deref())
5688                .unwrap_or("");
5689            println!(
5690                "{:<15} {:<8} {:<18} {:<10} {}",
5691                p.code, p.role, p.status, sas, note
5692            );
5693        }
5694    }
5695    Ok(())
5696}
5697
5698/// Stream-mode pair-list: never exits. Diffs per-code state every
5699/// `interval_secs` and prints one JSON line per transition (creation,
5700/// status flip, deletion). Useful for shell pipelines:
5701///
5702/// ```text
5703/// wire pair-list --watch | while read line; do
5704///     CODE=$(echo "$line" | jq -r .code)
5705///     STATUS=$(echo "$line" | jq -r .status)
5706///     ...
5707/// done
5708/// ```
5709fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
5710    use std::collections::HashMap;
5711    use std::io::Write;
5712    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5713    // Emit a snapshot synthetic event for every currently-pending pair on
5714    // startup so a consumer that arrives mid-flight sees the current state.
5715    let mut prev: HashMap<String, String> = HashMap::new();
5716    {
5717        let items = crate::pending_pair::list_pending()?;
5718        for p in &items {
5719            println!("{}", serde_json::to_string(&p)?);
5720            prev.insert(p.code.clone(), p.status.clone());
5721        }
5722        // Flush so the consumer's `while read` gets the snapshot promptly.
5723        let _ = std::io::stdout().flush();
5724    }
5725    loop {
5726        std::thread::sleep(interval);
5727        let items = match crate::pending_pair::list_pending() {
5728            Ok(v) => v,
5729            Err(_) => continue,
5730        };
5731        let mut cur: HashMap<String, String> = HashMap::new();
5732        for p in &items {
5733            cur.insert(p.code.clone(), p.status.clone());
5734            match prev.get(&p.code) {
5735                None => {
5736                    // New code appeared.
5737                    println!("{}", serde_json::to_string(&p)?);
5738                }
5739                Some(prev_status) if prev_status != &p.status => {
5740                    // Status flipped.
5741                    println!("{}", serde_json::to_string(&p)?);
5742                }
5743                _ => {}
5744            }
5745        }
5746        for code in prev.keys() {
5747            if !cur.contains_key(code) {
5748                // File disappeared → finalized or cancelled. Emit a synthetic
5749                // "removed" marker so the consumer sees the terminal event.
5750                println!(
5751                    "{}",
5752                    serde_json::to_string(&json!({
5753                        "code": code,
5754                        "status": "removed",
5755                        "_synthetic": true,
5756                    }))?
5757                );
5758            }
5759        }
5760        let _ = std::io::stdout().flush();
5761        prev = cur;
5762    }
5763}
5764
5765/// Block until a pending pair reaches `target_status` or terminates. Process
5766/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
5767/// timeout) so shell scripts can branch directly.
5768fn cmd_pair_watch(
5769    code_phrase: &str,
5770    target_status: &str,
5771    timeout_secs: u64,
5772    as_json: bool,
5773) -> Result<()> {
5774    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5775    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5776    let mut last_seen_status: Option<String> = None;
5777    loop {
5778        let p_opt = crate::pending_pair::read_pending(&code)?;
5779        let now = std::time::Instant::now();
5780        match p_opt {
5781            None => {
5782                // File gone — either finalized (success if target=sas_ready
5783                // since finalization implies it passed sas_ready) or never
5784                // existed. Distinguish by whether we ever saw it.
5785                if last_seen_status.is_some() {
5786                    if as_json {
5787                        println!(
5788                            "{}",
5789                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5790                        );
5791                    } else {
5792                        println!("pair {code} finalized (file removed)");
5793                    }
5794                    return Ok(());
5795                } else {
5796                    if as_json {
5797                        println!(
5798                            "{}",
5799                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5800                        );
5801                    }
5802                    std::process::exit(1);
5803                }
5804            }
5805            Some(p) => {
5806                let cur = p.status.clone();
5807                if Some(cur.clone()) != last_seen_status {
5808                    if as_json {
5809                        // Emit per-transition line so scripts can stream.
5810                        println!("{}", serde_json::to_string(&p)?);
5811                    }
5812                    last_seen_status = Some(cur.clone());
5813                }
5814                if cur == target_status {
5815                    if !as_json {
5816                        let sas_str = p
5817                            .sas
5818                            .as_ref()
5819                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5820                            .unwrap_or_else(|| "—".to_string());
5821                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
5822                    }
5823                    return Ok(());
5824                }
5825                if cur == "aborted" || cur == "aborted_restart" {
5826                    if !as_json {
5827                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
5828                        eprintln!("pair {code} {cur}: {err}");
5829                    }
5830                    std::process::exit(1);
5831                }
5832            }
5833        }
5834        if now >= deadline {
5835            if !as_json {
5836                eprintln!(
5837                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5838                );
5839            }
5840            std::process::exit(2);
5841        }
5842        std::thread::sleep(std::time::Duration::from_millis(250));
5843    }
5844}
5845
5846fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5847    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5848    let p = crate::pending_pair::read_pending(&code)?
5849        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5850    let client = crate::relay_client::RelayClient::new(&p.relay_url);
5851    let _ = client.pair_abandon(&p.code_hash);
5852    crate::pending_pair::delete_pending(&code)?;
5853    if as_json {
5854        println!(
5855            "{}",
5856            serde_json::to_string(&json!({
5857                "state": "cancelled",
5858                "code_phrase": code,
5859            }))?
5860        );
5861    } else {
5862        println!("cancelled pending pair {code} (relay slot released, file removed).");
5863    }
5864    Ok(())
5865}
5866
5867// ---------- pair-abandon — release stuck pair-slot ----------
5868
5869fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5870    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
5871    // typed — normalize via the existing parser.
5872    let code = crate::sas::parse_code_phrase(code_phrase)?;
5873    let code_hash = crate::pair_session::derive_code_hash(code);
5874    let client = crate::relay_client::RelayClient::new(relay_url);
5875    client.pair_abandon(&code_hash)?;
5876    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
5877    println!("host can now issue a fresh code; guest can re-join.");
5878    Ok(())
5879}
5880
5881// ---------- invite / accept — one-paste pair (v0.4.0) ----------
5882
5883fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
5884    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
5885
5886    // If --share, register the invite at the relay's short-URL endpoint and
5887    // build the one-curl onboarding line for the peer to paste.
5888    let share_payload: Option<Value> = if share {
5889        let client = reqwest::blocking::Client::new();
5890        let single_use = if uses == 1 { Some(1u32) } else { None };
5891        let body = json!({
5892            "invite_url": url,
5893            "ttl_seconds": ttl,
5894            "uses": single_use,
5895        });
5896        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
5897        let resp = client.post(&endpoint).json(&body).send()?;
5898        if !resp.status().is_success() {
5899            let code = resp.status();
5900            let txt = resp.text().unwrap_or_default();
5901            bail!("relay {code} on /v1/invite/register: {txt}");
5902        }
5903        let parsed: Value = resp.json()?;
5904        let token = parsed
5905            .get("token")
5906            .and_then(Value::as_str)
5907            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
5908            .to_string();
5909        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
5910        let curl_line = format!("curl -fsSL {share_url} | sh");
5911        Some(json!({
5912            "token": token,
5913            "share_url": share_url,
5914            "curl": curl_line,
5915            "expires_unix": parsed.get("expires_unix"),
5916        }))
5917    } else {
5918        None
5919    };
5920
5921    if as_json {
5922        let mut out = json!({
5923            "invite_url": url,
5924            "ttl_secs": ttl,
5925            "uses": uses,
5926            "relay": relay,
5927        });
5928        if let Some(s) = &share_payload {
5929            out["share"] = s.clone();
5930        }
5931        println!("{}", serde_json::to_string(&out)?);
5932    } else if let Some(s) = share_payload {
5933        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
5934        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
5935        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
5936        println!("{curl}");
5937    } else {
5938        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
5939        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
5940        println!("{url}");
5941    }
5942    Ok(())
5943}
5944
5945fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
5946    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
5947    // resolve it to the underlying wire://pair?... URL via ?format=url before
5948    // accepting. Saves them from having to know which URL shape goes where.
5949    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
5950        let sep = if url.contains('?') { '&' } else { '?' };
5951        let resolve_url = format!("{url}{sep}format=url");
5952        let client = reqwest::blocking::Client::new();
5953        let resp = client
5954            .get(&resolve_url)
5955            .send()
5956            .with_context(|| format!("GET {resolve_url}"))?;
5957        if !resp.status().is_success() {
5958            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
5959        }
5960        let body = resp.text().unwrap_or_default().trim().to_string();
5961        if !body.starts_with("wire://pair?") {
5962            bail!(
5963                "short URL {url} did not resolve to a wire:// invite. \
5964                 (got: {}{})",
5965                body.chars().take(80).collect::<String>(),
5966                if body.chars().count() > 80 { "…" } else { "" }
5967            );
5968        }
5969        body
5970    } else {
5971        url.to_string()
5972    };
5973
5974    let result = crate::pair_invite::accept_invite(&resolved)?;
5975    if as_json {
5976        println!("{}", serde_json::to_string(&result)?);
5977    } else {
5978        let did = result
5979            .get("paired_with")
5980            .and_then(Value::as_str)
5981            .unwrap_or("?");
5982        println!("paired with {did}");
5983        println!(
5984            "you can now: wire send {} <kind> <body>",
5985            crate::agent_card::display_handle_from_did(did)
5986        );
5987    }
5988    Ok(())
5989}
5990
5991// ---------- whois / profile (v0.5) ----------
5992
5993fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
5994    if let Some(h) = handle {
5995        let parsed = crate::pair_profile::parse_handle(h)?;
5996        // Special-case: if the supplied handle matches our own, skip the
5997        // network round-trip and print local.
5998        if config::is_initialized()? {
5999            let card = config::read_agent_card()?;
6000            let local_handle = card
6001                .get("profile")
6002                .and_then(|p| p.get("handle"))
6003                .and_then(Value::as_str)
6004                .map(str::to_string);
6005            if local_handle.as_deref() == Some(h) {
6006                return cmd_whois(None, as_json, None);
6007            }
6008        }
6009        // Remote resolution via .well-known/wire/agent on the handle's domain.
6010        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6011        if as_json {
6012            println!("{}", serde_json::to_string(&resolved)?);
6013        } else {
6014            print_resolved_profile(&resolved);
6015        }
6016        return Ok(());
6017    }
6018    let card = config::read_agent_card()?;
6019    if as_json {
6020        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6021        println!(
6022            "{}",
6023            serde_json::to_string(&json!({
6024                "did": card.get("did").cloned().unwrap_or(Value::Null),
6025                "profile": profile,
6026            }))?
6027        );
6028    } else {
6029        print!("{}", crate::pair_profile::render_self_summary()?);
6030    }
6031    Ok(())
6032}
6033
6034fn print_resolved_profile(resolved: &Value) {
6035    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6036    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6037    let relay = resolved
6038        .get("relay_url")
6039        .and_then(Value::as_str)
6040        .unwrap_or("");
6041    let slot = resolved
6042        .get("slot_id")
6043        .and_then(Value::as_str)
6044        .unwrap_or("");
6045    let profile = resolved
6046        .get("card")
6047        .and_then(|c| c.get("profile"))
6048        .cloned()
6049        .unwrap_or(Value::Null);
6050    println!("{did}");
6051    println!("  nick:         {nick}");
6052    if !relay.is_empty() {
6053        println!("  relay_url:    {relay}");
6054    }
6055    if !slot.is_empty() {
6056        println!("  slot_id:      {slot}");
6057    }
6058    let pick =
6059        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6060    if let Some(s) = pick("display_name") {
6061        println!("  display_name: {s}");
6062    }
6063    if let Some(s) = pick("emoji") {
6064        println!("  emoji:        {s}");
6065    }
6066    if let Some(s) = pick("motto") {
6067        println!("  motto:        {s}");
6068    }
6069    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6070        let joined: Vec<String> = arr
6071            .iter()
6072            .filter_map(|v| v.as_str().map(str::to_string))
6073            .collect();
6074        println!("  vibe:         {}", joined.join(", "));
6075    }
6076    if let Some(s) = pick("pronouns") {
6077        println!("  pronouns:     {s}");
6078    }
6079}
6080
6081/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
6082/// signed pair_drop event with our card + slot coords, deliver via the
6083/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
6084/// Peer's daemon completes the bilateral pin on its next pull and emits a
6085/// pair_drop_ack carrying their slot_token so we can send back.
6086/// Extract just the host portion from `https://host:port/path` → `host`.
6087/// Returns empty string if the URL is malformed.
6088fn host_of_url(url: &str) -> String {
6089    let no_scheme = url
6090        .trim_start_matches("https://")
6091        .trim_start_matches("http://");
6092    no_scheme
6093        .split('/')
6094        .next()
6095        .unwrap_or("")
6096        .split(':')
6097        .next()
6098        .unwrap_or("")
6099        .to_string()
6100}
6101
6102/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
6103/// operator's own relay? Used to suppress the cross-relay phishing
6104/// warning in `wire add` for the happy path.
6105fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6106    // Hard-coded known-good list. wireup.net is the default relay.
6107    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6108    let peer_domain = peer_domain.trim().to_ascii_lowercase();
6109    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6110        return true;
6111    }
6112    // Operator's OWN relay is implicitly trusted — they're already
6113    // bound to it; pairing same-relay peers is the common case.
6114    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6115    if !our_host.is_empty() && our_host == peer_domain {
6116        return true;
6117    }
6118    false
6119}
6120
6121/// v0.6.6: pair with a sister session on this machine without federation.
6122/// Reads the sister's agent-card + endpoints from disk, pins them into our
6123/// trust + relay_state, builds the same `pair_drop` event the federation
6124/// path would emit, then POSTs it directly to the sister's local-relay slot.
6125/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
6126/// the cwd-derived `wire`) are addressable because the local relay never
6127/// needed a public claim for sister coordination.
6128/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
6129/// to a local sister session.
6130///
6131/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
6132/// either form. Exact session-name matches always win; nickname matches
6133/// are a fallback so operators can type "winter-bay" instead of "wire".
6134/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
6135/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
6136/// with the candidate list so the caller can surface a disambiguation
6137/// hint instead of silently picking one.
6138fn resolve_local_session<'a>(
6139    sessions: &'a [crate::session::SessionInfo],
6140    input: &str,
6141) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6142    // Exact session-name match always wins, even if a nickname elsewhere
6143    // also matches. Predictable for scripts and operator muscle memory.
6144    if let Some(s) = sessions.iter().find(|s| s.name == input) {
6145        return Ok(s);
6146    }
6147    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6148        .iter()
6149        .filter(|s| {
6150            s.character
6151                .as_ref()
6152                .map(|c| c.nickname == input)
6153                .unwrap_or(false)
6154        })
6155        .collect();
6156    match nick_matches.len() {
6157        0 => Err(ResolveError::NotFound),
6158        1 => Ok(nick_matches[0]),
6159        _ => Err(ResolveError::Ambiguous(
6160            nick_matches.iter().map(|s| s.name.clone()).collect(),
6161        )),
6162    }
6163}
6164
6165#[derive(Debug)]
6166enum ResolveError {
6167    NotFound,
6168    Ambiguous(Vec<String>),
6169}
6170
6171/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
6172/// to a pinned peer's canonical handle.
6173///
6174/// `wire send <peer>` accepts either the handle the peer registered with
6175/// or their character nickname (DID-hash-derived). Exact handle match
6176/// always wins. When a nickname matches multiple peers (theoretically
6177/// possible via DID-hash collision in the (adj, noun) space), returns
6178/// `Ambiguous` so the caller can surface a disambiguation hint instead
6179/// of silently picking one.
6180///
6181/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
6182/// overrides on the peer's side live in their local `display.json` and
6183/// aren't yet published via agent-card. (That's the v0.7+ federation
6184/// lifecycle work — peers publishing overrides so we resolve by what
6185/// they call themselves, not just what their DID hashes to.)
6186fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6187    let trust = match config::read_trust() {
6188        Ok(t) => t,
6189        Err(_) => return Ok(None),
6190    };
6191    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6192        Some(a) => a,
6193        None => return Ok(None),
6194    };
6195    if agents.contains_key(input) {
6196        return Ok(Some(input.to_string()));
6197    }
6198    let mut nick_matches: Vec<String> = Vec::new();
6199    for (handle, agent) in agents.iter() {
6200        // v0.7.0-alpha.6: prefer peer's published display nickname over
6201        // auto-derived. Allows `wire send <their-chosen-name>` not just
6202        // `wire send <their-did-hash-derived-name>`.
6203        let character = match agent.get("card") {
6204            Some(card) => crate::character::Character::from_card(card),
6205            None => match agent.get("did").and_then(Value::as_str) {
6206                Some(did) => crate::character::Character::from_did(did),
6207                None => continue,
6208            },
6209        };
6210        if character.nickname == input {
6211            nick_matches.push(handle.clone());
6212        }
6213    }
6214    match nick_matches.len() {
6215        0 => Ok(None),
6216        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6217        _ => Err(ResolveError::Ambiguous(nick_matches)),
6218    }
6219}
6220
6221fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6222    // 1. Locate sister session by name OR character nickname.
6223    let sessions = crate::session::list_sessions()?;
6224    let sister = match resolve_local_session(&sessions, sister_name) {
6225        Ok(s) => s,
6226        Err(ResolveError::NotFound) => bail!(
6227            "no sister session named `{sister_name}` (matched by session name or character nickname). \
6228             Run `wire session list` to see what's available."
6229        ),
6230        Err(ResolveError::Ambiguous(candidates)) => bail!(
6231            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6232             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6233            candidates.len(),
6234            candidates.join(", ")
6235        ),
6236    };
6237    // If we matched via nickname (not exact name), surface that so the
6238    // operator sees what we resolved to. Quiet when names match exactly.
6239    if sister.name != sister_name {
6240        eprintln!(
6241            "wire add: resolved nickname `{sister_name}` → session `{}`",
6242            sister.name
6243        );
6244    }
6245
6246    // 2. Refuse self-pair — operator owns both sides, but a self-loop
6247    // breaks the bilateral state machine.
6248    let our_card = config::read_agent_card()
6249        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6250    let our_did = our_card
6251        .get("did")
6252        .and_then(Value::as_str)
6253        .ok_or_else(|| anyhow!("agent-card missing did"))?
6254        .to_string();
6255    if let Some(sister_did) = sister.did.as_deref()
6256        && sister_did == our_did
6257    {
6258        bail!("refusing to add self (`{sister_name}` is this very session)");
6259    }
6260
6261    // 3. Read sister's agent-card + relay state from disk.
6262    let sister_card_path = sister
6263        .home_dir
6264        .join("config")
6265        .join("wire")
6266        .join("agent-card.json");
6267    let sister_card: Value = serde_json::from_slice(
6268        &std::fs::read(&sister_card_path)
6269            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6270    )
6271    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6272    let sister_relay_state: Value = std::fs::read(
6273        sister
6274            .home_dir
6275            .join("config")
6276            .join("wire")
6277            .join("relay.json"),
6278    )
6279    .ok()
6280    .and_then(|b| serde_json::from_slice(&b).ok())
6281    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6282
6283    let sister_did = sister_card
6284        .get("did")
6285        .and_then(Value::as_str)
6286        .ok_or_else(|| anyhow!("sister card missing did"))?
6287        .to_string();
6288    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6289
6290    // Pull sister's full endpoint set; we want the local one for delivery
6291    // and we'll pin all of them so OUR pushes prefer local-first per the
6292    // existing routing logic.
6293    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6294    if sister_endpoints.is_empty() {
6295        bail!(
6296            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6297        );
6298    }
6299    let sister_local = sister_endpoints
6300        .iter()
6301        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6302    let delivery_endpoint = match sister_local {
6303        Some(e) => e.clone(),
6304        None => sister_endpoints[0].clone(),
6305    };
6306
6307    // 4. Ensure WE have a slot to advertise back. For local-only sessions
6308    // this is the local slot; for dual-slot sessions, federation is fine.
6309    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
6310    // for pure local-only — instead, pick our own existing federation
6311    // endpoint if present, else fall back to whatever's first.
6312    let our_relay_state = config::read_relay_state()?;
6313    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6314    if our_endpoints.is_empty() {
6315        bail!(
6316            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6317        );
6318    }
6319    let our_advertised = our_endpoints
6320        .iter()
6321        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6322        .cloned()
6323        .unwrap_or_else(|| our_endpoints[0].clone());
6324
6325    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
6326    // relay_state.peers with their full endpoint set. slot_token lands
6327    // via pair_drop_ack as usual.
6328    let mut trust = config::read_trust()?;
6329    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6330    config::write_trust(&trust)?;
6331    let mut relay_state = config::read_relay_state()?;
6332    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6333    config::write_relay_state(&relay_state)?;
6334
6335    // 6. Build the same pair_drop event the federation path emits, with
6336    // our card + endpoints in the body so the sister can pin us back.
6337    let sk_seed = config::read_private_key()?;
6338    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6339    let pk_b64 = our_card
6340        .get("verify_keys")
6341        .and_then(Value::as_object)
6342        .and_then(|m| m.values().next())
6343        .and_then(|v| v.get("key"))
6344        .and_then(Value::as_str)
6345        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6346    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6347    let now = time::OffsetDateTime::now_utc()
6348        .format(&time::format_description::well_known::Rfc3339)
6349        .unwrap_or_default();
6350    let mut body = json!({
6351        "card": our_card,
6352        "relay_url": our_advertised.relay_url,
6353        "slot_id": our_advertised.slot_id,
6354        "slot_token": our_advertised.slot_token,
6355    });
6356    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6357    let event = json!({
6358        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6359        "timestamp": now,
6360        "from": our_did,
6361        "to": sister_did,
6362        "type": "pair_drop",
6363        "kind": 1100u32,
6364        "body": body,
6365    });
6366    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6367    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6368
6369    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
6370    // (the federation handle indexer) — we already know the slot coords
6371    // from disk, so post_event is sufficient.
6372    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6373    client
6374        .post_event(
6375            &delivery_endpoint.slot_id,
6376            &delivery_endpoint.slot_token,
6377            &signed,
6378        )
6379        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6380
6381    if as_json {
6382        println!(
6383            "{}",
6384            serde_json::to_string(&json!({
6385                "handle": sister_name,
6386                "paired_with": sister_did,
6387                "peer_handle": sister_handle,
6388                "event_id": event_id,
6389                "delivered_via": match delivery_endpoint.scope {
6390                    crate::endpoints::EndpointScope::Local => "local",
6391                    crate::endpoints::EndpointScope::Lan => "lan",
6392                    crate::endpoints::EndpointScope::Uds => "uds",
6393                    crate::endpoints::EndpointScope::Federation => "federation",
6394                },
6395                "status": "drop_sent",
6396            }))?
6397        );
6398    } else {
6399        let scope = match delivery_endpoint.scope {
6400            crate::endpoints::EndpointScope::Local => "local",
6401            crate::endpoints::EndpointScope::Lan => "lan",
6402            crate::endpoints::EndpointScope::Uds => "uds",
6403            crate::endpoints::EndpointScope::Federation => "federation",
6404        };
6405        println!(
6406            "→ 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.",
6407            delivery_endpoint.relay_url
6408        );
6409    }
6410    Ok(())
6411}
6412
6413fn cmd_add(
6414    handle_arg: &str,
6415    relay_override: Option<&str>,
6416    local_sister: bool,
6417    as_json: bool,
6418) -> Result<()> {
6419    // v0.7.4: nickname-friendly local-sister resolution. Whether the
6420    // operator passed `--local-sister` explicitly OR just typed a bare
6421    // name (no `@<relay>`), try to resolve through the local sessions
6422    // registry so character nicknames AND session names AND card
6423    // handles all work as input. Closes the "I only know this peer by
6424    // its character name" ergonomic gap that forced operators into
6425    // `wire session list-local | grep <nick> | awk` dances.
6426    if local_sister {
6427        let resolved = crate::session::resolve_local_sister(handle_arg)
6428            .unwrap_or_else(|| handle_arg.to_string());
6429        return cmd_add_local_sister(&resolved, as_json);
6430    }
6431    if !handle_arg.contains('@')
6432        && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6433    {
6434        eprintln!(
6435            "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6436             — routing via --local-sister (disk-read card, no relay lookup)."
6437        );
6438        return cmd_add_local_sister(&resolved, as_json);
6439    }
6440    if !handle_arg.contains('@') {
6441        bail!(
6442            "`{handle_arg}` doesn't match any local sister session and has no \
6443             @<relay> suffix for federation.\n\
6444             — Local sisters: `wire session list-local` (operator types name OR \
6445             character nickname)\n\
6446             — Federation:    `wire add <handle>@<relay-domain>` (e.g. \
6447             `wire add alice@wireup.net`)"
6448        );
6449    }
6450    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6451
6452    // 1. Auto-init self if needed + ensure a relay slot.
6453    let (our_did, our_relay, our_slot_id, our_slot_token) =
6454        crate::pair_invite::ensure_self_with_relay(relay_override)?;
6455    if our_did == format!("did:wire:{}", parsed.nick) {
6456        // Lazy guard — actual self-add would also be caught by FCFS later.
6457        bail!("refusing to add self (handle matches own DID)");
6458    }
6459
6460    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
6461    // already sitting in pending-inbound, the operator is now accepting it.
6462    // Pin trust, save relay coords + slot_token from the stored drop, ship
6463    // our own slot_token back via pair_drop_ack, delete the pending record.
6464    //
6465    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
6466    // receiver-side auto-promote was removed there; operator consent flows
6467    // through here. After this branch returns, both sides are bilaterally
6468    // pinned and capability flows in both directions.
6469    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6470        return cmd_add_accept_pending(
6471            handle_arg,
6472            &parsed.nick,
6473            &pending,
6474            &our_relay,
6475            &our_slot_id,
6476            &our_slot_token,
6477            as_json,
6478        );
6479    }
6480
6481    // v0.5.19 (#9.4): cross-relay phishing guardrail.
6482    //
6483    // Threat: operator wants to add `boss@wireup.net` but types
6484    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
6485    // The .well-known resolution returns whoever claimed the nick on the
6486    // *typo* relay, the bilateral gate still completes (the attacker
6487    // accepts the pair on their side), and the operator pins the
6488    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
6489    // there's no asymmetry to detect when the attacker WANTS to be
6490    // paired.
6491    //
6492    // Mitigation: warn loudly when the peer's relay domain is novel
6493    // (not the operator's own relay, not in a small known-good set).
6494    // Doesn't block — operators have legitimate reasons to pair across
6495    // relays. The signal lands in shell history so a phished operator
6496    // can find it in retrospect.
6497    if !is_known_relay_domain(&parsed.domain, &our_relay) {
6498        eprintln!(
6499            "wire add: WARN unfamiliar relay domain `{}`.",
6500            parsed.domain
6501        );
6502        eprintln!(
6503            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
6504            host_of_url(&our_relay)
6505        );
6506        eprintln!(
6507            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
6508            parsed.nick
6509        );
6510        eprintln!(
6511            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
6512            parsed.nick
6513        );
6514        eprintln!("  peer out-of-band that they actually run a relay at this domain");
6515        eprintln!("  before relying on the pair. (See issue #9.4.)");
6516    }
6517
6518    // 2. Resolve peer via .well-known on their relay.
6519    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6520    let peer_card = resolved
6521        .get("card")
6522        .cloned()
6523        .ok_or_else(|| anyhow!("resolved missing card"))?;
6524    let peer_did = resolved
6525        .get("did")
6526        .and_then(Value::as_str)
6527        .ok_or_else(|| anyhow!("resolved missing did"))?
6528        .to_string();
6529    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
6530    let peer_slot_id = resolved
6531        .get("slot_id")
6532        .and_then(Value::as_str)
6533        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
6534        .to_string();
6535    let peer_relay = resolved
6536        .get("relay_url")
6537        .and_then(Value::as_str)
6538        .map(str::to_string)
6539        .or_else(|| relay_override.map(str::to_string))
6540        .unwrap_or_else(|| format!("https://{}", parsed.domain));
6541
6542    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
6543    let mut trust = config::read_trust()?;
6544    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
6545    config::write_trust(&trust)?;
6546    let mut relay_state = config::read_relay_state()?;
6547    let existing_token = relay_state
6548        .get("peers")
6549        .and_then(|p| p.get(&peer_handle))
6550        .and_then(|p| p.get("slot_token"))
6551        .and_then(Value::as_str)
6552        .map(str::to_string)
6553        .unwrap_or_default();
6554    relay_state["peers"][&peer_handle] = json!({
6555        "relay_url": peer_relay,
6556        "slot_id": peer_slot_id,
6557        "slot_token": existing_token, // empty until pair_drop_ack lands
6558    });
6559    config::write_relay_state(&relay_state)?;
6560
6561    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
6562    // is the v0.5 zero-paste open-mode path).
6563    let our_card = config::read_agent_card()?;
6564    let sk_seed = config::read_private_key()?;
6565    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6566    let pk_b64 = our_card
6567        .get("verify_keys")
6568        .and_then(Value::as_object)
6569        .and_then(|m| m.values().next())
6570        .and_then(|v| v.get("key"))
6571        .and_then(Value::as_str)
6572        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6573    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6574    let now = time::OffsetDateTime::now_utc()
6575        .format(&time::format_description::well_known::Rfc3339)
6576        .unwrap_or_default();
6577    // v0.5.17: advertise all our endpoints (federation + optional local)
6578    // to the peer in the pair_drop body. Back-compat: top-level
6579    // relay_url/slot_id/slot_token still point at the federation
6580    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
6581    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
6582    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6583    let mut body = json!({
6584        "card": our_card,
6585        "relay_url": our_relay,
6586        "slot_id": our_slot_id,
6587        "slot_token": our_slot_token,
6588    });
6589    if !our_endpoints.is_empty() {
6590        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6591    }
6592    let event = json!({
6593        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6594        "timestamp": now,
6595        "from": our_did,
6596        "to": peer_did,
6597        "type": "pair_drop",
6598        "kind": 1100u32,
6599        "body": body,
6600    });
6601    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6602
6603    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
6604    let client = crate::relay_client::RelayClient::new(&peer_relay);
6605    let resp = client.handle_intro(&parsed.nick, &signed)?;
6606    let event_id = signed
6607        .get("event_id")
6608        .and_then(Value::as_str)
6609        .unwrap_or("")
6610        .to_string();
6611
6612    if as_json {
6613        println!(
6614            "{}",
6615            serde_json::to_string(&json!({
6616                "handle": handle_arg,
6617                "paired_with": peer_did,
6618                "peer_handle": peer_handle,
6619                "event_id": event_id,
6620                "drop_response": resp,
6621                "status": "drop_sent",
6622            }))?
6623        );
6624    } else {
6625        println!(
6626            "→ 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."
6627        );
6628    }
6629    Ok(())
6630}
6631
6632/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
6633/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
6634/// coords + slot_token from the stored drop, ship our slot_token back via
6635/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
6636/// invite-URL path (which is already bilateral by virtue of the pre-shared
6637/// nonce).
6638fn cmd_add_accept_pending(
6639    handle_arg: &str,
6640    peer_nick: &str,
6641    pending: &crate::pending_inbound_pair::PendingInboundPair,
6642    _our_relay: &str,
6643    _our_slot_id: &str,
6644    _our_slot_token: &str,
6645    as_json: bool,
6646) -> Result<()> {
6647    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
6648    //    `wire add` against this handle while a drop was waiting.
6649    let mut trust = config::read_trust()?;
6650    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
6651    config::write_trust(&trust)?;
6652
6653    // 2. Record peer's relay coords + slot_token (already shipped to us in
6654    //    the original drop body; held back until now).
6655    // v0.5.17: pin all advertised endpoints (federation + optional local).
6656    // Falls back to a single federation entry when the record was written
6657    // by v0.5.16-era code that didn't carry endpoints[].
6658    let mut relay_state = config::read_relay_state()?;
6659    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
6660        vec![crate::endpoints::Endpoint::federation(
6661            pending.peer_relay_url.clone(),
6662            pending.peer_slot_id.clone(),
6663            pending.peer_slot_token.clone(),
6664        )]
6665    } else {
6666        pending.peer_endpoints.clone()
6667    };
6668    crate::endpoints::pin_peer_endpoints(
6669        &mut relay_state,
6670        &pending.peer_handle,
6671        &endpoints_to_pin,
6672    )?;
6673    config::write_relay_state(&relay_state)?;
6674
6675    // 3. Ship our slot_token to peer via pair_drop_ack so they can write back.
6676    crate::pair_invite::send_pair_drop_ack(
6677        &pending.peer_handle,
6678        &pending.peer_relay_url,
6679        &pending.peer_slot_id,
6680        &pending.peer_slot_token,
6681    )
6682    .with_context(|| {
6683        format!(
6684            "pair_drop_ack send to {} @ {} slot {} failed",
6685            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
6686        )
6687    })?;
6688
6689    // 4. Delete the pending-inbound record now that bilateral is complete.
6690    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
6691
6692    if as_json {
6693        println!(
6694            "{}",
6695            serde_json::to_string(&json!({
6696                "handle": handle_arg,
6697                "paired_with": pending.peer_did,
6698                "peer_handle": pending.peer_handle,
6699                "status": "bilateral_accepted",
6700                "via": "pending_inbound",
6701            }))?
6702        );
6703    } else {
6704        println!(
6705            "→ 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} \"...\"`.",
6706            peer = pending.peer_handle,
6707        );
6708    }
6709    Ok(())
6710}
6711
6712/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
6713/// for a pending-inbound pair request. Pin trust, write relay_state from the
6714/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
6715/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
6716/// when a pending-inbound record exists, but without needing to remember
6717/// the peer's relay domain.
6718fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
6719    let nick = crate::agent_card::bare_handle(peer_nick);
6720    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
6721        anyhow!(
6722            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
6723             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
6724        )
6725    })?;
6726    let (_our_did, our_relay, our_slot_id, our_slot_token) =
6727        crate::pair_invite::ensure_self_with_relay(None)?;
6728    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
6729    cmd_add_accept_pending(
6730        &handle_arg,
6731        nick,
6732        &pending,
6733        &our_relay,
6734        &our_slot_id,
6735        &our_slot_token,
6736        as_json,
6737    )
6738}
6739
6740/// v0.5.14: programmatic access to pending-inbound for scripts.
6741/// `wire pair-list-inbound --json` returns a flat array of records.
6742fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
6743    let items = crate::pending_inbound_pair::list_pending_inbound()?;
6744    if as_json {
6745        println!("{}", serde_json::to_string(&items)?);
6746        return Ok(());
6747    }
6748    if items.is_empty() {
6749        println!("no pending pair requests — your inbox is clear.");
6750        return Ok(());
6751    }
6752    // v0.9.3: conversational output. Tabular data is for --json. Humans
6753    // get one short sentence per pending peer, each rendered with the
6754    // peer's character (DID-derived emoji + nickname) so they can match
6755    // the speaker against their statusline / mesh-status view at a
6756    // glance. The "next step" sentence at the bottom names the exact
6757    // verbs to run.
6758    let plural = if items.len() == 1 { "" } else { "s" };
6759    println!("{} pending pair request{plural}:\n", items.len());
6760    for p in &items {
6761        let ch = crate::character::Character::from_did(&p.peer_did);
6762        let glyph = crate::character::emoji_with_fallback(&ch);
6763        // ASCII-friendly arrow if the operator's terminal can't render
6764        // emoji (the same routine drives the fallback).
6765        println!(
6766            "  {glyph} {nick}  ({handle})  wants to pair with you",
6767            nick = ch.nickname,
6768            handle = p.peer_handle,
6769        );
6770    }
6771    println!();
6772    println!(
6773        "→ to accept any: `wire accept <name>`  (e.g. `wire accept {first}`)",
6774        first = items
6775            .first()
6776            .map(|p| {
6777                let ch = crate::character::Character::from_did(&p.peer_did);
6778                ch.nickname
6779            })
6780            .unwrap_or_else(|| "<name>".to_string())
6781    );
6782    println!("→ to refuse:    `wire reject <name>`");
6783    Ok(())
6784}
6785
6786/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
6787/// without pairing. No event is sent back to the peer; their side stays
6788/// pending until they time out or the operator-side data ages out.
6789fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
6790    let nick = crate::agent_card::bare_handle(peer_nick);
6791    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
6792    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
6793
6794    if as_json {
6795        println!(
6796            "{}",
6797            serde_json::to_string(&json!({
6798                "peer": nick,
6799                "rejected": existed.is_some(),
6800                "had_pending": existed.is_some(),
6801            }))?
6802        );
6803    } else if existed.is_some() {
6804        println!(
6805            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6806        );
6807    } else {
6808        println!("no pending pair from {nick} — nothing to reject");
6809    }
6810    Ok(())
6811}
6812
6813// ---------- session (v0.5.16) ----------
6814//
6815// Multi-session wire on one machine. See src/session.rs for the storage
6816// layout + naming rules. The CLI dispatcher here orchestrates child
6817// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
6818// each session-local `init` / `claim` / `daemon` runs in its own world
6819// without cross-contamination via env vars in this process.
6820
6821/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
6822/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
6823fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6824    match cmd {
6825        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6826        MeshCommand::Broadcast {
6827            kind,
6828            scope,
6829            exclude,
6830            noreply,
6831            body,
6832            json,
6833        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6834        MeshCommand::Role { action } => cmd_mesh_role(action),
6835        MeshCommand::Route {
6836            role,
6837            strategy,
6838            exclude,
6839            kind,
6840            body,
6841            json,
6842        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
6843    }
6844}
6845
6846/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
6847/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
6848/// picks ONE via the requested strategy, then signs + pushes the event
6849/// to that peer. Pinned-peers-only by construction (same as broadcast).
6850fn cmd_mesh_route(
6851    role: &str,
6852    strategy: &str,
6853    exclude: &[String],
6854    kind: &str,
6855    body_arg: &str,
6856    as_json: bool,
6857) -> Result<()> {
6858    use std::time::Instant;
6859
6860    if !config::is_initialized()? {
6861        bail!("not initialized — run `wire init <handle>` first");
6862    }
6863    let strategy = strategy.to_ascii_lowercase();
6864    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
6865        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
6866    }
6867
6868    // Our pinned-peer set: only these handles are addressable. mesh-route
6869    // refuses to invent a recipient, same posture as broadcast.
6870    let state = config::read_relay_state()?;
6871    let pinned: std::collections::BTreeSet<String> = state["peers"]
6872        .as_object()
6873        .map(|m| m.keys().cloned().collect())
6874        .unwrap_or_default();
6875
6876    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6877
6878    // Enumerate every sister on the box, read each one's role from its
6879    // signed agent-card. Filter: matching role AND pinned AND not
6880    // excluded. `list_sessions` returns the cross-session view (using the
6881    // v0.6.4 inside-session sessions_root fallback).
6882    let sessions = crate::session::list_sessions()?;
6883    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
6884    for s in &sessions {
6885        let handle = match s.handle.as_ref() {
6886            Some(h) => h.clone(),
6887            None => continue,
6888        };
6889        if exclude_set.contains(handle.as_str()) {
6890            continue;
6891        }
6892        if !pinned.contains(&handle) {
6893            continue;
6894        }
6895        let card_path = s
6896            .home_dir
6897            .join("config")
6898            .join("wire")
6899            .join("agent-card.json");
6900        let card_role = std::fs::read(&card_path)
6901            .ok()
6902            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6903            .and_then(|c| {
6904                c.get("profile")
6905                    .and_then(|p| p.get("role"))
6906                    .and_then(Value::as_str)
6907                    .map(str::to_string)
6908            });
6909        if card_role.as_deref() == Some(role) {
6910            candidates.push((handle, s.did.clone()));
6911        }
6912    }
6913
6914    candidates.sort_by(|a, b| a.0.cmp(&b.0));
6915    candidates.dedup_by(|a, b| a.0 == b.0);
6916
6917    if candidates.is_empty() {
6918        bail!(
6919            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
6920        );
6921    }
6922
6923    let chosen = match strategy.as_str() {
6924        "first" => candidates[0].clone(),
6925        "random" => {
6926            use rand::Rng;
6927            let idx = rand::thread_rng().gen_range(0..candidates.len());
6928            candidates[idx].clone()
6929        }
6930        "round-robin" => {
6931            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
6932            // `{role: last_picked_handle}`. Next pick = first candidate
6933            // alphabetically AFTER last_picked, wrapping around when no
6934            // candidate is greater.
6935            let cursor_path = mesh_route_cursor_path()?;
6936            let mut cursors: std::collections::BTreeMap<String, String> =
6937                read_mesh_route_cursors(&cursor_path);
6938            let last = cursors.get(role).cloned();
6939            let pick = match last {
6940                None => candidates[0].clone(),
6941                Some(last_h) => candidates
6942                    .iter()
6943                    .find(|(h, _)| h.as_str() > last_h.as_str())
6944                    .cloned()
6945                    .unwrap_or_else(|| candidates[0].clone()),
6946            };
6947            cursors.insert(role.to_string(), pick.0.clone());
6948            write_mesh_route_cursors(&cursor_path, &cursors)?;
6949            pick
6950        }
6951        _ => unreachable!(),
6952    };
6953
6954    let (chosen_handle, _chosen_did) = chosen;
6955
6956    // Body parsing follows wire send / mesh broadcast.
6957    let body_value: Value = if body_arg == "-" {
6958        use std::io::Read;
6959        let mut raw = String::new();
6960        std::io::stdin()
6961            .read_to_string(&mut raw)
6962            .with_context(|| "reading body from stdin")?;
6963        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6964    } else if let Some(path) = body_arg.strip_prefix('@') {
6965        let raw =
6966            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6967        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6968    } else {
6969        Value::String(body_arg.to_string())
6970    };
6971
6972    let sk_seed = config::read_private_key()?;
6973    let card = config::read_agent_card()?;
6974    let did = card
6975        .get("did")
6976        .and_then(Value::as_str)
6977        .ok_or_else(|| anyhow!("agent-card missing did"))?
6978        .to_string();
6979    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6980    let pk_b64 = card
6981        .get("verify_keys")
6982        .and_then(Value::as_object)
6983        .and_then(|m| m.values().next())
6984        .and_then(|v| v.get("key"))
6985        .and_then(Value::as_str)
6986        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6987    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6988
6989    let kind_id = parse_kind(kind)?;
6990    let now_iso = time::OffsetDateTime::now_utc()
6991        .format(&time::format_description::well_known::Rfc3339)
6992        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6993
6994    let event = json!({
6995        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6996        "timestamp": now_iso,
6997        "from": did,
6998        "to": format!("did:wire:{chosen_handle}"),
6999        "type": kind,
7000        "kind": kind_id,
7001        "body": json!({
7002            "content": body_value,
7003            "routed_via": {
7004                "role": role,
7005                "strategy": strategy,
7006            },
7007        }),
7008    });
7009    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7010        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
7011    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7012
7013    let line = serde_json::to_vec(&signed)?;
7014    config::append_outbox_record(&chosen_handle, &line)?;
7015
7016    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
7017    if endpoints.is_empty() {
7018        bail!(
7019            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
7020        );
7021    }
7022    let start = Instant::now();
7023    let mut delivered = false;
7024    let mut last_err: Option<String> = None;
7025    let mut via_scope: Option<String> = None;
7026    for ep in &endpoints {
7027        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
7028        // route via uds_request, others via reqwest. Allows peers with
7029        // UDS-tagged endpoints in their agent-card to receive events
7030        // over the local socket instead of loopback HTTP.
7031        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7032            Ok(_) => {
7033                delivered = true;
7034                via_scope = Some(
7035                    match ep.scope {
7036                        crate::endpoints::EndpointScope::Local => "local",
7037                        crate::endpoints::EndpointScope::Lan => "lan",
7038                        crate::endpoints::EndpointScope::Uds => "uds",
7039                        crate::endpoints::EndpointScope::Federation => "federation",
7040                    }
7041                    .to_string(),
7042                );
7043                break;
7044            }
7045            Err(e) => last_err = Some(format!("{e:#}")),
7046        }
7047    }
7048    let rtt_ms = start.elapsed().as_millis() as u64;
7049
7050    let summary = json!({
7051        "role": role,
7052        "strategy": strategy,
7053        "routed_to": chosen_handle,
7054        "event_id": event_id,
7055        "delivered": delivered,
7056        "delivered_via": via_scope,
7057        "rtt_ms": rtt_ms,
7058        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
7059        "error": last_err,
7060    });
7061
7062    if as_json {
7063        println!("{}", serde_json::to_string(&summary)?);
7064    } else if delivered {
7065        let via = via_scope.as_deref().unwrap_or("?");
7066        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
7067    } else {
7068        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
7069        bail!("delivery to `{chosen_handle}` failed: {err}");
7070    }
7071    Ok(())
7072}
7073
7074fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
7075    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
7076}
7077
7078fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
7079    std::fs::read(path)
7080        .ok()
7081        .and_then(|b| serde_json::from_slice(&b).ok())
7082        .unwrap_or_default()
7083}
7084
7085fn write_mesh_route_cursors(
7086    path: &std::path::Path,
7087    cursors: &std::collections::BTreeMap<String, String>,
7088) -> Result<()> {
7089    if let Some(parent) = path.parent() {
7090        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
7091    }
7092    let body = serde_json::to_vec_pretty(cursors)?;
7093    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
7094    Ok(())
7095}
7096
7097/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
7098/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
7099/// behind a discoverability-friendlier surface, plus cross-session
7100/// enumeration for the list path.
7101fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
7102    match action {
7103        MeshRoleAction::Set { role, json } => {
7104            validate_role_tag(&role)?;
7105            let new_profile =
7106                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
7107            if json {
7108                println!(
7109                    "{}",
7110                    serde_json::to_string(&json!({
7111                        "role": role,
7112                        "profile": new_profile,
7113                    }))?
7114                );
7115            } else {
7116                println!("self role = {role} (signed into agent-card)");
7117            }
7118        }
7119        MeshRoleAction::Get { peer, json } => {
7120            let (who, role) = match peer.as_deref() {
7121                None => {
7122                    let card = config::read_agent_card()?;
7123                    let role = card
7124                        .get("profile")
7125                        .and_then(|p| p.get("role"))
7126                        .and_then(Value::as_str)
7127                        .map(str::to_string);
7128                    let who = card
7129                        .get("did")
7130                        .and_then(Value::as_str)
7131                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
7132                        .unwrap_or_else(|| "self".to_string());
7133                    (who, role)
7134                }
7135                Some(handle) => {
7136                    let bare = crate::agent_card::bare_handle(handle).to_string();
7137                    let trust = config::read_trust()?;
7138                    let role = trust
7139                        .get("agents")
7140                        .and_then(|a| a.get(&bare))
7141                        .and_then(|a| a.get("card"))
7142                        .and_then(|c| c.get("profile"))
7143                        .and_then(|p| p.get("role"))
7144                        .and_then(Value::as_str)
7145                        .map(str::to_string);
7146                    (bare, role)
7147                }
7148            };
7149            if json {
7150                println!(
7151                    "{}",
7152                    serde_json::to_string(&json!({
7153                        "handle": who,
7154                        "role": role,
7155                    }))?
7156                );
7157            } else {
7158                match role {
7159                    Some(r) => println!("{who}: {r}"),
7160                    None => println!("{who}: (unset)"),
7161                }
7162            }
7163        }
7164        MeshRoleAction::List { json } => {
7165            let mut self_did: Option<String> = None;
7166            if let Ok(card) = config::read_agent_card() {
7167                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
7168            }
7169            let sessions = crate::session::list_sessions()?;
7170            let mut rows: Vec<Value> = Vec::new();
7171            for s in &sessions {
7172                let card_path = s
7173                    .home_dir
7174                    .join("config")
7175                    .join("wire")
7176                    .join("agent-card.json");
7177                let role = std::fs::read(&card_path)
7178                    .ok()
7179                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7180                    .and_then(|c| {
7181                        c.get("profile")
7182                            .and_then(|p| p.get("role"))
7183                            .and_then(Value::as_str)
7184                            .map(str::to_string)
7185                    });
7186                let is_self = match (&self_did, &s.did) {
7187                    (Some(a), Some(b)) => a == b,
7188                    _ => false,
7189                };
7190                rows.push(json!({
7191                    "name": s.name,
7192                    "handle": s.handle,
7193                    "role": role,
7194                    "self": is_self,
7195                }));
7196            }
7197            rows.sort_by(|a, b| {
7198                a["name"]
7199                    .as_str()
7200                    .unwrap_or("")
7201                    .cmp(b["name"].as_str().unwrap_or(""))
7202            });
7203            if json {
7204                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
7205            } else if rows.is_empty() {
7206                println!("no sister sessions on this machine.");
7207            } else {
7208                println!("SISTER ROLES (this machine):");
7209                for r in &rows {
7210                    let name = r["name"].as_str().unwrap_or("?");
7211                    let role = r["role"].as_str().unwrap_or("(unset)");
7212                    let marker = if r["self"].as_bool().unwrap_or(false) {
7213                        "    ← you"
7214                    } else {
7215                        ""
7216                    };
7217                    println!("  {name:<24} {role}{marker}");
7218                }
7219            }
7220        }
7221        MeshRoleAction::Clear { json } => {
7222            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
7223            if json {
7224                println!(
7225                    "{}",
7226                    serde_json::to_string(&json!({
7227                        "cleared": true,
7228                        "profile": new_profile,
7229                    }))?
7230                );
7231            } else {
7232                println!("self role cleared");
7233            }
7234        }
7235    }
7236    Ok(())
7237}
7238
7239/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
7240/// No vocabulary check — operators choose the taxonomy (planner /
7241/// reviewer / dispatcher / your-custom-tag). The constraint is purely
7242/// to keep the tag safe for filenames / URLs / shell args.
7243fn validate_role_tag(role: &str) -> Result<()> {
7244    if role.is_empty() {
7245        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
7246    }
7247    if role.len() > 32 {
7248        bail!("role too long ({} chars; max 32)", role.len());
7249    }
7250    for c in role.chars() {
7251        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
7252            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
7253        }
7254    }
7255    Ok(())
7256}
7257
7258/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
7259///
7260/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
7261/// canonical event including `to:`, so per-recipient signing is required;
7262/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
7263/// Per-recipient pushes happen in parallel via `std::thread::scope` so
7264/// broadcast-to-5 takes ~1× RTT, not 5×.
7265///
7266/// **Scope filter.** Default `local` — only peers reachable via a same-
7267/// machine local relay (priority-1 endpoint has `scope=local`). This is
7268/// the lowest-blast-radius default: local-only broadcasts cannot escape
7269/// the operator's machine. `federation` flips to public-relay peers
7270/// only; `both` removes the filter.
7271///
7272/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
7273/// resolution, never trust["agents"] expansion. Closes #8-class
7274/// phonebook-scrape vectors by construction: an attacker pinning a
7275/// hostile handle has to first be pinned bidirectionally by the
7276/// operator, and even then `--exclude` is the loud opt-out.
7277fn cmd_mesh_broadcast(
7278    kind: &str,
7279    scope_str: &str,
7280    exclude: &[String],
7281    _noreply: bool,
7282    body_arg: &str,
7283    as_json: bool,
7284) -> Result<()> {
7285    use std::time::Instant;
7286
7287    if !config::is_initialized()? {
7288        bail!("not initialized — run `wire init <handle>` first");
7289    }
7290
7291    let scope = match scope_str {
7292        "local" => crate::endpoints::EndpointScope::Local,
7293        "federation" => crate::endpoints::EndpointScope::Federation,
7294        "both" => {
7295            // Sentinel: we don't actually have a `Both` variant on the
7296            // scope enum; use a tri-state below. Treat as Local for the
7297            // typed match and special-case it via the bool below.
7298            crate::endpoints::EndpointScope::Local
7299        }
7300        other => bail!("unknown scope `{other}` — use local | federation | both"),
7301    };
7302    let any_scope = scope_str == "both";
7303
7304    let state = config::read_relay_state()?;
7305    let peers = state["peers"].as_object().cloned().unwrap_or_default();
7306    if peers.is_empty() {
7307        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
7308    }
7309
7310    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7311
7312    // Walk the pinned-peer set, filter by scope + exclude. Keep the
7313    // priority-ordered endpoint list for each match so the push can
7314    // try local first then fall through to federation (when scope=both).
7315    struct Target {
7316        handle: String,
7317        endpoints: Vec<crate::endpoints::Endpoint>,
7318    }
7319    let mut targets: Vec<Target> = Vec::new();
7320    let mut skipped_wrong_scope: Vec<String> = Vec::new();
7321    let mut skipped_excluded: Vec<String> = Vec::new();
7322    for handle in peers.keys() {
7323        if exclude_set.contains(handle.as_str()) {
7324            skipped_excluded.push(handle.clone());
7325            continue;
7326        }
7327        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
7328        let filtered: Vec<crate::endpoints::Endpoint> = ordered
7329            .into_iter()
7330            .filter(|ep| any_scope || ep.scope == scope)
7331            .collect();
7332        if filtered.is_empty() {
7333            skipped_wrong_scope.push(handle.clone());
7334            continue;
7335        }
7336        targets.push(Target {
7337            handle: handle.clone(),
7338            endpoints: filtered,
7339        });
7340    }
7341
7342    if targets.is_empty() {
7343        bail!(
7344            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
7345            skipped_excluded.len(),
7346            skipped_wrong_scope.len()
7347        );
7348    }
7349
7350    // Load signing material once; share across per-peer signatures.
7351    let sk_seed = config::read_private_key()?;
7352    let card = config::read_agent_card()?;
7353    let did = card
7354        .get("did")
7355        .and_then(Value::as_str)
7356        .ok_or_else(|| anyhow!("agent-card missing did"))?
7357        .to_string();
7358    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7359    let pk_b64 = card
7360        .get("verify_keys")
7361        .and_then(Value::as_object)
7362        .and_then(|m| m.values().next())
7363        .and_then(|v| v.get("key"))
7364        .and_then(Value::as_str)
7365        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7366    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7367
7368    let body_value: Value = if body_arg == "-" {
7369        use std::io::Read;
7370        let mut raw = String::new();
7371        std::io::stdin()
7372            .read_to_string(&mut raw)
7373            .with_context(|| "reading body from stdin")?;
7374        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7375    } else if let Some(path) = body_arg.strip_prefix('@') {
7376        let raw =
7377            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7378        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7379    } else {
7380        Value::String(body_arg.to_string())
7381    };
7382
7383    let kind_id = parse_kind(kind)?;
7384    let now_iso = time::OffsetDateTime::now_utc()
7385        .format(&time::format_description::well_known::Rfc3339)
7386        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7387
7388    let broadcast_id = generate_broadcast_id();
7389    let target_count = targets.len();
7390
7391    // Build + sign every event up front (sequential, ~50µs/sig). Then
7392    // queue to outbox + push to relay in parallel per-peer. Returns
7393    // a per-peer outcome we then sort by handle for deterministic output.
7394    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
7395        Vec::with_capacity(targets.len());
7396    for t in &targets {
7397        let body = json!({
7398            "content": body_value,
7399            "broadcast_id": broadcast_id,
7400            "broadcast_target_count": target_count,
7401        });
7402        let event = json!({
7403            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7404            "timestamp": now_iso,
7405            "from": did,
7406            "to": format!("did:wire:{}", t.handle),
7407            "type": kind,
7408            "kind": kind_id,
7409            "body": body,
7410        });
7411        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7412            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
7413        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7414        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
7415    }
7416
7417    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
7418    // holds a per-path mutex; writes are independent across handles but
7419    // we want the side-effect ordering deterministic).
7420    for (peer, _, signed, _) in &signed_per_peer {
7421        let line = serde_json::to_vec(signed)?;
7422        config::append_outbox_record(peer, &line)?;
7423    }
7424
7425    // Per-peer parallel push. Each thread tries the priority-ordered
7426    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
7427    // error_opt) over a channel.
7428    use std::sync::mpsc;
7429    let (tx, rx) = mpsc::channel::<Value>();
7430    std::thread::scope(|s| {
7431        for (peer, endpoints, signed, event_id) in &signed_per_peer {
7432            let tx = tx.clone();
7433            let peer = peer.clone();
7434            let event_id = event_id.clone();
7435            let endpoints = endpoints.clone();
7436            let signed = signed.clone();
7437            s.spawn(move || {
7438                let start = Instant::now();
7439                let mut delivered = false;
7440                let mut last_err: Option<String> = None;
7441                let mut delivered_via: Option<String> = None;
7442                for ep in &endpoints {
7443                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
7444                    // uds_request, else reqwest). Same as cmd_send's
7445                    // single-peer path above; this is the parallel
7446                    // multi-peer broadcast loop.
7447                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7448                        Ok(_) => {
7449                            delivered = true;
7450                            delivered_via = Some(
7451                                match ep.scope {
7452                                    crate::endpoints::EndpointScope::Local => "local",
7453                                    crate::endpoints::EndpointScope::Lan => "lan",
7454                                    crate::endpoints::EndpointScope::Uds => "uds",
7455                                    crate::endpoints::EndpointScope::Federation => "federation",
7456                                }
7457                                .to_string(),
7458                            );
7459                            break;
7460                        }
7461                        Err(e) => last_err = Some(format!("{e:#}")),
7462                    }
7463                }
7464                let rtt_ms = start.elapsed().as_millis() as u64;
7465                let _ = tx.send(json!({
7466                    "peer": peer,
7467                    "event_id": event_id,
7468                    "delivered": delivered,
7469                    "delivered_via": delivered_via,
7470                    "rtt_ms": rtt_ms,
7471                    "error": last_err,
7472                }));
7473            });
7474        }
7475    });
7476    drop(tx);
7477
7478    let mut results: Vec<Value> = rx.iter().collect();
7479    results.sort_by(|a, b| {
7480        a["peer"]
7481            .as_str()
7482            .unwrap_or("")
7483            .cmp(b["peer"].as_str().unwrap_or(""))
7484    });
7485
7486    let delivered = results
7487        .iter()
7488        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
7489        .count();
7490    let failed = results.len() - delivered;
7491
7492    let summary = json!({
7493        "broadcast_id": broadcast_id,
7494        "kind": kind,
7495        "scope": scope_str,
7496        "target_count": target_count,
7497        "delivered": delivered,
7498        "failed": failed,
7499        "skipped_excluded": skipped_excluded,
7500        "skipped_wrong_scope": skipped_wrong_scope,
7501        "results": results,
7502    });
7503
7504    if as_json {
7505        println!("{}", serde_json::to_string(&summary)?);
7506        return Ok(());
7507    }
7508
7509    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
7510    for r in &results {
7511        let peer = r["peer"].as_str().unwrap_or("?");
7512        let delivered = r["delivered"].as_bool().unwrap_or(false);
7513        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
7514        let via = r["delivered_via"].as_str().unwrap_or("");
7515        if delivered {
7516            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
7517        } else {
7518            let err = r["error"].as_str().unwrap_or("?");
7519            println!("  {peer:<24} ✗ failed — {err}");
7520        }
7521    }
7522    if !skipped_excluded.is_empty() {
7523        println!("  excluded: {}", skipped_excluded.join(", "));
7524    }
7525    if !skipped_wrong_scope.is_empty() {
7526        println!(
7527            "  skipped (wrong scope): {}",
7528            skipped_wrong_scope.join(", ")
7529        );
7530    }
7531    println!("broadcast_id: {broadcast_id}");
7532    Ok(())
7533}
7534
7535/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
7536/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
7537/// correlate by string equality, the shape is for human readability.
7538fn generate_broadcast_id() -> String {
7539    use rand::RngCore;
7540    let mut buf = [0u8; 16];
7541    rand::thread_rng().fill_bytes(&mut buf);
7542    let h = hex::encode(buf);
7543    format!(
7544        "{}-{}-{}-{}-{}",
7545        &h[0..8],
7546        &h[8..12],
7547        &h[12..16],
7548        &h[16..20],
7549        &h[20..32],
7550    )
7551}
7552
7553fn cmd_session(cmd: SessionCommand) -> Result<()> {
7554    match cmd {
7555        SessionCommand::New {
7556            name,
7557            relay,
7558            with_local,
7559            local_relay,
7560            with_lan,
7561            lan_relay,
7562            with_uds,
7563            uds_socket,
7564            no_daemon,
7565            local_only,
7566            json,
7567        } => cmd_session_new(
7568            name.as_deref(),
7569            &relay,
7570            with_local,
7571            &local_relay,
7572            with_lan,
7573            lan_relay.as_deref(),
7574            with_uds,
7575            uds_socket.as_deref(),
7576            no_daemon,
7577            local_only,
7578            json,
7579        ),
7580        SessionCommand::List { json } => cmd_session_list(json),
7581        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
7582        SessionCommand::PairAllLocal {
7583            settle_secs,
7584            federation_relay,
7585            json,
7586        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
7587        SessionCommand::MeshStatus { stale_secs, json } => {
7588            cmd_session_mesh_status(stale_secs, json)
7589        }
7590        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
7591        SessionCommand::Current { json } => cmd_session_current(json),
7592        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
7593        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
7594    }
7595}
7596
7597fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
7598    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7599    let cwd_str = cwd.to_string_lossy().into_owned();
7600
7601    let resolved_name = match name_arg {
7602        Some(n) => crate::session::sanitize_name(n),
7603        None => crate::session::sanitize_name(
7604            cwd.file_name()
7605                .and_then(|s| s.to_str())
7606                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
7607        ),
7608    };
7609
7610    let session_home = crate::session::session_dir(&resolved_name)?;
7611    if !session_home.exists() {
7612        bail!(
7613            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
7614            session_home.display()
7615        );
7616    }
7617
7618    let prior = crate::session::read_registry()
7619        .ok()
7620        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
7621    if prior.as_deref() == Some(resolved_name.as_str()) {
7622        if json {
7623            println!(
7624                "{}",
7625                serde_json::to_string(&json!({
7626                    "cwd": cwd_str,
7627                    "session": resolved_name,
7628                    "changed": false,
7629                }))?
7630            );
7631        } else {
7632            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
7633        }
7634        return Ok(());
7635    }
7636    if let Some(prior_name) = &prior {
7637        eprintln!(
7638            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
7639        );
7640    }
7641
7642    crate::session::update_registry(|reg| {
7643        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
7644        Ok(())
7645    })?;
7646
7647    if json {
7648        println!(
7649            "{}",
7650            serde_json::to_string(&json!({
7651                "cwd": cwd_str,
7652                "session": resolved_name,
7653                "changed": true,
7654                "previous": prior,
7655            }))?
7656        );
7657    } else {
7658        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
7659        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
7660    }
7661    Ok(())
7662}
7663
7664fn resolve_session_name(name: Option<&str>) -> Result<String> {
7665    if let Some(n) = name {
7666        return Ok(crate::session::sanitize_name(n));
7667    }
7668    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7669    let registry = crate::session::read_registry().unwrap_or_default();
7670    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
7671}
7672
7673#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
7674// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
7675// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
7676fn cmd_session_new(
7677    name_arg: Option<&str>,
7678    relay: &str,
7679    with_local: bool,
7680    local_relay: &str,
7681    with_lan: bool,
7682    lan_relay: Option<&str>,
7683    with_uds: bool,
7684    uds_socket: Option<&std::path::Path>,
7685    no_daemon: bool,
7686    local_only: bool,
7687    as_json: bool,
7688) -> Result<()> {
7689    // v0.6.6: --local-only implies --with-local (a federation-free
7690    // session with no endpoints at all would be unaddressable).
7691    let with_local = with_local || local_only;
7692    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
7693    if with_lan && lan_relay.is_none() {
7694        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
7695    }
7696    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
7697    if with_uds && uds_socket.is_none() {
7698        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
7699    }
7700    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7701    let mut registry = crate::session::read_registry().unwrap_or_default();
7702    let name = match name_arg {
7703        Some(n) => crate::session::sanitize_name(n),
7704        None => crate::session::derive_name_from_cwd(&cwd, &registry),
7705    };
7706    let session_home = crate::session::session_dir(&name)?;
7707
7708    let already_exists = session_home.exists()
7709        && session_home
7710            .join("config")
7711            .join("wire")
7712            .join("agent-card.json")
7713            .exists();
7714    if already_exists {
7715        // Idempotent: re-register the cwd (if not already), refresh the
7716        // daemon if requested, surface the env-var line. Do not re-init
7717        // identity — that would clobber the keypair.
7718        registry
7719            .by_cwd
7720            .insert(cwd.to_string_lossy().into_owned(), name.clone());
7721        crate::session::write_registry(&registry)?;
7722        let info = render_session_info(&name, &session_home, &cwd)?;
7723        emit_session_new_result(&info, "already_exists", as_json)?;
7724        if !no_daemon {
7725            ensure_session_daemon(&session_home)?;
7726        }
7727        return Ok(());
7728    }
7729
7730    std::fs::create_dir_all(&session_home)
7731        .with_context(|| format!("creating session dir {session_home:?}"))?;
7732
7733    // Phase 1: init identity in the new session's WIRE_HOME. For
7734    // federation-bound sessions we pass `--relay` so init also
7735    // allocates a federation slot in the same step; for `--local-only`
7736    // we run init with `--offline` (v0.9 requires explicit reachability
7737    // acknowledgement at init time) because cmd_session_new allocates
7738    // the local-relay slot itself via try_allocate_local_slot below.
7739    // The session is not actually slotless — init is just deferred to
7740    // the subsequent allocation pass.
7741    let init_args: Vec<&str> = if local_only {
7742        vec!["init", &name, "--offline"]
7743    } else {
7744        vec!["init", &name, "--relay", relay]
7745    };
7746    let init_status = run_wire_with_home(&session_home, &init_args)?;
7747    if !init_status.success() {
7748        let how = if local_only {
7749            format!("`wire init {name}` (local-only)")
7750        } else {
7751            format!("`wire init {name} --relay {relay}`")
7752        };
7753        bail!("{how} failed inside session dir {session_home:?}");
7754    }
7755
7756    // Phase 2: claim the handle on the federation relay — SKIPPED when
7757    // `--local-only`. Local-only sessions have no public address and
7758    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
7759    // tries to publish them.
7760    let effective_handle = if local_only {
7761        name.clone()
7762    } else {
7763        let mut claim_attempt = 0u32;
7764        let mut effective = name.clone();
7765        loop {
7766            claim_attempt += 1;
7767            let status =
7768                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
7769            if status.success() {
7770                break;
7771            }
7772            if claim_attempt >= 5 {
7773                bail!(
7774                    "5 failed attempts to claim a handle on {relay} for session {name}. \
7775                     Try `wire session destroy {name} --force` and re-run with a different name, \
7776                     or use `--local-only` if you don't need a federation address."
7777                );
7778            }
7779            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
7780            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
7781            let token = suffix
7782                .rsplit('-')
7783                .next()
7784                .filter(|t| t.len() == 4)
7785                .map(str::to_string)
7786                .unwrap_or_else(|| format!("{claim_attempt}"));
7787            effective = format!("{name}-{token}");
7788        }
7789        effective
7790    };
7791
7792    // Persist the cwd → name mapping NOW so subsequent invocations from
7793    // this directory short-circuit to the "already_exists" branch.
7794    registry
7795        .by_cwd
7796        .insert(cwd.to_string_lossy().into_owned(), name.clone());
7797    crate::session::write_registry(&registry)?;
7798
7799    // v0.5.17: --with-local probes the local relay and, if it's
7800    // reachable, allocates a second slot there. The session's
7801    // relay_state.json grows a `self.endpoints[]` array carrying both
7802    // endpoints; routing layer (cmd_push) prefers local for sister-
7803    // session peers that also have a local slot.
7804    //
7805    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
7806    // allocation; a failed probe leaves the session with no endpoints,
7807    // which we surface as a hard error (the operator asked for local-
7808    // only but the local relay isn't running — fix that first).
7809    if with_local {
7810        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
7811        if local_only {
7812            // Verify the local slot landed. If the local relay was
7813            // unreachable, the session would be unreachable from
7814            // anywhere — surface that loudly instead of leaving an
7815            // orphaned session dir.
7816            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
7817            let state: Value = std::fs::read(&relay_state_path)
7818                .ok()
7819                .and_then(|b| serde_json::from_slice(&b).ok())
7820                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7821            let endpoints = crate::endpoints::self_endpoints(&state);
7822            let has_local = endpoints
7823                .iter()
7824                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
7825            if !has_local {
7826                bail!(
7827                    "--local-only requested but local-relay probe at {local_relay} failed — \
7828                     ensure the local relay is running (`wire service install --local-relay`), \
7829                     then re-run `wire session new {name} --local-only`."
7830                );
7831            }
7832        }
7833    }
7834
7835    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
7836    // Sits AFTER local because cmd_session_new's flow is "add endpoints
7837    // alongside existing self.endpoints[]" — order independent post-init.
7838    if with_lan && let Some(lan_url) = lan_relay {
7839        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
7840    }
7841    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
7842    if with_uds && let Some(socket_path) = uds_socket {
7843        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
7844    }
7845
7846    if !no_daemon {
7847        ensure_session_daemon(&session_home)?;
7848    }
7849
7850    let info = render_session_info(&name, &session_home, &cwd)?;
7851    emit_session_new_result(&info, "created", as_json)
7852}
7853
7854/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
7855/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
7856/// sister sessions can route over the local socket instead of loopback
7857/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
7858/// alpha.17 — reqwest has no UDS support.
7859///
7860/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
7861/// and try_allocate_lan_slot semantics): session stays at existing
7862/// endpoint mix, operator can retry once the UDS relay is up.
7863#[cfg(unix)]
7864fn try_allocate_uds_slot(
7865    session_home: &std::path::Path,
7866    handle: &str,
7867    uds_socket: &std::path::Path,
7868) {
7869    // Probe healthz first so we fail fast with a clear stderr if the
7870    // socket doesn't exist OR isn't a wire relay.
7871    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
7872        Ok((200, _)) => true,
7873        Ok((status, body)) => {
7874            eprintln!(
7875                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
7876                String::from_utf8_lossy(&body)
7877            );
7878            return;
7879        }
7880        Err(e) => {
7881            eprintln!(
7882                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
7883                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
7884            );
7885            return;
7886        }
7887    };
7888    if !healthz {
7889        return;
7890    }
7891
7892    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
7893    let alloc_body = serde_json::json!({"handle": handle}).to_string();
7894    let (status, body) = match crate::relay_client::uds_request(
7895        uds_socket,
7896        "POST",
7897        "/v1/slot/allocate",
7898        &[("Content-Type", "application/json")],
7899        alloc_body.as_bytes(),
7900    ) {
7901        Ok(r) => r,
7902        Err(e) => {
7903            eprintln!(
7904                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
7905            );
7906            return;
7907        }
7908    };
7909    if status >= 300 {
7910        eprintln!(
7911            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
7912            String::from_utf8_lossy(&body)
7913        );
7914        return;
7915    }
7916    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
7917        Ok(a) => a,
7918        Err(e) => {
7919            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
7920            return;
7921        }
7922    };
7923
7924    let state_path = session_home.join("config").join("wire").join("relay.json");
7925    let mut state: serde_json::Value = std::fs::read(&state_path)
7926        .ok()
7927        .and_then(|b| serde_json::from_slice(&b).ok())
7928        .unwrap_or_else(|| serde_json::json!({}));
7929
7930    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7931        .get("self")
7932        .and_then(|s| s.get("endpoints"))
7933        .and_then(|e| e.as_array())
7934        .map(|arr| {
7935            arr.iter()
7936                .filter_map(|v| {
7937                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7938                })
7939                .collect()
7940        })
7941        .unwrap_or_default();
7942    endpoints.push(crate::endpoints::Endpoint::uds(
7943        format!("unix://{}", uds_socket.display()),
7944        alloc.slot_id.clone(),
7945        alloc.slot_token.clone(),
7946    ));
7947
7948    let self_obj = state
7949        .as_object_mut()
7950        .expect("relay_state root is an object")
7951        .entry("self")
7952        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7953    if !self_obj.is_object() {
7954        *self_obj = serde_json::Value::Object(serde_json::Map::new());
7955    }
7956    if let Some(obj) = self_obj.as_object_mut() {
7957        obj.insert(
7958            "endpoints".into(),
7959            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7960        );
7961    }
7962    if let Err(e) = std::fs::write(
7963        &state_path,
7964        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7965    ) {
7966        eprintln!("wire session new: failed to write {state_path:?}: {e}");
7967        return;
7968    }
7969    eprintln!(
7970        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
7971        uds_socket.display(),
7972        alloc.slot_id
7973    );
7974}
7975
7976#[cfg(not(unix))]
7977fn try_allocate_uds_slot(
7978    _session_home: &std::path::Path,
7979    _handle: &str,
7980    _uds_socket: &std::path::Path,
7981) {
7982    eprintln!(
7983        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
7984    );
7985}
7986
7987/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
7988/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
7989/// pulling the agent-card see a third reachable address.
7990///
7991/// Mirrors `try_allocate_local_slot` but tags the endpoint
7992/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
7993/// session stays at whatever endpoint mix it already had — operators
7994/// can retry with `wire session new --with-lan --lan-relay <url>` once
7995/// the LAN relay is up.
7996fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
7997    let probe = match crate::relay_client::build_blocking_client(Some(
7998        std::time::Duration::from_millis(500),
7999    )) {
8000        Ok(c) => c,
8001        Err(e) => {
8002            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
8003            return;
8004        }
8005    };
8006    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
8007    match probe.get(&healthz_url).send() {
8008        Ok(resp) if resp.status().is_success() => {}
8009        Ok(resp) => {
8010            eprintln!(
8011                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
8012                resp.status()
8013            );
8014            return;
8015        }
8016        Err(e) => {
8017            eprintln!(
8018                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
8019                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
8020                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8021            );
8022            return;
8023        }
8024    };
8025
8026    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
8027    let alloc = match lan_client.allocate_slot(Some(handle)) {
8028        Ok(a) => a,
8029        Err(e) => {
8030            eprintln!(
8031                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
8032            );
8033            return;
8034        }
8035    };
8036
8037    let state_path = session_home.join("config").join("wire").join("relay.json");
8038    let mut state: serde_json::Value = std::fs::read(&state_path)
8039        .ok()
8040        .and_then(|b| serde_json::from_slice(&b).ok())
8041        .unwrap_or_else(|| serde_json::json!({}));
8042
8043    // Read existing endpoints array and add the LAN one. Preserve
8044    // federation / local entries already there.
8045    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8046        .get("self")
8047        .and_then(|s| s.get("endpoints"))
8048        .and_then(|e| e.as_array())
8049        .map(|arr| {
8050            arr.iter()
8051                .filter_map(|v| {
8052                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8053                })
8054                .collect()
8055        })
8056        .unwrap_or_default();
8057    endpoints.push(crate::endpoints::Endpoint::lan(
8058        lan_relay.trim_end_matches('/').to_string(),
8059        alloc.slot_id.clone(),
8060        alloc.slot_token.clone(),
8061    ));
8062
8063    let self_obj = state
8064        .as_object_mut()
8065        .expect("relay_state root is an object")
8066        .entry("self")
8067        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8068    if !self_obj.is_object() {
8069        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8070    }
8071    if let Some(obj) = self_obj.as_object_mut() {
8072        obj.insert(
8073            "endpoints".into(),
8074            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8075        );
8076    }
8077    if let Err(e) = std::fs::write(
8078        &state_path,
8079        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8080    ) {
8081        eprintln!("wire session new: failed to write {state_path:?}: {e}");
8082        return;
8083    }
8084    eprintln!(
8085        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
8086        alloc.slot_id
8087    );
8088}
8089
8090/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
8091/// a short timeout, allocate a slot there and update the session's
8092/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
8093///
8094/// Failure to reach the local relay is NOT fatal — the session stays
8095/// federation-only. Logs to stderr on failure so operators can tell
8096/// the local relay isn't running, but doesn't abort the bootstrap.
8097fn try_allocate_local_slot(
8098    session_home: &std::path::Path,
8099    handle: &str,
8100    _federation_relay: &str,
8101    local_relay: &str,
8102) {
8103    // Probe healthz with a tight timeout. Use a fresh client (don't
8104    // share the daemon-wide one) so the timeout is local to this call.
8105    let probe = match crate::relay_client::build_blocking_client(Some(
8106        std::time::Duration::from_millis(500),
8107    )) {
8108        Ok(c) => c,
8109        Err(e) => {
8110            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
8111            return;
8112        }
8113    };
8114    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
8115    match probe.get(&healthz_url).send() {
8116        Ok(resp) if resp.status().is_success() => {}
8117        Ok(resp) => {
8118            eprintln!(
8119                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
8120                resp.status()
8121            );
8122            return;
8123        }
8124        Err(e) => {
8125            eprintln!(
8126                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
8127                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
8128                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8129            );
8130            return;
8131        }
8132    };
8133
8134    // Allocate a slot on the local relay.
8135    let local_client = crate::relay_client::RelayClient::new(local_relay);
8136    let alloc = match local_client.allocate_slot(Some(handle)) {
8137        Ok(a) => a,
8138        Err(e) => {
8139            eprintln!(
8140                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
8141            );
8142            return;
8143        }
8144    };
8145
8146    // Merge into the session's relay.json. We invoke wire via
8147    // run_wire_with_home for federation calls (subprocess isolation),
8148    // but relay.json is a simple file we can edit directly
8149    // — and need to, because there's no `wire bind-relay --add-local`
8150    // command yet (could add later; out of scope for v0.5.17 MVP).
8151    //
8152    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
8153    // does not exist (canonical filename is `relay.json` per
8154    // `config::relay_state_path`). The mis-named file write succeeded
8155    // but landed in a sibling path nothing else reads. Every
8156    // `wire session new --with-local` invocation silently degraded to
8157    // federation-only despite the "local slot allocated" stderr line.
8158    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
8159    // session's relay.json — it had only the federation endpoint.
8160    let state_path = session_home.join("config").join("wire").join("relay.json");
8161    let mut state: serde_json::Value = std::fs::read(&state_path)
8162        .ok()
8163        .and_then(|b| serde_json::from_slice(&b).ok())
8164        .unwrap_or_else(|| serde_json::json!({}));
8165    // Read the existing federation self info (already written by
8166    // `wire init` + `wire bind-relay` path during session bootstrap).
8167    let fed_endpoint = state.get("self").and_then(|s| {
8168        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
8169        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
8170        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
8171        Some(crate::endpoints::Endpoint::federation(
8172            url.to_string(),
8173            slot_id.to_string(),
8174            slot_token.to_string(),
8175        ))
8176    });
8177
8178    let local_endpoint = crate::endpoints::Endpoint::local(
8179        local_relay.trim_end_matches('/').to_string(),
8180        alloc.slot_id.clone(),
8181        alloc.slot_token.clone(),
8182    );
8183
8184    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
8185    if let Some(f) = fed_endpoint.clone() {
8186        endpoints.push(f);
8187    }
8188    endpoints.push(local_endpoint);
8189
8190    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
8191    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
8192    // `slot_token` fields must point at the LOCAL endpoint so callers
8193    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
8194    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
8195    // still find a valid slot. Pre-v0.6.6 this branch wrote
8196    // `relay_url: federation_relay` with no slot_id, which produced
8197    // half-populated self state that broke pair-accept on local-only
8198    // sessions.
8199    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
8200        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
8201        None => (
8202            local_relay.trim_end_matches('/').to_string(),
8203            alloc.slot_id.clone(),
8204            alloc.slot_token.clone(),
8205        ),
8206    };
8207    let self_obj = state
8208        .as_object_mut()
8209        .expect("relay_state root is an object")
8210        .entry("self")
8211        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8212    // The entry might be Value::Null (left by read_relay_state's default
8213    // template) — replace with an object before mutating.
8214    if !self_obj.is_object() {
8215        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8216    }
8217    if let Some(obj) = self_obj.as_object_mut() {
8218        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
8219        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
8220        obj.insert(
8221            "slot_token".into(),
8222            serde_json::Value::String(legacy_slot_token),
8223        );
8224        obj.insert(
8225            "endpoints".into(),
8226            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8227        );
8228    }
8229
8230    if let Err(e) = std::fs::write(
8231        &state_path,
8232        serde_json::to_vec_pretty(&state).unwrap_or_default(),
8233    ) {
8234        eprintln!(
8235            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
8236        );
8237        return;
8238    }
8239    eprintln!(
8240        "wire session new: local slot allocated on {local_relay} (slot_id={})",
8241        alloc.slot_id
8242    );
8243}
8244
8245fn render_session_info(
8246    name: &str,
8247    session_home: &std::path::Path,
8248    cwd: &std::path::Path,
8249) -> Result<serde_json::Value> {
8250    let card_path = session_home
8251        .join("config")
8252        .join("wire")
8253        .join("agent-card.json");
8254    let (did, handle) = if card_path.exists() {
8255        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
8256        let did = card
8257            .get("did")
8258            .and_then(Value::as_str)
8259            .unwrap_or("")
8260            .to_string();
8261        let handle = card
8262            .get("handle")
8263            .and_then(Value::as_str)
8264            .map(str::to_string)
8265            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8266        (did, handle)
8267    } else {
8268        (String::new(), String::new())
8269    };
8270    Ok(json!({
8271        "name": name,
8272        "home_dir": session_home.to_string_lossy(),
8273        "cwd": cwd.to_string_lossy(),
8274        "did": did,
8275        "handle": handle,
8276        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8277    }))
8278}
8279
8280fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
8281    if as_json {
8282        let mut obj = info.clone();
8283        obj["status"] = json!(status);
8284        println!("{}", serde_json::to_string(&obj)?);
8285    } else {
8286        let name = info["name"].as_str().unwrap_or("?");
8287        let handle = info["handle"].as_str().unwrap_or("?");
8288        let home = info["home_dir"].as_str().unwrap_or("?");
8289        let did = info["did"].as_str().unwrap_or("?");
8290        let export = info["export"].as_str().unwrap_or("?");
8291        let prefix = if status == "already_exists" {
8292            "session already exists (re-registered cwd)"
8293        } else {
8294            "session created"
8295        };
8296        println!(
8297            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
8298        );
8299    }
8300    Ok(())
8301}
8302
8303fn run_wire_with_home(
8304    session_home: &std::path::Path,
8305    args: &[&str],
8306) -> Result<std::process::ExitStatus> {
8307    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8308    let status = std::process::Command::new(&bin)
8309        .env("WIRE_HOME", session_home)
8310        .env_remove("RUST_LOG")
8311        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
8312        // We already own the session; nested init would clobber state.
8313        .env("WIRE_AUTO_INIT", "0")
8314        .args(args)
8315        .status()
8316        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8317    Ok(status)
8318}
8319
8320/// v0.7.0-alpha.2: idempotent per-cwd session creation.
8321///
8322/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
8323/// registered session for the current cwd — including via parent-walk —
8324/// this creates one inline so every Claude tab in a fresh project gets
8325/// its own wire identity rather than collapsing onto the machine-wide
8326/// default. Without this, multiple Claudes in unwired cwds all render
8327/// the same character (the default identity's character), defeating the
8328/// "every session looks different" promise.
8329///
8330/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
8331/// `run_wire_with_home` subprocess context).
8332///
8333/// Best-effort: any failure (no home dir, name collision pathology,
8334/// `wire init` subprocess crash) is logged to stderr and we fall back
8335/// to default identity. Must not block MCP startup.
8336///
8337/// MUST be called BEFORE worker thread spawn (env::set_var safety).
8338pub fn maybe_auto_init_cwd_session(label: &str) {
8339    if std::env::var("WIRE_HOME").is_ok() {
8340        return; // explicit override OR auto-detect already won
8341    }
8342    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
8343        return; // operator opt-out
8344    }
8345    let cwd = match std::env::current_dir() {
8346        Ok(c) => c,
8347        Err(_) => return,
8348    };
8349    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
8350    // already runs but we want to be robust to ordering).
8351    if crate::session::detect_session_wire_home(&cwd).is_some() {
8352        return;
8353    }
8354
8355    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
8356    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
8357    // Two different cwds with the same basename (e.g. /a/projx +
8358    // /b/projx) used to race outside the lock: both read empty
8359    // registry, both derived name="projx", per-name lock didn't help
8360    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
8361    //
8362    // Single lock serializes ALL auto-init across the sessions_root.
8363    // Inside the lock: re-read registry, derive_name_from_cwd which
8364    // adds path-hash suffix when basename is occupied by another cwd
8365    // already committed to the registry. Different cwds get DIFFERENT
8366    // names guaranteed.
8367    //
8368    // Cost: parallel auto-inits in different cwds now serialize
8369    // (~hundreds of ms each when local relay is up). Acceptable —
8370    // auto-init runs once per cwd per machine; not a hot path.
8371    use fs2::FileExt;
8372    let sessions_root = match crate::session::sessions_root() {
8373        Ok(r) => r,
8374        Err(_) => return,
8375    };
8376    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
8377        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
8378        return;
8379    }
8380    let lock_path = sessions_root.join(".auto-init.lock");
8381    let lock_file = match std::fs::OpenOptions::new()
8382        .create(true)
8383        .truncate(false)
8384        .read(true)
8385        .write(true)
8386        .open(&lock_path)
8387    {
8388        Ok(f) => f,
8389        Err(e) => {
8390            eprintln!(
8391                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
8392            );
8393            return;
8394        }
8395    };
8396    if let Err(e) = lock_file.lock_exclusive() {
8397        eprintln!(
8398            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
8399        );
8400        return;
8401    }
8402    // Lock acquired. Read registry + derive name now that all parallel
8403    // racers serialize through us — derive_name_from_cwd adds a
8404    // path-hash suffix if the basename is already claimed by another
8405    // cwd in the (now-stable) registry.
8406    let registry = crate::session::read_registry().unwrap_or_default();
8407    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
8408    let session_home = match crate::session::session_dir(&name) {
8409        Ok(h) => h,
8410        Err(_) => {
8411            let _ = fs2::FileExt::unlock(&lock_file);
8412            return;
8413        }
8414    };
8415    let agent_card_path = session_home
8416        .join("config")
8417        .join("wire")
8418        .join("agent-card.json");
8419    let needs_init = !agent_card_path.exists();
8420
8421    if needs_init {
8422        if let Err(e) = std::fs::create_dir_all(&session_home) {
8423            eprintln!(
8424                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
8425            );
8426            let _ = fs2::FileExt::unlock(&lock_file);
8427            return;
8428        }
8429        // v0.9: --offline; the surrounding session-spawn path runs
8430        // try_allocate_local_slot afterward to attach an inbound slot
8431        // when a local relay is available. Init itself stays slotless
8432        // because it's a precursor step, not the final state.
8433        match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
8434            Ok(status) if status.success() => {}
8435            Ok(status) => {
8436                eprintln!(
8437                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
8438                );
8439                let _ = fs2::FileExt::unlock(&lock_file);
8440                return;
8441            }
8442            Err(e) => {
8443                eprintln!(
8444                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
8445                );
8446                let _ = fs2::FileExt::unlock(&lock_file);
8447                return;
8448            }
8449        }
8450        // Best-effort: allocate a local-relay slot so this auto-init'd
8451        // session is addressable by sister sessions. Skipped silently when
8452        // the local relay isn't running (the function itself reports to
8453        // stderr). Auto-init'd sessions without endpoints can still
8454        // surface their character but cannot receive pair_drops until the
8455        // operator runs `wire bind-relay` or restarts the local relay.
8456        try_allocate_local_slot(
8457            &session_home,
8458            &name,
8459            "https://wireup.net",
8460            "http://127.0.0.1:8771",
8461        );
8462    } else {
8463        // Race loser path: peer already created the session. Surface
8464        // this honestly so the operator can see we adopted rather than
8465        // double-initialized.
8466        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8467            eprintln!(
8468                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
8469            );
8470        }
8471    }
8472    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
8473    // BEFORE releasing the auto-init lock. Pre-fix released the lock
8474    // here and committed the registry update afterward — racers in
8475    // OTHER cwds with the same basename would acquire the lock,
8476    // read the registry (still without our entry), and derive the
8477    // SAME name we just claimed. Live regression test caught it:
8478    // two cwds /a/projx + /b/projx both got name "projx", both
8479    // mapped to the same identity. Update the registry WHILE STILL
8480    // holding the auto-init lock so the next racer sees our claim.
8481    let cwd_key = cwd.to_string_lossy().into_owned();
8482    let name_for_reg = name.clone();
8483    if let Err(e) = crate::session::update_registry(|reg| {
8484        reg.by_cwd.insert(cwd_key, name_for_reg);
8485        Ok(())
8486    }) {
8487        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
8488        // proceed — env var still gets set below
8489    }
8490    // NOW release the lock — racers waiting will see our registry
8491    // entry on their re-read.
8492    let _ = fs2::FileExt::unlock(&lock_file);
8493
8494    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8495        eprintln!(
8496            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
8497            cwd.display(),
8498            session_home.display()
8499        );
8500    }
8501    // SAFETY: caller contract is "before any thread spawn." MCP::run
8502    // calls this immediately after `maybe_adopt_session_wire_home`.
8503    unsafe {
8504        std::env::set_var("WIRE_HOME", &session_home);
8505    }
8506}
8507
8508fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
8509    // Check if a daemon is already alive in this session's WIRE_HOME.
8510    // If so, no-op (let the existing process keep running).
8511    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8512    if pidfile.exists() {
8513        let bytes = std::fs::read(&pidfile).unwrap_or_default();
8514        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8515            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8516        } else {
8517            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8518        };
8519        if let Some(p) = pid {
8520            let alive = {
8521                #[cfg(target_os = "linux")]
8522                {
8523                    std::path::Path::new(&format!("/proc/{p}")).exists()
8524                }
8525                #[cfg(not(target_os = "linux"))]
8526                {
8527                    std::process::Command::new("kill")
8528                        .args(["-0", &p.to_string()])
8529                        .output()
8530                        .map(|o| o.status.success())
8531                        .unwrap_or(false)
8532                }
8533            };
8534            if alive {
8535                return Ok(());
8536            }
8537        }
8538    }
8539
8540    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
8541    // versioned pidfile; we just kick it off and return.
8542    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8543    let log_path = session_home.join("state").join("wire").join("daemon.log");
8544    if let Some(parent) = log_path.parent() {
8545        std::fs::create_dir_all(parent).ok();
8546    }
8547    let log_file = std::fs::OpenOptions::new()
8548        .create(true)
8549        .append(true)
8550        .open(&log_path)
8551        .with_context(|| format!("opening daemon log {log_path:?}"))?;
8552    let log_err = log_file.try_clone()?;
8553    std::process::Command::new(&bin)
8554        .env("WIRE_HOME", session_home)
8555        .env_remove("RUST_LOG")
8556        .args(["daemon", "--interval", "5"])
8557        .stdout(log_file)
8558        .stderr(log_err)
8559        .stdin(std::process::Stdio::null())
8560        .spawn()
8561        .with_context(|| "spawning session-local `wire daemon`")?;
8562    Ok(())
8563}
8564
8565fn cmd_session_list(as_json: bool) -> Result<()> {
8566    let items = crate::session::list_sessions()?;
8567    if as_json {
8568        println!("{}", serde_json::to_string(&items)?);
8569        return Ok(());
8570    }
8571    if items.is_empty() {
8572        println!("no sessions on this machine. `wire session new` to create one.");
8573        return Ok(());
8574    }
8575    println!(
8576        "{:<22} {:<24} {:<24} {:<10} CWD",
8577        "CHARACTER", "NAME", "HANDLE", "DAEMON"
8578    );
8579    for s in items {
8580        // ANSI-escape-wrapped character takes more visual width than its
8581        // displayed glyph count; pad based on the plain-text form, then
8582        // wrap in escapes so the column lines up across rows.
8583        let plain = s
8584            .character
8585            .as_ref()
8586            .map(|c| c.short())
8587            .unwrap_or_else(|| "?".to_string());
8588        let colored = s
8589            .character
8590            .as_ref()
8591            .map(|c| c.colored())
8592            .unwrap_or_else(|| "?".to_string());
8593        // Approximate display width: emoji renders as ~2 cells in most
8594        // terminals; the rest are 1 cell each. We pad to 18 displayed
8595        // chars (≈22 byte slots when counting emoji).
8596        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
8597        let pad = 22usize.saturating_sub(displayed_width);
8598        println!(
8599            "{}{}  {:<24} {:<24} {:<10} {}",
8600            colored,
8601            " ".repeat(pad),
8602            s.name,
8603            s.handle.as_deref().unwrap_or("?"),
8604            if s.daemon_running { "running" } else { "down" },
8605            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8606        );
8607    }
8608    Ok(())
8609}
8610
8611/// v0.5.19: `wire session list-local` — sister-session discovery.
8612///
8613/// For each on-disk session, read its `relay-state.json` and surface
8614/// the ones that have a Local-scope endpoint (allocated via
8615/// `wire session new --with-local`). Group by the local-relay URL so
8616/// the operator can see at a glance which sessions are mutually
8617/// reachable over the same loopback relay.
8618///
8619/// Read-only, no daemon contact. Useful as the prelude to teaming /
8620/// pairing same-box sister claudes (see also `wire session
8621/// pair-all-local` once implemented).
8622fn cmd_session_list_local(as_json: bool) -> Result<()> {
8623    let listing = crate::session::list_local_sessions()?;
8624    if as_json {
8625        println!("{}", serde_json::to_string(&listing)?);
8626        return Ok(());
8627    }
8628
8629    if listing.local.is_empty() && listing.federation_only.is_empty() {
8630        println!(
8631            "no sessions on this machine. `wire session new --with-local` to create one \
8632             with a local-relay endpoint (start the relay first: \
8633             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
8634        );
8635        return Ok(());
8636    }
8637
8638    if listing.local.is_empty() {
8639        println!(
8640            "no sister sessions reachable via a local relay. \
8641             Re-run `wire session new --with-local` to add a Local endpoint, or \
8642             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
8643        );
8644    } else {
8645        // Stable iteration order: sort the relay URLs.
8646        let mut keys: Vec<&String> = listing.local.keys().collect();
8647        keys.sort();
8648        for relay_url in keys {
8649            let group = &listing.local[relay_url];
8650            println!("LOCAL RELAY: {relay_url}");
8651            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
8652            for s in group {
8653                println!(
8654                    "  {:<24} {:<32} {:<10} {}",
8655                    s.name,
8656                    s.handle.as_deref().unwrap_or("?"),
8657                    if s.daemon_running { "running" } else { "down" },
8658                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8659                );
8660            }
8661            println!();
8662        }
8663    }
8664
8665    if !listing.federation_only.is_empty() {
8666        println!("federation-only (no local endpoint):");
8667        for s in &listing.federation_only {
8668            println!(
8669                "  {:<24} {:<32} {}",
8670                s.name,
8671                s.handle.as_deref().unwrap_or("?"),
8672                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8673            );
8674        }
8675    }
8676    Ok(())
8677}
8678
8679/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
8680/// session that has a Local-scope endpoint. Skips already-paired
8681/// pairs; reports a per-pair outcome JSON suitable for scripting.
8682///
8683/// Same-uid trust anchor: the caller owns every session enumerated by
8684/// `list_local_sessions`, so the operator running this command IS the
8685/// consent for both sides. The bilateral SAS / network-level handshake
8686/// assumes strangers; same-uid sister sessions are not strangers.
8687///
8688/// Per-pair flow (sequential to keep relay-side load + log clarity):
8689///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
8690///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
8691///   3. sleep settle_secs                       (pair_drop reaches B)
8692///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
8693///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
8694///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
8695///   7. sleep settle_secs                       (ack reaches A)
8696///   8. WIRE_HOME=A wire pull --json            (A pins B)
8697fn cmd_session_pair_all_local(
8698    settle_secs: u64,
8699    federation_relay: &str,
8700    as_json: bool,
8701) -> Result<()> {
8702    use std::collections::BTreeSet;
8703    use std::time::Duration;
8704
8705    let listing = crate::session::list_local_sessions()?;
8706    // Flatten + dedup by session NAME (same session can appear under
8707    // multiple local-relay URLs if it advertises two local endpoints;
8708    // rare, but pair each pair exactly once).
8709    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
8710        Default::default();
8711    for group in listing.local.into_values() {
8712        for s in group {
8713            by_name.entry(s.name.clone()).or_insert(s);
8714        }
8715    }
8716    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8717
8718    if sessions.len() < 2 {
8719        let msg = format!(
8720            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
8721            sessions.len()
8722        );
8723        if as_json {
8724            println!(
8725                "{}",
8726                serde_json::to_string(&json!({
8727                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
8728                    "pairs_attempted": 0,
8729                    "pairs_succeeded": 0,
8730                    "pairs_skipped_already_paired": 0,
8731                    "pairs_failed": 0,
8732                    "note": msg,
8733                }))?
8734            );
8735        } else {
8736            println!("{msg}");
8737            if let Some(s) = sessions.first() {
8738                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
8739            }
8740            println!("Use `wire session new --with-local` to add more.");
8741        }
8742        return Ok(());
8743    }
8744
8745    let fed_host = host_of_url(federation_relay);
8746    if fed_host.is_empty() {
8747        bail!(
8748            "federation_relay `{federation_relay}` has no parseable host — \
8749             pass a full URL like `https://wireup.net`."
8750        );
8751    }
8752
8753    // Enumerate unordered pairs deterministically by session name.
8754    let mut attempted = 0u32;
8755    let mut succeeded = 0u32;
8756    let mut skipped_already = 0u32;
8757    let mut failed = 0u32;
8758    let mut per_pair: Vec<Value> = Vec::new();
8759
8760    for i in 0..sessions.len() {
8761        for j in (i + 1)..sessions.len() {
8762            let a = &sessions[i];
8763            let b = &sessions[j];
8764            attempted += 1;
8765
8766            // Already-paired check: if A's relay-state has B's CARD
8767            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
8768            // are character handles (not session names), so we use
8769            // each side's handle field (already on the LocalSessionView)
8770            // for the lookup rather than the session name.
8771            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
8772            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
8773            let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
8774            let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
8775            if a_pinned_b && b_pinned_a {
8776                skipped_already += 1;
8777                per_pair.push(json!({
8778                    "from": a.name,
8779                    "to": b.name,
8780                    "status": "already_paired",
8781                }));
8782                continue;
8783            }
8784
8785            let pair_result = drive_bilateral_pair(
8786                &a.home_dir,
8787                &a.name,
8788                &b.home_dir,
8789                &b.name,
8790                &fed_host,
8791                federation_relay,
8792                settle_secs,
8793            );
8794
8795            match pair_result {
8796                Ok(()) => {
8797                    succeeded += 1;
8798                    per_pair.push(json!({
8799                        "from": a.name,
8800                        "to": b.name,
8801                        "status": "paired",
8802                    }));
8803                }
8804                Err(e) => {
8805                    failed += 1;
8806                    let detail = format!("{e:#}");
8807                    per_pair.push(json!({
8808                        "from": a.name,
8809                        "to": b.name,
8810                        "status": "failed",
8811                        "error": detail,
8812                    }));
8813                }
8814            }
8815
8816            // Brief settle between pairs so we don't slam the relay
8817            // with N(N-1) parallel requests.
8818            std::thread::sleep(Duration::from_millis(200));
8819        }
8820    }
8821
8822    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
8823    let summary = json!({
8824        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
8825        "pairs_attempted": attempted,
8826        "pairs_succeeded": succeeded,
8827        "pairs_skipped_already_paired": skipped_already,
8828        "pairs_failed": failed,
8829        "results": per_pair,
8830    });
8831    if as_json {
8832        println!("{}", serde_json::to_string(&summary)?);
8833    } else {
8834        println!(
8835            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
8836            sessions.len(),
8837            attempted
8838        );
8839        println!("  paired:                 {succeeded}");
8840        println!("  skipped (already pinned): {skipped_already}");
8841        println!("  failed:                 {failed}");
8842        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
8843            let from = entry["from"].as_str().unwrap_or("?");
8844            let to = entry["to"].as_str().unwrap_or("?");
8845            let status = entry["status"].as_str().unwrap_or("?");
8846            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
8847            if err.is_empty() {
8848                println!("  {from:<24} ↔ {to:<24} {status}");
8849            } else {
8850                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
8851            }
8852        }
8853    }
8854    Ok(())
8855}
8856
8857/// Check whether `session_home`'s `relay.json` already lists `peer_name`
8858/// under `state.peers`. Best-effort — any read/parse error → false.
8859fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
8860    val_session_relay_state(session_home)
8861        .and_then(|v| v.get("peers").cloned())
8862        .and_then(|p| p.get(peer_name).cloned())
8863        .is_some()
8864}
8865
8866/// Read a session's `relay.json` directly without mutating the process'
8867/// WIRE_HOME env (which would race other threads / processes). Returns
8868/// `None` on any read or parse error — callers treat missing state as
8869/// "no peers / no endpoints" rather than aborting.
8870fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
8871    let path = session_home.join("config").join("wire").join("relay.json");
8872    let bytes = std::fs::read(&path).ok()?;
8873    serde_json::from_slice(&bytes).ok()
8874}
8875
8876/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
8877/// One probe per directed edge against the relay backing that edge's
8878/// priority-1 endpoint; output groups by undirected pair.
8879fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
8880    use std::collections::BTreeMap;
8881
8882    // Flatten by session NAME — same dedup logic as pair-all-local so a
8883    // session advertising two local endpoints doesn't get double-counted.
8884    let listing = crate::session::list_local_sessions()?;
8885    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
8886    for group in listing.local.into_values() {
8887        for s in group {
8888            by_name.entry(s.name.clone()).or_insert(s);
8889        }
8890    }
8891    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8892    let federation_only = listing.federation_only;
8893
8894    if sessions.is_empty() {
8895        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
8896        if as_json {
8897            println!(
8898                "{}",
8899                serde_json::to_string(&json!({
8900                    "sessions": [],
8901                    "edges": [],
8902                    "local_relay": null,
8903                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8904                    "summary": {
8905                        "session_count": 0,
8906                        "edge_count": 0,
8907                        "healthy": 0,
8908                        "stale": 0,
8909                        "asymmetric": 0,
8910                    },
8911                    "note": msg,
8912                }))?
8913            );
8914        } else {
8915            println!("{msg}");
8916            println!("Use `wire session new --with-local` to create one.");
8917        }
8918        return Ok(());
8919    }
8920
8921    // Build a name → session-state map: relay_state + reachable handle set.
8922    struct SessionState {
8923        view: crate::session::LocalSessionView,
8924        relay_state: Value,
8925        local_relay_url: Option<String>,
8926    }
8927    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
8928    for s in sessions {
8929        let relay_state = val_session_relay_state(&s.home_dir)
8930            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8931        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
8932        sstates.push(SessionState {
8933            view: s,
8934            relay_state,
8935            local_relay_url,
8936        });
8937    }
8938
8939    // Probe each unique local-relay URL once for healthz so the operator
8940    // sees one liveness line per local relay, not one per edge.
8941    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
8942    for s in &sstates {
8943        if let Some(url) = &s.local_relay_url
8944            && !local_relays.contains_key(url)
8945        {
8946            let healthy = probe_relay_healthz(url);
8947            local_relays.insert(url.clone(), healthy);
8948        }
8949    }
8950
8951    let now = std::time::SystemTime::now()
8952        .duration_since(std::time::UNIX_EPOCH)
8953        .map(|d| d.as_secs())
8954        .unwrap_or(0);
8955
8956    // Edges: walk every unordered pair, surface bilateral state + each
8957    // direction's last_pull. Probe priority-1 endpoint (local preferred
8958    // by `peer_endpoints_in_priority_order`).
8959    let mut edges: Vec<Value> = Vec::new();
8960    let mut healthy_count = 0u32;
8961    let mut stale_count = 0u32;
8962    let mut asymmetric_count = 0u32;
8963
8964    for i in 0..sstates.len() {
8965        for j in (i + 1)..sstates.len() {
8966            let a = &sstates[i];
8967            let b = &sstates[j];
8968            // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
8969            // (DID-derived character), not the session name. Look the
8970            // peer up by its handle (with a session-name fallback for
8971            // pre-v0.11 sessions that haven't re-init'd yet).
8972            let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
8973            let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
8974            let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
8975            let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
8976
8977            let bilateral = a_to_b.pinned && b_to_a.pinned;
8978            // Scope = the most-local scope available in either direction.
8979            // (If a→b is local and b→a is federation, the asymmetric
8980            // detail surfaces below; the headline scope is the better.)
8981            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
8982                (Some("local"), _) | (_, Some("local")) => "local",
8983                (Some("federation"), _) | (_, Some("federation")) => "federation",
8984                _ => "unknown",
8985            };
8986
8987            // Health: stale if either direction's last_pull is older than
8988            // `stale_secs`, or never observed when both sides are pinned.
8989            let mut status = if bilateral { "healthy" } else { "asymmetric" };
8990            if bilateral {
8991                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
8992                    Some(s) => s > stale_secs,
8993                    None => d.probed,
8994                });
8995                if either_stale {
8996                    status = "stale";
8997                }
8998            }
8999
9000            match status {
9001                "healthy" => healthy_count += 1,
9002                "stale" => stale_count += 1,
9003                "asymmetric" => asymmetric_count += 1,
9004                _ => {}
9005            }
9006
9007            edges.push(json!({
9008                "from": a.view.name,
9009                "to": b.view.name,
9010                "bilateral": bilateral,
9011                "scope": scope,
9012                "status": status,
9013                "directions": {
9014                    a.view.name.clone(): direction_summary(&a_to_b),
9015                    b.view.name.clone(): direction_summary(&b_to_a),
9016                },
9017            }));
9018        }
9019    }
9020
9021    let summary = json!({
9022        "sessions": sstates.iter().map(|s| json!({
9023            "name": s.view.name,
9024            "handle": s.view.handle,
9025            "cwd": s.view.cwd,
9026            "daemon_running": s.view.daemon_running,
9027            "local_relay": s.local_relay_url,
9028        })).collect::<Vec<_>>(),
9029        "edges": edges,
9030        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
9031            "url": url,
9032            "healthy": healthy,
9033        })).collect::<Vec<_>>(),
9034        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9035        "summary": {
9036            "session_count": sstates.len(),
9037            "edge_count": edges.len(),
9038            "healthy": healthy_count,
9039            "stale": stale_count,
9040            "asymmetric": asymmetric_count,
9041            "stale_threshold_secs": stale_secs,
9042        },
9043    });
9044
9045    if as_json {
9046        println!("{}", serde_json::to_string(&summary)?);
9047        return Ok(());
9048    }
9049
9050    println!(
9051        "wire mesh: {} session(s), {} edge(s)",
9052        sstates.len(),
9053        edges.len()
9054    );
9055    for (url, healthy) in &local_relays {
9056        let tick = if *healthy { "✓" } else { "✗" };
9057        println!("  local-relay {url} {tick}");
9058    }
9059    if !federation_only.is_empty() {
9060        print!("  federation-only sessions:");
9061        for f in &federation_only {
9062            print!(" {}", f.name);
9063        }
9064        println!();
9065    }
9066
9067    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
9068    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
9069    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
9070    print!("\n{:>col_w$}", "", col_w = col_w);
9071    for n in &names {
9072        print!("{:>col_w$}", n, col_w = col_w);
9073    }
9074    println!();
9075    for (i, row) in names.iter().enumerate() {
9076        print!("{:>col_w$}", row, col_w = col_w);
9077        for (j, col) in names.iter().enumerate() {
9078            let cell = if i == j {
9079                "self".to_string()
9080            } else {
9081                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
9082                match d.scope.as_deref() {
9083                    Some("local") => "local".to_string(),
9084                    Some("federation") => "fed".to_string(),
9085                    _ => "—".to_string(),
9086                }
9087            };
9088            print!("{:>col_w$}", cell, col_w = col_w);
9089        }
9090        println!();
9091    }
9092
9093    println!("\nHealth (stale threshold: {stale_secs}s):");
9094    for e in &edges {
9095        let from = e["from"].as_str().unwrap_or("?");
9096        let to = e["to"].as_str().unwrap_or("?");
9097        let scope = e["scope"].as_str().unwrap_or("?");
9098        let status = e["status"].as_str().unwrap_or("?");
9099        let mark = match status {
9100            "healthy" => "✓",
9101            "stale" => "⚠",
9102            "asymmetric" => "!",
9103            _ => "?",
9104        };
9105        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
9106        let mut details: Vec<String> = Vec::new();
9107        for (who, d) in &dirs {
9108            let silent = d.get("silent_secs").and_then(Value::as_u64);
9109            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
9110            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
9111            let label = match (pinned, probed, silent) {
9112                (false, _, _) => format!("{who} has not pinned"),
9113                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
9114                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
9115                (true, true, Some(s)) => format!("{who} silent {s}s"),
9116                (true, true, None) => format!("{who} never pulled"),
9117            };
9118            details.push(label);
9119        }
9120        println!(
9121            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
9122            details.join(" | ")
9123        );
9124    }
9125    Ok(())
9126}
9127
9128#[derive(Default)]
9129struct DirectedEdge {
9130    pinned: bool,
9131    scope: Option<String>,
9132    last_pull_at_unix: Option<u64>,
9133    silent_secs: Option<u64>,
9134    probed: bool,
9135    event_count: usize,
9136}
9137
9138/// Probe a single directed edge from `from_state`'s view of `to_name`.
9139/// Picks the priority-1 endpoint (local preferred when reachable) and
9140/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
9141/// failure (the function records `probed = true`, `last_pull = None`,
9142/// which the caller treats as "never pulled, route exists" = stale).
9143fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
9144    let pinned = from_state
9145        .get("peers")
9146        .and_then(|p| p.get(to_name))
9147        .is_some();
9148    if !pinned {
9149        return DirectedEdge::default();
9150    }
9151    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
9152    let ep = match endpoints.into_iter().next() {
9153        Some(e) => e,
9154        None => {
9155            return DirectedEdge {
9156                pinned: true,
9157                ..Default::default()
9158            };
9159        }
9160    };
9161    let scope = Some(
9162        match ep.scope {
9163            crate::endpoints::EndpointScope::Local => "local",
9164            crate::endpoints::EndpointScope::Lan => "lan",
9165            crate::endpoints::EndpointScope::Uds => "uds",
9166            crate::endpoints::EndpointScope::Federation => "federation",
9167        }
9168        .to_string(),
9169    );
9170    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
9171    let (count, last) = client
9172        .slot_state(&ep.slot_id, &ep.slot_token)
9173        .unwrap_or((0, None));
9174    let silent = last.map(|t| now.saturating_sub(t));
9175    DirectedEdge {
9176        pinned: true,
9177        scope,
9178        last_pull_at_unix: last,
9179        silent_secs: silent,
9180        probed: true,
9181        event_count: count,
9182    }
9183}
9184
9185fn direction_summary(d: &DirectedEdge) -> Value {
9186    json!({
9187        "pinned": d.pinned,
9188        "scope": d.scope,
9189        "probed": d.probed,
9190        "last_pull_at_unix": d.last_pull_at_unix,
9191        "silent_secs": d.silent_secs,
9192        "event_count": d.event_count,
9193    })
9194}
9195
9196/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
9197fn probe_relay_healthz(url: &str) -> bool {
9198    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
9199    let client = match reqwest::blocking::Client::builder()
9200        .timeout(std::time::Duration::from_millis(500))
9201        .build()
9202    {
9203        Ok(c) => c,
9204        Err(_) => return false,
9205    };
9206    match client.get(&probe_url).send() {
9207        Ok(r) => r.status().is_success(),
9208        Err(_) => false,
9209    }
9210}
9211
9212/// Drive one bilateral pair handshake between two sister sessions
9213/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
9214/// flow so failures bubble up at the offending step, not buried in
9215/// a parallel race. See `cmd_session_pair_all_local` docstring.
9216///
9217/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
9218/// federation `.well-known/wire/agent` resolution. Reads B's card +
9219/// endpoints directly off disk under `b_home` and pins them. This
9220/// makes pair-all-local work for sister sessions whose federation
9221/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
9222/// for sessions created with `wire session new --local-only`
9223/// (no federation slot at all). The `_federation_relay` / `_fed_host`
9224/// parameters are retained for callers that want to log them but
9225/// the handshake itself no longer touches federation.
9226fn drive_bilateral_pair(
9227    a_home: &std::path::Path,
9228    a_name: &str,
9229    b_home: &std::path::Path,
9230    b_name: &str,
9231    _fed_host: &str,
9232    _federation_relay: &str,
9233    settle_secs: u64,
9234) -> Result<()> {
9235    use std::time::Duration;
9236    let bin = std::env::current_exe().context("locating self exe")?;
9237
9238    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
9239        let out = std::process::Command::new(&bin)
9240            .env("WIRE_HOME", home)
9241            .env_remove("RUST_LOG")
9242            .args(args)
9243            .output()
9244            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9245        if !out.status.success() {
9246            bail!(
9247                "`wire {}` failed: stderr={}",
9248                args.join(" "),
9249                String::from_utf8_lossy(&out.stderr).trim()
9250            );
9251        }
9252        Ok(())
9253    };
9254
9255    // v0.11: each session's agent-card.handle is the DID-derived
9256    // character, not the session name. pair-accept lookups key on the
9257    // CARD HANDLE, so we discover each side's canonical handle from
9258    // its agent-card on disk before driving the pair flow.
9259    let read_card_handle = |home: &std::path::Path| -> Result<String> {
9260        let card_path = home.join("config").join("wire").join("agent-card.json");
9261        let bytes = std::fs::read(&card_path)
9262            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
9263        let card: Value = serde_json::from_slice(&bytes)?;
9264        card.get("handle")
9265            .and_then(Value::as_str)
9266            .map(str::to_string)
9267            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
9268    };
9269    let a_handle = read_card_handle(a_home)
9270        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
9271    let b_handle = read_card_handle(b_home)
9272        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
9273
9274    // 1. A initiates via --local-sister (uses the session NAME for
9275    // the registry lookup; cmd_add_local_sister auto-resolves
9276    // session→handle internally).
9277    run(a_home, &["add", b_name, "--local-sister", "--json"])
9278        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
9279
9280    // 3. settle so pair_drop reaches B's slot
9281    std::thread::sleep(Duration::from_secs(settle_secs));
9282
9283    // 4. B pulls pair_drop → 5. B pair-accept (pins A by CARD HANDLE,
9284    // not by session name — under v0.11 these differ) → 6. B push ack
9285    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
9286    run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
9287        format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
9288    })?;
9289    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
9290
9291    // 7. settle so ack reaches A's slot
9292    std::thread::sleep(Duration::from_secs(settle_secs));
9293
9294    // 8. A pulls ack (pins B by CARD HANDLE)
9295    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
9296    // suppress unused warning when both handles are consumed
9297    let _ = &b_handle;
9298
9299    Ok(())
9300}
9301
9302fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
9303    let name = resolve_session_name(name_arg)?;
9304    let session_home = crate::session::session_dir(&name)?;
9305    if !session_home.exists() {
9306        bail!(
9307            "no session named {name:?} on this machine. `wire session list` to enumerate, \
9308             `wire session new {name}` to create."
9309        );
9310    }
9311    if as_json {
9312        println!(
9313            "{}",
9314            serde_json::to_string(&json!({
9315                "name": name,
9316                "home_dir": session_home.to_string_lossy(),
9317                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9318            }))?
9319        );
9320    } else {
9321        println!("export WIRE_HOME={}", session_home.to_string_lossy());
9322    }
9323    Ok(())
9324}
9325
9326fn cmd_session_current(as_json: bool) -> Result<()> {
9327    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9328    let registry = crate::session::read_registry().unwrap_or_default();
9329    let cwd_key = cwd.to_string_lossy().into_owned();
9330    let name = registry.by_cwd.get(&cwd_key).cloned();
9331    if as_json {
9332        println!(
9333            "{}",
9334            serde_json::to_string(&json!({
9335                "cwd": cwd_key,
9336                "session": name,
9337            }))?
9338        );
9339    } else if let Some(n) = name {
9340        println!("{n}");
9341    } else {
9342        println!("(no session registered for this cwd)");
9343    }
9344    Ok(())
9345}
9346
9347fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
9348    let name = crate::session::sanitize_name(name_arg);
9349    let session_home = crate::session::session_dir(&name)?;
9350    if !session_home.exists() {
9351        if as_json {
9352            println!(
9353                "{}",
9354                serde_json::to_string(&json!({
9355                    "name": name,
9356                    "destroyed": false,
9357                    "reason": "no such session",
9358                }))?
9359            );
9360        } else {
9361            println!("no session named {name:?} — nothing to destroy.");
9362        }
9363        return Ok(());
9364    }
9365    if !force {
9366        bail!(
9367            "destroying session {name:?} would delete its keypair + state irrecoverably. \
9368             Pass --force to confirm."
9369        );
9370    }
9371
9372    // Kill the session-local daemon if alive.
9373    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9374    if let Ok(bytes) = std::fs::read(&pidfile) {
9375        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9376            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9377        } else {
9378            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9379        };
9380        if let Some(p) = pid {
9381            let _ = std::process::Command::new("kill")
9382                .args(["-TERM", &p.to_string()])
9383                .output();
9384        }
9385    }
9386
9387    std::fs::remove_dir_all(&session_home)
9388        .with_context(|| format!("removing session dir {session_home:?}"))?;
9389
9390    // Strip from registry.
9391    let mut registry = crate::session::read_registry().unwrap_or_default();
9392    registry.by_cwd.retain(|_, v| v != &name);
9393    crate::session::write_registry(&registry)?;
9394
9395    if as_json {
9396        println!(
9397            "{}",
9398            serde_json::to_string(&json!({
9399                "name": name,
9400                "destroyed": true,
9401            }))?
9402        );
9403    } else {
9404        println!("destroyed session {name:?}.");
9405    }
9406    Ok(())
9407}
9408
9409// ---------- diag (structured trace) ----------
9410
9411fn cmd_diag(action: DiagAction) -> Result<()> {
9412    let state = config::state_dir()?;
9413    let knob = state.join("diag.enabled");
9414    let log_path = state.join("diag.jsonl");
9415    match action {
9416        DiagAction::Tail { limit, json } => {
9417            let entries = crate::diag::tail(limit);
9418            if json {
9419                for e in entries {
9420                    println!("{}", serde_json::to_string(&e)?);
9421                }
9422            } else if entries.is_empty() {
9423                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
9424            } else {
9425                for e in entries {
9426                    let ts = e["ts"].as_u64().unwrap_or(0);
9427                    let ty = e["type"].as_str().unwrap_or("?");
9428                    let pid = e["pid"].as_u64().unwrap_or(0);
9429                    let payload = e["payload"].to_string();
9430                    println!("[{ts}] pid={pid} {ty} {payload}");
9431                }
9432            }
9433        }
9434        DiagAction::Enable => {
9435            config::ensure_dirs()?;
9436            std::fs::write(&knob, "1")?;
9437            println!("wire diag: enabled at {knob:?}");
9438        }
9439        DiagAction::Disable => {
9440            if knob.exists() {
9441                std::fs::remove_file(&knob)?;
9442            }
9443            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
9444        }
9445        DiagAction::Status { json } => {
9446            let enabled = crate::diag::is_enabled();
9447            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
9448            if json {
9449                println!(
9450                    "{}",
9451                    serde_json::to_string(&serde_json::json!({
9452                        "enabled": enabled,
9453                        "log_path": log_path,
9454                        "log_size_bytes": size,
9455                    }))?
9456                );
9457            } else {
9458                println!("wire diag status");
9459                println!("  enabled:    {enabled}");
9460                println!("  log:        {log_path:?}");
9461                println!("  log size:   {size} bytes");
9462            }
9463        }
9464    }
9465    Ok(())
9466}
9467
9468// ---------- service (install / uninstall / status) ----------
9469
9470fn cmd_service(action: ServiceAction) -> Result<()> {
9471    let kind = |local_relay: bool| {
9472        if local_relay {
9473            crate::service::ServiceKind::LocalRelay
9474        } else {
9475            crate::service::ServiceKind::Daemon
9476        }
9477    };
9478    let (report, as_json) = match action {
9479        ServiceAction::Install { local_relay, json } => {
9480            (crate::service::install_kind(kind(local_relay))?, json)
9481        }
9482        ServiceAction::Uninstall { local_relay, json } => {
9483            (crate::service::uninstall_kind(kind(local_relay))?, json)
9484        }
9485        ServiceAction::Status { local_relay, json } => {
9486            (crate::service::status_kind(kind(local_relay))?, json)
9487        }
9488    };
9489    if as_json {
9490        println!("{}", serde_json::to_string(&report)?);
9491    } else {
9492        println!("wire service {}", report.action);
9493        println!("  platform:  {}", report.platform);
9494        println!("  unit:      {}", report.unit_path);
9495        println!("  status:    {}", report.status);
9496        println!("  detail:    {}", report.detail);
9497    }
9498    Ok(())
9499}
9500
9501// ---------- upgrade (atomic daemon swap) ----------
9502
9503/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
9504/// fresh one from the currently-installed binary, write a new versioned
9505/// pidfile. The fix for today's exact failure mode: a daemon process that
9506/// kept running OLD binary text in memory under a symlink that had since
9507/// been repointed at a NEW binary on disk.
9508///
9509/// Idempotent. If no stale daemon is running, just starts a fresh one
9510/// (same as `wire daemon &` but with the wait-until-alive guard from
9511/// ensure_up::ensure_daemon_running).
9512///
9513/// `--check` mode reports drift without acting — lists the processes
9514/// that WOULD be killed and the binary version of each.
9515fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
9516    // 1. Identify all running wire processes. v0.7.3: walks `pgrep -f`
9517    // on unix / `Get-CimInstance Win32_Process` on Windows via the
9518    // shared `platform::find_processes_by_cmdline`. Covers both the
9519    // long-lived sync `wire daemon` *and* the `wire relay-server`
9520    // local-only loopback — the pre-v0.7.3 upgrade only swept daemons
9521    // and left stale relay-server children pinned on the old binary,
9522    // forcing operators to `pkill -f relay-server` manually after
9523    // every version bump.
9524    let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
9525    let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
9526    let running_pids: Vec<u32> = daemon_pids
9527        .iter()
9528        .chain(relay_pids.iter())
9529        .copied()
9530        .collect();
9531
9532    // 2. Read pidfile to surface what the daemon THINKS it is.
9533    let record = crate::ensure_up::read_pid_record("daemon");
9534    let recorded_version: Option<String> = match &record {
9535        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
9536        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
9537        _ => None,
9538    };
9539    let cli_version = env!("CARGO_PKG_VERSION").to_string();
9540
9541    // 2b. v0.6.9: snapshot which sessions HAD a running daemon BEFORE
9542    // we kill anything. Step 3's pgrep+SIGTERM also kills session-owned
9543    // daemons (they share the `wire daemon` command line), so by the
9544    // time the respawn loop runs, `daemon_running` would always be
9545    // false and zero sessions would respawn. Capture state up front
9546    // and respawn whatever was alive at the start.
9547    let sessions_to_respawn_after_kill: Vec<std::path::PathBuf> = crate::session::list_sessions()
9548        .unwrap_or_default()
9549        .into_iter()
9550        .filter(|s| s.daemon_running)
9551        .map(|s| s.home_dir)
9552        .collect();
9553
9554    if check_only {
9555        // v0.6.8: also surface session-level state + PATH dupes in --check.
9556        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
9557            .unwrap_or_default()
9558            .iter()
9559            .filter(|s| s.daemon_running)
9560            .map(|s| s.name.clone())
9561            .collect();
9562        let mut path_dupes: Vec<String> = Vec::new();
9563        if let Ok(path) = std::env::var("PATH") {
9564            let mut seen: std::collections::HashSet<std::path::PathBuf> =
9565                std::collections::HashSet::new();
9566            for dir in path.split(':') {
9567                let candidate = std::path::PathBuf::from(dir).join("wire");
9568                if candidate.exists() {
9569                    let canon = candidate.canonicalize().unwrap_or(candidate);
9570                    if seen.insert(canon.clone()) {
9571                        path_dupes.push(canon.to_string_lossy().into_owned());
9572                    }
9573                }
9574            }
9575        }
9576        // v0.7.3: enumerate which service units WOULD be refreshed.
9577        // Read-only — `status_kind` doesn't touch anything.
9578        let installed_service_kinds: Vec<&'static str> = [
9579            (crate::service::ServiceKind::Daemon, "daemon"),
9580            (crate::service::ServiceKind::LocalRelay, "local-relay"),
9581        ]
9582        .into_iter()
9583        .filter_map(|(k, label)| {
9584            crate::service::status_kind(k)
9585                .ok()
9586                .filter(|r| r.status != "absent")
9587                .map(|_| label)
9588        })
9589        .collect();
9590        let report = json!({
9591            "running_pids": running_pids,
9592            "running_daemons": daemon_pids,
9593            "running_relay_servers": relay_pids,
9594            "pidfile_version": recorded_version,
9595            "cli_version": cli_version,
9596            "would_kill": running_pids,
9597            "would_refresh_services": installed_service_kinds,
9598            "session_daemons_running": sessions_with_daemons,
9599            "path_binaries": path_dupes,
9600            "path_duplicate_warning": path_dupes.len() > 1,
9601        });
9602        if as_json {
9603            println!("{}", serde_json::to_string(&report)?);
9604        } else {
9605            println!("wire upgrade --check");
9606            println!("  cli version:      {cli_version}");
9607            println!(
9608                "  pidfile version:  {}",
9609                recorded_version.as_deref().unwrap_or("(missing)")
9610            );
9611            if running_pids.is_empty() {
9612                println!("  running daemons:  none");
9613                println!("  running relays:   none");
9614            } else {
9615                if daemon_pids.is_empty() {
9616                    println!("  running daemons:  none");
9617                } else {
9618                    let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
9619                    println!("  running daemons:  pids {}", p.join(", "));
9620                }
9621                if relay_pids.is_empty() {
9622                    println!("  running relays:   none");
9623                } else {
9624                    let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
9625                    println!("  running relays:   pids {}", p.join(", "));
9626                }
9627                println!("  would kill all + spawn fresh");
9628            }
9629            if !installed_service_kinds.is_empty() {
9630                println!(
9631                    "  would refresh:    {} installed service unit(s) → new binary path",
9632                    installed_service_kinds.join(", ")
9633                );
9634            }
9635            if !sessions_with_daemons.is_empty() {
9636                println!(
9637                    "  session daemons:  {} (would respawn under new binary)",
9638                    sessions_with_daemons.join(", ")
9639                );
9640            }
9641            if path_dupes.len() > 1 {
9642                println!(
9643                    "  PATH warning:     {} distinct `wire` binaries on PATH:",
9644                    path_dupes.len()
9645                );
9646                for b in &path_dupes {
9647                    println!("                      {b}");
9648                }
9649                println!("                    operators should remove the stale ones");
9650            }
9651        }
9652        return Ok(());
9653    }
9654
9655    // 3. Kill every running wire process (daemons + relay-servers).
9656    // Graceful first (SIGTERM / taskkill), then forceful (SIGKILL /
9657    // taskkill /F) after a brief grace period. v0.7.3: uses
9658    // `platform::kill_process` so the Windows path goes through
9659    // `taskkill /T /PID` (kills the process tree, important for
9660    // relay-server's hyper worker threads).
9661    let mut killed: Vec<u32> = Vec::new();
9662    for pid in &running_pids {
9663        if crate::platform::kill_process(*pid, false) {
9664            killed.push(*pid);
9665        }
9666    }
9667    // Wait up to ~2s for graceful exit.
9668    if !killed.is_empty() {
9669        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
9670        loop {
9671            let still_alive: Vec<u32> = killed
9672                .iter()
9673                .copied()
9674                .filter(|p| process_alive_pid(*p))
9675                .collect();
9676            if still_alive.is_empty() {
9677                break;
9678            }
9679            if std::time::Instant::now() >= deadline {
9680                // Force-kill hold-outs.
9681                for pid in still_alive {
9682                    let _ = crate::platform::kill_process(pid, true);
9683                }
9684                break;
9685            }
9686            std::thread::sleep(std::time::Duration::from_millis(50));
9687        }
9688    }
9689
9690    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
9691    //    old daemon is still owning it.
9692    let pidfile = config::state_dir()?.join("daemon.pid");
9693    if pidfile.exists() {
9694        let _ = std::fs::remove_file(&pidfile);
9695    }
9696
9697    // 4b. v0.6.8/9 stale-cleanup: wipe every session's pidfile (step 3's
9698    // pgrep+SIGTERM has already killed the processes; pidfile tombstones
9699    // would otherwise block ensure_session_daemon's "already running"
9700    // short-circuit). The respawn list comes from the v0.6.9 pre-kill
9701    // snapshot above — checking `daemon_running` here would always
9702    // return false because we just killed them.
9703    if let Ok(sessions) = crate::session::list_sessions() {
9704        for s in &sessions {
9705            let session_pidfile = s.home_dir.join("state").join("wire").join("daemon.pid");
9706            if session_pidfile.exists() {
9707                let _ = std::fs::remove_file(&session_pidfile);
9708            }
9709        }
9710    }
9711    let session_daemons_to_respawn = sessions_to_respawn_after_kill;
9712
9713    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
9714    // multiple distinct files on $PATH, surface the conflict — operators
9715    // get bitten when an old binary at /usr/local/bin shadows a fresh
9716    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
9717    let mut path_dupes: Vec<String> = Vec::new();
9718    if let Ok(path) = std::env::var("PATH") {
9719        let mut seen: std::collections::HashSet<std::path::PathBuf> =
9720            std::collections::HashSet::new();
9721        for dir in path.split(':') {
9722            let candidate = std::path::PathBuf::from(dir).join("wire");
9723            if candidate.exists() {
9724                let canon = candidate.canonicalize().unwrap_or(candidate);
9725                if seen.insert(canon.clone()) {
9726                    path_dupes.push(canon.to_string_lossy().into_owned());
9727                }
9728            }
9729        }
9730    }
9731    let path_warning = if path_dupes.len() > 1 {
9732        Some(format!(
9733            "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n  {}",
9734            path_dupes.len(),
9735            path_dupes.join("\n  ")
9736        ))
9737    } else {
9738        None
9739    };
9740
9741    // 4d. v0.7.3 NEW: refresh installed service units so they point at
9742    // the freshly-installed binary path. Without this step, an upgrade
9743    // would: kill the old daemon, leave the launchd plist /
9744    // systemd unit / Windows scheduled task pointing at the OLD
9745    // binary path (or, worse, an old binary location that's been
9746    // unlinked), and then the OS's auto-respawn would either fail or
9747    // bring the OLD binary back from the dead. Reinstalling rewrites
9748    // the unit with `std::env::current_exe()` (the freshly-resolved
9749    // path of the running upgrade-driver process) and re-bootstraps /
9750    // re-enables / re-registers so the next OS-driven start uses it.
9751    //
9752    // Only refreshes units that are already installed — does NOT
9753    // install services the operator never opted into.
9754    let mut service_refreshes: Vec<Value> = Vec::new();
9755    for kind in [
9756        crate::service::ServiceKind::Daemon,
9757        crate::service::ServiceKind::LocalRelay,
9758    ] {
9759        let already_installed = crate::service::status_kind(kind)
9760            .map(|r| r.status != "absent")
9761            .unwrap_or(false);
9762        if !already_installed {
9763            continue;
9764        }
9765        match crate::service::install_kind(kind) {
9766            Ok(rep) => service_refreshes.push(json!({
9767                "kind": rep.kind,
9768                "platform": rep.platform,
9769                "status": rep.status,
9770                "unit_path": rep.unit_path,
9771                "action": "refreshed",
9772            })),
9773            Err(e) => service_refreshes.push(json!({
9774                "kind": format!("{kind:?}"),
9775                "action": "refresh_failed",
9776                "error": format!("{e:#}"),
9777            })),
9778        }
9779    }
9780
9781    // 5. Spawn fresh daemon via ensure_up — atomically waits for
9782    //    process_alive + writes the versioned pidfile. (If the Daemon
9783    //    service was refreshed above, it has already started a fresh
9784    //    process under the new binary; ensure_daemon_running notices
9785    //    and short-circuits to "already running".)
9786    let spawned = crate::ensure_up::ensure_daemon_running()?;
9787
9788    // 5b. v0.6.8: respawn each session daemon under the new binary.
9789    // Reuses `ensure_session_daemon` — same code path `wire session new`
9790    // takes for the initial spawn (writes versioned pidfile, opens log,
9791    // detaches). Best effort: failure of one session's respawn doesn't
9792    // abort the upgrade for the others.
9793    let mut session_respawns: Vec<Value> = Vec::new();
9794    for home in &session_daemons_to_respawn {
9795        match ensure_session_daemon(home) {
9796            Ok(()) => session_respawns.push(json!({
9797                "session_home": home.to_string_lossy(),
9798                "status": "respawned",
9799            })),
9800            Err(e) => session_respawns.push(json!({
9801                "session_home": home.to_string_lossy(),
9802                "status": "failed",
9803                "error": format!("{e:#}"),
9804            })),
9805        }
9806    }
9807
9808    let new_record = crate::ensure_up::read_pid_record("daemon");
9809    let new_pid = new_record.pid();
9810    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
9811        Some(d.version.clone())
9812    } else {
9813        None
9814    };
9815
9816    if as_json {
9817        println!(
9818            "{}",
9819            serde_json::to_string(&json!({
9820                "killed": killed,
9821                "killed_daemons": daemon_pids,
9822                "killed_relay_servers": relay_pids,
9823                "service_refreshes": service_refreshes,
9824                "spawned_fresh_daemon": spawned,
9825                "new_pid": new_pid,
9826                "new_version": new_version,
9827                "cli_version": cli_version,
9828                "session_respawns": session_respawns,
9829                "path_binaries": path_dupes,
9830                "path_warning": path_warning,
9831            }))?
9832        );
9833    } else {
9834        if killed.is_empty() {
9835            println!("wire upgrade: no stale wire processes running");
9836        } else {
9837            println!(
9838                "wire upgrade: killed {} process(es) — {} daemon(s) + {} relay-server(s) (pids {})",
9839                killed.len(),
9840                daemon_pids.len(),
9841                relay_pids.len(),
9842                killed
9843                    .iter()
9844                    .map(|p| p.to_string())
9845                    .collect::<Vec<_>>()
9846                    .join(", ")
9847            );
9848        }
9849        if !service_refreshes.is_empty() {
9850            println!(
9851                "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
9852                service_refreshes.len()
9853            );
9854            for r in &service_refreshes {
9855                let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
9856                let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
9857                let status = r.get("status").and_then(Value::as_str).unwrap_or("");
9858                let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
9859                if action == "refreshed" {
9860                    println!("                    - {kind}: {action} ({status}, {platform})");
9861                } else {
9862                    let err = r.get("error").and_then(Value::as_str).unwrap_or("");
9863                    println!("                    - {kind}: {action} ({err})");
9864                }
9865            }
9866        }
9867        if spawned {
9868            println!(
9869                "wire upgrade: spawned fresh daemon (pid {} v{})",
9870                new_pid
9871                    .map(|p| p.to_string())
9872                    .unwrap_or_else(|| "?".to_string()),
9873                new_version.as_deref().unwrap_or(&cli_version),
9874            );
9875        } else {
9876            println!("wire upgrade: daemon was already running on current binary");
9877        }
9878        if !session_respawns.is_empty() {
9879            println!(
9880                "wire upgrade: refreshed {} session daemon(s):",
9881                session_respawns.len()
9882            );
9883            for r in &session_respawns {
9884                let h = r["session_home"].as_str().unwrap_or("?");
9885                let s = r["status"].as_str().unwrap_or("?");
9886                let label = std::path::Path::new(h)
9887                    .file_name()
9888                    .map(|f| f.to_string_lossy().into_owned())
9889                    .unwrap_or_else(|| h.to_string());
9890                println!("  {label:<24} {s}");
9891            }
9892        }
9893        if let Some(msg) = &path_warning {
9894            eprintln!("wire upgrade: {msg}");
9895        }
9896    }
9897    Ok(())
9898}
9899
9900/// v0.9.1: should this command emit JSON by default?
9901///
9902/// - `explicit=true` → operator passed `--json`, always JSON.
9903/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
9904///   captured output parses cleanly without operators remembering to
9905///   append `--json`. Mirrors `gh`, `kubectl`, etc.
9906/// - interactive TTY → human format (false).
9907/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
9908///   that parsed the human text by accident).
9909fn json_default(explicit: bool) -> bool {
9910    if explicit {
9911        return true;
9912    }
9913    if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
9914        return false;
9915    }
9916    use std::io::IsTerminal;
9917    !std::io::stdout().is_terminal()
9918}
9919
9920fn process_alive_pid(pid: u32) -> bool {
9921    // v0.7.3: delegate to the cross-platform helper. See
9922    // `platform::process_alive` for the per-OS dispatch — Windows now
9923    // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
9924    // gave a hard-coded false on Windows pre-v0.7.3.
9925    crate::platform::process_alive(pid)
9926}
9927
9928// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
9929
9930/// Iterative Levenshtein distance between two strings, case-insensitive.
9931/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
9932/// resolves against (typically <30 chars).
9933fn levenshtein_ci(a: &str, b: &str) -> usize {
9934    let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
9935    let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
9936    let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
9937    let (m, n) = (a.len(), b.len());
9938    if m == 0 {
9939        return n;
9940    }
9941    let mut prev: Vec<usize> = (0..=m).collect();
9942    let mut curr = vec![0usize; m + 1];
9943    for j in 1..=n {
9944        curr[0] = j;
9945        for i in 1..=m {
9946            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
9947            curr[i] = std::cmp::min(
9948                std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
9949                prev[i - 1] + cost,
9950            );
9951        }
9952        std::mem::swap(&mut prev, &mut curr);
9953    }
9954    prev[m]
9955}
9956
9957/// Return up to `max_results` names from `pool` whose edit distance to
9958/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
9959/// "did you mean" suggestions on resolution miss.
9960pub fn closest_candidates(
9961    needle: &str,
9962    pool: &[String],
9963    max_distance: usize,
9964    max_results: usize,
9965) -> Vec<String> {
9966    let mut scored: Vec<(usize, &String)> = pool
9967        .iter()
9968        .map(|c| (levenshtein_ci(needle, c), c))
9969        .filter(|(d, _)| *d <= max_distance)
9970        .collect();
9971    scored.sort_by_key(|(d, _)| *d);
9972    scored
9973        .into_iter()
9974        .take(max_results)
9975        .map(|(_, c)| c.clone())
9976        .collect()
9977}
9978
9979/// Collect every name that `resolve_name_to_target` would currently
9980/// match: pinned-peer handles, pinned-peer character nicknames, sister
9981/// session names, sister character nicknames, sister handles. Used for
9982/// the `did_you_mean` pool on resolution miss.
9983fn known_local_names() -> Vec<String> {
9984    let mut names: Vec<String> = Vec::new();
9985    if let Ok(trust) = config::read_trust() {
9986        // (debug eprintln removed; left bug-trail in commit message)
9987        // trust.agents is an object keyed by handle, NOT an array —
9988        // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
9989        // the object's keys (which ARE the handles) plus each entry's
9990        // did for the DID-derived character nickname.
9991        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
9992            for (handle, agent) in agents {
9993                names.push(handle.clone());
9994                if let Some(did) = agent.get("did").and_then(Value::as_str) {
9995                    let ch = crate::character::Character::from_did(did);
9996                    names.push(ch.nickname);
9997                }
9998            }
9999        }
10000    }
10001    if let Ok(sessions) = crate::session::list_sessions() {
10002        for s in sessions {
10003            names.push(s.name.clone());
10004            if let Some(h) = &s.handle {
10005                names.push(h.clone());
10006            }
10007            if let Some(ch) = &s.character {
10008                names.push(ch.nickname.clone());
10009            }
10010        }
10011    }
10012    names.sort();
10013    names.dedup();
10014    names
10015}
10016
10017/// v0.9.2 deprecation banner with two ergonomic guards:
10018/// 1. Suppress in JSON mode (the caller is expected to fold the
10019///    deprecation note into its JSON output instead).
10020/// 2. Cache once-per-shell-session via a marker env var; subsequent
10021///    invocations in the same shell stay silent.
10022///
10023/// `verb` is the legacy verb name, `replacement` is the canonical one.
10024fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
10025    if json_mode {
10026        return;
10027    }
10028    // Pull a marker from environment of THIS process. Persistent across
10029    // multiple wire invocations only when the shell sets and exports
10030    // WIRE_DEPRECATION_NAGGED — operators rarely do, so practically
10031    // this nags once per `wire foo` invocation. The single-process
10032    // dedup matters most for scripts that call multiple deprecated
10033    // verbs in one wire run, which is currently impossible (one verb
10034    // per process) but documented for future loop-style wire shells.
10035    let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
10036    if std::env::var(&key).is_ok() {
10037        return;
10038    }
10039    // SAFETY: deprecation_warn is called from sync dispatcher code paths
10040    // before any worker thread spawns; env::set_var in Rust 2024 is
10041    // safe at that point. Pattern matches maybe_adopt_session_wire_home.
10042    unsafe {
10043        std::env::set_var(&key, "1");
10044    }
10045    eprintln!(
10046        "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
10047         Will be removed in v1.0 (target 2026-Q3). \
10048         Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
10049        verb.replace('-', "_")
10050    );
10051}
10052
10053// ---------- doctor (single-command diagnostic) ----------
10054
10055/// One DoctorCheck = one verdict on one health dimension.
10056#[derive(Clone, Debug, serde::Serialize)]
10057pub struct DoctorCheck {
10058    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
10059    /// Stable across versions for tooling consumption.
10060    pub id: String,
10061    /// PASS / WARN / FAIL.
10062    pub status: String,
10063    /// One-line human summary.
10064    pub detail: String,
10065    /// Optional remediation hint shown after the failing line.
10066    #[serde(skip_serializing_if = "Option::is_none")]
10067    pub fix: Option<String>,
10068}
10069
10070impl DoctorCheck {
10071    fn pass(id: &str, detail: impl Into<String>) -> Self {
10072        Self {
10073            id: id.into(),
10074            status: "PASS".into(),
10075            detail: detail.into(),
10076            fix: None,
10077        }
10078    }
10079    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10080        Self {
10081            id: id.into(),
10082            status: "WARN".into(),
10083            detail: detail.into(),
10084            fix: Some(fix.into()),
10085        }
10086    }
10087    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10088        Self {
10089            id: id.into(),
10090            status: "FAIL".into(),
10091            detail: detail.into(),
10092            fix: Some(fix.into()),
10093        }
10094    }
10095}
10096
10097/// `wire doctor` — single-command diagnostic for the silent-fail classes
10098/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
10099/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
10100/// so operators don't have to know where each lives.
10101fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
10102    let checks: Vec<DoctorCheck> = vec![
10103        check_daemon_health(),
10104        check_daemon_pid_consistency(),
10105        check_relay_reachable(),
10106        check_pair_rejections(recent_rejections),
10107        check_cursor_progress(),
10108    ];
10109
10110    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
10111    let warns = checks.iter().filter(|c| c.status == "WARN").count();
10112
10113    if as_json {
10114        println!(
10115            "{}",
10116            serde_json::to_string(&json!({
10117                "checks": checks,
10118                "fail_count": fails,
10119                "warn_count": warns,
10120                "ok": fails == 0,
10121            }))?
10122        );
10123    } else {
10124        println!("wire doctor — {} checks", checks.len());
10125        for c in &checks {
10126            let bullet = match c.status.as_str() {
10127                "PASS" => "✓",
10128                "WARN" => "!",
10129                "FAIL" => "✗",
10130                _ => "?",
10131            };
10132            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
10133            if let Some(fix) = &c.fix {
10134                println!("      fix: {fix}");
10135            }
10136        }
10137        println!();
10138        if fails == 0 && warns == 0 {
10139            println!("ALL GREEN");
10140        } else {
10141            println!("{fails} FAIL, {warns} WARN");
10142        }
10143    }
10144
10145    if fails > 0 {
10146        std::process::exit(1);
10147    }
10148    Ok(())
10149}
10150
10151/// Check: daemon running, exactly one instance, no orphans.
10152///
10153/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
10154/// days, advancing cursor without pinning). `wire status` lied about it.
10155/// `wire doctor` must catch THIS class: multiple daemons running, OR
10156/// pid-file claims daemon down while a process is actually up.
10157fn check_daemon_health() -> DoctorCheck {
10158    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
10159    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
10160    // hardening): every surface routes through ensure_up::daemon_liveness
10161    // so they share one view of the world. No more parallel liveness
10162    // logic to drift out of sync.
10163    let snap = crate::ensure_up::daemon_liveness();
10164    let pgrep_pids = &snap.pgrep_pids;
10165    let pidfile_pid = snap.pidfile_pid;
10166    let pidfile_alive = snap.pidfile_alive;
10167    let orphan_pids = &snap.orphan_pids;
10168
10169    let fmt_pids = |xs: &[u32]| -> String {
10170        xs.iter()
10171            .map(|p| p.to_string())
10172            .collect::<Vec<_>>()
10173            .join(", ")
10174    };
10175
10176    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
10177        (0, _, _) => DoctorCheck::fail(
10178            "daemon",
10179            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
10180            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
10181        ),
10182        // Single daemon AND it matches the pidfile → healthy.
10183        (1, true, true) => DoctorCheck::pass(
10184            "daemon",
10185            format!(
10186                "one daemon running (pid {}, matches pidfile)",
10187                pgrep_pids[0]
10188            ),
10189        ),
10190        // Pidfile is alive but pgrep ALSO sees orphan processes.
10191        (n, true, false) => DoctorCheck::fail(
10192            "daemon",
10193            format!(
10194                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
10195                 The orphans race the relay cursor — they advance past events your current binary can't process. \
10196                 (Issue #2 exact class.)",
10197                fmt_pids(pgrep_pids),
10198                pidfile_pid.unwrap(),
10199                fmt_pids(orphan_pids),
10200            ),
10201            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
10202        ),
10203        // Pidfile is dead but processes ARE running → all are orphans.
10204        (n, false, _) => DoctorCheck::fail(
10205            "daemon",
10206            format!(
10207                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
10208                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
10209                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
10210                fmt_pids(pgrep_pids),
10211                match pidfile_pid {
10212                    Some(p) => format!("claims pid {p} which is dead"),
10213                    None => "is missing".to_string(),
10214                },
10215            ),
10216            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
10217        ),
10218        // Multiple daemons all matching … impossible by construction; fall back to warn.
10219        (n, true, true) => DoctorCheck::warn(
10220            "daemon",
10221            format!(
10222                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
10223                fmt_pids(pgrep_pids)
10224            ),
10225            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
10226        ),
10227    }
10228}
10229
10230/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
10231/// check. Surfaces version mismatch (daemon running old binary text in
10232/// memory under a current symlink — today's exact bug class), schema
10233/// drift (future format bumps), and identity contamination (daemon's
10234/// recorded DID doesn't match this box's configured DID).
10235///
10236/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
10237/// JSON pid record whose recorded `pid` is no longer a live OS process.
10238/// Pre-hardening this check PASSed in that state (it only validated
10239/// content, not liveness), letting `wire status: DOWN` and
10240/// `wire doctor: PASS` disagree for 25 min in incident #2.
10241fn check_daemon_pid_consistency() -> DoctorCheck {
10242    let snap = crate::ensure_up::daemon_liveness();
10243    match &snap.record {
10244        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
10245            "daemon_pid_consistency",
10246            "no daemon.pid yet — fresh box or daemon never started",
10247        ),
10248        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
10249            "daemon_pid_consistency",
10250            format!("daemon.pid is corrupt: {reason}"),
10251            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
10252        ),
10253        crate::ensure_up::PidRecord::LegacyInt(pid) => {
10254            // Legacy pidfile: still surface liveness so a dead legacy pid
10255            // doesn't quietly PASS this check while status says DOWN.
10256            let pid = *pid;
10257            if !crate::ensure_up::pid_is_alive(pid) {
10258                return DoctorCheck::warn(
10259                    "daemon_pid_consistency",
10260                    format!(
10261                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
10262                         Stale pidfile from a crashed pre-0.5.11 daemon. \
10263                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
10264                    ),
10265                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
10266                );
10267            }
10268            DoctorCheck::warn(
10269                "daemon_pid_consistency",
10270                format!(
10271                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
10272                     Daemon was started by a pre-0.5.11 binary."
10273                ),
10274                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
10275            )
10276        }
10277        crate::ensure_up::PidRecord::Json(d) => {
10278            // v0.5.19 liveness gate: if the recorded pid is dead, the
10279            // pidfile is stale and the rest of the content drift checks
10280            // are moot — `wire upgrade` is the answer regardless.
10281            if !snap.pidfile_alive {
10282                return DoctorCheck::warn(
10283                    "daemon_pid_consistency",
10284                    format!(
10285                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
10286                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
10287                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
10288                        pid = d.pid,
10289                        version = d.version,
10290                    ),
10291                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
10292                     (kills any orphan daemon advancing the cursor without coordination)",
10293                );
10294            }
10295            let mut issues: Vec<String> = Vec::new();
10296            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
10297                issues.push(format!(
10298                    "schema={} (expected {})",
10299                    d.schema,
10300                    crate::ensure_up::DAEMON_PID_SCHEMA
10301                ));
10302            }
10303            let cli_version = env!("CARGO_PKG_VERSION");
10304            if d.version != cli_version {
10305                issues.push(format!("version daemon={} cli={cli_version}", d.version));
10306            }
10307            if !std::path::Path::new(&d.bin_path).exists() {
10308                issues.push(format!("bin_path {} missing on disk", d.bin_path));
10309            }
10310            // Cross-check DID + relay against current config (best-effort).
10311            if let Ok(card) = config::read_agent_card()
10312                && let Some(current_did) = card.get("did").and_then(Value::as_str)
10313                && let Some(recorded_did) = &d.did
10314                && recorded_did != current_did
10315            {
10316                issues.push(format!(
10317                    "did daemon={recorded_did} config={current_did} — identity drift"
10318                ));
10319            }
10320            if let Ok(state) = config::read_relay_state()
10321                && let Some(current_relay) = state
10322                    .get("self")
10323                    .and_then(|s| s.get("relay_url"))
10324                    .and_then(Value::as_str)
10325                && let Some(recorded_relay) = &d.relay_url
10326                && recorded_relay != current_relay
10327            {
10328                issues.push(format!(
10329                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
10330                ));
10331            }
10332            if issues.is_empty() {
10333                DoctorCheck::pass(
10334                    "daemon_pid_consistency",
10335                    format!(
10336                        "daemon v{} bound to {} as {}",
10337                        d.version,
10338                        d.relay_url.as_deref().unwrap_or("?"),
10339                        d.did.as_deref().unwrap_or("?")
10340                    ),
10341                )
10342            } else {
10343                DoctorCheck::warn(
10344                    "daemon_pid_consistency",
10345                    format!("daemon pidfile drift: {}", issues.join("; ")),
10346                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
10347                )
10348            }
10349        }
10350    }
10351}
10352
10353/// Check: bound relay's /healthz returns 200.
10354fn check_relay_reachable() -> DoctorCheck {
10355    let state = match config::read_relay_state() {
10356        Ok(s) => s,
10357        Err(e) => {
10358            return DoctorCheck::fail(
10359                "relay",
10360                format!("could not read relay state: {e}"),
10361                "run `wire up <handle>@<relay>` to bootstrap",
10362            );
10363        }
10364    };
10365    let url = state
10366        .get("self")
10367        .and_then(|s| s.get("relay_url"))
10368        .and_then(Value::as_str)
10369        .unwrap_or("");
10370    if url.is_empty() {
10371        return DoctorCheck::warn(
10372            "relay",
10373            "no relay bound — wire send/pull will not work",
10374            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
10375        );
10376    }
10377    let client = crate::relay_client::RelayClient::new(url);
10378    match client.check_healthz() {
10379        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
10380        Err(e) => DoctorCheck::fail(
10381            "relay",
10382            format!("{url} unreachable: {e}"),
10383            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
10384        ),
10385    }
10386}
10387
10388/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
10389/// entry there is a silent failure that, pre-0.5.11, would have left the
10390/// operator wondering why pairing didn't complete.
10391fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
10392    let path = match config::state_dir() {
10393        Ok(d) => d.join("pair-rejected.jsonl"),
10394        Err(e) => {
10395            return DoctorCheck::warn(
10396                "pair_rejections",
10397                format!("could not resolve state dir: {e}"),
10398                "set WIRE_HOME or fix XDG_STATE_HOME",
10399            );
10400        }
10401    };
10402    if !path.exists() {
10403        return DoctorCheck::pass(
10404            "pair_rejections",
10405            "no pair-rejected.jsonl — no recorded pair failures",
10406        );
10407    }
10408    let body = match std::fs::read_to_string(&path) {
10409        Ok(b) => b,
10410        Err(e) => {
10411            return DoctorCheck::warn(
10412                "pair_rejections",
10413                format!("could not read {path:?}: {e}"),
10414                "check file permissions",
10415            );
10416        }
10417    };
10418    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
10419    if lines.is_empty() {
10420        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
10421    }
10422    let total = lines.len();
10423    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
10424    let mut summary: Vec<String> = Vec::new();
10425    for line in &recent {
10426        if let Ok(rec) = serde_json::from_str::<Value>(line) {
10427            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
10428            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
10429            summary.push(format!("{peer}/{code}"));
10430        }
10431    }
10432    DoctorCheck::warn(
10433        "pair_rejections",
10434        format!(
10435            "{total} pair failures recorded. recent: [{}]",
10436            summary.join(", ")
10437        ),
10438        format!(
10439            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
10440        ),
10441    )
10442}
10443
10444/// Check: cursor isn't stuck. We can't tell without polling — but we can
10445/// report the current cursor position so operators see if it changes.
10446/// Real "stuck" detection needs two pulls separated in time; defer that
10447/// behaviour to a `wire doctor --watch` mode.
10448fn check_cursor_progress() -> DoctorCheck {
10449    let state = match config::read_relay_state() {
10450        Ok(s) => s,
10451        Err(e) => {
10452            return DoctorCheck::warn(
10453                "cursor",
10454                format!("could not read relay state: {e}"),
10455                "check ~/Library/Application Support/wire/relay.json",
10456            );
10457        }
10458    };
10459    let cursor = state
10460        .get("self")
10461        .and_then(|s| s.get("last_pulled_event_id"))
10462        .and_then(Value::as_str)
10463        .map(|s| s.chars().take(16).collect::<String>())
10464        .unwrap_or_else(|| "<none>".to_string());
10465    DoctorCheck::pass(
10466        "cursor",
10467        format!(
10468            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
10469        ),
10470    )
10471}
10472
10473#[cfg(test)]
10474mod doctor_tests {
10475    use super::*;
10476
10477    #[test]
10478    fn doctor_check_constructors_set_status_correctly() {
10479        // Silent-fail-prevention rule: pass/warn/fail must be visibly
10480        // distinguishable to operators. If any constructor lets the wrong
10481        // status through, `wire doctor` lies and we're back to today's
10482        // 30-minute debug.
10483        let p = DoctorCheck::pass("x", "ok");
10484        assert_eq!(p.status, "PASS");
10485        assert_eq!(p.fix, None);
10486
10487        let w = DoctorCheck::warn("x", "watch out", "do this");
10488        assert_eq!(w.status, "WARN");
10489        assert_eq!(w.fix, Some("do this".to_string()));
10490
10491        let f = DoctorCheck::fail("x", "broken", "fix it");
10492        assert_eq!(f.status, "FAIL");
10493        assert_eq!(f.fix, Some("fix it".to_string()));
10494    }
10495
10496    #[test]
10497    fn check_pair_rejections_no_file_is_pass() {
10498        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
10499        // as a problem.
10500        config::test_support::with_temp_home(|| {
10501            config::ensure_dirs().unwrap();
10502            let c = check_pair_rejections(5);
10503            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
10504        });
10505    }
10506
10507    #[test]
10508    fn check_pair_rejections_with_entries_warns() {
10509        // Existence of rejections is itself a signal — even if each entry
10510        // is a "known good failure," the operator wants to know they
10511        // happened.
10512        config::test_support::with_temp_home(|| {
10513            config::ensure_dirs().unwrap();
10514            crate::pair_invite::record_pair_rejection(
10515                "willard",
10516                "pair_drop_ack_send_failed",
10517                "POST 502",
10518            );
10519            let c = check_pair_rejections(5);
10520            assert_eq!(c.status, "WARN");
10521            assert!(c.detail.contains("1 pair failures"));
10522            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
10523        });
10524    }
10525}
10526
10527// ---------- up megacommand (full bootstrap) ----------
10528
10529/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
10530/// pair. Composes the steps that today's onboarding walks operators through
10531/// one by one (init / bind-relay / claim / background daemon / arm monitor
10532/// recipe). Idempotent: every step checks current state and skips if done.
10533///
10534/// Argument parsing accepts:
10535///   - `<nick>@<relay-host>` — explicit relay
10536///   - `<nick>`              — defaults to wireup.net (the configured
10537///     public relay)
10538fn cmd_up(
10539    handle_arg: &str,
10540    name: Option<&str>,
10541    with_local: Option<&str>,
10542    no_local: bool,
10543    as_json: bool,
10544) -> Result<()> {
10545    let (nick, relay_url) = match handle_arg.split_once('@') {
10546        Some((n, host)) => {
10547            let url = if host.starts_with("http://") || host.starts_with("https://") {
10548                host.to_string()
10549            } else {
10550                format!("https://{host}")
10551            };
10552            (n.to_string(), url)
10553        }
10554        None => (
10555            handle_arg.to_string(),
10556            crate::pair_invite::DEFAULT_RELAY.to_string(),
10557        ),
10558    };
10559
10560    let mut report: Vec<(String, String)> = Vec::new();
10561    let mut step = |stage: &str, detail: String| {
10562        report.push((stage.to_string(), detail.clone()));
10563        if !as_json {
10564            eprintln!("wire up: {stage} — {detail}");
10565        }
10566    };
10567
10568    // 1. init (or note existing identity). v0.11 one-name rule: the nick
10569    // before `@` is vestigial — identity (and the claimed handle) is the
10570    // DID-derived persona, NOT the operator-typed string. We pass the arg
10571    // to auto-init only so its "operator-typed X ignored" message fires;
10572    // the persona always wins. No bail on nick mismatch (the typed nick
10573    // can't select an identity, so a mismatch is not an error).
10574    if config::is_initialized()? {
10575        step("init", "already initialized".to_string());
10576    } else {
10577        cmd_init(
10578            &nick,
10579            name,
10580            Some(&relay_url),
10581            false,
10582            /* as_json */ false,
10583        )?;
10584        step("init", format!("created identity bound to {relay_url}"));
10585    }
10586
10587    // Canonical persona handle — the one name we claim and are addressed by.
10588    let canonical = {
10589        let card = config::read_agent_card()?;
10590        let did = card.get("did").and_then(Value::as_str).unwrap_or("");
10591        crate::agent_card::display_handle_from_did(did).to_string()
10592    };
10593    if canonical != nick {
10594        step(
10595            "identity",
10596            format!("persona is `{canonical}` (typed `{nick}` ignored — one-name rule)"),
10597        );
10598    }
10599
10600    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
10601    // already initialized we may need to bind to the requested relay
10602    // separately (operator switched relays).
10603    let relay_state = config::read_relay_state()?;
10604    let bound_relay = relay_state
10605        .get("self")
10606        .and_then(|s| s.get("relay_url"))
10607        .and_then(Value::as_str)
10608        .unwrap_or("")
10609        .to_string();
10610    if bound_relay.is_empty() {
10611        // Identity exists but never bound to a relay — bind now.
10612        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
10613        // Pass `false` so the safety check kicks in if state was non-empty.
10614        cmd_bind_relay(
10615            &relay_url,
10616            /* scope */ None, // infer from URL (federation for wireup.net)
10617            /* replace */ false,
10618            /* migrate_pinned */ false,
10619            /* as_json */ false,
10620        )?;
10621        step("bind-relay", format!("bound to {relay_url}"));
10622    } else if bound_relay != relay_url {
10623        step(
10624            "bind-relay",
10625            format!(
10626                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
10627                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
10628            ),
10629        );
10630    } else {
10631        step("bind-relay", format!("already bound to {bound_relay}"));
10632    }
10633
10634    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
10635    // re-claims are accepted by the relay.
10636    match cmd_claim(
10637        &canonical,
10638        Some(&relay_url),
10639        None,
10640        /* hidden */ false,
10641        /* as_json */ false,
10642    ) {
10643        Ok(()) => step(
10644            "claim",
10645            format!("{canonical}@{} claimed", strip_proto(&relay_url)),
10646        ),
10647        Err(e) => step(
10648            "claim",
10649            format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
10650        ),
10651    }
10652
10653    // 3b. Opportunistic local dual-slot (additive). Gives same-box sister
10654    // sessions sub-millisecond loopback routing alongside the federation
10655    // slot. Local relays carry no handle directory — nothing to claim
10656    // there; sister discovery is via `wire session list-local`.
10657    if no_local {
10658        step("local-slot", "skipped (--no-local)".to_string());
10659    } else {
10660        let local_url = with_local
10661            .unwrap_or("http://127.0.0.1:8771")
10662            .trim_end_matches('/');
10663        let already_local = crate::endpoints::self_endpoints(
10664            &config::read_relay_state().unwrap_or_else(|_| json!({})),
10665        )
10666        .iter()
10667        .any(|e| e.relay_url == local_url);
10668        if relay_url.trim_end_matches('/') == local_url || already_local {
10669            step("local-slot", "already covered".to_string());
10670        } else if crate::relay_client::RelayClient::new(local_url)
10671            .check_healthz()
10672            .is_ok()
10673        {
10674            match cmd_bind_relay(
10675                local_url,
10676                Some("local"),
10677                /* replace */ false,
10678                /* migrate_pinned */ false,
10679                /* as_json */ false,
10680            ) {
10681                Ok(()) => step(
10682                    "local-slot",
10683                    format!("dual-bound local relay {local_url} for sister routing"),
10684                ),
10685                Err(e) => step("local-slot", format!("skipped local relay: {e}")),
10686            }
10687        } else {
10688            step(
10689                "local-slot",
10690                format!(
10691                    "no local relay reachable at {local_url} — federation only \
10692                     (sisters resolve via session-list)"
10693                ),
10694            );
10695        }
10696    }
10697
10698    // 4. Background daemon — must be running for pull/push/ack to flow.
10699    match crate::ensure_up::ensure_daemon_running() {
10700        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
10701        Ok(false) => step("daemon", "already running".to_string()),
10702        Err(e) => step(
10703            "daemon",
10704            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
10705        ),
10706    }
10707
10708    // 5. Final summary — point operator at the next commands.
10709    let summary =
10710        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
10711         `wire monitor` to watch incoming events."
10712            .to_string();
10713    step("ready", summary.clone());
10714
10715    if as_json {
10716        let steps_json: Vec<_> = report
10717            .iter()
10718            .map(|(k, v)| json!({"stage": k, "detail": v}))
10719            .collect();
10720        println!(
10721            "{}",
10722            serde_json::to_string(&json!({
10723                "nick": canonical,
10724                "relay": relay_url,
10725                "steps": steps_json,
10726            }))?
10727        );
10728    }
10729    Ok(())
10730}
10731
10732/// Strip http:// or https:// prefix for display in `wire up` step output.
10733fn strip_proto(url: &str) -> String {
10734    url.trim_start_matches("https://")
10735        .trim_start_matches("http://")
10736        .to_string()
10737}
10738
10739// ---------- pair megacommand (zero-paste handle-based) ----------
10740
10741/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
10742/// the handle is in `nick@domain` form. Wraps:
10743///
10744///   1. cmd_add — resolve, pin, drop intro
10745///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
10746///      (signalled by `peers.<handle>.slot_token` populating in relay state)
10747///   3. Verify bilateral pin: trust contains peer + relay state has token
10748///   4. Print final state — both sides VERIFIED + can `wire send`
10749///
10750/// On timeout: hard-errors with the specific stuck step so the operator
10751/// knows which side to chase. No silent partial success.
10752fn cmd_pair_megacommand(
10753    handle_arg: &str,
10754    relay_override: Option<&str>,
10755    timeout_secs: u64,
10756    _as_json: bool,
10757) -> Result<()> {
10758    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
10759    let peer_handle = parsed.nick.clone();
10760
10761    eprintln!("wire pair: resolving {handle_arg}...");
10762    cmd_add(
10763        handle_arg,
10764        relay_override,
10765        /* local_sister */ false,
10766        /* as_json */ false,
10767    )?;
10768
10769    eprintln!(
10770        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
10771         to ack (their daemon must be running + pulling)..."
10772    );
10773
10774    // Trigger an immediate daemon-style pull so we don't wait the full daemon
10775    // interval. Best-effort — if it fails, we still fall through to the
10776    // polling loop.
10777    let _ = run_sync_pull();
10778
10779    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
10780    let poll_interval = std::time::Duration::from_millis(500);
10781
10782    loop {
10783        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
10784        let _ = run_sync_pull();
10785        let relay_state = config::read_relay_state()?;
10786        let peer_entry = relay_state
10787            .get("peers")
10788            .and_then(|p| p.get(&peer_handle))
10789            .cloned();
10790        let token = peer_entry
10791            .as_ref()
10792            .and_then(|e| e.get("slot_token"))
10793            .and_then(Value::as_str)
10794            .unwrap_or("");
10795
10796        if !token.is_empty() {
10797            // Bilateral pin complete — we have their slot_token, we can send.
10798            let trust = config::read_trust()?;
10799            let pinned_in_trust = trust
10800                .get("agents")
10801                .and_then(|a| a.get(&peer_handle))
10802                .is_some();
10803            println!(
10804                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
10805                if pinned_in_trust {
10806                    "VERIFIED"
10807                } else {
10808                    "MISSING (bug)"
10809                }
10810            );
10811            return Ok(());
10812        }
10813
10814        if std::time::Instant::now() >= deadline {
10815            // Timeout — surface the EXACT stuck step. Likely culprits:
10816            //   - peer daemon not running on their box
10817            //   - peer's relay slot is offline
10818            //   - their daemon is on an older binary that doesn't know
10819            //     pair_drop kind=1100 (the P0.1 class — now visible via
10820            //     wire pull --json on their side as a blocking rejection)
10821            bail!(
10822                "wire pair: timed out after {timeout_secs}s. \
10823                 peer {peer_handle} never sent pair_drop_ack. \
10824                 likely causes: (a) their daemon is down — ask them to run \
10825                 `wire status` and `wire daemon &`; (b) their binary is older \
10826                 than 0.5.x and doesn't understand pair_drop events — ask \
10827                 them to `wire upgrade`; (c) network / relay blip — re-run \
10828                 `wire pair {handle_arg}` to retry."
10829            );
10830        }
10831
10832        std::thread::sleep(poll_interval);
10833    }
10834}
10835
10836fn cmd_claim(
10837    nick: &str,
10838    relay_override: Option<&str>,
10839    public_url: Option<&str>,
10840    hidden: bool,
10841    as_json: bool,
10842) -> Result<()> {
10843    if !crate::pair_profile::is_valid_nick(nick) {
10844        bail!(
10845            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
10846        );
10847    }
10848    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
10849    // + claim handle. Operator should never have to run init/bind-relay first.
10850    let (_did, relay_url, slot_id, slot_token) =
10851        crate::pair_invite::ensure_self_with_relay(relay_override)?;
10852    let card = config::read_agent_card()?;
10853
10854    let client = crate::relay_client::RelayClient::new(&relay_url);
10855    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
10856    // (back-compat); Some(false) for `--hidden`. Relays older than
10857    // v0.5.19 ignore the field, so this is safe to always send.
10858    let discoverable = if hidden { Some(false) } else { None };
10859    let resp =
10860        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
10861
10862    if as_json {
10863        println!(
10864            "{}",
10865            serde_json::to_string(&json!({
10866                "nick": nick,
10867                "relay": relay_url,
10868                "response": resp,
10869            }))?
10870        );
10871    } else {
10872        // Best-effort: derive the public domain from the relay URL. If
10873        // operator passed --public-url that's the canonical address; else
10874        // the relay URL itself. Falls back to a placeholder if both miss.
10875        let domain = public_url
10876            .unwrap_or(&relay_url)
10877            .trim_start_matches("https://")
10878            .trim_start_matches("http://")
10879            .trim_end_matches('/')
10880            .split('/')
10881            .next()
10882            .unwrap_or("<this-relay-domain>")
10883            .to_string();
10884        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
10885        println!("verify with: wire whois {nick}@{domain}");
10886    }
10887    Ok(())
10888}
10889
10890fn cmd_profile(action: ProfileAction) -> Result<()> {
10891    match action {
10892        ProfileAction::Set { field, value, json } => {
10893            // Try parsing the value as JSON; if that fails, treat it as a
10894            // bare string. Lets operators pass either `42` or `"hello"` or
10895            // `["rust","late-night"]` without quoting hell.
10896            let parsed: Value =
10897                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
10898            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
10899            if json {
10900                println!(
10901                    "{}",
10902                    serde_json::to_string(&json!({
10903                        "field": field,
10904                        "profile": new_profile,
10905                    }))?
10906                );
10907            } else {
10908                println!("profile.{field} set");
10909            }
10910        }
10911        ProfileAction::Get { json } => return cmd_whois(None, json, None),
10912        ProfileAction::Clear { field, json } => {
10913            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
10914            if json {
10915                println!(
10916                    "{}",
10917                    serde_json::to_string(&json!({
10918                        "field": field,
10919                        "cleared": true,
10920                        "profile": new_profile,
10921                    }))?
10922                );
10923            } else {
10924                println!("profile.{field} cleared");
10925            }
10926        }
10927    }
10928    Ok(())
10929}
10930
10931// ---------- setup — one-shot MCP host registration ----------
10932
10933fn cmd_setup(apply: bool) -> Result<()> {
10934    use std::path::PathBuf;
10935
10936    let entry = json!({"command": "wire", "args": ["mcp"]});
10937    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
10938
10939    // Detect probable MCP host config locations. Cross-platform — we only
10940    // touch the file if it already exists OR --apply was passed.
10941    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
10942    if let Some(home) = dirs::home_dir() {
10943        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
10944        // The mcpServers map lives at the top level of that file.
10945        targets.push(("Claude Code", home.join(".claude.json")));
10946        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
10947        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
10948        // Claude Desktop macOS
10949        #[cfg(target_os = "macos")]
10950        targets.push((
10951            "Claude Desktop (macOS)",
10952            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
10953        ));
10954        // Claude Desktop Windows
10955        #[cfg(target_os = "windows")]
10956        if let Ok(appdata) = std::env::var("APPDATA") {
10957            targets.push((
10958                "Claude Desktop (Windows)",
10959                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
10960            ));
10961        }
10962        // Cursor
10963        targets.push(("Cursor", home.join(".cursor/mcp.json")));
10964    }
10965    // Project-local — works for several MCP-aware tools
10966    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
10967
10968    println!("wire setup\n");
10969    println!("MCP server snippet (add this to your client's mcpServers):");
10970    println!();
10971    println!("{entry_pretty}");
10972    println!();
10973
10974    if !apply {
10975        println!("Probable MCP host config locations on this machine:");
10976        for (name, path) in &targets {
10977            let marker = if path.exists() {
10978                "✓ found"
10979            } else {
10980                "  (would create)"
10981            };
10982            println!("  {marker:14}  {name}: {}", path.display());
10983        }
10984        println!();
10985        println!("Run `wire setup --apply` to merge wire into each config above.");
10986        println!(
10987            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
10988        );
10989        return Ok(());
10990    }
10991
10992    let mut modified: Vec<String> = Vec::new();
10993    let mut skipped: Vec<String> = Vec::new();
10994    for (name, path) in &targets {
10995        match upsert_mcp_entry(path, "wire", &entry) {
10996            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
10997            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
10998            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
10999        }
11000    }
11001    if !modified.is_empty() {
11002        println!("Modified:");
11003        for line in &modified {
11004            println!("  {line}");
11005        }
11006        println!();
11007        println!("Restart the app(s) above to load wire MCP.");
11008    }
11009    if !skipped.is_empty() {
11010        println!();
11011        println!("Skipped:");
11012        for line in &skipped {
11013            println!("  {line}");
11014        }
11015    }
11016    Ok(())
11017}
11018
11019/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
11020/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
11021fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
11022    let mut cfg: Value = if path.exists() {
11023        let body = std::fs::read_to_string(path).context("reading config")?;
11024        serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
11025    } else {
11026        json!({})
11027    };
11028    if !cfg.is_object() {
11029        cfg = json!({});
11030    }
11031    let root = cfg.as_object_mut().unwrap();
11032    let servers = root
11033        .entry("mcpServers".to_string())
11034        .or_insert_with(|| json!({}));
11035    if !servers.is_object() {
11036        *servers = json!({});
11037    }
11038    let map = servers.as_object_mut().unwrap();
11039    if map.get(server_name) == Some(entry) {
11040        return Ok(false);
11041    }
11042    map.insert(server_name.to_string(), entry.clone());
11043    if let Some(parent) = path.parent()
11044        && !parent.as_os_str().is_empty()
11045    {
11046        std::fs::create_dir_all(parent).context("creating parent dir")?;
11047    }
11048    let out = serde_json::to_string_pretty(&cfg)? + "\n";
11049    std::fs::write(path, out).context("writing config")?;
11050    Ok(true)
11051}
11052
11053// ---------- reactor — event-handler dispatch loop ----------
11054
11055#[allow(clippy::too_many_arguments)]
11056fn cmd_reactor(
11057    on_event: &str,
11058    peer_filter: Option<&str>,
11059    kind_filter: Option<&str>,
11060    verified_only: bool,
11061    interval_secs: u64,
11062    once: bool,
11063    dry_run: bool,
11064    max_per_minute: u32,
11065    max_chain_depth: u32,
11066) -> Result<()> {
11067    use crate::inbox_watch::{InboxEvent, InboxWatcher};
11068    use std::collections::{HashMap, HashSet, VecDeque};
11069    use std::io::Write;
11070    use std::process::{Command, Stdio};
11071    use std::time::{Duration, Instant};
11072
11073    let cursor_path = config::state_dir()?.join("reactor.cursor");
11074    // event_ids THIS reactor's handler has caused to be sent (via wire send).
11075    // Used by chain-depth check — an incoming `(re:X)` where X is in this set
11076    // means peer is replying to something we just said → don't reply back.
11077    //
11078    // Persisted across restarts so a reactor that crashes mid-conversation
11079    // doesn't re-enter the loop. Reads on startup, writes after each
11080    // outbox-grow detection. Capped at 500 entries (LRU-ish — old entries
11081    // dropped from front of file).
11082    let emitted_path = config::state_dir()?.join("reactor-emitted.log");
11083    let mut emitted_ids: HashSet<String> = HashSet::new();
11084    if emitted_path.exists()
11085        && let Ok(body) = std::fs::read_to_string(&emitted_path)
11086    {
11087        for line in body.lines() {
11088            let t = line.trim();
11089            if !t.is_empty() {
11090                emitted_ids.insert(t.to_string());
11091            }
11092        }
11093    }
11094    // Outbox file paths the reactor watches for new sent-event_ids.
11095    let outbox_dir = config::outbox_dir()?;
11096    // (peer → file size we've already scanned). Lets us notice new outbox
11097    // appends without re-reading the whole file each sweep.
11098    let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
11099
11100    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11101
11102    let kind_num: Option<u32> = match kind_filter {
11103        Some(k) => Some(parse_kind(k)?),
11104        None => None,
11105    };
11106
11107    // Per-peer sliding window of dispatch instants for rate-limit check.
11108    let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
11109
11110    let dispatch = |ev: &InboxEvent,
11111                    peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
11112                    emitted_ids: &HashSet<String>|
11113     -> Result<bool> {
11114        if let Some(p) = peer_filter
11115            && ev.peer != p
11116        {
11117            return Ok(false);
11118        }
11119        if verified_only && !ev.verified {
11120            return Ok(false);
11121        }
11122        if let Some(want) = kind_num {
11123            let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
11124            if ev_kind != Some(want) {
11125                return Ok(false);
11126            }
11127        }
11128
11129        // Chain-depth check: if the body contains `(re:<event_id>)` and that
11130        // event_id is in our emitted set, this is a reply to one of our
11131        // replies → loop suspected, skip.
11132        if max_chain_depth > 0 {
11133            let body_str = match &ev.raw["body"] {
11134                Value::String(s) => s.clone(),
11135                other => serde_json::to_string(other).unwrap_or_default(),
11136            };
11137            if let Some(referenced) = parse_re_marker(&body_str) {
11138                // Handler scripts usually truncate event_id (e.g. ${ID:0:12}).
11139                // Match emitted set by prefix to catch both full + truncated.
11140                let matched = emitted_ids.contains(&referenced)
11141                    || emitted_ids.iter().any(|full| full.starts_with(&referenced));
11142                if matched {
11143                    eprintln!(
11144                        "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
11145                        ev.event_id, ev.peer, referenced
11146                    );
11147                    return Ok(false);
11148                }
11149            }
11150        }
11151
11152        // Per-peer rate-limit check (sliding 60s window).
11153        if max_per_minute > 0 {
11154            let now = Instant::now();
11155            let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
11156            while let Some(&front) = win.front() {
11157                if now.duration_since(front) > Duration::from_secs(60) {
11158                    win.pop_front();
11159                } else {
11160                    break;
11161                }
11162            }
11163            if win.len() as u32 >= max_per_minute {
11164                eprintln!(
11165                    "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
11166                    ev.event_id, ev.peer, max_per_minute
11167                );
11168                return Ok(false);
11169            }
11170            win.push_back(now);
11171        }
11172
11173        if dry_run {
11174            println!("{}", serde_json::to_string(&ev.raw)?);
11175            return Ok(true);
11176        }
11177
11178        let mut child = Command::new("sh")
11179            .arg("-c")
11180            .arg(on_event)
11181            .stdin(Stdio::piped())
11182            .stdout(Stdio::inherit())
11183            .stderr(Stdio::inherit())
11184            .env("WIRE_EVENT_PEER", &ev.peer)
11185            .env("WIRE_EVENT_ID", &ev.event_id)
11186            .env("WIRE_EVENT_KIND", &ev.kind)
11187            .spawn()
11188            .with_context(|| format!("spawning reactor handler: {on_event}"))?;
11189        if let Some(mut stdin) = child.stdin.take() {
11190            let body = serde_json::to_vec(&ev.raw)?;
11191            let _ = stdin.write_all(&body);
11192            let _ = stdin.write_all(b"\n");
11193        }
11194        std::mem::drop(child);
11195        Ok(true)
11196    };
11197
11198    // Scan outbox files for newly-appended event_ids and add to emitted set.
11199    let scan_outbox = |emitted_ids: &mut HashSet<String>,
11200                       outbox_cursors: &mut HashMap<String, u64>|
11201     -> Result<usize> {
11202        if !outbox_dir.exists() {
11203            return Ok(0);
11204        }
11205        let mut added = 0;
11206        let mut new_ids: Vec<String> = Vec::new();
11207        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
11208            let path = entry.path();
11209            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
11210                continue;
11211            }
11212            let peer = match path.file_stem().and_then(|s| s.to_str()) {
11213                Some(s) => s.to_string(),
11214                None => continue,
11215            };
11216            let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
11217            let start = *outbox_cursors.get(&peer).unwrap_or(&0);
11218            if cur_len <= start {
11219                outbox_cursors.insert(peer, start);
11220                continue;
11221            }
11222            let body = std::fs::read_to_string(&path).unwrap_or_default();
11223            let tail = &body[start as usize..];
11224            for line in tail.lines() {
11225                if let Ok(v) = serde_json::from_str::<Value>(line)
11226                    && let Some(eid) = v.get("event_id").and_then(Value::as_str)
11227                    && emitted_ids.insert(eid.to_string())
11228                {
11229                    new_ids.push(eid.to_string());
11230                    added += 1;
11231                }
11232            }
11233            outbox_cursors.insert(peer, cur_len);
11234        }
11235        if !new_ids.is_empty() {
11236            // Append new ids to disk, cap on-disk file at 500 entries.
11237            let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
11238            if all.len() > 500 {
11239                all.sort();
11240                let drop_n = all.len() - 500;
11241                let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
11242                emitted_ids.retain(|x| !dropped.contains(x));
11243                all = emitted_ids.iter().cloned().collect();
11244            }
11245            let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
11246        }
11247        Ok(added)
11248    };
11249
11250    let sweep = |watcher: &mut InboxWatcher,
11251                 emitted_ids: &mut HashSet<String>,
11252                 outbox_cursors: &mut HashMap<String, u64>,
11253                 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
11254     -> Result<usize> {
11255        // Pick up any event_ids we sent since last sweep.
11256        let _ = scan_outbox(emitted_ids, outbox_cursors);
11257
11258        let events = watcher.poll()?;
11259        let mut fired = 0usize;
11260        for ev in &events {
11261            match dispatch(ev, peer_dispatch_log, emitted_ids) {
11262                Ok(true) => fired += 1,
11263                Ok(false) => {}
11264                Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
11265            }
11266        }
11267        watcher.save_cursors(&cursor_path)?;
11268        Ok(fired)
11269    };
11270
11271    if once {
11272        sweep(
11273            &mut watcher,
11274            &mut emitted_ids,
11275            &mut outbox_cursors,
11276            &mut peer_dispatch_log,
11277        )?;
11278        return Ok(());
11279    }
11280    let interval = std::time::Duration::from_secs(interval_secs.max(1));
11281    loop {
11282        if let Err(e) = sweep(
11283            &mut watcher,
11284            &mut emitted_ids,
11285            &mut outbox_cursors,
11286            &mut peer_dispatch_log,
11287        ) {
11288            eprintln!("wire reactor: sweep error: {e}");
11289        }
11290        std::thread::sleep(interval);
11291    }
11292}
11293
11294/// Parse `(re:<event_id>)` marker out of an event body. Returns the
11295/// referenced event_id (full or prefix) if present. Tolerates spaces.
11296fn parse_re_marker(body: &str) -> Option<String> {
11297    let needle = "(re:";
11298    let i = body.find(needle)?;
11299    let rest = &body[i + needle.len()..];
11300    let end = rest.find(')')?;
11301    let id = rest[..end].trim().to_string();
11302    if id.is_empty() {
11303        return None;
11304    }
11305    Some(id)
11306}
11307
11308// ---------- notify (Goal 2) ----------
11309
11310fn cmd_notify(
11311    interval_secs: u64,
11312    peer_filter: Option<&str>,
11313    once: bool,
11314    as_json: bool,
11315) -> Result<()> {
11316    use crate::inbox_watch::InboxWatcher;
11317    let cursor_path = config::state_dir()?.join("notify.cursor");
11318    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11319
11320    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
11321        let events = watcher.poll()?;
11322        for ev in events {
11323            if let Some(p) = peer_filter
11324                && ev.peer != p
11325            {
11326                continue;
11327            }
11328            if as_json {
11329                println!("{}", serde_json::to_string(&ev)?);
11330            } else {
11331                os_notify_inbox_event(&ev);
11332            }
11333        }
11334        watcher.save_cursors(&cursor_path)?;
11335        Ok(())
11336    };
11337
11338    if once {
11339        return sweep(&mut watcher);
11340    }
11341
11342    let interval = std::time::Duration::from_secs(interval_secs.max(1));
11343    loop {
11344        if let Err(e) = sweep(&mut watcher) {
11345            eprintln!("wire notify: sweep error: {e}");
11346        }
11347        std::thread::sleep(interval);
11348    }
11349}
11350
11351fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
11352    let who = persona_label(&ev.peer);
11353    let title = if ev.verified {
11354        format!("wire ← {who}")
11355    } else {
11356        format!("wire ← {who} (UNVERIFIED)")
11357    };
11358    let body = format!("{}: {}", ev.kind, ev.body_preview);
11359    crate::os_notify::toast(&title, &body);
11360}
11361
11362#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
11363fn os_toast(title: &str, body: &str) {
11364    eprintln!("[wire notify] {title}\n  {body}");
11365}
11366
11367// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).