Skip to main content

wire/
cli.rs

1//! `wire` CLI surface.
2//!
3//! Every subcommand emits human-readable text by default and structured JSON
4//! when `--json` is passed. Stable JSON shape is part of the API contract —
5//! see `docs/AGENT_INTEGRATION.md`.
6//!
7//! Subcommand split:
8//!   - **agent-safe**: `whoami`, `peers`, `verify`, `send`, `tail` — pure
9//!     message-layer ops, no trust establishment.
10//!   - **trust-establishing**: `init`, `pair-host`, `pair-join`. The CLI
11//!     uses interactive `y/N` prompts here. The MCP equivalents
12//!     (`wire_init`, `wire_pair_initiate`, `wire_pair_join`, `wire_pair_check`,
13//!     `wire_pair_confirm`) preserve the human gate by requiring the user to
14//!     type the 6 SAS digits back into chat — see `docs/THREAT_MODEL.md` T10/T14.
15
16use anyhow::{Context, Result, anyhow, bail};
17use clap::{Parser, Subcommand};
18use serde_json::{Value, json};
19
20use crate::{
21    agent_card::{build_agent_card, sign_agent_card},
22    config,
23    signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
24    trust::{add_self_to_trust, empty_trust},
25};
26
27/// Top-level CLI.
28#[derive(Parser, Debug)]
29#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
30pub struct Cli {
31    #[command(subcommand)]
32    pub command: Command,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Command {
37    /// Generate a keypair, write self-card, and bind an inbound slot.
38    /// (HUMAN-ONLY — DO NOT exec from agents.)
39    ///
40    /// v0.9: refuses to create a slotless session by default. Pre-v0.9
41    /// the silent slotless state caused the 2026-05-23 silent-fail
42    /// incident — pairing + sending succeeded but peers black-holed
43    /// inbound. Operators must now name how the session is reachable:
44    /// `--relay <url>` (binds a slot inline) or `--offline` (opt into
45    /// slotless, acknowledge `wire bind-relay` is required before any
46    /// pair or send).
47    ///
48    /// v0.13.1: folded into `wire up` and hidden. Your handle is your
49    /// DID-derived persona (one-name rule), so the typed `handle` arg is a
50    /// vestigial seed with no effect on identity. Kept callable for explicit
51    /// offline keygen (`wire init x --offline`); everyone else uses `wire up`.
52    #[command(hide = true)]
53    Init {
54        /// Vestigial seed — ignored; your handle is your DID-derived persona.
55        handle: String,
56        /// Optional display name (defaults to capitalized handle).
57        #[arg(long)]
58        name: Option<String>,
59        /// Relay URL — binds an inbound slot in the same step. Required
60        /// unless `--offline` is passed. Example:
61        /// `--relay http://127.0.0.1:8771` (local), `--relay https://wireup.net`
62        /// (federation).
63        #[arg(long)]
64        relay: Option<String>,
65        /// v0.9: opt into a slotless session — keypair only, no inbound
66        /// mailbox. You MUST run `wire bind-relay <url>` before any
67        /// pair / send / dial; until then peers cannot reach you.
68        /// Useful for offline keypair generation; rare in practice.
69        #[arg(long, conflicts_with = "relay")]
70        offline: bool,
71        /// Emit JSON.
72        #[arg(long)]
73        json: bool,
74    },
75    // (Old `Join` stub removed in iter 11 — superseded by `pair-join` with
76    // `join` alias. See PairJoin below.)
77    /// Print this agent's identity (DID, fingerprint, mailbox slot).
78    Whoami {
79        #[arg(long)]
80        json: bool,
81        /// Print just `<emoji> <nickname>` (e.g. `🦊 foxtrot-meadow`).
82        /// Plain text, no ANSI escapes. Useful for piping into other tools.
83        #[arg(long, conflicts_with = "json")]
84        short: bool,
85        /// Print `<emoji> <nickname>` wrapped in ANSI 256-color escapes.
86        /// Drop into a Claude Code statusline command for live identity display.
87        #[arg(long, conflicts_with_all = ["json", "short"])]
88        colored: bool,
89    },
90    /// List pinned peers with their tiers and capabilities.
91    Peers {
92        #[arg(long)]
93        json: bool,
94    },
95    /// v0.9.5: emit shell completion script to stdout. Pipe to your
96    /// shell's completion dir to enable tab-completion of wire verbs
97    /// + handles + flags.
98    ///
99    /// Example installs:
100    ///   bash:       `wire completions bash > /etc/bash_completion.d/wire`
101    ///   zsh:        `wire completions zsh > ~/.zsh/completions/_wire`
102    ///   fish:       `wire completions fish > ~/.config/fish/completions/wire.fish`
103    ///   pwsh:       `wire completions powershell > $PROFILE` (append)
104    ///   elvish:     `wire completions elvish > ~/.elvish/lib/wire.elv`
105    Completions {
106        /// Shell to generate completions for.
107        #[arg(value_enum)]
108        shell: clap_complete::Shell,
109    },
110    /// v0.9.3: one-screen "you are here" view. Prints the current
111    /// session's character + handle + cwd, plus a short list of
112    /// neighbors (sister sessions on the local relay, pinned peers).
113    /// Designed for the operator's quick "wait which Claude is this,
114    /// and who's around?" question — no `--json` shuffling, no
115    /// remembering `wire whoami` vs `wire peers` vs `wire session
116    /// list-local`.
117    Here {
118        #[arg(long)]
119        json: bool,
120    },
121    /// v0.9 canonical surface: list pending-inbound pair requests waiting
122    /// for your consent. Aliases the legacy `pair-list-inbound` verb
123    /// but with the shorter, intent-first name. Operators reach for
124    /// "what's pending?" not "what's in my pair-list-inbound table?"
125    Pending {
126        #[arg(long)]
127        json: bool,
128    },
129    /// Sign and queue an event to a peer.
130    ///
131    /// Forms (P0.S 0.5.11):
132    ///   wire send <peer> <body>              # kind defaults to "claim"
133    ///   wire send <peer> <kind> <body>       # explicit kind (back-compat)
134    ///   wire send <peer> -                   # body from stdin (kind=claim)
135    ///   wire send <peer> @/path/to/body.json # body from file
136    Send {
137        /// Peer handle (without `did:wire:` prefix).
138        peer: String,
139        /// When `<body>` is omitted, this is the event body (kind defaults
140        /// to `claim`). When both this and `<body>` are given, this is the
141        /// event kind (`decision`, `claim`, etc., or numeric kind id) and
142        /// the next positional is the body.
143        kind_or_body: String,
144        /// Event body — free-form text, `@/path/to/body.json` to load from
145        /// a file, or `-` to read from stdin. Optional; omit to use
146        /// `<kind_or_body>` as the body with kind=`claim`.
147        body: Option<String>,
148        /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
149        #[arg(long)]
150        deadline: Option<String>,
151        /// v0.10: skip the v0.9 auto-pair-on-miss behavior. Send fails
152        /// loudly if the peer isn't pinned yet. Use when you want strict
153        /// "no implicit dialing" semantics — scripts that error vs.
154        /// performing a side-effecting pair as a fallback.
155        #[arg(long)]
156        no_auto_pair: bool,
157        /// Emit JSON.
158        #[arg(long)]
159        json: bool,
160    },
161    /// v0.8 — "go talk to this name." The one verb operators reach for.
162    ///
163    /// `wire dial <name>` accepts a character nickname (`noble-slate`),
164    /// a session name (`slancha-api`), a card handle, or a DID — whichever
165    /// face you happen to know the peer by. Resolution order:
166    ///
167    /// 1. Already-pinned peer? → no-op (or send if a message was passed).
168    /// 2. Local sister session? → bilateral pair via the disk-read
169    ///    `--local-sister` path (no relay round-trip, no .well-known
170    ///    lookup, no SAS digits).
171    /// 3. Otherwise → bail with a clear hint pointing at federation
172    ///    syntax (`wire dial <handle>@<relay>` for cross-machine peers).
173    ///
174    /// With an optional message, `wire dial <name> "<msg>"` also queues
175    /// and pushes the message after the pair completes. Idempotent: re-
176    /// dialling a known peer just sends.
177    Dial {
178        /// Peer name. Character nickname (preferred), session name,
179        /// card handle, or DID — anything that identifies the peer to
180        /// you.
181        name: String,
182        /// Optional first message to send after the pair lands. Same
183        /// semantics as the body argument to `wire send`. Defaults to
184        /// kind=claim.
185        message: Option<String>,
186        /// Emit JSON.
187        #[arg(long)]
188        json: bool,
189    },
190    /// Stream signed events from peers.
191    Tail {
192        /// Optional peer filter; if omitted, tails all peers.
193        peer: Option<String>,
194        /// Emit JSONL (one event per line).
195        #[arg(long)]
196        json: bool,
197        /// Maximum events to read before exiting (0 = stream until SIGINT).
198        #[arg(long, default_value_t = 0)]
199        limit: usize,
200    },
201    /// Live tail of new inbox events across all pinned peers — one line per
202    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
203    /// by default.
204    ///
205    /// Designed to be left running in an agent harness's stream-watcher
206    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
207    /// session as they arrive, not on next manual `wire pull`.
208    ///
209    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
210    /// template.
211    Monitor {
212        /// Only show events from this peer.
213        #[arg(long)]
214        peer: Option<String>,
215        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
216        #[arg(long)]
217        json: bool,
218        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
219        /// Default filters them out as noise.
220        #[arg(long)]
221        include_handshake: bool,
222        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
223        #[arg(long, default_value_t = 500)]
224        interval_ms: u64,
225        /// Replay last N events from history before going live (0 = none).
226        #[arg(long, default_value_t = 0)]
227        replay: usize,
228    },
229    /// Verify a signed event from a JSON file or stdin (`-`).
230    Verify {
231        /// Path to event JSON, or `-` for stdin.
232        path: String,
233        /// Emit JSON.
234        #[arg(long)]
235        json: bool,
236    },
237    /// Run the MCP (Model Context Protocol) server over stdio.
238    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
239    /// `wire_send`, `wire_tail`, etc. as native tools.
240    Mcp,
241    /// Run a relay server on this host.
242    RelayServer {
243        /// Bind address (e.g. `127.0.0.1:8770`).
244        #[arg(long, default_value = "127.0.0.1:8770")]
245        bind: String,
246        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
247        /// skip `.well-known/wire/agent` serving. The relay becomes
248        /// invisible from outside the box — only same-machine processes
249        /// can pair through it. Right call for within-machine agent
250        /// coordination where you don't want metadata leaking to a
251        /// public relay. Pair this with `wire session new` which probes
252        /// `127.0.0.1:8771` and allocates a local slot automatically.
253        #[arg(long)]
254        local_only: bool,
255        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
256        /// When set, --bind is ignored. Implies --local-only semantics
257        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
258        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
259        /// sister sessions. Unix only (Windows refuses).
260        #[arg(long)]
261        uds: Option<std::path::PathBuf>,
262    },
263    /// Allocate a slot on a relay; bind it to this agent's identity.
264    ///
265    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
266    /// current slot, this command refuses by default — silent migration
267    /// silently black-holes their inbound messages. Pass
268    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
269    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
270    /// for safe rotation.
271    BindRelay {
272        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
273        url: String,
274        /// Endpoint scope: `federation` | `local` | `lan` | `uds`.
275        /// Default inferred from the URL (loopback host -> local,
276        /// `unix://` -> uds, otherwise federation). Pass explicitly when
277        /// the inference is ambiguous (e.g. a federation relay on a
278        /// loopback address in tests).
279        #[arg(long)]
280        scope: Option<String>,
281        /// DESTRUCTIVE: drop all existing self slots and bind only this
282        /// relay (the pre-v0.12 single-slot behavior). Default is
283        /// ADDITIVE — the new slot is appended to `self.endpoints[]`,
284        /// keeping any existing slots so pinned peers are not
285        /// black-holed.
286        #[arg(long)]
287        replace: bool,
288        /// Acknowledge that pinned peers will black-hole until they
289        /// re-pin manually. Required for `--replace` (and same-relay
290        /// rotation) when `state.peers` is non-empty; ignored on fresh
291        /// boxes. Use `wire rotate-slot` instead for the supported
292        /// same-relay rotation path.
293        #[arg(long)]
294        migrate_pinned: bool,
295        #[arg(long)]
296        json: bool,
297    },
298    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
299    /// real `wire join` lands in the SPAKE2 iter.)
300    AddPeerSlot {
301        /// Peer handle (becomes did:wire:<handle>).
302        handle: String,
303        /// Peer's relay base URL.
304        url: String,
305        /// Peer's slot id.
306        slot_id: String,
307        /// Slot bearer token (shared between paired peers in v0.1).
308        slot_token: String,
309        #[arg(long)]
310        json: bool,
311    },
312    /// Drain outbox JSONL files to peers' relay slots.
313    Push {
314        /// Optional peer filter; default = all peers with outbox entries.
315        peer: Option<String>,
316        #[arg(long)]
317        json: bool,
318    },
319    /// Pull events from our relay slot, verify, write to inbox.
320    Pull {
321        #[arg(long)]
322        json: bool,
323    },
324    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
325    /// Useful as a single "where am I" check.
326    Status {
327        /// Inspect a paired peer's transport / attention / responder health.
328        #[arg(long)]
329        peer: Option<String>,
330        #[arg(long)]
331        json: bool,
332    },
333    /// Publish or inspect auto-responder health for this slot.
334    Responder {
335        #[command(subcommand)]
336        command: ResponderCommand,
337    },
338    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
339    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
340    Pin {
341        /// Path to peer's signed agent-card JSON.
342        card_file: String,
343        #[arg(long)]
344        json: bool,
345    },
346    /// Allocate a NEW slot on the same relay and abandon the old one.
347    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
348    /// slot announcing the new mailbox before swapping. After rotation,
349    /// peers must re-pair (or operator runs `add-peer-slot` with the new
350    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
351    ///
352    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
353    /// abusive bearer-holder spamming your slot). Rotate → old slot is
354    /// orphaned → attacker's leverage gone. Operator pairs again with
355    /// peers they still want.
356    RotateSlot {
357        /// Skip the wire_close announcement to peers (faster but they won't know
358        /// where you went).
359        #[arg(long)]
360        no_announce: bool,
361        #[arg(long)]
362        json: bool,
363    },
364    /// Remove a peer from trust + relay state. Inbox/outbox files for that
365    /// peer are NOT deleted (operator can grep history); pass --purge to
366    /// also wipe the JSONL files.
367    ForgetPeer {
368        /// Peer handle to forget.
369        handle: String,
370        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
371        #[arg(long)]
372        purge: bool,
373        #[arg(long)]
374        json: bool,
375    },
376    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
377    /// peers' relay slots and pull inbox from our own slot. Foreground process;
378    /// background it with systemd / `&` / tmux as you prefer.
379    Daemon {
380        /// Sync interval in seconds. Default 5.
381        #[arg(long, default_value_t = 5)]
382        interval: u64,
383        /// Run a single sync cycle and exit (useful for cron-driven setups).
384        #[arg(long)]
385        once: bool,
386        #[arg(long)]
387        json: bool,
388    },
389    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
390    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
391    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
392    /// read the SAS digits aloud and confirm.)
393    #[command(hide = true)] // v0.9 deprecated
394    PairHost {
395        /// Relay base URL.
396        #[arg(long)]
397        relay: String,
398        /// Skip the SAS confirmation prompt. ONLY use when piping under
399        /// automated tests or when the SAS has already been verified by
400        /// another channel. Documented as test-only.
401        #[arg(long)]
402        yes: bool,
403        /// How long (seconds) to wait for the peer to join before timing out.
404        #[arg(long, default_value_t = 300)]
405        timeout: u64,
406        /// Detach: write a pending-pair file, print the code phrase, and exit
407        /// immediately. The running `wire daemon` does the handshake in the
408        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
409        /// `wire pair-list` shows pending sessions. Default is foreground
410        /// blocking behavior for backward compat.
411        #[arg(long)]
412        detach: bool,
413        /// Emit JSON instead of text. Currently only meaningful with --detach.
414        #[arg(long)]
415        json: bool,
416    },
417    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
418    ///
419    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
420    #[command(alias = "join")]
421    #[command(hide = true)] // v0.9 deprecated
422    PairJoin {
423        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
424        code_phrase: String,
425        /// Relay base URL (must match the host's relay).
426        #[arg(long)]
427        relay: String,
428        #[arg(long)]
429        yes: bool,
430        #[arg(long, default_value_t = 300)]
431        timeout: u64,
432        /// Detach: see `pair-host --detach`.
433        #[arg(long)]
434        detach: bool,
435        /// Emit JSON instead of text. Currently only meaningful with --detach.
436        #[arg(long)]
437        json: bool,
438    },
439    /// Confirm SAS digits for a detached pending pair. The daemon must be
440    /// running for this to do anything — it picks up the confirmation on its
441    /// next tick. Mismatch aborts the pair.
442    #[command(hide = true)] // v0.9 deprecated
443    PairConfirm {
444        /// The code phrase the original `wire pair-host --detach` printed.
445        code_phrase: String,
446        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
447        digits: String,
448        /// Emit JSON instead of human-readable text.
449        #[arg(long)]
450        json: bool,
451    },
452    /// List all pending detached pair sessions and their state.
453    #[command(hide = true)] // v0.9 deprecated
454    PairList {
455        /// Emit JSON instead of the table.
456        #[arg(long)]
457        json: bool,
458        /// Stream mode: never exit; print one JSON line per status transition
459        /// (creation, status change, deletion) across all pending pairs.
460        /// Compose with bash `while read` to react in shell. Implies --json.
461        #[arg(long)]
462        watch: bool,
463        /// Poll interval in seconds for --watch.
464        #[arg(long, default_value_t = 1)]
465        watch_interval: u64,
466    },
467    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
468    #[command(hide = true)] // v0.9 deprecated
469    PairCancel {
470        code_phrase: String,
471        #[arg(long)]
472        json: bool,
473    },
474    /// Block until a pending pair reaches a target status (default sas_ready),
475    /// or terminates (finalized = file removed, aborted, aborted_restart), or
476    /// the timeout expires. Useful for shell scripts that want to drive the
477    /// detached flow without polling pair-list themselves.
478    ///
479    /// Exit codes:
480    ///   0 — reached target status (or finalized, if target was sas_ready)
481    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
482    ///   2 — timeout
483    #[command(hide = true)] // v0.9 deprecated
484    PairWatch {
485        code_phrase: String,
486        /// Target status to wait for. Default: sas_ready.
487        #[arg(long, default_value = "sas_ready")]
488        status: String,
489        /// Max seconds to wait.
490        #[arg(long, default_value_t = 300)]
491        timeout: u64,
492        /// Emit JSON on each status change (one per line) instead of just on exit.
493        #[arg(long)]
494        json: bool,
495    },
496    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
497    /// pair-join, then registers wire as an MCP server. Single command from
498    /// nothing to paired and ready — no separate init/pair-host/setup steps.
499    /// Operator still must confirm SAS digits.
500    ///
501    /// Examples:
502    ///   wire pair paul                          # host a new pair on default relay
503    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
504    ///
505    /// v0.10: hidden from --help. Federation pair flow is now
506    /// `wire dial <handle>@<relay>` + `wire accept-invite <URL>`.
507    /// `wire pair` stays callable for back-compat scripts; v1.0 removes.
508    #[command(hide = true)] // v0.10 deprecated — use `wire dial <h>@<relay>`
509    Pair {
510        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
511        /// step if no identity exists; ignored if already initialized.
512        handle: String,
513        /// Code phrase from peer's pair-host output. Omit to be the host
514        /// (this command will print one for you to share).
515        #[arg(long)]
516        code: Option<String>,
517        /// Relay base URL. Defaults to the laulpogan public-good relay.
518        #[arg(long, default_value = "https://wireup.net")]
519        relay: String,
520        /// Skip SAS prompt. Test-only.
521        #[arg(long)]
522        yes: bool,
523        /// Pair-step timeout in seconds.
524        #[arg(long, default_value_t = 300)]
525        timeout: u64,
526        /// Skip the post-pair `setup --apply` step (don't register wire as
527        /// an MCP server in detected client configs).
528        #[arg(long)]
529        no_setup: bool,
530        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
531        /// exits immediately, daemon does the handshake). Confirm via
532        /// `wire pair-confirm <code> <digits>` from any terminal. See
533        /// `pair-host --detach` for details.
534        #[arg(long)]
535        detach: bool,
536    },
537    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
538    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
539    /// confirmation, leaving the relay-side slot stuck with "guest already
540    /// registered" or "host already registered" until the 5-minute TTL expires.
541    /// Either side can call. Idempotent.
542    #[command(hide = true)] // v0.9 deprecated
543    PairAbandon {
544        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
545        code_phrase: String,
546        /// Relay base URL.
547        #[arg(long, default_value = "https://wireup.net")]
548        relay: String,
549    },
550    /// Accept a pending-inbound pair request (v0.5.14). Explicit alias for
551    /// the bilateral-completion path that `wire add <peer>@<relay>` also
552    /// drives — but doesn't require remembering the peer's relay domain
553    /// (the relay coords come from the stored pair_drop). Errors if no
554    /// pending-inbound record exists for that peer.
555    #[command(hide = true)] // v0.9 deprecated
556    PairAccept {
557        /// Bare peer handle (without `@<relay>`).
558        peer: String,
559        /// Emit JSON.
560        #[arg(long)]
561        json: bool,
562    },
563    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
564    /// you@<your-relay>` against your handle, their signed pair_drop lands
565    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
566    /// <peer>` to delete the record without pairing. The peer never receives
567    /// our slot_token; from their side the pair stays pending until they
568    /// time out.
569    #[command(hide = true)] // v0.9 deprecated
570    PairReject {
571        /// Bare peer handle (without `@<relay>`).
572        peer: String,
573        /// Emit JSON.
574        #[arg(long)]
575        json: bool,
576    },
577    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
578    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
579    /// `pair-list --json` shape but for inbound). Use this in scripts that
580    /// need to enumerate inbound pair requests without parsing the SPAKE2
581    /// table format from `wire pair-list`.
582    #[command(hide = true)] // v0.9 deprecated
583    PairListInbound {
584        /// Emit JSON.
585        #[arg(long)]
586        json: bool,
587    },
588    /// Manage isolated wire sessions on this machine (v0.5.16).
589    ///
590    /// Each session = its own DID + handle + relay slot + daemon + inbox/
591    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
592    /// in different projects) run on the same machine — without sessions
593    /// they all share one identity and race the inbox cursor.
594    ///
595    /// Names are derived from `basename(cwd)` and cached in a registry,
596    /// so re-entering the same project reuses the same identity.
597    #[command(subcommand)]
598    Session(SessionCommand),
599    /// Manage this session's identity display layer (character override).
600    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
601    /// itself picks a custom nickname + emoji that overrides the
602    /// auto-derived hash-based defaults.
603    Identity {
604        #[command(subcommand)]
605        cmd: IdentityCommand,
606    },
607    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
608    /// sister-session mesh. `wire mesh status` is the live view of every
609    /// paired sister (alias for `wire session mesh-status`); `wire mesh
610    /// broadcast` fans one signed event to every pinned peer.
611    #[command(subcommand)]
612    Mesh(MeshCommand),
613    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
614    /// Cursor, project-local) and either print or auto-merge the wire MCP
615    /// server entry. Default prints; pass `--apply` to actually modify config
616    /// files. Idempotent — re-running is safe.
617    Setup {
618        /// Actually write the changes (default = print only).
619        #[arg(long)]
620        apply: bool,
621        /// Install a Claude Code statusLine showing your wire persona
622        /// (liveness dot + emoji + nickname in the persona's accent color +
623        /// cwd) instead of merging the MCP server. Writes a renderer script
624        /// and merges a `statusLine` block into Claude Code's settings.json
625        /// (honors $CLAUDE_CONFIG_DIR). Combine with --apply to write.
626        #[arg(long)]
627        statusline: bool,
628        /// With --statusline: uninstall it (drop the statusLine key + remove
629        /// the renderer script) instead of installing.
630        #[arg(long)]
631        remove: bool,
632    },
633    /// Show an agent's profile. With no arg, prints local self. With a
634    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
635    /// endpoint and verifies the returned signed card before display.
636    Whois {
637        /// Optional handle (`nick@domain`). Omit to show self.
638        handle: Option<String>,
639        #[arg(long)]
640        json: bool,
641        /// Override the relay base URL used for resolution (default:
642        /// `https://<domain>` from the handle).
643        #[arg(long)]
644        relay: Option<String>,
645    },
646    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
647    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
648    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
649    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
650    /// their slot_token so we can `wire send` to them).
651    Add {
652        /// Peer handle (`nick@domain`), OR a bare sister-session name
653        /// when `--local-sister` is set.
654        handle: String,
655        /// Override the relay base URL used for resolution.
656        #[arg(long)]
657        relay: Option<String>,
658        /// v0.6.6: pair with a sister session on this machine without
659        /// touching federation. Looks up `handle` as a session name in
660        /// `wire session list`, reads that session's agent-card +
661        /// endpoints from disk, pins directly, then delivers the
662        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
663        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
664        /// addressable because they don't need a federation claim.
665        #[arg(long)]
666        local_sister: bool,
667        #[arg(long)]
668        json: bool,
669    },
670    /// Come online in one command — `wire up` does what used to take five
671    /// (init + bind-relay + claim your persona + background daemon +
672    /// restart-on-login). Idempotent: re-run on an already-set-up box prints
673    /// state without churn.
674    ///
675    /// There is no name to choose: your handle IS your DID-derived persona
676    /// (one-name rule). The optional argument is just which relay to use.
677    ///
678    /// Examples:
679    ///   wire up                        # default public relay (wireup.net)
680    ///   wire up @wireup.net            # explicit federation relay
681    ///   wire up http://127.0.0.1:8771  # a local / self-hosted relay
682    Up {
683        /// Relay to bind + claim your persona on: `@wireup.net`, `wireup.net`,
684        /// or a full URL. Omit for the default public relay. No nick — your
685        /// handle is your DID-derived persona.
686        relay: Option<String>,
687        /// Optional display name for your profile card (cosmetic; distinct
688        /// from your addressable handle/persona).
689        #[arg(long)]
690        name: Option<String>,
691        /// Also additively dual-bind a LOCAL relay slot for fast same-box
692        /// sister-session routing. Defaults to probing
693        /// `http://127.0.0.1:8771`; pass a URL to override. Local relays
694        /// carry no handle directory, so nothing is claimed there.
695        #[arg(long)]
696        with_local: Option<String>,
697        /// Skip the opportunistic local dual-bind entirely.
698        #[arg(long)]
699        no_local: bool,
700        #[arg(long)]
701        json: bool,
702    },
703    /// Diagnose wire setup health. Single command that surfaces every
704    /// silent-fail class — daemon down or duplicated, relay unreachable,
705    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
706    /// Replaces today's 30-minute manual debug.
707    ///
708    /// Exit code non-zero if any FAIL findings.
709    Doctor {
710        /// Emit JSON.
711        #[arg(long)]
712        json: bool,
713        /// Show last N entries from pair-rejected.jsonl in the report.
714        #[arg(long, default_value_t = 5)]
715        recent_rejections: usize,
716    },
717    /// Atomic upgrade: kill every `wire daemon` process, spawn a fresh
718    /// one from the current binary, write a new pidfile. Eliminates the
719    /// "stale binary text in memory under a fresh symlink" bug class that
720    /// burned 30 minutes today.
721    Upgrade {
722        /// Report drift without taking action (lists processes that would
723        /// be killed + the version of each).
724        #[arg(long)]
725        check: bool,
726        #[arg(long)]
727        json: bool,
728    },
729    /// Install / inspect / remove a launchd plist (macOS) or systemd
730    /// user unit (linux) that runs `wire daemon` on login + restarts
731    /// on crash. Replaces today's "background it with tmux/&/systemd
732    /// as you prefer" footgun.
733    Service {
734        #[command(subcommand)]
735        action: ServiceAction,
736    },
737    /// Inspect or toggle the structured diagnostic trace
738    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
739    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
740    /// (writes the file knob a running daemon picks up automatically).
741    Diag {
742        #[command(subcommand)]
743        action: DiagAction,
744    },
745    /// Claim your persona on a relay's handle directory. Anyone can then
746    /// reach this agent by `<persona>@<relay-domain>` via the relay's
747    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
748    ///
749    /// ONE-NAME RULE (v0.13.1): the claimed handle is always your DID-derived
750    /// persona. The `nick` arg is vestigial — if it differs it is ignored
751    /// (like the typed name `wire init` / `wire up` already ignore), so your
752    /// phonebook entry can never drift from your agent-card handle.
753    ///
754    /// v0.13.1: hidden — `wire up` claims your persona for you. Kept callable
755    /// (idempotent re-claim) but not a user verb; there is no nick to choose.
756    #[command(hide = true)]
757    Claim {
758        /// Vestigial: ignored if it differs from your DID-derived persona.
759        nick: String,
760        /// Relay to claim the nick on. Default = relay our slot is on.
761        #[arg(long)]
762        relay: Option<String>,
763        /// Public URL the relay should advertise to resolvers (default = relay).
764        #[arg(long)]
765        public_url: Option<String>,
766        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
767        /// directory listing. The handle stays claimed (FCFS still
768        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
769        /// still resolves, so peers you share the handle with out-of-band
770        /// can still pair. Bulk scrapers / phonebook crawlers will not
771        /// see the nick. Use this for handles meant for known-peer
772        /// pairing only — see issue #9.
773        #[arg(long)]
774        hidden: bool,
775        #[arg(long)]
776        json: bool,
777    },
778    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
779    /// avatar_url, handle, now). Re-signs the agent-card atomically.
780    ///
781    /// Examples:
782    ///   wire profile set motto "compiles or dies trying"
783    ///   wire profile set emoji "🦀"
784    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
785    ///   wire profile set handle "coffee-ghost@anthropic.dev"
786    ///   wire profile get
787    Profile {
788        #[command(subcommand)]
789        action: ProfileAction,
790    },
791    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
792    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
793    /// a relay slot on first use. Default TTL 24h, single-use.
794    #[command(hide = true)] // v0.9 deprecated
795    Invite {
796        /// Override the relay URL for first-time auto-allocation.
797        #[arg(long, default_value = "https://wireup.net")]
798        relay: String,
799        /// Invite lifetime in seconds (default 86400 = 24h).
800        #[arg(long, default_value_t = 86_400)]
801        ttl: u64,
802        /// Number of distinct peers that can accept this invite before it's
803        /// consumed (default 1).
804        #[arg(long, default_value_t = 1)]
805        uses: u32,
806        /// Register the invite at the relay's short-URL endpoint and print
807        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
808        /// Installs wire if missing, then accepts the invite, then pairs.
809        #[arg(long)]
810        share: bool,
811        /// Emit JSON.
812        #[arg(long)]
813        json: bool,
814    },
815    /// v0.9: accept a pending-inbound pair request by character
816    /// nickname or card handle. Replaces the verbose `wire pair-accept
817    /// <peer>`.
818    ///
819    /// v0.9.4: the URL-vs-name smart-dispatch from v0.9 is gone. To
820    /// accept a federation invite URL use `wire accept-invite <URL>`
821    /// (split out as an explicit verb to eliminate the input-shape
822    /// ambiguity). `wire accept <URL>` still works for back-compat
823    /// but emits a deprecation banner pointing at `accept-invite`.
824    Accept {
825        /// Pending peer name (character nickname or card handle).
826        target: String,
827        /// Emit JSON.
828        #[arg(long)]
829        json: bool,
830    },
831    /// v0.9.4: accept a federation invite URL minted by `wire invite`.
832    /// Pins issuer, sends signed card to issuer's slot. Auto-inits +
833    /// auto-allocates as needed.
834    ///
835    /// Split out from `wire accept` to eliminate the URL-vs-name
836    /// smart-dispatch ambiguity (peer handles can legitimately collide
837    /// with URL-shaped strings; the explicit verb removes the inference).
838    #[command(alias = "invite-accept")]
839    AcceptInvite {
840        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
841        url: String,
842        /// Emit JSON.
843        #[arg(long)]
844        json: bool,
845    },
846    /// v0.9: refuse a pending-inbound pair request without pairing. Aliases
847    /// the legacy `wire pair-reject <peer>`.
848    Reject {
849        /// Peer name (character nickname or handle) from `wire pending`.
850        peer: String,
851        /// Emit JSON.
852        #[arg(long)]
853        json: bool,
854    },
855    /// Watch the inbox for new verified events and fire an OS notification per
856    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
857    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
858    /// re-emit history.
859    Notify {
860        /// Poll interval in seconds.
861        #[arg(long, default_value_t = 2)]
862        interval: u64,
863        /// Only notify for events from this peer (handle, no did: prefix).
864        #[arg(long)]
865        peer: Option<String>,
866        /// Run a single sweep and exit (useful for cron / tests).
867        #[arg(long)]
868        once: bool,
869        /// Suppress the OS notification call; print one JSON line per event to
870        /// stdout instead (for piping into other tooling or smoke-testing
871        /// without a desktop session).
872        #[arg(long)]
873        json: bool,
874    },
875}
876
877#[derive(Subcommand, Debug)]
878pub enum DiagAction {
879    /// Tail the last N entries from diag.jsonl.
880    Tail {
881        #[arg(long, default_value_t = 20)]
882        limit: usize,
883        #[arg(long)]
884        json: bool,
885    },
886    /// Flip the file-based knob ON. Running daemons pick this up on
887    /// the next emit call without restart.
888    Enable,
889    /// Flip the file-based knob OFF.
890    Disable,
891    /// Report whether diag is currently enabled + the file's size.
892    Status {
893        #[arg(long)]
894        json: bool,
895    },
896}
897
898#[derive(Subcommand, Debug)]
899pub enum IdentityCommand {
900    /// Print the current character (DID-derived, the only name).
901    /// Equivalent to `wire whoami --short` but scoped here for grouping.
902    Show {
903        #[arg(long)]
904        json: bool,
905    },
906    /// List all identities on this machine — one row per session, with
907    /// each session's character, DID, federation handle, and cwd. Same
908    /// shape as `wire session list`, scoped here for the v0.7+ noun-
909    /// CLI surface.
910    List {
911        #[arg(long)]
912        json: bool,
913    },
914    /// Promote this identity to FEDERATION lifecycle: claim your persona on
915    /// the relay so peers can `wire dial <persona>@<relay-domain>` you.
916    /// Re-claims with current display fields so the relay always serves the
917    /// latest signed card. Equivalent to `wire claim`.
918    ///
919    /// v0.13.1: hidden — `wire up` publishes your persona for you, and the
920    /// nick is vestigial (one-name rule). Kept callable for re-publish.
921    #[command(hide = true)]
922    Publish {
923        /// Vestigial: ignored; your handle is your DID-derived persona.
924        nick: String,
925        /// Override the relay URL. Defaults to the session's bound relay
926        /// from `wire init --relay <url>`. Public relay if unset.
927        #[arg(long)]
928        relay: Option<String>,
929        /// Public-facing URL for the agent-card location (when the relay
930        /// is behind a CDN with a different public domain).
931        #[arg(long, alias = "public")]
932        public_url: Option<String>,
933        /// Skip listing in the relay's public phonebook. The card is
934        /// still claimable + reachable; just doesn't appear in
935        /// `wireup.net/phonebook` for stranger-discovery.
936        #[arg(long)]
937        hidden: bool,
938        #[arg(long)]
939        json: bool,
940    },
941    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
942    /// Equivalent to `wire session destroy <name>`, scoped here for the
943    /// noun-CLI surface. Requires `--force` (the underlying command does).
944    Destroy {
945        /// Session name to destroy (use `wire identity list` to see).
946        name: String,
947        /// Bypass the confirmation prompt.
948        #[arg(long)]
949        force: bool,
950        #[arg(long)]
951        json: bool,
952    },
953    /// Create an identity in an EXPLICIT lifecycle state (vs. the
954    /// implicit `wire init` + `wire claim` flow).
955    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
956    ///
957    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
958    /// next reboot). In-memory semantics not yet supported — the
959    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
960    /// For pure-RAM identities, see v1.0 vision.
961    ///
962    /// `--local` is the explicit form of today's default; identity
963    /// persists to the machine-wide sessions root.
964    Create {
965        /// Session name. Defaults to derived from cwd (anonymous mode
966        /// uses a random name).
967        #[arg(long)]
968        name: Option<String>,
969        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
970        /// reboot, no federation). Mutually exclusive with --local.
971        #[arg(long, conflicts_with = "local")]
972        anonymous: bool,
973        /// Create a LOCAL identity (machine-persistent, no federation).
974        /// Default — explicit flag for clarity.
975        #[arg(long)]
976        local: bool,
977        #[arg(long)]
978        json: bool,
979    },
980    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
981    /// the machine-wide sessions root + register in the cwd map.
982    /// After persist, the identity survives reboot.
983    /// v0.7.0-alpha.20.
984    Persist {
985        /// The anonymous identity's name (from `wire identity list`).
986        name: String,
987        /// Optional rename during persist. Default: keep the anon name.
988        #[arg(long = "as", value_name = "NEW_NAME")]
989        as_name: Option<String>,
990        #[arg(long)]
991        json: bool,
992    },
993    /// Demote an identity ONE level in the lifecycle:
994    ///   federation → local: removes the relay slot binding but keeps
995    ///   the keypair + agent-card. Operator can later re-publish with
996    ///   `wire identity publish`. v0.7.0-alpha.20.
997    ///
998    /// (local → anonymous is not exposed; the safer flow is destroy +
999    /// recreate, since "demoting" a persistent identity to ephemeral
1000    /// has surprising semantics — what about the keypair? what about
1001    /// pinned peers? Better to be explicit with destroy.)
1002    Demote {
1003        /// Session name to demote.
1004        name: String,
1005        #[arg(long)]
1006        json: bool,
1007    },
1008}
1009
1010#[derive(Subcommand, Debug)]
1011pub enum SessionCommand {
1012    /// Bootstrap a new isolated session in this machine's sessions root.
1013    /// With no name, derives one from `basename(cwd)` and caches it in
1014    /// the registry so re-running from the same project reuses it.
1015    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1016    /// the new session's WIRE_HOME. Output includes the `export
1017    /// WIRE_HOME=...` line operators paste into their shell to activate
1018    /// it.
1019    New {
1020        /// Optional session name. Default = derived from `basename(cwd)`.
1021        name: Option<String>,
1022        /// Relay URL for the session's slot allocation + handle claim.
1023        #[arg(long, default_value = "https://wireup.net")]
1024        relay: String,
1025        /// v0.5.17: also allocate a second slot on a same-machine local
1026        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1027        /// sister-session traffic prefers this path: zero round-trip
1028        /// latency, zero metadata exposure to the public relay. Probes
1029        /// `<local-relay>/healthz` first; silently skips if the local
1030        /// relay isn't running.
1031        #[arg(long)]
1032        with_local: bool,
1033        /// v0.5.17: override the local relay URL probed by `--with-local`.
1034        /// Default is `http://127.0.0.1:8771` to match
1035        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1036        #[arg(long, default_value = "http://127.0.0.1:8771")]
1037        local_relay: String,
1038        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1039        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1040        /// Lets other machines on the same network reach this session
1041        /// directly without round-tripping the public federation relay
1042        /// at https://wireup.net. LAN endpoint is published in the
1043        /// agent-card; opt-in per session (default off).
1044        #[arg(long)]
1045        with_lan: bool,
1046        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1047        /// LAN IP — operator must type the address). Example:
1048        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1049        #[arg(long)]
1050        lan_relay: Option<String>,
1051        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1052        /// relay (must be running e.g. via `wire relay-server --uds
1053        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1054        /// bypasses the macOS firewall + Tailscale userspace-netstack
1055        /// class of issues entirely for sister-session traffic. UDS
1056        /// endpoint is published in the agent-card.
1057        #[arg(long)]
1058        with_uds: bool,
1059        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1060        /// is set. Example: `/tmp/wire.sock` or
1061        /// `~/.wire/local.sock`.
1062        #[arg(long)]
1063        uds_socket: Option<std::path::PathBuf>,
1064        /// Skip spawning the session-local daemon. Use when you want
1065        /// to drive sync explicitly from the agent or test rig.
1066        #[arg(long)]
1067        no_daemon: bool,
1068        /// v0.6.6: create a federation-free session — no nick claim on
1069        /// `--relay`, no federation slot allocation. Implies
1070        /// `--with-local`. The session exists only to coordinate with
1071        /// other sister sessions on this machine; it has no public
1072        /// address and cannot be reached from outside. Reserved nicks
1073        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1074        /// to publish them.
1075        #[arg(long)]
1076        local_only: bool,
1077        /// Emit JSON.
1078        #[arg(long)]
1079        json: bool,
1080    },
1081    /// List all sessions on this machine with their handle, DID,
1082    /// daemon liveness, and the cwd they're associated with.
1083    List {
1084        #[arg(long)]
1085        json: bool,
1086    },
1087    /// List sister sessions reachable via a same-machine local relay
1088    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1089    /// share. Sessions without a Local-scope endpoint are listed
1090    /// separately so the operator can tell which are federation-only.
1091    /// Read-only — does not probe any relay or touch daemons.
1092    ListLocal {
1093        #[arg(long)]
1094        json: bool,
1095    },
1096    /// v0.6.0 (issue #12): mesh-pair every sister session against every
1097    /// other in O(N²) handshakes. For each unordered pair (A, B) that
1098    /// is not already paired, drives the bilateral flow end-to-end:
1099    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
1100    /// B's side, then a final pull on A so the ack lands. Idempotent —
1101    /// re-running skips pairs already in `state.peers`.
1102    ///
1103    /// **Trust anchor:** the operator running this command owns every
1104    /// session listed in `wire session list-local` (they all live under
1105    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1106    /// That filesystem-permission boundary IS the consent for both
1107    /// sides — the bilateral SAS / network-level handshake assumes
1108    /// strangers; same-uid sister sessions are by definition not
1109    /// strangers. Cross-uid sister sessions are out of scope; today
1110    /// `wire session list-local` only enumerates this user's sessions.
1111    PairAllLocal {
1112        /// Seconds to wait between handshake stages for pair_drop /
1113        /// pair_drop_ack to propagate over the relay. Default 1s
1114        /// (local-relay is typically <100ms RTT). Bump if you see
1115        /// "pending-inbound never arrived" errors on a slow relay.
1116        #[arg(long, default_value_t = 1)]
1117        settle_secs: u64,
1118        /// Federation relay to bind each `wire add` against. Default
1119        /// `https://wireup.net`. Sister sessions should be bound to
1120        /// the same federation relay; the pair handshake routes through
1121        /// it for the .well-known resolution + pair_drop deposit.
1122        #[arg(long, default_value = "https://wireup.net")]
1123        federation_relay: String,
1124        #[arg(long)]
1125        json: bool,
1126    },
1127    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1128    /// machine. Enumerates every session in `wire session list-local`,
1129    /// walks each session's `relay.json#peers` to find which other sister
1130    /// sessions it has pinned, and probes the local relay for each edge's
1131    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1132    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1133    /// local_relay, summary}` so scripts can scrape.
1134    ///
1135    /// Read-only — does NOT touch peers or daemons, only the relay's
1136    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1137    /// already hold. Silent on any probe failure (degrades to "no
1138    /// signal" rather than abort) so a half-broken mesh is still
1139    /// inspectable.
1140    MeshStatus {
1141        /// Threshold in seconds for "stale" classification on an edge.
1142        /// An edge whose receiver hasn't polled their slot in this long
1143        /// is flagged. Default 300s (5 min) — same as the per-send
1144        /// `phyllis` attentiveness nag.
1145        #[arg(long, default_value_t = 300)]
1146        stale_secs: u64,
1147        #[arg(long)]
1148        json: bool,
1149    },
1150    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1151    /// can `eval $(wire session env <name>)` to activate it. With no
1152    /// name, resolves the cwd through the registry.
1153    Env {
1154        /// Session name. Default = derived from cwd via the registry.
1155        name: Option<String>,
1156        #[arg(long)]
1157        json: bool,
1158    },
1159    /// Identify which session the current cwd maps to in the registry.
1160    /// Prints `(none)` if cwd isn't registered — `wire session new`
1161    /// would create one.
1162    Current {
1163        #[arg(long)]
1164        json: bool,
1165    },
1166    /// Attach an existing session to the current cwd in the registry,
1167    /// so subsequent auto-detect from this cwd resolves to that session
1168    /// instead of walking up to an ancestor's binding. Use when an
1169    /// ancestor dir (e.g. `~/Source`) is already registered and is
1170    /// shadowing per-project identities for cwds beneath it. Idempotent;
1171    /// re-binding to the same name is a no-op. Re-binding to a different
1172    /// name overwrites the prior entry with a stderr warning.
1173    Bind {
1174        /// Session name to bind. Must already exist (run `wire session
1175        /// new <name>` first if not). With no name, auto-derives from
1176        /// `basename(cwd)` and errors if no session of that name exists.
1177        name: Option<String>,
1178        #[arg(long)]
1179        json: bool,
1180    },
1181    /// Tear down a session: kills its daemon (if running), deletes its
1182    /// state directory, and removes it from the registry. Requires
1183    /// `--force` because state loss is unrecoverable (keypair gone).
1184    Destroy {
1185        name: String,
1186        /// Confirm state-deleting operation.
1187        #[arg(long)]
1188        force: bool,
1189        #[arg(long)]
1190        json: bool,
1191    },
1192}
1193
1194/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1195/// session's view of the pinned peer set. `status` is the read-only
1196/// observability primitive (alias for `wire session mesh-status`);
1197/// `broadcast` fans a signed event to every pinned peer in one call.
1198#[derive(Subcommand, Debug)]
1199pub enum MeshCommand {
1200    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1201    /// per-edge health roll-up across every sister session on this machine.
1202    Status {
1203        /// Threshold in seconds for "stale" classification on an edge.
1204        #[arg(long, default_value_t = 300)]
1205        stale_secs: u64,
1206        #[arg(long)]
1207        json: bool,
1208    },
1209    /// Fan one signed event to every pinned peer. Each peer receives a
1210    /// distinct `event_id` but every copy shares the same `broadcast_id`
1211    /// UUID so receivers can correlate them as a single broadcast.
1212    ///
1213    /// `--scope local` (default) only fans to peers reachable via a same-
1214    /// machine local relay. `--scope federation` only to public-relay
1215    /// peers. `--scope both` to every pinned peer.
1216    ///
1217    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1218    /// for "ack-loop" prevention: a peer responding to a broadcast can
1219    /// exclude its own broadcaster when re-broadcasting.
1220    ///
1221    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1222    /// file, `-` reads stdin (JSON if parseable, else literal).
1223    ///
1224    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1225    /// peers — that would re-introduce the phonebook-scrape risk closed
1226    /// in v0.5.14 (T8).
1227    Broadcast {
1228        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1229        /// `heartbeat`. Same vocabulary as `wire send`.
1230        #[arg(long, default_value = "claim")]
1231        kind: String,
1232        /// `local`, `federation`, or `both`. Default `local`.
1233        #[arg(long, default_value = "local")]
1234        scope: String,
1235        /// Skip a specific peer handle. Repeatable.
1236        #[arg(long)]
1237        exclude: Vec<String>,
1238        /// Drop the broadcast event ID from the relay-side attentiveness
1239        /// nag (`phyllis`) — useful when broadcasting to many peers and
1240        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1241        #[arg(long)]
1242        noreply: bool,
1243        /// Body — string, `@/path` for a file, or `-` for stdin.
1244        body: String,
1245        #[arg(long)]
1246        json: bool,
1247    },
1248    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1249    /// capability-aware addressing. Stored as `profile.role` on the
1250    /// signed agent-card — propagates over the existing pair / .well-
1251    /// known plumbing, no new persistence.
1252    ///
1253    /// First slice of the Layer-2 capability metadata umbrella (#13).
1254    /// `wire mesh route` (issue #21) will consume these tags to pick
1255    /// the right sister for a task.
1256    Role {
1257        #[command(subcommand)]
1258        action: MeshRoleAction,
1259    },
1260    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1261    /// to one sister session and deliver an event to that one peer.
1262    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1263    /// can now address "the reviewer" instead of hard-coding a handle.
1264    ///
1265    /// Strategies:
1266    ///   - `round-robin` (default): per-role cursor, persisted at
1267    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1268    ///   - `first`: alphabetically-first matching sister. Deterministic.
1269    ///   - `random`: uniform random among matches. Stateless.
1270    ///
1271    /// Pinned-peers-only by construction (same posture as `broadcast`).
1272    /// Caller must already have the target sister pinned in
1273    /// `state.peers` — otherwise we can't sign + push. Run
1274    /// `wire session pair-all-local` first if the mesh isn't wired.
1275    Route {
1276        /// Role to match (operator-defined tag from `wire mesh role set`).
1277        role: String,
1278        /// `round-robin` (default), `first`, or `random`.
1279        #[arg(long, default_value = "round-robin")]
1280        strategy: String,
1281        /// Skip a specific sister handle. Repeatable.
1282        #[arg(long)]
1283        exclude: Vec<String>,
1284        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1285        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1286        #[arg(long, default_value = "claim")]
1287        kind: String,
1288        /// Body — string, `@/path` for a file, or `-` for stdin.
1289        body: String,
1290        #[arg(long)]
1291        json: bool,
1292    },
1293}
1294
1295/// v0.6.4: subcommands of `wire mesh role`.
1296#[derive(Subcommand, Debug)]
1297pub enum MeshRoleAction {
1298    /// Assign self to a role. Role is a free-form ASCII string
1299    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1300    /// the vocabulary out-of-band — common starters: `planner`,
1301    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1302    Set {
1303        role: String,
1304        #[arg(long)]
1305        json: bool,
1306    },
1307    /// Read self or a peer's role. With no arg, prints self. With a
1308    /// handle, reads from the peer's pinned agent-card.
1309    Get {
1310        peer: Option<String>,
1311        #[arg(long)]
1312        json: bool,
1313    },
1314    /// List roles across every sister session on this machine. Reads
1315    /// each session's agent-card by path — no network, no env mutation.
1316    List {
1317        #[arg(long)]
1318        json: bool,
1319    },
1320    /// Remove self from any assigned role. Re-signs the card with
1321    /// `profile.role: null`.
1322    Clear {
1323        #[arg(long)]
1324        json: bool,
1325    },
1326}
1327
1328#[derive(Subcommand, Debug)]
1329pub enum ServiceAction {
1330    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1331    /// load it. Idempotent — re-running re-bootstraps an existing service.
1332    ///
1333    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1334    /// process). Pass `--local-relay` to install the loopback relay
1335    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1336    /// transport sister-Claudes use to coordinate on the same machine
1337    /// (v0.5.17 dual-slot). The two services have distinct labels +
1338    /// log files, so you can install both.
1339    Install {
1340        /// Install the local-relay service instead of the daemon.
1341        #[arg(long)]
1342        local_relay: bool,
1343        #[arg(long)]
1344        json: bool,
1345    },
1346    /// Unload + delete the service unit. Daemon keeps running until the
1347    /// next reboot or `wire upgrade`; this only changes the boot-time
1348    /// behaviour.
1349    Uninstall {
1350        /// Uninstall the local-relay service instead of the daemon.
1351        #[arg(long)]
1352        local_relay: bool,
1353        #[arg(long)]
1354        json: bool,
1355    },
1356    /// Report whether the unit is installed + active.
1357    Status {
1358        /// Show status of the local-relay service instead of the daemon.
1359        #[arg(long)]
1360        local_relay: bool,
1361        #[arg(long)]
1362        json: bool,
1363    },
1364}
1365
1366#[derive(Subcommand, Debug)]
1367pub enum ResponderCommand {
1368    /// Publish this agent's auto-responder health.
1369    Set {
1370        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1371        status: String,
1372        /// Optional operator-facing reason.
1373        #[arg(long)]
1374        reason: Option<String>,
1375        /// Emit JSON.
1376        #[arg(long)]
1377        json: bool,
1378    },
1379    /// Read responder health for self, or for a paired peer.
1380    Get {
1381        /// Optional peer handle; omitted means this agent's own slot.
1382        peer: Option<String>,
1383        /// Emit JSON.
1384        #[arg(long)]
1385        json: bool,
1386    },
1387}
1388
1389#[derive(Subcommand, Debug)]
1390pub enum ProfileAction {
1391    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1392    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1393    /// (JSON array) and `now` (JSON object).
1394    Set {
1395        field: String,
1396        value: String,
1397        #[arg(long)]
1398        json: bool,
1399    },
1400    /// Show all profile fields. Equivalent to `wire whois`.
1401    Get {
1402        #[arg(long)]
1403        json: bool,
1404    },
1405    /// Clear a profile field.
1406    Clear {
1407        field: String,
1408        #[arg(long)]
1409        json: bool,
1410    },
1411}
1412
1413/// Entry point — parse and dispatch.
1414pub fn run() -> Result<()> {
1415    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1416    // the session registry and adopt that session's home for this
1417    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1418    // detect — `wire whoami` / `wire monitor` from a project cwd now
1419    // resolve to that project's session identity, not the machine
1420    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1421    //
1422    // MUST run before any thread spawn — call it FIRST, before
1423    // `Cli::parse` (which uses clap internals only) and before any
1424    // command dispatch (which may spawn workers).
1425    crate::session::maybe_adopt_session_wire_home("cli");
1426    let cli = Cli::parse();
1427    match cli.command {
1428        Command::Init {
1429            handle,
1430            name,
1431            relay,
1432            offline,
1433            json,
1434        } => cmd_init(
1435            Some(&handle),
1436            name.as_deref(),
1437            relay.as_deref(),
1438            offline,
1439            json,
1440        ),
1441        Command::Status { peer, json } => {
1442            if let Some(peer) = peer {
1443                cmd_status_peer(&peer, json)
1444            } else {
1445                cmd_status(json)
1446            }
1447        }
1448        Command::Whoami {
1449            json,
1450            short,
1451            colored,
1452        } => cmd_whoami(json_default(json), short, colored),
1453        Command::Peers { json } => cmd_peers(json_default(json)),
1454        Command::Here { json } => cmd_here(json_default(json)),
1455        Command::Completions { shell } => {
1456            // v0.9.5: print shell completion script to stdout. Operator
1457            // pipes into their shell's completion dir; tab completion
1458            // covers verbs (dial, send, pending, accept, etc.) AND
1459            // their flags. Peer-name dynamic completion is a future
1460            // shell-side enhancement; clap_complete only ships the
1461            // static grammar.
1462            use clap::CommandFactory;
1463            let mut cmd = Cli::command();
1464            clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1465            Ok(())
1466        }
1467        Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1468        Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1469        Command::Send {
1470            peer,
1471            kind_or_body,
1472            body,
1473            deadline,
1474            no_auto_pair,
1475            json,
1476        } => {
1477            // P0.S: smart-positional API. `wire send peer body` =
1478            // kind=claim. `wire send peer kind body` = explicit kind.
1479            let (kind, body) = match body {
1480                Some(real_body) => (kind_or_body, real_body),
1481                None => ("claim".to_string(), kind_or_body),
1482            };
1483            cmd_send(
1484                &peer,
1485                &kind,
1486                &body,
1487                deadline.as_deref(),
1488                no_auto_pair,
1489                json_default(json),
1490            )
1491        }
1492        Command::Dial {
1493            name,
1494            message,
1495            json,
1496        } => cmd_dial(&name, message.as_deref(), json_default(json)),
1497        Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1498        Command::Monitor {
1499            peer,
1500            json,
1501            include_handshake,
1502            interval_ms,
1503            replay,
1504        } => cmd_monitor(
1505            peer.as_deref(),
1506            json,
1507            include_handshake,
1508            interval_ms,
1509            replay,
1510        ),
1511        Command::Verify { path, json } => cmd_verify(&path, json),
1512        Command::Responder { command } => match command {
1513            ResponderCommand::Set {
1514                status,
1515                reason,
1516                json,
1517            } => cmd_responder_set(&status, reason.as_deref(), json),
1518            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1519        },
1520        Command::Mcp => cmd_mcp(),
1521        Command::RelayServer {
1522            bind,
1523            local_only,
1524            uds,
1525        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1526        Command::BindRelay {
1527            url,
1528            scope,
1529            replace,
1530            migrate_pinned,
1531            json,
1532        } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1533        Command::AddPeerSlot {
1534            handle,
1535            url,
1536            slot_id,
1537            slot_token,
1538            json,
1539        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1540        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1541        Command::Pull { json } => cmd_pull(json),
1542        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1543        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1544        Command::ForgetPeer {
1545            handle,
1546            purge,
1547            json,
1548        } => cmd_forget_peer(&handle, purge, json),
1549        Command::Daemon {
1550            interval,
1551            once,
1552            json,
1553        } => cmd_daemon(interval, once, json),
1554        Command::PairHost {
1555            relay,
1556            yes,
1557            timeout,
1558            detach,
1559            json,
1560        } => {
1561            if detach {
1562                cmd_pair_host_detach(&relay, json)
1563            } else {
1564                cmd_pair_host(&relay, yes, timeout)
1565            }
1566        }
1567        Command::PairJoin {
1568            code_phrase,
1569            relay,
1570            yes,
1571            timeout,
1572            detach,
1573            json,
1574        } => {
1575            if detach {
1576                cmd_pair_join_detach(&code_phrase, &relay, json)
1577            } else {
1578                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1579            }
1580        }
1581        Command::PairConfirm {
1582            code_phrase,
1583            digits,
1584            json,
1585        } => cmd_pair_confirm(&code_phrase, &digits, json),
1586        Command::PairList {
1587            json,
1588            watch,
1589            watch_interval,
1590        } => cmd_pair_list(json, watch, watch_interval),
1591        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1592        Command::PairWatch {
1593            code_phrase,
1594            status,
1595            timeout,
1596            json,
1597        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1598        Command::Pair {
1599            handle,
1600            code,
1601            relay,
1602            yes,
1603            timeout,
1604            no_setup,
1605            detach,
1606        } => {
1607            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1608            // the zero-paste megacommand path — `wire pair slancha-spark@
1609            // wireup.net` does add + poll-for-ack + verify in one shot. The
1610            // SAS / code-based pair flow stays available for handles without
1611            // `@` (bootstrap pairing between two boxes that don't yet share a
1612            // relay directory).
1613            if handle.contains('@') && code.is_none() {
1614                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1615            } else if detach {
1616                cmd_pair_detach(&handle, code.as_deref(), &relay)
1617            } else {
1618                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1619            }
1620        }
1621        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1622        Command::PairAccept { peer, json } => {
1623            let j = json_default(json);
1624            deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1625            cmd_pair_accept(&peer, j)
1626        }
1627        Command::PairReject { peer, json } => {
1628            let j = json_default(json);
1629            deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1630            cmd_pair_reject(&peer, j)
1631        }
1632        Command::PairListInbound { json } => {
1633            let j = json_default(json);
1634            deprecation_warn("pair-list-inbound", "pending", j);
1635            cmd_pair_list_inbound(j)
1636        }
1637        Command::Session(cmd) => cmd_session(cmd),
1638        Command::Identity { cmd } => cmd_identity(cmd),
1639        Command::Mesh(cmd) => cmd_mesh(cmd),
1640        Command::Invite {
1641            relay,
1642            ttl,
1643            uses,
1644            share,
1645            json,
1646        } => cmd_invite(&relay, ttl, uses, share, json),
1647        Command::Accept { target, json } => {
1648            // v0.9.4: smart-dispatch retired. `wire accept` always means
1649            // pair-accept by name. URL-shaped input gets a deprecation
1650            // banner pointing at `wire accept-invite <URL>` and then
1651            // (for back-compat with v0.9 scripts) routes to the invite
1652            // accept path one last time. v1.0 will reject URLs here.
1653            let j = json_default(json);
1654            if target.starts_with("wire://pair?") {
1655                deprecation_warn("accept-url", "accept-invite <url>", j);
1656                cmd_accept(&target, j)
1657            } else {
1658                cmd_pair_accept(&target, j)
1659            }
1660        }
1661        Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1662        Command::Whois {
1663            handle,
1664            json,
1665            relay,
1666        } => {
1667            // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1668            // resolves through the local identity layer (pinned peers
1669            // + local sister sessions). `wire whois <nick>@<relay>`
1670            // keeps the existing federation `.well-known/wire/agent`
1671            // path. `wire whois` (no arg) prints self via the original
1672            // path. The character nickname is the canonical operator-
1673            // facing name as of v0.8 — most callers should hit the
1674            // local route.
1675            match handle.as_deref() {
1676                Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1677                other => cmd_whois(other, json, relay.as_deref()),
1678            }
1679        }
1680        Command::Add {
1681            handle,
1682            relay,
1683            local_sister,
1684            json,
1685        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1686        Command::Up {
1687            relay,
1688            name,
1689            with_local,
1690            no_local,
1691            json,
1692        } => cmd_up(
1693            relay.as_deref(),
1694            name.as_deref(),
1695            with_local.as_deref(),
1696            no_local,
1697            json,
1698        ),
1699        Command::Doctor {
1700            json,
1701            recent_rejections,
1702        } => cmd_doctor(json, recent_rejections),
1703        Command::Upgrade { check, json } => cmd_upgrade(check, json),
1704        Command::Service { action } => cmd_service(action),
1705        Command::Diag { action } => cmd_diag(action),
1706        Command::Claim {
1707            nick,
1708            relay,
1709            public_url,
1710            hidden,
1711            json,
1712        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1713        Command::Profile { action } => cmd_profile(action),
1714        Command::Setup {
1715            apply,
1716            statusline,
1717            remove,
1718        } => {
1719            if statusline {
1720                cmd_setup_statusline(apply, remove)
1721            } else {
1722                cmd_setup(apply)
1723            }
1724        }
1725        Command::Notify {
1726            interval,
1727            peer,
1728            once,
1729            json,
1730        } => cmd_notify(interval, peer.as_deref(), once, json),
1731    }
1732}
1733
1734// ---------- init ----------
1735
1736fn cmd_init(
1737    handle: Option<&str>,
1738    name: Option<&str>,
1739    relay: Option<&str>,
1740    offline: bool,
1741    as_json: bool,
1742) -> Result<()> {
1743    // One-name rule: a typed handle (if any) is only a vanity seed — the
1744    // persona is derived from the keypair fingerprint, so it has no effect
1745    // on the resulting identity. `wire up` passes None (there is no name to
1746    // type); an explicit `wire init <handle>` passes Some and we surface the
1747    // "ignored in favor of persona" notice for transparency.
1748    if let Some(h) = handle
1749        && !h
1750            .chars()
1751            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1752    {
1753        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
1754    }
1755    if config::is_initialized()? {
1756        bail!(
1757            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1758            config::config_dir()?
1759        );
1760    }
1761    // v0.9.1 smart-default reachability. If the operator passed neither
1762    // --relay nor --offline, probe the conventional local relay at
1763    // http://127.0.0.1:8771 and auto-attach if healthy. Closes the
1764    // silent-slotless footgun WITHOUT the v0.9 rejection wall, which
1765    // forced operators through a three-flag decision tree on first
1766    // invocation. Bare `wire init <handle>` is now ergonomic again
1767    // whenever a local relay is running (the common dev setup).
1768    //
1769    // Probe order:
1770    //   1. --relay <url>          → use it
1771    //   2. --offline               → skip slot allocation (rare power-user)
1772    //   3. local relay reachable  → auto-attach + log to stderr
1773    //   4. otherwise               → bail with actionable options
1774    let mut resolved_relay: Option<String> = relay.map(str::to_string);
1775    if resolved_relay.is_none() && !offline {
1776        let default_local = "http://127.0.0.1:8771";
1777        let client = crate::relay_client::RelayClient::new(default_local);
1778        if client.check_healthz().is_ok() {
1779            eprintln!(
1780                "wire init: local relay at {default_local} reachable — auto-attaching. \
1781                 Use --relay <url> to pick a different relay, --offline to skip."
1782            );
1783            resolved_relay = Some(default_local.to_string());
1784        } else {
1785            // v0.9.5: interactive prompt for first-time operators
1786            // when the smart-default can't auto-attach. Detect TTY on
1787            // stdin AND stderr — only prompt for humans. CI / agents
1788            // / non-interactive shells fall through to the explicit
1789            // error wall (unchanged behavior since v0.9.1).
1790            use std::io::{BufRead, IsTerminal, Write};
1791            let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1792            if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1793                eprintln!("wire init: no local relay reachable at {default_local}.");
1794                eprint!(
1795                    "  Bind to public federation relay https://wireup.net instead? \
1796                     [Y/n/offline/url]: "
1797                );
1798                let _ = std::io::stderr().flush();
1799                let mut input = String::new();
1800                let _ = std::io::stdin().lock().read_line(&mut input);
1801                let answer = input.trim();
1802                match answer {
1803                    "" | "y" | "Y" | "yes" | "YES" => {
1804                        eprintln!("wire init: binding to https://wireup.net");
1805                        resolved_relay = Some("https://wireup.net".to_string());
1806                    }
1807                    "n" | "N" | "no" | "NO" => {
1808                        bail!(
1809                            "wire init: declined federation default; re-run with --relay <url> or --offline."
1810                        );
1811                    }
1812                    "offline" | "OFFLINE" => {
1813                        eprintln!(
1814                            "wire init: proceeding offline. \
1815                             Run `wire bind-relay <url>` before pairing."
1816                        );
1817                        // Fall through with resolved_relay still None;
1818                        // the `offline` flag is conceptually set but
1819                        // the caller's local doesn't need updating —
1820                        // resolved_relay = None + offline behavior
1821                        // is identical for the rest of cmd_init.
1822                    }
1823                    url if url.starts_with("http://") || url.starts_with("https://") => {
1824                        eprintln!("wire init: binding to {url}");
1825                        resolved_relay = Some(url.to_string());
1826                    }
1827                    other => {
1828                        bail!(
1829                            "wire init: unrecognized answer `{other}` — \
1830                             expected Y/n/offline/<url>. Re-run with --relay or --offline."
1831                        );
1832                    }
1833                }
1834            } else {
1835                bail!(
1836                    "wire init: no relay specified and no local relay reachable at \
1837                     http://127.0.0.1:8771.\n\
1838                     Pick one (or just run `wire up`):\n\
1839                     • `wire service install --local-relay` — start the local relay, then re-run\n\
1840                     • `wire up @wireup.net` — bind to public federation in one command\n\
1841                     • `wire init --offline` — generate keypair only \
1842                     (peers cannot reach you until you `wire bind-relay <url>` later)"
1843                );
1844            }
1845        }
1846    }
1847    let relay = resolved_relay.as_deref();
1848
1849    config::ensure_dirs()?;
1850    let (sk_seed, pk_bytes) = generate_keypair();
1851    config::write_private_key(&sk_seed)?;
1852
1853    // v0.11 ONE-NAME: derive the character nickname from a synthetic DID
1854    // using the freshly-generated pubkey, then USE THE CHARACTER as the
1855    // canonical handle. The operator-typed `handle` arg becomes either:
1856    //   - identical to character (already-canonical input — no-op), OR
1857    //   - overridden in favor of character (operator-typed name was a
1858    //     vanity layer that would never have been federation-reachable).
1859    // Either way, agent-card.handle ends up == character, and every
1860    // downstream surface (relay phonebook, .well-known, dial/send) keys
1861    // on the same name an operator sees in their statusline.
1862    //
1863    // Per the v0.11 directive: "If you can't call someone via a name,
1864    // don't let them have it as a name." Operator-typed handles violated
1865    // that rule because the character was the displayed name but the
1866    // handle was the addressable one. Now they're the same string.
1867    // The seed string only fills the (immediately-discarded) handle portion
1868    // of a synthetic DID; the persona derives from the fp suffix regardless,
1869    // so any seed yields the same identity.
1870    let seed = handle.unwrap_or("agent");
1871    let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
1872    let character = crate::character::Character::from_did(&synth_did);
1873    let canonical_handle: &str = &character.nickname;
1874    if let Some(typed) = handle
1875        && typed != canonical_handle
1876    {
1877        eprintln!(
1878            "wire init: one-name rule — typed `{typed}` ignored in favor of \
1879             DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
1880        );
1881    }
1882
1883    let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
1884    let signed = sign_agent_card(&card, &sk_seed);
1885    config::write_agent_card(&signed)?;
1886
1887    let mut trust = empty_trust();
1888    add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
1889    config::write_trust(&trust)?;
1890
1891    let fp = fingerprint(&pk_bytes);
1892    let key_id = make_key_id(canonical_handle, &pk_bytes);
1893    // Rebind `handle` for the rest of cmd_init so downstream prints,
1894    // relay-state writes, etc. all reference the canonical name.
1895    let handle = canonical_handle;
1896
1897    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
1898    let mut relay_info: Option<(String, String)> = None;
1899    if let Some(url) = relay {
1900        let normalized = url.trim_end_matches('/');
1901        let client = crate::relay_client::RelayClient::new(normalized);
1902        client.check_healthz()?;
1903        let alloc = client.allocate_slot(Some(handle))?;
1904        let mut state = config::read_relay_state()?;
1905        state["self"] = json!({
1906            "relay_url": normalized,
1907            "slot_id": alloc.slot_id.clone(),
1908            "slot_token": alloc.slot_token,
1909        });
1910        config::write_relay_state(&state)?;
1911        relay_info = Some((normalized.to_string(), alloc.slot_id));
1912    }
1913
1914    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1915    if as_json {
1916        let mut out = json!({
1917            "did": did_str.clone(),
1918            "fingerprint": fp,
1919            "key_id": key_id,
1920            "config_dir": config::config_dir()?.to_string_lossy(),
1921        });
1922        if let Some((url, slot_id)) = &relay_info {
1923            out["relay_url"] = json!(url);
1924            out["slot_id"] = json!(slot_id);
1925        }
1926        println!("{}", serde_json::to_string(&out)?);
1927    } else {
1928        println!("generated {did_str} (ed25519:{key_id})");
1929        println!(
1930            "config written to {}",
1931            config::config_dir()?.to_string_lossy()
1932        );
1933        if let Some((url, slot_id)) = &relay_info {
1934            println!("bound to relay {url} (slot {slot_id})");
1935            println!();
1936            println!(
1937                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1938            );
1939        } else {
1940            println!();
1941            println!(
1942                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1943            );
1944        }
1945    }
1946    Ok(())
1947}
1948
1949// ---------- status ----------
1950
1951fn cmd_status(as_json: bool) -> Result<()> {
1952    let initialized = config::is_initialized()?;
1953
1954    let mut summary = json!({
1955        "initialized": initialized,
1956    });
1957
1958    if initialized {
1959        let card = config::read_agent_card()?;
1960        let did = card
1961            .get("did")
1962            .and_then(Value::as_str)
1963            .unwrap_or("")
1964            .to_string();
1965        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
1966        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
1967        // legacy cards.
1968        let handle = card
1969            .get("handle")
1970            .and_then(Value::as_str)
1971            .map(str::to_string)
1972            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1973        let pk_b64 = card
1974            .get("verify_keys")
1975            .and_then(Value::as_object)
1976            .and_then(|m| m.values().next())
1977            .and_then(|v| v.get("key"))
1978            .and_then(Value::as_str)
1979            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1980        let pk_bytes = crate::signing::b64decode(pk_b64)?;
1981        summary["did"] = json!(did);
1982        summary["handle"] = json!(handle);
1983        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1984        summary["capabilities"] = card
1985            .get("capabilities")
1986            .cloned()
1987            .unwrap_or_else(|| json!([]));
1988
1989        let trust = config::read_trust()?;
1990        let relay_state_for_tier =
1991            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1992        let mut peers = Vec::new();
1993        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1994            for (peer_handle, _agent) in agents {
1995                if peer_handle == &handle {
1996                    continue; // self
1997                }
1998                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
1999                // for peers we've pinned but never received a pair_drop_ack
2000                // from, so the operator sees the "we can't send to them yet"
2001                // state instead of seeing a misleading VERIFIED.
2002                peers.push(json!({
2003                    "handle": peer_handle,
2004                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2005                }));
2006            }
2007        }
2008        summary["peers"] = json!(peers);
2009
2010        let relay_state = config::read_relay_state()?;
2011        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2012        if !summary["self_relay"].is_null() {
2013            // Hide slot_token from default view.
2014            if let Some(obj) = summary["self_relay"].as_object_mut() {
2015                obj.remove("slot_token");
2016            }
2017        }
2018        summary["peer_slots_count"] = json!(
2019            relay_state
2020                .get("peers")
2021                .and_then(Value::as_object)
2022                .map(|m| m.len())
2023                .unwrap_or(0)
2024        );
2025
2026        // Outbox / inbox queue depth (file count + total events)
2027        let outbox = config::outbox_dir()?;
2028        let inbox = config::inbox_dir()?;
2029        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2030        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2031
2032        // v0.5.19: liveness snapshot through a single helper so this
2033        // surface and `wire doctor` agree by construction. Issue #2:
2034        // doctor PASSed while status said DOWN for 25 min because each
2035        // computed liveness independently. ensure_up::daemon_liveness
2036        // is the only path now.
2037        let snap = crate::ensure_up::daemon_liveness();
2038        let mut daemon = json!({
2039            "running": snap.pidfile_alive,
2040            "pid": snap.pidfile_pid,
2041            "all_running_pids": snap.pgrep_pids,
2042            "orphans": snap.orphan_pids,
2043        });
2044        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2045            daemon["version"] = json!(d.version);
2046            daemon["bin_path"] = json!(d.bin_path);
2047            daemon["did"] = json!(d.did);
2048            daemon["relay_url"] = json!(d.relay_url);
2049            daemon["started_at"] = json!(d.started_at);
2050            daemon["schema"] = json!(d.schema);
2051            if d.version != env!("CARGO_PKG_VERSION") {
2052                daemon["version_mismatch"] = json!({
2053                    "daemon": d.version.clone(),
2054                    "cli": env!("CARGO_PKG_VERSION"),
2055                });
2056            }
2057        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2058            daemon["pidfile_form"] = json!("legacy-int");
2059            daemon["version_mismatch"] = json!({
2060                "daemon": "<pre-0.5.11>",
2061                "cli": env!("CARGO_PKG_VERSION"),
2062            });
2063        }
2064        summary["daemon"] = daemon;
2065
2066        // Pending pair sessions — counts by status.
2067        let pending = crate::pending_pair::list_pending().unwrap_or_default();
2068        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2069        for p in &pending {
2070            *counts.entry(p.status.clone()).or_default() += 1;
2071        }
2072        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
2073        let pending_inbound =
2074            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2075        let inbound_handles: Vec<&str> = pending_inbound
2076            .iter()
2077            .map(|p| p.peer_handle.as_str())
2078            .collect();
2079        summary["pending_pairs"] = json!({
2080            "total": pending.len(),
2081            "by_status": counts,
2082            "inbound_count": pending_inbound.len(),
2083            "inbound_handles": inbound_handles,
2084        });
2085    }
2086
2087    if as_json {
2088        println!("{}", serde_json::to_string(&summary)?);
2089    } else if !initialized {
2090        println!("not initialized — run `wire init <handle>` first");
2091    } else {
2092        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
2093        println!(
2094            "fingerprint:   {}",
2095            summary["fingerprint"].as_str().unwrap_or("?")
2096        );
2097        println!("capabilities:  {}", summary["capabilities"]);
2098        if !summary["self_relay"].is_null() {
2099            println!(
2100                "self relay:    {} (slot {})",
2101                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2102                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2103            );
2104        } else {
2105            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
2106        }
2107        println!(
2108            "peers:         {}",
2109            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2110        );
2111        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2112            println!(
2113                "  - {:<20} tier={}",
2114                p["handle"].as_str().unwrap_or(""),
2115                p["tier"].as_str().unwrap_or("?")
2116            );
2117        }
2118        println!(
2119            "outbox:        {} file(s), {} event(s) queued",
2120            summary["outbox"]["files"].as_u64().unwrap_or(0),
2121            summary["outbox"]["events"].as_u64().unwrap_or(0)
2122        );
2123        println!(
2124            "inbox:         {} file(s), {} event(s) received",
2125            summary["inbox"]["files"].as_u64().unwrap_or(0),
2126            summary["inbox"]["events"].as_u64().unwrap_or(0)
2127        );
2128        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2129        let daemon_pid = summary["daemon"]["pid"]
2130            .as_u64()
2131            .map(|p| p.to_string())
2132            .unwrap_or_else(|| "—".to_string());
2133        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2134        let version_suffix = if !daemon_version.is_empty() {
2135            format!(" v{daemon_version}")
2136        } else {
2137            String::new()
2138        };
2139        println!(
2140            "daemon:        {} (pid {}{})",
2141            if daemon_running { "running" } else { "DOWN" },
2142            daemon_pid,
2143            version_suffix,
2144        );
2145        // P1.7: surface version mismatch + orphan procs loudly.
2146        if let Some(mm) = summary["daemon"].get("version_mismatch") {
2147            println!(
2148                "               !! version mismatch: daemon={} CLI={}. \
2149                 run `wire upgrade` to swap atomically.",
2150                mm["daemon"].as_str().unwrap_or("?"),
2151                mm["cli"].as_str().unwrap_or("?"),
2152            );
2153        }
2154        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2155            && !orphans.is_empty()
2156        {
2157            let pids: Vec<String> = orphans
2158                .iter()
2159                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2160                .collect();
2161            println!(
2162                "               !! orphan daemon process(es): pids {}. \
2163                 pgrep saw them but pidfile didn't — likely stale process from \
2164                 prior install. Multiple daemons race the relay cursor.",
2165                pids.join(", ")
2166            );
2167        }
2168        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2169        let inbound_count = summary["pending_pairs"]["inbound_count"]
2170            .as_u64()
2171            .unwrap_or(0);
2172        if pending_total > 0 {
2173            print!("pending pairs: {pending_total}");
2174            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2175                let parts: Vec<String> = obj
2176                    .iter()
2177                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2178                    .collect();
2179                if !parts.is_empty() {
2180                    print!(" ({})", parts.join(", "));
2181                }
2182            }
2183            println!();
2184        } else if inbound_count == 0 {
2185            println!("pending pairs: none");
2186        }
2187        // v0.5.14: separate line for pending-inbound zero-paste requests.
2188        // Loud because each one is awaiting an operator gesture and the
2189        // capability hasn't flowed yet.
2190        if inbound_count > 0 {
2191            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2192                .as_array()
2193                .map(|a| {
2194                    a.iter()
2195                        .filter_map(|v| v.as_str().map(str::to_string))
2196                        .collect()
2197                })
2198                .unwrap_or_default();
2199            println!(
2200                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2201                handles.join(", "),
2202            );
2203        }
2204    }
2205    Ok(())
2206}
2207
2208fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2209    if !dir.exists() {
2210        return Ok(json!({"files": 0, "events": 0}));
2211    }
2212    let mut files = 0usize;
2213    let mut events = 0usize;
2214    for entry in std::fs::read_dir(dir)? {
2215        let path = entry?.path();
2216        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2217            files += 1;
2218            if let Ok(body) = std::fs::read_to_string(&path) {
2219                events += body.lines().filter(|l| !l.trim().is_empty()).count();
2220            }
2221        }
2222    }
2223    Ok(json!({"files": files, "events": events}))
2224}
2225
2226// ---------- responder health ----------
2227
2228fn responder_status_allowed(status: &str) -> bool {
2229    matches!(
2230        status,
2231        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2232    )
2233}
2234
2235fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2236    let state = config::read_relay_state()?;
2237    let (label, slot_info) = match peer {
2238        Some(peer) => (
2239            peer.to_string(),
2240            state
2241                .get("peers")
2242                .and_then(|p| p.get(peer))
2243                .ok_or_else(|| {
2244                    anyhow!(
2245                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
2246                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
2247                         (`wire peers` lists who you've already paired with.)"
2248                    )
2249                })?,
2250        ),
2251        None => (
2252            "self".to_string(),
2253            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2254                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2255            })?,
2256        ),
2257    };
2258    let relay_url = slot_info["relay_url"]
2259        .as_str()
2260        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2261        .to_string();
2262    let slot_id = slot_info["slot_id"]
2263        .as_str()
2264        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2265        .to_string();
2266    let slot_token = slot_info["slot_token"]
2267        .as_str()
2268        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2269        .to_string();
2270    Ok((label, relay_url, slot_id, slot_token))
2271}
2272
2273fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2274    if !responder_status_allowed(status) {
2275        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2276    }
2277    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2278    let now = time::OffsetDateTime::now_utc()
2279        .format(&time::format_description::well_known::Rfc3339)
2280        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2281    let mut record = json!({
2282        "status": status,
2283        "set_at": now,
2284    });
2285    if let Some(reason) = reason {
2286        record["reason"] = json!(reason);
2287    }
2288    if status == "online" {
2289        record["last_success_at"] = json!(now);
2290    }
2291    let client = crate::relay_client::RelayClient::new(&relay_url);
2292    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2293    if as_json {
2294        println!("{}", serde_json::to_string(&saved)?);
2295    } else {
2296        let reason = saved
2297            .get("reason")
2298            .and_then(Value::as_str)
2299            .map(|r| format!(" — {r}"))
2300            .unwrap_or_default();
2301        println!(
2302            "responder {}{}",
2303            saved
2304                .get("status")
2305                .and_then(Value::as_str)
2306                .unwrap_or(status),
2307            reason
2308        );
2309    }
2310    Ok(())
2311}
2312
2313fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2314    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2315    let client = crate::relay_client::RelayClient::new(&relay_url);
2316    let health = client.responder_health_get(&slot_id, &slot_token)?;
2317    if as_json {
2318        println!(
2319            "{}",
2320            serde_json::to_string(&json!({
2321                "target": label,
2322                "responder_health": health,
2323            }))?
2324        );
2325    } else if health.is_null() {
2326        println!("{label}: responder health not reported");
2327    } else {
2328        let status = health
2329            .get("status")
2330            .and_then(Value::as_str)
2331            .unwrap_or("unknown");
2332        let reason = health
2333            .get("reason")
2334            .and_then(Value::as_str)
2335            .map(|r| format!(" — {r}"))
2336            .unwrap_or_default();
2337        let last_success = health
2338            .get("last_success_at")
2339            .and_then(Value::as_str)
2340            .map(|t| format!(" (last_success: {t})"))
2341            .unwrap_or_default();
2342        println!("{label}: {status}{reason}{last_success}");
2343    }
2344    Ok(())
2345}
2346
2347fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2348    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2349    let client = crate::relay_client::RelayClient::new(&relay_url);
2350
2351    let started = std::time::Instant::now();
2352    let transport_ok = client.healthz().unwrap_or(false);
2353    let latency_ms = started.elapsed().as_millis() as u64;
2354
2355    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2356    let now = std::time::SystemTime::now()
2357        .duration_since(std::time::UNIX_EPOCH)
2358        .map(|d| d.as_secs())
2359        .unwrap_or(0);
2360    let attention = match last_pull_at_unix {
2361        Some(last) if now.saturating_sub(last) <= 300 => json!({
2362            "status": "ok",
2363            "last_pull_at_unix": last,
2364            "age_seconds": now.saturating_sub(last),
2365            "event_count": event_count,
2366        }),
2367        Some(last) => json!({
2368            "status": "stale",
2369            "last_pull_at_unix": last,
2370            "age_seconds": now.saturating_sub(last),
2371            "event_count": event_count,
2372        }),
2373        None => json!({
2374            "status": "never_pulled",
2375            "last_pull_at_unix": Value::Null,
2376            "event_count": event_count,
2377        }),
2378    };
2379
2380    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2381    let responder = if responder_health.is_null() {
2382        json!({"status": "not_reported", "record": Value::Null})
2383    } else {
2384        json!({
2385            "status": responder_health
2386                .get("status")
2387                .and_then(Value::as_str)
2388                .unwrap_or("unknown"),
2389            "record": responder_health,
2390        })
2391    };
2392
2393    let report = json!({
2394        "peer": peer,
2395        "transport": {
2396            "status": if transport_ok { "ok" } else { "error" },
2397            "relay_url": relay_url,
2398            "latency_ms": latency_ms,
2399        },
2400        "attention": attention,
2401        "responder": responder,
2402    });
2403
2404    if as_json {
2405        println!("{}", serde_json::to_string(&report)?);
2406    } else {
2407        let transport_line = if transport_ok {
2408            format!("ok relay reachable ({latency_ms}ms)")
2409        } else {
2410            "error relay unreachable".to_string()
2411        };
2412        println!("transport      {transport_line}");
2413        match report["attention"]["status"].as_str().unwrap_or("unknown") {
2414            "ok" => println!(
2415                "attention      ok last pull {}s ago",
2416                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2417            ),
2418            "stale" => println!(
2419                "attention      stale last pull {}m ago",
2420                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2421            ),
2422            "never_pulled" => println!("attention      never pulled since relay reset"),
2423            other => println!("attention      {other}"),
2424        }
2425        if report["responder"]["status"] == "not_reported" {
2426            println!("auto-responder not reported");
2427        } else {
2428            let record = &report["responder"]["record"];
2429            let status = record
2430                .get("status")
2431                .and_then(Value::as_str)
2432                .unwrap_or("unknown");
2433            let reason = record
2434                .get("reason")
2435                .and_then(Value::as_str)
2436                .map(|r| format!(" — {r}"))
2437                .unwrap_or_default();
2438            println!("auto-responder {status}{reason}");
2439        }
2440    }
2441    Ok(())
2442}
2443
2444// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
2445
2446// ---------- whoami ----------
2447
2448/// Return the current cwd with the user's home dir abbreviated to `~/`.
2449/// Used in whoami `--short` / `--colored` output so multi-window operators
2450/// see *what project* each Claude is working in alongside the character.
2451fn current_cwd_display() -> String {
2452    let cwd = match std::env::current_dir() {
2453        Ok(c) => c,
2454        Err(_) => return String::from("?"),
2455    };
2456    if let Some(home) = dirs::home_dir()
2457        && let Ok(rel) = cwd.strip_prefix(&home)
2458    {
2459        // strip_prefix returns "" for cwd == home itself; show "~" then.
2460        let rel_str = rel.to_string_lossy();
2461        if rel_str.is_empty() {
2462            return String::from("~");
2463        }
2464        return format!("~/{}", rel_str);
2465    }
2466    cwd.to_string_lossy().into_owned()
2467}
2468
2469fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2470    if !config::is_initialized()? {
2471        bail!("not initialized — run `wire init <handle>` first");
2472    }
2473    let card = config::read_agent_card()?;
2474    let did = card
2475        .get("did")
2476        .and_then(Value::as_str)
2477        .unwrap_or("")
2478        .to_string();
2479    let handle = card
2480        .get("handle")
2481        .and_then(Value::as_str)
2482        .map(str::to_string)
2483        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2484    // v0.11: character is purely DID-derived. No overrides — the
2485    // operator-rename verb is gone and display.json reads are stripped
2486    // because they introduced a second name that peers couldn't find.
2487    let character = crate::character::Character::from_did(&did);
2488
2489    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
2490    // so operators tab-flipping between multiple Claude windows see both
2491    // *who* this session is (character) and *what* it's working on (cwd).
2492    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
2493    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
2494    let cwd_display = current_cwd_display();
2495
2496    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
2497    // beyond did — these calls are hot (statusline polls ~300ms).
2498    if short {
2499        println!("{} · {}", character.short(), cwd_display);
2500        return Ok(());
2501    }
2502    if colored {
2503        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2504        return Ok(());
2505    }
2506
2507    let pk_b64 = card
2508        .get("verify_keys")
2509        .and_then(Value::as_object)
2510        .and_then(|m| m.values().next())
2511        .and_then(|v| v.get("key"))
2512        .and_then(Value::as_str)
2513        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2514    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2515    let fp = fingerprint(&pk_bytes);
2516    let key_id = make_key_id(&handle, &pk_bytes);
2517    let capabilities = card
2518        .get("capabilities")
2519        .cloned()
2520        .unwrap_or_else(|| json!(["wire/v3.1"]));
2521
2522    if as_json {
2523        // v0.11: character_override is always false now (no rename verb,
2524        // no display.json reads). Field stays for back-compat with v0.10
2525        // JSON consumers that key off it.
2526        let has_override = false;
2527        println!(
2528            "{}",
2529            serde_json::to_string(&json!({
2530                "did": did,
2531                "handle": handle,
2532                "fingerprint": fp,
2533                "key_id": key_id,
2534                "public_key_b64": pk_b64,
2535                "capabilities": capabilities,
2536                "config_dir": config::config_dir()?.to_string_lossy(),
2537                "persona": character,
2538                "persona_override": has_override,
2539            }))?
2540        );
2541    } else {
2542        println!("{}", character.colored());
2543        println!("{did} (ed25519:{key_id})");
2544        println!("fingerprint: {fp}");
2545        println!("capabilities: {capabilities}");
2546    }
2547    Ok(())
2548}
2549
2550// ---------- identity (v0.7.0-alpha.3) ----------
2551
2552fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2553    match cmd {
2554        // v0.11: IdentityCommand::Rename deleted. The character is the
2555        // one canonical name (DID-derived); a local-display rename
2556        // would create a second name peers can't find, violating the
2557        // "names must be findable" invariant. Aliases (if needed
2558        // later) become relay-claimed entries that ARE findable —
2559        // a different architectural shape from rename.
2560        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2561        IdentityCommand::List { json } => cmd_session_list(json),
2562        IdentityCommand::Publish {
2563            nick,
2564            relay,
2565            public_url,
2566            hidden,
2567            json,
2568        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2569        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2570        IdentityCommand::Create {
2571            name,
2572            anonymous,
2573            local: _,
2574            json,
2575        } => cmd_identity_create(name.as_deref(), anonymous, json),
2576        IdentityCommand::Persist {
2577            name,
2578            as_name,
2579            json,
2580        } => cmd_identity_persist(&name, as_name.as_deref(), json),
2581        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2582    }
2583}
2584
2585/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
2586/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
2587/// paste into their shell; the identity lives there until reboot
2588/// clears /tmp. Persist promotes it to the real sessions root.
2589fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2590    if anonymous {
2591        // Generate a unique tmpdir for this anonymous identity.
2592        let rand_suffix = format!("{:08x}", rand::random::<u32>());
2593        let anon_name = name
2594            .map(crate::session::sanitize_name)
2595            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2596        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2597        std::fs::create_dir_all(&anon_root)
2598            .with_context(|| format!("creating anon root {anon_root:?}"))?;
2599        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
2600        let session_home = anon_root.join("sessions").join(&anon_name);
2601        std::fs::create_dir_all(&session_home)?;
2602        let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2603        if !status.success() {
2604            bail!("anonymous identity init failed: {status}");
2605        }
2606        // Register the anonymous name in a SIDE registry so persist
2607        // can find it later. Stored at <anon_root>/anon-marker.json.
2608        let marker = anon_root.join("anon-marker.json");
2609        std::fs::write(
2610            &marker,
2611            serde_json::to_vec_pretty(&serde_json::json!({
2612                "name": anon_name,
2613                "session_home": session_home.to_string_lossy(),
2614                "created_at": time::OffsetDateTime::now_utc()
2615                    .format(&time::format_description::well_known::Rfc3339)
2616                    .unwrap_or_default(),
2617                "kind": "anonymous",
2618            }))?,
2619        )?;
2620        let card = serde_json::from_slice::<Value>(&std::fs::read(
2621            session_home
2622                .join("config")
2623                .join("wire")
2624                .join("agent-card.json"),
2625        )?)?;
2626        let did = card
2627            .get("did")
2628            .and_then(Value::as_str)
2629            .unwrap_or("")
2630            .to_string();
2631        if as_json {
2632            println!(
2633                "{}",
2634                serde_json::to_string(&json!({
2635                    "kind": "anonymous",
2636                    "name": anon_name,
2637                    "did": did,
2638                    "session_home": session_home.to_string_lossy(),
2639                    "anon_root": anon_root.to_string_lossy(),
2640                }))?
2641            );
2642        } else {
2643            println!("created anonymous identity `{anon_name}` ({did})");
2644            println!(
2645                "  session_home: {} (dies on reboot — /tmp)",
2646                session_home.display()
2647            );
2648            println!();
2649            println!("activate in this shell:");
2650            println!("  export WIRE_HOME={}", session_home.display());
2651            println!();
2652            println!("promote to persistent later with:");
2653            println!("  wire identity persist {anon_name}");
2654        }
2655        return Ok(());
2656    }
2657    // --local (or default): delegate to existing session new flow.
2658    let name_arg = name.map(|s| s.to_string());
2659    cmd_session_new(
2660        name_arg.as_deref(),
2661        "https://wireup.net",
2662        false,
2663        "http://127.0.0.1:8771",
2664        false,
2665        None,
2666        false,
2667        None,
2668        true, // no_daemon: identity create just allocates the identity, no daemon
2669        true, // local_only: explicit lifecycle
2670        as_json,
2671    )
2672}
2673
2674/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
2675/// tmpdir to the persistent sessions root + registers in the cwd map.
2676fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2677    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
2678    let temp = std::env::temp_dir();
2679    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2680    for entry in std::fs::read_dir(&temp)?.flatten() {
2681        let path = entry.path();
2682        if !path
2683            .file_name()
2684            .and_then(|s| s.to_str())
2685            .map(|s| s.starts_with("wire-anon-"))
2686            .unwrap_or(false)
2687        {
2688            continue;
2689        }
2690        let marker = path.join("anon-marker.json");
2691        if let Ok(bytes) = std::fs::read(&marker)
2692            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2693            && json.get("name").and_then(Value::as_str) == Some(name)
2694        {
2695            let session_home = json
2696                .get("session_home")
2697                .and_then(Value::as_str)
2698                .map(std::path::PathBuf::from)
2699                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2700            found = Some((path, session_home));
2701            break;
2702        }
2703    }
2704    let (anon_root, anon_session_home) = found.ok_or_else(|| {
2705        anyhow!(
2706            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2707             run `wire identity list` to see available identities"
2708        )
2709    })?;
2710
2711    let new_name = as_name.unwrap_or(name);
2712    let new_session_home = crate::session::session_dir(new_name)?;
2713    if new_session_home.exists() {
2714        bail!(
2715            "target session `{new_name}` already exists at {new_session_home:?} — \
2716             pick a different name with --as <new-name>"
2717        );
2718    }
2719
2720    // Move the session dir from tmpdir to persistent root.
2721    if let Some(parent) = new_session_home.parent() {
2722        std::fs::create_dir_all(parent)?;
2723    }
2724    std::fs::rename(&anon_session_home, &new_session_home)
2725        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2726
2727    // Clean up the (now-empty) anon root + marker.
2728    let _ = std::fs::remove_dir_all(&anon_root);
2729
2730    // Register cwd → new_name (operator may have cd'd elsewhere; use the
2731    // session_home's grandparent as the conceptual "cwd" if no other).
2732    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2733    let cwd_key = cwd.to_string_lossy().into_owned();
2734    let new_name_for_reg = new_name.to_string();
2735    if let Err(e) = crate::session::update_registry(|reg| {
2736        reg.by_cwd.insert(cwd_key, new_name_for_reg);
2737        Ok(())
2738    }) {
2739        eprintln!("wire identity persist: failed to update registry: {e:#}");
2740    }
2741
2742    if as_json {
2743        println!(
2744            "{}",
2745            serde_json::to_string(&json!({
2746                "kind": "persisted",
2747                "from_name": name,
2748                "to_name": new_name,
2749                "session_home": new_session_home.to_string_lossy(),
2750            }))?
2751        );
2752    } else {
2753        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2754        println!(
2755            "  session_home: {} (survives reboot)",
2756            new_session_home.display()
2757        );
2758        println!("  registered cwd: {}", cwd.display());
2759    }
2760    Ok(())
2761}
2762
2763/// v0.7.0-alpha.20: demote federation → local. Removes the federation
2764/// slot binding from relay.json (and the legacy top-level fields). Keeps
2765/// the keypair + agent-card so re-publish later just calls `wire identity
2766/// publish` again. local → anonymous is NOT supported; destroy + recreate
2767/// is the safer path for that step-down.
2768fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2769    let sessions = crate::session::list_sessions()?;
2770    let session = sessions
2771        .iter()
2772        .find(|s| s.name == name)
2773        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2774    let relay_state_path = session
2775        .home_dir
2776        .join("config")
2777        .join("wire")
2778        .join("relay.json");
2779    if !relay_state_path.exists() {
2780        bail!("session `{name}` has no relay state — already demoted?");
2781    }
2782    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2783    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2784    let had_fed = self_obj
2785        .get("relay_url")
2786        .and_then(Value::as_str)
2787        .map(|u| {
2788            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2789        })
2790        .unwrap_or(false);
2791    if !had_fed {
2792        if as_json {
2793            println!(
2794                "{}",
2795                serde_json::to_string(
2796                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2797                )?
2798            );
2799        } else {
2800            println!("session `{name}` has no federation slot — nothing to demote");
2801        }
2802        return Ok(());
2803    }
2804    // Strip federation: remove top-level relay_url/slot_id/slot_token,
2805    // remove federation-scope entries from endpoints[].
2806    if let Some(self_mut) = state
2807        .as_object_mut()
2808        .and_then(|m| m.get_mut("self"))
2809        .and_then(|s| s.as_object_mut())
2810    {
2811        self_mut.remove("relay_url");
2812        self_mut.remove("slot_id");
2813        self_mut.remove("slot_token");
2814        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2815            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2816        }
2817    }
2818    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2819
2820    if as_json {
2821        println!(
2822            "{}",
2823            serde_json::to_string(
2824                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2825            )?
2826        );
2827    } else {
2828        println!("demoted `{name}` from federation → local");
2829        println!("  relay slot binding removed; keypair + agent-card retained");
2830        println!("  re-publish with `wire identity publish <nick>`");
2831    }
2832    Ok(())
2833}
2834
2835fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2836    let raw = crate::trust::get_tier(trust, handle);
2837    if raw != "VERIFIED" {
2838        return raw.to_string();
2839    }
2840    let token = relay_state
2841        .get("peers")
2842        .and_then(|p| p.get(handle))
2843        .and_then(|p| p.get("slot_token"))
2844        .and_then(Value::as_str)
2845        .unwrap_or("");
2846    if token.is_empty() {
2847        "PENDING_ACK".to_string()
2848    } else {
2849        raw.to_string()
2850    }
2851}
2852
2853fn cmd_peers(as_json: bool) -> Result<()> {
2854    let trust = config::read_trust()?;
2855    let agents = trust
2856        .get("agents")
2857        .and_then(Value::as_object)
2858        .cloned()
2859        .unwrap_or_default();
2860    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2861
2862    let mut self_did: Option<String> = None;
2863    if let Ok(card) = config::read_agent_card() {
2864        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2865    }
2866
2867    let mut peers = Vec::new();
2868    for (handle, agent) in agents.iter() {
2869        let did = agent
2870            .get("did")
2871            .and_then(Value::as_str)
2872            .unwrap_or("")
2873            .to_string();
2874        if Some(did.as_str()) == self_did.as_deref() {
2875            continue; // skip self-attestation
2876        }
2877        let tier = effective_peer_tier(&trust, &relay_state, handle);
2878        let capabilities = agent
2879            .get("card")
2880            .and_then(|c| c.get("capabilities"))
2881            .cloned()
2882            .unwrap_or_else(|| json!([]));
2883        // v0.7.0-alpha.6: prefer peer's published character override
2884        // (display.nickname / display.emoji on their pinned agent-card).
2885        // Falls back to auto-derived if peer hasn't renamed themselves
2886        // OR runs an older wire that doesn't publish the field.
2887        let character = if did.is_empty() {
2888            None
2889        } else {
2890            let card_obj = agent.get("card");
2891            Some(match card_obj {
2892                Some(card) => crate::character::Character::from_card(card),
2893                None => crate::character::Character::from_did(&did),
2894            })
2895        };
2896        peers.push(json!({
2897            "handle": handle,
2898            "did": did,
2899            "tier": tier,
2900            "capabilities": capabilities,
2901            "persona": character,
2902        }));
2903    }
2904
2905    if as_json {
2906        println!("{}", serde_json::to_string(&peers)?);
2907    } else if peers.is_empty() {
2908        println!("no peers pinned (run `wire join <code>` to pair)");
2909    } else {
2910        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
2911        // computed above (from peer's agent-card, honoring override) so
2912        // text and JSON output never diverge. Pre-alpha.8 the text loop
2913        // recomputed via Character::from_did (no override) — operators
2914        // saw different identities depending on --json flag.
2915        for p in &peers {
2916            let char_json = &p["persona"];
2917            let (colored_char, plain_len): (String, usize) = match char_json {
2918                serde_json::Value::Null => ("?".to_string(), 1),
2919                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2920                    Ok(c) => {
2921                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
2922                        (c.colored(), plain)
2923                    }
2924                    Err(_) => ("?".to_string(), 1),
2925                },
2926            };
2927            let pad = 22usize.saturating_sub(plain_len);
2928            println!(
2929                "{}{}  {:<20} {:<10} {}",
2930                colored_char,
2931                " ".repeat(pad),
2932                p["handle"].as_str().unwrap_or(""),
2933                p["tier"].as_str().unwrap_or(""),
2934                p["did"].as_str().unwrap_or(""),
2935            );
2936        }
2937    }
2938    Ok(())
2939}
2940
2941// ---------- send ----------
2942
2943/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
2944///
2945/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
2946/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
2947/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
2948/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
2949/// hasn't crossed two heartbeats means probably degraded.
2950fn maybe_warn_peer_attentiveness(peer: &str) {
2951    let state = match config::read_relay_state() {
2952        Ok(s) => s,
2953        Err(_) => return,
2954    };
2955    let p = state.get("peers").and_then(|p| p.get(peer));
2956    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2957        Some(s) if !s.is_empty() => s,
2958        _ => return,
2959    };
2960    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2961        Some(s) if !s.is_empty() => s,
2962        _ => return,
2963    };
2964    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
2965        Some(s) if !s.is_empty() => s.to_string(),
2966        _ => match state
2967            .get("self")
2968            .and_then(|s| s.get("relay_url"))
2969            .and_then(Value::as_str)
2970        {
2971            Some(s) if !s.is_empty() => s.to_string(),
2972            _ => return,
2973        },
2974    };
2975    let client = crate::relay_client::RelayClient::new(&relay_url);
2976    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
2977        Ok(t) => t,
2978        Err(_) => return,
2979    };
2980    let now = std::time::SystemTime::now()
2981        .duration_since(std::time::UNIX_EPOCH)
2982        .map(|d| d.as_secs())
2983        .unwrap_or(0);
2984    match last_pull {
2985        None => {
2986            eprintln!(
2987                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
2988            );
2989        }
2990        Some(t) if now.saturating_sub(t) > 300 => {
2991            let mins = now.saturating_sub(t) / 60;
2992            eprintln!(
2993                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
2994            );
2995        }
2996        _ => {}
2997    }
2998}
2999
3000pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3001    let trimmed = input.trim();
3002    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3003    {
3004        return Ok(trimmed.to_string());
3005    }
3006    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3007    let n: i64 = amount
3008        .parse()
3009        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3010    if n <= 0 {
3011        bail!("deadline duration must be positive: {input:?}");
3012    }
3013    let duration = match unit {
3014        "m" => time::Duration::minutes(n),
3015        "h" => time::Duration::hours(n),
3016        "d" => time::Duration::days(n),
3017        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3018    };
3019    Ok((time::OffsetDateTime::now_utc() + duration)
3020        .format(&time::format_description::well_known::Rfc3339)
3021        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3022}
3023
3024fn cmd_send(
3025    peer: &str,
3026    kind: &str,
3027    body_arg: &str,
3028    deadline: Option<&str>,
3029    // v0.10: when true, refuse to auto-pair on miss; fail loudly so
3030    // scripts can branch on the error instead of accepting an implicit
3031    // side effect.
3032    no_auto_pair: bool,
3033    as_json: bool,
3034) -> Result<()> {
3035    if !config::is_initialized()? {
3036        bail!("not initialized — run `wire init <handle>` first");
3037    }
3038    let peer_in = crate::agent_card::bare_handle(peer).to_string();
3039    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
3040    // match wins; nickname (DID-hash auto-derived) is the fallback.
3041    // Ambiguous nicknames (two pinned peers DID-hash to the same
3042    // adj-noun pair) fail loudly with disambiguation; unknown handles
3043    // pass through (matches existing `wire send` semantics — queue
3044    // first, deliver best-effort).
3045    let peer = match resolve_peer_handle(&peer_in) {
3046        Ok(Some(resolved)) if resolved != peer_in => {
3047            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3048            resolved
3049        }
3050        Ok(Some(canonical)) => canonical, // exact handle match
3051        Ok(None) => peer_in,              // unknown — pass through, downstream errors
3052        Err(ResolveError::Ambiguous(candidates)) => bail!(
3053            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3054             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3055            candidates.len(),
3056            candidates.join(", ")
3057        ),
3058        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
3059    };
3060
3061    // v0.9 auto-pair-on-miss: if the resolved peer isn't pinned yet but
3062    // matches a local sister session, pair first (disk-read --local-sister
3063    // path) then continue. Closes the "wire send returns queued but
3064    // peer never receives because we were never paired" silent-fail
3065    // class. Equivalent to `wire dial <name>` followed by `wire send
3066    // <name> ...` in one step.
3067    let peer_is_pinned = config::read_relay_state()
3068        .ok()
3069        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3070        .map(|peers| peers.contains_key(&peer))
3071        .unwrap_or(false);
3072    if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3073        if no_auto_pair {
3074            bail!(
3075                "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3076                 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3077                 then re-run send."
3078            );
3079        }
3080        eprintln!(
3081            "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3082             Pass --no-auto-pair to refuse implicit dialing."
3083        );
3084        cmd_add_local_sister(&sister_name, true).map_err(|e| {
3085            anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3086        })?;
3087    }
3088
3089    let peer = peer.as_str();
3090    let sk_seed = config::read_private_key()?;
3091    let card = config::read_agent_card()?;
3092    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3093    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3094    let pk_b64 = card
3095        .get("verify_keys")
3096        .and_then(Value::as_object)
3097        .and_then(|m| m.values().next())
3098        .and_then(|v| v.get("key"))
3099        .and_then(Value::as_str)
3100        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3101    let pk_bytes = crate::signing::b64decode(pk_b64)?;
3102
3103    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
3104    // P0.S (0.5.11): stdin support lets shells pipe in long content
3105    // without quoting/escaping ceremony, and supports heredocs naturally:
3106    //   wire send peer - <<EOF ... EOF
3107    let body_value: Value = if body_arg == "-" {
3108        use std::io::Read;
3109        let mut raw = String::new();
3110        std::io::stdin()
3111            .read_to_string(&mut raw)
3112            .with_context(|| "reading body from stdin")?;
3113        // Try parsing as JSON first; fall back to string literal for
3114        // plain-text bodies.
3115        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3116    } else if let Some(path) = body_arg.strip_prefix('@') {
3117        let raw =
3118            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3119        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3120    } else {
3121        Value::String(body_arg.to_string())
3122    };
3123
3124    let kind_id = parse_kind(kind)?;
3125
3126    let now = time::OffsetDateTime::now_utc()
3127        .format(&time::format_description::well_known::Rfc3339)
3128        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3129
3130    let mut event = json!({
3131        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3132        "timestamp": now,
3133        "from": did,
3134        "to": format!("did:wire:{peer}"),
3135        "type": kind,
3136        "kind": kind_id,
3137        "body": body_value,
3138    });
3139    if let Some(deadline) = deadline {
3140        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3141    }
3142    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3143    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3144
3145    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
3146    // coords in relay-state and ask the relay how recently the peer pulled.
3147    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
3148    // Never blocks the send — the event still queues to outbox.
3149    maybe_warn_peer_attentiveness(peer);
3150
3151    // For now we append to outbox JSONL and rely on a future daemon to push
3152    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
3153    // Append goes through `config::append_outbox_record` which holds a per-
3154    // path mutex so concurrent senders cannot interleave bytes mid-line.
3155    let line = serde_json::to_vec(&signed)?;
3156    let outbox = config::append_outbox_record(peer, &line)?;
3157
3158    if as_json {
3159        println!(
3160            "{}",
3161            serde_json::to_string(&json!({
3162                "event_id": event_id,
3163                "status": "queued",
3164                "peer": peer,
3165                "outbox": outbox.to_string_lossy(),
3166            }))?
3167        );
3168    } else {
3169        println!(
3170            "queued event {event_id} → {peer} (outbox: {})",
3171            outbox.display()
3172        );
3173    }
3174    Ok(())
3175}
3176
3177fn parse_kind(s: &str) -> Result<u32> {
3178    if let Ok(n) = s.parse::<u32>() {
3179        return Ok(n);
3180    }
3181    for (id, name) in crate::signing::kinds() {
3182        if *name == s {
3183            return Ok(*id);
3184        }
3185    }
3186    // Unknown name — default to kind 1 (decision) for v0.1.
3187    Ok(1)
3188}
3189
3190// ---------- here (v0.9.3 you-are-here view) ----------
3191
3192/// `wire here` — one-screen "you are this session, your neighbors are
3193/// these." Combines what `wire whoami`, `wire peers`, and `wire session
3194/// list-local` would otherwise force the operator to call separately.
3195fn cmd_here(as_json: bool) -> Result<()> {
3196    let initialized = config::is_initialized().unwrap_or(false);
3197
3198    // Self identity.
3199    let (self_did, self_handle, self_character) = if initialized {
3200        let card = config::read_agent_card().ok();
3201        let did = card
3202            .as_ref()
3203            .and_then(|c| c.get("did").and_then(Value::as_str))
3204            .unwrap_or("")
3205            .to_string();
3206        let handle = if did.is_empty() {
3207            String::new()
3208        } else {
3209            crate::agent_card::display_handle_from_did(&did).to_string()
3210        };
3211        let character = if did.is_empty() {
3212            None
3213        } else {
3214            // v0.11: DID-derived only. No display.json overrides.
3215            Some(crate::character::Character::from_did(&did))
3216        };
3217        (did, handle, character)
3218    } else {
3219        (String::new(), String::new(), None)
3220    };
3221
3222    let cwd = std::env::current_dir()
3223        .map(|p| p.to_string_lossy().into_owned())
3224        .unwrap_or_default();
3225    let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3226
3227    // Sister sessions (same-machine).
3228    let mut sisters: Vec<Value> = Vec::new();
3229    if let Ok(listing) = crate::session::list_local_sessions() {
3230        for group in listing.local.values() {
3231            for s in group {
3232                if s.handle.as_deref() == Some(self_handle.as_str()) {
3233                    continue; // skip self
3234                }
3235                let ch = s.did.as_deref().map(crate::character::Character::from_did);
3236                sisters.push(json!({
3237                    "session": s.name,
3238                    "handle": s.handle,
3239                    "persona": ch,
3240                }));
3241            }
3242        }
3243    }
3244
3245    // Pinned peers (trust ring agents).
3246    let mut peers: Vec<Value> = Vec::new();
3247    if initialized
3248        && let Ok(trust) = config::read_trust()
3249        && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3250    {
3251        for (handle, agent) in agents {
3252            if handle == &self_handle {
3253                continue; // skip self
3254            }
3255            let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3256            let ch = if did.is_empty() {
3257                None
3258            } else {
3259                Some(crate::character::Character::from_did(did))
3260            };
3261            peers.push(json!({
3262                "handle": handle,
3263                "did": did,
3264                "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3265                "persona": ch,
3266            }));
3267        }
3268    }
3269
3270    if as_json {
3271        println!(
3272            "{}",
3273            serde_json::to_string(&json!({
3274                "self": {
3275                    "handle": self_handle,
3276                    "did": self_did,
3277                    "persona": self_character,
3278                    "cwd": cwd,
3279                    "wire_home": wire_home,
3280                },
3281                "sister_sessions": sisters,
3282                "pinned_peers": peers,
3283            }))?
3284        );
3285        return Ok(());
3286    }
3287
3288    // Human format.
3289    if !initialized {
3290        println!("not initialized — run `wire init <handle>` to bootstrap.");
3291        return Ok(());
3292    }
3293    let glyph = self_character
3294        .as_ref()
3295        .map(crate::character::emoji_with_fallback)
3296        .unwrap_or_else(|| "?".to_string());
3297    let nick = self_character
3298        .as_ref()
3299        .map(|c| c.nickname.clone())
3300        .unwrap_or_default();
3301    println!("you are {glyph} {nick}  ({self_handle})");
3302    if !cwd.is_empty() {
3303        println!("  cwd:    {cwd}");
3304    }
3305    // Helper closure that mirrors emoji_with_fallback over a JSON-encoded
3306    // character object (because we already collected sisters/peers into
3307    // Value rows above). Looks up the canonical emoji-name and falls
3308    // back to that — never repeats the nickname inside the brackets.
3309    let render_glyph = |character: &Value| -> String {
3310        let emoji = character
3311            .get("emoji")
3312            .and_then(Value::as_str)
3313            .unwrap_or("?");
3314        let nickname = character
3315            .get("nickname")
3316            .and_then(Value::as_str)
3317            .unwrap_or("?");
3318        if crate::character::terminal_supports_emoji() {
3319            return emoji.to_string();
3320        }
3321        // Synthesize a minimal Character so emoji_with_fallback's
3322        // lookup table picks the right ASCII tag.
3323        let synth = crate::character::Character {
3324            nickname: nickname.to_string(),
3325            emoji: emoji.to_string(),
3326            palette: crate::character::Palette {
3327                primary_hex: String::new(),
3328                accent_hex: String::new(),
3329                ansi256_primary: 0,
3330                ansi256_accent: 0,
3331            },
3332        };
3333        crate::character::emoji_with_fallback(&synth)
3334    };
3335    if !sisters.is_empty() {
3336        println!();
3337        println!("sister sessions on this machine:");
3338        for s in &sisters {
3339            let session = s["session"].as_str().unwrap_or("?");
3340            let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3341            let glyph = render_glyph(&s["persona"]);
3342            println!("  {glyph} {ch_nick}  ({session})");
3343        }
3344    }
3345    if !peers.is_empty() {
3346        println!();
3347        println!("pinned peers:");
3348        for p in &peers {
3349            let handle = p["handle"].as_str().unwrap_or("?");
3350            let tier = p["tier"].as_str().unwrap_or("");
3351            let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3352            let glyph = render_glyph(&p["persona"]);
3353            println!("  {glyph} {ch_nick}  ({handle})  [{tier}]");
3354        }
3355    }
3356    if sisters.is_empty() && peers.is_empty() {
3357        println!();
3358        println!(
3359            "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3360        );
3361    }
3362    Ok(())
3363}
3364
3365// ---------- dial / whois (v0.8 canonical addressing) ----------
3366
3367/// `wire dial <name> [message]` — the one verb operators reach for.
3368/// Resolves any name (nickname/handle/session/DID) to a peer and
3369/// drives the right pair flow + optional first message. See the
3370/// `Command::Dial` doc for the resolution ladder.
3371///
3372/// v0.9: when `name` contains `@<relay>`, route through the federation
3373/// `wire add <handle>@<relay>` path (`.well-known/wire/agent` resolution
3374/// plus cross-machine pair_drop). No more bail with "federation isn't
3375/// implemented yet" — one verb across both orbits.
3376fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3377    if name.contains('@') {
3378        // Federation path. cmd_add already auto-detects (per v0.7.4)
3379        // when input has `@` and routes through the .well-known
3380        // resolver + pair_drop deposit. After it returns, the peer
3381        // is in pending-outbound; bilateral completes when the peer
3382        // accepts. Optionally send the first message after the add.
3383        cmd_add(name, None, false, true)
3384            .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3385        if let Some(msg) = message {
3386            // Peer handle for send = the nick part before the `@`.
3387            let bare = name.split('@').next().unwrap_or(name);
3388            cmd_send(bare, "claim", msg, None, false, as_json)?;
3389        }
3390        return Ok(());
3391    }
3392
3393    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3394    // success with `{found: false, candidates: [...]}` instead of
3395    // erroring. Agents can branch on `found` without wrapping in a
3396    // try/catch.
3397    let resolution = match resolve_name_to_target(name) {
3398        Ok(r) => r,
3399        Err(e) if as_json => {
3400            let pool = known_local_names();
3401            let suggestions = closest_candidates(name, &pool, 3, 3);
3402            println!(
3403                "{}",
3404                serde_json::to_string(&json!({
3405                    "name_input": name,
3406                    "found": false,
3407                    "candidates": suggestions,
3408                    "error": format!("{e:#}"),
3409                }))?
3410            );
3411            return Ok(());
3412        }
3413        Err(e) => return Err(e),
3414    };
3415    let mut steps: Vec<Value> = Vec::new();
3416
3417    match &resolution {
3418        DialTarget::PinnedPeer { handle, .. } => {
3419            steps.push(json!({
3420                "step": "resolved",
3421                "kind": "already_pinned",
3422                "handle": handle,
3423            }));
3424        }
3425        DialTarget::LocalSister { session_name, .. } => {
3426            steps.push(json!({
3427                "step": "resolved",
3428                "kind": "local_sister",
3429                "session": session_name,
3430            }));
3431            // Drive the bilateral pair via the disk-read sister path.
3432            // cmd_add_local_sister already handles "already paired"
3433            // gracefully (its internal state.peers check returns the
3434            // existing pin instead of re-issuing a pair_drop), so
3435            // re-dialling is idempotent.
3436            cmd_add_local_sister(session_name, true).map_err(|e| {
3437                anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3438            })?;
3439            steps.push(json!({
3440                "step": "paired",
3441                "via": "local_sister",
3442            }));
3443        }
3444    }
3445
3446    let send_handle = match &resolution {
3447        DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3448        DialTarget::LocalSister { handle, .. } => handle.clone(),
3449    };
3450
3451    let send_result = if let Some(msg) = message {
3452        let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3453        match &r {
3454            Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3455            Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3456        }
3457        Some(r)
3458    } else {
3459        None
3460    };
3461
3462    if as_json {
3463        println!(
3464            "{}",
3465            serde_json::to_string(&json!({
3466                "name_input": name,
3467                "resolved_handle": send_handle,
3468                "steps": steps,
3469            }))?
3470        );
3471    } else {
3472        println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3473        for s in &steps {
3474            let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3475            println!("  - {step}");
3476        }
3477        if message.is_some() {
3478            println!("  (use `wire tail {send_handle}` to read replies)");
3479        }
3480    }
3481    if let Some(Err(e)) = send_result {
3482        return Err(e);
3483    }
3484    Ok(())
3485}
3486
3487/// `wire whois <name>` — resolve any local name (nickname/session/
3488/// handle/DID) to the full identity row. The inspector for the
3489/// canonical addressing layer. For federation `handle@relay-domain`
3490/// resolution see `cmd_whois` (line 5536+) — the dispatcher chooses
3491/// based on whether the input contains `@`.
3492fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3493    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3494    // success (exit 0) with `{found: false, candidates: [...]}` so
3495    // agents don't need try/catch around `wire whois <name>`. In
3496    // human mode, the bail's did-you-mean line points at the
3497    // closest candidate.
3498    let resolution = match resolve_name_to_target(name) {
3499        Ok(r) => r,
3500        Err(e) if as_json => {
3501            let pool = known_local_names();
3502            let suggestions = closest_candidates(name, &pool, 3, 3);
3503            println!(
3504                "{}",
3505                serde_json::to_string(&json!({
3506                    "name_input": name,
3507                    "found": false,
3508                    "candidates": suggestions,
3509                    "error": format!("{e:#}"),
3510                }))?
3511            );
3512            return Ok(());
3513        }
3514        Err(e) => return Err(e),
3515    };
3516    match resolution {
3517        DialTarget::PinnedPeer {
3518            handle,
3519            did,
3520            nickname,
3521            emoji,
3522            tier,
3523        } => {
3524            if as_json {
3525                println!(
3526                    "{}",
3527                    serde_json::to_string(&json!({
3528                        "kind": "pinned_peer",
3529                        "handle": handle,
3530                        "did": did,
3531                        "nickname": nickname,
3532                        "emoji": emoji,
3533                        "tier": tier,
3534                    }))?
3535                );
3536            } else {
3537                let n = nickname.as_deref().unwrap_or("(no character)");
3538                let e = emoji.as_deref().unwrap_or("?");
3539                println!("{e} {n}");
3540                println!("  handle:   {handle}");
3541                println!("  did:      {did}");
3542                println!("  tier:     {tier}");
3543                println!("  reach:    pinned peer (already in trust ring + slot pinned)");
3544            }
3545        }
3546        DialTarget::LocalSister {
3547            session_name,
3548            handle,
3549            did,
3550            nickname,
3551            emoji,
3552        } => {
3553            if as_json {
3554                println!(
3555                    "{}",
3556                    serde_json::to_string(&json!({
3557                        "kind": "local_sister",
3558                        "session_name": session_name,
3559                        "handle": handle,
3560                        "did": did,
3561                        "nickname": nickname,
3562                        "emoji": emoji,
3563                    }))?
3564                );
3565            } else {
3566                let n = nickname.as_deref().unwrap_or("(no character)");
3567                let e = emoji.as_deref().unwrap_or("?");
3568                println!("{e} {n}");
3569                println!("  session:  {session_name}");
3570                println!("  handle:   {handle}");
3571                println!(
3572                    "  did:      {}",
3573                    did.as_deref().unwrap_or("(card unreadable)")
3574                );
3575                println!("  reach:    local sister on this machine — `wire dial {n}` pairs us");
3576            }
3577        }
3578    }
3579    Ok(())
3580}
3581
3582enum DialTarget {
3583    PinnedPeer {
3584        handle: String,
3585        did: String,
3586        nickname: Option<String>,
3587        emoji: Option<String>,
3588        tier: String,
3589    },
3590    LocalSister {
3591        session_name: String,
3592        handle: String,
3593        did: Option<String>,
3594        nickname: Option<String>,
3595        emoji: Option<String>,
3596    },
3597}
3598
3599/// Resolution order: pinned peers first (already in our trust ring),
3600/// then local sister sessions (on-disk discovery). Case-insensitive
3601/// match against handle, character nickname, session name, or DID.
3602fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3603    let needle = name.trim();
3604    if needle.is_empty() {
3605        bail!("empty name");
3606    }
3607
3608    // 1. Pinned peers — `wire peers` data. trust.agents is an object
3609    // keyed by handle (not an array); iterate as a map.
3610    if config::is_initialized().unwrap_or(false) {
3611        let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3612        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3613            for (handle_key, agent) in agents {
3614                let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3615                if did.is_empty() {
3616                    continue;
3617                }
3618                let handle = handle_key.clone();
3619                let character = crate::character::Character::from_did(did);
3620                let tier = agent
3621                    .get("tier")
3622                    .and_then(Value::as_str)
3623                    .unwrap_or("UNKNOWN")
3624                    .to_string();
3625                let matches = handle.eq_ignore_ascii_case(needle)
3626                    || did.eq_ignore_ascii_case(needle)
3627                    || character.nickname.eq_ignore_ascii_case(needle);
3628                if matches {
3629                    return Ok(DialTarget::PinnedPeer {
3630                        handle,
3631                        did: did.to_string(),
3632                        nickname: Some(character.nickname),
3633                        emoji: Some(character.emoji.to_string()),
3634                        tier,
3635                    });
3636                }
3637            }
3638        }
3639    }
3640
3641    // 2. Local sister sessions.
3642    if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3643        let sessions = crate::session::list_sessions().unwrap_or_default();
3644        let s = sessions.iter().find(|s| s.name == session_name);
3645        if let Some(s) = s {
3646            return Ok(DialTarget::LocalSister {
3647                session_name: s.name.clone(),
3648                handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3649                did: s.did.clone(),
3650                nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3651                emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3652            });
3653        }
3654    }
3655
3656    // v0.9.2: fuzzy did-you-mean suggestion on resolution miss. Walks
3657    // the union of pinned-peer handles + character nicknames + sister
3658    // session names + sister character nicknames, returns up to 3 names
3659    // within Levenshtein distance 3 of the operator's typed name.
3660    let pool = known_local_names();
3661    let suggestions = closest_candidates(name, &pool, 3, 3);
3662    if suggestions.is_empty() {
3663        bail!(
3664            "no peer matched `{name}`.\n\
3665             Tried: pinned peers (`wire peers`) + local sister sessions \
3666             (`wire session list-local`).\n\
3667             For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3668        );
3669    }
3670    bail!(
3671        "no peer matched `{name}`.\n\
3672         Did you mean: {}?\n\
3673         List all: `wire peers`, `wire session list-local`.",
3674        suggestions
3675            .iter()
3676            .map(|s| format!("`{s}`"))
3677            .collect::<Vec<_>>()
3678            .join(", ")
3679    );
3680}
3681
3682// ---------- tail ----------
3683
3684fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3685    let inbox = config::inbox_dir()?;
3686    if !inbox.exists() {
3687        if !as_json {
3688            eprintln!("no inbox yet — daemon hasn't run, or no events received");
3689        }
3690        return Ok(());
3691    }
3692    let trust = config::read_trust()?;
3693    let mut count = 0usize;
3694
3695    let entries: Vec<_> = std::fs::read_dir(&inbox)?
3696        .filter_map(|e| e.ok())
3697        .map(|e| e.path())
3698        .filter(|p| {
3699            p.extension().map(|x| x == "jsonl").unwrap_or(false)
3700                && match peer {
3701                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3702                    None => true,
3703                }
3704        })
3705        .collect();
3706
3707    for path in entries {
3708        let body = std::fs::read_to_string(&path)?;
3709        for line in body.lines() {
3710            let event: Value = match serde_json::from_str(line) {
3711                Ok(v) => v,
3712                Err(_) => continue,
3713            };
3714            let verified = verify_message_v31(&event, &trust).is_ok();
3715            if as_json {
3716                let mut event_with_meta = event.clone();
3717                if let Some(obj) = event_with_meta.as_object_mut() {
3718                    obj.insert("verified".into(), json!(verified));
3719                }
3720                println!("{}", serde_json::to_string(&event_with_meta)?);
3721            } else {
3722                let ts = event
3723                    .get("timestamp")
3724                    .and_then(Value::as_str)
3725                    .unwrap_or("?");
3726                let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3727                let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3728                let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3729                let summary = event
3730                    .get("body")
3731                    .map(|b| match b {
3732                        Value::String(s) => s.clone(),
3733                        _ => b.to_string(),
3734                    })
3735                    .unwrap_or_default();
3736                let mark = if verified { "✓" } else { "✗" };
3737                let deadline = event
3738                    .get("time_sensitive_until")
3739                    .and_then(Value::as_str)
3740                    .map(|d| format!(" deadline: {d}"))
3741                    .unwrap_or_default();
3742                println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3743            }
3744            count += 1;
3745            if limit > 0 && count >= limit {
3746                return Ok(());
3747            }
3748        }
3749    }
3750    Ok(())
3751}
3752
3753// ---------- monitor (live-tail across all peers, harness-friendly) ----------
3754
3755/// Events filtered out of `wire monitor` by default — pair handshake +
3756/// liveness pings. Operators almost never want these surfaced; an explicit
3757/// `--include-handshake` brings them back.
3758fn monitor_is_noise_kind(kind: &str) -> bool {
3759    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3760}
3761
3762/// Resolve a pinned peer's persona (the DID-derived nickname + emoji,
3763/// respecting an advertised override on their card). `None` if the peer
3764/// isn't in trust or can't be resolved — callers fall back to the handle.
3765fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
3766    let trust = config::read_trust().ok()?;
3767    let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
3768    if let Some(card) = agent.get("card") {
3769        Some(crate::character::Character::from_card(card))
3770    } else {
3771        let did = agent.get("did").and_then(Value::as_str)?;
3772        Some(crate::character::Character::from_did(did))
3773    }
3774}
3775
3776/// "emoji nickname" label for a peer, falling back to the raw handle.
3777fn persona_label(peer_handle: &str) -> String {
3778    match resolve_persona(peer_handle) {
3779        Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
3780        None => peer_handle.to_string(),
3781    }
3782}
3783
3784/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
3785/// full structured event for tooling consumption; the plain form is a tight
3786/// one-line summary suitable as a harness stream-watcher notification.
3787///
3788/// Kept PURE (no trust I/O) so it stays deterministic and cheap per event.
3789/// Persona enrichment for `--json` belongs at InboxEvent construction in
3790/// `inbox_watch` (a follow-up), not here.
3791fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3792    if as_json {
3793        Ok(serde_json::to_string(e)?)
3794    } else {
3795        let eid_short: String = e.event_id.chars().take(12).collect();
3796        let body = e.body_preview.replace('\n', " ");
3797        let ts: String = e.timestamp.chars().take(19).collect();
3798        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3799    }
3800}
3801
3802/// `wire monitor` — long-running line-per-event stream of new inbox events.
3803///
3804/// Built for agent harnesses that have an "every stdout line is a chat
3805/// notification" stream watcher (Claude Code Monitor tool, etc.). One
3806/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
3807/// | python parse | grep -v pair_drop` pipeline operators improvise on day
3808/// one of every wire session.
3809///
3810/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
3811/// pure handshake / liveness noise that operators almost never want
3812/// surfaced. Pass `--include-handshake` if you do.
3813///
3814/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
3815/// doesn't drown the operator in replay), with optional `--replay N` to
3816/// emit the last N events first.
3817fn cmd_monitor(
3818    peer_filter: Option<&str>,
3819    as_json: bool,
3820    include_handshake: bool,
3821    interval_ms: u64,
3822    replay: usize,
3823) -> Result<()> {
3824    let inbox_dir = config::inbox_dir()?;
3825    if !inbox_dir.exists() && !as_json {
3826        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3827    }
3828    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
3829
3830    // Optional replay — read existing files and emit the last `replay` events
3831    // (post-filter) before going live. Useful when the harness restarts and
3832    // wants recent context.
3833    if replay > 0 && inbox_dir.exists() {
3834        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3835        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3836            let path = entry.path();
3837            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3838                continue;
3839            }
3840            let peer = match path.file_stem().and_then(|s| s.to_str()) {
3841                Some(s) => s.to_string(),
3842                None => continue,
3843            };
3844            if let Some(filter) = peer_filter
3845                && peer != filter
3846            {
3847                continue;
3848            }
3849            let body = std::fs::read_to_string(&path).unwrap_or_default();
3850            for line in body.lines() {
3851                let line = line.trim();
3852                if line.is_empty() {
3853                    continue;
3854                }
3855                let signed: Value = match serde_json::from_str(line) {
3856                    Ok(v) => v,
3857                    Err(_) => continue,
3858                };
3859                let ev = crate::inbox_watch::InboxEvent::from_signed(
3860                    &peer, signed, /* verified */ true,
3861                );
3862                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3863                    continue;
3864                }
3865                all.push(ev);
3866            }
3867        }
3868        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
3869        // chronological for same-zoned timestamps).
3870        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3871        let start = all.len().saturating_sub(replay);
3872        for ev in &all[start..] {
3873            println!("{}", monitor_render(ev, as_json)?);
3874        }
3875        use std::io::Write;
3876        std::io::stdout().flush().ok();
3877    }
3878
3879    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
3880    // the first poll only returns events that arrived AFTER startup.
3881    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3882    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3883
3884    loop {
3885        // Never die silently. wisp-blossom (Win10) saw `wire monitor` exit 1
3886        // with ZERO bytes on stdout+stderr when a cursor-block (untrusted
3887        // signer's pair event) tripped the watcher — a silent death looks
3888        // identical to "still watching" and breaks the sister-collab model.
3889        // Surface the reason and KEEP watching instead of propagating a fatal
3890        // `?` that some callers swallow.
3891        let events = match w.poll() {
3892            Ok(evs) => evs,
3893            Err(e) => {
3894                eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
3895                std::thread::sleep(sleep_dur);
3896                continue;
3897            }
3898        };
3899        let mut wrote = false;
3900        for ev in events {
3901            if let Some(filter) = peer_filter
3902                && ev.peer != filter
3903            {
3904                continue;
3905            }
3906            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3907                continue;
3908            }
3909            println!("{}", monitor_render(&ev, as_json)?);
3910            wrote = true;
3911        }
3912        if wrote {
3913            use std::io::Write;
3914            std::io::stdout().flush().ok();
3915        }
3916        std::thread::sleep(sleep_dur);
3917    }
3918}
3919
3920#[cfg(test)]
3921mod tier_tests {
3922    use super::*;
3923    use serde_json::json;
3924
3925    fn trust_with(handle: &str, tier: &str) -> Value {
3926        json!({
3927            "version": 1,
3928            "agents": {
3929                handle: {
3930                    "tier": tier,
3931                    "did": format!("did:wire:{handle}"),
3932                    "card": {"capabilities": ["wire/v3.1"]}
3933                }
3934            }
3935        })
3936    }
3937
3938    #[test]
3939    fn pending_ack_when_verified_but_no_slot_token() {
3940        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
3941        // slot_token hasn't arrived yet. Display PENDING_ACK so the
3942        // operator knows wire send won't work yet.
3943        let trust = trust_with("willard", "VERIFIED");
3944        let relay_state = json!({
3945            "peers": {
3946                "willard": {
3947                    "relay_url": "https://relay",
3948                    "slot_id": "abc",
3949                    "slot_token": "",
3950                }
3951            }
3952        });
3953        assert_eq!(
3954            effective_peer_tier(&trust, &relay_state, "willard"),
3955            "PENDING_ACK"
3956        );
3957    }
3958
3959    #[test]
3960    fn verified_when_slot_token_present() {
3961        let trust = trust_with("willard", "VERIFIED");
3962        let relay_state = json!({
3963            "peers": {
3964                "willard": {
3965                    "relay_url": "https://relay",
3966                    "slot_id": "abc",
3967                    "slot_token": "tok123",
3968                }
3969            }
3970        });
3971        assert_eq!(
3972            effective_peer_tier(&trust, &relay_state, "willard"),
3973            "VERIFIED"
3974        );
3975    }
3976
3977    #[test]
3978    fn raw_tier_passes_through_for_non_verified() {
3979        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
3980        // UNTRUSTED regardless of slot_token state.
3981        let trust = trust_with("willard", "UNTRUSTED");
3982        let relay_state = json!({
3983            "peers": {"willard": {"slot_token": ""}}
3984        });
3985        assert_eq!(
3986            effective_peer_tier(&trust, &relay_state, "willard"),
3987            "UNTRUSTED"
3988        );
3989    }
3990
3991    #[test]
3992    fn pending_ack_when_relay_state_missing_peer() {
3993        // After wire add, trust gets updated BEFORE relay_state.peers does.
3994        // If relay_state has no entry for the peer at all, the operator
3995        // still hasn't completed the bilateral pin — show PENDING_ACK.
3996        let trust = trust_with("willard", "VERIFIED");
3997        let relay_state = json!({"peers": {}});
3998        assert_eq!(
3999            effective_peer_tier(&trust, &relay_state, "willard"),
4000            "PENDING_ACK"
4001        );
4002    }
4003}
4004
4005#[cfg(test)]
4006mod monitor_tests {
4007    use super::*;
4008    use crate::inbox_watch::InboxEvent;
4009    use serde_json::Value;
4010
4011    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4012        InboxEvent {
4013            peer: peer.to_string(),
4014            event_id: "abcd1234567890ef".to_string(),
4015            kind: kind.to_string(),
4016            body_preview: body.to_string(),
4017            verified: true,
4018            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4019            raw: Value::Null,
4020        }
4021    }
4022
4023    #[test]
4024    fn monitor_filter_drops_handshake_kinds_by_default() {
4025        // The whole point: pair_drop / pair_drop_ack / heartbeat are
4026        // protocol noise. If they leak into the operator's chat stream by
4027        // default, the recipe is useless ("wire monitor talks too much,
4028        // disabled it"). Burn this rule in.
4029        assert!(monitor_is_noise_kind("pair_drop"));
4030        assert!(monitor_is_noise_kind("pair_drop_ack"));
4031        assert!(monitor_is_noise_kind("heartbeat"));
4032
4033        // Real-payload kinds — operator wants every one.
4034        assert!(!monitor_is_noise_kind("claim"));
4035        assert!(!monitor_is_noise_kind("decision"));
4036        assert!(!monitor_is_noise_kind("ack"));
4037        assert!(!monitor_is_noise_kind("request"));
4038        assert!(!monitor_is_noise_kind("note"));
4039        // Unknown future kinds shouldn't be filtered as noise either —
4040        // operator probably wants to see something they don't recognise,
4041        // not have it silently dropped (the P0.1 lesson at the UX layer).
4042        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4043    }
4044
4045    #[test]
4046    fn monitor_render_plain_is_one_short_line() {
4047        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4048        let line = monitor_render(&e, false).unwrap();
4049        // Must be single-line.
4050        assert!(!line.contains('\n'), "render must be one line: {line}");
4051        // Must include peer, kind, body fragment, short event_id.
4052        assert!(line.contains("willard"));
4053        assert!(line.contains("claim"));
4054        assert!(line.contains("real v8 train"));
4055        // Short event id (first 12 chars).
4056        assert!(line.contains("abcd12345678"));
4057        assert!(
4058            !line.contains("abcd1234567890ef"),
4059            "should truncate full id"
4060        );
4061        // RFC3339-ish second precision.
4062        assert!(line.contains("2026-05-15T23:14:07"));
4063    }
4064
4065    #[test]
4066    fn monitor_render_strips_newlines_from_body() {
4067        // Multi-line bodies (markdown lists, code, etc.) must collapse to
4068        // one line — otherwise a single message produces multiple
4069        // notifications in the harness, ruining the "one event = one line"
4070        // contract the Monitor tool relies on.
4071        let e = ev("spark", "claim", "line one\nline two\nline three");
4072        let line = monitor_render(&e, false).unwrap();
4073        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4074        assert!(line.contains("line one line two line three"));
4075    }
4076
4077    #[test]
4078    fn monitor_render_json_is_valid_jsonl() {
4079        let e = ev("spark", "claim", "hi");
4080        let line = monitor_render(&e, true).unwrap();
4081        assert!(!line.contains('\n'));
4082        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4083        assert_eq!(parsed["peer"], "spark");
4084        assert_eq!(parsed["kind"], "claim");
4085        assert_eq!(parsed["body_preview"], "hi");
4086    }
4087
4088    #[test]
4089    fn monitor_does_not_drop_on_verified_null() {
4090        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
4091        // `select(.verified == true)` against inbox JSONL. Daemon writes
4092        // events with verified=null (verification happens at tail-time, not
4093        // write-time), so the filter silently rejected everything — same
4094        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
4095        // never surfaced for ~30min.
4096        //
4097        // wire monitor's render path must NOT consult `.verified` for any
4098        // filter decision. Lock that in here so a future "be conservative,
4099        // only emit verified" patch can't quietly land.
4100        let mut e = ev("spark", "claim", "from disk with verified=null");
4101        e.verified = false; // worst case — even if disk says unverified, emit
4102        let line = monitor_render(&e, false).unwrap();
4103        assert!(line.contains("from disk with verified=null"));
4104        // Noise filter operates purely on kind, never on verified.
4105        assert!(!monitor_is_noise_kind("claim"));
4106    }
4107}
4108
4109// ---------- verify ----------
4110
4111fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4112    let body = if path == "-" {
4113        let mut buf = String::new();
4114        use std::io::Read;
4115        std::io::stdin().read_to_string(&mut buf)?;
4116        buf
4117    } else {
4118        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4119    };
4120    let event: Value = serde_json::from_str(&body)?;
4121    let trust = config::read_trust()?;
4122    match verify_message_v31(&event, &trust) {
4123        Ok(()) => {
4124            if as_json {
4125                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4126            } else {
4127                println!("verified ✓");
4128            }
4129            Ok(())
4130        }
4131        Err(e) => {
4132            let reason = e.to_string();
4133            if as_json {
4134                println!(
4135                    "{}",
4136                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4137                );
4138            } else {
4139                eprintln!("FAILED: {reason}");
4140            }
4141            std::process::exit(1);
4142        }
4143    }
4144}
4145
4146// ---------- mcp / relay-server stubs ----------
4147
4148fn cmd_mcp() -> Result<()> {
4149    crate::mcp::run()
4150}
4151
4152fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4153    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
4154    // overriding --bind. Implies --local-only semantics. Routed to a
4155    // separate serve_uds entry point with a manual hyper accept loop
4156    // (axum 0.7's `serve` is TcpListener-only).
4157    if let Some(socket_path) = uds {
4158        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4159            std::path::PathBuf::from(home)
4160                .join("state")
4161                .join("wire-relay")
4162                .join("uds")
4163        } else {
4164            dirs::state_dir()
4165                .or_else(dirs::data_local_dir)
4166                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4167                .join("wire-relay")
4168                .join("uds")
4169        };
4170        let runtime = tokio::runtime::Builder::new_multi_thread()
4171            .enable_all()
4172            .build()?;
4173        return runtime.block_on(crate::relay_server::serve_uds(
4174            socket_path.to_path_buf(),
4175            base,
4176        ));
4177    }
4178    // v0.5.17: --local-only refuses non-loopback binds. Catches the
4179    // "wait did I just bind a publicly-reachable local-only relay" mistake
4180    // at startup rather than discovering it via an empty phonebook later.
4181    if local_only {
4182        validate_loopback_bind(bind)?;
4183    }
4184    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
4185    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
4186    // so a single user can run both client and server on one machine.
4187    // For --local-only, suffix with /local so a single operator can run
4188    // both a federation relay and a local-only relay without state collision.
4189    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4190        std::path::PathBuf::from(home)
4191            .join("state")
4192            .join("wire-relay")
4193    } else {
4194        dirs::state_dir()
4195            .or_else(dirs::data_local_dir)
4196            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4197            .join("wire-relay")
4198    };
4199    let state_dir = if local_only { base.join("local") } else { base };
4200    let runtime = tokio::runtime::Builder::new_multi_thread()
4201        .enable_all()
4202        .build()?;
4203    runtime.block_on(crate::relay_server::serve_with_mode(
4204        bind,
4205        state_dir,
4206        crate::relay_server::ServerMode { local_only },
4207    ))
4208}
4209
4210/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
4211/// resolves to something outside `127.0.0.0/8` or `::1`.
4212///
4213/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
4214/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
4215/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
4216///
4217/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
4218/// is the IP range Tailscale uses for tailnet addresses. Lets operators
4219/// pair wire across machines using their tailnet IPs (e.g. Mac at
4220/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
4221/// auth + encryption + NAT traversal, wire handles protocol + identity.
4222/// Sidesteps host firewall config entirely (utun interface bypass).
4223///
4224/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
4225/// multicast, broadcast. Those would publish a "local-only" relay to
4226/// the global internet — the v0.5.17 security gate's whole point.
4227fn validate_loopback_bind(bind: &str) -> Result<()> {
4228    // Split host:port. IPv6 literals use `[::]:port` form.
4229    let host = if let Some(stripped) = bind.strip_prefix('[') {
4230        let close = stripped
4231            .find(']')
4232            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4233        stripped[..close].to_string()
4234    } else {
4235        bind.rsplit_once(':')
4236            .map(|(h, _)| h.to_string())
4237            .unwrap_or_else(|| bind.to_string())
4238    };
4239    use std::net::{IpAddr, ToSocketAddrs};
4240    let probe = format!("{host}:0");
4241    let resolved: Vec<_> = probe
4242        .to_socket_addrs()
4243        .with_context(|| format!("resolving bind host {host:?}"))?
4244        .collect();
4245    if resolved.is_empty() {
4246        bail!("--local-only: bind host {host:?} resolved to no addresses");
4247    }
4248    for addr in &resolved {
4249        let ip = addr.ip();
4250        let is_acceptable = match ip {
4251            IpAddr::V4(v4) => {
4252                v4.is_loopback() || v4.is_private() || {
4253                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
4254                    let octets = v4.octets();
4255                    octets[0] == 100 && (64..=127).contains(&octets[1])
4256                }
4257            }
4258            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
4259        };
4260        if !is_acceptable {
4261            bail!(
4262                "--local-only refuses non-private bind: {host:?} resolves to {} \
4263                 which is not loopback (127/8, ::1), RFC 1918 private \
4264                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4265                 (100.64.0.0/10). Remove --local-only to bind publicly.",
4266                ip
4267            );
4268        }
4269    }
4270    Ok(())
4271}
4272
4273// ---------- bind-relay ----------
4274
4275fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4276    use crate::endpoints::EndpointScope;
4277    match s.to_lowercase().as_str() {
4278        "federation" | "fed" => Ok(EndpointScope::Federation),
4279        "local" => Ok(EndpointScope::Local),
4280        "lan" => Ok(EndpointScope::Lan),
4281        "uds" => Ok(EndpointScope::Uds),
4282        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4283    }
4284}
4285
4286/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
4287/// appended to `self.endpoints[]`, keeping any existing slots so an agent
4288/// can hold a local relay AND a federation relay simultaneously without
4289/// black-holing pinned peers. `--replace` restores the pre-v0.12
4290/// destructive single-slot behavior (guarded by issue #7).
4291fn cmd_bind_relay(
4292    url: &str,
4293    scope: Option<&str>,
4294    replace: bool,
4295    migrate_pinned: bool,
4296    as_json: bool,
4297) -> Result<()> {
4298    use crate::endpoints::{Endpoint, self_endpoints};
4299
4300    if !config::is_initialized()? {
4301        bail!("not initialized — run `wire init <handle>` first");
4302    }
4303    let card = config::read_agent_card()?;
4304    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4305    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4306
4307    let normalized = url.trim_end_matches('/');
4308    let new_scope = match scope {
4309        Some(s) => parse_scope(s)?,
4310        None => crate::endpoints::infer_scope_from_url(normalized),
4311    };
4312
4313    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4314    let pinned: Vec<String> = existing
4315        .get("peers")
4316        .and_then(|p| p.as_object())
4317        .map(|o| o.keys().cloned().collect())
4318        .unwrap_or_default();
4319
4320    let existing_eps = self_endpoints(&existing);
4321    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4322
4323    // Destructive paths that black-hole pinned peers (issue #7):
4324    //   • `--replace` drops every other slot.
4325    //   • re-binding the SAME relay rotates that slot in place.
4326    // An additive bind of a NEW relay keeps existing slots, so peers stay
4327    // reachable — no acknowledgement required. This is the v0.12 default
4328    // that unblocks simultaneous local + remote.
4329    let destructive = replace || is_rebind_same;
4330    if destructive && !pinned.is_empty() && !migrate_pinned {
4331        let list = pinned.join(", ");
4332        let why = if replace {
4333            "`--replace` drops your other slot(s)"
4334        } else {
4335            "re-binding the same relay rotates its slot"
4336        };
4337        bail!(
4338            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4339             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4340             read.\n\n\
4341             SAFE PATHS:\n\
4342             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4343             slots — no black-hole.\n\
4344             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4345             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4346             peer out-of-band.\n\n\
4347             Issue #7 (silent black-hole on relay change) caught this.",
4348            n = pinned.len(),
4349        );
4350    }
4351
4352    let client = crate::relay_client::RelayClient::new(normalized);
4353    client.check_healthz()?;
4354    let alloc = client.allocate_slot(Some(&handle))?;
4355
4356    if destructive && !pinned.is_empty() {
4357        eprintln!(
4358            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4359             until they re-pin: {peers}",
4360            mode = if replace { "replacing" } else { "rotating" },
4361            n = pinned.len(),
4362            peers = pinned.join(", "),
4363        );
4364    }
4365
4366    // Write the new slot via the single source of truth for the self-slot
4367    // shape. Additive by default; --replace starts from an empty self so
4368    // only this slot remains.
4369    let mut state = existing;
4370    if replace {
4371        state["self"] = Value::Null;
4372    }
4373    crate::endpoints::upsert_self_endpoint(
4374        &mut state,
4375        Endpoint {
4376            relay_url: normalized.to_string(),
4377            slot_id: alloc.slot_id.clone(),
4378            slot_token: alloc.slot_token.clone(),
4379            scope: new_scope,
4380        },
4381    );
4382    config::write_relay_state(&state)?;
4383    let eps = self_endpoints(&state);
4384
4385    let scope_str = format!("{new_scope:?}").to_lowercase();
4386    if as_json {
4387        println!(
4388            "{}",
4389            serde_json::to_string(&json!({
4390                "relay_url": normalized,
4391                "slot_id": alloc.slot_id,
4392                "scope": scope_str,
4393                "endpoints": eps.len(),
4394                "additive": !replace,
4395                "slot_token_present": true,
4396            }))?
4397        );
4398    } else {
4399        println!(
4400            "bound {scope_str} slot on {normalized} (slot {})",
4401            alloc.slot_id
4402        );
4403        println!(
4404            "self now has {n} endpoint(s): {list}",
4405            n = eps.len(),
4406            list = eps
4407                .iter()
4408                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4409                .collect::<Vec<_>>()
4410                .join(", "),
4411        );
4412    }
4413    Ok(())
4414}
4415
4416// ---------- add-peer-slot ----------
4417
4418fn cmd_add_peer_slot(
4419    handle: &str,
4420    url: &str,
4421    slot_id: &str,
4422    slot_token: &str,
4423    as_json: bool,
4424) -> Result<()> {
4425    use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
4426    let mut state = config::read_relay_state()?;
4427
4428    // E3 (v0.13.2): ADD this slot to the peer's endpoint set — don't REPLACE
4429    // the whole entry. The old flat `peers.insert` clobbered an existing
4430    // peer's federation endpoint when pinning a local slot, silently dropping
4431    // the federation route (glossy-magnolia + wisp-blossom repro: pinning a
4432    // loopback slot made the peer flat loopback-only). Mirror bind-relay's
4433    // additive semantics: upsert by relay_url into the peer's endpoints[].
4434    let new_ep = Endpoint {
4435        relay_url: url.to_string(),
4436        slot_id: slot_id.to_string(),
4437        slot_token: slot_token.to_string(),
4438        scope: infer_scope_from_url(url),
4439    };
4440    let mut endpoints: Vec<Endpoint> = state
4441        .get("peers")
4442        .and_then(|p| p.get(handle))
4443        .and_then(|e| e.get("endpoints"))
4444        .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
4445        .unwrap_or_default();
4446    // Back-compat: seed from legacy flat fields when the peer predates endpoints[].
4447    if endpoints.is_empty()
4448        && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
4449        && let (Some(ru), Some(si), Some(st)) = (
4450            peer.get("relay_url").and_then(Value::as_str),
4451            peer.get("slot_id").and_then(Value::as_str),
4452            peer.get("slot_token").and_then(Value::as_str),
4453        )
4454    {
4455        endpoints.push(Endpoint {
4456            relay_url: ru.to_string(),
4457            slot_id: si.to_string(),
4458            slot_token: st.to_string(),
4459            scope: infer_scope_from_url(ru),
4460        });
4461    }
4462    // Upsert by relay_url: refresh in place if already pinned, else append.
4463    if let Some(existing) = endpoints
4464        .iter_mut()
4465        .find(|e| e.relay_url == new_ep.relay_url)
4466    {
4467        *existing = new_ep;
4468    } else {
4469        endpoints.push(new_ep);
4470    }
4471    let n = endpoints.len();
4472    pin_peer_endpoints(&mut state, handle, &endpoints)?;
4473    config::write_relay_state(&state)?;
4474    if as_json {
4475        println!(
4476            "{}",
4477            serde_json::to_string(&json!({
4478                "handle": handle,
4479                "relay_url": url,
4480                "slot_id": slot_id,
4481                "added": true,
4482                "endpoint_count": n,
4483            }))?
4484        );
4485    } else {
4486        println!(
4487            "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
4488        );
4489    }
4490    Ok(())
4491}
4492
4493// ---------- push ----------
4494
4495fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4496    let state = config::read_relay_state()?;
4497    let peers = state["peers"].as_object().cloned().unwrap_or_default();
4498    if peers.is_empty() {
4499        bail!(
4500            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4501        );
4502    }
4503    let outbox_dir = config::outbox_dir()?;
4504    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
4505    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
4506    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
4507    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
4508    if outbox_dir.exists() {
4509        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4510        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4511            let path = entry.path();
4512            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4513                continue;
4514            }
4515            let stem = match path.file_stem().and_then(|s| s.to_str()) {
4516                Some(s) => s.to_string(),
4517                None => continue,
4518            };
4519            if pinned.contains(&stem) {
4520                continue;
4521            }
4522            // Try the bare-handle of the orphaned stem — if THAT matches a
4523            // pinned peer, the stem is a stale FQDN-suffixed file.
4524            let bare = crate::agent_card::bare_handle(&stem);
4525            if pinned.contains(bare) {
4526                eprintln!(
4527                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4528                     Merge with: `cat {} >> {}` then delete the FQDN file.",
4529                    stem,
4530                    path.display(),
4531                    outbox_dir.join(format!("{bare}.jsonl")).display(),
4532                );
4533            }
4534        }
4535    }
4536    if !outbox_dir.exists() {
4537        if as_json {
4538            println!(
4539                "{}",
4540                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4541            );
4542        } else {
4543            println!("phyllis: nothing to dial out — write a message first with `wire send`");
4544        }
4545        return Ok(());
4546    }
4547
4548    let mut pushed = Vec::new();
4549    let mut skipped = Vec::new();
4550
4551    // v0.5.17: walk each peer's pinned endpoints in priority order (local
4552    // first if we share a local relay, federation second). Try POST on the
4553    // first endpoint; on transport failure, fall through to the next.
4554    // Falls back to the v0.5.16 legacy single-endpoint code path when the
4555    // peer record carries no `endpoints[]` array (back-compat).
4556    for (peer_handle, _) in peers.iter() {
4557        if let Some(want) = peer_filter
4558            && peer_handle != want
4559        {
4560            continue;
4561        }
4562        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4563        if !outbox.exists() {
4564            continue;
4565        }
4566        let ordered_endpoints =
4567            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4568        if ordered_endpoints.is_empty() {
4569            // Unreachable peer (no federation endpoint AND our local
4570            // relay doesn't match the peer's). Skip with a loud reason
4571            // rather than silently dropping events.
4572            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4573                let event: Value = match serde_json::from_str(line) {
4574                    Ok(v) => v,
4575                    Err(_) => continue,
4576                };
4577                let event_id = event
4578                    .get("event_id")
4579                    .and_then(Value::as_str)
4580                    .unwrap_or("")
4581                    .to_string();
4582                skipped.push(json!({
4583                    "peer": peer_handle,
4584                    "event_id": event_id,
4585                    "reason": "no reachable endpoint pinned for peer",
4586                }));
4587            }
4588            continue;
4589        }
4590        let body = std::fs::read_to_string(&outbox)?;
4591        for line in body.lines() {
4592            let event: Value = match serde_json::from_str(line) {
4593                Ok(v) => v,
4594                Err(_) => continue,
4595            };
4596            let event_id = event
4597                .get("event_id")
4598                .and_then(Value::as_str)
4599                .unwrap_or("")
4600                .to_string();
4601
4602            let mut delivered = false;
4603            let mut last_err_reason: Option<String> = None;
4604            for endpoint in &ordered_endpoints {
4605                let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4606                match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
4607                    Ok(resp) => {
4608                        if resp.status == "duplicate" {
4609                            skipped.push(json!({
4610                                "peer": peer_handle,
4611                                "event_id": event_id,
4612                                "reason": "duplicate",
4613                                "endpoint": endpoint.relay_url,
4614                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4615                            }));
4616                        } else {
4617                            pushed.push(json!({
4618                                "peer": peer_handle,
4619                                "event_id": event_id,
4620                                "endpoint": endpoint.relay_url,
4621                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4622                            }));
4623                        }
4624                        delivered = true;
4625                        break;
4626                    }
4627                    Err(e) => {
4628                        // Local-first endpoint failed; record reason and
4629                        // try the next endpoint silently (operator sees
4630                        // the federation success). If every endpoint
4631                        // fails, the last reason is what gets reported.
4632                        last_err_reason = Some(crate::relay_client::format_transport_error(&e));
4633                    }
4634                }
4635            }
4636            if !delivered {
4637                skipped.push(json!({
4638                    "peer": peer_handle,
4639                    "event_id": event_id,
4640                    "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
4641                }));
4642            }
4643        }
4644    }
4645
4646    if as_json {
4647        println!(
4648            "{}",
4649            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
4650        );
4651    } else {
4652        println!(
4653            "pushed {} event(s); skipped {} ({})",
4654            pushed.len(),
4655            skipped.len(),
4656            if skipped.is_empty() {
4657                "none"
4658            } else {
4659                "see --json for detail"
4660            }
4661        );
4662    }
4663    Ok(())
4664}
4665
4666// ---------- pull ----------
4667
4668fn cmd_pull(as_json: bool) -> Result<()> {
4669    let state = config::read_relay_state()?;
4670    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4671    if self_state.is_null() {
4672        bail!("self slot not bound — run `wire bind-relay <url>` first");
4673    }
4674
4675    // v0.5.17: pull from every endpoint in self.endpoints (federation +
4676    // optional local). Each endpoint has its own per-scope cursor so we
4677    // don't re-pull events we've already seen on that path. Events from
4678    // all endpoints feed into the same inbox JSONL via process_events;
4679    // dedup by event_id is the last line of defense.
4680    // Falls back to a single federation endpoint synthesized from the
4681    // top-level legacy fields when self.endpoints is absent (v0.5.16
4682    // back-compat).
4683    let endpoints = crate::endpoints::self_endpoints(&state);
4684    if endpoints.is_empty() {
4685        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
4686    }
4687
4688    let inbox_dir = config::inbox_dir()?;
4689    config::ensure_dirs()?;
4690
4691    let mut total_seen = 0usize;
4692    let mut all_written: Vec<Value> = Vec::new();
4693    let mut all_rejected: Vec<Value> = Vec::new();
4694    let mut all_blocked = false;
4695    let mut all_advance_cursor_to: Option<String> = None;
4696
4697    for endpoint in &endpoints {
4698        let cursor_key = endpoint_cursor_key(endpoint.scope);
4699        let last_event_id = self_state
4700            .get(&cursor_key)
4701            .and_then(Value::as_str)
4702            .map(str::to_string);
4703        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4704        let events = match client.list_events(
4705            &endpoint.slot_id,
4706            &endpoint.slot_token,
4707            last_event_id.as_deref(),
4708            Some(1000),
4709        ) {
4710            Ok(ev) => ev,
4711            Err(e) => {
4712                // One endpoint's failure shouldn't kill the whole pull.
4713                // The local-relay-down case in particular needs to
4714                // gracefully continue against federation.
4715                eprintln!(
4716                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
4717                    endpoint.relay_url,
4718                    endpoint.scope,
4719                    crate::relay_client::format_transport_error(&e),
4720                );
4721                continue;
4722            }
4723        };
4724        total_seen += events.len();
4725        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
4726        all_written.extend(result.written.iter().cloned());
4727        all_rejected.extend(result.rejected.iter().cloned());
4728        if result.blocked {
4729            all_blocked = true;
4730        }
4731        // Advance per-endpoint cursor. The cursor key is scope-specific
4732        // so federation and local don't trample each other.
4733        if let Some(eid) = result.advance_cursor_to.clone() {
4734            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
4735                all_advance_cursor_to = Some(eid.clone());
4736            }
4737            let key = cursor_key.clone();
4738            config::update_relay_state(|state| {
4739                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4740                    self_obj.insert(key, Value::String(eid));
4741                }
4742                Ok(())
4743            })?;
4744        }
4745    }
4746
4747    // Compatibility shim for the legacy single-cursor code paths below:
4748    // `result` used to come from one process_events call; we now have
4749    // per-endpoint results aggregated into the all_* accumulators.
4750    // Reconstruct a synthetic result for the remaining display logic.
4751    let result = crate::pull::PullResult {
4752        written: all_written,
4753        rejected: all_rejected,
4754        blocked: all_blocked,
4755        advance_cursor_to: all_advance_cursor_to,
4756    };
4757    let events_len = total_seen;
4758
4759    // Cursor advance happened per-endpoint above; no aggregate cursor
4760    // write needed here.
4761
4762    if as_json {
4763        println!(
4764            "{}",
4765            serde_json::to_string(&json!({
4766                "written": result.written,
4767                "rejected": result.rejected,
4768                "total_seen": events_len,
4769                "cursor_blocked": result.blocked,
4770                "cursor_advanced_to": result.advance_cursor_to,
4771            }))?
4772        );
4773    } else {
4774        let blocking = result
4775            .rejected
4776            .iter()
4777            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
4778            .count();
4779        if blocking > 0 {
4780            println!(
4781                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
4782                events_len,
4783                result.written.len(),
4784                result.rejected.len(),
4785                blocking,
4786            );
4787        } else {
4788            println!(
4789                "pulled {} event(s); wrote {}; rejected {}",
4790                events_len,
4791                result.written.len(),
4792                result.rejected.len(),
4793            );
4794        }
4795    }
4796    Ok(())
4797}
4798
4799/// v0.5.17: cursor key for an endpoint's per-scope read position.
4800/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
4801/// back-compat with on-disk relay_state files; local uses a
4802/// `_local` suffix.
4803fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4804    match scope {
4805        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4806        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4807        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4808        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4809    }
4810}
4811
4812// ---------- rotate-slot ----------
4813
4814fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4815    if !config::is_initialized()? {
4816        bail!("not initialized — run `wire init <handle>` first");
4817    }
4818    let mut state = config::read_relay_state()?;
4819    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4820    if self_state.is_null() {
4821        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4822    }
4823    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
4824    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
4825    // top-level legacy fields directly and bailed for those sessions.
4826    let primary = crate::endpoints::self_primary_endpoint(&state)
4827        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
4828    let url = primary.relay_url.clone();
4829    let old_slot_id = primary.slot_id.clone();
4830    let old_slot_token = primary.slot_token.clone();
4831
4832    // Read identity to sign the announcement.
4833    let card = config::read_agent_card()?;
4834    let did = card
4835        .get("did")
4836        .and_then(Value::as_str)
4837        .unwrap_or("")
4838        .to_string();
4839    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4840    let pk_b64 = card
4841        .get("verify_keys")
4842        .and_then(Value::as_object)
4843        .and_then(|m| m.values().next())
4844        .and_then(|v| v.get("key"))
4845        .and_then(Value::as_str)
4846        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4847        .to_string();
4848    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4849    let sk_seed = config::read_private_key()?;
4850
4851    // Allocate new slot on the same relay.
4852    let normalized = url.trim_end_matches('/').to_string();
4853    let client = crate::relay_client::RelayClient::new(&normalized);
4854    client
4855        .check_healthz()
4856        .context("aborting rotation; old slot still valid")?;
4857    let alloc = client.allocate_slot(Some(&handle))?;
4858    let new_slot_id = alloc.slot_id.clone();
4859    let new_slot_token = alloc.slot_token.clone();
4860
4861    // Optionally announce the rotation to every paired peer via the OLD slot.
4862    // Each peer's recipient-side `wire pull` will pick up this event before
4863    // their daemon next polls the new slot — but auto-update of peer's
4864    // relay.json from a wire_close event is a v0.2 daemon feature; for now
4865    // peers see the event and an operator must manually `add-peer-slot` the
4866    // new coords, OR re-pair via SAS.
4867    let mut announced: Vec<String> = Vec::new();
4868    if !no_announce {
4869        let now = time::OffsetDateTime::now_utc()
4870            .format(&time::format_description::well_known::Rfc3339)
4871            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4872        let body = json!({
4873            "reason": "operator-initiated slot rotation",
4874            "new_relay_url": url,
4875            "new_slot_id": new_slot_id,
4876            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
4877            // In v0.1 slot tokens are bilateral-shared, so peer can post via
4878            // existing add-peer-slot flow if operator chooses to re-issue.
4879        });
4880        let peers = state["peers"].as_object().cloned().unwrap_or_default();
4881        for (peer_handle, _peer_info) in peers.iter() {
4882            let event = json!({
4883                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4884                "timestamp": now.clone(),
4885                "from": did,
4886                "to": format!("did:wire:{peer_handle}"),
4887                "type": "wire_close",
4888                "kind": 1201,
4889                "body": body.clone(),
4890            });
4891            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4892                Ok(s) => s,
4893                Err(e) => {
4894                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4895                    continue;
4896                }
4897            };
4898            // Post to OUR old slot (we're announcing on our own slot, NOT
4899            // peer's slot — peer reads from us). Wait, this is wrong: peers
4900            // read from THEIR OWN slot via wire pull. To reach peer A, we
4901            // post to peer A's slot. Use the existing per-peer slot mapping.
4902            let peer_info = match state["peers"].get(peer_handle) {
4903                Some(p) => p.clone(),
4904                None => continue,
4905            };
4906            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4907            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4908            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4909            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4910                continue;
4911            }
4912            let peer_client = if peer_url == url {
4913                client.clone()
4914            } else {
4915                crate::relay_client::RelayClient::new(peer_url)
4916            };
4917            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4918                Ok(_) => announced.push(peer_handle.clone()),
4919                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4920            }
4921        }
4922    }
4923
4924    // Swap the self-slot to the new one.
4925    state["self"] = json!({
4926        "relay_url": url,
4927        "slot_id": new_slot_id,
4928        "slot_token": new_slot_token,
4929    });
4930    config::write_relay_state(&state)?;
4931
4932    if as_json {
4933        println!(
4934            "{}",
4935            serde_json::to_string(&json!({
4936                "rotated": true,
4937                "old_slot_id": old_slot_id,
4938                "new_slot_id": new_slot_id,
4939                "relay_url": url,
4940                "announced_to": announced,
4941            }))?
4942        );
4943    } else {
4944        println!("rotated slot on {url}");
4945        println!(
4946            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4947        );
4948        println!("  new slot_id: {new_slot_id}");
4949        if !announced.is_empty() {
4950            println!(
4951                "  announced wire_close (kind=1201) to: {}",
4952                announced.join(", ")
4953            );
4954        }
4955        println!();
4956        println!("next steps:");
4957        println!("  - peers see the wire_close event in their next `wire pull`");
4958        println!(
4959            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4960        );
4961        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
4962        println!("  - until they do, you'll receive but they won't be able to reach you");
4963        // Suppress unused warning
4964        let _ = old_slot_token;
4965    }
4966    Ok(())
4967}
4968
4969// ---------- forget-peer ----------
4970
4971fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4972    let mut trust = config::read_trust()?;
4973    let mut removed_from_trust = false;
4974    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4975        && agents.remove(handle).is_some()
4976    {
4977        removed_from_trust = true;
4978    }
4979    config::write_trust(&trust)?;
4980
4981    let mut state = config::read_relay_state()?;
4982    let mut removed_from_relay = false;
4983    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4984        && peers.remove(handle).is_some()
4985    {
4986        removed_from_relay = true;
4987    }
4988    config::write_relay_state(&state)?;
4989
4990    let mut purged: Vec<String> = Vec::new();
4991    if purge {
4992        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4993            let path = dir.join(format!("{handle}.jsonl"));
4994            if path.exists() {
4995                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4996                purged.push(path.to_string_lossy().into());
4997            }
4998        }
4999    }
5000
5001    if !removed_from_trust && !removed_from_relay {
5002        if as_json {
5003            println!(
5004                "{}",
5005                serde_json::to_string(&json!({
5006                    "removed": false,
5007                    "reason": format!("peer {handle:?} not pinned"),
5008                }))?
5009            );
5010        } else {
5011            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
5012        }
5013        return Ok(());
5014    }
5015
5016    if as_json {
5017        println!(
5018            "{}",
5019            serde_json::to_string(&json!({
5020                "handle": handle,
5021                "removed_from_trust": removed_from_trust,
5022                "removed_from_relay_state": removed_from_relay,
5023                "purged_files": purged,
5024            }))?
5025        );
5026    } else {
5027        println!("forgot peer {handle:?}");
5028        if removed_from_trust {
5029            println!("  - removed from trust.json");
5030        }
5031        if removed_from_relay {
5032            println!("  - removed from relay.json");
5033        }
5034        if !purged.is_empty() {
5035            for p in &purged {
5036                println!("  - deleted {p}");
5037            }
5038        } else if !purge {
5039            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
5040        }
5041    }
5042    Ok(())
5043}
5044
5045// ---------- daemon (long-lived push+pull sync) ----------
5046
5047fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5048    if !config::is_initialized()? {
5049        bail!("not initialized — run `wire init <handle>` first");
5050    }
5051    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5052
5053    if !as_json {
5054        if once {
5055            eprintln!("wire daemon: single sync cycle, then exit");
5056        } else {
5057            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5058        }
5059    }
5060
5061    // Recover from prior crash: any pending pair in transient state had its
5062    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
5063    // the relay slots and mark the files so the operator can re-issue.
5064    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5065        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5066    }
5067
5068    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
5069    // to our slot, the subscriber signals `wake_rx`; we use it as the
5070    // sleep-or-wake gate of the polling loop. Polling stays as the
5071    // safety net — stream errors fall back transparently to the existing
5072    // interval-based cadence.
5073    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5074    if !once {
5075        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5076    }
5077
5078    loop {
5079        let pushed = run_sync_push().unwrap_or_else(|e| {
5080            eprintln!("daemon: push error: {e:#}");
5081            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5082        });
5083        let pulled = run_sync_pull().unwrap_or_else(|e| {
5084            eprintln!("daemon: pull error: {e:#}");
5085            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5086        });
5087        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5088            eprintln!("daemon: pending-pair tick error: {e:#}");
5089            json!({"transitions": []})
5090        });
5091
5092        if as_json {
5093            println!(
5094                "{}",
5095                serde_json::to_string(&json!({
5096                    "ts": time::OffsetDateTime::now_utc()
5097                        .format(&time::format_description::well_known::Rfc3339)
5098                        .unwrap_or_default(),
5099                    "push": pushed,
5100                    "pull": pulled,
5101                    "pairs": pairs,
5102                }))?
5103            );
5104        } else {
5105            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5106            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5107            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5108            let pair_transitions = pairs["transitions"]
5109                .as_array()
5110                .map(|a| a.len())
5111                .unwrap_or(0);
5112            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5113                eprintln!(
5114                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5115                );
5116            }
5117            // Loud per-transition logging so operator sees pair progress live.
5118            if let Some(arr) = pairs["transitions"].as_array() {
5119                for t in arr {
5120                    eprintln!(
5121                        "  pair {} : {} → {}",
5122                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
5123                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
5124                        t.get("to").and_then(Value::as_str).unwrap_or("?")
5125                    );
5126                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
5127                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5128                    {
5129                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
5130                        eprintln!(
5131                            "    Run: wire pair-confirm {} {}",
5132                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
5133                            sas
5134                        );
5135                    }
5136                }
5137            }
5138        }
5139
5140        if once {
5141            return Ok(());
5142        }
5143        // Wait either for the next poll-interval tick OR for a stream
5144        // wake signal — whichever comes first. Drain any additional
5145        // wake-ups that accumulated during the previous cycle since one
5146        // pull catches up everything.
5147        //
5148        // v0.13.2 (wisp-blossom): if the stream subscriber thread has gone
5149        // away, `wake_rx` is Disconnected and `recv_timeout` returns
5150        // INSTANTLY — which would busy-spin the sync loop (hammering push/pull
5151        // + the relay with zero delay). Fall back to a plain sleep so a dead
5152        // stream degrades to normal polling and never kills or pegs the
5153        // daemon. (Realizes the "decouple stream from sync" hardening — a
5154        // stream failure must never affect the push/pull loop.)
5155        match wake_rx.recv_timeout(interval) {
5156            Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
5157            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
5158                std::thread::sleep(interval);
5159            }
5160        }
5161        while wake_rx.try_recv().is_ok() {}
5162    }
5163}
5164
5165/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
5166/// shape `wire push --json` emits.
5167fn run_sync_push() -> Result<Value> {
5168    let state = config::read_relay_state()?;
5169    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5170    if peers.is_empty() {
5171        return Ok(json!({"pushed": [], "skipped": []}));
5172    }
5173    let outbox_dir = config::outbox_dir()?;
5174    if !outbox_dir.exists() {
5175        return Ok(json!({"pushed": [], "skipped": []}));
5176    }
5177    let mut pushed = Vec::new();
5178    let mut skipped = Vec::new();
5179    for (peer_handle, slot_info) in peers.iter() {
5180        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5181        if !outbox.exists() {
5182            continue;
5183        }
5184        let url = slot_info["relay_url"].as_str().unwrap_or("");
5185        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5186        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5187        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5188            continue;
5189        }
5190        let client = crate::relay_client::RelayClient::new(url);
5191        let body = std::fs::read_to_string(&outbox)?;
5192        for line in body.lines() {
5193            let event: Value = match serde_json::from_str(line) {
5194                Ok(v) => v,
5195                Err(_) => continue,
5196            };
5197            let event_id = event
5198                .get("event_id")
5199                .and_then(Value::as_str)
5200                .unwrap_or("")
5201                .to_string();
5202            match client.post_event(slot_id, slot_token, &event) {
5203                Ok(resp) => {
5204                    if resp.status == "duplicate" {
5205                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5206                    } else {
5207                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5208                    }
5209                }
5210                Err(e) => {
5211                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
5212                    // errors aren't hidden behind the topmost-context URL string.
5213                    // Issue #6 highest-impact silent-fail fix.
5214                    let reason = crate::relay_client::format_transport_error(&e);
5215                    skipped
5216                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5217                }
5218            }
5219        }
5220    }
5221    Ok(json!({"pushed": pushed, "skipped": skipped}))
5222}
5223
5224/// Programmatic pull. Same shape as `wire pull --json`.
5225///
5226/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
5227/// created via `wire session new --with-local` (which only writes
5228/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
5229/// Pre-v0.9 this function read only the top-level fields and silently
5230/// returned `{}` for any v0.5.17+ session.
5231fn run_sync_pull() -> Result<Value> {
5232    let state = config::read_relay_state()?;
5233    if state.get("self").map(Value::is_null).unwrap_or(true) {
5234        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5235    }
5236    // E2 (v0.13.2): pull EVERY self endpoint, not just the primary. A session
5237    // that bound a local slot (additive) alongside its federation slot used to
5238    // have the daemon pull ONLY the primary (federation) endpoint — the local
5239    // slot was never serviced, so same-box loopback delivery silently never
5240    // happened until a manual restart re-seeded the (startup-only) stream
5241    // subscriber. Now each endpoint is pulled with its OWN cursor.
5242    let endpoints = crate::endpoints::self_endpoints(&state);
5243    if endpoints.is_empty() {
5244        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5245    }
5246    let inbox_dir = config::inbox_dir()?;
5247    config::ensure_dirs()?;
5248
5249    // Per-slot cursors live at `self.cursors.<slot_id>`. The legacy global
5250    // `self.last_pulled_event_id` is migrated as the cursor for the PRIMARY
5251    // slot only (a federation event id won't match a local slot's log); other
5252    // slots start from None and `process_events` dedups against the inbox.
5253    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
5254    let legacy_cursor = self_obj
5255        .get("last_pulled_event_id")
5256        .and_then(Value::as_str)
5257        .map(str::to_string);
5258    let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
5259    let mut cursors: serde_json::Map<String, Value> = self_obj
5260        .get("cursors")
5261        .and_then(Value::as_object)
5262        .cloned()
5263        .unwrap_or_default();
5264
5265    let mut all_written: Vec<Value> = Vec::new();
5266    let mut all_rejected: Vec<Value> = Vec::new();
5267    let mut total_seen = 0usize;
5268    let mut blocked_any = false;
5269
5270    for ep in &endpoints {
5271        if ep.relay_url.is_empty() {
5272            continue;
5273        }
5274        let cursor = cursors
5275            .get(&ep.slot_id)
5276            .and_then(Value::as_str)
5277            .map(str::to_string)
5278            .or_else(|| {
5279                if Some(&ep.slot_id) == primary_slot.as_ref() {
5280                    legacy_cursor.clone()
5281                } else {
5282                    None
5283                }
5284            });
5285        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
5286        // One endpoint erroring (relay down, slot gone) must NOT stop the
5287        // others — a dead local relay shouldn't black-hole federation pulls.
5288        let events =
5289            match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
5290                Ok(e) => e,
5291                Err(e) => {
5292                    eprintln!(
5293                        "daemon: pull error on {} slot {} (continuing): {e:#}",
5294                        ep.relay_url, ep.slot_id
5295                    );
5296                    continue;
5297                }
5298            };
5299        total_seen += events.len();
5300        // P0.1 shared cursor-blocking logic (matches `wire pull`). A block on
5301        // one slot only stalls THAT slot's cursor; other slots keep flowing.
5302        let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
5303        if let Some(eid) = &result.advance_cursor_to {
5304            cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
5305        }
5306        blocked_any |= result.blocked;
5307        all_written.extend(result.written);
5308        all_rejected.extend(result.rejected);
5309    }
5310
5311    // P0.3 flock-protected RMW: persist per-slot cursors + keep the legacy
5312    // global cursor in sync with the primary slot for back-compat with older
5313    // binaries that only read `last_pulled_event_id`.
5314    let primary_cursor = primary_slot
5315        .as_ref()
5316        .and_then(|s| cursors.get(s))
5317        .and_then(Value::as_str)
5318        .map(str::to_string);
5319    config::update_relay_state(|state| {
5320        if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5321            self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
5322            if let Some(pc) = &primary_cursor {
5323                self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
5324            }
5325        }
5326        Ok(())
5327    })?;
5328
5329    Ok(json!({
5330        "written": all_written,
5331        "rejected": all_rejected,
5332        "total_seen": total_seen,
5333        "cursor_blocked": blocked_any,
5334        "endpoints_pulled": endpoints.len(),
5335    }))
5336}
5337
5338// ---------- pin (manual out-of-band peer pairing) ----------
5339
5340fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5341    let body =
5342        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5343    let card: Value =
5344        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5345    crate::agent_card::verify_agent_card(&card)
5346        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5347
5348    let mut trust = config::read_trust()?;
5349    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5350
5351    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5352    let handle = crate::agent_card::display_handle_from_did(did).to_string();
5353    config::write_trust(&trust)?;
5354
5355    if as_json {
5356        println!(
5357            "{}",
5358            serde_json::to_string(&json!({
5359                "handle": handle,
5360                "did": did,
5361                "tier": "VERIFIED",
5362                "pinned": true,
5363            }))?
5364        );
5365    } else {
5366        println!("pinned {handle} ({did}) at tier VERIFIED");
5367    }
5368    Ok(())
5369}
5370
5371// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
5372
5373fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5374    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5375}
5376
5377fn cmd_pair_join(
5378    code_phrase: &str,
5379    relay_url: &str,
5380    auto_yes: bool,
5381    timeout_secs: u64,
5382) -> Result<()> {
5383    pair_orchestrate(
5384        relay_url,
5385        Some(code_phrase),
5386        "guest",
5387        auto_yes,
5388        timeout_secs,
5389    )
5390}
5391
5392/// Shared orchestration for both sides of the SAS pairing.
5393///
5394/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
5395/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
5396/// `pair_session_confirm_sas` instead.
5397fn pair_orchestrate(
5398    relay_url: &str,
5399    code_in: Option<&str>,
5400    role: &str,
5401    auto_yes: bool,
5402    timeout_secs: u64,
5403) -> Result<()> {
5404    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5405
5406    let mut s = pair_session_open(role, relay_url, code_in)?;
5407
5408    if role == "host" {
5409        eprintln!();
5410        eprintln!("share this code phrase with your peer:");
5411        eprintln!();
5412        eprintln!("    {}", s.code);
5413        eprintln!();
5414        eprintln!(
5415            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5416            s.code
5417        );
5418    } else {
5419        eprintln!();
5420        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5421    }
5422
5423    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
5424    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
5425    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
5426    // see the process is alive while the other side connects.
5427    const HEARTBEAT_SECS: u64 = 10;
5428    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5429    let started = std::time::Instant::now();
5430    let mut last_heartbeat = started;
5431    let formatted = loop {
5432        if let Some(sas) = pair_session_try_sas(&mut s)? {
5433            break sas;
5434        }
5435        let now = std::time::Instant::now();
5436        if now >= deadline {
5437            return Err(anyhow!(
5438                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5439            ));
5440        }
5441        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5442            let elapsed = now.duration_since(started).as_secs();
5443            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
5444            last_heartbeat = now;
5445        }
5446        std::thread::sleep(std::time::Duration::from_millis(250));
5447    };
5448
5449    eprintln!();
5450    eprintln!("SAS digits (must match peer's terminal):");
5451    eprintln!();
5452    eprintln!("    {formatted}");
5453    eprintln!();
5454
5455    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
5456    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
5457    if !auto_yes {
5458        eprint!("does this match your peer's terminal? [y/N]: ");
5459        use std::io::Write;
5460        std::io::stderr().flush().ok();
5461        let mut input = String::new();
5462        std::io::stdin().read_line(&mut input)?;
5463        let trimmed = input.trim().to_lowercase();
5464        if trimmed != "y" && trimmed != "yes" {
5465            bail!("SAS confirmation declined — aborting pairing");
5466        }
5467    }
5468    s.sas_confirmed = true;
5469
5470    // Stage 4 — seal+exchange bootstrap, pin peer.
5471    let result = pair_session_finalize(&mut s, timeout_secs)?;
5472
5473    let peer_did = result["paired_with"].as_str().unwrap_or("");
5474    let peer_role = if role == "host" { "guest" } else { "host" };
5475    eprintln!("paired with {peer_did} (peer role: {peer_role})");
5476    eprintln!("peer card pinned at tier VERIFIED");
5477    eprintln!(
5478        "peer relay slot saved to {}",
5479        config::relay_state_path()?.display()
5480    );
5481
5482    println!("{}", serde_json::to_string(&result)?);
5483    Ok(())
5484}
5485
5486// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
5487// and pair_session_finalize, both of which inline their own deadline loops.)
5488
5489// ---------- pair — single-shot init + pair-* + setup ----------
5490
5491fn cmd_pair(
5492    handle: &str,
5493    code: Option<&str>,
5494    relay: &str,
5495    auto_yes: bool,
5496    timeout_secs: u64,
5497    no_setup: bool,
5498) -> Result<()> {
5499    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
5500    // bails loudly if a different handle is already set (operator must explicitly delete).
5501    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5502    let did = init_result
5503        .get("did")
5504        .and_then(|v| v.as_str())
5505        .unwrap_or("(unknown)")
5506        .to_string();
5507    let already = init_result
5508        .get("already_initialized")
5509        .and_then(|v| v.as_bool())
5510        .unwrap_or(false);
5511    if already {
5512        println!("(identity {did} already initialized — reusing)");
5513    } else {
5514        println!("initialized {did}");
5515    }
5516    println!();
5517
5518    // Step 2 — pair-host or pair-join based on code presence.
5519    match code {
5520        None => {
5521            println!("hosting pair on {relay} (no code = host) ...");
5522            cmd_pair_host(relay, auto_yes, timeout_secs)?;
5523        }
5524        Some(c) => {
5525            println!("joining pair with code {c} on {relay} ...");
5526            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5527        }
5528    }
5529
5530    // Step 3 — register wire as MCP server in detected client configs (idempotent).
5531    if !no_setup {
5532        println!();
5533        println!("registering wire as MCP server in detected client configs ...");
5534        if let Err(e) = cmd_setup(true) {
5535            // Non-fatal — pair succeeded, just print the warning.
5536            eprintln!("warn: setup --apply failed: {e}");
5537            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
5538        }
5539    }
5540
5541    println!();
5542    println!("pair complete. Next steps:");
5543    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
5544    println!("  wire send <peer> claim <msg>   # send your peer something");
5545    println!("  wire tail                      # watch incoming events");
5546    Ok(())
5547}
5548
5549// ---------- detached pair (daemon-orchestrated) ----------
5550
5551/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
5552/// pair-host/-join into a single command. The non-detached variant lives in
5553/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
5554fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5555    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5556    let did = init_result
5557        .get("did")
5558        .and_then(|v| v.as_str())
5559        .unwrap_or("(unknown)")
5560        .to_string();
5561    let already = init_result
5562        .get("already_initialized")
5563        .and_then(|v| v.as_bool())
5564        .unwrap_or(false);
5565    if already {
5566        println!("(identity {did} already initialized — reusing)");
5567    } else {
5568        println!("initialized {did}");
5569    }
5570    println!();
5571    match code {
5572        None => cmd_pair_host_detach(relay, false),
5573        Some(c) => cmd_pair_join_detach(c, relay, false),
5574    }
5575}
5576
5577fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5578    if !config::is_initialized()? {
5579        bail!("not initialized — run `wire init <handle>` first");
5580    }
5581    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5582        Ok(b) => b,
5583        Err(e) => {
5584            if !as_json {
5585                eprintln!(
5586                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5587                );
5588            }
5589            false
5590        }
5591    };
5592    let code = crate::sas::generate_code_phrase();
5593    let code_hash = crate::pair_session::derive_code_hash(&code);
5594    let now = time::OffsetDateTime::now_utc()
5595        .format(&time::format_description::well_known::Rfc3339)
5596        .unwrap_or_default();
5597    let p = crate::pending_pair::PendingPair {
5598        code: code.clone(),
5599        code_hash,
5600        role: "host".to_string(),
5601        relay_url: relay_url.to_string(),
5602        status: "request_host".to_string(),
5603        sas: None,
5604        peer_did: None,
5605        created_at: now,
5606        last_error: None,
5607        pair_id: None,
5608        our_slot_id: None,
5609        our_slot_token: None,
5610        spake2_seed_b64: None,
5611    };
5612    crate::pending_pair::write_pending(&p)?;
5613    if as_json {
5614        println!(
5615            "{}",
5616            serde_json::to_string(&json!({
5617                "state": "queued",
5618                "code_phrase": code,
5619                "relay_url": relay_url,
5620                "role": "host",
5621                "daemon_spawned": daemon_spawned,
5622            }))?
5623        );
5624    } else {
5625        if daemon_spawned {
5626            println!("(started wire daemon in background)");
5627        }
5628        println!("detached pair-host queued. Share this code with your peer:\n");
5629        println!("    {code}\n");
5630        println!("Next steps:");
5631        println!("  wire pair-list                                # check status");
5632        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
5633        println!("  wire pair-cancel  {code}            # to abort");
5634    }
5635    Ok(())
5636}
5637
5638fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
5639    if !config::is_initialized()? {
5640        bail!("not initialized — run `wire init <handle>` first");
5641    }
5642    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5643        Ok(b) => b,
5644        Err(e) => {
5645            if !as_json {
5646                eprintln!(
5647                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5648                );
5649            }
5650            false
5651        }
5652    };
5653    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5654    let code_hash = crate::pair_session::derive_code_hash(&code);
5655    let now = time::OffsetDateTime::now_utc()
5656        .format(&time::format_description::well_known::Rfc3339)
5657        .unwrap_or_default();
5658    let p = crate::pending_pair::PendingPair {
5659        code: code.clone(),
5660        code_hash,
5661        role: "guest".to_string(),
5662        relay_url: relay_url.to_string(),
5663        status: "request_guest".to_string(),
5664        sas: None,
5665        peer_did: None,
5666        created_at: now,
5667        last_error: None,
5668        pair_id: None,
5669        our_slot_id: None,
5670        our_slot_token: None,
5671        spake2_seed_b64: None,
5672    };
5673    crate::pending_pair::write_pending(&p)?;
5674    if as_json {
5675        println!(
5676            "{}",
5677            serde_json::to_string(&json!({
5678                "state": "queued",
5679                "code_phrase": code,
5680                "relay_url": relay_url,
5681                "role": "guest",
5682                "daemon_spawned": daemon_spawned,
5683            }))?
5684        );
5685    } else {
5686        if daemon_spawned {
5687            println!("(started wire daemon in background)");
5688        }
5689        println!("detached pair-join queued for code {code}.");
5690        println!(
5691            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
5692        );
5693    }
5694    Ok(())
5695}
5696
5697fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
5698    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5699    let typed: String = typed_digits
5700        .chars()
5701        .filter(|c| c.is_ascii_digit())
5702        .collect();
5703    if typed.len() != 6 {
5704        bail!(
5705            "expected 6 digits (got {} after stripping non-digits)",
5706            typed.len()
5707        );
5708    }
5709    let mut p = crate::pending_pair::read_pending(&code)?
5710        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
5711    if p.status != "sas_ready" {
5712        bail!(
5713            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
5714            p.status
5715        );
5716    }
5717    let stored = p
5718        .sas
5719        .as_ref()
5720        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
5721        .clone();
5722    if stored == typed {
5723        p.status = "confirmed".to_string();
5724        crate::pending_pair::write_pending(&p)?;
5725        if as_json {
5726            println!(
5727                "{}",
5728                serde_json::to_string(&json!({
5729                    "state": "confirmed",
5730                    "code_phrase": code,
5731                }))?
5732            );
5733        } else {
5734            println!("digits match. Daemon will finalize the handshake on its next tick.");
5735            println!("Run `wire peers` after a few seconds to confirm.");
5736        }
5737    } else {
5738        p.status = "aborted".to_string();
5739        p.last_error = Some(format!(
5740            "SAS digit mismatch (typed {typed}, expected {stored})"
5741        ));
5742        let client = crate::relay_client::RelayClient::new(&p.relay_url);
5743        let _ = client.pair_abandon(&p.code_hash);
5744        crate::pending_pair::write_pending(&p)?;
5745        crate::os_notify::toast(
5746            &format!("wire — pair aborted ({})", p.code),
5747            p.last_error.as_deref().unwrap_or("digits mismatch"),
5748        );
5749        if as_json {
5750            println!(
5751                "{}",
5752                serde_json::to_string(&json!({
5753                    "state": "aborted",
5754                    "code_phrase": code,
5755                    "error": "digits mismatch",
5756                }))?
5757            );
5758        }
5759        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
5760    }
5761    Ok(())
5762}
5763
5764fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
5765    if watch {
5766        return cmd_pair_list_watch(watch_interval_secs);
5767    }
5768    let spake2_items = crate::pending_pair::list_pending()?;
5769    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
5770    if as_json {
5771        // Backwards-compat: flat SPAKE2 array (the shape every existing
5772        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
5773        // surface programmatically via `wire pair-list-inbound --json`
5774        // and via `wire status --json` `pending_pairs.inbound_*` fields.
5775        println!("{}", serde_json::to_string(&spake2_items)?);
5776        return Ok(());
5777    }
5778    if spake2_items.is_empty() && inbound_items.is_empty() {
5779        println!("no pending pair sessions.");
5780        return Ok(());
5781    }
5782    // v0.5.14: inbound section first — these need operator action right now.
5783    // SPAKE2 sessions are typically already mid-flow.
5784    if !inbound_items.is_empty() {
5785        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
5786        println!(
5787            "{:<20} {:<35} {:<25} NEXT STEP",
5788            "PEER", "RELAY", "RECEIVED"
5789        );
5790        for p in &inbound_items {
5791            println!(
5792                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
5793                p.peer_handle,
5794                p.peer_relay_url,
5795                p.received_at,
5796                peer = p.peer_handle,
5797            );
5798        }
5799        println!();
5800    }
5801    if !spake2_items.is_empty() {
5802        println!("SPAKE2 SESSIONS");
5803        println!(
5804            "{:<15} {:<8} {:<18} {:<10} NOTE",
5805            "CODE", "ROLE", "STATUS", "SAS"
5806        );
5807        for p in spake2_items {
5808            let sas = p
5809                .sas
5810                .as_ref()
5811                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
5812                .unwrap_or_else(|| "—".to_string());
5813            let note = p
5814                .last_error
5815                .as_deref()
5816                .or(p.peer_did.as_deref())
5817                .unwrap_or("");
5818            println!(
5819                "{:<15} {:<8} {:<18} {:<10} {}",
5820                p.code, p.role, p.status, sas, note
5821            );
5822        }
5823    }
5824    Ok(())
5825}
5826
5827/// Stream-mode pair-list: never exits. Diffs per-code state every
5828/// `interval_secs` and prints one JSON line per transition (creation,
5829/// status flip, deletion). Useful for shell pipelines:
5830///
5831/// ```text
5832/// wire pair-list --watch | while read line; do
5833///     CODE=$(echo "$line" | jq -r .code)
5834///     STATUS=$(echo "$line" | jq -r .status)
5835///     ...
5836/// done
5837/// ```
5838fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
5839    use std::collections::HashMap;
5840    use std::io::Write;
5841    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5842    // Emit a snapshot synthetic event for every currently-pending pair on
5843    // startup so a consumer that arrives mid-flight sees the current state.
5844    let mut prev: HashMap<String, String> = HashMap::new();
5845    {
5846        let items = crate::pending_pair::list_pending()?;
5847        for p in &items {
5848            println!("{}", serde_json::to_string(&p)?);
5849            prev.insert(p.code.clone(), p.status.clone());
5850        }
5851        // Flush so the consumer's `while read` gets the snapshot promptly.
5852        let _ = std::io::stdout().flush();
5853    }
5854    loop {
5855        std::thread::sleep(interval);
5856        let items = match crate::pending_pair::list_pending() {
5857            Ok(v) => v,
5858            Err(_) => continue,
5859        };
5860        let mut cur: HashMap<String, String> = HashMap::new();
5861        for p in &items {
5862            cur.insert(p.code.clone(), p.status.clone());
5863            match prev.get(&p.code) {
5864                None => {
5865                    // New code appeared.
5866                    println!("{}", serde_json::to_string(&p)?);
5867                }
5868                Some(prev_status) if prev_status != &p.status => {
5869                    // Status flipped.
5870                    println!("{}", serde_json::to_string(&p)?);
5871                }
5872                _ => {}
5873            }
5874        }
5875        for code in prev.keys() {
5876            if !cur.contains_key(code) {
5877                // File disappeared → finalized or cancelled. Emit a synthetic
5878                // "removed" marker so the consumer sees the terminal event.
5879                println!(
5880                    "{}",
5881                    serde_json::to_string(&json!({
5882                        "code": code,
5883                        "status": "removed",
5884                        "_synthetic": true,
5885                    }))?
5886                );
5887            }
5888        }
5889        let _ = std::io::stdout().flush();
5890        prev = cur;
5891    }
5892}
5893
5894/// Block until a pending pair reaches `target_status` or terminates. Process
5895/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
5896/// timeout) so shell scripts can branch directly.
5897fn cmd_pair_watch(
5898    code_phrase: &str,
5899    target_status: &str,
5900    timeout_secs: u64,
5901    as_json: bool,
5902) -> Result<()> {
5903    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5904    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5905    let mut last_seen_status: Option<String> = None;
5906    loop {
5907        let p_opt = crate::pending_pair::read_pending(&code)?;
5908        let now = std::time::Instant::now();
5909        match p_opt {
5910            None => {
5911                // File gone — either finalized (success if target=sas_ready
5912                // since finalization implies it passed sas_ready) or never
5913                // existed. Distinguish by whether we ever saw it.
5914                if last_seen_status.is_some() {
5915                    if as_json {
5916                        println!(
5917                            "{}",
5918                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5919                        );
5920                    } else {
5921                        println!("pair {code} finalized (file removed)");
5922                    }
5923                    return Ok(());
5924                } else {
5925                    if as_json {
5926                        println!(
5927                            "{}",
5928                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5929                        );
5930                    }
5931                    std::process::exit(1);
5932                }
5933            }
5934            Some(p) => {
5935                let cur = p.status.clone();
5936                if Some(cur.clone()) != last_seen_status {
5937                    if as_json {
5938                        // Emit per-transition line so scripts can stream.
5939                        println!("{}", serde_json::to_string(&p)?);
5940                    }
5941                    last_seen_status = Some(cur.clone());
5942                }
5943                if cur == target_status {
5944                    if !as_json {
5945                        let sas_str = p
5946                            .sas
5947                            .as_ref()
5948                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5949                            .unwrap_or_else(|| "—".to_string());
5950                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
5951                    }
5952                    return Ok(());
5953                }
5954                if cur == "aborted" || cur == "aborted_restart" {
5955                    if !as_json {
5956                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
5957                        eprintln!("pair {code} {cur}: {err}");
5958                    }
5959                    std::process::exit(1);
5960                }
5961            }
5962        }
5963        if now >= deadline {
5964            if !as_json {
5965                eprintln!(
5966                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5967                );
5968            }
5969            std::process::exit(2);
5970        }
5971        std::thread::sleep(std::time::Duration::from_millis(250));
5972    }
5973}
5974
5975fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5976    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5977    let p = crate::pending_pair::read_pending(&code)?
5978        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5979    let client = crate::relay_client::RelayClient::new(&p.relay_url);
5980    let _ = client.pair_abandon(&p.code_hash);
5981    crate::pending_pair::delete_pending(&code)?;
5982    if as_json {
5983        println!(
5984            "{}",
5985            serde_json::to_string(&json!({
5986                "state": "cancelled",
5987                "code_phrase": code,
5988            }))?
5989        );
5990    } else {
5991        println!("cancelled pending pair {code} (relay slot released, file removed).");
5992    }
5993    Ok(())
5994}
5995
5996// ---------- pair-abandon — release stuck pair-slot ----------
5997
5998fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5999    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
6000    // typed — normalize via the existing parser.
6001    let code = crate::sas::parse_code_phrase(code_phrase)?;
6002    let code_hash = crate::pair_session::derive_code_hash(code);
6003    let client = crate::relay_client::RelayClient::new(relay_url);
6004    client.pair_abandon(&code_hash)?;
6005    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
6006    println!("host can now issue a fresh code; guest can re-join.");
6007    Ok(())
6008}
6009
6010// ---------- invite / accept — one-paste pair (v0.4.0) ----------
6011
6012fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6013    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6014
6015    // If --share, register the invite at the relay's short-URL endpoint and
6016    // build the one-curl onboarding line for the peer to paste.
6017    let share_payload: Option<Value> = if share {
6018        let client = reqwest::blocking::Client::new();
6019        let single_use = if uses == 1 { Some(1u32) } else { None };
6020        let body = json!({
6021            "invite_url": url,
6022            "ttl_seconds": ttl,
6023            "uses": single_use,
6024        });
6025        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6026        let resp = client.post(&endpoint).json(&body).send()?;
6027        if !resp.status().is_success() {
6028            let code = resp.status();
6029            let txt = resp.text().unwrap_or_default();
6030            bail!("relay {code} on /v1/invite/register: {txt}");
6031        }
6032        let parsed: Value = resp.json()?;
6033        let token = parsed
6034            .get("token")
6035            .and_then(Value::as_str)
6036            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6037            .to_string();
6038        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6039        let curl_line = format!("curl -fsSL {share_url} | sh");
6040        Some(json!({
6041            "token": token,
6042            "share_url": share_url,
6043            "curl": curl_line,
6044            "expires_unix": parsed.get("expires_unix"),
6045        }))
6046    } else {
6047        None
6048    };
6049
6050    if as_json {
6051        let mut out = json!({
6052            "invite_url": url,
6053            "ttl_secs": ttl,
6054            "uses": uses,
6055            "relay": relay,
6056        });
6057        if let Some(s) = &share_payload {
6058            out["share"] = s.clone();
6059        }
6060        println!("{}", serde_json::to_string(&out)?);
6061    } else if let Some(s) = share_payload {
6062        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6063        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6064        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6065        println!("{curl}");
6066    } else {
6067        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6068        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6069        println!("{url}");
6070    }
6071    Ok(())
6072}
6073
6074fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6075    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
6076    // resolve it to the underlying wire://pair?... URL via ?format=url before
6077    // accepting. Saves them from having to know which URL shape goes where.
6078    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6079        let sep = if url.contains('?') { '&' } else { '?' };
6080        let resolve_url = format!("{url}{sep}format=url");
6081        let client = reqwest::blocking::Client::new();
6082        let resp = client
6083            .get(&resolve_url)
6084            .send()
6085            .with_context(|| format!("GET {resolve_url}"))?;
6086        if !resp.status().is_success() {
6087            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6088        }
6089        let body = resp.text().unwrap_or_default().trim().to_string();
6090        if !body.starts_with("wire://pair?") {
6091            bail!(
6092                "short URL {url} did not resolve to a wire:// invite. \
6093                 (got: {}{})",
6094                body.chars().take(80).collect::<String>(),
6095                if body.chars().count() > 80 { "…" } else { "" }
6096            );
6097        }
6098        body
6099    } else {
6100        url.to_string()
6101    };
6102
6103    let result = crate::pair_invite::accept_invite(&resolved)?;
6104    if as_json {
6105        println!("{}", serde_json::to_string(&result)?);
6106    } else {
6107        let did = result
6108            .get("paired_with")
6109            .and_then(Value::as_str)
6110            .unwrap_or("?");
6111        println!("paired with {did}");
6112        println!(
6113            "you can now: wire send {} <kind> <body>",
6114            crate::agent_card::display_handle_from_did(did)
6115        );
6116    }
6117    Ok(())
6118}
6119
6120// ---------- whois / profile (v0.5) ----------
6121
6122fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6123    if let Some(h) = handle {
6124        let parsed = crate::pair_profile::parse_handle(h)?;
6125        // Special-case: if the supplied handle matches our own, skip the
6126        // network round-trip and print local.
6127        if config::is_initialized()? {
6128            let card = config::read_agent_card()?;
6129            let local_handle = card
6130                .get("profile")
6131                .and_then(|p| p.get("handle"))
6132                .and_then(Value::as_str)
6133                .map(str::to_string);
6134            if local_handle.as_deref() == Some(h) {
6135                return cmd_whois(None, as_json, None);
6136            }
6137        }
6138        // Remote resolution via .well-known/wire/agent on the handle's domain.
6139        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6140        if as_json {
6141            println!("{}", serde_json::to_string(&resolved)?);
6142        } else {
6143            print_resolved_profile(&resolved);
6144        }
6145        return Ok(());
6146    }
6147    let card = config::read_agent_card()?;
6148    if as_json {
6149        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6150        println!(
6151            "{}",
6152            serde_json::to_string(&json!({
6153                "did": card.get("did").cloned().unwrap_or(Value::Null),
6154                "profile": profile,
6155            }))?
6156        );
6157    } else {
6158        print!("{}", crate::pair_profile::render_self_summary()?);
6159    }
6160    Ok(())
6161}
6162
6163fn print_resolved_profile(resolved: &Value) {
6164    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6165    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6166    let relay = resolved
6167        .get("relay_url")
6168        .and_then(Value::as_str)
6169        .unwrap_or("");
6170    let slot = resolved
6171        .get("slot_id")
6172        .and_then(Value::as_str)
6173        .unwrap_or("");
6174    let profile = resolved
6175        .get("card")
6176        .and_then(|c| c.get("profile"))
6177        .cloned()
6178        .unwrap_or(Value::Null);
6179    println!("{did}");
6180    println!("  nick:         {nick}");
6181    if !relay.is_empty() {
6182        println!("  relay_url:    {relay}");
6183    }
6184    if !slot.is_empty() {
6185        println!("  slot_id:      {slot}");
6186    }
6187    let pick =
6188        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6189    if let Some(s) = pick("display_name") {
6190        println!("  display_name: {s}");
6191    }
6192    if let Some(s) = pick("emoji") {
6193        println!("  emoji:        {s}");
6194    }
6195    if let Some(s) = pick("motto") {
6196        println!("  motto:        {s}");
6197    }
6198    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6199        let joined: Vec<String> = arr
6200            .iter()
6201            .filter_map(|v| v.as_str().map(str::to_string))
6202            .collect();
6203        println!("  vibe:         {}", joined.join(", "));
6204    }
6205    if let Some(s) = pick("pronouns") {
6206        println!("  pronouns:     {s}");
6207    }
6208}
6209
6210/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
6211/// signed pair_drop event with our card + slot coords, deliver via the
6212/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
6213/// Peer's daemon completes the bilateral pin on its next pull and emits a
6214/// pair_drop_ack carrying their slot_token so we can send back.
6215/// Extract just the host portion from `https://host:port/path` → `host`.
6216/// Returns empty string if the URL is malformed.
6217fn host_of_url(url: &str) -> String {
6218    let no_scheme = url
6219        .trim_start_matches("https://")
6220        .trim_start_matches("http://");
6221    no_scheme
6222        .split('/')
6223        .next()
6224        .unwrap_or("")
6225        .split(':')
6226        .next()
6227        .unwrap_or("")
6228        .to_string()
6229}
6230
6231/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
6232/// operator's own relay? Used to suppress the cross-relay phishing
6233/// warning in `wire add` for the happy path.
6234fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6235    // Hard-coded known-good list. wireup.net is the default relay.
6236    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6237    let peer_domain = peer_domain.trim().to_ascii_lowercase();
6238    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6239        return true;
6240    }
6241    // Operator's OWN relay is implicitly trusted — they're already
6242    // bound to it; pairing same-relay peers is the common case.
6243    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6244    if !our_host.is_empty() && our_host == peer_domain {
6245        return true;
6246    }
6247    false
6248}
6249
6250/// v0.6.6: pair with a sister session on this machine without federation.
6251/// Reads the sister's agent-card + endpoints from disk, pins them into our
6252/// trust + relay_state, builds the same `pair_drop` event the federation
6253/// path would emit, then POSTs it directly to the sister's local-relay slot.
6254/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
6255/// the cwd-derived `wire`) are addressable because the local relay never
6256/// needed a public claim for sister coordination.
6257/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
6258/// to a local sister session.
6259///
6260/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
6261/// either form. Exact session-name matches always win; nickname matches
6262/// are a fallback so operators can type "winter-bay" instead of "wire".
6263/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
6264/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
6265/// with the candidate list so the caller can surface a disambiguation
6266/// hint instead of silently picking one.
6267fn resolve_local_session<'a>(
6268    sessions: &'a [crate::session::SessionInfo],
6269    input: &str,
6270) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6271    // Exact session-name match always wins, even if a nickname elsewhere
6272    // also matches. Predictable for scripts and operator muscle memory.
6273    if let Some(s) = sessions.iter().find(|s| s.name == input) {
6274        return Ok(s);
6275    }
6276    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6277        .iter()
6278        .filter(|s| {
6279            s.character
6280                .as_ref()
6281                .map(|c| c.nickname == input)
6282                .unwrap_or(false)
6283        })
6284        .collect();
6285    match nick_matches.len() {
6286        0 => Err(ResolveError::NotFound),
6287        1 => Ok(nick_matches[0]),
6288        _ => Err(ResolveError::Ambiguous(
6289            nick_matches.iter().map(|s| s.name.clone()).collect(),
6290        )),
6291    }
6292}
6293
6294#[derive(Debug)]
6295enum ResolveError {
6296    NotFound,
6297    Ambiguous(Vec<String>),
6298}
6299
6300/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
6301/// to a pinned peer's canonical handle.
6302///
6303/// `wire send <peer>` accepts either the handle the peer registered with
6304/// or their character nickname (DID-hash-derived). Exact handle match
6305/// always wins. When a nickname matches multiple peers (theoretically
6306/// possible via DID-hash collision in the (adj, noun) space), returns
6307/// `Ambiguous` so the caller can surface a disambiguation hint instead
6308/// of silently picking one.
6309///
6310/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
6311/// overrides on the peer's side live in their local `display.json` and
6312/// aren't yet published via agent-card. (That's the v0.7+ federation
6313/// lifecycle work — peers publishing overrides so we resolve by what
6314/// they call themselves, not just what their DID hashes to.)
6315fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6316    let trust = match config::read_trust() {
6317        Ok(t) => t,
6318        Err(_) => return Ok(None),
6319    };
6320    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6321        Some(a) => a,
6322        None => return Ok(None),
6323    };
6324    if agents.contains_key(input) {
6325        return Ok(Some(input.to_string()));
6326    }
6327    let mut nick_matches: Vec<String> = Vec::new();
6328    for (handle, agent) in agents.iter() {
6329        // v0.7.0-alpha.6: prefer peer's published display nickname over
6330        // auto-derived. Allows `wire send <their-chosen-name>` not just
6331        // `wire send <their-did-hash-derived-name>`.
6332        let character = match agent.get("card") {
6333            Some(card) => crate::character::Character::from_card(card),
6334            None => match agent.get("did").and_then(Value::as_str) {
6335                Some(did) => crate::character::Character::from_did(did),
6336                None => continue,
6337            },
6338        };
6339        if character.nickname == input {
6340            nick_matches.push(handle.clone());
6341        }
6342    }
6343    match nick_matches.len() {
6344        0 => Ok(None),
6345        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6346        _ => Err(ResolveError::Ambiguous(nick_matches)),
6347    }
6348}
6349
6350fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6351    // 1. Locate sister session by name OR character nickname.
6352    let sessions = crate::session::list_sessions()?;
6353    let sister = match resolve_local_session(&sessions, sister_name) {
6354        Ok(s) => s,
6355        Err(ResolveError::NotFound) => bail!(
6356            "no sister session named `{sister_name}` (matched by session name or character nickname). \
6357             Run `wire session list` to see what's available."
6358        ),
6359        Err(ResolveError::Ambiguous(candidates)) => bail!(
6360            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6361             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6362            candidates.len(),
6363            candidates.join(", ")
6364        ),
6365    };
6366    // If we matched via nickname (not exact name), surface that so the
6367    // operator sees what we resolved to. Quiet when names match exactly.
6368    if sister.name != sister_name {
6369        eprintln!(
6370            "wire add: resolved nickname `{sister_name}` → session `{}`",
6371            sister.name
6372        );
6373    }
6374
6375    // 2. Refuse self-pair — operator owns both sides, but a self-loop
6376    // breaks the bilateral state machine.
6377    let our_card = config::read_agent_card()
6378        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6379    let our_did = our_card
6380        .get("did")
6381        .and_then(Value::as_str)
6382        .ok_or_else(|| anyhow!("agent-card missing did"))?
6383        .to_string();
6384    if let Some(sister_did) = sister.did.as_deref()
6385        && sister_did == our_did
6386    {
6387        bail!("refusing to add self (`{sister_name}` is this very session)");
6388    }
6389
6390    // 3. Read sister's agent-card + relay state from disk.
6391    let sister_card_path = sister
6392        .home_dir
6393        .join("config")
6394        .join("wire")
6395        .join("agent-card.json");
6396    let sister_card: Value = serde_json::from_slice(
6397        &std::fs::read(&sister_card_path)
6398            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6399    )
6400    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6401    let sister_relay_state: Value = std::fs::read(
6402        sister
6403            .home_dir
6404            .join("config")
6405            .join("wire")
6406            .join("relay.json"),
6407    )
6408    .ok()
6409    .and_then(|b| serde_json::from_slice(&b).ok())
6410    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6411
6412    let sister_did = sister_card
6413        .get("did")
6414        .and_then(Value::as_str)
6415        .ok_or_else(|| anyhow!("sister card missing did"))?
6416        .to_string();
6417    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6418
6419    // Pull sister's full endpoint set; we want the local one for delivery
6420    // and we'll pin all of them so OUR pushes prefer local-first per the
6421    // existing routing logic.
6422    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6423    if sister_endpoints.is_empty() {
6424        bail!(
6425            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6426        );
6427    }
6428    let sister_local = sister_endpoints
6429        .iter()
6430        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6431    let delivery_endpoint = match sister_local {
6432        Some(e) => e.clone(),
6433        None => sister_endpoints[0].clone(),
6434    };
6435
6436    // 4. Ensure WE have a slot to advertise back. For local-only sessions
6437    // this is the local slot; for dual-slot sessions, federation is fine.
6438    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
6439    // for pure local-only — instead, pick our own existing federation
6440    // endpoint if present, else fall back to whatever's first.
6441    let our_relay_state = config::read_relay_state()?;
6442    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6443    if our_endpoints.is_empty() {
6444        bail!(
6445            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6446        );
6447    }
6448    let our_advertised = our_endpoints
6449        .iter()
6450        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6451        .cloned()
6452        .unwrap_or_else(|| our_endpoints[0].clone());
6453
6454    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
6455    // relay_state.peers with their full endpoint set. slot_token lands
6456    // via pair_drop_ack as usual.
6457    let mut trust = config::read_trust()?;
6458    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6459    config::write_trust(&trust)?;
6460    let mut relay_state = config::read_relay_state()?;
6461    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6462    config::write_relay_state(&relay_state)?;
6463
6464    // 6. Build the same pair_drop event the federation path emits, with
6465    // our card + endpoints in the body so the sister can pin us back.
6466    let sk_seed = config::read_private_key()?;
6467    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6468    let pk_b64 = our_card
6469        .get("verify_keys")
6470        .and_then(Value::as_object)
6471        .and_then(|m| m.values().next())
6472        .and_then(|v| v.get("key"))
6473        .and_then(Value::as_str)
6474        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6475    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6476    let now = time::OffsetDateTime::now_utc()
6477        .format(&time::format_description::well_known::Rfc3339)
6478        .unwrap_or_default();
6479    let mut body = json!({
6480        "card": our_card,
6481        "relay_url": our_advertised.relay_url,
6482        "slot_id": our_advertised.slot_id,
6483        "slot_token": our_advertised.slot_token,
6484    });
6485    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6486    let event = json!({
6487        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6488        "timestamp": now,
6489        "from": our_did,
6490        "to": sister_did,
6491        "type": "pair_drop",
6492        "kind": 1100u32,
6493        "body": body,
6494    });
6495    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6496    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6497
6498    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
6499    // (the federation handle indexer) — we already know the slot coords
6500    // from disk, so post_event is sufficient.
6501    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6502    client
6503        .post_event(
6504            &delivery_endpoint.slot_id,
6505            &delivery_endpoint.slot_token,
6506            &signed,
6507        )
6508        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6509
6510    if as_json {
6511        println!(
6512            "{}",
6513            serde_json::to_string(&json!({
6514                "handle": sister_name,
6515                "paired_with": sister_did,
6516                "peer_handle": sister_handle,
6517                "event_id": event_id,
6518                "delivered_via": match delivery_endpoint.scope {
6519                    crate::endpoints::EndpointScope::Local => "local",
6520                    crate::endpoints::EndpointScope::Lan => "lan",
6521                    crate::endpoints::EndpointScope::Uds => "uds",
6522                    crate::endpoints::EndpointScope::Federation => "federation",
6523                },
6524                "status": "drop_sent",
6525            }))?
6526        );
6527    } else {
6528        let scope = match delivery_endpoint.scope {
6529            crate::endpoints::EndpointScope::Local => "local",
6530            crate::endpoints::EndpointScope::Lan => "lan",
6531            crate::endpoints::EndpointScope::Uds => "uds",
6532            crate::endpoints::EndpointScope::Federation => "federation",
6533        };
6534        println!(
6535            "→ 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.",
6536            delivery_endpoint.relay_url
6537        );
6538    }
6539    Ok(())
6540}
6541
6542fn cmd_add(
6543    handle_arg: &str,
6544    relay_override: Option<&str>,
6545    local_sister: bool,
6546    as_json: bool,
6547) -> Result<()> {
6548    // v0.7.4: nickname-friendly local-sister resolution. Whether the
6549    // operator passed `--local-sister` explicitly OR just typed a bare
6550    // name (no `@<relay>`), try to resolve through the local sessions
6551    // registry so character nicknames AND session names AND card
6552    // handles all work as input. Closes the "I only know this peer by
6553    // its character name" ergonomic gap that forced operators into
6554    // `wire session list-local | grep <nick> | awk` dances.
6555    if local_sister {
6556        let resolved = crate::session::resolve_local_sister(handle_arg)
6557            .unwrap_or_else(|| handle_arg.to_string());
6558        return cmd_add_local_sister(&resolved, as_json);
6559    }
6560    if !handle_arg.contains('@')
6561        && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6562    {
6563        eprintln!(
6564            "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6565             — routing via --local-sister (disk-read card, no relay lookup)."
6566        );
6567        return cmd_add_local_sister(&resolved, as_json);
6568    }
6569    if !handle_arg.contains('@') {
6570        bail!(
6571            "`{handle_arg}` doesn't match any local sister session and has no \
6572             @<relay> suffix for federation.\n\
6573             — Local sisters: `wire session list-local` (operator types name OR \
6574             character nickname)\n\
6575             — Federation:    `wire add <handle>@<relay-domain>` (e.g. \
6576             `wire add alice@wireup.net`)"
6577        );
6578    }
6579    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6580
6581    // 1. Auto-init self if needed + ensure a relay slot.
6582    let (our_did, our_relay, our_slot_id, our_slot_token) =
6583        crate::pair_invite::ensure_self_with_relay(relay_override)?;
6584    if our_did == format!("did:wire:{}", parsed.nick) {
6585        // Lazy guard — actual self-add would also be caught by FCFS later.
6586        bail!("refusing to add self (handle matches own DID)");
6587    }
6588
6589    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
6590    // already sitting in pending-inbound, the operator is now accepting it.
6591    // Pin trust, save relay coords + slot_token from the stored drop, ship
6592    // our own slot_token back via pair_drop_ack, delete the pending record.
6593    //
6594    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
6595    // receiver-side auto-promote was removed there; operator consent flows
6596    // through here. After this branch returns, both sides are bilaterally
6597    // pinned and capability flows in both directions.
6598    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6599        return cmd_add_accept_pending(
6600            handle_arg,
6601            &parsed.nick,
6602            &pending,
6603            &our_relay,
6604            &our_slot_id,
6605            &our_slot_token,
6606            as_json,
6607        );
6608    }
6609
6610    // v0.5.19 (#9.4): cross-relay phishing guardrail.
6611    //
6612    // Threat: operator wants to add `boss@wireup.net` but types
6613    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
6614    // The .well-known resolution returns whoever claimed the nick on the
6615    // *typo* relay, the bilateral gate still completes (the attacker
6616    // accepts the pair on their side), and the operator pins the
6617    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
6618    // there's no asymmetry to detect when the attacker WANTS to be
6619    // paired.
6620    //
6621    // Mitigation: warn loudly when the peer's relay domain is novel
6622    // (not the operator's own relay, not in a small known-good set).
6623    // Doesn't block — operators have legitimate reasons to pair across
6624    // relays. The signal lands in shell history so a phished operator
6625    // can find it in retrospect.
6626    if !is_known_relay_domain(&parsed.domain, &our_relay) {
6627        eprintln!(
6628            "wire add: WARN unfamiliar relay domain `{}`.",
6629            parsed.domain
6630        );
6631        eprintln!(
6632            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
6633            host_of_url(&our_relay)
6634        );
6635        eprintln!(
6636            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
6637            parsed.nick
6638        );
6639        eprintln!(
6640            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
6641            parsed.nick
6642        );
6643        eprintln!("  peer out-of-band that they actually run a relay at this domain");
6644        eprintln!("  before relying on the pair. (See issue #9.4.)");
6645    }
6646
6647    // 2. Resolve peer via .well-known on their relay.
6648    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6649    let peer_card = resolved
6650        .get("card")
6651        .cloned()
6652        .ok_or_else(|| anyhow!("resolved missing card"))?;
6653    let peer_did = resolved
6654        .get("did")
6655        .and_then(Value::as_str)
6656        .ok_or_else(|| anyhow!("resolved missing did"))?
6657        .to_string();
6658    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
6659    let peer_slot_id = resolved
6660        .get("slot_id")
6661        .and_then(Value::as_str)
6662        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
6663        .to_string();
6664    let peer_relay = resolved
6665        .get("relay_url")
6666        .and_then(Value::as_str)
6667        .map(str::to_string)
6668        .or_else(|| relay_override.map(str::to_string))
6669        .unwrap_or_else(|| format!("https://{}", parsed.domain));
6670
6671    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
6672    let mut trust = config::read_trust()?;
6673    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
6674    config::write_trust(&trust)?;
6675    let mut relay_state = config::read_relay_state()?;
6676    // Additive re-pin (v0.13.2, E3 token-bleed fix). The old code REPLACED the
6677    // whole peer entry with a flat federation-only one, seeding the token from
6678    // the entry's TOP-LEVEL `slot_token`. Two bugs (glossy-magnolia repro):
6679    //   1. re-dialing a peer that had a local endpoint (from add-peer-slot)
6680    //      CLOBBERED that local endpoint.
6681    //   2. after a local add-peer-slot the top-level token was the LOCAL token,
6682    //      so the federation endpoint inherited a stale LOCAL bearer →
6683    //      federation delivery would 401.
6684    // Fix: merge the federation endpoint into the peer's endpoints[] (preserve
6685    // the local one), and seed its token ONLY from a prior FEDERATION endpoint
6686    // on the same relay (re-dialing an already-acked peer), never a local one —
6687    // empty until the pair_drop_ack lands otherwise.
6688    let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
6689        .get("peers")
6690        .and_then(|p| p.get(&peer_handle))
6691        .and_then(|e| e.get("endpoints"))
6692        .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
6693        .unwrap_or_default();
6694    let fed_token = endpoints
6695        .iter()
6696        .find(|e| {
6697            e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
6698        })
6699        .map(|e| e.slot_token.clone())
6700        .unwrap_or_default();
6701    let fed_ep = crate::endpoints::Endpoint {
6702        relay_url: peer_relay.clone(),
6703        slot_id: peer_slot_id.clone(),
6704        slot_token: fed_token, // empty until pair_drop_ack lands
6705        scope: crate::endpoints::EndpointScope::Federation,
6706    };
6707    if let Some(existing) = endpoints
6708        .iter_mut()
6709        .find(|e| e.relay_url == fed_ep.relay_url)
6710    {
6711        *existing = fed_ep;
6712    } else {
6713        endpoints.push(fed_ep);
6714    }
6715    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
6716    config::write_relay_state(&relay_state)?;
6717
6718    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
6719    // is the v0.5 zero-paste open-mode path).
6720    let our_card = config::read_agent_card()?;
6721    let sk_seed = config::read_private_key()?;
6722    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6723    let pk_b64 = our_card
6724        .get("verify_keys")
6725        .and_then(Value::as_object)
6726        .and_then(|m| m.values().next())
6727        .and_then(|v| v.get("key"))
6728        .and_then(Value::as_str)
6729        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6730    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6731    let now = time::OffsetDateTime::now_utc()
6732        .format(&time::format_description::well_known::Rfc3339)
6733        .unwrap_or_default();
6734    // v0.5.17: advertise all our endpoints (federation + optional local)
6735    // to the peer in the pair_drop body. Back-compat: top-level
6736    // relay_url/slot_id/slot_token still point at the federation
6737    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
6738    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
6739    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6740    let mut body = json!({
6741        "card": our_card,
6742        "relay_url": our_relay,
6743        "slot_id": our_slot_id,
6744        "slot_token": our_slot_token,
6745    });
6746    if !our_endpoints.is_empty() {
6747        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6748    }
6749    let event = json!({
6750        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6751        "timestamp": now,
6752        "from": our_did,
6753        "to": peer_did,
6754        "type": "pair_drop",
6755        "kind": 1100u32,
6756        "body": body,
6757    });
6758    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6759
6760    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
6761    let client = crate::relay_client::RelayClient::new(&peer_relay);
6762    let resp = client.handle_intro(&parsed.nick, &signed)?;
6763    let event_id = signed
6764        .get("event_id")
6765        .and_then(Value::as_str)
6766        .unwrap_or("")
6767        .to_string();
6768
6769    if as_json {
6770        println!(
6771            "{}",
6772            serde_json::to_string(&json!({
6773                "handle": handle_arg,
6774                "paired_with": peer_did,
6775                "peer_handle": peer_handle,
6776                "event_id": event_id,
6777                "drop_response": resp,
6778                "status": "drop_sent",
6779            }))?
6780        );
6781    } else {
6782        println!(
6783            "→ 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."
6784        );
6785    }
6786    Ok(())
6787}
6788
6789/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
6790/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
6791/// coords + slot_token from the stored drop, ship our slot_token back via
6792/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
6793/// invite-URL path (which is already bilateral by virtue of the pre-shared
6794/// nonce).
6795fn cmd_add_accept_pending(
6796    handle_arg: &str,
6797    peer_nick: &str,
6798    pending: &crate::pending_inbound_pair::PendingInboundPair,
6799    _our_relay: &str,
6800    _our_slot_id: &str,
6801    _our_slot_token: &str,
6802    as_json: bool,
6803) -> Result<()> {
6804    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
6805    //    `wire add` against this handle while a drop was waiting.
6806    let mut trust = config::read_trust()?;
6807    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
6808    config::write_trust(&trust)?;
6809
6810    // 2. Record peer's relay coords + slot_token (already shipped to us in
6811    //    the original drop body; held back until now).
6812    // v0.5.17: pin all advertised endpoints (federation + optional local).
6813    // Falls back to a single federation entry when the record was written
6814    // by v0.5.16-era code that didn't carry endpoints[].
6815    let mut relay_state = config::read_relay_state()?;
6816    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
6817        vec![crate::endpoints::Endpoint::federation(
6818            pending.peer_relay_url.clone(),
6819            pending.peer_slot_id.clone(),
6820            pending.peer_slot_token.clone(),
6821        )]
6822    } else {
6823        pending.peer_endpoints.clone()
6824    };
6825    crate::endpoints::pin_peer_endpoints(
6826        &mut relay_state,
6827        &pending.peer_handle,
6828        &endpoints_to_pin,
6829    )?;
6830    config::write_relay_state(&relay_state)?;
6831
6832    // 3. Ship our slot_token to peer via pair_drop_ack so they can write back.
6833    crate::pair_invite::send_pair_drop_ack(
6834        &pending.peer_handle,
6835        &pending.peer_relay_url,
6836        &pending.peer_slot_id,
6837        &pending.peer_slot_token,
6838    )
6839    .with_context(|| {
6840        format!(
6841            "pair_drop_ack send to {} @ {} slot {} failed",
6842            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
6843        )
6844    })?;
6845
6846    // 4. Delete the pending-inbound record now that bilateral is complete.
6847    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
6848
6849    if as_json {
6850        println!(
6851            "{}",
6852            serde_json::to_string(&json!({
6853                "handle": handle_arg,
6854                "paired_with": pending.peer_did,
6855                "peer_handle": pending.peer_handle,
6856                "status": "bilateral_accepted",
6857                "via": "pending_inbound",
6858            }))?
6859        );
6860    } else {
6861        println!(
6862            "→ 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} \"...\"`.",
6863            peer = pending.peer_handle,
6864        );
6865    }
6866    Ok(())
6867}
6868
6869/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
6870/// for a pending-inbound pair request. Pin trust, write relay_state from the
6871/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
6872/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
6873/// when a pending-inbound record exists, but without needing to remember
6874/// the peer's relay domain.
6875fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
6876    let nick = crate::agent_card::bare_handle(peer_nick);
6877    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
6878        anyhow!(
6879            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
6880             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
6881        )
6882    })?;
6883    let (_our_did, our_relay, our_slot_id, our_slot_token) =
6884        crate::pair_invite::ensure_self_with_relay(None)?;
6885    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
6886    cmd_add_accept_pending(
6887        &handle_arg,
6888        nick,
6889        &pending,
6890        &our_relay,
6891        &our_slot_id,
6892        &our_slot_token,
6893        as_json,
6894    )
6895}
6896
6897/// v0.5.14: programmatic access to pending-inbound for scripts.
6898/// `wire pair-list-inbound --json` returns a flat array of records.
6899fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
6900    let items = crate::pending_inbound_pair::list_pending_inbound()?;
6901    if as_json {
6902        println!("{}", serde_json::to_string(&items)?);
6903        return Ok(());
6904    }
6905    if items.is_empty() {
6906        println!("no pending pair requests — your inbox is clear.");
6907        return Ok(());
6908    }
6909    // v0.9.3: conversational output. Tabular data is for --json. Humans
6910    // get one short sentence per pending peer, each rendered with the
6911    // peer's character (DID-derived emoji + nickname) so they can match
6912    // the speaker against their statusline / mesh-status view at a
6913    // glance. The "next step" sentence at the bottom names the exact
6914    // verbs to run.
6915    let plural = if items.len() == 1 { "" } else { "s" };
6916    println!("{} pending pair request{plural}:\n", items.len());
6917    for p in &items {
6918        let ch = crate::character::Character::from_did(&p.peer_did);
6919        let glyph = crate::character::emoji_with_fallback(&ch);
6920        // ASCII-friendly arrow if the operator's terminal can't render
6921        // emoji (the same routine drives the fallback).
6922        println!(
6923            "  {glyph} {nick}  ({handle})  wants to pair with you",
6924            nick = ch.nickname,
6925            handle = p.peer_handle,
6926        );
6927    }
6928    println!();
6929    println!(
6930        "→ to accept any: `wire accept <name>`  (e.g. `wire accept {first}`)",
6931        first = items
6932            .first()
6933            .map(|p| {
6934                let ch = crate::character::Character::from_did(&p.peer_did);
6935                ch.nickname
6936            })
6937            .unwrap_or_else(|| "<name>".to_string())
6938    );
6939    println!("→ to refuse:    `wire reject <name>`");
6940    Ok(())
6941}
6942
6943/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
6944/// without pairing. No event is sent back to the peer; their side stays
6945/// pending until they time out or the operator-side data ages out.
6946fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
6947    let nick = crate::agent_card::bare_handle(peer_nick);
6948    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
6949    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
6950
6951    if as_json {
6952        println!(
6953            "{}",
6954            serde_json::to_string(&json!({
6955                "peer": nick,
6956                "rejected": existed.is_some(),
6957                "had_pending": existed.is_some(),
6958            }))?
6959        );
6960    } else if existed.is_some() {
6961        println!(
6962            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6963        );
6964    } else {
6965        println!("no pending pair from {nick} — nothing to reject");
6966    }
6967    Ok(())
6968}
6969
6970// ---------- session (v0.5.16) ----------
6971//
6972// Multi-session wire on one machine. See src/session.rs for the storage
6973// layout + naming rules. The CLI dispatcher here orchestrates child
6974// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
6975// each session-local `init` / `claim` / `daemon` runs in its own world
6976// without cross-contamination via env vars in this process.
6977
6978/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
6979/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
6980fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6981    match cmd {
6982        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6983        MeshCommand::Broadcast {
6984            kind,
6985            scope,
6986            exclude,
6987            noreply,
6988            body,
6989            json,
6990        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6991        MeshCommand::Role { action } => cmd_mesh_role(action),
6992        MeshCommand::Route {
6993            role,
6994            strategy,
6995            exclude,
6996            kind,
6997            body,
6998            json,
6999        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
7000    }
7001}
7002
7003/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
7004/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
7005/// picks ONE via the requested strategy, then signs + pushes the event
7006/// to that peer. Pinned-peers-only by construction (same as broadcast).
7007fn cmd_mesh_route(
7008    role: &str,
7009    strategy: &str,
7010    exclude: &[String],
7011    kind: &str,
7012    body_arg: &str,
7013    as_json: bool,
7014) -> Result<()> {
7015    use std::time::Instant;
7016
7017    if !config::is_initialized()? {
7018        bail!("not initialized — run `wire init <handle>` first");
7019    }
7020    let strategy = strategy.to_ascii_lowercase();
7021    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
7022        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
7023    }
7024
7025    // Our pinned-peer set: only these handles are addressable. mesh-route
7026    // refuses to invent a recipient, same posture as broadcast.
7027    let state = config::read_relay_state()?;
7028    let pinned: std::collections::BTreeSet<String> = state["peers"]
7029        .as_object()
7030        .map(|m| m.keys().cloned().collect())
7031        .unwrap_or_default();
7032
7033    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7034
7035    // Enumerate every sister on the box, read each one's role from its
7036    // signed agent-card. Filter: matching role AND pinned AND not
7037    // excluded. `list_sessions` returns the cross-session view (using the
7038    // v0.6.4 inside-session sessions_root fallback).
7039    let sessions = crate::session::list_sessions()?;
7040    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
7041    for s in &sessions {
7042        let handle = match s.handle.as_ref() {
7043            Some(h) => h.clone(),
7044            None => continue,
7045        };
7046        if exclude_set.contains(handle.as_str()) {
7047            continue;
7048        }
7049        if !pinned.contains(&handle) {
7050            continue;
7051        }
7052        let card_path = s
7053            .home_dir
7054            .join("config")
7055            .join("wire")
7056            .join("agent-card.json");
7057        let card_role = std::fs::read(&card_path)
7058            .ok()
7059            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7060            .and_then(|c| {
7061                c.get("profile")
7062                    .and_then(|p| p.get("role"))
7063                    .and_then(Value::as_str)
7064                    .map(str::to_string)
7065            });
7066        if card_role.as_deref() == Some(role) {
7067            candidates.push((handle, s.did.clone()));
7068        }
7069    }
7070
7071    candidates.sort_by(|a, b| a.0.cmp(&b.0));
7072    candidates.dedup_by(|a, b| a.0 == b.0);
7073
7074    if candidates.is_empty() {
7075        bail!(
7076            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
7077        );
7078    }
7079
7080    let chosen = match strategy.as_str() {
7081        "first" => candidates[0].clone(),
7082        "random" => {
7083            use rand::Rng;
7084            let idx = rand::thread_rng().gen_range(0..candidates.len());
7085            candidates[idx].clone()
7086        }
7087        "round-robin" => {
7088            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
7089            // `{role: last_picked_handle}`. Next pick = first candidate
7090            // alphabetically AFTER last_picked, wrapping around when no
7091            // candidate is greater.
7092            let cursor_path = mesh_route_cursor_path()?;
7093            let mut cursors: std::collections::BTreeMap<String, String> =
7094                read_mesh_route_cursors(&cursor_path);
7095            let last = cursors.get(role).cloned();
7096            let pick = match last {
7097                None => candidates[0].clone(),
7098                Some(last_h) => candidates
7099                    .iter()
7100                    .find(|(h, _)| h.as_str() > last_h.as_str())
7101                    .cloned()
7102                    .unwrap_or_else(|| candidates[0].clone()),
7103            };
7104            cursors.insert(role.to_string(), pick.0.clone());
7105            write_mesh_route_cursors(&cursor_path, &cursors)?;
7106            pick
7107        }
7108        _ => unreachable!(),
7109    };
7110
7111    let (chosen_handle, _chosen_did) = chosen;
7112
7113    // Body parsing follows wire send / mesh broadcast.
7114    let body_value: Value = if body_arg == "-" {
7115        use std::io::Read;
7116        let mut raw = String::new();
7117        std::io::stdin()
7118            .read_to_string(&mut raw)
7119            .with_context(|| "reading body from stdin")?;
7120        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7121    } else if let Some(path) = body_arg.strip_prefix('@') {
7122        let raw =
7123            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7124        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7125    } else {
7126        Value::String(body_arg.to_string())
7127    };
7128
7129    let sk_seed = config::read_private_key()?;
7130    let card = config::read_agent_card()?;
7131    let did = card
7132        .get("did")
7133        .and_then(Value::as_str)
7134        .ok_or_else(|| anyhow!("agent-card missing did"))?
7135        .to_string();
7136    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7137    let pk_b64 = card
7138        .get("verify_keys")
7139        .and_then(Value::as_object)
7140        .and_then(|m| m.values().next())
7141        .and_then(|v| v.get("key"))
7142        .and_then(Value::as_str)
7143        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7144    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7145
7146    let kind_id = parse_kind(kind)?;
7147    let now_iso = time::OffsetDateTime::now_utc()
7148        .format(&time::format_description::well_known::Rfc3339)
7149        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7150
7151    let event = json!({
7152        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7153        "timestamp": now_iso,
7154        "from": did,
7155        "to": format!("did:wire:{chosen_handle}"),
7156        "type": kind,
7157        "kind": kind_id,
7158        "body": json!({
7159            "content": body_value,
7160            "routed_via": {
7161                "role": role,
7162                "strategy": strategy,
7163            },
7164        }),
7165    });
7166    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7167        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
7168    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7169
7170    let line = serde_json::to_vec(&signed)?;
7171    config::append_outbox_record(&chosen_handle, &line)?;
7172
7173    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
7174    if endpoints.is_empty() {
7175        bail!(
7176            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
7177        );
7178    }
7179    let start = Instant::now();
7180    let mut delivered = false;
7181    let mut last_err: Option<String> = None;
7182    let mut via_scope: Option<String> = None;
7183    for ep in &endpoints {
7184        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
7185        // route via uds_request, others via reqwest. Allows peers with
7186        // UDS-tagged endpoints in their agent-card to receive events
7187        // over the local socket instead of loopback HTTP.
7188        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7189            Ok(_) => {
7190                delivered = true;
7191                via_scope = Some(
7192                    match ep.scope {
7193                        crate::endpoints::EndpointScope::Local => "local",
7194                        crate::endpoints::EndpointScope::Lan => "lan",
7195                        crate::endpoints::EndpointScope::Uds => "uds",
7196                        crate::endpoints::EndpointScope::Federation => "federation",
7197                    }
7198                    .to_string(),
7199                );
7200                break;
7201            }
7202            Err(e) => last_err = Some(format!("{e:#}")),
7203        }
7204    }
7205    let rtt_ms = start.elapsed().as_millis() as u64;
7206
7207    let summary = json!({
7208        "role": role,
7209        "strategy": strategy,
7210        "routed_to": chosen_handle,
7211        "event_id": event_id,
7212        "delivered": delivered,
7213        "delivered_via": via_scope,
7214        "rtt_ms": rtt_ms,
7215        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
7216        "error": last_err,
7217    });
7218
7219    if as_json {
7220        println!("{}", serde_json::to_string(&summary)?);
7221    } else if delivered {
7222        let via = via_scope.as_deref().unwrap_or("?");
7223        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
7224    } else {
7225        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
7226        bail!("delivery to `{chosen_handle}` failed: {err}");
7227    }
7228    Ok(())
7229}
7230
7231fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
7232    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
7233}
7234
7235fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
7236    std::fs::read(path)
7237        .ok()
7238        .and_then(|b| serde_json::from_slice(&b).ok())
7239        .unwrap_or_default()
7240}
7241
7242fn write_mesh_route_cursors(
7243    path: &std::path::Path,
7244    cursors: &std::collections::BTreeMap<String, String>,
7245) -> Result<()> {
7246    if let Some(parent) = path.parent() {
7247        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
7248    }
7249    let body = serde_json::to_vec_pretty(cursors)?;
7250    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
7251    Ok(())
7252}
7253
7254/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
7255/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
7256/// behind a discoverability-friendlier surface, plus cross-session
7257/// enumeration for the list path.
7258fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
7259    match action {
7260        MeshRoleAction::Set { role, json } => {
7261            validate_role_tag(&role)?;
7262            let new_profile =
7263                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
7264            if json {
7265                println!(
7266                    "{}",
7267                    serde_json::to_string(&json!({
7268                        "role": role,
7269                        "profile": new_profile,
7270                    }))?
7271                );
7272            } else {
7273                println!("self role = {role} (signed into agent-card)");
7274            }
7275        }
7276        MeshRoleAction::Get { peer, json } => {
7277            let (who, role) = match peer.as_deref() {
7278                None => {
7279                    let card = config::read_agent_card()?;
7280                    let role = card
7281                        .get("profile")
7282                        .and_then(|p| p.get("role"))
7283                        .and_then(Value::as_str)
7284                        .map(str::to_string);
7285                    let who = card
7286                        .get("did")
7287                        .and_then(Value::as_str)
7288                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
7289                        .unwrap_or_else(|| "self".to_string());
7290                    (who, role)
7291                }
7292                Some(handle) => {
7293                    let bare = crate::agent_card::bare_handle(handle).to_string();
7294                    let trust = config::read_trust()?;
7295                    let role = trust
7296                        .get("agents")
7297                        .and_then(|a| a.get(&bare))
7298                        .and_then(|a| a.get("card"))
7299                        .and_then(|c| c.get("profile"))
7300                        .and_then(|p| p.get("role"))
7301                        .and_then(Value::as_str)
7302                        .map(str::to_string);
7303                    (bare, role)
7304                }
7305            };
7306            if json {
7307                println!(
7308                    "{}",
7309                    serde_json::to_string(&json!({
7310                        "handle": who,
7311                        "role": role,
7312                    }))?
7313                );
7314            } else {
7315                match role {
7316                    Some(r) => println!("{who}: {r}"),
7317                    None => println!("{who}: (unset)"),
7318                }
7319            }
7320        }
7321        MeshRoleAction::List { json } => {
7322            let mut self_did: Option<String> = None;
7323            if let Ok(card) = config::read_agent_card() {
7324                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
7325            }
7326            let sessions = crate::session::list_sessions()?;
7327            let mut rows: Vec<Value> = Vec::new();
7328            for s in &sessions {
7329                let card_path = s
7330                    .home_dir
7331                    .join("config")
7332                    .join("wire")
7333                    .join("agent-card.json");
7334                let role = std::fs::read(&card_path)
7335                    .ok()
7336                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7337                    .and_then(|c| {
7338                        c.get("profile")
7339                            .and_then(|p| p.get("role"))
7340                            .and_then(Value::as_str)
7341                            .map(str::to_string)
7342                    });
7343                let is_self = match (&self_did, &s.did) {
7344                    (Some(a), Some(b)) => a == b,
7345                    _ => false,
7346                };
7347                rows.push(json!({
7348                    "name": s.name,
7349                    "handle": s.handle,
7350                    "role": role,
7351                    "self": is_self,
7352                }));
7353            }
7354            rows.sort_by(|a, b| {
7355                a["name"]
7356                    .as_str()
7357                    .unwrap_or("")
7358                    .cmp(b["name"].as_str().unwrap_or(""))
7359            });
7360            if json {
7361                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
7362            } else if rows.is_empty() {
7363                println!("no sister sessions on this machine.");
7364            } else {
7365                println!("SISTER ROLES (this machine):");
7366                for r in &rows {
7367                    let name = r["name"].as_str().unwrap_or("?");
7368                    let role = r["role"].as_str().unwrap_or("(unset)");
7369                    let marker = if r["self"].as_bool().unwrap_or(false) {
7370                        "    ← you"
7371                    } else {
7372                        ""
7373                    };
7374                    println!("  {name:<24} {role}{marker}");
7375                }
7376            }
7377        }
7378        MeshRoleAction::Clear { json } => {
7379            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
7380            if json {
7381                println!(
7382                    "{}",
7383                    serde_json::to_string(&json!({
7384                        "cleared": true,
7385                        "profile": new_profile,
7386                    }))?
7387                );
7388            } else {
7389                println!("self role cleared");
7390            }
7391        }
7392    }
7393    Ok(())
7394}
7395
7396/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
7397/// No vocabulary check — operators choose the taxonomy (planner /
7398/// reviewer / dispatcher / your-custom-tag). The constraint is purely
7399/// to keep the tag safe for filenames / URLs / shell args.
7400fn validate_role_tag(role: &str) -> Result<()> {
7401    if role.is_empty() {
7402        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
7403    }
7404    if role.len() > 32 {
7405        bail!("role too long ({} chars; max 32)", role.len());
7406    }
7407    for c in role.chars() {
7408        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
7409            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
7410        }
7411    }
7412    Ok(())
7413}
7414
7415/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
7416///
7417/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
7418/// canonical event including `to:`, so per-recipient signing is required;
7419/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
7420/// Per-recipient pushes happen in parallel via `std::thread::scope` so
7421/// broadcast-to-5 takes ~1× RTT, not 5×.
7422///
7423/// **Scope filter.** Default `local` — only peers reachable via a same-
7424/// machine local relay (priority-1 endpoint has `scope=local`). This is
7425/// the lowest-blast-radius default: local-only broadcasts cannot escape
7426/// the operator's machine. `federation` flips to public-relay peers
7427/// only; `both` removes the filter.
7428///
7429/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
7430/// resolution, never trust["agents"] expansion. Closes #8-class
7431/// phonebook-scrape vectors by construction: an attacker pinning a
7432/// hostile handle has to first be pinned bidirectionally by the
7433/// operator, and even then `--exclude` is the loud opt-out.
7434fn cmd_mesh_broadcast(
7435    kind: &str,
7436    scope_str: &str,
7437    exclude: &[String],
7438    _noreply: bool,
7439    body_arg: &str,
7440    as_json: bool,
7441) -> Result<()> {
7442    use std::time::Instant;
7443
7444    if !config::is_initialized()? {
7445        bail!("not initialized — run `wire init <handle>` first");
7446    }
7447
7448    let scope = match scope_str {
7449        "local" => crate::endpoints::EndpointScope::Local,
7450        "federation" => crate::endpoints::EndpointScope::Federation,
7451        "both" => {
7452            // Sentinel: we don't actually have a `Both` variant on the
7453            // scope enum; use a tri-state below. Treat as Local for the
7454            // typed match and special-case it via the bool below.
7455            crate::endpoints::EndpointScope::Local
7456        }
7457        other => bail!("unknown scope `{other}` — use local | federation | both"),
7458    };
7459    let any_scope = scope_str == "both";
7460
7461    let state = config::read_relay_state()?;
7462    let peers = state["peers"].as_object().cloned().unwrap_or_default();
7463    if peers.is_empty() {
7464        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
7465    }
7466
7467    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7468
7469    // Walk the pinned-peer set, filter by scope + exclude. Keep the
7470    // priority-ordered endpoint list for each match so the push can
7471    // try local first then fall through to federation (when scope=both).
7472    struct Target {
7473        handle: String,
7474        endpoints: Vec<crate::endpoints::Endpoint>,
7475    }
7476    let mut targets: Vec<Target> = Vec::new();
7477    let mut skipped_wrong_scope: Vec<String> = Vec::new();
7478    let mut skipped_excluded: Vec<String> = Vec::new();
7479    for handle in peers.keys() {
7480        if exclude_set.contains(handle.as_str()) {
7481            skipped_excluded.push(handle.clone());
7482            continue;
7483        }
7484        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
7485        let filtered: Vec<crate::endpoints::Endpoint> = ordered
7486            .into_iter()
7487            .filter(|ep| any_scope || ep.scope == scope)
7488            .collect();
7489        if filtered.is_empty() {
7490            skipped_wrong_scope.push(handle.clone());
7491            continue;
7492        }
7493        targets.push(Target {
7494            handle: handle.clone(),
7495            endpoints: filtered,
7496        });
7497    }
7498
7499    if targets.is_empty() {
7500        bail!(
7501            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
7502            skipped_excluded.len(),
7503            skipped_wrong_scope.len()
7504        );
7505    }
7506
7507    // Load signing material once; share across per-peer signatures.
7508    let sk_seed = config::read_private_key()?;
7509    let card = config::read_agent_card()?;
7510    let did = card
7511        .get("did")
7512        .and_then(Value::as_str)
7513        .ok_or_else(|| anyhow!("agent-card missing did"))?
7514        .to_string();
7515    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7516    let pk_b64 = card
7517        .get("verify_keys")
7518        .and_then(Value::as_object)
7519        .and_then(|m| m.values().next())
7520        .and_then(|v| v.get("key"))
7521        .and_then(Value::as_str)
7522        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7523    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7524
7525    let body_value: Value = if body_arg == "-" {
7526        use std::io::Read;
7527        let mut raw = String::new();
7528        std::io::stdin()
7529            .read_to_string(&mut raw)
7530            .with_context(|| "reading body from stdin")?;
7531        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7532    } else if let Some(path) = body_arg.strip_prefix('@') {
7533        let raw =
7534            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7535        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7536    } else {
7537        Value::String(body_arg.to_string())
7538    };
7539
7540    let kind_id = parse_kind(kind)?;
7541    let now_iso = time::OffsetDateTime::now_utc()
7542        .format(&time::format_description::well_known::Rfc3339)
7543        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7544
7545    let broadcast_id = generate_broadcast_id();
7546    let target_count = targets.len();
7547
7548    // Build + sign every event up front (sequential, ~50µs/sig). Then
7549    // queue to outbox + push to relay in parallel per-peer. Returns
7550    // a per-peer outcome we then sort by handle for deterministic output.
7551    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
7552        Vec::with_capacity(targets.len());
7553    for t in &targets {
7554        let body = json!({
7555            "content": body_value,
7556            "broadcast_id": broadcast_id,
7557            "broadcast_target_count": target_count,
7558        });
7559        let event = json!({
7560            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7561            "timestamp": now_iso,
7562            "from": did,
7563            "to": format!("did:wire:{}", t.handle),
7564            "type": kind,
7565            "kind": kind_id,
7566            "body": body,
7567        });
7568        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7569            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
7570        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7571        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
7572    }
7573
7574    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
7575    // holds a per-path mutex; writes are independent across handles but
7576    // we want the side-effect ordering deterministic).
7577    for (peer, _, signed, _) in &signed_per_peer {
7578        let line = serde_json::to_vec(signed)?;
7579        config::append_outbox_record(peer, &line)?;
7580    }
7581
7582    // Per-peer parallel push. Each thread tries the priority-ordered
7583    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
7584    // error_opt) over a channel.
7585    use std::sync::mpsc;
7586    let (tx, rx) = mpsc::channel::<Value>();
7587    std::thread::scope(|s| {
7588        for (peer, endpoints, signed, event_id) in &signed_per_peer {
7589            let tx = tx.clone();
7590            let peer = peer.clone();
7591            let event_id = event_id.clone();
7592            let endpoints = endpoints.clone();
7593            let signed = signed.clone();
7594            s.spawn(move || {
7595                let start = Instant::now();
7596                let mut delivered = false;
7597                let mut last_err: Option<String> = None;
7598                let mut delivered_via: Option<String> = None;
7599                for ep in &endpoints {
7600                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
7601                    // uds_request, else reqwest). Same as cmd_send's
7602                    // single-peer path above; this is the parallel
7603                    // multi-peer broadcast loop.
7604                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7605                        Ok(_) => {
7606                            delivered = true;
7607                            delivered_via = Some(
7608                                match ep.scope {
7609                                    crate::endpoints::EndpointScope::Local => "local",
7610                                    crate::endpoints::EndpointScope::Lan => "lan",
7611                                    crate::endpoints::EndpointScope::Uds => "uds",
7612                                    crate::endpoints::EndpointScope::Federation => "federation",
7613                                }
7614                                .to_string(),
7615                            );
7616                            break;
7617                        }
7618                        Err(e) => last_err = Some(format!("{e:#}")),
7619                    }
7620                }
7621                let rtt_ms = start.elapsed().as_millis() as u64;
7622                let _ = tx.send(json!({
7623                    "peer": peer,
7624                    "event_id": event_id,
7625                    "delivered": delivered,
7626                    "delivered_via": delivered_via,
7627                    "rtt_ms": rtt_ms,
7628                    "error": last_err,
7629                }));
7630            });
7631        }
7632    });
7633    drop(tx);
7634
7635    let mut results: Vec<Value> = rx.iter().collect();
7636    results.sort_by(|a, b| {
7637        a["peer"]
7638            .as_str()
7639            .unwrap_or("")
7640            .cmp(b["peer"].as_str().unwrap_or(""))
7641    });
7642
7643    let delivered = results
7644        .iter()
7645        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
7646        .count();
7647    let failed = results.len() - delivered;
7648
7649    let summary = json!({
7650        "broadcast_id": broadcast_id,
7651        "kind": kind,
7652        "scope": scope_str,
7653        "target_count": target_count,
7654        "delivered": delivered,
7655        "failed": failed,
7656        "skipped_excluded": skipped_excluded,
7657        "skipped_wrong_scope": skipped_wrong_scope,
7658        "results": results,
7659    });
7660
7661    if as_json {
7662        println!("{}", serde_json::to_string(&summary)?);
7663        return Ok(());
7664    }
7665
7666    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
7667    for r in &results {
7668        let peer = r["peer"].as_str().unwrap_or("?");
7669        let delivered = r["delivered"].as_bool().unwrap_or(false);
7670        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
7671        let via = r["delivered_via"].as_str().unwrap_or("");
7672        if delivered {
7673            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
7674        } else {
7675            let err = r["error"].as_str().unwrap_or("?");
7676            println!("  {peer:<24} ✗ failed — {err}");
7677        }
7678    }
7679    if !skipped_excluded.is_empty() {
7680        println!("  excluded: {}", skipped_excluded.join(", "));
7681    }
7682    if !skipped_wrong_scope.is_empty() {
7683        println!(
7684            "  skipped (wrong scope): {}",
7685            skipped_wrong_scope.join(", ")
7686        );
7687    }
7688    println!("broadcast_id: {broadcast_id}");
7689    Ok(())
7690}
7691
7692/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
7693/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
7694/// correlate by string equality, the shape is for human readability.
7695fn generate_broadcast_id() -> String {
7696    use rand::RngCore;
7697    let mut buf = [0u8; 16];
7698    rand::thread_rng().fill_bytes(&mut buf);
7699    let h = hex::encode(buf);
7700    format!(
7701        "{}-{}-{}-{}-{}",
7702        &h[0..8],
7703        &h[8..12],
7704        &h[12..16],
7705        &h[16..20],
7706        &h[20..32],
7707    )
7708}
7709
7710fn cmd_session(cmd: SessionCommand) -> Result<()> {
7711    match cmd {
7712        SessionCommand::New {
7713            name,
7714            relay,
7715            with_local,
7716            local_relay,
7717            with_lan,
7718            lan_relay,
7719            with_uds,
7720            uds_socket,
7721            no_daemon,
7722            local_only,
7723            json,
7724        } => cmd_session_new(
7725            name.as_deref(),
7726            &relay,
7727            with_local,
7728            &local_relay,
7729            with_lan,
7730            lan_relay.as_deref(),
7731            with_uds,
7732            uds_socket.as_deref(),
7733            no_daemon,
7734            local_only,
7735            json,
7736        ),
7737        SessionCommand::List { json } => cmd_session_list(json),
7738        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
7739        SessionCommand::PairAllLocal {
7740            settle_secs,
7741            federation_relay,
7742            json,
7743        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
7744        SessionCommand::MeshStatus { stale_secs, json } => {
7745            cmd_session_mesh_status(stale_secs, json)
7746        }
7747        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
7748        SessionCommand::Current { json } => cmd_session_current(json),
7749        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
7750        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
7751    }
7752}
7753
7754fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
7755    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7756    let cwd_str = cwd.to_string_lossy().into_owned();
7757
7758    let resolved_name = match name_arg {
7759        Some(n) => crate::session::sanitize_name(n),
7760        None => crate::session::sanitize_name(
7761            cwd.file_name()
7762                .and_then(|s| s.to_str())
7763                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
7764        ),
7765    };
7766
7767    let session_home = crate::session::session_dir(&resolved_name)?;
7768    if !session_home.exists() {
7769        bail!(
7770            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
7771            session_home.display()
7772        );
7773    }
7774
7775    let prior = crate::session::read_registry()
7776        .ok()
7777        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
7778    if prior.as_deref() == Some(resolved_name.as_str()) {
7779        if json {
7780            println!(
7781                "{}",
7782                serde_json::to_string(&json!({
7783                    "cwd": cwd_str,
7784                    "session": resolved_name,
7785                    "changed": false,
7786                }))?
7787            );
7788        } else {
7789            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
7790        }
7791        return Ok(());
7792    }
7793    if let Some(prior_name) = &prior {
7794        eprintln!(
7795            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
7796        );
7797    }
7798
7799    crate::session::update_registry(|reg| {
7800        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
7801        Ok(())
7802    })?;
7803
7804    if json {
7805        println!(
7806            "{}",
7807            serde_json::to_string(&json!({
7808                "cwd": cwd_str,
7809                "session": resolved_name,
7810                "changed": true,
7811                "previous": prior,
7812            }))?
7813        );
7814    } else {
7815        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
7816        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
7817    }
7818    Ok(())
7819}
7820
7821fn resolve_session_name(name: Option<&str>) -> Result<String> {
7822    if let Some(n) = name {
7823        return Ok(crate::session::sanitize_name(n));
7824    }
7825    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7826    let registry = crate::session::read_registry().unwrap_or_default();
7827    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
7828}
7829
7830#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
7831// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
7832// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
7833fn cmd_session_new(
7834    name_arg: Option<&str>,
7835    relay: &str,
7836    with_local: bool,
7837    local_relay: &str,
7838    with_lan: bool,
7839    lan_relay: Option<&str>,
7840    with_uds: bool,
7841    uds_socket: Option<&std::path::Path>,
7842    no_daemon: bool,
7843    local_only: bool,
7844    as_json: bool,
7845) -> Result<()> {
7846    // v0.6.6: --local-only implies --with-local (a federation-free
7847    // session with no endpoints at all would be unaddressable).
7848    let with_local = with_local || local_only;
7849    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
7850    if with_lan && lan_relay.is_none() {
7851        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
7852    }
7853    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
7854    if with_uds && uds_socket.is_none() {
7855        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
7856    }
7857    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7858    let mut registry = crate::session::read_registry().unwrap_or_default();
7859    let name = match name_arg {
7860        Some(n) => crate::session::sanitize_name(n),
7861        None => crate::session::derive_name_from_cwd(&cwd, &registry),
7862    };
7863    let session_home = crate::session::session_dir(&name)?;
7864
7865    let already_exists = session_home.exists()
7866        && session_home
7867            .join("config")
7868            .join("wire")
7869            .join("agent-card.json")
7870            .exists();
7871    if already_exists {
7872        // Idempotent: re-register the cwd (if not already), refresh the
7873        // daemon if requested, surface the env-var line. Do not re-init
7874        // identity — that would clobber the keypair.
7875        registry
7876            .by_cwd
7877            .insert(cwd.to_string_lossy().into_owned(), name.clone());
7878        crate::session::write_registry(&registry)?;
7879        let info = render_session_info(&name, &session_home, &cwd)?;
7880        emit_session_new_result(&info, "already_exists", as_json)?;
7881        if !no_daemon {
7882            ensure_session_daemon(&session_home)?;
7883        }
7884        return Ok(());
7885    }
7886
7887    std::fs::create_dir_all(&session_home)
7888        .with_context(|| format!("creating session dir {session_home:?}"))?;
7889
7890    // Phase 1: init identity in the new session's WIRE_HOME. For
7891    // federation-bound sessions we pass `--relay` so init also
7892    // allocates a federation slot in the same step; for `--local-only`
7893    // we run init with `--offline` (v0.9 requires explicit reachability
7894    // acknowledgement at init time) because cmd_session_new allocates
7895    // the local-relay slot itself via try_allocate_local_slot below.
7896    // The session is not actually slotless — init is just deferred to
7897    // the subsequent allocation pass.
7898    let init_args: Vec<&str> = if local_only {
7899        vec!["init", &name, "--offline"]
7900    } else {
7901        vec!["init", &name, "--relay", relay]
7902    };
7903    let init_status = run_wire_with_home(&session_home, &init_args)?;
7904    if !init_status.success() {
7905        let how = if local_only {
7906            format!("`wire init {name}` (local-only)")
7907        } else {
7908            format!("`wire init {name} --relay {relay}`")
7909        };
7910        bail!("{how} failed inside session dir {session_home:?}");
7911    }
7912
7913    // Phase 2: claim the handle on the federation relay — SKIPPED when
7914    // `--local-only`. Local-only sessions have no public address and
7915    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
7916    // tries to publish them.
7917    let effective_handle = if local_only {
7918        name.clone()
7919    } else {
7920        let mut claim_attempt = 0u32;
7921        let mut effective = name.clone();
7922        loop {
7923            claim_attempt += 1;
7924            let status =
7925                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
7926            if status.success() {
7927                break;
7928            }
7929            if claim_attempt >= 5 {
7930                bail!(
7931                    "5 failed attempts to claim a handle on {relay} for session {name}. \
7932                     Try `wire session destroy {name} --force` and re-run with a different name, \
7933                     or use `--local-only` if you don't need a federation address."
7934                );
7935            }
7936            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
7937            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
7938            let token = suffix
7939                .rsplit('-')
7940                .next()
7941                .filter(|t| t.len() == 4)
7942                .map(str::to_string)
7943                .unwrap_or_else(|| format!("{claim_attempt}"));
7944            effective = format!("{name}-{token}");
7945        }
7946        effective
7947    };
7948
7949    // Persist the cwd → name mapping NOW so subsequent invocations from
7950    // this directory short-circuit to the "already_exists" branch.
7951    registry
7952        .by_cwd
7953        .insert(cwd.to_string_lossy().into_owned(), name.clone());
7954    crate::session::write_registry(&registry)?;
7955
7956    // v0.5.17: --with-local probes the local relay and, if it's
7957    // reachable, allocates a second slot there. The session's
7958    // relay_state.json grows a `self.endpoints[]` array carrying both
7959    // endpoints; routing layer (cmd_push) prefers local for sister-
7960    // session peers that also have a local slot.
7961    //
7962    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
7963    // allocation; a failed probe leaves the session with no endpoints,
7964    // which we surface as a hard error (the operator asked for local-
7965    // only but the local relay isn't running — fix that first).
7966    if with_local {
7967        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
7968        if local_only {
7969            // Verify the local slot landed. If the local relay was
7970            // unreachable, the session would be unreachable from
7971            // anywhere — surface that loudly instead of leaving an
7972            // orphaned session dir.
7973            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
7974            let state: Value = std::fs::read(&relay_state_path)
7975                .ok()
7976                .and_then(|b| serde_json::from_slice(&b).ok())
7977                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7978            let endpoints = crate::endpoints::self_endpoints(&state);
7979            let has_local = endpoints
7980                .iter()
7981                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
7982            if !has_local {
7983                bail!(
7984                    "--local-only requested but local-relay probe at {local_relay} failed — \
7985                     ensure the local relay is running (`wire service install --local-relay`), \
7986                     then re-run `wire session new {name} --local-only`."
7987                );
7988            }
7989        }
7990    }
7991
7992    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
7993    // Sits AFTER local because cmd_session_new's flow is "add endpoints
7994    // alongside existing self.endpoints[]" — order independent post-init.
7995    if with_lan && let Some(lan_url) = lan_relay {
7996        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
7997    }
7998    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
7999    if with_uds && let Some(socket_path) = uds_socket {
8000        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
8001    }
8002
8003    if !no_daemon {
8004        ensure_session_daemon(&session_home)?;
8005    }
8006
8007    let info = render_session_info(&name, &session_home, &cwd)?;
8008    emit_session_new_result(&info, "created", as_json)
8009}
8010
8011/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
8012/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
8013/// sister sessions can route over the local socket instead of loopback
8014/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
8015/// alpha.17 — reqwest has no UDS support.
8016///
8017/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
8018/// and try_allocate_lan_slot semantics): session stays at existing
8019/// endpoint mix, operator can retry once the UDS relay is up.
8020#[cfg(unix)]
8021fn try_allocate_uds_slot(
8022    session_home: &std::path::Path,
8023    handle: &str,
8024    uds_socket: &std::path::Path,
8025) {
8026    // Probe healthz first so we fail fast with a clear stderr if the
8027    // socket doesn't exist OR isn't a wire relay.
8028    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
8029        Ok((200, _)) => true,
8030        Ok((status, body)) => {
8031            eprintln!(
8032                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
8033                String::from_utf8_lossy(&body)
8034            );
8035            return;
8036        }
8037        Err(e) => {
8038            eprintln!(
8039                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
8040                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
8041            );
8042            return;
8043        }
8044    };
8045    if !healthz {
8046        return;
8047    }
8048
8049    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
8050    let alloc_body = serde_json::json!({"handle": handle}).to_string();
8051    let (status, body) = match crate::relay_client::uds_request(
8052        uds_socket,
8053        "POST",
8054        "/v1/slot/allocate",
8055        &[("Content-Type", "application/json")],
8056        alloc_body.as_bytes(),
8057    ) {
8058        Ok(r) => r,
8059        Err(e) => {
8060            eprintln!(
8061                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
8062            );
8063            return;
8064        }
8065    };
8066    if status >= 300 {
8067        eprintln!(
8068            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
8069            String::from_utf8_lossy(&body)
8070        );
8071        return;
8072    }
8073    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
8074        Ok(a) => a,
8075        Err(e) => {
8076            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
8077            return;
8078        }
8079    };
8080
8081    let state_path = session_home.join("config").join("wire").join("relay.json");
8082    let mut state: serde_json::Value = std::fs::read(&state_path)
8083        .ok()
8084        .and_then(|b| serde_json::from_slice(&b).ok())
8085        .unwrap_or_else(|| serde_json::json!({}));
8086
8087    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8088        .get("self")
8089        .and_then(|s| s.get("endpoints"))
8090        .and_then(|e| e.as_array())
8091        .map(|arr| {
8092            arr.iter()
8093                .filter_map(|v| {
8094                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8095                })
8096                .collect()
8097        })
8098        .unwrap_or_default();
8099    endpoints.push(crate::endpoints::Endpoint::uds(
8100        format!("unix://{}", uds_socket.display()),
8101        alloc.slot_id.clone(),
8102        alloc.slot_token.clone(),
8103    ));
8104
8105    let self_obj = state
8106        .as_object_mut()
8107        .expect("relay_state root is an object")
8108        .entry("self")
8109        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8110    if !self_obj.is_object() {
8111        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8112    }
8113    if let Some(obj) = self_obj.as_object_mut() {
8114        obj.insert(
8115            "endpoints".into(),
8116            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8117        );
8118    }
8119    if let Err(e) = std::fs::write(
8120        &state_path,
8121        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8122    ) {
8123        eprintln!("wire session new: failed to write {state_path:?}: {e}");
8124        return;
8125    }
8126    eprintln!(
8127        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
8128        uds_socket.display(),
8129        alloc.slot_id
8130    );
8131}
8132
8133#[cfg(not(unix))]
8134fn try_allocate_uds_slot(
8135    _session_home: &std::path::Path,
8136    _handle: &str,
8137    _uds_socket: &std::path::Path,
8138) {
8139    eprintln!(
8140        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
8141    );
8142}
8143
8144/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
8145/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
8146/// pulling the agent-card see a third reachable address.
8147///
8148/// Mirrors `try_allocate_local_slot` but tags the endpoint
8149/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
8150/// session stays at whatever endpoint mix it already had — operators
8151/// can retry with `wire session new --with-lan --lan-relay <url>` once
8152/// the LAN relay is up.
8153fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
8154    let probe = match crate::relay_client::build_blocking_client(Some(
8155        std::time::Duration::from_millis(500),
8156    )) {
8157        Ok(c) => c,
8158        Err(e) => {
8159            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
8160            return;
8161        }
8162    };
8163    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
8164    match probe.get(&healthz_url).send() {
8165        Ok(resp) if resp.status().is_success() => {}
8166        Ok(resp) => {
8167            eprintln!(
8168                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
8169                resp.status()
8170            );
8171            return;
8172        }
8173        Err(e) => {
8174            eprintln!(
8175                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
8176                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
8177                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8178            );
8179            return;
8180        }
8181    };
8182
8183    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
8184    let alloc = match lan_client.allocate_slot(Some(handle)) {
8185        Ok(a) => a,
8186        Err(e) => {
8187            eprintln!(
8188                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
8189            );
8190            return;
8191        }
8192    };
8193
8194    let state_path = session_home.join("config").join("wire").join("relay.json");
8195    let mut state: serde_json::Value = std::fs::read(&state_path)
8196        .ok()
8197        .and_then(|b| serde_json::from_slice(&b).ok())
8198        .unwrap_or_else(|| serde_json::json!({}));
8199
8200    // Read existing endpoints array and add the LAN one. Preserve
8201    // federation / local entries already there.
8202    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8203        .get("self")
8204        .and_then(|s| s.get("endpoints"))
8205        .and_then(|e| e.as_array())
8206        .map(|arr| {
8207            arr.iter()
8208                .filter_map(|v| {
8209                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8210                })
8211                .collect()
8212        })
8213        .unwrap_or_default();
8214    endpoints.push(crate::endpoints::Endpoint::lan(
8215        lan_relay.trim_end_matches('/').to_string(),
8216        alloc.slot_id.clone(),
8217        alloc.slot_token.clone(),
8218    ));
8219
8220    let self_obj = state
8221        .as_object_mut()
8222        .expect("relay_state root is an object")
8223        .entry("self")
8224        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8225    if !self_obj.is_object() {
8226        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8227    }
8228    if let Some(obj) = self_obj.as_object_mut() {
8229        obj.insert(
8230            "endpoints".into(),
8231            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8232        );
8233    }
8234    if let Err(e) = std::fs::write(
8235        &state_path,
8236        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8237    ) {
8238        eprintln!("wire session new: failed to write {state_path:?}: {e}");
8239        return;
8240    }
8241    eprintln!(
8242        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
8243        alloc.slot_id
8244    );
8245}
8246
8247/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
8248/// a short timeout, allocate a slot there and update the session's
8249/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
8250///
8251/// Failure to reach the local relay is NOT fatal — the session stays
8252/// federation-only. Logs to stderr on failure so operators can tell
8253/// the local relay isn't running, but doesn't abort the bootstrap.
8254fn try_allocate_local_slot(
8255    session_home: &std::path::Path,
8256    handle: &str,
8257    _federation_relay: &str,
8258    local_relay: &str,
8259) {
8260    // Probe healthz with a tight timeout. Use a fresh client (don't
8261    // share the daemon-wide one) so the timeout is local to this call.
8262    let probe = match crate::relay_client::build_blocking_client(Some(
8263        std::time::Duration::from_millis(500),
8264    )) {
8265        Ok(c) => c,
8266        Err(e) => {
8267            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
8268            return;
8269        }
8270    };
8271    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
8272    match probe.get(&healthz_url).send() {
8273        Ok(resp) if resp.status().is_success() => {}
8274        Ok(resp) => {
8275            eprintln!(
8276                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
8277                resp.status()
8278            );
8279            return;
8280        }
8281        Err(e) => {
8282            eprintln!(
8283                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
8284                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
8285                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8286            );
8287            return;
8288        }
8289    };
8290
8291    // Allocate a slot on the local relay.
8292    let local_client = crate::relay_client::RelayClient::new(local_relay);
8293    let alloc = match local_client.allocate_slot(Some(handle)) {
8294        Ok(a) => a,
8295        Err(e) => {
8296            eprintln!(
8297                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
8298            );
8299            return;
8300        }
8301    };
8302
8303    // Merge into the session's relay.json. We invoke wire via
8304    // run_wire_with_home for federation calls (subprocess isolation),
8305    // but relay.json is a simple file we can edit directly
8306    // — and need to, because there's no `wire bind-relay --add-local`
8307    // command yet (could add later; out of scope for v0.5.17 MVP).
8308    //
8309    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
8310    // does not exist (canonical filename is `relay.json` per
8311    // `config::relay_state_path`). The mis-named file write succeeded
8312    // but landed in a sibling path nothing else reads. Every
8313    // `wire session new --with-local` invocation silently degraded to
8314    // federation-only despite the "local slot allocated" stderr line.
8315    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
8316    // session's relay.json — it had only the federation endpoint.
8317    let state_path = session_home.join("config").join("wire").join("relay.json");
8318    let mut state: serde_json::Value = std::fs::read(&state_path)
8319        .ok()
8320        .and_then(|b| serde_json::from_slice(&b).ok())
8321        .unwrap_or_else(|| serde_json::json!({}));
8322    // Read the existing federation self info (already written by
8323    // `wire init` + `wire bind-relay` path during session bootstrap).
8324    let fed_endpoint = state.get("self").and_then(|s| {
8325        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
8326        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
8327        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
8328        Some(crate::endpoints::Endpoint::federation(
8329            url.to_string(),
8330            slot_id.to_string(),
8331            slot_token.to_string(),
8332        ))
8333    });
8334
8335    let local_endpoint = crate::endpoints::Endpoint::local(
8336        local_relay.trim_end_matches('/').to_string(),
8337        alloc.slot_id.clone(),
8338        alloc.slot_token.clone(),
8339    );
8340
8341    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
8342    if let Some(f) = fed_endpoint.clone() {
8343        endpoints.push(f);
8344    }
8345    endpoints.push(local_endpoint);
8346
8347    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
8348    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
8349    // `slot_token` fields must point at the LOCAL endpoint so callers
8350    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
8351    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
8352    // still find a valid slot. Pre-v0.6.6 this branch wrote
8353    // `relay_url: federation_relay` with no slot_id, which produced
8354    // half-populated self state that broke pair-accept on local-only
8355    // sessions.
8356    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
8357        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
8358        None => (
8359            local_relay.trim_end_matches('/').to_string(),
8360            alloc.slot_id.clone(),
8361            alloc.slot_token.clone(),
8362        ),
8363    };
8364    let self_obj = state
8365        .as_object_mut()
8366        .expect("relay_state root is an object")
8367        .entry("self")
8368        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8369    // The entry might be Value::Null (left by read_relay_state's default
8370    // template) — replace with an object before mutating.
8371    if !self_obj.is_object() {
8372        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8373    }
8374    if let Some(obj) = self_obj.as_object_mut() {
8375        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
8376        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
8377        obj.insert(
8378            "slot_token".into(),
8379            serde_json::Value::String(legacy_slot_token),
8380        );
8381        obj.insert(
8382            "endpoints".into(),
8383            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8384        );
8385    }
8386
8387    if let Err(e) = std::fs::write(
8388        &state_path,
8389        serde_json::to_vec_pretty(&state).unwrap_or_default(),
8390    ) {
8391        eprintln!(
8392            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
8393        );
8394        return;
8395    }
8396    eprintln!(
8397        "wire session new: local slot allocated on {local_relay} (slot_id={})",
8398        alloc.slot_id
8399    );
8400}
8401
8402fn render_session_info(
8403    name: &str,
8404    session_home: &std::path::Path,
8405    cwd: &std::path::Path,
8406) -> Result<serde_json::Value> {
8407    let card_path = session_home
8408        .join("config")
8409        .join("wire")
8410        .join("agent-card.json");
8411    let (did, handle) = if card_path.exists() {
8412        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
8413        let did = card
8414            .get("did")
8415            .and_then(Value::as_str)
8416            .unwrap_or("")
8417            .to_string();
8418        let handle = card
8419            .get("handle")
8420            .and_then(Value::as_str)
8421            .map(str::to_string)
8422            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8423        (did, handle)
8424    } else {
8425        (String::new(), String::new())
8426    };
8427    Ok(json!({
8428        "name": name,
8429        "home_dir": session_home.to_string_lossy(),
8430        "cwd": cwd.to_string_lossy(),
8431        "did": did,
8432        "handle": handle,
8433        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8434    }))
8435}
8436
8437fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
8438    if as_json {
8439        let mut obj = info.clone();
8440        obj["status"] = json!(status);
8441        println!("{}", serde_json::to_string(&obj)?);
8442    } else {
8443        let name = info["name"].as_str().unwrap_or("?");
8444        let handle = info["handle"].as_str().unwrap_or("?");
8445        let home = info["home_dir"].as_str().unwrap_or("?");
8446        let did = info["did"].as_str().unwrap_or("?");
8447        let export = info["export"].as_str().unwrap_or("?");
8448        let prefix = if status == "already_exists" {
8449            "session already exists (re-registered cwd)"
8450        } else {
8451            "session created"
8452        };
8453        println!(
8454            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
8455        );
8456    }
8457    Ok(())
8458}
8459
8460fn run_wire_with_home(
8461    session_home: &std::path::Path,
8462    args: &[&str],
8463) -> Result<std::process::ExitStatus> {
8464    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8465    let status = std::process::Command::new(&bin)
8466        .env("WIRE_HOME", session_home)
8467        .env_remove("RUST_LOG")
8468        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
8469        // We already own the session; nested init would clobber state.
8470        .env("WIRE_AUTO_INIT", "0")
8471        .args(args)
8472        .status()
8473        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8474    Ok(status)
8475}
8476
8477/// v0.7.0-alpha.2: idempotent per-cwd session creation.
8478///
8479/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
8480/// registered session for the current cwd — including via parent-walk —
8481/// this creates one inline so every Claude tab in a fresh project gets
8482/// its own wire identity rather than collapsing onto the machine-wide
8483/// default. Without this, multiple Claudes in unwired cwds all render
8484/// the same character (the default identity's character), defeating the
8485/// "every session looks different" promise.
8486///
8487/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
8488/// `run_wire_with_home` subprocess context).
8489///
8490/// Best-effort: any failure (no home dir, name collision pathology,
8491/// `wire init` subprocess crash) is logged to stderr and we fall back
8492/// to default identity. Must not block MCP startup.
8493///
8494/// MUST be called BEFORE worker thread spawn (env::set_var safety).
8495pub fn maybe_auto_init_cwd_session(label: &str) {
8496    if std::env::var("WIRE_HOME").is_ok() {
8497        return; // explicit override OR auto-detect already won
8498    }
8499    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
8500        return; // operator opt-out
8501    }
8502    let cwd = match std::env::current_dir() {
8503        Ok(c) => c,
8504        Err(_) => return,
8505    };
8506    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
8507    // already runs but we want to be robust to ordering).
8508    if crate::session::detect_session_wire_home(&cwd).is_some() {
8509        return;
8510    }
8511
8512    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
8513    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
8514    // Two different cwds with the same basename (e.g. /a/projx +
8515    // /b/projx) used to race outside the lock: both read empty
8516    // registry, both derived name="projx", per-name lock didn't help
8517    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
8518    //
8519    // Single lock serializes ALL auto-init across the sessions_root.
8520    // Inside the lock: re-read registry, derive_name_from_cwd which
8521    // adds path-hash suffix when basename is occupied by another cwd
8522    // already committed to the registry. Different cwds get DIFFERENT
8523    // names guaranteed.
8524    //
8525    // Cost: parallel auto-inits in different cwds now serialize
8526    // (~hundreds of ms each when local relay is up). Acceptable —
8527    // auto-init runs once per cwd per machine; not a hot path.
8528    use fs2::FileExt;
8529    let sessions_root = match crate::session::sessions_root() {
8530        Ok(r) => r,
8531        Err(_) => return,
8532    };
8533    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
8534        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
8535        return;
8536    }
8537    let lock_path = sessions_root.join(".auto-init.lock");
8538    let lock_file = match std::fs::OpenOptions::new()
8539        .create(true)
8540        .truncate(false)
8541        .read(true)
8542        .write(true)
8543        .open(&lock_path)
8544    {
8545        Ok(f) => f,
8546        Err(e) => {
8547            eprintln!(
8548                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
8549            );
8550            return;
8551        }
8552    };
8553    if let Err(e) = lock_file.lock_exclusive() {
8554        eprintln!(
8555            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
8556        );
8557        return;
8558    }
8559    // Lock acquired. Read registry + derive name now that all parallel
8560    // racers serialize through us — derive_name_from_cwd adds a
8561    // path-hash suffix if the basename is already claimed by another
8562    // cwd in the (now-stable) registry.
8563    let registry = crate::session::read_registry().unwrap_or_default();
8564    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
8565    let session_home = match crate::session::session_dir(&name) {
8566        Ok(h) => h,
8567        Err(_) => {
8568            let _ = fs2::FileExt::unlock(&lock_file);
8569            return;
8570        }
8571    };
8572    let agent_card_path = session_home
8573        .join("config")
8574        .join("wire")
8575        .join("agent-card.json");
8576    let needs_init = !agent_card_path.exists();
8577
8578    if needs_init {
8579        if let Err(e) = std::fs::create_dir_all(&session_home) {
8580            eprintln!(
8581                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
8582            );
8583            let _ = fs2::FileExt::unlock(&lock_file);
8584            return;
8585        }
8586        // v0.9: --offline; the surrounding session-spawn path runs
8587        // try_allocate_local_slot afterward to attach an inbound slot
8588        // when a local relay is available. Init itself stays slotless
8589        // because it's a precursor step, not the final state.
8590        match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
8591            Ok(status) if status.success() => {}
8592            Ok(status) => {
8593                eprintln!(
8594                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
8595                );
8596                let _ = fs2::FileExt::unlock(&lock_file);
8597                return;
8598            }
8599            Err(e) => {
8600                eprintln!(
8601                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
8602                );
8603                let _ = fs2::FileExt::unlock(&lock_file);
8604                return;
8605            }
8606        }
8607        // Best-effort: allocate a local-relay slot so this auto-init'd
8608        // session is addressable by sister sessions. Skipped silently when
8609        // the local relay isn't running (the function itself reports to
8610        // stderr). Auto-init'd sessions without endpoints can still
8611        // surface their character but cannot receive pair_drops until the
8612        // operator runs `wire bind-relay` or restarts the local relay.
8613        try_allocate_local_slot(
8614            &session_home,
8615            &name,
8616            "https://wireup.net",
8617            "http://127.0.0.1:8771",
8618        );
8619    } else {
8620        // Race loser path: peer already created the session. Surface
8621        // this honestly so the operator can see we adopted rather than
8622        // double-initialized.
8623        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8624            eprintln!(
8625                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
8626            );
8627        }
8628    }
8629    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
8630    // BEFORE releasing the auto-init lock. Pre-fix released the lock
8631    // here and committed the registry update afterward — racers in
8632    // OTHER cwds with the same basename would acquire the lock,
8633    // read the registry (still without our entry), and derive the
8634    // SAME name we just claimed. Live regression test caught it:
8635    // two cwds /a/projx + /b/projx both got name "projx", both
8636    // mapped to the same identity. Update the registry WHILE STILL
8637    // holding the auto-init lock so the next racer sees our claim.
8638    let cwd_key = cwd.to_string_lossy().into_owned();
8639    let name_for_reg = name.clone();
8640    if let Err(e) = crate::session::update_registry(|reg| {
8641        reg.by_cwd.insert(cwd_key, name_for_reg);
8642        Ok(())
8643    }) {
8644        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
8645        // proceed — env var still gets set below
8646    }
8647    // NOW release the lock — racers waiting will see our registry
8648    // entry on their re-read.
8649    let _ = fs2::FileExt::unlock(&lock_file);
8650
8651    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8652        eprintln!(
8653            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
8654            cwd.display(),
8655            session_home.display()
8656        );
8657    }
8658    // SAFETY: caller contract is "before any thread spawn." MCP::run
8659    // calls this immediately after `maybe_adopt_session_wire_home`.
8660    unsafe {
8661        std::env::set_var("WIRE_HOME", &session_home);
8662    }
8663}
8664
8665fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
8666    // Check if a daemon is already alive in this session's WIRE_HOME.
8667    // If so, no-op (let the existing process keep running).
8668    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8669    if pidfile.exists() {
8670        let bytes = std::fs::read(&pidfile).unwrap_or_default();
8671        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8672            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8673        } else {
8674            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8675        };
8676        if let Some(p) = pid {
8677            let alive = {
8678                #[cfg(target_os = "linux")]
8679                {
8680                    std::path::Path::new(&format!("/proc/{p}")).exists()
8681                }
8682                #[cfg(not(target_os = "linux"))]
8683                {
8684                    std::process::Command::new("kill")
8685                        .args(["-0", &p.to_string()])
8686                        .output()
8687                        .map(|o| o.status.success())
8688                        .unwrap_or(false)
8689                }
8690            };
8691            if alive {
8692                return Ok(());
8693            }
8694        }
8695    }
8696
8697    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
8698    // versioned pidfile; we just kick it off and return.
8699    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8700    let log_path = session_home.join("state").join("wire").join("daemon.log");
8701    if let Some(parent) = log_path.parent() {
8702        std::fs::create_dir_all(parent).ok();
8703    }
8704    let log_file = std::fs::OpenOptions::new()
8705        .create(true)
8706        .append(true)
8707        .open(&log_path)
8708        .with_context(|| format!("opening daemon log {log_path:?}"))?;
8709    let log_err = log_file.try_clone()?;
8710    std::process::Command::new(&bin)
8711        .env("WIRE_HOME", session_home)
8712        .env_remove("RUST_LOG")
8713        .args(["daemon", "--interval", "5"])
8714        .stdout(log_file)
8715        .stderr(log_err)
8716        .stdin(std::process::Stdio::null())
8717        .spawn()
8718        .with_context(|| "spawning session-local `wire daemon`")?;
8719    Ok(())
8720}
8721
8722fn cmd_session_list(as_json: bool) -> Result<()> {
8723    let items = crate::session::list_sessions()?;
8724    if as_json {
8725        println!("{}", serde_json::to_string(&items)?);
8726        return Ok(());
8727    }
8728    if items.is_empty() {
8729        println!("no sessions on this machine. `wire session new` to create one.");
8730        return Ok(());
8731    }
8732    println!(
8733        "{:<22} {:<24} {:<24} {:<10} CWD",
8734        "PERSONA", "NAME", "HANDLE", "DAEMON"
8735    );
8736    for s in items {
8737        // ANSI-escape-wrapped character takes more visual width than its
8738        // displayed glyph count; pad based on the plain-text form, then
8739        // wrap in escapes so the column lines up across rows.
8740        let plain = s
8741            .character
8742            .as_ref()
8743            .map(|c| c.short())
8744            .unwrap_or_else(|| "?".to_string());
8745        let colored = s
8746            .character
8747            .as_ref()
8748            .map(|c| c.colored())
8749            .unwrap_or_else(|| "?".to_string());
8750        // Approximate display width: emoji renders as ~2 cells in most
8751        // terminals; the rest are 1 cell each. We pad to 18 displayed
8752        // chars (≈22 byte slots when counting emoji).
8753        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
8754        let pad = 22usize.saturating_sub(displayed_width);
8755        println!(
8756            "{}{}  {:<24} {:<24} {:<10} {}",
8757            colored,
8758            " ".repeat(pad),
8759            s.name,
8760            s.handle.as_deref().unwrap_or("?"),
8761            if s.daemon_running { "running" } else { "down" },
8762            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8763        );
8764    }
8765    Ok(())
8766}
8767
8768/// v0.5.19: `wire session list-local` — sister-session discovery.
8769///
8770/// For each on-disk session, read its `relay-state.json` and surface
8771/// the ones that have a Local-scope endpoint (allocated via
8772/// `wire session new --with-local`). Group by the local-relay URL so
8773/// the operator can see at a glance which sessions are mutually
8774/// reachable over the same loopback relay.
8775///
8776/// Read-only, no daemon contact. Useful as the prelude to teaming /
8777/// pairing same-box sister claudes (see also `wire session
8778/// pair-all-local` once implemented).
8779fn cmd_session_list_local(as_json: bool) -> Result<()> {
8780    let listing = crate::session::list_local_sessions()?;
8781    if as_json {
8782        println!("{}", serde_json::to_string(&listing)?);
8783        return Ok(());
8784    }
8785
8786    if listing.local.is_empty() && listing.federation_only.is_empty() {
8787        println!(
8788            "no sessions on this machine. `wire session new --with-local` to create one \
8789             with a local-relay endpoint (start the relay first: \
8790             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
8791        );
8792        return Ok(());
8793    }
8794
8795    if listing.local.is_empty() {
8796        println!(
8797            "no sister sessions reachable via a local relay. \
8798             Re-run `wire session new --with-local` to add a Local endpoint, or \
8799             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
8800        );
8801    } else {
8802        // Stable iteration order: sort the relay URLs.
8803        let mut keys: Vec<&String> = listing.local.keys().collect();
8804        keys.sort();
8805        for relay_url in keys {
8806            let group = &listing.local[relay_url];
8807            println!("LOCAL RELAY: {relay_url}");
8808            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
8809            for s in group {
8810                println!(
8811                    "  {:<24} {:<32} {:<10} {}",
8812                    s.name,
8813                    s.handle.as_deref().unwrap_or("?"),
8814                    if s.daemon_running { "running" } else { "down" },
8815                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8816                );
8817            }
8818            println!();
8819        }
8820    }
8821
8822    if !listing.federation_only.is_empty() {
8823        println!("federation-only (no local endpoint):");
8824        for s in &listing.federation_only {
8825            println!(
8826                "  {:<24} {:<32} {}",
8827                s.name,
8828                s.handle.as_deref().unwrap_or("?"),
8829                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8830            );
8831        }
8832    }
8833    Ok(())
8834}
8835
8836/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
8837/// session that has a Local-scope endpoint. Skips already-paired
8838/// pairs; reports a per-pair outcome JSON suitable for scripting.
8839///
8840/// Same-uid trust anchor: the caller owns every session enumerated by
8841/// `list_local_sessions`, so the operator running this command IS the
8842/// consent for both sides. The bilateral SAS / network-level handshake
8843/// assumes strangers; same-uid sister sessions are not strangers.
8844///
8845/// Per-pair flow (sequential to keep relay-side load + log clarity):
8846///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
8847///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
8848///   3. sleep settle_secs                       (pair_drop reaches B)
8849///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
8850///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
8851///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
8852///   7. sleep settle_secs                       (ack reaches A)
8853///   8. WIRE_HOME=A wire pull --json            (A pins B)
8854fn cmd_session_pair_all_local(
8855    settle_secs: u64,
8856    federation_relay: &str,
8857    as_json: bool,
8858) -> Result<()> {
8859    use std::collections::BTreeSet;
8860    use std::time::Duration;
8861
8862    let listing = crate::session::list_local_sessions()?;
8863    // Flatten + dedup by session NAME (same session can appear under
8864    // multiple local-relay URLs if it advertises two local endpoints;
8865    // rare, but pair each pair exactly once).
8866    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
8867        Default::default();
8868    for group in listing.local.into_values() {
8869        for s in group {
8870            by_name.entry(s.name.clone()).or_insert(s);
8871        }
8872    }
8873    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8874
8875    if sessions.len() < 2 {
8876        let msg = format!(
8877            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
8878            sessions.len()
8879        );
8880        if as_json {
8881            println!(
8882                "{}",
8883                serde_json::to_string(&json!({
8884                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
8885                    "pairs_attempted": 0,
8886                    "pairs_succeeded": 0,
8887                    "pairs_skipped_already_paired": 0,
8888                    "pairs_failed": 0,
8889                    "note": msg,
8890                }))?
8891            );
8892        } else {
8893            println!("{msg}");
8894            if let Some(s) = sessions.first() {
8895                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
8896            }
8897            println!("Use `wire session new --with-local` to add more.");
8898        }
8899        return Ok(());
8900    }
8901
8902    let fed_host = host_of_url(federation_relay);
8903    if fed_host.is_empty() {
8904        bail!(
8905            "federation_relay `{federation_relay}` has no parseable host — \
8906             pass a full URL like `https://wireup.net`."
8907        );
8908    }
8909
8910    // Enumerate unordered pairs deterministically by session name.
8911    let mut attempted = 0u32;
8912    let mut succeeded = 0u32;
8913    let mut skipped_already = 0u32;
8914    let mut failed = 0u32;
8915    let mut per_pair: Vec<Value> = Vec::new();
8916
8917    for i in 0..sessions.len() {
8918        for j in (i + 1)..sessions.len() {
8919            let a = &sessions[i];
8920            let b = &sessions[j];
8921            attempted += 1;
8922
8923            // Already-paired check: if A's relay-state has B's CARD
8924            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
8925            // are character handles (not session names), so we use
8926            // each side's handle field (already on the LocalSessionView)
8927            // for the lookup rather than the session name.
8928            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
8929            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
8930            let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
8931            let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
8932            if a_pinned_b && b_pinned_a {
8933                skipped_already += 1;
8934                per_pair.push(json!({
8935                    "from": a.name,
8936                    "to": b.name,
8937                    "status": "already_paired",
8938                }));
8939                continue;
8940            }
8941
8942            let pair_result = drive_bilateral_pair(
8943                &a.home_dir,
8944                &a.name,
8945                &b.home_dir,
8946                &b.name,
8947                &fed_host,
8948                federation_relay,
8949                settle_secs,
8950            );
8951
8952            match pair_result {
8953                Ok(()) => {
8954                    succeeded += 1;
8955                    per_pair.push(json!({
8956                        "from": a.name,
8957                        "to": b.name,
8958                        "status": "paired",
8959                    }));
8960                }
8961                Err(e) => {
8962                    failed += 1;
8963                    let detail = format!("{e:#}");
8964                    per_pair.push(json!({
8965                        "from": a.name,
8966                        "to": b.name,
8967                        "status": "failed",
8968                        "error": detail,
8969                    }));
8970                }
8971            }
8972
8973            // Brief settle between pairs so we don't slam the relay
8974            // with N(N-1) parallel requests.
8975            std::thread::sleep(Duration::from_millis(200));
8976        }
8977    }
8978
8979    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
8980    let summary = json!({
8981        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
8982        "pairs_attempted": attempted,
8983        "pairs_succeeded": succeeded,
8984        "pairs_skipped_already_paired": skipped_already,
8985        "pairs_failed": failed,
8986        "results": per_pair,
8987    });
8988    if as_json {
8989        println!("{}", serde_json::to_string(&summary)?);
8990    } else {
8991        println!(
8992            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
8993            sessions.len(),
8994            attempted
8995        );
8996        println!("  paired:                 {succeeded}");
8997        println!("  skipped (already pinned): {skipped_already}");
8998        println!("  failed:                 {failed}");
8999        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
9000            let from = entry["from"].as_str().unwrap_or("?");
9001            let to = entry["to"].as_str().unwrap_or("?");
9002            let status = entry["status"].as_str().unwrap_or("?");
9003            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
9004            if err.is_empty() {
9005                println!("  {from:<24} ↔ {to:<24} {status}");
9006            } else {
9007                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
9008            }
9009        }
9010    }
9011    Ok(())
9012}
9013
9014/// Check whether `session_home`'s `relay.json` already lists `peer_name`
9015/// under `state.peers`. Best-effort — any read/parse error → false.
9016fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
9017    val_session_relay_state(session_home)
9018        .and_then(|v| v.get("peers").cloned())
9019        .and_then(|p| p.get(peer_name).cloned())
9020        .is_some()
9021}
9022
9023/// Read a session's `relay.json` directly without mutating the process'
9024/// WIRE_HOME env (which would race other threads / processes). Returns
9025/// `None` on any read or parse error — callers treat missing state as
9026/// "no peers / no endpoints" rather than aborting.
9027fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
9028    let path = session_home.join("config").join("wire").join("relay.json");
9029    let bytes = std::fs::read(&path).ok()?;
9030    serde_json::from_slice(&bytes).ok()
9031}
9032
9033/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
9034/// One probe per directed edge against the relay backing that edge's
9035/// priority-1 endpoint; output groups by undirected pair.
9036fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
9037    use std::collections::BTreeMap;
9038
9039    // Flatten by session NAME — same dedup logic as pair-all-local so a
9040    // session advertising two local endpoints doesn't get double-counted.
9041    let listing = crate::session::list_local_sessions()?;
9042    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
9043    for group in listing.local.into_values() {
9044        for s in group {
9045            by_name.entry(s.name.clone()).or_insert(s);
9046        }
9047    }
9048    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
9049    let federation_only = listing.federation_only;
9050
9051    if sessions.is_empty() {
9052        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
9053        if as_json {
9054            println!(
9055                "{}",
9056                serde_json::to_string(&json!({
9057                    "sessions": [],
9058                    "edges": [],
9059                    "local_relay": null,
9060                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9061                    "summary": {
9062                        "session_count": 0,
9063                        "edge_count": 0,
9064                        "healthy": 0,
9065                        "stale": 0,
9066                        "asymmetric": 0,
9067                    },
9068                    "note": msg,
9069                }))?
9070            );
9071        } else {
9072            println!("{msg}");
9073            println!("Use `wire session new --with-local` to create one.");
9074        }
9075        return Ok(());
9076    }
9077
9078    // Build a name → session-state map: relay_state + reachable handle set.
9079    struct SessionState {
9080        view: crate::session::LocalSessionView,
9081        relay_state: Value,
9082        local_relay_url: Option<String>,
9083    }
9084    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
9085    for s in sessions {
9086        let relay_state = val_session_relay_state(&s.home_dir)
9087            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9088        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
9089        sstates.push(SessionState {
9090            view: s,
9091            relay_state,
9092            local_relay_url,
9093        });
9094    }
9095
9096    // Probe each unique local-relay URL once for healthz so the operator
9097    // sees one liveness line per local relay, not one per edge.
9098    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
9099    for s in &sstates {
9100        if let Some(url) = &s.local_relay_url
9101            && !local_relays.contains_key(url)
9102        {
9103            let healthy = probe_relay_healthz(url);
9104            local_relays.insert(url.clone(), healthy);
9105        }
9106    }
9107
9108    let now = std::time::SystemTime::now()
9109        .duration_since(std::time::UNIX_EPOCH)
9110        .map(|d| d.as_secs())
9111        .unwrap_or(0);
9112
9113    // Edges: walk every unordered pair, surface bilateral state + each
9114    // direction's last_pull. Probe priority-1 endpoint (local preferred
9115    // by `peer_endpoints_in_priority_order`).
9116    let mut edges: Vec<Value> = Vec::new();
9117    let mut healthy_count = 0u32;
9118    let mut stale_count = 0u32;
9119    let mut asymmetric_count = 0u32;
9120
9121    for i in 0..sstates.len() {
9122        for j in (i + 1)..sstates.len() {
9123            let a = &sstates[i];
9124            let b = &sstates[j];
9125            // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
9126            // (DID-derived character), not the session name. Look the
9127            // peer up by its handle (with a session-name fallback for
9128            // pre-v0.11 sessions that haven't re-init'd yet).
9129            let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
9130            let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
9131            let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
9132            let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
9133
9134            let bilateral = a_to_b.pinned && b_to_a.pinned;
9135            // Scope = the most-local scope available in either direction.
9136            // (If a→b is local and b→a is federation, the asymmetric
9137            // detail surfaces below; the headline scope is the better.)
9138            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
9139                (Some("local"), _) | (_, Some("local")) => "local",
9140                (Some("federation"), _) | (_, Some("federation")) => "federation",
9141                _ => "unknown",
9142            };
9143
9144            // Health: stale if either direction's last_pull is older than
9145            // `stale_secs`, or never observed when both sides are pinned.
9146            let mut status = if bilateral { "healthy" } else { "asymmetric" };
9147            if bilateral {
9148                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
9149                    Some(s) => s > stale_secs,
9150                    None => d.probed,
9151                });
9152                if either_stale {
9153                    status = "stale";
9154                }
9155            }
9156
9157            match status {
9158                "healthy" => healthy_count += 1,
9159                "stale" => stale_count += 1,
9160                "asymmetric" => asymmetric_count += 1,
9161                _ => {}
9162            }
9163
9164            edges.push(json!({
9165                "from": a.view.name,
9166                "to": b.view.name,
9167                "bilateral": bilateral,
9168                "scope": scope,
9169                "status": status,
9170                "directions": {
9171                    a.view.name.clone(): direction_summary(&a_to_b),
9172                    b.view.name.clone(): direction_summary(&b_to_a),
9173                },
9174            }));
9175        }
9176    }
9177
9178    let summary = json!({
9179        "sessions": sstates.iter().map(|s| json!({
9180            "name": s.view.name,
9181            "handle": s.view.handle,
9182            "cwd": s.view.cwd,
9183            "daemon_running": s.view.daemon_running,
9184            "local_relay": s.local_relay_url,
9185        })).collect::<Vec<_>>(),
9186        "edges": edges,
9187        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
9188            "url": url,
9189            "healthy": healthy,
9190        })).collect::<Vec<_>>(),
9191        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9192        "summary": {
9193            "session_count": sstates.len(),
9194            "edge_count": edges.len(),
9195            "healthy": healthy_count,
9196            "stale": stale_count,
9197            "asymmetric": asymmetric_count,
9198            "stale_threshold_secs": stale_secs,
9199        },
9200    });
9201
9202    if as_json {
9203        println!("{}", serde_json::to_string(&summary)?);
9204        return Ok(());
9205    }
9206
9207    println!(
9208        "wire mesh: {} session(s), {} edge(s)",
9209        sstates.len(),
9210        edges.len()
9211    );
9212    for (url, healthy) in &local_relays {
9213        let tick = if *healthy { "✓" } else { "✗" };
9214        println!("  local-relay {url} {tick}");
9215    }
9216    if !federation_only.is_empty() {
9217        print!("  federation-only sessions:");
9218        for f in &federation_only {
9219            print!(" {}", f.name);
9220        }
9221        println!();
9222    }
9223
9224    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
9225    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
9226    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
9227    print!("\n{:>col_w$}", "", col_w = col_w);
9228    for n in &names {
9229        print!("{:>col_w$}", n, col_w = col_w);
9230    }
9231    println!();
9232    for (i, row) in names.iter().enumerate() {
9233        print!("{:>col_w$}", row, col_w = col_w);
9234        for (j, col) in names.iter().enumerate() {
9235            let cell = if i == j {
9236                "self".to_string()
9237            } else {
9238                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
9239                match d.scope.as_deref() {
9240                    Some("local") => "local".to_string(),
9241                    Some("federation") => "fed".to_string(),
9242                    _ => "—".to_string(),
9243                }
9244            };
9245            print!("{:>col_w$}", cell, col_w = col_w);
9246        }
9247        println!();
9248    }
9249
9250    println!("\nHealth (stale threshold: {stale_secs}s):");
9251    for e in &edges {
9252        let from = e["from"].as_str().unwrap_or("?");
9253        let to = e["to"].as_str().unwrap_or("?");
9254        let scope = e["scope"].as_str().unwrap_or("?");
9255        let status = e["status"].as_str().unwrap_or("?");
9256        let mark = match status {
9257            "healthy" => "✓",
9258            "stale" => "⚠",
9259            "asymmetric" => "!",
9260            _ => "?",
9261        };
9262        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
9263        let mut details: Vec<String> = Vec::new();
9264        for (who, d) in &dirs {
9265            let silent = d.get("silent_secs").and_then(Value::as_u64);
9266            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
9267            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
9268            let label = match (pinned, probed, silent) {
9269                (false, _, _) => format!("{who} has not pinned"),
9270                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
9271                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
9272                (true, true, Some(s)) => format!("{who} silent {s}s"),
9273                (true, true, None) => format!("{who} never pulled"),
9274            };
9275            details.push(label);
9276        }
9277        println!(
9278            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
9279            details.join(" | ")
9280        );
9281    }
9282    Ok(())
9283}
9284
9285#[derive(Default)]
9286struct DirectedEdge {
9287    pinned: bool,
9288    scope: Option<String>,
9289    last_pull_at_unix: Option<u64>,
9290    silent_secs: Option<u64>,
9291    probed: bool,
9292    event_count: usize,
9293}
9294
9295/// Probe a single directed edge from `from_state`'s view of `to_name`.
9296/// Picks the priority-1 endpoint (local preferred when reachable) and
9297/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
9298/// failure (the function records `probed = true`, `last_pull = None`,
9299/// which the caller treats as "never pulled, route exists" = stale).
9300fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
9301    let pinned = from_state
9302        .get("peers")
9303        .and_then(|p| p.get(to_name))
9304        .is_some();
9305    if !pinned {
9306        return DirectedEdge::default();
9307    }
9308    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
9309    let ep = match endpoints.into_iter().next() {
9310        Some(e) => e,
9311        None => {
9312            return DirectedEdge {
9313                pinned: true,
9314                ..Default::default()
9315            };
9316        }
9317    };
9318    let scope = Some(
9319        match ep.scope {
9320            crate::endpoints::EndpointScope::Local => "local",
9321            crate::endpoints::EndpointScope::Lan => "lan",
9322            crate::endpoints::EndpointScope::Uds => "uds",
9323            crate::endpoints::EndpointScope::Federation => "federation",
9324        }
9325        .to_string(),
9326    );
9327    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
9328    let (count, last) = client
9329        .slot_state(&ep.slot_id, &ep.slot_token)
9330        .unwrap_or((0, None));
9331    let silent = last.map(|t| now.saturating_sub(t));
9332    DirectedEdge {
9333        pinned: true,
9334        scope,
9335        last_pull_at_unix: last,
9336        silent_secs: silent,
9337        probed: true,
9338        event_count: count,
9339    }
9340}
9341
9342fn direction_summary(d: &DirectedEdge) -> Value {
9343    json!({
9344        "pinned": d.pinned,
9345        "scope": d.scope,
9346        "probed": d.probed,
9347        "last_pull_at_unix": d.last_pull_at_unix,
9348        "silent_secs": d.silent_secs,
9349        "event_count": d.event_count,
9350    })
9351}
9352
9353/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
9354fn probe_relay_healthz(url: &str) -> bool {
9355    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
9356    let client = match reqwest::blocking::Client::builder()
9357        .timeout(std::time::Duration::from_millis(500))
9358        .build()
9359    {
9360        Ok(c) => c,
9361        Err(_) => return false,
9362    };
9363    match client.get(&probe_url).send() {
9364        Ok(r) => r.status().is_success(),
9365        Err(_) => false,
9366    }
9367}
9368
9369/// Drive one bilateral pair handshake between two sister sessions
9370/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
9371/// flow so failures bubble up at the offending step, not buried in
9372/// a parallel race. See `cmd_session_pair_all_local` docstring.
9373///
9374/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
9375/// federation `.well-known/wire/agent` resolution. Reads B's card +
9376/// endpoints directly off disk under `b_home` and pins them. This
9377/// makes pair-all-local work for sister sessions whose federation
9378/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
9379/// for sessions created with `wire session new --local-only`
9380/// (no federation slot at all). The `_federation_relay` / `_fed_host`
9381/// parameters are retained for callers that want to log them but
9382/// the handshake itself no longer touches federation.
9383fn drive_bilateral_pair(
9384    a_home: &std::path::Path,
9385    a_name: &str,
9386    b_home: &std::path::Path,
9387    b_name: &str,
9388    _fed_host: &str,
9389    _federation_relay: &str,
9390    settle_secs: u64,
9391) -> Result<()> {
9392    use std::time::Duration;
9393    let bin = std::env::current_exe().context("locating self exe")?;
9394
9395    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
9396        let out = std::process::Command::new(&bin)
9397            .env("WIRE_HOME", home)
9398            .env_remove("RUST_LOG")
9399            .args(args)
9400            .output()
9401            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9402        if !out.status.success() {
9403            bail!(
9404                "`wire {}` failed: stderr={}",
9405                args.join(" "),
9406                String::from_utf8_lossy(&out.stderr).trim()
9407            );
9408        }
9409        Ok(())
9410    };
9411
9412    // v0.11: each session's agent-card.handle is the DID-derived
9413    // character, not the session name. pair-accept lookups key on the
9414    // CARD HANDLE, so we discover each side's canonical handle from
9415    // its agent-card on disk before driving the pair flow.
9416    let read_card_handle = |home: &std::path::Path| -> Result<String> {
9417        let card_path = home.join("config").join("wire").join("agent-card.json");
9418        let bytes = std::fs::read(&card_path)
9419            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
9420        let card: Value = serde_json::from_slice(&bytes)?;
9421        card.get("handle")
9422            .and_then(Value::as_str)
9423            .map(str::to_string)
9424            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
9425    };
9426    let a_handle = read_card_handle(a_home)
9427        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
9428    let b_handle = read_card_handle(b_home)
9429        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
9430
9431    // 1. A initiates via --local-sister (uses the session NAME for
9432    // the registry lookup; cmd_add_local_sister auto-resolves
9433    // session→handle internally).
9434    run(a_home, &["add", b_name, "--local-sister", "--json"])
9435        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
9436
9437    // 3. settle so pair_drop reaches B's slot
9438    std::thread::sleep(Duration::from_secs(settle_secs));
9439
9440    // 4. B pulls pair_drop → 5. B pair-accept (pins A by CARD HANDLE,
9441    // not by session name — under v0.11 these differ) → 6. B push ack
9442    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
9443    run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
9444        format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
9445    })?;
9446    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
9447
9448    // 7. settle so ack reaches A's slot
9449    std::thread::sleep(Duration::from_secs(settle_secs));
9450
9451    // 8. A pulls ack (pins B by CARD HANDLE)
9452    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
9453    // suppress unused warning when both handles are consumed
9454    let _ = &b_handle;
9455
9456    Ok(())
9457}
9458
9459fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
9460    let name = resolve_session_name(name_arg)?;
9461    let session_home = crate::session::session_dir(&name)?;
9462    if !session_home.exists() {
9463        bail!(
9464            "no session named {name:?} on this machine. `wire session list` to enumerate, \
9465             `wire session new {name}` to create."
9466        );
9467    }
9468    if as_json {
9469        println!(
9470            "{}",
9471            serde_json::to_string(&json!({
9472                "name": name,
9473                "home_dir": session_home.to_string_lossy(),
9474                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9475            }))?
9476        );
9477    } else {
9478        println!("export WIRE_HOME={}", session_home.to_string_lossy());
9479    }
9480    Ok(())
9481}
9482
9483fn cmd_session_current(as_json: bool) -> Result<()> {
9484    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9485    let registry = crate::session::read_registry().unwrap_or_default();
9486    let cwd_key = cwd.to_string_lossy().into_owned();
9487    let name = registry.by_cwd.get(&cwd_key).cloned();
9488    if as_json {
9489        println!(
9490            "{}",
9491            serde_json::to_string(&json!({
9492                "cwd": cwd_key,
9493                "session": name,
9494            }))?
9495        );
9496    } else if let Some(n) = name {
9497        println!("{n}");
9498    } else {
9499        println!("(no session registered for this cwd)");
9500    }
9501    Ok(())
9502}
9503
9504fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
9505    let name = crate::session::sanitize_name(name_arg);
9506    let session_home = crate::session::session_dir(&name)?;
9507    if !session_home.exists() {
9508        if as_json {
9509            println!(
9510                "{}",
9511                serde_json::to_string(&json!({
9512                    "name": name,
9513                    "destroyed": false,
9514                    "reason": "no such session",
9515                }))?
9516            );
9517        } else {
9518            println!("no session named {name:?} — nothing to destroy.");
9519        }
9520        return Ok(());
9521    }
9522    if !force {
9523        bail!(
9524            "destroying session {name:?} would delete its keypair + state irrecoverably. \
9525             Pass --force to confirm."
9526        );
9527    }
9528
9529    // Kill the session-local daemon if alive.
9530    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9531    if let Ok(bytes) = std::fs::read(&pidfile) {
9532        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9533            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9534        } else {
9535            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9536        };
9537        if let Some(p) = pid {
9538            let _ = std::process::Command::new("kill")
9539                .args(["-TERM", &p.to_string()])
9540                .output();
9541        }
9542    }
9543
9544    std::fs::remove_dir_all(&session_home)
9545        .with_context(|| format!("removing session dir {session_home:?}"))?;
9546
9547    // Strip from registry.
9548    let mut registry = crate::session::read_registry().unwrap_or_default();
9549    registry.by_cwd.retain(|_, v| v != &name);
9550    crate::session::write_registry(&registry)?;
9551
9552    if as_json {
9553        println!(
9554            "{}",
9555            serde_json::to_string(&json!({
9556                "name": name,
9557                "destroyed": true,
9558            }))?
9559        );
9560    } else {
9561        println!("destroyed session {name:?}.");
9562    }
9563    Ok(())
9564}
9565
9566// ---------- diag (structured trace) ----------
9567
9568fn cmd_diag(action: DiagAction) -> Result<()> {
9569    let state = config::state_dir()?;
9570    let knob = state.join("diag.enabled");
9571    let log_path = state.join("diag.jsonl");
9572    match action {
9573        DiagAction::Tail { limit, json } => {
9574            let entries = crate::diag::tail(limit);
9575            if json {
9576                for e in entries {
9577                    println!("{}", serde_json::to_string(&e)?);
9578                }
9579            } else if entries.is_empty() {
9580                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
9581            } else {
9582                for e in entries {
9583                    let ts = e["ts"].as_u64().unwrap_or(0);
9584                    let ty = e["type"].as_str().unwrap_or("?");
9585                    let pid = e["pid"].as_u64().unwrap_or(0);
9586                    let payload = e["payload"].to_string();
9587                    println!("[{ts}] pid={pid} {ty} {payload}");
9588                }
9589            }
9590        }
9591        DiagAction::Enable => {
9592            config::ensure_dirs()?;
9593            std::fs::write(&knob, "1")?;
9594            println!("wire diag: enabled at {knob:?}");
9595        }
9596        DiagAction::Disable => {
9597            if knob.exists() {
9598                std::fs::remove_file(&knob)?;
9599            }
9600            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
9601        }
9602        DiagAction::Status { json } => {
9603            let enabled = crate::diag::is_enabled();
9604            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
9605            if json {
9606                println!(
9607                    "{}",
9608                    serde_json::to_string(&serde_json::json!({
9609                        "enabled": enabled,
9610                        "log_path": log_path,
9611                        "log_size_bytes": size,
9612                    }))?
9613                );
9614            } else {
9615                println!("wire diag status");
9616                println!("  enabled:    {enabled}");
9617                println!("  log:        {log_path:?}");
9618                println!("  log size:   {size} bytes");
9619            }
9620        }
9621    }
9622    Ok(())
9623}
9624
9625// ---------- service (install / uninstall / status) ----------
9626
9627fn cmd_service(action: ServiceAction) -> Result<()> {
9628    let kind = |local_relay: bool| {
9629        if local_relay {
9630            crate::service::ServiceKind::LocalRelay
9631        } else {
9632            crate::service::ServiceKind::Daemon
9633        }
9634    };
9635    let (report, as_json) = match action {
9636        ServiceAction::Install { local_relay, json } => {
9637            (crate::service::install_kind(kind(local_relay))?, json)
9638        }
9639        ServiceAction::Uninstall { local_relay, json } => {
9640            (crate::service::uninstall_kind(kind(local_relay))?, json)
9641        }
9642        ServiceAction::Status { local_relay, json } => {
9643            (crate::service::status_kind(kind(local_relay))?, json)
9644        }
9645    };
9646    if as_json {
9647        println!("{}", serde_json::to_string(&report)?);
9648    } else {
9649        println!("wire service {}", report.action);
9650        println!("  platform:  {}", report.platform);
9651        println!("  unit:      {}", report.unit_path);
9652        println!("  status:    {}", report.status);
9653        println!("  detail:    {}", report.detail);
9654    }
9655    Ok(())
9656}
9657
9658// ---------- upgrade (atomic daemon swap) ----------
9659
9660/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
9661/// fresh one from the currently-installed binary, write a new versioned
9662/// pidfile. The fix for today's exact failure mode: a daemon process that
9663/// kept running OLD binary text in memory under a symlink that had since
9664/// been repointed at a NEW binary on disk.
9665///
9666/// Idempotent. If no stale daemon is running, just starts a fresh one
9667/// (same as `wire daemon &` but with the wait-until-alive guard from
9668/// ensure_up::ensure_daemon_running).
9669///
9670/// `--check` mode reports drift without acting — lists the processes
9671/// that WOULD be killed and the binary version of each.
9672///
9673/// Session-scoped upgrade kill set (v0.13.2, B fix): THIS session's own daemon
9674/// (`my_pid`, from its pidfile — reliable even when the OS process scan can't
9675/// see it, as on Windows) plus TRUE orphans (found `wire daemon` pids owned by
9676/// no session), EXCLUDING sibling sessions' daemons. Pure + unit-tested so the
9677/// session-scoping is locked — the box-wide predecessor accumulated daemons.
9678fn upgrade_kill_set(
9679    my_pid: Option<u32>,
9680    found_daemon_pids: &[u32],
9681    owned_session_pids: &std::collections::HashSet<u32>,
9682) -> Vec<u32> {
9683    let mut k: Vec<u32> = Vec::new();
9684    if let Some(p) = my_pid {
9685        k.push(p);
9686    }
9687    for &p in found_daemon_pids {
9688        if !owned_session_pids.contains(&p) && Some(p) != my_pid {
9689            k.push(p); // true orphan — owned by no session
9690        }
9691    }
9692    k.sort_unstable();
9693    k.dedup();
9694    k
9695}
9696
9697#[cfg(test)]
9698mod upgrade_tests {
9699    use super::*;
9700    use std::collections::HashSet;
9701
9702    #[test]
9703    fn upgrade_kill_set_is_session_scoped() {
9704        // owned: my daemon 100, sibling session daemon 200.
9705        let owned: HashSet<u32> = [100, 200].into_iter().collect();
9706        // found by the process scan: mine (100), sibling (200), a true orphan (999).
9707        let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
9708        assert!(k.contains(&100), "must kill my own daemon (to replace it)");
9709        assert!(k.contains(&999), "must sweep a true orphan");
9710        assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
9711
9712        // CRITICAL: even when the process scan returns EMPTY (Windows CIM can't
9713        // match the quoted command line), my own daemon is still killed via its
9714        // pidfile pid — this is the B-accumulation fix.
9715        assert_eq!(
9716            upgrade_kill_set(Some(100), &[], &owned),
9717            vec![100],
9718            "own daemon killed even when the process scan is empty"
9719        );
9720
9721        // Uninitialized session (no own daemon): only true orphans.
9722        assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
9723    }
9724}
9725
9726fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
9727    // 1. Identify all running wire processes. v0.7.3: walks `pgrep -f`
9728    // on unix / `Get-CimInstance Win32_Process` on Windows via the
9729    // shared `platform::find_processes_by_cmdline`. Covers both the
9730    // long-lived sync `wire daemon` *and* the `wire relay-server`
9731    // local-only loopback — the pre-v0.7.3 upgrade only swept daemons
9732    // and left stale relay-server children pinned on the old binary,
9733    // forcing operators to `pkill -f relay-server` manually after
9734    // every version bump.
9735    let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
9736    let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
9737    let running_pids: Vec<u32> = daemon_pids
9738        .iter()
9739        .chain(relay_pids.iter())
9740        .copied()
9741        .collect();
9742
9743    // 2. Read pidfile to surface what the daemon THINKS it is.
9744    let record = crate::ensure_up::read_pid_record("daemon");
9745    let recorded_version: Option<String> = match &record {
9746        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
9747        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
9748        _ => None,
9749    };
9750    let cli_version = env!("CARGO_PKG_VERSION").to_string();
9751
9752    // 2b. v0.13.2 (B fix — session-scoped upgrade). `wire upgrade` now
9753    // refreshes THIS session's daemon, not the whole box. The old box-wide
9754    // design (kill every `wire daemon` process, wipe every session's pidfile,
9755    // respawn every session) was wrong for a multi-session / shared-relay box
9756    // AND broke on Windows: the CIM scan can't match the quoted
9757    // `"...\wire.exe" daemon` command line (no contiguous `wire daemon`), so it
9758    // found nothing to kill, then the respawn loop ACCUMULATED daemons
9759    // (glossy-magnolia: 2->5->8->11). The kill set is now:
9760    //   (a) THIS session's own daemon, via its pidfile pid — reliable and
9761    //       CIM-independent; plus
9762    //   (b) TRUE orphans: `wire daemon` pids owned by NO session.
9763    // It SPARES sibling sessions' daemons AND the shared loopback relay-server
9764    // (killing it would break every same-box session's routing).
9765    let my_daemon_pid = record.pid();
9766    let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
9767        .unwrap_or_default()
9768        .iter()
9769        .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
9770        .collect();
9771    let kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
9772    // relay_pids are intentionally NOT killed — the local relay is shared.
9773
9774    if check_only {
9775        // v0.6.8: also surface session-level state + PATH dupes in --check.
9776        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
9777            .unwrap_or_default()
9778            .iter()
9779            .filter(|s| s.daemon_running)
9780            .map(|s| s.name.clone())
9781            .collect();
9782        let mut path_dupes: Vec<String> = Vec::new();
9783        if let Ok(path) = std::env::var("PATH") {
9784            let mut seen: std::collections::HashSet<std::path::PathBuf> =
9785                std::collections::HashSet::new();
9786            for dir in path.split(':') {
9787                let candidate = std::path::PathBuf::from(dir).join("wire");
9788                if candidate.exists() {
9789                    let canon = candidate.canonicalize().unwrap_or(candidate);
9790                    if seen.insert(canon.clone()) {
9791                        path_dupes.push(canon.to_string_lossy().into_owned());
9792                    }
9793                }
9794            }
9795        }
9796        // v0.7.3: enumerate which service units WOULD be refreshed.
9797        // Read-only — `status_kind` doesn't touch anything.
9798        let installed_service_kinds: Vec<&'static str> = [
9799            (crate::service::ServiceKind::Daemon, "daemon"),
9800            (crate::service::ServiceKind::LocalRelay, "local-relay"),
9801        ]
9802        .into_iter()
9803        .filter_map(|(k, label)| {
9804            crate::service::status_kind(k)
9805                .ok()
9806                .filter(|r| r.status != "absent")
9807                .map(|_| label)
9808        })
9809        .collect();
9810        let report = json!({
9811            "running_pids": running_pids,
9812            "running_daemons": daemon_pids,
9813            "running_relay_servers": relay_pids,
9814            "pidfile_version": recorded_version,
9815            "cli_version": cli_version,
9816            "would_kill": kill_set,
9817            "would_refresh_services": installed_service_kinds,
9818            "session_daemons_running": sessions_with_daemons,
9819            "path_binaries": path_dupes,
9820            "path_duplicate_warning": path_dupes.len() > 1,
9821        });
9822        if as_json {
9823            println!("{}", serde_json::to_string(&report)?);
9824        } else {
9825            println!("wire upgrade --check");
9826            println!("  cli version:      {cli_version}");
9827            println!(
9828                "  pidfile version:  {}",
9829                recorded_version.as_deref().unwrap_or("(missing)")
9830            );
9831            if running_pids.is_empty() {
9832                println!("  running daemons:  none");
9833                println!("  running relays:   none");
9834            } else {
9835                if daemon_pids.is_empty() {
9836                    println!("  running daemons:  none");
9837                } else {
9838                    let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
9839                    println!("  running daemons:  pids {}", p.join(", "));
9840                }
9841                if relay_pids.is_empty() {
9842                    println!("  running relays:   none");
9843                } else {
9844                    let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
9845                    println!("  running relays:   pids {}", p.join(", "));
9846                }
9847                println!("  would kill all + spawn fresh");
9848            }
9849            if !installed_service_kinds.is_empty() {
9850                println!(
9851                    "  would refresh:    {} installed service unit(s) → new binary path",
9852                    installed_service_kinds.join(", ")
9853                );
9854            }
9855            if !sessions_with_daemons.is_empty() {
9856                println!(
9857                    "  session daemons:  {} (would respawn under new binary)",
9858                    sessions_with_daemons.join(", ")
9859                );
9860            }
9861            if path_dupes.len() > 1 {
9862                println!(
9863                    "  PATH warning:     {} distinct `wire` binaries on PATH:",
9864                    path_dupes.len()
9865                );
9866                for b in &path_dupes {
9867                    println!("                      {b}");
9868                }
9869                println!("                    operators should remove the stale ones");
9870            }
9871        }
9872        return Ok(());
9873    }
9874
9875    // 3. Terminate the kill set. Graceful first, then FORCE-kill any survivor.
9876    //
9877    // v0.13.2 (B fix #2): the force-kill must NOT be gated on graceful having
9878    // "succeeded". On Windows, `taskkill /PID /T` WITHOUT `/F` is a no-op for a
9879    // windowless daemon (it returns failure), so the rc9 logic — which only
9880    // force-killed pids that graceful had reported killing — force-killed
9881    // NOTHING, and the daemon survived every `wire upgrade` (glossy: pidfile
9882    // pids 3676/25236/24660 all survived → accumulation). Now we attempt
9883    // graceful best-effort, grace-wait, then force-kill EVERY pid still alive
9884    // regardless of the graceful result. Force-kill (`taskkill /F /T` /
9885    // SIGKILL) is the load-bearing step.
9886    for pid in &kill_set {
9887        let _ = crate::platform::kill_process(*pid, false); // best-effort graceful
9888    }
9889    if !kill_set.is_empty() {
9890        // Brief grace for platforms where graceful works (Unix SIGTERM).
9891        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
9892        while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
9893        {
9894            std::thread::sleep(std::time::Duration::from_millis(50));
9895        }
9896        // Force-kill every survivor — this is what actually kills the
9897        // windowless daemon on Windows.
9898        for pid in &kill_set {
9899            if process_alive_pid(*pid) {
9900                let _ = crate::platform::kill_process(*pid, true);
9901            }
9902        }
9903        std::thread::sleep(std::time::Duration::from_millis(200)); // settle
9904    }
9905    // Report what's actually gone (drives the "no stale" message + JSON).
9906    let killed: Vec<u32> = kill_set
9907        .iter()
9908        .copied()
9909        .filter(|p| !process_alive_pid(*p))
9910        .collect();
9911
9912    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
9913    //    old daemon is still owning it.
9914    let pidfile = config::state_dir()?.join("daemon.pid");
9915    if pidfile.exists() {
9916        let _ = std::fs::remove_file(&pidfile);
9917    }
9918
9919    // 4b. v0.13.2: session-scoped — only THIS session's pidfile is wiped
9920    // (already removed at step 4 above). We deliberately DO NOT touch sibling
9921    // sessions' pidfiles: their daemons were spared, so wiping their pidfiles
9922    // would make them look down and the old box-wide respawn would spawn
9923    // duplicates (the accumulation bug). Each sibling refreshes itself on its
9924    // own `wire upgrade`.
9925
9926    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
9927    // multiple distinct files on $PATH, surface the conflict — operators
9928    // get bitten when an old binary at /usr/local/bin shadows a fresh
9929    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
9930    let mut path_dupes: Vec<String> = Vec::new();
9931    if let Ok(path) = std::env::var("PATH") {
9932        let mut seen: std::collections::HashSet<std::path::PathBuf> =
9933            std::collections::HashSet::new();
9934        for dir in path.split(':') {
9935            let candidate = std::path::PathBuf::from(dir).join("wire");
9936            if candidate.exists() {
9937                let canon = candidate.canonicalize().unwrap_or(candidate);
9938                if seen.insert(canon.clone()) {
9939                    path_dupes.push(canon.to_string_lossy().into_owned());
9940                }
9941            }
9942        }
9943    }
9944    let path_warning = if path_dupes.len() > 1 {
9945        Some(format!(
9946            "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n  {}",
9947            path_dupes.len(),
9948            path_dupes.join("\n  ")
9949        ))
9950    } else {
9951        None
9952    };
9953
9954    // 4d. v0.7.3 NEW: refresh installed service units so they point at
9955    // the freshly-installed binary path. Without this step, an upgrade
9956    // would: kill the old daemon, leave the launchd plist /
9957    // systemd unit / Windows scheduled task pointing at the OLD
9958    // binary path (or, worse, an old binary location that's been
9959    // unlinked), and then the OS's auto-respawn would either fail or
9960    // bring the OLD binary back from the dead. Reinstalling rewrites
9961    // the unit with `std::env::current_exe()` (the freshly-resolved
9962    // path of the running upgrade-driver process) and re-bootstraps /
9963    // re-enables / re-registers so the next OS-driven start uses it.
9964    //
9965    // Only refreshes units that are already installed — does NOT
9966    // install services the operator never opted into.
9967    let mut service_refreshes: Vec<Value> = Vec::new();
9968    for kind in [
9969        crate::service::ServiceKind::Daemon,
9970        crate::service::ServiceKind::LocalRelay,
9971    ] {
9972        let already_installed = crate::service::status_kind(kind)
9973            .map(|r| r.status != "absent")
9974            .unwrap_or(false);
9975        if !already_installed {
9976            continue;
9977        }
9978        match crate::service::install_kind(kind) {
9979            Ok(rep) => service_refreshes.push(json!({
9980                "kind": rep.kind,
9981                "platform": rep.platform,
9982                "status": rep.status,
9983                "unit_path": rep.unit_path,
9984                "action": "refreshed",
9985            })),
9986            Err(e) => service_refreshes.push(json!({
9987                "kind": format!("{kind:?}"),
9988                "action": "refresh_failed",
9989                "error": format!("{e:#}"),
9990            })),
9991        }
9992    }
9993
9994    // 5. Spawn fresh daemon via ensure_up — atomically waits for
9995    //    process_alive + writes the versioned pidfile. (If the Daemon
9996    //    service was refreshed above, it has already started a fresh
9997    //    process under the new binary; ensure_daemon_running notices
9998    //    and short-circuits to "already running".)
9999    let spawned = crate::ensure_up::ensure_daemon_running()?;
10000
10001    // 5b. v0.13.2: session-scoped — no sibling respawn. `ensure_daemon_running`
10002    // above already respawned THIS session's daemon; sibling sessions were
10003    // spared (never killed), so there is nothing to respawn for them. Each
10004    // refreshes itself on its own `wire upgrade`.
10005    let session_respawns: Vec<Value> = Vec::new();
10006
10007    let new_record = crate::ensure_up::read_pid_record("daemon");
10008    let new_pid = new_record.pid();
10009    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
10010        Some(d.version.clone())
10011    } else {
10012        None
10013    };
10014
10015    if as_json {
10016        println!(
10017            "{}",
10018            serde_json::to_string(&json!({
10019                "killed": killed,
10020                "found_daemons": daemon_pids,
10021                "spared_relay_servers": relay_pids,
10022                "service_refreshes": service_refreshes,
10023                "spawned_fresh_daemon": spawned,
10024                "new_pid": new_pid,
10025                "new_version": new_version,
10026                "cli_version": cli_version,
10027                "session_respawns": session_respawns,
10028                "path_binaries": path_dupes,
10029                "path_warning": path_warning,
10030            }))?
10031        );
10032    } else {
10033        if killed.is_empty() {
10034            println!("wire upgrade: no stale wire processes running");
10035        } else {
10036            let killed_list = killed
10037                .iter()
10038                .map(|p| p.to_string())
10039                .collect::<Vec<_>>()
10040                .join(", ");
10041            // Session-scoped: report what was actually killed, and that the
10042            // shared relay-server was SPARED (not killed) — the old wording
10043            // lumped the spared relay into the killed count and read like it
10044            // had been terminated (glossy-magnolia nit).
10045            if relay_pids.is_empty() {
10046                println!(
10047                    "wire upgrade: killed {} daemon(s) [{killed_list}]",
10048                    killed.len()
10049                );
10050            } else {
10051                let relay_list = relay_pids
10052                    .iter()
10053                    .map(|p| p.to_string())
10054                    .collect::<Vec<_>>()
10055                    .join(", ");
10056                println!(
10057                    "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
10058                    killed.len(),
10059                    relay_pids.len()
10060                );
10061            }
10062        }
10063        if !service_refreshes.is_empty() {
10064            println!(
10065                "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
10066                service_refreshes.len()
10067            );
10068            for r in &service_refreshes {
10069                let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
10070                let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
10071                let status = r.get("status").and_then(Value::as_str).unwrap_or("");
10072                let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
10073                if action == "refreshed" {
10074                    println!("                    - {kind}: {action} ({status}, {platform})");
10075                } else {
10076                    let err = r.get("error").and_then(Value::as_str).unwrap_or("");
10077                    println!("                    - {kind}: {action} ({err})");
10078                }
10079            }
10080        }
10081        if spawned {
10082            println!(
10083                "wire upgrade: spawned fresh daemon (pid {} v{})",
10084                new_pid
10085                    .map(|p| p.to_string())
10086                    .unwrap_or_else(|| "?".to_string()),
10087                new_version.as_deref().unwrap_or(&cli_version),
10088            );
10089        } else {
10090            println!("wire upgrade: daemon was already running on current binary");
10091        }
10092        if !session_respawns.is_empty() {
10093            println!(
10094                "wire upgrade: refreshed {} session daemon(s):",
10095                session_respawns.len()
10096            );
10097            for r in &session_respawns {
10098                let h = r["session_home"].as_str().unwrap_or("?");
10099                let s = r["status"].as_str().unwrap_or("?");
10100                let label = std::path::Path::new(h)
10101                    .file_name()
10102                    .map(|f| f.to_string_lossy().into_owned())
10103                    .unwrap_or_else(|| h.to_string());
10104                println!("  {label:<24} {s}");
10105            }
10106        }
10107        if let Some(msg) = &path_warning {
10108            eprintln!("wire upgrade: {msg}");
10109        }
10110    }
10111    Ok(())
10112}
10113
10114/// v0.9.1: should this command emit JSON by default?
10115///
10116/// - `explicit=true` → operator passed `--json`, always JSON.
10117/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
10118///   captured output parses cleanly without operators remembering to
10119///   append `--json`. Mirrors `gh`, `kubectl`, etc.
10120/// - interactive TTY → human format (false).
10121/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
10122///   that parsed the human text by accident).
10123fn json_default(explicit: bool) -> bool {
10124    if explicit {
10125        return true;
10126    }
10127    if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
10128        return false;
10129    }
10130    use std::io::IsTerminal;
10131    !std::io::stdout().is_terminal()
10132}
10133
10134fn process_alive_pid(pid: u32) -> bool {
10135    // v0.7.3: delegate to the cross-platform helper. See
10136    // `platform::process_alive` for the per-OS dispatch — Windows now
10137    // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
10138    // gave a hard-coded false on Windows pre-v0.7.3.
10139    crate::platform::process_alive(pid)
10140}
10141
10142// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
10143
10144/// Iterative Levenshtein distance between two strings, case-insensitive.
10145/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
10146/// resolves against (typically <30 chars).
10147fn levenshtein_ci(a: &str, b: &str) -> usize {
10148    let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
10149    let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
10150    let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
10151    let (m, n) = (a.len(), b.len());
10152    if m == 0 {
10153        return n;
10154    }
10155    let mut prev: Vec<usize> = (0..=m).collect();
10156    let mut curr = vec![0usize; m + 1];
10157    for j in 1..=n {
10158        curr[0] = j;
10159        for i in 1..=m {
10160            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
10161            curr[i] = std::cmp::min(
10162                std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
10163                prev[i - 1] + cost,
10164            );
10165        }
10166        std::mem::swap(&mut prev, &mut curr);
10167    }
10168    prev[m]
10169}
10170
10171/// Return up to `max_results` names from `pool` whose edit distance to
10172/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
10173/// "did you mean" suggestions on resolution miss.
10174pub fn closest_candidates(
10175    needle: &str,
10176    pool: &[String],
10177    max_distance: usize,
10178    max_results: usize,
10179) -> Vec<String> {
10180    let mut scored: Vec<(usize, &String)> = pool
10181        .iter()
10182        .map(|c| (levenshtein_ci(needle, c), c))
10183        .filter(|(d, _)| *d <= max_distance)
10184        .collect();
10185    scored.sort_by_key(|(d, _)| *d);
10186    scored
10187        .into_iter()
10188        .take(max_results)
10189        .map(|(_, c)| c.clone())
10190        .collect()
10191}
10192
10193/// Collect every name that `resolve_name_to_target` would currently
10194/// match: pinned-peer handles, pinned-peer character nicknames, sister
10195/// session names, sister character nicknames, sister handles. Used for
10196/// the `did_you_mean` pool on resolution miss.
10197fn known_local_names() -> Vec<String> {
10198    let mut names: Vec<String> = Vec::new();
10199    if let Ok(trust) = config::read_trust() {
10200        // (debug eprintln removed; left bug-trail in commit message)
10201        // trust.agents is an object keyed by handle, NOT an array —
10202        // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
10203        // the object's keys (which ARE the handles) plus each entry's
10204        // did for the DID-derived character nickname.
10205        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
10206            for (handle, agent) in agents {
10207                names.push(handle.clone());
10208                if let Some(did) = agent.get("did").and_then(Value::as_str) {
10209                    let ch = crate::character::Character::from_did(did);
10210                    names.push(ch.nickname);
10211                }
10212            }
10213        }
10214    }
10215    if let Ok(sessions) = crate::session::list_sessions() {
10216        for s in sessions {
10217            names.push(s.name.clone());
10218            if let Some(h) = &s.handle {
10219                names.push(h.clone());
10220            }
10221            if let Some(ch) = &s.character {
10222                names.push(ch.nickname.clone());
10223            }
10224        }
10225    }
10226    names.sort();
10227    names.dedup();
10228    names
10229}
10230
10231/// v0.9.2 deprecation banner with two ergonomic guards:
10232/// 1. Suppress in JSON mode (the caller is expected to fold the
10233///    deprecation note into its JSON output instead).
10234/// 2. Cache once-per-shell-session via a marker env var; subsequent
10235///    invocations in the same shell stay silent.
10236///
10237/// `verb` is the legacy verb name, `replacement` is the canonical one.
10238fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
10239    if json_mode {
10240        return;
10241    }
10242    // Pull a marker from environment of THIS process. Persistent across
10243    // multiple wire invocations only when the shell sets and exports
10244    // WIRE_DEPRECATION_NAGGED — operators rarely do, so practically
10245    // this nags once per `wire foo` invocation. The single-process
10246    // dedup matters most for scripts that call multiple deprecated
10247    // verbs in one wire run, which is currently impossible (one verb
10248    // per process) but documented for future loop-style wire shells.
10249    let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
10250    if std::env::var(&key).is_ok() {
10251        return;
10252    }
10253    // SAFETY: deprecation_warn is called from sync dispatcher code paths
10254    // before any worker thread spawns; env::set_var in Rust 2024 is
10255    // safe at that point. Pattern matches maybe_adopt_session_wire_home.
10256    unsafe {
10257        std::env::set_var(&key, "1");
10258    }
10259    eprintln!(
10260        "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
10261         Will be removed in v1.0 (target 2026-Q3). \
10262         Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
10263        verb.replace('-', "_")
10264    );
10265}
10266
10267// ---------- doctor (single-command diagnostic) ----------
10268
10269/// One DoctorCheck = one verdict on one health dimension.
10270#[derive(Clone, Debug, serde::Serialize)]
10271pub struct DoctorCheck {
10272    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
10273    /// Stable across versions for tooling consumption.
10274    pub id: String,
10275    /// PASS / WARN / FAIL.
10276    pub status: String,
10277    /// One-line human summary.
10278    pub detail: String,
10279    /// Optional remediation hint shown after the failing line.
10280    #[serde(skip_serializing_if = "Option::is_none")]
10281    pub fix: Option<String>,
10282}
10283
10284impl DoctorCheck {
10285    fn pass(id: &str, detail: impl Into<String>) -> Self {
10286        Self {
10287            id: id.into(),
10288            status: "PASS".into(),
10289            detail: detail.into(),
10290            fix: None,
10291        }
10292    }
10293    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10294        Self {
10295            id: id.into(),
10296            status: "WARN".into(),
10297            detail: detail.into(),
10298            fix: Some(fix.into()),
10299        }
10300    }
10301    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10302        Self {
10303            id: id.into(),
10304            status: "FAIL".into(),
10305            detail: detail.into(),
10306            fix: Some(fix.into()),
10307        }
10308    }
10309}
10310
10311/// `wire doctor` — single-command diagnostic for the silent-fail classes
10312/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
10313/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
10314/// so operators don't have to know where each lives.
10315fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
10316    let checks: Vec<DoctorCheck> = vec![
10317        check_daemon_health(),
10318        check_daemon_pid_consistency(),
10319        check_relay_reachable(),
10320        check_pair_rejections(recent_rejections),
10321        check_cursor_progress(),
10322    ];
10323
10324    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
10325    let warns = checks.iter().filter(|c| c.status == "WARN").count();
10326
10327    if as_json {
10328        println!(
10329            "{}",
10330            serde_json::to_string(&json!({
10331                "checks": checks,
10332                "fail_count": fails,
10333                "warn_count": warns,
10334                "ok": fails == 0,
10335            }))?
10336        );
10337    } else {
10338        println!("wire doctor — {} checks", checks.len());
10339        for c in &checks {
10340            let bullet = match c.status.as_str() {
10341                "PASS" => "✓",
10342                "WARN" => "!",
10343                "FAIL" => "✗",
10344                _ => "?",
10345            };
10346            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
10347            if let Some(fix) = &c.fix {
10348                println!("      fix: {fix}");
10349            }
10350        }
10351        println!();
10352        if fails == 0 && warns == 0 {
10353            println!("ALL GREEN");
10354        } else {
10355            println!("{fails} FAIL, {warns} WARN");
10356        }
10357    }
10358
10359    if fails > 0 {
10360        std::process::exit(1);
10361    }
10362    Ok(())
10363}
10364
10365/// Check: daemon running, exactly one instance, no orphans.
10366///
10367/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
10368/// days, advancing cursor without pinning). `wire status` lied about it.
10369/// `wire doctor` must catch THIS class: multiple daemons running, OR
10370/// pid-file claims daemon down while a process is actually up.
10371fn check_daemon_health() -> DoctorCheck {
10372    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
10373    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
10374    // hardening): every surface routes through ensure_up::daemon_liveness
10375    // so they share one view of the world. No more parallel liveness
10376    // logic to drift out of sync.
10377    let snap = crate::ensure_up::daemon_liveness();
10378    let pgrep_pids = &snap.pgrep_pids;
10379    let pidfile_pid = snap.pidfile_pid;
10380    let pidfile_alive = snap.pidfile_alive;
10381    let orphan_pids = &snap.orphan_pids;
10382
10383    let fmt_pids = |xs: &[u32]| -> String {
10384        xs.iter()
10385            .map(|p| p.to_string())
10386            .collect::<Vec<_>>()
10387            .join(", ")
10388    };
10389
10390    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
10391        (0, _, _) => DoctorCheck::fail(
10392            "daemon",
10393            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
10394            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
10395        ),
10396        // Single daemon AND it matches the pidfile → healthy.
10397        (1, true, true) => DoctorCheck::pass(
10398            "daemon",
10399            format!(
10400                "one daemon running (pid {}, matches pidfile)",
10401                pgrep_pids[0]
10402            ),
10403        ),
10404        // Pidfile is alive but pgrep ALSO sees orphan processes.
10405        (n, true, false) => DoctorCheck::fail(
10406            "daemon",
10407            format!(
10408                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
10409                 The orphans race the relay cursor — they advance past events your current binary can't process. \
10410                 (Issue #2 exact class.)",
10411                fmt_pids(pgrep_pids),
10412                pidfile_pid.unwrap(),
10413                fmt_pids(orphan_pids),
10414            ),
10415            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
10416        ),
10417        // Pidfile is dead but processes ARE running → all are orphans.
10418        (n, false, _) => DoctorCheck::fail(
10419            "daemon",
10420            format!(
10421                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
10422                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
10423                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
10424                fmt_pids(pgrep_pids),
10425                match pidfile_pid {
10426                    Some(p) => format!("claims pid {p} which is dead"),
10427                    None => "is missing".to_string(),
10428                },
10429            ),
10430            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
10431        ),
10432        // Multiple daemons all matching … impossible by construction; fall back to warn.
10433        (n, true, true) => DoctorCheck::warn(
10434            "daemon",
10435            format!(
10436                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
10437                fmt_pids(pgrep_pids)
10438            ),
10439            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
10440        ),
10441    }
10442}
10443
10444/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
10445/// check. Surfaces version mismatch (daemon running old binary text in
10446/// memory under a current symlink — today's exact bug class), schema
10447/// drift (future format bumps), and identity contamination (daemon's
10448/// recorded DID doesn't match this box's configured DID).
10449///
10450/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
10451/// JSON pid record whose recorded `pid` is no longer a live OS process.
10452/// Pre-hardening this check PASSed in that state (it only validated
10453/// content, not liveness), letting `wire status: DOWN` and
10454/// `wire doctor: PASS` disagree for 25 min in incident #2.
10455fn check_daemon_pid_consistency() -> DoctorCheck {
10456    let snap = crate::ensure_up::daemon_liveness();
10457    match &snap.record {
10458        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
10459            "daemon_pid_consistency",
10460            "no daemon.pid yet — fresh box or daemon never started",
10461        ),
10462        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
10463            "daemon_pid_consistency",
10464            format!("daemon.pid is corrupt: {reason}"),
10465            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
10466        ),
10467        crate::ensure_up::PidRecord::LegacyInt(pid) => {
10468            // Legacy pidfile: still surface liveness so a dead legacy pid
10469            // doesn't quietly PASS this check while status says DOWN.
10470            let pid = *pid;
10471            if !crate::ensure_up::pid_is_alive(pid) {
10472                return DoctorCheck::warn(
10473                    "daemon_pid_consistency",
10474                    format!(
10475                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
10476                         Stale pidfile from a crashed pre-0.5.11 daemon. \
10477                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
10478                    ),
10479                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
10480                );
10481            }
10482            DoctorCheck::warn(
10483                "daemon_pid_consistency",
10484                format!(
10485                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
10486                     Daemon was started by a pre-0.5.11 binary."
10487                ),
10488                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
10489            )
10490        }
10491        crate::ensure_up::PidRecord::Json(d) => {
10492            // v0.5.19 liveness gate: if the recorded pid is dead, the
10493            // pidfile is stale and the rest of the content drift checks
10494            // are moot — `wire upgrade` is the answer regardless.
10495            if !snap.pidfile_alive {
10496                return DoctorCheck::warn(
10497                    "daemon_pid_consistency",
10498                    format!(
10499                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
10500                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
10501                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
10502                        pid = d.pid,
10503                        version = d.version,
10504                    ),
10505                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
10506                     (kills any orphan daemon advancing the cursor without coordination)",
10507                );
10508            }
10509            let mut issues: Vec<String> = Vec::new();
10510            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
10511                issues.push(format!(
10512                    "schema={} (expected {})",
10513                    d.schema,
10514                    crate::ensure_up::DAEMON_PID_SCHEMA
10515                ));
10516            }
10517            let cli_version = env!("CARGO_PKG_VERSION");
10518            if d.version != cli_version {
10519                issues.push(format!("version daemon={} cli={cli_version}", d.version));
10520            }
10521            if !std::path::Path::new(&d.bin_path).exists() {
10522                issues.push(format!("bin_path {} missing on disk", d.bin_path));
10523            }
10524            // Cross-check DID + relay against current config (best-effort).
10525            if let Ok(card) = config::read_agent_card()
10526                && let Some(current_did) = card.get("did").and_then(Value::as_str)
10527                && let Some(recorded_did) = &d.did
10528                && recorded_did != current_did
10529            {
10530                issues.push(format!(
10531                    "did daemon={recorded_did} config={current_did} — identity drift"
10532                ));
10533            }
10534            if let Ok(state) = config::read_relay_state()
10535                && let Some(current_relay) = state
10536                    .get("self")
10537                    .and_then(|s| s.get("relay_url"))
10538                    .and_then(Value::as_str)
10539                && let Some(recorded_relay) = &d.relay_url
10540                && recorded_relay != current_relay
10541            {
10542                issues.push(format!(
10543                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
10544                ));
10545            }
10546            if issues.is_empty() {
10547                DoctorCheck::pass(
10548                    "daemon_pid_consistency",
10549                    format!(
10550                        "daemon v{} bound to {} as {}",
10551                        d.version,
10552                        d.relay_url.as_deref().unwrap_or("?"),
10553                        d.did.as_deref().unwrap_or("?")
10554                    ),
10555                )
10556            } else {
10557                DoctorCheck::warn(
10558                    "daemon_pid_consistency",
10559                    format!("daemon pidfile drift: {}", issues.join("; ")),
10560                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
10561                )
10562            }
10563        }
10564    }
10565}
10566
10567/// Check: bound relay's /healthz returns 200.
10568fn check_relay_reachable() -> DoctorCheck {
10569    let state = match config::read_relay_state() {
10570        Ok(s) => s,
10571        Err(e) => {
10572            return DoctorCheck::fail(
10573                "relay",
10574                format!("could not read relay state: {e}"),
10575                "run `wire up <handle>@<relay>` to bootstrap",
10576            );
10577        }
10578    };
10579    let url = state
10580        .get("self")
10581        .and_then(|s| s.get("relay_url"))
10582        .and_then(Value::as_str)
10583        .unwrap_or("");
10584    if url.is_empty() {
10585        return DoctorCheck::warn(
10586            "relay",
10587            "no relay bound — wire send/pull will not work",
10588            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
10589        );
10590    }
10591    let client = crate::relay_client::RelayClient::new(url);
10592    match client.check_healthz() {
10593        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
10594        Err(e) => DoctorCheck::fail(
10595            "relay",
10596            format!("{url} unreachable: {e}"),
10597            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
10598        ),
10599    }
10600}
10601
10602/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
10603/// entry there is a silent failure that, pre-0.5.11, would have left the
10604/// operator wondering why pairing didn't complete.
10605fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
10606    let path = match config::state_dir() {
10607        Ok(d) => d.join("pair-rejected.jsonl"),
10608        Err(e) => {
10609            return DoctorCheck::warn(
10610                "pair_rejections",
10611                format!("could not resolve state dir: {e}"),
10612                "set WIRE_HOME or fix XDG_STATE_HOME",
10613            );
10614        }
10615    };
10616    if !path.exists() {
10617        return DoctorCheck::pass(
10618            "pair_rejections",
10619            "no pair-rejected.jsonl — no recorded pair failures",
10620        );
10621    }
10622    let body = match std::fs::read_to_string(&path) {
10623        Ok(b) => b,
10624        Err(e) => {
10625            return DoctorCheck::warn(
10626                "pair_rejections",
10627                format!("could not read {path:?}: {e}"),
10628                "check file permissions",
10629            );
10630        }
10631    };
10632    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
10633    if lines.is_empty() {
10634        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
10635    }
10636    let total = lines.len();
10637    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
10638    let mut summary: Vec<String> = Vec::new();
10639    for line in &recent {
10640        if let Ok(rec) = serde_json::from_str::<Value>(line) {
10641            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
10642            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
10643            summary.push(format!("{peer}/{code}"));
10644        }
10645    }
10646    DoctorCheck::warn(
10647        "pair_rejections",
10648        format!(
10649            "{total} pair failures recorded. recent: [{}]",
10650            summary.join(", ")
10651        ),
10652        format!(
10653            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
10654        ),
10655    )
10656}
10657
10658/// Check: cursor isn't stuck. We can't tell without polling — but we can
10659/// report the current cursor position so operators see if it changes.
10660/// Real "stuck" detection needs two pulls separated in time; defer that
10661/// behaviour to a `wire doctor --watch` mode.
10662fn check_cursor_progress() -> DoctorCheck {
10663    let state = match config::read_relay_state() {
10664        Ok(s) => s,
10665        Err(e) => {
10666            return DoctorCheck::warn(
10667                "cursor",
10668                format!("could not read relay state: {e}"),
10669                "check ~/Library/Application Support/wire/relay.json",
10670            );
10671        }
10672    };
10673    let cursor = state
10674        .get("self")
10675        .and_then(|s| s.get("last_pulled_event_id"))
10676        .and_then(Value::as_str)
10677        .map(|s| s.chars().take(16).collect::<String>())
10678        .unwrap_or_else(|| "<none>".to_string());
10679    DoctorCheck::pass(
10680        "cursor",
10681        format!(
10682            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
10683        ),
10684    )
10685}
10686
10687#[cfg(test)]
10688mod doctor_tests {
10689    use super::*;
10690
10691    #[test]
10692    fn doctor_check_constructors_set_status_correctly() {
10693        // Silent-fail-prevention rule: pass/warn/fail must be visibly
10694        // distinguishable to operators. If any constructor lets the wrong
10695        // status through, `wire doctor` lies and we're back to today's
10696        // 30-minute debug.
10697        let p = DoctorCheck::pass("x", "ok");
10698        assert_eq!(p.status, "PASS");
10699        assert_eq!(p.fix, None);
10700
10701        let w = DoctorCheck::warn("x", "watch out", "do this");
10702        assert_eq!(w.status, "WARN");
10703        assert_eq!(w.fix, Some("do this".to_string()));
10704
10705        let f = DoctorCheck::fail("x", "broken", "fix it");
10706        assert_eq!(f.status, "FAIL");
10707        assert_eq!(f.fix, Some("fix it".to_string()));
10708    }
10709
10710    #[test]
10711    fn check_pair_rejections_no_file_is_pass() {
10712        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
10713        // as a problem.
10714        config::test_support::with_temp_home(|| {
10715            config::ensure_dirs().unwrap();
10716            let c = check_pair_rejections(5);
10717            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
10718        });
10719    }
10720
10721    #[test]
10722    fn check_pair_rejections_with_entries_warns() {
10723        // Existence of rejections is itself a signal — even if each entry
10724        // is a "known good failure," the operator wants to know they
10725        // happened.
10726        config::test_support::with_temp_home(|| {
10727            config::ensure_dirs().unwrap();
10728            crate::pair_invite::record_pair_rejection(
10729                "willard",
10730                "pair_drop_ack_send_failed",
10731                "POST 502",
10732            );
10733            let c = check_pair_rejections(5);
10734            assert_eq!(c.status, "WARN");
10735            assert!(c.detail.contains("1 pair failures"));
10736            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
10737        });
10738    }
10739}
10740
10741// ---------- up megacommand (full bootstrap) ----------
10742
10743/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
10744/// pair. Composes the steps that today's onboarding walks operators through
10745/// one by one (init / bind-relay / claim / background daemon / arm monitor
10746/// recipe). Idempotent: every step checks current state and skips if done.
10747///
10748/// Argument parsing accepts:
10749///   - `<nick>@<relay-host>` — explicit relay
10750///   - `<nick>`              — defaults to wireup.net (the configured
10751///     public relay)
10752fn cmd_up(
10753    relay_arg: Option<&str>,
10754    name: Option<&str>,
10755    with_local: Option<&str>,
10756    no_local: bool,
10757    as_json: bool,
10758) -> Result<()> {
10759    // No nick to parse — your handle is your DID-derived persona (one-name
10760    // rule). The optional arg is only which relay to bind/claim on. Accepts
10761    // `@host`, bare `host`, or a full URL; defaults to the public relay.
10762    let relay_url = match relay_arg {
10763        Some(r) => {
10764            let r = r.trim_start_matches('@');
10765            if r.starts_with("http://") || r.starts_with("https://") {
10766                r.to_string()
10767            } else {
10768                format!("https://{r}")
10769            }
10770        }
10771        None => crate::pair_invite::DEFAULT_RELAY.to_string(),
10772    };
10773
10774    let mut report: Vec<(String, String)> = Vec::new();
10775    let mut step = |stage: &str, detail: String| {
10776        report.push((stage.to_string(), detail.clone()));
10777        if !as_json {
10778            eprintln!("wire up: {stage} — {detail}");
10779        }
10780    };
10781
10782    // 1. init (or note existing identity). No typed name — cmd_init(None)
10783    // generates the persona from the freshly-minted keypair (one-name rule).
10784    if config::is_initialized()? {
10785        step("init", "already initialized".to_string());
10786    } else {
10787        cmd_init(
10788            None,
10789            name,
10790            Some(&relay_url),
10791            false,
10792            /* as_json */ false,
10793        )?;
10794        step("init", format!("created identity bound to {relay_url}"));
10795    }
10796
10797    // Canonical persona handle — the one name we claim and are addressed by.
10798    let canonical = {
10799        let card = config::read_agent_card()?;
10800        let did = card.get("did").and_then(Value::as_str).unwrap_or("");
10801        crate::agent_card::display_handle_from_did(did).to_string()
10802    };
10803    step("identity", format!("persona is `{canonical}`"));
10804
10805    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
10806    // already initialized we may need to bind to the requested relay
10807    // separately (operator switched relays).
10808    let relay_state = config::read_relay_state()?;
10809    let bound_relay = relay_state
10810        .get("self")
10811        .and_then(|s| s.get("relay_url"))
10812        .and_then(Value::as_str)
10813        .unwrap_or("")
10814        .to_string();
10815    if bound_relay.is_empty() {
10816        // Identity exists but never bound to a relay — bind now.
10817        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
10818        // Pass `false` so the safety check kicks in if state was non-empty.
10819        cmd_bind_relay(
10820            &relay_url, /* scope */ None, // infer from URL (federation for wireup.net)
10821            /* replace */ false, /* migrate_pinned */ false, /* as_json */ false,
10822        )?;
10823        step("bind-relay", format!("bound to {relay_url}"));
10824    } else if bound_relay != relay_url {
10825        step(
10826            "bind-relay",
10827            format!(
10828                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
10829                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
10830            ),
10831        );
10832    } else {
10833        step("bind-relay", format!("already bound to {bound_relay}"));
10834    }
10835
10836    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
10837    // re-claims are accepted by the relay.
10838    match cmd_claim(
10839        &canonical,
10840        Some(&relay_url),
10841        None,
10842        /* hidden */ false,
10843        /* as_json */ false,
10844    ) {
10845        Ok(()) => step(
10846            "claim",
10847            format!("{canonical}@{} claimed", strip_proto(&relay_url)),
10848        ),
10849        Err(e) => step(
10850            "claim",
10851            format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
10852        ),
10853    }
10854
10855    // 3b. Opportunistic local dual-slot (additive). Gives same-box sister
10856    // sessions sub-millisecond loopback routing alongside the federation
10857    // slot. Local relays carry no handle directory — nothing to claim
10858    // there; sister discovery is via `wire session list-local`.
10859    if no_local {
10860        step("local-slot", "skipped (--no-local)".to_string());
10861    } else {
10862        let local_url = with_local
10863            .unwrap_or("http://127.0.0.1:8771")
10864            .trim_end_matches('/');
10865        let already_local = crate::endpoints::self_endpoints(
10866            &config::read_relay_state().unwrap_or_else(|_| json!({})),
10867        )
10868        .iter()
10869        .any(|e| e.relay_url == local_url);
10870        if relay_url.trim_end_matches('/') == local_url || already_local {
10871            step("local-slot", "already covered".to_string());
10872        } else if crate::relay_client::RelayClient::new(local_url)
10873            .check_healthz()
10874            .is_ok()
10875        {
10876            match cmd_bind_relay(
10877                local_url,
10878                Some("local"),
10879                /* replace */ false,
10880                /* migrate_pinned */ false,
10881                /* as_json */ false,
10882            ) {
10883                Ok(()) => step(
10884                    "local-slot",
10885                    format!("dual-bound local relay {local_url} for sister routing"),
10886                ),
10887                Err(e) => step("local-slot", format!("skipped local relay: {e}")),
10888            }
10889        } else {
10890            step(
10891                "local-slot",
10892                format!(
10893                    "no local relay reachable at {local_url} — federation only \
10894                     (sisters resolve via session-list)"
10895                ),
10896            );
10897        }
10898    }
10899
10900    // 4. Background daemon — must be running for pull/push/ack to flow.
10901    match crate::ensure_up::ensure_daemon_running() {
10902        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
10903        Ok(false) => step("daemon", "already running".to_string()),
10904        Err(e) => step(
10905            "daemon",
10906            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
10907        ),
10908    }
10909
10910    // 5. Final summary — point operator at the next commands.
10911    let summary =
10912        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
10913         `wire monitor` to watch incoming events."
10914            .to_string();
10915    step("ready", summary.clone());
10916
10917    if as_json {
10918        let steps_json: Vec<_> = report
10919            .iter()
10920            .map(|(k, v)| json!({"stage": k, "detail": v}))
10921            .collect();
10922        println!(
10923            "{}",
10924            serde_json::to_string(&json!({
10925                "nick": canonical,
10926                "relay": relay_url,
10927                "steps": steps_json,
10928            }))?
10929        );
10930    }
10931    Ok(())
10932}
10933
10934/// Strip http:// or https:// prefix for display in `wire up` step output.
10935fn strip_proto(url: &str) -> String {
10936    url.trim_start_matches("https://")
10937        .trim_start_matches("http://")
10938        .to_string()
10939}
10940
10941// ---------- pair megacommand (zero-paste handle-based) ----------
10942
10943/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
10944/// the handle is in `nick@domain` form. Wraps:
10945///
10946///   1. cmd_add — resolve, pin, drop intro
10947///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
10948///      (signalled by `peers.<handle>.slot_token` populating in relay state)
10949///   3. Verify bilateral pin: trust contains peer + relay state has token
10950///   4. Print final state — both sides VERIFIED + can `wire send`
10951///
10952/// On timeout: hard-errors with the specific stuck step so the operator
10953/// knows which side to chase. No silent partial success.
10954fn cmd_pair_megacommand(
10955    handle_arg: &str,
10956    relay_override: Option<&str>,
10957    timeout_secs: u64,
10958    _as_json: bool,
10959) -> Result<()> {
10960    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
10961    let peer_handle = parsed.nick.clone();
10962
10963    eprintln!("wire pair: resolving {handle_arg}...");
10964    cmd_add(
10965        handle_arg,
10966        relay_override,
10967        /* local_sister */ false,
10968        /* as_json */ false,
10969    )?;
10970
10971    eprintln!(
10972        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
10973         to ack (their daemon must be running + pulling)..."
10974    );
10975
10976    // Trigger an immediate daemon-style pull so we don't wait the full daemon
10977    // interval. Best-effort — if it fails, we still fall through to the
10978    // polling loop.
10979    let _ = run_sync_pull();
10980
10981    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
10982    let poll_interval = std::time::Duration::from_millis(500);
10983
10984    loop {
10985        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
10986        let _ = run_sync_pull();
10987        let relay_state = config::read_relay_state()?;
10988        let peer_entry = relay_state
10989            .get("peers")
10990            .and_then(|p| p.get(&peer_handle))
10991            .cloned();
10992        let token = peer_entry
10993            .as_ref()
10994            .and_then(|e| e.get("slot_token"))
10995            .and_then(Value::as_str)
10996            .unwrap_or("");
10997
10998        if !token.is_empty() {
10999            // Bilateral pin complete — we have their slot_token, we can send.
11000            let trust = config::read_trust()?;
11001            let pinned_in_trust = trust
11002                .get("agents")
11003                .and_then(|a| a.get(&peer_handle))
11004                .is_some();
11005            println!(
11006                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
11007                if pinned_in_trust {
11008                    "VERIFIED"
11009                } else {
11010                    "MISSING (bug)"
11011                }
11012            );
11013            return Ok(());
11014        }
11015
11016        if std::time::Instant::now() >= deadline {
11017            // Timeout — surface the EXACT stuck step. Likely culprits:
11018            //   - peer daemon not running on their box
11019            //   - peer's relay slot is offline
11020            //   - their daemon is on an older binary that doesn't know
11021            //     pair_drop kind=1100 (the P0.1 class — now visible via
11022            //     wire pull --json on their side as a blocking rejection)
11023            bail!(
11024                "wire pair: timed out after {timeout_secs}s. \
11025                 peer {peer_handle} never sent pair_drop_ack. \
11026                 likely causes: (a) their daemon is down — ask them to run \
11027                 `wire status` and `wire daemon &`; (b) their binary is older \
11028                 than 0.5.x and doesn't understand pair_drop events — ask \
11029                 them to `wire upgrade`; (c) network / relay blip — re-run \
11030                 `wire pair {handle_arg}` to retry."
11031            );
11032        }
11033
11034        std::thread::sleep(poll_interval);
11035    }
11036}
11037
11038fn cmd_claim(
11039    nick: &str,
11040    relay_override: Option<&str>,
11041    public_url: Option<&str>,
11042    hidden: bool,
11043    as_json: bool,
11044) -> Result<()> {
11045    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
11046    // + claim handle. Operator should never have to run init/bind-relay first.
11047    let (_did, relay_url, slot_id, slot_token) =
11048        crate::pair_invite::ensure_self_with_relay(relay_override)?;
11049    let card = config::read_agent_card()?;
11050
11051    // v0.13.1 one-name enforcement: the handle you claim in the phonebook
11052    // MUST equal your DID-derived persona, so the directory entry can never
11053    // drift from your agent-card handle. A typed nick that differs is ignored
11054    // (mirrors how `wire init` coerces the typed name). This closes the
11055    // claim-path reopening of the v0.11 "two names" footgun — before this,
11056    // `wire claim coffee-ghost` published coffee-ghost@relay -> your DID while
11057    // your card said e.g. outback-sandpiper. The typed `nick` arg is now
11058    // vestigial, exactly like the one `wire init` / `wire up` already accept.
11059    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
11060    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
11061    if !canonical.is_empty() && nick != canonical && !as_json {
11062        eprintln!(
11063            "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
11064        );
11065    }
11066    let nick = if canonical.is_empty() {
11067        nick
11068    } else {
11069        canonical.as_str()
11070    };
11071    if !crate::pair_profile::is_valid_nick(nick) {
11072        bail!(
11073            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
11074        );
11075    }
11076
11077    let client = crate::relay_client::RelayClient::new(&relay_url);
11078    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
11079    // (back-compat); Some(false) for `--hidden`. Relays older than
11080    // v0.5.19 ignore the field, so this is safe to always send.
11081    let discoverable = if hidden { Some(false) } else { None };
11082    let resp =
11083        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
11084
11085    if as_json {
11086        println!(
11087            "{}",
11088            serde_json::to_string(&json!({
11089                "nick": nick,
11090                "relay": relay_url,
11091                "response": resp,
11092            }))?
11093        );
11094    } else {
11095        // Best-effort: derive the public domain from the relay URL. If
11096        // operator passed --public-url that's the canonical address; else
11097        // the relay URL itself. Falls back to a placeholder if both miss.
11098        let domain = public_url
11099            .unwrap_or(&relay_url)
11100            .trim_start_matches("https://")
11101            .trim_start_matches("http://")
11102            .trim_end_matches('/')
11103            .split('/')
11104            .next()
11105            .unwrap_or("<this-relay-domain>")
11106            .to_string();
11107        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
11108        println!("verify with: wire whois {nick}@{domain}");
11109    }
11110    Ok(())
11111}
11112
11113fn cmd_profile(action: ProfileAction) -> Result<()> {
11114    match action {
11115        ProfileAction::Set { field, value, json } => {
11116            // Try parsing the value as JSON; if that fails, treat it as a
11117            // bare string. Lets operators pass either `42` or `"hello"` or
11118            // `["rust","late-night"]` without quoting hell.
11119            let parsed: Value =
11120                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
11121            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
11122            if json {
11123                println!(
11124                    "{}",
11125                    serde_json::to_string(&json!({
11126                        "field": field,
11127                        "profile": new_profile,
11128                    }))?
11129                );
11130            } else {
11131                println!("profile.{field} set");
11132            }
11133        }
11134        ProfileAction::Get { json } => return cmd_whois(None, json, None),
11135        ProfileAction::Clear { field, json } => {
11136            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
11137            if json {
11138                println!(
11139                    "{}",
11140                    serde_json::to_string(&json!({
11141                        "field": field,
11142                        "cleared": true,
11143                        "profile": new_profile,
11144                    }))?
11145                );
11146            } else {
11147                println!("profile.{field} cleared");
11148            }
11149        }
11150    }
11151    Ok(())
11152}
11153
11154// ---------- setup — one-shot MCP host registration ----------
11155
11156fn cmd_setup(apply: bool) -> Result<()> {
11157    use std::path::PathBuf;
11158
11159    let entry = json!({"command": "wire", "args": ["mcp"]});
11160    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
11161
11162    // Detect probable MCP host config locations. Cross-platform — we only
11163    // touch the file if it already exists OR --apply was passed.
11164    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
11165    if let Some(home) = dirs::home_dir() {
11166        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
11167        // The mcpServers map lives at the top level of that file.
11168        targets.push(("Claude Code", home.join(".claude.json")));
11169        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
11170        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
11171        // Claude Desktop macOS
11172        #[cfg(target_os = "macos")]
11173        targets.push((
11174            "Claude Desktop (macOS)",
11175            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
11176        ));
11177        // Claude Desktop Windows
11178        #[cfg(target_os = "windows")]
11179        if let Ok(appdata) = std::env::var("APPDATA") {
11180            targets.push((
11181                "Claude Desktop (Windows)",
11182                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
11183            ));
11184        }
11185        // Cursor
11186        targets.push(("Cursor", home.join(".cursor/mcp.json")));
11187    }
11188    // Project-local — works for several MCP-aware tools
11189    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
11190
11191    println!("wire setup\n");
11192    println!("MCP server snippet (add this to your client's mcpServers):");
11193    println!();
11194    println!("{entry_pretty}");
11195    println!();
11196
11197    if !apply {
11198        println!("Probable MCP host config locations on this machine:");
11199        for (name, path) in &targets {
11200            let marker = if path.exists() {
11201                "✓ found"
11202            } else {
11203                "  (would create)"
11204            };
11205            println!("  {marker:14}  {name}: {}", path.display());
11206        }
11207        println!();
11208        println!("Run `wire setup --apply` to merge wire into each config above.");
11209        println!(
11210            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
11211        );
11212        return Ok(());
11213    }
11214
11215    let mut modified: Vec<String> = Vec::new();
11216    let mut skipped: Vec<String> = Vec::new();
11217    for (name, path) in &targets {
11218        match upsert_mcp_entry(path, "wire", &entry) {
11219            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
11220            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
11221            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
11222        }
11223    }
11224    if !modified.is_empty() {
11225        println!("Modified:");
11226        for line in &modified {
11227            println!("  {line}");
11228        }
11229        println!();
11230        println!("Restart the app(s) above to load wire MCP.");
11231    }
11232    if !skipped.is_empty() {
11233        println!();
11234        println!("Skipped:");
11235        for line in &skipped {
11236            println!("  {line}");
11237        }
11238    }
11239    Ok(())
11240}
11241
11242/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
11243/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
11244fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
11245    let mut cfg: Value = if path.exists() {
11246        let body = std::fs::read_to_string(path).context("reading config")?;
11247        serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
11248    } else {
11249        json!({})
11250    };
11251    if !cfg.is_object() {
11252        cfg = json!({});
11253    }
11254    let root = cfg.as_object_mut().unwrap();
11255    let servers = root
11256        .entry("mcpServers".to_string())
11257        .or_insert_with(|| json!({}));
11258    if !servers.is_object() {
11259        *servers = json!({});
11260    }
11261    let map = servers.as_object_mut().unwrap();
11262    if map.get(server_name) == Some(entry) {
11263        return Ok(false);
11264    }
11265    map.insert(server_name.to_string(), entry.clone());
11266    if let Some(parent) = path.parent()
11267        && !parent.as_os_str().is_empty()
11268    {
11269        std::fs::create_dir_all(parent).context("creating parent dir")?;
11270    }
11271    let out = serde_json::to_string_pretty(&cfg)? + "\n";
11272    std::fs::write(path, out).context("writing config")?;
11273    Ok(true)
11274}
11275
11276// ---------- setup --statusline ----------
11277
11278/// Bundled Claude Code statusLine renderer (persona emoji + nickname + cwd,
11279/// pidfile+tasklist liveness). Embedded at compile time; written to the
11280/// Claude config dir on `wire setup --statusline --apply`.
11281const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
11282
11283/// `wire setup --statusline [--apply] [--remove]` — install/remove a Claude
11284/// Code statusLine that renders this session's wire persona. Honors
11285/// `$CLAUDE_CONFIG_DIR` (default `~/.claude`). Writes the renderer script and
11286/// merges a `statusLine` block into settings.json, preserving existing keys
11287/// and refusing to clobber a settings.json that exists but isn't valid JSON.
11288fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
11289    use std::path::PathBuf;
11290    let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
11291        .map(PathBuf::from)
11292        .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
11293        .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
11294    let settings_path = cfg_dir.join("settings.json");
11295    let script_path = cfg_dir.join("wire-statusline.sh");
11296    // Resolve the shell invocation. On Windows a bare `bash` resolves to
11297    // System32\bash.exe (WSL) — wrong environment, Windows paths invalid,
11298    // statusline breaks — so we emit the absolute git-bash path. On Unix a
11299    // bare `bash <script>` is correct. Script path is quoted for spaces.
11300    let (command, command_warn) = statusline_command(&script_path);
11301
11302    println!("wire setup --statusline\n");
11303    println!("Claude config dir: {}", cfg_dir.display());
11304    println!("  renderer:  {}", script_path.display());
11305    println!("  settings:  {}", settings_path.display());
11306    if let Some(w) = &command_warn {
11307        println!("  ⚠ {w}");
11308    }
11309    println!();
11310
11311    if remove {
11312        if !apply {
11313            println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
11314            println!("Run `wire setup --statusline --remove --apply` to do it.");
11315            return Ok(());
11316        }
11317        let dropped = remove_statusline_entry(&settings_path)?;
11318        let script_gone = if script_path.exists() {
11319            std::fs::remove_file(&script_path).is_ok()
11320        } else {
11321            false
11322        };
11323        println!(
11324            "Removed: statusLine key {} · renderer {}",
11325            if dropped { "dropped" } else { "absent" },
11326            if script_gone { "deleted" } else { "absent" }
11327        );
11328        return Ok(());
11329    }
11330
11331    if !apply {
11332        println!("Would write the renderer above and merge into settings.json:");
11333        println!();
11334        println!("  \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
11335        println!();
11336        println!("Resulting statusline:  ● <emoji> <nickname> · <cwd>");
11337        println!("Run `wire setup --statusline --apply` to install.");
11338        println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
11339        return Ok(());
11340    }
11341
11342    if let Some(parent) = script_path.parent() {
11343        std::fs::create_dir_all(parent).context("creating Claude config dir")?;
11344    }
11345    std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
11346    #[cfg(unix)]
11347    {
11348        use std::os::unix::fs::PermissionsExt;
11349        if let Ok(meta) = std::fs::metadata(&script_path) {
11350            let mut perms = meta.permissions();
11351            perms.set_mode(0o755);
11352            let _ = std::fs::set_permissions(&script_path, perms);
11353        }
11354    }
11355    let changed = upsert_statusline_entry(&settings_path, &command)?;
11356    println!("✓ renderer written: {}", script_path.display());
11357    if changed {
11358        println!("✓ merged statusLine into: {}", settings_path.display());
11359    } else {
11360        println!(
11361            "  settings.json already configured: {}",
11362            settings_path.display()
11363        );
11364    }
11365    println!();
11366    println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
11367    Ok(())
11368}
11369
11370/// Merge a `statusLine` command block into a Claude settings.json, preserving
11371/// all other keys. Returns Ok(true) if changed. Refuses to clobber a file that
11372/// exists but is not valid JSON.
11373fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
11374    let mut cfg: Value = if path.exists() {
11375        let body = std::fs::read_to_string(path).context("reading settings.json")?;
11376        if body.trim().is_empty() {
11377            json!({})
11378        } else {
11379            serde_json::from_str(&body).context(
11380                "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
11381            )?
11382        }
11383    } else {
11384        json!({})
11385    };
11386    if !cfg.is_object() {
11387        bail!("settings.json root is not a JSON object — refusing to clobber");
11388    }
11389    let desired = json!({"type": "command", "command": command});
11390    let root = cfg.as_object_mut().unwrap();
11391    if root.get("statusLine") == Some(&desired) {
11392        return Ok(false);
11393    }
11394    root.insert("statusLine".to_string(), desired);
11395    if let Some(parent) = path.parent()
11396        && !parent.as_os_str().is_empty()
11397    {
11398        std::fs::create_dir_all(parent).context("creating parent dir")?;
11399    }
11400    let out = serde_json::to_string_pretty(&cfg)? + "\n";
11401    std::fs::write(path, out).context("writing settings.json")?;
11402    Ok(true)
11403}
11404
11405/// Drop the `statusLine` key from settings.json. Ok(true) if a key was removed,
11406/// Ok(false) if file/key absent. Refuses to edit invalid JSON.
11407fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
11408    if !path.exists() {
11409        return Ok(false);
11410    }
11411    let body = std::fs::read_to_string(path).context("reading settings.json")?;
11412    if body.trim().is_empty() {
11413        return Ok(false);
11414    }
11415    let mut cfg: Value = serde_json::from_str(&body)
11416        .context("settings.json is not valid JSON — refusing to edit")?;
11417    let Some(root) = cfg.as_object_mut() else {
11418        return Ok(false);
11419    };
11420    if root.remove("statusLine").is_none() {
11421        return Ok(false);
11422    }
11423    let out = serde_json::to_string_pretty(&cfg)? + "\n";
11424    std::fs::write(path, out).context("writing settings.json")?;
11425    Ok(true)
11426}
11427
11428/// Build the `statusLine.command` string for this platform. Returns the
11429/// command plus an optional warning to surface to the operator.
11430fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
11431    #[cfg(windows)]
11432    {
11433        match resolve_git_bash() {
11434            Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
11435            None => (
11436                format!("bash \"{}\"", script_path.display()),
11437                Some(
11438                    "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
11439                     WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
11440                     Windows or set statusLine.command to your git-bash bash.exe path."
11441                        .to_string(),
11442                ),
11443            ),
11444        }
11445    }
11446    #[cfg(unix)]
11447    {
11448        (format!("bash \"{}\"", script_path.display()), None)
11449    }
11450}
11451
11452/// Locate the git-bash `bash.exe` on Windows, avoiding the WSL launcher at
11453/// `System32\bash.exe`. Claude Code's statusLine command needs the real
11454/// git-bash so the renderer runs in a POSIX-ish env with valid paths.
11455#[cfg(windows)]
11456fn resolve_git_bash() -> Option<String> {
11457    use std::path::PathBuf;
11458    // 1. `where.exe bash` — take the first hit that is NOT under System32
11459    //    (that one is the WSL `bash.exe` launcher).
11460    if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
11461        && out.status.success()
11462    {
11463        for line in String::from_utf8_lossy(&out.stdout).lines() {
11464            let p = line.trim();
11465            if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
11466                return Some(p.to_string());
11467            }
11468        }
11469    }
11470    // 2. Common Git-for-Windows install locations.
11471    let candidates = [
11472        std::env::var("ProgramFiles")
11473            .ok()
11474            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
11475        std::env::var("ProgramFiles(x86)")
11476            .ok()
11477            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
11478        std::env::var("LocalAppData")
11479            .ok()
11480            .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
11481    ];
11482    candidates
11483        .into_iter()
11484        .flatten()
11485        .find(|c| PathBuf::from(c).exists())
11486}
11487
11488#[cfg(test)]
11489mod statusline_tests {
11490    use super::*;
11491
11492    #[test]
11493    fn statusline_merge_preserves_keys_and_is_idempotent() {
11494        let dir = tempfile::tempdir().unwrap();
11495        let path = dir.path().join("settings.json");
11496        std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
11497        // First merge changes the file but keeps existing keys.
11498        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
11499        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11500        assert_eq!(v["theme"], "dark");
11501        assert_eq!(v["model"], "opus");
11502        assert_eq!(v["statusLine"]["type"], "command");
11503        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
11504        // Identical re-merge = no change.
11505        assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
11506        // Remove drops ONLY statusLine.
11507        assert!(remove_statusline_entry(&path).unwrap());
11508        let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11509        assert_eq!(v2["theme"], "dark");
11510        assert!(v2.get("statusLine").is_none());
11511        // Remove again = no-op.
11512        assert!(!remove_statusline_entry(&path).unwrap());
11513    }
11514
11515    #[test]
11516    fn statusline_merge_refuses_to_clobber_invalid_json() {
11517        let dir = tempfile::tempdir().unwrap();
11518        let path = dir.path().join("settings.json");
11519        std::fs::write(&path, "this is not json {").unwrap();
11520        let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
11521        assert!(
11522            format!("{err:#}").contains("not valid JSON"),
11523            "err: {err:#}"
11524        );
11525        // File left untouched.
11526        assert_eq!(
11527            std::fs::read_to_string(&path).unwrap(),
11528            "this is not json {"
11529        );
11530    }
11531
11532    #[test]
11533    fn statusline_creates_settings_when_absent() {
11534        let dir = tempfile::tempdir().unwrap();
11535        let path = dir.path().join("settings.json");
11536        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
11537        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11538        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
11539    }
11540}
11541
11542// ---------- notify (Goal 2) ----------
11543
11544fn cmd_notify(
11545    interval_secs: u64,
11546    peer_filter: Option<&str>,
11547    once: bool,
11548    as_json: bool,
11549) -> Result<()> {
11550    use crate::inbox_watch::InboxWatcher;
11551    let cursor_path = config::state_dir()?.join("notify.cursor");
11552    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11553
11554    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
11555        let events = watcher.poll()?;
11556        for ev in events {
11557            if let Some(p) = peer_filter
11558                && ev.peer != p
11559            {
11560                continue;
11561            }
11562            if as_json {
11563                println!("{}", serde_json::to_string(&ev)?);
11564            } else {
11565                os_notify_inbox_event(&ev);
11566            }
11567        }
11568        watcher.save_cursors(&cursor_path)?;
11569        Ok(())
11570    };
11571
11572    if once {
11573        return sweep(&mut watcher);
11574    }
11575
11576    let interval = std::time::Duration::from_secs(interval_secs.max(1));
11577    loop {
11578        if let Err(e) = sweep(&mut watcher) {
11579            eprintln!("wire notify: sweep error: {e}");
11580        }
11581        std::thread::sleep(interval);
11582    }
11583}
11584
11585fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
11586    let who = persona_label(&ev.peer);
11587    let title = if ev.verified {
11588        format!("wire ← {who}")
11589    } else {
11590        format!("wire ← {who} (UNVERIFIED)")
11591    };
11592    let body = format!("{}: {}", ev.kind, ev.body_preview);
11593    crate::os_notify::toast(&title, &body);
11594}
11595
11596#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
11597fn os_toast(title: &str, body: &str) {
11598    eprintln!("[wire notify] {title}\n  {body}");
11599}
11600
11601// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).