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    ///
192    /// Defaults to NEWEST-N orientation: with `--limit N`, prints the most
193    /// recent N events across all matched peers, sorted chronologically
194    /// (oldest of the window first, newest last — same orientation as Unix
195    /// `tail`). Pass `--oldest` to flip back to first-N (FIFO) behaviour.
196    /// `--limit 0` returns the full inbox in chronological order.
197    Tail {
198        /// Optional peer filter; if omitted, tails all peers.
199        peer: Option<String>,
200        /// Emit JSONL (one event per line).
201        #[arg(long)]
202        json: bool,
203        /// Maximum events to print. 0 = print everything (oldest → newest).
204        #[arg(long, default_value_t = 0)]
205        limit: usize,
206        /// Return the FIRST `--limit` events (oldest-N) instead of the
207        /// default last-N (newest-N). No effect when `--limit` is 0.
208        #[arg(long)]
209        oldest: bool,
210    },
211    /// Live tail of new inbox events across all pinned peers — one line per
212    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
213    /// by default.
214    ///
215    /// Designed to be left running in an agent harness's stream-watcher
216    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
217    /// session as they arrive, not on next manual `wire pull`.
218    ///
219    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
220    /// template.
221    Monitor {
222        /// Only show events from this peer.
223        #[arg(long)]
224        peer: Option<String>,
225        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
226        #[arg(long)]
227        json: bool,
228        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
229        /// Default filters them out as noise.
230        #[arg(long)]
231        include_handshake: bool,
232        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
233        #[arg(long, default_value_t = 500)]
234        interval_ms: u64,
235        /// Replay last N events from history before going live (0 = none).
236        #[arg(long, default_value_t = 0)]
237        replay: usize,
238    },
239    /// Verify a signed event from a JSON file or stdin (`-`).
240    Verify {
241        /// Path to event JSON, or `-` for stdin.
242        path: String,
243        /// Emit JSON.
244        #[arg(long)]
245        json: bool,
246    },
247    /// Run the MCP (Model Context Protocol) server over stdio.
248    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
249    /// `wire_send`, `wire_tail`, etc. as native tools.
250    Mcp,
251    /// Run a relay server on this host.
252    RelayServer {
253        /// Bind address (e.g. `127.0.0.1:8770`).
254        #[arg(long, default_value = "127.0.0.1:8770")]
255        bind: String,
256        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
257        /// skip `.well-known/wire/agent` serving. The relay becomes
258        /// invisible from outside the box — only same-machine processes
259        /// can pair through it. Right call for within-machine agent
260        /// coordination where you don't want metadata leaking to a
261        /// public relay. Pair this with `wire session new` which probes
262        /// `127.0.0.1:8771` and allocates a local slot automatically.
263        #[arg(long)]
264        local_only: bool,
265        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
266        /// When set, --bind is ignored. Implies --local-only semantics
267        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
268        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
269        /// sister sessions. Unix only (Windows refuses).
270        #[arg(long)]
271        uds: Option<std::path::PathBuf>,
272    },
273    /// Allocate a slot on a relay; bind it to this agent's identity.
274    ///
275    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
276    /// current slot, this command refuses by default — silent migration
277    /// silently black-holes their inbound messages. Pass
278    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
279    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
280    /// for safe rotation.
281    BindRelay {
282        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
283        url: String,
284        /// Endpoint scope: `federation` | `local` | `lan` | `uds`.
285        /// Default inferred from the URL (loopback host -> local,
286        /// `unix://` -> uds, otherwise federation). Pass explicitly when
287        /// the inference is ambiguous (e.g. a federation relay on a
288        /// loopback address in tests).
289        #[arg(long)]
290        scope: Option<String>,
291        /// DESTRUCTIVE: drop all existing self slots and bind only this
292        /// relay (the pre-v0.12 single-slot behavior). Default is
293        /// ADDITIVE — the new slot is appended to `self.endpoints[]`,
294        /// keeping any existing slots so pinned peers are not
295        /// black-holed.
296        #[arg(long)]
297        replace: bool,
298        /// Acknowledge that pinned peers will black-hole until they
299        /// re-pin manually. Required for `--replace` (and same-relay
300        /// rotation) when `state.peers` is non-empty; ignored on fresh
301        /// boxes. Use `wire rotate-slot` instead for the supported
302        /// same-relay rotation path.
303        #[arg(long)]
304        migrate_pinned: bool,
305        #[arg(long)]
306        json: bool,
307    },
308    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
309    /// real `wire join` lands in the SPAKE2 iter.)
310    AddPeerSlot {
311        /// Peer handle (becomes did:wire:<handle>).
312        handle: String,
313        /// Peer's relay base URL.
314        url: String,
315        /// Peer's slot id.
316        slot_id: String,
317        /// Slot bearer token (shared between paired peers in v0.1).
318        slot_token: String,
319        #[arg(long)]
320        json: bool,
321    },
322    /// Drain outbox JSONL files to peers' relay slots.
323    Push {
324        /// Optional peer filter; default = all peers with outbox entries.
325        peer: Option<String>,
326        #[arg(long)]
327        json: bool,
328    },
329    /// Pull events from our relay slot, verify, write to inbox.
330    Pull {
331        #[arg(long)]
332        json: bool,
333    },
334    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
335    /// Useful as a single "where am I" check.
336    Status {
337        /// Inspect a paired peer's transport / attention / responder health.
338        #[arg(long)]
339        peer: Option<String>,
340        #[arg(long)]
341        json: bool,
342    },
343    /// Publish or inspect auto-responder health for this slot.
344    Responder {
345        #[command(subcommand)]
346        command: ResponderCommand,
347    },
348    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
349    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
350    Pin {
351        /// Path to peer's signed agent-card JSON.
352        card_file: String,
353        #[arg(long)]
354        json: bool,
355    },
356    /// Allocate a NEW slot on the same relay and abandon the old one.
357    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
358    /// slot announcing the new mailbox before swapping. After rotation,
359    /// peers must re-pair (or operator runs `add-peer-slot` with the new
360    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
361    ///
362    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
363    /// abusive bearer-holder spamming your slot). Rotate → old slot is
364    /// orphaned → attacker's leverage gone. Operator pairs again with
365    /// peers they still want.
366    RotateSlot {
367        /// Skip the wire_close announcement to peers (faster but they won't know
368        /// where you went).
369        #[arg(long)]
370        no_announce: bool,
371        #[arg(long)]
372        json: bool,
373    },
374    /// Remove a peer from trust + relay state. Inbox/outbox files for that
375    /// peer are NOT deleted (operator can grep history); pass --purge to
376    /// also wipe the JSONL files.
377    ForgetPeer {
378        /// Peer handle to forget.
379        handle: String,
380        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
381        #[arg(long)]
382        purge: bool,
383        #[arg(long)]
384        json: bool,
385    },
386    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
387    /// peers' relay slots and pull inbox from our own slot. Foreground process;
388    /// background it with systemd / `&` / tmux as you prefer.
389    Daemon {
390        /// Sync interval in seconds. Default 5.
391        #[arg(long, default_value_t = 5)]
392        interval: u64,
393        /// Run a single sync cycle and exit (useful for cron-driven setups).
394        #[arg(long)]
395        once: bool,
396        #[arg(long)]
397        json: bool,
398    },
399    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
400    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
401    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
402    /// read the SAS digits aloud and confirm.)
403    #[command(hide = true)] // v0.9 deprecated
404    PairHost {
405        /// Relay base URL.
406        #[arg(long)]
407        relay: String,
408        /// Skip the SAS confirmation prompt. ONLY use when piping under
409        /// automated tests or when the SAS has already been verified by
410        /// another channel. Documented as test-only.
411        #[arg(long)]
412        yes: bool,
413        /// How long (seconds) to wait for the peer to join before timing out.
414        #[arg(long, default_value_t = 300)]
415        timeout: u64,
416        /// Detach: write a pending-pair file, print the code phrase, and exit
417        /// immediately. The running `wire daemon` does the handshake in the
418        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
419        /// `wire pair-list` shows pending sessions. Default is foreground
420        /// blocking behavior for backward compat.
421        #[arg(long)]
422        detach: bool,
423        /// Emit JSON instead of text. Currently only meaningful with --detach.
424        #[arg(long)]
425        json: bool,
426    },
427    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
428    ///
429    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
430    #[command(alias = "join")]
431    #[command(hide = true)] // v0.9 deprecated
432    PairJoin {
433        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
434        code_phrase: String,
435        /// Relay base URL (must match the host's relay).
436        #[arg(long)]
437        relay: String,
438        #[arg(long)]
439        yes: bool,
440        #[arg(long, default_value_t = 300)]
441        timeout: u64,
442        /// Detach: see `pair-host --detach`.
443        #[arg(long)]
444        detach: bool,
445        /// Emit JSON instead of text. Currently only meaningful with --detach.
446        #[arg(long)]
447        json: bool,
448    },
449    /// Confirm SAS digits for a detached pending pair. The daemon must be
450    /// running for this to do anything — it picks up the confirmation on its
451    /// next tick. Mismatch aborts the pair.
452    #[command(hide = true)] // v0.9 deprecated
453    PairConfirm {
454        /// The code phrase the original `wire pair-host --detach` printed.
455        code_phrase: String,
456        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
457        digits: String,
458        /// Emit JSON instead of human-readable text.
459        #[arg(long)]
460        json: bool,
461    },
462    /// List all pending detached pair sessions and their state.
463    #[command(hide = true)] // v0.9 deprecated
464    PairList {
465        /// Emit JSON instead of the table.
466        #[arg(long)]
467        json: bool,
468        /// Stream mode: never exit; print one JSON line per status transition
469        /// (creation, status change, deletion) across all pending pairs.
470        /// Compose with bash `while read` to react in shell. Implies --json.
471        #[arg(long)]
472        watch: bool,
473        /// Poll interval in seconds for --watch.
474        #[arg(long, default_value_t = 1)]
475        watch_interval: u64,
476    },
477    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
478    #[command(hide = true)] // v0.9 deprecated
479    PairCancel {
480        code_phrase: String,
481        #[arg(long)]
482        json: bool,
483    },
484    /// Block until a pending pair reaches a target status (default sas_ready),
485    /// or terminates (finalized = file removed, aborted, aborted_restart), or
486    /// the timeout expires. Useful for shell scripts that want to drive the
487    /// detached flow without polling pair-list themselves.
488    ///
489    /// Exit codes:
490    ///   0 — reached target status (or finalized, if target was sas_ready)
491    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
492    ///   2 — timeout
493    #[command(hide = true)] // v0.9 deprecated
494    PairWatch {
495        code_phrase: String,
496        /// Target status to wait for. Default: sas_ready.
497        #[arg(long, default_value = "sas_ready")]
498        status: String,
499        /// Max seconds to wait.
500        #[arg(long, default_value_t = 300)]
501        timeout: u64,
502        /// Emit JSON on each status change (one per line) instead of just on exit.
503        #[arg(long)]
504        json: bool,
505    },
506    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
507    /// pair-join, then registers wire as an MCP server. Single command from
508    /// nothing to paired and ready — no separate init/pair-host/setup steps.
509    /// Operator still must confirm SAS digits.
510    ///
511    /// Examples:
512    ///   wire pair paul                          # host a new pair on default relay
513    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
514    ///
515    /// v0.10: hidden from --help. Federation pair flow is now
516    /// `wire dial <handle>@<relay>` + `wire accept-invite <URL>`.
517    /// `wire pair` stays callable for back-compat scripts; v1.0 removes.
518    #[command(hide = true)] // v0.10 deprecated — use `wire dial <h>@<relay>`
519    Pair {
520        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
521        /// step if no identity exists; ignored if already initialized.
522        handle: String,
523        /// Code phrase from peer's pair-host output. Omit to be the host
524        /// (this command will print one for you to share).
525        #[arg(long)]
526        code: Option<String>,
527        /// Relay base URL. Defaults to the laulpogan public-good relay.
528        #[arg(long, default_value = "https://wireup.net")]
529        relay: String,
530        /// Skip SAS prompt. Test-only.
531        #[arg(long)]
532        yes: bool,
533        /// Pair-step timeout in seconds.
534        #[arg(long, default_value_t = 300)]
535        timeout: u64,
536        /// Skip the post-pair `setup --apply` step (don't register wire as
537        /// an MCP server in detected client configs).
538        #[arg(long)]
539        no_setup: bool,
540        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
541        /// exits immediately, daemon does the handshake). Confirm via
542        /// `wire pair-confirm <code> <digits>` from any terminal. See
543        /// `pair-host --detach` for details.
544        #[arg(long)]
545        detach: bool,
546    },
547    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
548    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
549    /// confirmation, leaving the relay-side slot stuck with "guest already
550    /// registered" or "host already registered" until the 5-minute TTL expires.
551    /// Either side can call. Idempotent.
552    #[command(hide = true)] // v0.9 deprecated
553    PairAbandon {
554        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
555        code_phrase: String,
556        /// Relay base URL.
557        #[arg(long, default_value = "https://wireup.net")]
558        relay: String,
559    },
560    /// Accept a pending-inbound pair request (v0.5.14). Explicit alias for
561    /// the bilateral-completion path that `wire add <peer>@<relay>` also
562    /// drives — but doesn't require remembering the peer's relay domain
563    /// (the relay coords come from the stored pair_drop). Errors if no
564    /// pending-inbound record exists for that peer.
565    #[command(hide = true)] // v0.9 deprecated
566    PairAccept {
567        /// Bare peer handle (without `@<relay>`).
568        peer: String,
569        /// Emit JSON.
570        #[arg(long)]
571        json: bool,
572    },
573    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
574    /// you@<your-relay>` against your handle, their signed pair_drop lands
575    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
576    /// <peer>` to delete the record without pairing. The peer never receives
577    /// our slot_token; from their side the pair stays pending until they
578    /// time out.
579    #[command(hide = true)] // v0.9 deprecated
580    PairReject {
581        /// Bare peer handle (without `@<relay>`).
582        peer: String,
583        /// Emit JSON.
584        #[arg(long)]
585        json: bool,
586    },
587    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
588    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
589    /// `pair-list --json` shape but for inbound). Use this in scripts that
590    /// need to enumerate inbound pair requests without parsing the SPAKE2
591    /// table format from `wire pair-list`.
592    #[command(hide = true)] // v0.9 deprecated
593    PairListInbound {
594        /// Emit JSON.
595        #[arg(long)]
596        json: bool,
597    },
598    /// Manage isolated wire sessions on this machine (v0.5.16).
599    ///
600    /// Each session = its own DID + handle + relay slot + daemon + inbox/
601    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
602    /// in different projects) run on the same machine — without sessions
603    /// they all share one identity and race the inbox cursor.
604    ///
605    /// Names are derived from `basename(cwd)` and cached in a registry,
606    /// so re-entering the same project reuses the same identity.
607    #[command(subcommand)]
608    Session(SessionCommand),
609    /// Manage this session's identity display layer (character override).
610    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
611    /// itself picks a custom nickname + emoji that overrides the
612    /// auto-derived hash-based defaults.
613    Identity {
614        #[command(subcommand)]
615        cmd: IdentityCommand,
616    },
617    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
618    /// sister-session mesh. `wire mesh status` is the live view of every
619    /// paired sister (alias for `wire session mesh-status`); `wire mesh
620    /// broadcast` fans one signed event to every pinned peer.
621    #[command(subcommand)]
622    Mesh(MeshCommand),
623    /// Group chat (v0.13.3): create a named group, add VERIFIED peers, and
624    /// send/tail messages across the whole member set. Membership is a signed
625    /// roster (group-scoped tiers, separate from bilateral peer trust).
626    #[command(subcommand)]
627    Group(GroupCommand),
628    /// Mint operator / organization identities for the offline org-membership
629    /// layer (RFC-001): `wire enroll op` / `org-create` / `org-add-member`.
630    #[command(subcommand)]
631    Enroll(EnrollCommand),
632    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
633    /// Cursor, project-local) and either print or auto-merge the wire MCP
634    /// server entry. Default prints; pass `--apply` to actually modify config
635    /// files. Idempotent — re-running is safe.
636    Setup {
637        /// Actually write the changes (default = print only).
638        #[arg(long)]
639        apply: bool,
640        /// Install a Claude Code statusLine showing your wire persona
641        /// (liveness dot + emoji + nickname in the persona's accent color +
642        /// cwd) instead of merging the MCP server. Writes a renderer script
643        /// and merges a `statusLine` block into Claude Code's settings.json
644        /// (honors $CLAUDE_CONFIG_DIR). Combine with --apply to write.
645        #[arg(long)]
646        statusline: bool,
647        /// With --statusline: uninstall it (drop the statusLine key + remove
648        /// the renderer script) instead of installing.
649        #[arg(long)]
650        remove: bool,
651    },
652    /// Show an agent's profile. With no arg, prints local self. With a
653    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
654    /// endpoint and verifies the returned signed card before display.
655    Whois {
656        /// Optional handle (`nick@domain`). Omit to show self.
657        handle: Option<String>,
658        #[arg(long)]
659        json: bool,
660        /// Override the relay base URL used for resolution (default:
661        /// `https://<domain>` from the handle).
662        #[arg(long)]
663        relay: Option<String>,
664    },
665    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
666    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
667    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
668    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
669    /// their slot_token so we can `wire send` to them).
670    Add {
671        /// Peer handle (`nick@domain`), OR a bare sister-session name
672        /// when `--local-sister` is set.
673        handle: String,
674        /// Override the relay base URL used for resolution.
675        #[arg(long)]
676        relay: Option<String>,
677        /// v0.6.6: pair with a sister session on this machine without
678        /// touching federation. Looks up `handle` as a session name in
679        /// `wire session list`, reads that session's agent-card +
680        /// endpoints from disk, pins directly, then delivers the
681        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
682        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
683        /// addressable because they don't need a federation claim.
684        #[arg(long)]
685        local_sister: bool,
686        #[arg(long)]
687        json: bool,
688    },
689    /// Come online in one command — `wire up` does what used to take five
690    /// (init + bind-relay + claim your persona + background daemon +
691    /// restart-on-login). Idempotent: re-run on an already-set-up box prints
692    /// state without churn.
693    ///
694    /// There is no name to choose: your handle IS your DID-derived persona
695    /// (one-name rule). The optional argument is just which relay to use.
696    ///
697    /// Examples:
698    ///   wire up                        # default public relay (wireup.net)
699    ///   wire up @wireup.net            # explicit federation relay
700    ///   wire up http://127.0.0.1:8771  # a local / self-hosted relay
701    Up {
702        /// Relay to bind + claim your persona on: `@wireup.net`, `wireup.net`,
703        /// or a full URL. Omit for the default public relay. No nick — your
704        /// handle is your DID-derived persona.
705        relay: Option<String>,
706        /// Optional display name for your profile card (cosmetic; distinct
707        /// from your addressable handle/persona).
708        #[arg(long)]
709        name: Option<String>,
710        /// Also additively dual-bind a LOCAL relay slot for fast same-box
711        /// sister-session routing. Defaults to probing
712        /// `http://127.0.0.1:8771`; pass a URL to override. Local relays
713        /// carry no handle directory, so nothing is claimed there.
714        #[arg(long)]
715        with_local: Option<String>,
716        /// Skip the opportunistic local dual-bind entirely.
717        #[arg(long)]
718        no_local: bool,
719        #[arg(long)]
720        json: bool,
721    },
722    /// Diagnose wire setup health. Single command that surfaces every
723    /// silent-fail class — daemon down or duplicated, relay unreachable,
724    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
725    /// Replaces today's 30-minute manual debug.
726    ///
727    /// Exit code non-zero if any FAIL findings.
728    Doctor {
729        /// Emit JSON.
730        #[arg(long)]
731        json: bool,
732        /// Show last N entries from pair-rejected.jsonl in the report.
733        #[arg(long, default_value_t = 5)]
734        recent_rejections: usize,
735    },
736    /// Update + restart in one step (alias: `wire update`). ALWAYS checks
737    /// crates.io for a newer published wire; if one exists it installs it
738    /// (via `cargo install slancha-wire` when a Rust toolchain is on PATH,
739    /// else by downloading + SHA-256-verifying the prebuilt release binary
740    /// and replacing this one in place), then does the atomic daemon swap —
741    /// kill every `wire daemon`, respawn from the (now-current) binary, write
742    /// a fresh pidfile. No newer version → it skips the install and just
743    /// restarts the daemon. `--check` reports what would happen (available
744    /// update + processes that would be restarted) without doing it;
745    /// `--local` skips the crates.io check and only restarts the daemon
746    /// (offline, or running a local dev build).
747    #[command(visible_alias = "update")]
748    Upgrade {
749        /// Report current vs latest + drift without taking action.
750        #[arg(long)]
751        check: bool,
752        /// Skip the crates.io update check; just restart the daemon from the
753        /// current binary (offline / local dev build).
754        #[arg(long)]
755        local: bool,
756        #[arg(long)]
757        json: bool,
758    },
759    /// Install / inspect / remove a launchd plist (macOS) or systemd
760    /// user unit (linux) that runs `wire daemon` on login + restarts
761    /// on crash. Replaces today's "background it with tmux/&/systemd
762    /// as you prefer" footgun.
763    Service {
764        #[command(subcommand)]
765        action: ServiceAction,
766    },
767    /// Inspect or toggle the structured diagnostic trace
768    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
769    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
770    /// (writes the file knob a running daemon picks up automatically).
771    Diag {
772        #[command(subcommand)]
773        action: DiagAction,
774    },
775    /// Claim your persona on a relay's handle directory. Anyone can then
776    /// reach this agent by `<persona>@<relay-domain>` via the relay's
777    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
778    ///
779    /// ONE-NAME RULE (v0.13.1): the claimed handle is always your DID-derived
780    /// persona. The `nick` arg is vestigial — if it differs it is ignored
781    /// (like the typed name `wire init` / `wire up` already ignore), so your
782    /// phonebook entry can never drift from your agent-card handle.
783    ///
784    /// v0.13.1: hidden — `wire up` claims your persona for you. Kept callable
785    /// (idempotent re-claim) but not a user verb; there is no nick to choose.
786    #[command(hide = true)]
787    Claim {
788        /// Vestigial: ignored if it differs from your DID-derived persona.
789        nick: String,
790        /// Relay to claim the nick on. Default = relay our slot is on.
791        #[arg(long)]
792        relay: Option<String>,
793        /// Public URL the relay should advertise to resolvers (default = relay).
794        #[arg(long)]
795        public_url: Option<String>,
796        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
797        /// directory listing. The handle stays claimed (FCFS still
798        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
799        /// still resolves, so peers you share the handle with out-of-band
800        /// can still pair. Bulk scrapers / phonebook crawlers will not
801        /// see the nick. Use this for handles meant for known-peer
802        /// pairing only — see issue #9.
803        #[arg(long)]
804        hidden: bool,
805        #[arg(long)]
806        json: bool,
807    },
808    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
809    /// avatar_url, handle, now). Re-signs the agent-card atomically.
810    ///
811    /// Examples:
812    ///   wire profile set motto "compiles or dies trying"
813    ///   wire profile set emoji "🦀"
814    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
815    ///   wire profile set handle "coffee-ghost@anthropic.dev"
816    ///   wire profile get
817    Profile {
818        #[command(subcommand)]
819        action: ProfileAction,
820    },
821    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
822    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
823    /// a relay slot on first use. Default TTL 24h, single-use.
824    #[command(hide = true)] // v0.9 deprecated
825    Invite {
826        /// Override the relay URL for first-time auto-allocation.
827        #[arg(long, default_value = "https://wireup.net")]
828        relay: String,
829        /// Invite lifetime in seconds (default 86400 = 24h).
830        #[arg(long, default_value_t = 86_400)]
831        ttl: u64,
832        /// Number of distinct peers that can accept this invite before it's
833        /// consumed (default 1).
834        #[arg(long, default_value_t = 1)]
835        uses: u32,
836        /// Register the invite at the relay's short-URL endpoint and print
837        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
838        /// Installs wire if missing, then accepts the invite, then pairs.
839        #[arg(long)]
840        share: bool,
841        /// Emit JSON.
842        #[arg(long)]
843        json: bool,
844    },
845    /// v0.9: accept a pending-inbound pair request by character
846    /// nickname or card handle. Replaces the verbose `wire pair-accept
847    /// <peer>`.
848    ///
849    /// v0.9.4: the URL-vs-name smart-dispatch from v0.9 is gone. To
850    /// accept a federation invite URL use `wire accept-invite <URL>`
851    /// (split out as an explicit verb to eliminate the input-shape
852    /// ambiguity). `wire accept <URL>` still works for back-compat
853    /// but emits a deprecation banner pointing at `accept-invite`.
854    Accept {
855        /// Pending peer name (character nickname or card handle).
856        target: String,
857        /// Emit JSON.
858        #[arg(long)]
859        json: bool,
860    },
861    /// v0.9.4: accept a federation invite URL minted by `wire invite`.
862    /// Pins issuer, sends signed card to issuer's slot. Auto-inits +
863    /// auto-allocates as needed.
864    ///
865    /// Split out from `wire accept` to eliminate the URL-vs-name
866    /// smart-dispatch ambiguity (peer handles can legitimately collide
867    /// with URL-shaped strings; the explicit verb removes the inference).
868    #[command(alias = "invite-accept")]
869    AcceptInvite {
870        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
871        url: String,
872        /// Emit JSON.
873        #[arg(long)]
874        json: bool,
875    },
876    /// v0.9: refuse a pending-inbound pair request without pairing. Aliases
877    /// the legacy `wire pair-reject <peer>`.
878    Reject {
879        /// Peer name (character nickname or handle) from `wire pending`.
880        peer: String,
881        /// Emit JSON.
882        #[arg(long)]
883        json: bool,
884    },
885    /// Watch the inbox for new verified events and fire an OS notification per
886    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
887    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
888    /// re-emit history.
889    Notify {
890        /// Poll interval in seconds.
891        #[arg(long, default_value_t = 2)]
892        interval: u64,
893        /// Only notify for events from this peer (handle, no did: prefix).
894        #[arg(long)]
895        peer: Option<String>,
896        /// Run a single sweep and exit (useful for cron / tests).
897        #[arg(long)]
898        once: bool,
899        /// Suppress the OS notification call; print one JSON line per event to
900        /// stdout instead (for piping into other tooling or smoke-testing
901        /// without a desktop session).
902        #[arg(long)]
903        json: bool,
904    },
905    /// Silence (or re-enable) all wire desktop toasts. Persistent across
906    /// daemon restarts via a file at `<config_dir>/quiet`. `wire quiet on`
907    /// = silence; `wire quiet off` = restore; `wire quiet status` = report.
908    /// Same effect as exporting `WIRE_NO_TOASTS=1` (the env-var override
909    /// is for launchd contexts where the daemon's env isn't writable from
910    /// the operator's shell).
911    Quiet {
912        #[command(subcommand)]
913        action: QuietAction,
914    },
915}
916
917#[derive(Subcommand, Debug)]
918pub enum QuietAction {
919    /// Touch `<config_dir>/quiet` — silences every wire desktop toast
920    /// (pair_drop, pending_pair, monitor, inbox). Idempotent.
921    On,
922    /// Remove `<config_dir>/quiet` — re-enables toasts. Idempotent (no
923    /// error if already off / file absent).
924    Off,
925    /// Report current state: `on` (file present) / `off` (file absent) /
926    /// `forced-on-by-env` (`WIRE_NO_TOASTS=1` in env, overrides file).
927    Status {
928        /// Emit `{"state": "...", "via": "file"|"env"|"none"}` JSON
929        /// instead of the human one-liner.
930        #[arg(long)]
931        json: bool,
932    },
933}
934
935#[derive(Subcommand, Debug)]
936pub enum DiagAction {
937    /// Tail the last N entries from diag.jsonl.
938    Tail {
939        #[arg(long, default_value_t = 20)]
940        limit: usize,
941        #[arg(long)]
942        json: bool,
943    },
944    /// Flip the file-based knob ON. Running daemons pick this up on
945    /// the next emit call without restart.
946    Enable,
947    /// Flip the file-based knob OFF.
948    Disable,
949    /// Report whether diag is currently enabled + the file's size.
950    Status {
951        #[arg(long)]
952        json: bool,
953    },
954}
955
956/// `wire enroll …` — mint the operator/org identities + certs the offline
957/// org-membership layer (RFC-001) consumes. Keys are stored 0600 alongside
958/// `private.key`. (Publishing these claims on the agent's own card — the
959/// card-emit integration — is a separate follow-up.)
960#[derive(Subcommand, Debug)]
961pub enum EnrollCommand {
962    /// Mint this machine's operator root key (`op.key`) and print its `op_did`.
963    Op {
964        /// Operator handle (display only; the op_did commits to the key).
965        #[arg(long, default_value = "operator")]
966        handle: String,
967        #[arg(long)]
968        json: bool,
969    },
970    /// Mint an organization root key and print its `org_did` + `org_pubkey`.
971    OrgCreate {
972        /// Org handle (display only; the org_did commits to the key).
973        #[arg(long)]
974        handle: String,
975        #[arg(long)]
976        json: bool,
977    },
978    /// Issue a membership cert: the named org signs an operator's `op_did`.
979    /// Prints the `{org_did, org_pubkey, member_cert}` bundle for the operator
980    /// to add to their card's `org_memberships[]`.
981    OrgAddMember {
982        /// The operator DID to vouch for (`did:wire:op:…`).
983        op_did: String,
984        /// Which org signs (its `org_did`).
985        #[arg(long)]
986        org: String,
987        #[arg(long)]
988        json: bool,
989    },
990    /// Rebuild the agent card with the **current** enrollment state and
991    /// republish to the phonebook. Closes the enroll-after-`init` DX gap:
992    /// claims are normally attached at card-build time, but an operator who
993    /// enrolls AFTER `init` has a stored card that pre-dates the claims. Run
994    /// this once after `wire enroll op` / `org-add-member` to surface them.
995    /// Idempotent: not-enrolled rebuilds a claims-free card; not-bound prints
996    /// "local only".
997    Republish {
998        #[arg(long)]
999        json: bool,
1000    },
1001}
1002
1003#[derive(Subcommand, Debug)]
1004pub enum IdentityCommand {
1005    /// Print the current character (DID-derived, the only name).
1006    /// Equivalent to `wire whoami --short` but scoped here for grouping.
1007    Show {
1008        #[arg(long)]
1009        json: bool,
1010    },
1011    /// List all identities on this machine — one row per session, with
1012    /// each session's character, DID, federation handle, and cwd. Same
1013    /// shape as `wire session list`, scoped here for the v0.7+ noun-
1014    /// CLI surface.
1015    List {
1016        #[arg(long)]
1017        json: bool,
1018    },
1019    /// Promote this identity to FEDERATION lifecycle: claim your persona on
1020    /// the relay so peers can `wire dial <persona>@<relay-domain>` you.
1021    /// Re-claims with current display fields so the relay always serves the
1022    /// latest signed card. Equivalent to `wire claim`.
1023    ///
1024    /// v0.13.1: hidden — `wire up` publishes your persona for you, and the
1025    /// nick is vestigial (one-name rule). Kept callable for re-publish.
1026    #[command(hide = true)]
1027    Publish {
1028        /// Vestigial: ignored; your handle is your DID-derived persona.
1029        nick: String,
1030        /// Override the relay URL. Defaults to the session's bound relay
1031        /// from `wire init --relay <url>`. Public relay if unset.
1032        #[arg(long)]
1033        relay: Option<String>,
1034        /// Public-facing URL for the agent-card location (when the relay
1035        /// is behind a CDN with a different public domain).
1036        #[arg(long, alias = "public")]
1037        public_url: Option<String>,
1038        /// Skip listing in the relay's public phonebook. The card is
1039        /// still claimable + reachable; just doesn't appear in
1040        /// `wireup.net/phonebook` for stranger-discovery.
1041        #[arg(long)]
1042        hidden: bool,
1043        #[arg(long)]
1044        json: bool,
1045    },
1046    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
1047    /// Equivalent to `wire session destroy <name>`, scoped here for the
1048    /// noun-CLI surface. Requires `--force` (the underlying command does).
1049    Destroy {
1050        /// Session name to destroy (use `wire identity list` to see).
1051        name: String,
1052        /// Bypass the confirmation prompt.
1053        #[arg(long)]
1054        force: bool,
1055        #[arg(long)]
1056        json: bool,
1057    },
1058    /// Create an identity in an EXPLICIT lifecycle state (vs. the
1059    /// implicit `wire init` + `wire claim` flow).
1060    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
1061    ///
1062    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
1063    /// next reboot). In-memory semantics not yet supported — the
1064    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
1065    /// For pure-RAM identities, see v1.0 vision.
1066    ///
1067    /// `--local` is the explicit form of today's default; identity
1068    /// persists to the machine-wide sessions root.
1069    Create {
1070        /// Session name. Defaults to derived from cwd (anonymous mode
1071        /// uses a random name).
1072        #[arg(long)]
1073        name: Option<String>,
1074        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
1075        /// reboot, no federation). Mutually exclusive with --local.
1076        #[arg(long, conflicts_with = "local")]
1077        anonymous: bool,
1078        /// Create a LOCAL identity (machine-persistent, no federation).
1079        /// Default — explicit flag for clarity.
1080        #[arg(long)]
1081        local: bool,
1082        #[arg(long)]
1083        json: bool,
1084    },
1085    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
1086    /// the machine-wide sessions root + register in the cwd map.
1087    /// After persist, the identity survives reboot.
1088    /// v0.7.0-alpha.20.
1089    Persist {
1090        /// The anonymous identity's name (from `wire identity list`).
1091        name: String,
1092        /// Optional rename during persist. Default: keep the anon name.
1093        #[arg(long = "as", value_name = "NEW_NAME")]
1094        as_name: Option<String>,
1095        #[arg(long)]
1096        json: bool,
1097    },
1098    /// Demote an identity ONE level in the lifecycle:
1099    ///   federation → local: removes the relay slot binding but keeps
1100    ///   the keypair + agent-card. Operator can later re-publish with
1101    ///   `wire identity publish`. v0.7.0-alpha.20.
1102    ///
1103    /// (local → anonymous is not exposed; the safer flow is destroy +
1104    /// recreate, since "demoting" a persistent identity to ephemeral
1105    /// has surprising semantics — what about the keypair? what about
1106    /// pinned peers? Better to be explicit with destroy.)
1107    Demote {
1108        /// Session name to demote.
1109        name: String,
1110        #[arg(long)]
1111        json: bool,
1112    },
1113}
1114
1115#[derive(Subcommand, Debug)]
1116pub enum SessionCommand {
1117    /// Bootstrap a new isolated session in this machine's sessions root.
1118    /// With no name, derives one from `basename(cwd)` and caches it in
1119    /// the registry so re-running from the same project reuses it.
1120    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1121    /// the new session's WIRE_HOME. Output includes the `export
1122    /// WIRE_HOME=...` line operators paste into their shell to activate
1123    /// it.
1124    New {
1125        /// Optional session name. Default = derived from `basename(cwd)`.
1126        name: Option<String>,
1127        /// Relay URL for the session's slot allocation + handle claim.
1128        #[arg(long, default_value = "https://wireup.net")]
1129        relay: String,
1130        /// v0.5.17: also allocate a second slot on a same-machine local
1131        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1132        /// sister-session traffic prefers this path: zero round-trip
1133        /// latency, zero metadata exposure to the public relay. Probes
1134        /// `<local-relay>/healthz` first; silently skips if the local
1135        /// relay isn't running.
1136        #[arg(long)]
1137        with_local: bool,
1138        /// v0.5.17: override the local relay URL probed by `--with-local`.
1139        /// Default is `http://127.0.0.1:8771` to match
1140        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1141        #[arg(long, default_value = "http://127.0.0.1:8771")]
1142        local_relay: String,
1143        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1144        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1145        /// Lets other machines on the same network reach this session
1146        /// directly without round-tripping the public federation relay
1147        /// at https://wireup.net. LAN endpoint is published in the
1148        /// agent-card; opt-in per session (default off).
1149        #[arg(long)]
1150        with_lan: bool,
1151        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1152        /// LAN IP — operator must type the address). Example:
1153        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1154        #[arg(long)]
1155        lan_relay: Option<String>,
1156        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1157        /// relay (must be running e.g. via `wire relay-server --uds
1158        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1159        /// bypasses the macOS firewall + Tailscale userspace-netstack
1160        /// class of issues entirely for sister-session traffic. UDS
1161        /// endpoint is published in the agent-card.
1162        #[arg(long)]
1163        with_uds: bool,
1164        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1165        /// is set. Example: `/tmp/wire.sock` or
1166        /// `~/.wire/local.sock`.
1167        #[arg(long)]
1168        uds_socket: Option<std::path::PathBuf>,
1169        /// Skip spawning the session-local daemon. Use when you want
1170        /// to drive sync explicitly from the agent or test rig.
1171        #[arg(long)]
1172        no_daemon: bool,
1173        /// v0.6.6: create a federation-free session — no nick claim on
1174        /// `--relay`, no federation slot allocation. Implies
1175        /// `--with-local`. The session exists only to coordinate with
1176        /// other sister sessions on this machine; it has no public
1177        /// address and cannot be reached from outside. Reserved nicks
1178        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1179        /// to publish them.
1180        #[arg(long)]
1181        local_only: bool,
1182        /// Emit JSON.
1183        #[arg(long)]
1184        json: bool,
1185    },
1186    /// List all sessions on this machine with their handle, DID,
1187    /// daemon liveness, and the cwd they're associated with.
1188    List {
1189        #[arg(long)]
1190        json: bool,
1191    },
1192    /// List sister sessions reachable via a same-machine local relay
1193    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1194    /// share. Sessions without a Local-scope endpoint are listed
1195    /// separately so the operator can tell which are federation-only.
1196    /// Read-only — does not probe any relay or touch daemons.
1197    ListLocal {
1198        #[arg(long)]
1199        json: bool,
1200    },
1201    /// v0.6.0 (issue #12): mesh-pair every sister session against every
1202    /// other in O(N²) handshakes. For each unordered pair (A, B) that
1203    /// is not already paired, drives the bilateral flow end-to-end:
1204    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
1205    /// B's side, then a final pull on A so the ack lands. Idempotent —
1206    /// re-running skips pairs already in `state.peers`.
1207    ///
1208    /// **Trust anchor:** the operator running this command owns every
1209    /// session listed in `wire session list-local` (they all live under
1210    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1211    /// That filesystem-permission boundary IS the consent for both
1212    /// sides — the bilateral SAS / network-level handshake assumes
1213    /// strangers; same-uid sister sessions are by definition not
1214    /// strangers. Cross-uid sister sessions are out of scope; today
1215    /// `wire session list-local` only enumerates this user's sessions.
1216    PairAllLocal {
1217        /// Seconds to wait between handshake stages for pair_drop /
1218        /// pair_drop_ack to propagate over the relay. Default 1s
1219        /// (local-relay is typically <100ms RTT). Bump if you see
1220        /// "pending-inbound never arrived" errors on a slow relay.
1221        #[arg(long, default_value_t = 1)]
1222        settle_secs: u64,
1223        /// Federation relay to bind each `wire add` against. Default
1224        /// `https://wireup.net`. Sister sessions should be bound to
1225        /// the same federation relay; the pair handshake routes through
1226        /// it for the .well-known resolution + pair_drop deposit.
1227        #[arg(long, default_value = "https://wireup.net")]
1228        federation_relay: String,
1229        #[arg(long)]
1230        json: bool,
1231    },
1232    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1233    /// machine. Enumerates every session in `wire session list-local`,
1234    /// walks each session's `relay.json#peers` to find which other sister
1235    /// sessions it has pinned, and probes the local relay for each edge's
1236    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1237    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1238    /// local_relay, summary}` so scripts can scrape.
1239    ///
1240    /// Read-only — does NOT touch peers or daemons, only the relay's
1241    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1242    /// already hold. Silent on any probe failure (degrades to "no
1243    /// signal" rather than abort) so a half-broken mesh is still
1244    /// inspectable.
1245    MeshStatus {
1246        /// Threshold in seconds for "stale" classification on an edge.
1247        /// An edge whose receiver hasn't polled their slot in this long
1248        /// is flagged. Default 300s (5 min) — same as the per-send
1249        /// `phyllis` attentiveness nag.
1250        #[arg(long, default_value_t = 300)]
1251        stale_secs: u64,
1252        #[arg(long)]
1253        json: bool,
1254    },
1255    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1256    /// can `eval $(wire session env <name>)` to activate it. With no
1257    /// name, resolves the cwd through the registry.
1258    Env {
1259        /// Session name. Default = derived from cwd via the registry.
1260        name: Option<String>,
1261        #[arg(long)]
1262        json: bool,
1263    },
1264    /// Identify which session the current cwd maps to in the registry.
1265    /// Prints `(none)` if cwd isn't registered — `wire session new`
1266    /// would create one.
1267    Current {
1268        #[arg(long)]
1269        json: bool,
1270    },
1271    /// Attach an existing session to the current cwd in the registry,
1272    /// so subsequent auto-detect from this cwd resolves to that session
1273    /// instead of walking up to an ancestor's binding. Use when an
1274    /// ancestor dir (e.g. `~/Source`) is already registered and is
1275    /// shadowing per-project identities for cwds beneath it. Idempotent;
1276    /// re-binding to the same name is a no-op. Re-binding to a different
1277    /// name overwrites the prior entry with a stderr warning.
1278    Bind {
1279        /// Session name to bind. Must already exist (run `wire session
1280        /// new <name>` first if not). With no name, auto-derives from
1281        /// `basename(cwd)` and errors if no session of that name exists.
1282        name: Option<String>,
1283        #[arg(long)]
1284        json: bool,
1285    },
1286    /// Tear down a session: kills its daemon (if running), deletes its
1287    /// state directory, and removes it from the registry. Requires
1288    /// `--force` because state loss is unrecoverable (keypair gone).
1289    Destroy {
1290        name: String,
1291        /// Confirm state-deleting operation.
1292        #[arg(long)]
1293        force: bool,
1294        #[arg(long)]
1295        json: bool,
1296    },
1297}
1298
1299/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1300/// session's view of the pinned peer set. `status` is the read-only
1301/// observability primitive (alias for `wire session mesh-status`);
1302/// Group-chat verbs (v0.13.3). Membership is a creator-signed roster
1303/// (`src/group.rs`); send fans a signed message over the member set.
1304#[derive(Subcommand, Debug)]
1305pub enum GroupCommand {
1306    /// Create a new group — you become the creator + sole member, roster signed.
1307    Create {
1308        /// Group name (human label).
1309        name: String,
1310        #[arg(long)]
1311        json: bool,
1312    },
1313    /// Add a bilaterally-VERIFIED pinned peer to a group you created (Member tier).
1314    Add {
1315        /// Group id or name.
1316        group: String,
1317        /// Peer handle (must be a VERIFIED pinned peer).
1318        peer: String,
1319        #[arg(long)]
1320        json: bool,
1321    },
1322    /// Send a message to every other member of a group (signed fan-out).
1323    Send {
1324        /// Group id or name.
1325        group: String,
1326        /// Message text.
1327        message: String,
1328        #[arg(long)]
1329        json: bool,
1330    },
1331    /// Show recent messages received for a group.
1332    Tail {
1333        /// Group id or name.
1334        group: String,
1335        /// Max messages to show.
1336        #[arg(long, default_value_t = 20)]
1337        limit: usize,
1338        #[arg(long)]
1339        json: bool,
1340    },
1341    /// List your groups + their members and tiers.
1342    List {
1343        #[arg(long)]
1344        json: bool,
1345    },
1346    /// Mint a shareable join code for a group (a self-contained token carrying
1347    /// the room coords + signed roster). Anyone you give it to can `wire group
1348    /// join <code>` to enter the room at Introduced tier. The code IS the room
1349    /// key — share it only with people you want in the room.
1350    Invite {
1351        /// Group id or name.
1352        group: String,
1353        #[arg(long)]
1354        json: bool,
1355    },
1356    /// Join a group from a code minted by `wire group invite`. Materializes the
1357    /// room locally, pins the existing members on the creator's vouch, and
1358    /// announces you to the room so members can verify your messages.
1359    Join {
1360        /// The `wire-group:` code (or bare base64 payload).
1361        code: String,
1362        #[arg(long)]
1363        json: bool,
1364    },
1365}
1366
1367/// `broadcast` fans a signed event to every pinned peer in one call.
1368#[derive(Subcommand, Debug)]
1369pub enum MeshCommand {
1370    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1371    /// per-edge health roll-up across every sister session on this machine.
1372    Status {
1373        /// Threshold in seconds for "stale" classification on an edge.
1374        #[arg(long, default_value_t = 300)]
1375        stale_secs: u64,
1376        #[arg(long)]
1377        json: bool,
1378    },
1379    /// Fan one signed event to every pinned peer. Each peer receives a
1380    /// distinct `event_id` but every copy shares the same `broadcast_id`
1381    /// UUID so receivers can correlate them as a single broadcast.
1382    ///
1383    /// `--scope local` (default) only fans to peers reachable via a same-
1384    /// machine local relay. `--scope federation` only to public-relay
1385    /// peers. `--scope both` to every pinned peer.
1386    ///
1387    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1388    /// for "ack-loop" prevention: a peer responding to a broadcast can
1389    /// exclude its own broadcaster when re-broadcasting.
1390    ///
1391    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1392    /// file, `-` reads stdin (JSON if parseable, else literal).
1393    ///
1394    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1395    /// peers — that would re-introduce the phonebook-scrape risk closed
1396    /// in v0.5.14 (T8).
1397    Broadcast {
1398        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1399        /// `heartbeat`. Same vocabulary as `wire send`.
1400        #[arg(long, default_value = "claim")]
1401        kind: String,
1402        /// `local`, `federation`, or `both`. Default `local`.
1403        #[arg(long, default_value = "local")]
1404        scope: String,
1405        /// Skip a specific peer handle. Repeatable.
1406        #[arg(long)]
1407        exclude: Vec<String>,
1408        /// Drop the broadcast event ID from the relay-side attentiveness
1409        /// nag (`phyllis`) — useful when broadcasting to many peers and
1410        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1411        #[arg(long)]
1412        noreply: bool,
1413        /// Body — string, `@/path` for a file, or `-` for stdin.
1414        body: String,
1415        #[arg(long)]
1416        json: bool,
1417    },
1418    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1419    /// capability-aware addressing. Stored as `profile.role` on the
1420    /// signed agent-card — propagates over the existing pair / .well-
1421    /// known plumbing, no new persistence.
1422    ///
1423    /// First slice of the Layer-2 capability metadata umbrella (#13).
1424    /// `wire mesh route` (issue #21) will consume these tags to pick
1425    /// the right sister for a task.
1426    Role {
1427        #[command(subcommand)]
1428        action: MeshRoleAction,
1429    },
1430    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1431    /// to one sister session and deliver an event to that one peer.
1432    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1433    /// can now address "the reviewer" instead of hard-coding a handle.
1434    ///
1435    /// Strategies:
1436    ///   - `round-robin` (default): per-role cursor, persisted at
1437    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1438    ///   - `first`: alphabetically-first matching sister. Deterministic.
1439    ///   - `random`: uniform random among matches. Stateless.
1440    ///
1441    /// Pinned-peers-only by construction (same posture as `broadcast`).
1442    /// Caller must already have the target sister pinned in
1443    /// `state.peers` — otherwise we can't sign + push. Run
1444    /// `wire session pair-all-local` first if the mesh isn't wired.
1445    Route {
1446        /// Role to match (operator-defined tag from `wire mesh role set`).
1447        role: String,
1448        /// `round-robin` (default), `first`, or `random`.
1449        #[arg(long, default_value = "round-robin")]
1450        strategy: String,
1451        /// Skip a specific sister handle. Repeatable.
1452        #[arg(long)]
1453        exclude: Vec<String>,
1454        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1455        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1456        #[arg(long, default_value = "claim")]
1457        kind: String,
1458        /// Body — string, `@/path` for a file, or `-` for stdin.
1459        body: String,
1460        #[arg(long)]
1461        json: bool,
1462    },
1463}
1464
1465/// v0.6.4: subcommands of `wire mesh role`.
1466#[derive(Subcommand, Debug)]
1467pub enum MeshRoleAction {
1468    /// Assign self to a role. Role is a free-form ASCII string
1469    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1470    /// the vocabulary out-of-band — common starters: `planner`,
1471    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1472    Set {
1473        role: String,
1474        #[arg(long)]
1475        json: bool,
1476    },
1477    /// Read self or a peer's role. With no arg, prints self. With a
1478    /// handle, reads from the peer's pinned agent-card.
1479    Get {
1480        peer: Option<String>,
1481        #[arg(long)]
1482        json: bool,
1483    },
1484    /// List roles across every sister session on this machine. Reads
1485    /// each session's agent-card by path — no network, no env mutation.
1486    List {
1487        #[arg(long)]
1488        json: bool,
1489    },
1490    /// Remove self from any assigned role. Re-signs the card with
1491    /// `profile.role: null`.
1492    Clear {
1493        #[arg(long)]
1494        json: bool,
1495    },
1496}
1497
1498#[derive(Subcommand, Debug)]
1499pub enum ServiceAction {
1500    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1501    /// load it. Idempotent — re-running re-bootstraps an existing service.
1502    ///
1503    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1504    /// process). Pass `--local-relay` to install the loopback relay
1505    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1506    /// transport sister-Claudes use to coordinate on the same machine
1507    /// (v0.5.17 dual-slot). The two services have distinct labels +
1508    /// log files, so you can install both.
1509    Install {
1510        /// Install the local-relay service instead of the daemon.
1511        #[arg(long)]
1512        local_relay: bool,
1513        #[arg(long)]
1514        json: bool,
1515    },
1516    /// Unload + delete the service unit. Daemon keeps running until the
1517    /// next reboot or `wire upgrade`; this only changes the boot-time
1518    /// behaviour.
1519    Uninstall {
1520        /// Uninstall the local-relay service instead of the daemon.
1521        #[arg(long)]
1522        local_relay: bool,
1523        #[arg(long)]
1524        json: bool,
1525    },
1526    /// Report whether the unit is installed + active.
1527    Status {
1528        /// Show status of the local-relay service instead of the daemon.
1529        #[arg(long)]
1530        local_relay: bool,
1531        #[arg(long)]
1532        json: bool,
1533    },
1534}
1535
1536#[derive(Subcommand, Debug)]
1537pub enum ResponderCommand {
1538    /// Publish this agent's auto-responder health.
1539    Set {
1540        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1541        status: String,
1542        /// Optional operator-facing reason.
1543        #[arg(long)]
1544        reason: Option<String>,
1545        /// Emit JSON.
1546        #[arg(long)]
1547        json: bool,
1548    },
1549    /// Read responder health for self, or for a paired peer.
1550    Get {
1551        /// Optional peer handle; omitted means this agent's own slot.
1552        peer: Option<String>,
1553        /// Emit JSON.
1554        #[arg(long)]
1555        json: bool,
1556    },
1557}
1558
1559#[derive(Subcommand, Debug)]
1560pub enum ProfileAction {
1561    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1562    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1563    /// (JSON array) and `now` (JSON object).
1564    Set {
1565        field: String,
1566        value: String,
1567        #[arg(long)]
1568        json: bool,
1569    },
1570    /// Show all profile fields. Equivalent to `wire whois`.
1571    Get {
1572        #[arg(long)]
1573        json: bool,
1574    },
1575    /// Clear a profile field.
1576    Clear {
1577        field: String,
1578        #[arg(long)]
1579        json: bool,
1580    },
1581}
1582
1583/// Entry point — parse and dispatch.
1584pub fn run() -> Result<()> {
1585    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1586    // the session registry and adopt that session's home for this
1587    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1588    // detect — `wire whoami` / `wire monitor` from a project cwd now
1589    // resolve to that project's session identity, not the machine
1590    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1591    //
1592    // MUST run before any thread spawn — call it FIRST, before
1593    // `Cli::parse` (which uses clap internals only) and before any
1594    // command dispatch (which may spawn workers).
1595    crate::session::maybe_adopt_session_wire_home("cli");
1596    let cli = Cli::parse();
1597    match cli.command {
1598        Command::Init {
1599            handle,
1600            name,
1601            relay,
1602            offline,
1603            json,
1604        } => cmd_init(
1605            Some(&handle),
1606            name.as_deref(),
1607            relay.as_deref(),
1608            offline,
1609            json,
1610        ),
1611        Command::Status { peer, json } => {
1612            if let Some(peer) = peer {
1613                cmd_status_peer(&peer, json)
1614            } else {
1615                cmd_status(json)
1616            }
1617        }
1618        Command::Whoami {
1619            json,
1620            short,
1621            colored,
1622        } => cmd_whoami(json_default(json), short, colored),
1623        Command::Peers { json } => cmd_peers(json_default(json)),
1624        Command::Here { json } => cmd_here(json_default(json)),
1625        Command::Completions { shell } => {
1626            // v0.9.5: print shell completion script to stdout. Operator
1627            // pipes into their shell's completion dir; tab completion
1628            // covers verbs (dial, send, pending, accept, etc.) AND
1629            // their flags. Peer-name dynamic completion is a future
1630            // shell-side enhancement; clap_complete only ships the
1631            // static grammar.
1632            use clap::CommandFactory;
1633            let mut cmd = Cli::command();
1634            clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1635            Ok(())
1636        }
1637        Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1638        Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1639        Command::Send {
1640            peer,
1641            kind_or_body,
1642            body,
1643            deadline,
1644            no_auto_pair,
1645            json,
1646        } => {
1647            // P0.S: smart-positional API. `wire send peer body` =
1648            // kind=claim. `wire send peer kind body` = explicit kind.
1649            let (kind, body) = match body {
1650                Some(real_body) => (kind_or_body, real_body),
1651                None => ("claim".to_string(), kind_or_body),
1652            };
1653            cmd_send(
1654                &peer,
1655                &kind,
1656                &body,
1657                deadline.as_deref(),
1658                no_auto_pair,
1659                json_default(json),
1660            )
1661        }
1662        Command::Dial {
1663            name,
1664            message,
1665            json,
1666        } => cmd_dial(&name, message.as_deref(), json_default(json)),
1667        Command::Tail {
1668            peer,
1669            json,
1670            limit,
1671            oldest,
1672        } => cmd_tail(peer.as_deref(), json, limit, oldest),
1673        Command::Monitor {
1674            peer,
1675            json,
1676            include_handshake,
1677            interval_ms,
1678            replay,
1679        } => cmd_monitor(
1680            peer.as_deref(),
1681            json,
1682            include_handshake,
1683            interval_ms,
1684            replay,
1685        ),
1686        Command::Verify { path, json } => cmd_verify(&path, json),
1687        Command::Responder { command } => match command {
1688            ResponderCommand::Set {
1689                status,
1690                reason,
1691                json,
1692            } => cmd_responder_set(&status, reason.as_deref(), json),
1693            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1694        },
1695        Command::Mcp => cmd_mcp(),
1696        Command::RelayServer {
1697            bind,
1698            local_only,
1699            uds,
1700        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1701        Command::BindRelay {
1702            url,
1703            scope,
1704            replace,
1705            migrate_pinned,
1706            json,
1707        } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1708        Command::AddPeerSlot {
1709            handle,
1710            url,
1711            slot_id,
1712            slot_token,
1713            json,
1714        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1715        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1716        Command::Pull { json } => cmd_pull(json),
1717        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1718        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1719        Command::ForgetPeer {
1720            handle,
1721            purge,
1722            json,
1723        } => cmd_forget_peer(&handle, purge, json),
1724        Command::Daemon {
1725            interval,
1726            once,
1727            json,
1728        } => cmd_daemon(interval, once, json),
1729        Command::PairHost {
1730            relay,
1731            yes,
1732            timeout,
1733            detach,
1734            json,
1735        } => {
1736            if detach {
1737                cmd_pair_host_detach(&relay, json)
1738            } else {
1739                cmd_pair_host(&relay, yes, timeout)
1740            }
1741        }
1742        Command::PairJoin {
1743            code_phrase,
1744            relay,
1745            yes,
1746            timeout,
1747            detach,
1748            json,
1749        } => {
1750            if detach {
1751                cmd_pair_join_detach(&code_phrase, &relay, json)
1752            } else {
1753                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1754            }
1755        }
1756        Command::PairConfirm {
1757            code_phrase,
1758            digits,
1759            json,
1760        } => cmd_pair_confirm(&code_phrase, &digits, json),
1761        Command::PairList {
1762            json,
1763            watch,
1764            watch_interval,
1765        } => cmd_pair_list(json, watch, watch_interval),
1766        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1767        Command::PairWatch {
1768            code_phrase,
1769            status,
1770            timeout,
1771            json,
1772        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1773        Command::Pair {
1774            handle,
1775            code,
1776            relay,
1777            yes,
1778            timeout,
1779            no_setup,
1780            detach,
1781        } => {
1782            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1783            // the zero-paste megacommand path — `wire pair slancha-spark@
1784            // wireup.net` does add + poll-for-ack + verify in one shot. The
1785            // SAS / code-based pair flow stays available for handles without
1786            // `@` (bootstrap pairing between two boxes that don't yet share a
1787            // relay directory).
1788            if handle.contains('@') && code.is_none() {
1789                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1790            } else if detach {
1791                cmd_pair_detach(&handle, code.as_deref(), &relay)
1792            } else {
1793                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1794            }
1795        }
1796        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1797        Command::PairAccept { peer, json } => {
1798            let j = json_default(json);
1799            deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1800            cmd_pair_accept(&peer, j)
1801        }
1802        Command::PairReject { peer, json } => {
1803            let j = json_default(json);
1804            deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1805            cmd_pair_reject(&peer, j)
1806        }
1807        Command::PairListInbound { json } => {
1808            let j = json_default(json);
1809            deprecation_warn("pair-list-inbound", "pending", j);
1810            cmd_pair_list_inbound(j)
1811        }
1812        Command::Session(cmd) => cmd_session(cmd),
1813        Command::Identity { cmd } => cmd_identity(cmd),
1814        Command::Mesh(cmd) => cmd_mesh(cmd),
1815        Command::Group(cmd) => cmd_group(cmd),
1816        Command::Enroll(cmd) => cmd_enroll(cmd),
1817        Command::Invite {
1818            relay,
1819            ttl,
1820            uses,
1821            share,
1822            json,
1823        } => cmd_invite(&relay, ttl, uses, share, json),
1824        Command::Accept { target, json } => {
1825            // v0.9.4: smart-dispatch retired. `wire accept` always means
1826            // pair-accept by name. URL-shaped input gets a deprecation
1827            // banner pointing at `wire accept-invite <URL>` and then
1828            // (for back-compat with v0.9 scripts) routes to the invite
1829            // accept path one last time. v1.0 will reject URLs here.
1830            let j = json_default(json);
1831            if target.starts_with("wire://pair?") {
1832                deprecation_warn("accept-url", "accept-invite <url>", j);
1833                cmd_accept(&target, j)
1834            } else {
1835                cmd_pair_accept(&target, j)
1836            }
1837        }
1838        Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1839        Command::Whois {
1840            handle,
1841            json,
1842            relay,
1843        } => {
1844            // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1845            // resolves through the local identity layer (pinned peers
1846            // + local sister sessions). `wire whois <nick>@<relay>`
1847            // keeps the existing federation `.well-known/wire/agent`
1848            // path. `wire whois` (no arg) prints self via the original
1849            // path. The character nickname is the canonical operator-
1850            // facing name as of v0.8 — most callers should hit the
1851            // local route.
1852            match handle.as_deref() {
1853                Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1854                other => cmd_whois(other, json, relay.as_deref()),
1855            }
1856        }
1857        Command::Add {
1858            handle,
1859            relay,
1860            local_sister,
1861            json,
1862        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1863        Command::Up {
1864            relay,
1865            name,
1866            with_local,
1867            no_local,
1868            json,
1869        } => cmd_up(
1870            relay.as_deref(),
1871            name.as_deref(),
1872            with_local.as_deref(),
1873            no_local,
1874            json,
1875        ),
1876        Command::Doctor {
1877            json,
1878            recent_rejections,
1879        } => cmd_doctor(json, recent_rejections),
1880        Command::Upgrade { check, local, json } => cmd_upgrade(check, local, json),
1881        Command::Service { action } => cmd_service(action),
1882        Command::Diag { action } => cmd_diag(action),
1883        Command::Claim {
1884            nick,
1885            relay,
1886            public_url,
1887            hidden,
1888            json,
1889        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1890        Command::Profile { action } => cmd_profile(action),
1891        Command::Setup {
1892            apply,
1893            statusline,
1894            remove,
1895        } => {
1896            if statusline {
1897                cmd_setup_statusline(apply, remove)
1898            } else {
1899                cmd_setup(apply)
1900            }
1901        }
1902        Command::Notify {
1903            interval,
1904            peer,
1905            once,
1906            json,
1907        } => cmd_notify(interval, peer.as_deref(), once, json),
1908        Command::Quiet { action } => cmd_quiet(action),
1909    }
1910}
1911
1912// ---------- quiet (v0.14.x toast kill switch) ----------
1913
1914/// Path to the file that, when present, silences every wire desktop
1915/// toast. Created by `wire quiet on`, removed by `wire quiet off`. Read
1916/// per-toast-call by `crate::os_notify::toasts_disabled` — no daemon
1917/// restart needed for the toggle to take effect, just for binary swap.
1918fn quiet_flag_path() -> Result<std::path::PathBuf> {
1919    Ok(config::config_dir()?.join("quiet"))
1920}
1921
1922fn cmd_quiet(action: QuietAction) -> Result<()> {
1923    match action {
1924        QuietAction::On => {
1925            let path = quiet_flag_path()?;
1926            if let Some(parent) = path.parent() {
1927                std::fs::create_dir_all(parent).with_context(|| {
1928                    format!("creating config dir for quiet flag: {}", parent.display())
1929                })?;
1930            }
1931            // Idempotent: open with create-if-missing, write nothing.
1932            std::fs::OpenOptions::new()
1933                .create(true)
1934                .truncate(true)
1935                .write(true)
1936                .open(&path)
1937                .with_context(|| format!("writing {}", path.display()))?;
1938            println!(
1939                "wire quiet: ON (toasts silenced — file at {})",
1940                path.display()
1941            );
1942            Ok(())
1943        }
1944        QuietAction::Off => {
1945            let path = quiet_flag_path()?;
1946            match std::fs::remove_file(&path) {
1947                Ok(()) => println!("wire quiet: OFF (toasts re-enabled)"),
1948                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1949                    println!("wire quiet: OFF (was already off)")
1950                }
1951                Err(e) => return Err(anyhow!("removing {}: {e}", path.display())),
1952            }
1953            // Re-check env: a user can override file-off with WIRE_NO_TOASTS=1.
1954            if std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0") {
1955                println!(
1956                    "  note: WIRE_NO_TOASTS={} is still set in env — toasts stay silenced for this process / daemon until `launchctl unsetenv WIRE_NO_TOASTS` (or unset in your shell).",
1957                    std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
1958                );
1959            }
1960            Ok(())
1961        }
1962        QuietAction::Status { json } => {
1963            let env_set = std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0");
1964            let file_present = quiet_flag_path()?.exists();
1965            let (state, via) = match (env_set, file_present) {
1966                (true, _) => ("on", "env"),
1967                (false, true) => ("on", "file"),
1968                (false, false) => ("off", "none"),
1969            };
1970            if json {
1971                println!(
1972                    "{}",
1973                    serde_json::to_string(&json!({
1974                        "state": state,
1975                        "via": via,
1976                        "file": quiet_flag_path()?.display().to_string(),
1977                        "env_WIRE_NO_TOASTS": std::env::var("WIRE_NO_TOASTS").ok(),
1978                    }))?
1979                );
1980            } else {
1981                match (env_set, file_present) {
1982                    (true, _) => println!(
1983                        "wire quiet: ON (via WIRE_NO_TOASTS={} in env)",
1984                        std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
1985                    ),
1986                    (false, true) => println!(
1987                        "wire quiet: ON (via file at {})",
1988                        quiet_flag_path()?.display()
1989                    ),
1990                    (false, false) => println!("wire quiet: OFF"),
1991                }
1992            }
1993            Ok(())
1994        }
1995    }
1996}
1997
1998// ---------- init ----------
1999
2000fn cmd_init(
2001    handle: Option<&str>,
2002    name: Option<&str>,
2003    relay: Option<&str>,
2004    offline: bool,
2005    as_json: bool,
2006) -> Result<()> {
2007    // One-name rule: a typed handle (if any) is only a vanity seed — the
2008    // persona is derived from the keypair fingerprint, so it has no effect
2009    // on the resulting identity. `wire up` passes None (there is no name to
2010    // type); an explicit `wire init <handle>` passes Some and we surface the
2011    // "ignored in favor of persona" notice for transparency.
2012    if let Some(h) = handle
2013        && !h
2014            .chars()
2015            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2016    {
2017        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
2018    }
2019    if config::is_initialized()? {
2020        bail!(
2021            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
2022            config::config_dir()?
2023        );
2024    }
2025    // v0.9.1 smart-default reachability. If the operator passed neither
2026    // --relay nor --offline, probe the conventional local relay at
2027    // http://127.0.0.1:8771 and auto-attach if healthy. Closes the
2028    // silent-slotless footgun WITHOUT the v0.9 rejection wall, which
2029    // forced operators through a three-flag decision tree on first
2030    // invocation. Bare `wire init <handle>` is now ergonomic again
2031    // whenever a local relay is running (the common dev setup).
2032    //
2033    // Probe order:
2034    //   1. --relay <url>          → use it
2035    //   2. --offline               → skip slot allocation (rare power-user)
2036    //   3. local relay reachable  → auto-attach + log to stderr
2037    //   4. otherwise               → bail with actionable options
2038    let mut resolved_relay: Option<String> = relay.map(str::to_string);
2039    if resolved_relay.is_none() && !offline {
2040        let default_local = "http://127.0.0.1:8771";
2041        let client = crate::relay_client::RelayClient::new(default_local);
2042        if client.check_healthz().is_ok() {
2043            eprintln!(
2044                "wire init: local relay at {default_local} reachable — auto-attaching. \
2045                 Use --relay <url> to pick a different relay, --offline to skip."
2046            );
2047            resolved_relay = Some(default_local.to_string());
2048        } else {
2049            // v0.9.5: interactive prompt for first-time operators
2050            // when the smart-default can't auto-attach. Detect TTY on
2051            // stdin AND stderr — only prompt for humans. CI / agents
2052            // / non-interactive shells fall through to the explicit
2053            // error wall (unchanged behavior since v0.9.1).
2054            use std::io::{BufRead, IsTerminal, Write};
2055            let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
2056            if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
2057                eprintln!("wire init: no local relay reachable at {default_local}.");
2058                eprint!(
2059                    "  Bind to public federation relay https://wireup.net instead? \
2060                     [Y/n/offline/url]: "
2061                );
2062                let _ = std::io::stderr().flush();
2063                let mut input = String::new();
2064                let _ = std::io::stdin().lock().read_line(&mut input);
2065                let answer = input.trim();
2066                match answer {
2067                    "" | "y" | "Y" | "yes" | "YES" => {
2068                        eprintln!("wire init: binding to https://wireup.net");
2069                        resolved_relay = Some("https://wireup.net".to_string());
2070                    }
2071                    "n" | "N" | "no" | "NO" => {
2072                        bail!(
2073                            "wire init: declined federation default; re-run with --relay <url> or --offline."
2074                        );
2075                    }
2076                    "offline" | "OFFLINE" => {
2077                        eprintln!(
2078                            "wire init: proceeding offline. \
2079                             Run `wire bind-relay <url>` before pairing."
2080                        );
2081                        // Fall through with resolved_relay still None;
2082                        // the `offline` flag is conceptually set but
2083                        // the caller's local doesn't need updating —
2084                        // resolved_relay = None + offline behavior
2085                        // is identical for the rest of cmd_init.
2086                    }
2087                    url if url.starts_with("http://") || url.starts_with("https://") => {
2088                        eprintln!("wire init: binding to {url}");
2089                        resolved_relay = Some(url.to_string());
2090                    }
2091                    other => {
2092                        bail!(
2093                            "wire init: unrecognized answer `{other}` — \
2094                             expected Y/n/offline/<url>. Re-run with --relay or --offline."
2095                        );
2096                    }
2097                }
2098            } else {
2099                bail!(
2100                    "wire init: no relay specified and no local relay reachable at \
2101                     http://127.0.0.1:8771.\n\
2102                     Pick one (or just run `wire up`):\n\
2103                     • `wire service install --local-relay` — start the local relay, then re-run\n\
2104                     • `wire up @wireup.net` — bind to public federation in one command\n\
2105                     • `wire init --offline` — generate keypair only \
2106                     (peers cannot reach you until you `wire bind-relay <url>` later)"
2107                );
2108            }
2109        }
2110    }
2111    let relay = resolved_relay.as_deref();
2112
2113    config::ensure_dirs()?;
2114    let (sk_seed, pk_bytes) = generate_keypair();
2115    config::write_private_key(&sk_seed)?;
2116
2117    // v0.11 ONE-NAME: derive the character nickname from a synthetic DID
2118    // using the freshly-generated pubkey, then USE THE CHARACTER as the
2119    // canonical handle. The operator-typed `handle` arg becomes either:
2120    //   - identical to character (already-canonical input — no-op), OR
2121    //   - overridden in favor of character (operator-typed name was a
2122    //     vanity layer that would never have been federation-reachable).
2123    // Either way, agent-card.handle ends up == character, and every
2124    // downstream surface (relay phonebook, .well-known, dial/send) keys
2125    // on the same name an operator sees in their statusline.
2126    //
2127    // Per the v0.11 directive: "If you can't call someone via a name,
2128    // don't let them have it as a name." Operator-typed handles violated
2129    // that rule because the character was the displayed name but the
2130    // handle was the addressable one. Now they're the same string.
2131    // The seed string only fills the (immediately-discarded) handle portion
2132    // of a synthetic DID; the persona derives from the fp suffix regardless,
2133    // so any seed yields the same identity.
2134    let seed = handle.unwrap_or("agent");
2135    let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
2136    let character = crate::character::Character::from_did(&synth_did);
2137    let canonical_handle: &str = &character.nickname;
2138    if let Some(typed) = handle
2139        && typed != canonical_handle
2140    {
2141        eprintln!(
2142            "wire init: one-name rule — typed `{typed}` ignored in favor of \
2143             DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
2144        );
2145    }
2146
2147    let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
2148    // Card-emit (RFC-001 Phase 1b): attach operator/org claims if enrolled
2149    // (fail-soft no-op otherwise; signed below so the sig covers the claims).
2150    let card = crate::enroll::with_op_claims_if_enrolled(card)?;
2151    let signed = sign_agent_card(&card, &sk_seed);
2152    config::write_agent_card(&signed)?;
2153
2154    let mut trust = empty_trust();
2155    add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
2156    config::write_trust(&trust)?;
2157
2158    let fp = fingerprint(&pk_bytes);
2159    let key_id = make_key_id(canonical_handle, &pk_bytes);
2160    // Rebind `handle` for the rest of cmd_init so downstream prints,
2161    // relay-state writes, etc. all reference the canonical name.
2162    let handle = canonical_handle;
2163
2164    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
2165    let mut relay_info: Option<(String, String)> = None;
2166    if let Some(url) = relay {
2167        let normalized = url.trim_end_matches('/');
2168        let client = crate::relay_client::RelayClient::new(normalized);
2169        client.check_healthz()?;
2170        let alloc = client.allocate_slot(Some(handle))?;
2171        let mut state = config::read_relay_state()?;
2172        state["self"] = json!({
2173            "relay_url": normalized,
2174            "slot_id": alloc.slot_id.clone(),
2175            "slot_token": alloc.slot_token,
2176        });
2177        config::write_relay_state(&state)?;
2178        relay_info = Some((normalized.to_string(), alloc.slot_id));
2179    }
2180
2181    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
2182    if as_json {
2183        let mut out = json!({
2184            "did": did_str.clone(),
2185            "fingerprint": fp,
2186            "key_id": key_id,
2187            "config_dir": config::config_dir()?.to_string_lossy(),
2188        });
2189        if let Some((url, slot_id)) = &relay_info {
2190            out["relay_url"] = json!(url);
2191            out["slot_id"] = json!(slot_id);
2192        }
2193        println!("{}", serde_json::to_string(&out)?);
2194    } else {
2195        println!("generated {did_str} (ed25519:{key_id})");
2196        println!(
2197            "config written to {}",
2198            config::config_dir()?.to_string_lossy()
2199        );
2200        if let Some((url, slot_id)) = &relay_info {
2201            println!("bound to relay {url} (slot {slot_id})");
2202            println!();
2203            println!(
2204                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
2205            );
2206        } else {
2207            println!();
2208            println!(
2209                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
2210            );
2211        }
2212    }
2213    Ok(())
2214}
2215
2216// ---------- status ----------
2217
2218fn cmd_status(as_json: bool) -> Result<()> {
2219    let initialized = config::is_initialized()?;
2220
2221    let mut summary = json!({
2222        "initialized": initialized,
2223    });
2224
2225    if initialized {
2226        let card = config::read_agent_card()?;
2227        let did = card
2228            .get("did")
2229            .and_then(Value::as_str)
2230            .unwrap_or("")
2231            .to_string();
2232        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
2233        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
2234        // legacy cards.
2235        let handle = card
2236            .get("handle")
2237            .and_then(Value::as_str)
2238            .map(str::to_string)
2239            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2240        let pk_b64 = card
2241            .get("verify_keys")
2242            .and_then(Value::as_object)
2243            .and_then(|m| m.values().next())
2244            .and_then(|v| v.get("key"))
2245            .and_then(Value::as_str)
2246            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2247        let pk_bytes = crate::signing::b64decode(pk_b64)?;
2248        summary["did"] = json!(did);
2249        summary["handle"] = json!(handle);
2250        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2251        summary["capabilities"] = card
2252            .get("capabilities")
2253            .cloned()
2254            .unwrap_or_else(|| json!([]));
2255
2256        let trust = config::read_trust()?;
2257        let relay_state_for_tier =
2258            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2259        let mut peers = Vec::new();
2260        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2261            for (peer_handle, _agent) in agents {
2262                if peer_handle == &handle {
2263                    continue; // self
2264                }
2265                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
2266                // for peers we've pinned but never received a pair_drop_ack
2267                // from, so the operator sees the "we can't send to them yet"
2268                // state instead of seeing a misleading VERIFIED.
2269                peers.push(json!({
2270                    "handle": peer_handle,
2271                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2272                }));
2273            }
2274        }
2275        summary["peers"] = json!(peers);
2276
2277        let relay_state = config::read_relay_state()?;
2278        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2279        if !summary["self_relay"].is_null() {
2280            // Hide slot_token from default view.
2281            if let Some(obj) = summary["self_relay"].as_object_mut() {
2282                obj.remove("slot_token");
2283            }
2284        }
2285        summary["peer_slots_count"] = json!(
2286            relay_state
2287                .get("peers")
2288                .and_then(Value::as_object)
2289                .map(|m| m.len())
2290                .unwrap_or(0)
2291        );
2292
2293        // Outbox / inbox queue depth (file count + total events)
2294        let outbox = config::outbox_dir()?;
2295        let inbox = config::inbox_dir()?;
2296        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2297        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2298
2299        // v0.5.19: liveness snapshot through a single helper so this
2300        // surface and `wire doctor` agree by construction. Issue #2:
2301        // doctor PASSed while status said DOWN for 25 min because each
2302        // computed liveness independently. ensure_up::daemon_liveness
2303        // is the only path now.
2304        let snap = crate::ensure_up::daemon_liveness();
2305        let mut daemon = json!({
2306            "running": snap.pidfile_alive,
2307            "pid": snap.pidfile_pid,
2308            "all_running_pids": snap.pgrep_pids,
2309            "orphans": snap.orphan_pids,
2310        });
2311        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2312            daemon["version"] = json!(d.version);
2313            daemon["bin_path"] = json!(d.bin_path);
2314            daemon["did"] = json!(d.did);
2315            daemon["relay_url"] = json!(d.relay_url);
2316            daemon["started_at"] = json!(d.started_at);
2317            daemon["schema"] = json!(d.schema);
2318            if d.version != env!("CARGO_PKG_VERSION") {
2319                daemon["version_mismatch"] = json!({
2320                    "daemon": d.version.clone(),
2321                    "cli": env!("CARGO_PKG_VERSION"),
2322                });
2323            }
2324        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2325            daemon["pidfile_form"] = json!("legacy-int");
2326            daemon["version_mismatch"] = json!({
2327                "daemon": "<pre-0.5.11>",
2328                "cli": env!("CARGO_PKG_VERSION"),
2329            });
2330        }
2331        summary["daemon"] = daemon;
2332
2333        // Pending pair sessions — counts by status.
2334        let pending = crate::pending_pair::list_pending().unwrap_or_default();
2335        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2336        for p in &pending {
2337            *counts.entry(p.status.clone()).or_default() += 1;
2338        }
2339        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
2340        let pending_inbound =
2341            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2342        let inbound_handles: Vec<&str> = pending_inbound
2343            .iter()
2344            .map(|p| p.peer_handle.as_str())
2345            .collect();
2346        summary["pending_pairs"] = json!({
2347            "total": pending.len(),
2348            "by_status": counts,
2349            "inbound_count": pending_inbound.len(),
2350            "inbound_handles": inbound_handles,
2351        });
2352    }
2353
2354    if as_json {
2355        println!("{}", serde_json::to_string(&summary)?);
2356    } else if !initialized {
2357        println!("not initialized — run `wire init <handle>` first");
2358    } else {
2359        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
2360        println!(
2361            "fingerprint:   {}",
2362            summary["fingerprint"].as_str().unwrap_or("?")
2363        );
2364        println!("capabilities:  {}", summary["capabilities"]);
2365        if !summary["self_relay"].is_null() {
2366            println!(
2367                "self relay:    {} (slot {})",
2368                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2369                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2370            );
2371        } else {
2372            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
2373        }
2374        println!(
2375            "peers:         {}",
2376            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2377        );
2378        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2379            println!(
2380                "  - {:<20} tier={}",
2381                p["handle"].as_str().unwrap_or(""),
2382                p["tier"].as_str().unwrap_or("?")
2383            );
2384        }
2385        println!(
2386            "outbox:        {} file(s), {} event(s) queued",
2387            summary["outbox"]["files"].as_u64().unwrap_or(0),
2388            summary["outbox"]["events"].as_u64().unwrap_or(0)
2389        );
2390        println!(
2391            "inbox:         {} file(s), {} event(s) received",
2392            summary["inbox"]["files"].as_u64().unwrap_or(0),
2393            summary["inbox"]["events"].as_u64().unwrap_or(0)
2394        );
2395        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2396        let daemon_pid = summary["daemon"]["pid"]
2397            .as_u64()
2398            .map(|p| p.to_string())
2399            .unwrap_or_else(|| "—".to_string());
2400        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2401        let version_suffix = if !daemon_version.is_empty() {
2402            format!(" v{daemon_version}")
2403        } else {
2404            String::new()
2405        };
2406        println!(
2407            "daemon:        {} (pid {}{})",
2408            if daemon_running { "running" } else { "DOWN" },
2409            daemon_pid,
2410            version_suffix,
2411        );
2412        // P1.7: surface version mismatch + orphan procs loudly.
2413        if let Some(mm) = summary["daemon"].get("version_mismatch") {
2414            println!(
2415                "               !! version mismatch: daemon={} CLI={}. \
2416                 run `wire upgrade` to swap atomically.",
2417                mm["daemon"].as_str().unwrap_or("?"),
2418                mm["cli"].as_str().unwrap_or("?"),
2419            );
2420        }
2421        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2422            && !orphans.is_empty()
2423        {
2424            let pids: Vec<String> = orphans
2425                .iter()
2426                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2427                .collect();
2428            println!(
2429                "               !! orphan daemon process(es): pids {}. \
2430                 pgrep saw them but pidfile didn't — likely stale process from \
2431                 prior install. Multiple daemons race the relay cursor.",
2432                pids.join(", ")
2433            );
2434        }
2435        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2436        let inbound_count = summary["pending_pairs"]["inbound_count"]
2437            .as_u64()
2438            .unwrap_or(0);
2439        if pending_total > 0 {
2440            print!("pending pairs: {pending_total}");
2441            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2442                let parts: Vec<String> = obj
2443                    .iter()
2444                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2445                    .collect();
2446                if !parts.is_empty() {
2447                    print!(" ({})", parts.join(", "));
2448                }
2449            }
2450            println!();
2451        } else if inbound_count == 0 {
2452            println!("pending pairs: none");
2453        }
2454        // v0.5.14: separate line for pending-inbound zero-paste requests.
2455        // Loud because each one is awaiting an operator gesture and the
2456        // capability hasn't flowed yet.
2457        if inbound_count > 0 {
2458            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2459                .as_array()
2460                .map(|a| {
2461                    a.iter()
2462                        .filter_map(|v| v.as_str().map(str::to_string))
2463                        .collect()
2464                })
2465                .unwrap_or_default();
2466            println!(
2467                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2468                handles.join(", "),
2469            );
2470        }
2471    }
2472    Ok(())
2473}
2474
2475fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2476    if !dir.exists() {
2477        return Ok(json!({"files": 0, "events": 0}));
2478    }
2479    let mut files = 0usize;
2480    let mut events = 0usize;
2481    for entry in std::fs::read_dir(dir)? {
2482        let path = entry?.path();
2483        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2484            files += 1;
2485            if let Ok(body) = std::fs::read_to_string(&path) {
2486                events += body.lines().filter(|l| !l.trim().is_empty()).count();
2487            }
2488        }
2489    }
2490    Ok(json!({"files": files, "events": events}))
2491}
2492
2493// ---------- responder health ----------
2494
2495fn responder_status_allowed(status: &str) -> bool {
2496    matches!(
2497        status,
2498        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2499    )
2500}
2501
2502fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2503    let state = config::read_relay_state()?;
2504    let (label, slot_info) = match peer {
2505        Some(peer) => (
2506            peer.to_string(),
2507            state
2508                .get("peers")
2509                .and_then(|p| p.get(peer))
2510                .ok_or_else(|| {
2511                    anyhow!(
2512                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
2513                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
2514                         (`wire peers` lists who you've already paired with.)"
2515                    )
2516                })?,
2517        ),
2518        None => (
2519            "self".to_string(),
2520            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2521                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2522            })?,
2523        ),
2524    };
2525    let relay_url = slot_info["relay_url"]
2526        .as_str()
2527        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2528        .to_string();
2529    let slot_id = slot_info["slot_id"]
2530        .as_str()
2531        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2532        .to_string();
2533    let slot_token = slot_info["slot_token"]
2534        .as_str()
2535        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2536        .to_string();
2537    Ok((label, relay_url, slot_id, slot_token))
2538}
2539
2540fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2541    if !responder_status_allowed(status) {
2542        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2543    }
2544    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2545    let now = time::OffsetDateTime::now_utc()
2546        .format(&time::format_description::well_known::Rfc3339)
2547        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2548    let mut record = json!({
2549        "status": status,
2550        "set_at": now,
2551    });
2552    if let Some(reason) = reason {
2553        record["reason"] = json!(reason);
2554    }
2555    if status == "online" {
2556        record["last_success_at"] = json!(now);
2557    }
2558    let client = crate::relay_client::RelayClient::new(&relay_url);
2559    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2560    if as_json {
2561        println!("{}", serde_json::to_string(&saved)?);
2562    } else {
2563        let reason = saved
2564            .get("reason")
2565            .and_then(Value::as_str)
2566            .map(|r| format!(" — {r}"))
2567            .unwrap_or_default();
2568        println!(
2569            "responder {}{}",
2570            saved
2571                .get("status")
2572                .and_then(Value::as_str)
2573                .unwrap_or(status),
2574            reason
2575        );
2576    }
2577    Ok(())
2578}
2579
2580fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2581    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2582    let client = crate::relay_client::RelayClient::new(&relay_url);
2583    let health = client.responder_health_get(&slot_id, &slot_token)?;
2584    if as_json {
2585        println!(
2586            "{}",
2587            serde_json::to_string(&json!({
2588                "target": label,
2589                "responder_health": health,
2590            }))?
2591        );
2592    } else if health.is_null() {
2593        println!("{label}: responder health not reported");
2594    } else {
2595        let status = health
2596            .get("status")
2597            .and_then(Value::as_str)
2598            .unwrap_or("unknown");
2599        let reason = health
2600            .get("reason")
2601            .and_then(Value::as_str)
2602            .map(|r| format!(" — {r}"))
2603            .unwrap_or_default();
2604        let last_success = health
2605            .get("last_success_at")
2606            .and_then(Value::as_str)
2607            .map(|t| format!(" (last_success: {t})"))
2608            .unwrap_or_default();
2609        println!("{label}: {status}{reason}{last_success}");
2610    }
2611    Ok(())
2612}
2613
2614fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2615    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2616    let client = crate::relay_client::RelayClient::new(&relay_url);
2617
2618    let started = std::time::Instant::now();
2619    let transport_ok = client.healthz().unwrap_or(false);
2620    let latency_ms = started.elapsed().as_millis() as u64;
2621
2622    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2623    let now = std::time::SystemTime::now()
2624        .duration_since(std::time::UNIX_EPOCH)
2625        .map(|d| d.as_secs())
2626        .unwrap_or(0);
2627    let attention = match last_pull_at_unix {
2628        Some(last) if now.saturating_sub(last) <= 300 => json!({
2629            "status": "ok",
2630            "last_pull_at_unix": last,
2631            "age_seconds": now.saturating_sub(last),
2632            "event_count": event_count,
2633        }),
2634        Some(last) => json!({
2635            "status": "stale",
2636            "last_pull_at_unix": last,
2637            "age_seconds": now.saturating_sub(last),
2638            "event_count": event_count,
2639        }),
2640        None => json!({
2641            "status": "never_pulled",
2642            "last_pull_at_unix": Value::Null,
2643            "event_count": event_count,
2644        }),
2645    };
2646
2647    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2648    let responder = if responder_health.is_null() {
2649        json!({"status": "not_reported", "record": Value::Null})
2650    } else {
2651        json!({
2652            "status": responder_health
2653                .get("status")
2654                .and_then(Value::as_str)
2655                .unwrap_or("unknown"),
2656            "record": responder_health,
2657        })
2658    };
2659
2660    let report = json!({
2661        "peer": peer,
2662        "transport": {
2663            "status": if transport_ok { "ok" } else { "error" },
2664            "relay_url": relay_url,
2665            "latency_ms": latency_ms,
2666        },
2667        "attention": attention,
2668        "responder": responder,
2669    });
2670
2671    if as_json {
2672        println!("{}", serde_json::to_string(&report)?);
2673    } else {
2674        let transport_line = if transport_ok {
2675            format!("ok relay reachable ({latency_ms}ms)")
2676        } else {
2677            "error relay unreachable".to_string()
2678        };
2679        println!("transport      {transport_line}");
2680        match report["attention"]["status"].as_str().unwrap_or("unknown") {
2681            "ok" => println!(
2682                "attention      ok last pull {}s ago",
2683                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2684            ),
2685            "stale" => println!(
2686                "attention      stale last pull {}m ago",
2687                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2688            ),
2689            "never_pulled" => println!("attention      never pulled since relay reset"),
2690            other => println!("attention      {other}"),
2691        }
2692        if report["responder"]["status"] == "not_reported" {
2693            println!("auto-responder not reported");
2694        } else {
2695            let record = &report["responder"]["record"];
2696            let status = record
2697                .get("status")
2698                .and_then(Value::as_str)
2699                .unwrap_or("unknown");
2700            let reason = record
2701                .get("reason")
2702                .and_then(Value::as_str)
2703                .map(|r| format!(" — {r}"))
2704                .unwrap_or_default();
2705            println!("auto-responder {status}{reason}");
2706        }
2707    }
2708    Ok(())
2709}
2710
2711// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
2712
2713// ---------- whoami ----------
2714
2715/// Return the current cwd with the user's home dir abbreviated to `~/`.
2716/// Used in whoami `--short` / `--colored` output so multi-window operators
2717/// see *what project* each Claude is working in alongside the character.
2718fn current_cwd_display() -> String {
2719    let cwd = match std::env::current_dir() {
2720        Ok(c) => c,
2721        Err(_) => return String::from("?"),
2722    };
2723    if let Some(home) = dirs::home_dir()
2724        && let Ok(rel) = cwd.strip_prefix(&home)
2725    {
2726        // strip_prefix returns "" for cwd == home itself; show "~" then.
2727        let rel_str = rel.to_string_lossy();
2728        if rel_str.is_empty() {
2729            return String::from("~");
2730        }
2731        return format!("~/{rel_str}");
2732    }
2733    cwd.to_string_lossy().into_owned()
2734}
2735
2736/// v0.14: extract the inline op claims from an agent card (or pinned
2737/// trust row) for surfacing on operator-facing read paths. Returns the
2738/// subset of fields actually present and non-null — operators read the
2739/// absence to mean "not enrolled / older peer".
2740///
2741/// Surfaced fields: `op_did`, `op_pubkey`, `op_cert`, `org_memberships`,
2742/// `schema_version`. All RFC-001-defined; all public commits, safe to
2743/// surface on every read verb. Centralized here so whoami / peers / whois
2744/// stay in lock-step as the inline set grows (e.g. `sso_attest` in v0.15).
2745///
2746/// `pub(crate)` so the MCP surface (`src/mcp.rs`) wires the same helper
2747/// into `tool_whoami` / `tool_peers` — agents reading MCP responses must
2748/// see the same op claims that operators see via CLI.
2749pub(crate) fn op_claims_from_card(card: &Value) -> serde_json::Map<String, Value> {
2750    let mut out = serde_json::Map::new();
2751    for key in [
2752        "op_did",
2753        "op_pubkey",
2754        "op_cert",
2755        "org_memberships",
2756        "schema_version",
2757    ] {
2758        if let Some(v) = card.get(key)
2759            && !v.is_null()
2760        {
2761            out.insert(key.to_string(), v.clone());
2762        }
2763    }
2764    out
2765}
2766
2767fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2768    if !config::is_initialized()? {
2769        bail!("not initialized — run `wire init <handle>` first");
2770    }
2771    let card = config::read_agent_card()?;
2772    let did = card
2773        .get("did")
2774        .and_then(Value::as_str)
2775        .unwrap_or("")
2776        .to_string();
2777    let handle = card
2778        .get("handle")
2779        .and_then(Value::as_str)
2780        .map(str::to_string)
2781        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2782    // v0.11: character is purely DID-derived. No overrides — the
2783    // operator-rename verb is gone and display.json reads are stripped
2784    // because they introduced a second name that peers couldn't find.
2785    let character = crate::character::Character::from_did(&did);
2786
2787    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
2788    // so operators tab-flipping between multiple Claude windows see both
2789    // *who* this session is (character) and *what* it's working on (cwd).
2790    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
2791    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
2792    let cwd_display = current_cwd_display();
2793
2794    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
2795    // beyond did — these calls are hot (statusline polls ~300ms).
2796    if short {
2797        println!("{} · {}", character.short(), cwd_display);
2798        return Ok(());
2799    }
2800    if colored {
2801        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2802        return Ok(());
2803    }
2804
2805    let pk_b64 = card
2806        .get("verify_keys")
2807        .and_then(Value::as_object)
2808        .and_then(|m| m.values().next())
2809        .and_then(|v| v.get("key"))
2810        .and_then(Value::as_str)
2811        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2812    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2813    let fp = fingerprint(&pk_bytes);
2814    let key_id = make_key_id(&handle, &pk_bytes);
2815    let capabilities = card
2816        .get("capabilities")
2817        .cloned()
2818        .unwrap_or_else(|| json!(["wire/v3.1"]));
2819
2820    if as_json {
2821        // v0.11: character_override is always false now (no rename verb,
2822        // no display.json reads). Field stays for back-compat with v0.10
2823        // JSON consumers that key off it.
2824        let has_override = false;
2825        let mut payload = serde_json::Map::new();
2826        payload.insert("did".into(), json!(did));
2827        payload.insert("handle".into(), json!(handle));
2828        payload.insert("fingerprint".into(), json!(fp));
2829        payload.insert("key_id".into(), json!(key_id));
2830        payload.insert("public_key_b64".into(), json!(pk_b64));
2831        payload.insert("capabilities".into(), capabilities);
2832        payload.insert(
2833            "config_dir".into(),
2834            json!(config::config_dir()?.to_string_lossy()),
2835        );
2836        payload.insert("persona".into(), serde_json::to_value(&character)?);
2837        payload.insert("persona_override".into(), json!(has_override));
2838        // v0.14: surface the RFC-001 op claims (when enrolled) on the
2839        // canonical operator read verb. Absent ⇒ pre-v0.14 card or not
2840        // yet enrolled. See `op_claims_from_card` rationale.
2841        for (k, v) in op_claims_from_card(&card) {
2842            payload.insert(k, v);
2843        }
2844        println!("{}", serde_json::to_string(&payload)?);
2845    } else {
2846        println!("{}", character.colored());
2847        println!("{did} (ed25519:{key_id})");
2848        println!("fingerprint: {fp}");
2849        println!("capabilities: {capabilities}");
2850        // v0.14: when enrolled, surface op_did + membership count so
2851        // the operator can spot at a glance whether the marquee identity
2852        // layer is active. Silent when not enrolled (no clutter for
2853        // pre-v0.14 cards).
2854        if let Some(op_did) = card.get("op_did").and_then(Value::as_str) {
2855            let memberships = card
2856                .get("org_memberships")
2857                .and_then(Value::as_array)
2858                .map(|a| a.len())
2859                .unwrap_or(0);
2860            let plural = if memberships == 1 { "" } else { "s" };
2861            println!("enrolled: {op_did} ({memberships} org membership{plural})");
2862        }
2863    }
2864    Ok(())
2865}
2866
2867// ---------- identity (v0.7.0-alpha.3) ----------
2868
2869fn cmd_enroll(cmd: EnrollCommand) -> Result<()> {
2870    match cmd {
2871        EnrollCommand::Op { handle, json } => {
2872            let (sk, pk) = crate::signing::generate_keypair();
2873            crate::config::write_op_key(&sk)?;
2874            crate::config::write_op_handle(&handle)?;
2875            let op_did = crate::agent_card::did_for_op(&handle, &pk);
2876            let op_pubkey = crate::signing::b64encode(&pk);
2877            if json {
2878                println!(
2879                    "{}",
2880                    serde_json::to_string(&json!({"op_did": op_did, "op_pubkey": op_pubkey}))?
2881                );
2882            } else {
2883                println!(
2884                    "→ operator enrolled\n  op_did:    {op_did}\n  op_pubkey: {op_pubkey}\n  key saved 0600 at {:?}",
2885                    crate::config::op_key_path()?
2886                );
2887            }
2888            Ok(())
2889        }
2890        EnrollCommand::OrgCreate { handle, json } => {
2891            let (sk, pk) = crate::signing::generate_keypair();
2892            let org_did = crate::agent_card::did_for_org(&handle, &pk);
2893            crate::config::write_org_key(&org_did, &sk)?;
2894            let org_pubkey = crate::signing::b64encode(&pk);
2895            if json {
2896                println!(
2897                    "{}",
2898                    serde_json::to_string(&json!({"org_did": org_did, "org_pubkey": org_pubkey}))?
2899                );
2900            } else {
2901                println!(
2902                    "→ organization created\n  org_did:    {org_did}\n  org_pubkey: {org_pubkey}\n  key saved 0600 at {:?}",
2903                    crate::config::org_key_path(&org_did)?
2904                );
2905            }
2906            Ok(())
2907        }
2908        EnrollCommand::OrgAddMember { op_did, org, json } => {
2909            if !crate::agent_card::is_op_did(&op_did) {
2910                bail!("not a valid operator DID (did:wire:op:<handle>-<32hex>): {op_did}");
2911            }
2912            let org_sk = crate::config::read_org_key(&org).with_context(|| {
2913                format!("no stored key for org {org} — run `wire enroll org-create` first")
2914            })?;
2915            let org_pk = ed25519_dalek::SigningKey::from_bytes(&org_sk)
2916                .verifying_key()
2917                .to_bytes();
2918            let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did)?;
2919            let org_pubkey = crate::signing::b64encode(&org_pk);
2920            // Store locally so card-emit can attach it (same-machine operator);
2921            // also printed below for the cross-machine share case.
2922            crate::config::add_membership(&org, &org_pubkey, &member_cert)?;
2923            if json {
2924                println!(
2925                    "{}",
2926                    serde_json::to_string(&json!({
2927                        "org_did": org, "org_pubkey": org_pubkey, "member_cert": member_cert
2928                    }))?
2929                );
2930            } else {
2931                println!(
2932                    "→ membership issued for {op_did}\n  add to the operator's card org_memberships[]:\n  {{\"org_did\": \"{org}\", \"org_pubkey\": \"{org_pubkey}\", \"member_cert\": \"{member_cert}\"}}"
2933                );
2934            }
2935            Ok(())
2936        }
2937        EnrollCommand::Republish { json } => {
2938            // Rebuild the on-disk card with current enrollment, then republish
2939            // via the same path `profile set` uses. Closes the enroll-after-init
2940            // DX gap (see `enroll::rebuild_card_with_current_claims`).
2941            let card = crate::enroll::rebuild_card_with_current_claims()?;
2942            let published = republish_card_to_phonebook();
2943            let op_did = card
2944                .get("op_did")
2945                .and_then(Value::as_str)
2946                .map(str::to_string);
2947            let n_memberships = card
2948                .get("org_memberships")
2949                .and_then(Value::as_array)
2950                .map(Vec::len)
2951                .unwrap_or(0);
2952            if json {
2953                println!(
2954                    "{}",
2955                    serde_json::to_string(&json!({
2956                        "op_did": op_did,
2957                        "org_memberships": n_memberships,
2958                        "published": published,
2959                    }))?
2960                );
2961            } else {
2962                match op_did {
2963                    Some(did) => println!(
2964                        "→ card rebuilt with current enrollment\n  op_did:    {did}\n  memberships: {n_memberships}"
2965                    ),
2966                    None => println!(
2967                        "→ card rebuilt — no operator enrolled (claims stripped if previously present)"
2968                    ),
2969                }
2970                print_profile_publish_result(&published);
2971            }
2972            Ok(())
2973        }
2974    }
2975}
2976
2977fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2978    match cmd {
2979        // v0.11: IdentityCommand::Rename deleted. The character is the
2980        // one canonical name (DID-derived); a local-display rename
2981        // would create a second name peers can't find, violating the
2982        // "names must be findable" invariant. Aliases (if needed
2983        // later) become relay-claimed entries that ARE findable —
2984        // a different architectural shape from rename.
2985        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2986        IdentityCommand::List { json } => cmd_session_list(json),
2987        IdentityCommand::Publish {
2988            nick,
2989            relay,
2990            public_url,
2991            hidden,
2992            json,
2993        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2994        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2995        IdentityCommand::Create {
2996            name,
2997            anonymous,
2998            local: _,
2999            json,
3000        } => cmd_identity_create(name.as_deref(), anonymous, json),
3001        IdentityCommand::Persist {
3002            name,
3003            as_name,
3004            json,
3005        } => cmd_identity_persist(&name, as_name.as_deref(), json),
3006        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
3007    }
3008}
3009
3010/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
3011/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
3012/// paste into their shell; the identity lives there until reboot
3013/// clears /tmp. Persist promotes it to the real sessions root.
3014fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
3015    if anonymous {
3016        // Generate a unique tmpdir for this anonymous identity.
3017        let rand_suffix = format!("{:08x}", rand::random::<u32>());
3018        let anon_name = name
3019            .map(crate::session::sanitize_name)
3020            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
3021        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
3022        std::fs::create_dir_all(&anon_root)
3023            .with_context(|| format!("creating anon root {anon_root:?}"))?;
3024        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
3025        let session_home = anon_root.join("sessions").join(&anon_name);
3026        std::fs::create_dir_all(&session_home)?;
3027        let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
3028        if !status.success() {
3029            bail!("anonymous identity init failed: {status}");
3030        }
3031        // Register the anonymous name in a SIDE registry so persist
3032        // can find it later. Stored at <anon_root>/anon-marker.json.
3033        let marker = anon_root.join("anon-marker.json");
3034        std::fs::write(
3035            &marker,
3036            serde_json::to_vec_pretty(&serde_json::json!({
3037                "name": anon_name,
3038                "session_home": session_home.to_string_lossy(),
3039                "created_at": time::OffsetDateTime::now_utc()
3040                    .format(&time::format_description::well_known::Rfc3339)
3041                    .unwrap_or_default(),
3042                "kind": "anonymous",
3043            }))?,
3044        )?;
3045        let card = serde_json::from_slice::<Value>(&std::fs::read(
3046            session_home
3047                .join("config")
3048                .join("wire")
3049                .join("agent-card.json"),
3050        )?)?;
3051        let did = card
3052            .get("did")
3053            .and_then(Value::as_str)
3054            .unwrap_or("")
3055            .to_string();
3056        if as_json {
3057            println!(
3058                "{}",
3059                serde_json::to_string(&json!({
3060                    "kind": "anonymous",
3061                    "name": anon_name,
3062                    "did": did,
3063                    "session_home": session_home.to_string_lossy(),
3064                    "anon_root": anon_root.to_string_lossy(),
3065                }))?
3066            );
3067        } else {
3068            println!("created anonymous identity `{anon_name}` ({did})");
3069            println!(
3070                "  session_home: {} (dies on reboot — /tmp)",
3071                session_home.display()
3072            );
3073            println!();
3074            println!("activate in this shell:");
3075            println!("  export WIRE_HOME={}", session_home.display());
3076            println!();
3077            println!("promote to persistent later with:");
3078            println!("  wire identity persist {anon_name}");
3079        }
3080        return Ok(());
3081    }
3082    // --local (or default): delegate to existing session new flow.
3083    let name_arg = name.map(|s| s.to_string());
3084    cmd_session_new(
3085        name_arg.as_deref(),
3086        "https://wireup.net",
3087        false,
3088        "http://127.0.0.1:8771",
3089        false,
3090        None,
3091        false,
3092        None,
3093        true, // no_daemon: identity create just allocates the identity, no daemon
3094        true, // local_only: explicit lifecycle
3095        as_json,
3096    )
3097}
3098
3099/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
3100/// tmpdir to the persistent sessions root + registers in the cwd map.
3101fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
3102    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
3103    let temp = std::env::temp_dir();
3104    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3105    for entry in std::fs::read_dir(&temp)?.flatten() {
3106        let path = entry.path();
3107        if !path
3108            .file_name()
3109            .and_then(|s| s.to_str())
3110            .map(|s| s.starts_with("wire-anon-"))
3111            .unwrap_or(false)
3112        {
3113            continue;
3114        }
3115        let marker = path.join("anon-marker.json");
3116        if let Ok(bytes) = std::fs::read(&marker)
3117            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
3118            && json.get("name").and_then(Value::as_str) == Some(name)
3119        {
3120            let session_home = json
3121                .get("session_home")
3122                .and_then(Value::as_str)
3123                .map(std::path::PathBuf::from)
3124                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
3125            found = Some((path, session_home));
3126            break;
3127        }
3128    }
3129    let (anon_root, anon_session_home) = found.ok_or_else(|| {
3130        anyhow!(
3131            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
3132             run `wire identity list` to see available identities"
3133        )
3134    })?;
3135
3136    let new_name = as_name.unwrap_or(name);
3137    let new_session_home = crate::session::session_dir(new_name)?;
3138    if new_session_home.exists() {
3139        bail!(
3140            "target session `{new_name}` already exists at {new_session_home:?} — \
3141             pick a different name with --as <new-name>"
3142        );
3143    }
3144
3145    // Move the session dir from tmpdir to persistent root.
3146    if let Some(parent) = new_session_home.parent() {
3147        std::fs::create_dir_all(parent)?;
3148    }
3149    std::fs::rename(&anon_session_home, &new_session_home)
3150        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
3151
3152    // Clean up the (now-empty) anon root + marker.
3153    let _ = std::fs::remove_dir_all(&anon_root);
3154
3155    // Register cwd → new_name (operator may have cd'd elsewhere; use the
3156    // session_home's grandparent as the conceptual "cwd" if no other).
3157    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
3158    let cwd_key = crate::session::normalize_cwd_key(&cwd);
3159    let new_name_for_reg = new_name.to_string();
3160    if let Err(e) = crate::session::update_registry(|reg| {
3161        reg.by_cwd.insert(cwd_key, new_name_for_reg);
3162        Ok(())
3163    }) {
3164        eprintln!("wire identity persist: failed to update registry: {e:#}");
3165    }
3166
3167    if as_json {
3168        println!(
3169            "{}",
3170            serde_json::to_string(&json!({
3171                "kind": "persisted",
3172                "from_name": name,
3173                "to_name": new_name,
3174                "session_home": new_session_home.to_string_lossy(),
3175            }))?
3176        );
3177    } else {
3178        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
3179        println!(
3180            "  session_home: {} (survives reboot)",
3181            new_session_home.display()
3182        );
3183        println!("  registered cwd: {}", cwd.display());
3184    }
3185    Ok(())
3186}
3187
3188/// v0.7.0-alpha.20: demote federation → local. Removes the federation
3189/// slot binding from relay.json (and the legacy top-level fields). Keeps
3190/// the keypair + agent-card so re-publish later just calls `wire identity
3191/// publish` again. local → anonymous is NOT supported; destroy + recreate
3192/// is the safer path for that step-down.
3193fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
3194    let sessions = crate::session::list_sessions()?;
3195    let session = sessions
3196        .iter()
3197        .find(|s| s.name == name)
3198        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
3199    let relay_state_path = session
3200        .home_dir
3201        .join("config")
3202        .join("wire")
3203        .join("relay.json");
3204    if !relay_state_path.exists() {
3205        bail!("session `{name}` has no relay state — already demoted?");
3206    }
3207    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
3208    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
3209    let had_fed = self_obj
3210        .get("relay_url")
3211        .and_then(Value::as_str)
3212        .map(|u| {
3213            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
3214        })
3215        .unwrap_or(false);
3216    if !had_fed {
3217        if as_json {
3218            println!(
3219                "{}",
3220                serde_json::to_string(
3221                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
3222                )?
3223            );
3224        } else {
3225            println!("session `{name}` has no federation slot — nothing to demote");
3226        }
3227        return Ok(());
3228    }
3229    // Strip federation: remove top-level relay_url/slot_id/slot_token,
3230    // remove federation-scope entries from endpoints[].
3231    if let Some(self_mut) = state
3232        .as_object_mut()
3233        .and_then(|m| m.get_mut("self"))
3234        .and_then(|s| s.as_object_mut())
3235    {
3236        self_mut.remove("relay_url");
3237        self_mut.remove("slot_id");
3238        self_mut.remove("slot_token");
3239        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
3240            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
3241        }
3242    }
3243    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
3244
3245    if as_json {
3246        println!(
3247            "{}",
3248            serde_json::to_string(
3249                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
3250            )?
3251        );
3252    } else {
3253        println!("demoted `{name}` from federation → local");
3254        println!("  relay slot binding removed; keypair + agent-card retained");
3255        println!("  re-publish with `wire identity publish <nick>`");
3256    }
3257    Ok(())
3258}
3259
3260fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
3261    let raw = crate::trust::get_tier(trust, handle);
3262    if raw != "VERIFIED" {
3263        return raw.to_string();
3264    }
3265    let token = relay_state
3266        .get("peers")
3267        .and_then(|p| p.get(handle))
3268        .and_then(|p| p.get("slot_token"))
3269        .and_then(Value::as_str)
3270        .unwrap_or("");
3271    if token.is_empty() {
3272        "PENDING_ACK".to_string()
3273    } else {
3274        raw.to_string()
3275    }
3276}
3277
3278fn cmd_peers(as_json: bool) -> Result<()> {
3279    let trust = config::read_trust()?;
3280    let agents = trust
3281        .get("agents")
3282        .and_then(Value::as_object)
3283        .cloned()
3284        .unwrap_or_default();
3285    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
3286
3287    let mut self_did: Option<String> = None;
3288    if let Ok(card) = config::read_agent_card() {
3289        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
3290    }
3291
3292    let mut peers = Vec::new();
3293    for (handle, agent) in agents.iter() {
3294        let did = agent
3295            .get("did")
3296            .and_then(Value::as_str)
3297            .unwrap_or("")
3298            .to_string();
3299        if Some(did.as_str()) == self_did.as_deref() {
3300            continue; // skip self-attestation
3301        }
3302        let tier = effective_peer_tier(&trust, &relay_state, handle);
3303        let capabilities = agent
3304            .get("card")
3305            .and_then(|c| c.get("capabilities"))
3306            .cloned()
3307            .unwrap_or_else(|| json!([]));
3308        // v0.7.0-alpha.6: prefer peer's published character override
3309        // (display.nickname / display.emoji on their pinned agent-card).
3310        // Falls back to auto-derived if peer hasn't renamed themselves
3311        // OR runs an older wire that doesn't publish the field.
3312        let character = if did.is_empty() {
3313            None
3314        } else {
3315            let card_obj = agent.get("card");
3316            Some(match card_obj {
3317                Some(card) => crate::character::Character::from_card(card),
3318                None => crate::character::Character::from_did(&did),
3319            })
3320        };
3321        // v0.14: surface peer's op claims when their pinned card carries
3322        // them (post-v0.14 peers). Older peers ⇒ absent keys; same shape
3323        // as `wire whoami --json` so operators have one mental model.
3324        let peer_op_claims = agent
3325            .get("card")
3326            .map(op_claims_from_card)
3327            .unwrap_or_default();
3328        let mut row = serde_json::Map::new();
3329        row.insert("handle".into(), json!(handle));
3330        row.insert("did".into(), json!(did));
3331        row.insert("tier".into(), json!(tier));
3332        row.insert("capabilities".into(), capabilities);
3333        row.insert("persona".into(), serde_json::to_value(&character)?);
3334        for (k, v) in peer_op_claims {
3335            row.insert(k, v);
3336        }
3337        peers.push(Value::Object(row));
3338    }
3339
3340    if as_json {
3341        println!("{}", serde_json::to_string(&peers)?);
3342    } else if peers.is_empty() {
3343        println!("no peers pinned (run `wire join <code>` to pair)");
3344    } else {
3345        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
3346        // computed above (from peer's agent-card, honoring override) so
3347        // text and JSON output never diverge. Pre-alpha.8 the text loop
3348        // recomputed via Character::from_did (no override) — operators
3349        // saw different identities depending on --json flag.
3350        for p in &peers {
3351            let char_json = &p["persona"];
3352            let (colored_char, plain_len): (String, usize) = match char_json {
3353                serde_json::Value::Null => ("?".to_string(), 1),
3354                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3355                    Ok(c) => {
3356                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
3357                        (c.colored(), plain)
3358                    }
3359                    Err(_) => ("?".to_string(), 1),
3360                },
3361            };
3362            let pad = 22usize.saturating_sub(plain_len);
3363            println!(
3364                "{}{}  {:<20} {:<10} {}",
3365                colored_char,
3366                " ".repeat(pad),
3367                p["handle"].as_str().unwrap_or(""),
3368                p["tier"].as_str().unwrap_or(""),
3369                p["did"].as_str().unwrap_or(""),
3370            );
3371        }
3372    }
3373    Ok(())
3374}
3375
3376// ---------- send ----------
3377
3378/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
3379///
3380/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
3381/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
3382/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
3383/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
3384/// hasn't crossed two heartbeats means probably degraded.
3385fn maybe_warn_peer_attentiveness(peer: &str) {
3386    let state = match config::read_relay_state() {
3387        Ok(s) => s,
3388        Err(_) => return,
3389    };
3390    let p = state.get("peers").and_then(|p| p.get(peer));
3391    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3392        Some(s) if !s.is_empty() => s,
3393        _ => return,
3394    };
3395    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3396        Some(s) if !s.is_empty() => s,
3397        _ => return,
3398    };
3399    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3400        Some(s) if !s.is_empty() => s.to_string(),
3401        _ => match state
3402            .get("self")
3403            .and_then(|s| s.get("relay_url"))
3404            .and_then(Value::as_str)
3405        {
3406            Some(s) if !s.is_empty() => s.to_string(),
3407            _ => return,
3408        },
3409    };
3410    let client = crate::relay_client::RelayClient::new(&relay_url);
3411    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3412        Ok(t) => t,
3413        Err(_) => return,
3414    };
3415    let now = std::time::SystemTime::now()
3416        .duration_since(std::time::UNIX_EPOCH)
3417        .map(|d| d.as_secs())
3418        .unwrap_or(0);
3419    match last_pull {
3420        None => {
3421            eprintln!(
3422                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3423            );
3424        }
3425        Some(t) if now.saturating_sub(t) > 300 => {
3426            let mins = now.saturating_sub(t) / 60;
3427            eprintln!(
3428                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3429            );
3430        }
3431        _ => {}
3432    }
3433}
3434
3435pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3436    let trimmed = input.trim();
3437    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3438    {
3439        return Ok(trimmed.to_string());
3440    }
3441    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3442    let n: i64 = amount
3443        .parse()
3444        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3445    if n <= 0 {
3446        bail!("deadline duration must be positive: {input:?}");
3447    }
3448    let duration = match unit {
3449        "m" => time::Duration::minutes(n),
3450        "h" => time::Duration::hours(n),
3451        "d" => time::Duration::days(n),
3452        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3453    };
3454    Ok((time::OffsetDateTime::now_utc() + duration)
3455        .format(&time::format_description::well_known::Rfc3339)
3456        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3457}
3458
3459fn cmd_send(
3460    peer: &str,
3461    kind: &str,
3462    body_arg: &str,
3463    deadline: Option<&str>,
3464    // v0.10: when true, refuse to auto-pair on miss; fail loudly so
3465    // scripts can branch on the error instead of accepting an implicit
3466    // side effect.
3467    no_auto_pair: bool,
3468    as_json: bool,
3469) -> Result<()> {
3470    if !config::is_initialized()? {
3471        bail!("not initialized — run `wire init <handle>` first");
3472    }
3473    let peer_in = crate::agent_card::bare_handle(peer).to_string();
3474    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
3475    // match wins; nickname (DID-hash auto-derived) is the fallback.
3476    // Ambiguous nicknames (two pinned peers DID-hash to the same
3477    // adj-noun pair) fail loudly with disambiguation; unknown handles
3478    // pass through (matches existing `wire send` semantics — queue
3479    // first, deliver best-effort).
3480    let peer = match resolve_peer_handle(&peer_in) {
3481        Ok(Some(resolved)) if resolved != peer_in => {
3482            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3483            resolved
3484        }
3485        Ok(Some(canonical)) => canonical, // exact handle match
3486        Ok(None) => peer_in,              // unknown — pass through, downstream errors
3487        Err(ResolveError::Ambiguous(candidates)) => bail!(
3488            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3489             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3490            candidates.len(),
3491            candidates.join(", ")
3492        ),
3493        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
3494    };
3495
3496    // v0.9 auto-pair-on-miss: if the resolved peer isn't pinned yet but
3497    // matches a local sister session, pair first (disk-read --local-sister
3498    // path) then continue. Closes the "wire send returns queued but
3499    // peer never receives because we were never paired" silent-fail
3500    // class. Equivalent to `wire dial <name>` followed by `wire send
3501    // <name> ...` in one step.
3502    let peer_is_pinned = config::read_relay_state()
3503        .ok()
3504        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3505        .map(|peers| peers.contains_key(&peer))
3506        .unwrap_or(false);
3507    if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3508        if no_auto_pair {
3509            bail!(
3510                "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3511                 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3512                 then re-run send."
3513            );
3514        }
3515        eprintln!(
3516            "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3517             Pass --no-auto-pair to refuse implicit dialing."
3518        );
3519        cmd_add_local_sister(&sister_name, true).map_err(|e| {
3520            anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3521        })?;
3522    }
3523
3524    let peer = peer.as_str();
3525    let sk_seed = config::read_private_key()?;
3526    let card = config::read_agent_card()?;
3527    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3528    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3529    let pk_b64 = card
3530        .get("verify_keys")
3531        .and_then(Value::as_object)
3532        .and_then(|m| m.values().next())
3533        .and_then(|v| v.get("key"))
3534        .and_then(Value::as_str)
3535        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3536    let pk_bytes = crate::signing::b64decode(pk_b64)?;
3537
3538    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
3539    // P0.S (0.5.11): stdin support lets shells pipe in long content
3540    // without quoting/escaping ceremony, and supports heredocs naturally:
3541    //   wire send peer - <<EOF ... EOF
3542    let body_value: Value = if body_arg == "-" {
3543        use std::io::Read;
3544        let mut raw = String::new();
3545        std::io::stdin()
3546            .read_to_string(&mut raw)
3547            .with_context(|| "reading body from stdin")?;
3548        // Try parsing as JSON first; fall back to string literal for
3549        // plain-text bodies.
3550        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3551    } else if let Some(path) = body_arg.strip_prefix('@') {
3552        let raw =
3553            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3554        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3555    } else {
3556        Value::String(body_arg.to_string())
3557    };
3558
3559    let kind_id = parse_kind(kind)?;
3560
3561    let now = time::OffsetDateTime::now_utc()
3562        .format(&time::format_description::well_known::Rfc3339)
3563        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3564
3565    let mut event = json!({
3566        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3567        "timestamp": now,
3568        "from": did,
3569        "to": format!("did:wire:{peer}"),
3570        "type": kind,
3571        "kind": kind_id,
3572        "body": body_value,
3573    });
3574    if let Some(deadline) = deadline {
3575        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3576    }
3577    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3578    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3579
3580    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
3581    // coords in relay-state and ask the relay how recently the peer pulled.
3582    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
3583    // Never blocks the send — the event still queues to outbox.
3584    maybe_warn_peer_attentiveness(peer);
3585
3586    // For now we append to outbox JSONL and rely on a future daemon to push
3587    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
3588    // Append goes through `config::append_outbox_record` which holds a per-
3589    // path mutex so concurrent senders cannot interleave bytes mid-line.
3590    let line = serde_json::to_vec(&signed)?;
3591    let outbox = config::append_outbox_record(peer, &line)?;
3592
3593    if as_json {
3594        println!(
3595            "{}",
3596            serde_json::to_string(&json!({
3597                "event_id": event_id,
3598                "status": "queued",
3599                "peer": peer,
3600                "outbox": outbox.to_string_lossy(),
3601            }))?
3602        );
3603    } else {
3604        println!(
3605            "queued event {event_id} → {peer} (outbox: {})",
3606            outbox.display()
3607        );
3608    }
3609    Ok(())
3610}
3611
3612fn parse_kind(s: &str) -> Result<u32> {
3613    if let Ok(n) = s.parse::<u32>() {
3614        return Ok(n);
3615    }
3616    for (id, name) in crate::signing::kinds() {
3617        if *name == s {
3618            return Ok(*id);
3619        }
3620    }
3621    // Unknown name — default to kind 1 (decision) for v0.1.
3622    Ok(1)
3623}
3624
3625// ---------- here (v0.9.3 you-are-here view) ----------
3626
3627/// `wire here` — one-screen "you are this session, your neighbors are
3628/// these." Combines what `wire whoami`, `wire peers`, and `wire session
3629/// list-local` would otherwise force the operator to call separately.
3630fn cmd_here(as_json: bool) -> Result<()> {
3631    let initialized = config::is_initialized().unwrap_or(false);
3632
3633    // Self identity.
3634    let (self_did, self_handle, self_character) = if initialized {
3635        let card = config::read_agent_card().ok();
3636        let did = card
3637            .as_ref()
3638            .and_then(|c| c.get("did").and_then(Value::as_str))
3639            .unwrap_or("")
3640            .to_string();
3641        let handle = if did.is_empty() {
3642            String::new()
3643        } else {
3644            crate::agent_card::display_handle_from_did(&did).to_string()
3645        };
3646        let character = if did.is_empty() {
3647            None
3648        } else {
3649            // v0.11: DID-derived only. No display.json overrides.
3650            Some(crate::character::Character::from_did(&did))
3651        };
3652        (did, handle, character)
3653    } else {
3654        (String::new(), String::new(), None)
3655    };
3656
3657    let cwd = std::env::current_dir()
3658        .map(|p| p.to_string_lossy().into_owned())
3659        .unwrap_or_default();
3660    let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3661
3662    // Sister sessions (same-machine).
3663    let mut sisters: Vec<Value> = Vec::new();
3664    if let Ok(listing) = crate::session::list_local_sessions() {
3665        for group in listing.local.values() {
3666            for s in group {
3667                if s.handle.as_deref() == Some(self_handle.as_str()) {
3668                    continue; // skip self
3669                }
3670                let ch = s.did.as_deref().map(crate::character::Character::from_did);
3671                sisters.push(json!({
3672                    "session": s.name,
3673                    "handle": s.handle,
3674                    "persona": ch,
3675                }));
3676            }
3677        }
3678    }
3679
3680    // Pinned peers (trust ring agents).
3681    let mut peers: Vec<Value> = Vec::new();
3682    if initialized
3683        && let Ok(trust) = config::read_trust()
3684        && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3685    {
3686        for (handle, agent) in agents {
3687            if handle == &self_handle {
3688                continue; // skip self
3689            }
3690            let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3691            let ch = if did.is_empty() {
3692                None
3693            } else {
3694                Some(crate::character::Character::from_did(did))
3695            };
3696            peers.push(json!({
3697                "handle": handle,
3698                "did": did,
3699                "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3700                "persona": ch,
3701            }));
3702        }
3703    }
3704
3705    if as_json {
3706        println!(
3707            "{}",
3708            serde_json::to_string(&json!({
3709                "self": {
3710                    "handle": self_handle,
3711                    "did": self_did,
3712                    "persona": self_character,
3713                    "cwd": cwd,
3714                    "wire_home": wire_home,
3715                },
3716                "sister_sessions": sisters,
3717                "pinned_peers": peers,
3718            }))?
3719        );
3720        return Ok(());
3721    }
3722
3723    // Human format.
3724    if !initialized {
3725        println!("not initialized — run `wire init <handle>` to bootstrap.");
3726        return Ok(());
3727    }
3728    let glyph = self_character
3729        .as_ref()
3730        .map(crate::character::emoji_with_fallback)
3731        .unwrap_or_else(|| "?".to_string());
3732    let nick = self_character
3733        .as_ref()
3734        .map(|c| c.nickname.clone())
3735        .unwrap_or_default();
3736    println!("you are {glyph} {nick}  ({self_handle})");
3737    if !cwd.is_empty() {
3738        println!("  cwd:    {cwd}");
3739    }
3740    // Helper closure that mirrors emoji_with_fallback over a JSON-encoded
3741    // character object (because we already collected sisters/peers into
3742    // Value rows above). Looks up the canonical emoji-name and falls
3743    // back to that — never repeats the nickname inside the brackets.
3744    let render_glyph = |character: &Value| -> String {
3745        let emoji = character
3746            .get("emoji")
3747            .and_then(Value::as_str)
3748            .unwrap_or("?");
3749        let nickname = character
3750            .get("nickname")
3751            .and_then(Value::as_str)
3752            .unwrap_or("?");
3753        if crate::character::terminal_supports_emoji() {
3754            return emoji.to_string();
3755        }
3756        // Synthesize a minimal Character so emoji_with_fallback's
3757        // lookup table picks the right ASCII tag.
3758        let synth = crate::character::Character {
3759            nickname: nickname.to_string(),
3760            emoji: emoji.to_string(),
3761            palette: crate::character::Palette {
3762                primary_hex: String::new(),
3763                accent_hex: String::new(),
3764                ansi256_primary: 0,
3765                ansi256_accent: 0,
3766            },
3767        };
3768        crate::character::emoji_with_fallback(&synth)
3769    };
3770    if !sisters.is_empty() {
3771        println!();
3772        println!("sister sessions on this machine:");
3773        for s in &sisters {
3774            let session = s["session"].as_str().unwrap_or("?");
3775            let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3776            let glyph = render_glyph(&s["persona"]);
3777            println!("  {glyph} {ch_nick}  ({session})");
3778        }
3779    }
3780    if !peers.is_empty() {
3781        println!();
3782        println!("pinned peers:");
3783        for p in &peers {
3784            let handle = p["handle"].as_str().unwrap_or("?");
3785            let tier = p["tier"].as_str().unwrap_or("");
3786            let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3787            let glyph = render_glyph(&p["persona"]);
3788            println!("  {glyph} {ch_nick}  ({handle})  [{tier}]");
3789        }
3790    }
3791    if sisters.is_empty() && peers.is_empty() {
3792        println!();
3793        println!(
3794            "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3795        );
3796    }
3797    Ok(())
3798}
3799
3800// ---------- dial / whois (v0.8 canonical addressing) ----------
3801
3802/// `wire dial <name> [message]` — the one verb operators reach for.
3803/// Resolves any name (nickname/handle/session/DID) to a peer and
3804/// drives the right pair flow + optional first message. See the
3805/// `Command::Dial` doc for the resolution ladder.
3806///
3807/// v0.9: when `name` contains `@<relay>`, route through the federation
3808/// `wire add <handle>@<relay>` path (`.well-known/wire/agent` resolution
3809/// plus cross-machine pair_drop). No more bail with "federation isn't
3810/// implemented yet" — one verb across both orbits.
3811fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3812    if name.contains('@') {
3813        // Federation path. cmd_add already auto-detects (per v0.7.4)
3814        // when input has `@` and routes through the .well-known
3815        // resolver + pair_drop deposit. After it returns, the peer
3816        // is in pending-outbound; bilateral completes when the peer
3817        // accepts. Optionally send the first message after the add.
3818        cmd_add(name, None, false, true)
3819            .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3820        if let Some(msg) = message {
3821            // Peer handle for send = the nick part before the `@`.
3822            let bare = name.split('@').next().unwrap_or(name);
3823            cmd_send(bare, "claim", msg, None, false, as_json)?;
3824        }
3825        return Ok(());
3826    }
3827
3828    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3829    // success with `{found: false, candidates: [...]}` instead of
3830    // erroring. Agents can branch on `found` without wrapping in a
3831    // try/catch.
3832    let resolution = match resolve_name_to_target(name) {
3833        Ok(r) => r,
3834        Err(e) if as_json => {
3835            let pool = known_local_names();
3836            let suggestions = closest_candidates(name, &pool, 3, 3);
3837            println!(
3838                "{}",
3839                serde_json::to_string(&json!({
3840                    "name_input": name,
3841                    "found": false,
3842                    "candidates": suggestions,
3843                    "error": format!("{e:#}"),
3844                }))?
3845            );
3846            return Ok(());
3847        }
3848        Err(e) => return Err(e),
3849    };
3850    let mut steps: Vec<Value> = Vec::new();
3851
3852    match &resolution {
3853        DialTarget::PinnedPeer { handle, .. } => {
3854            steps.push(json!({
3855                "step": "resolved",
3856                "kind": "already_pinned",
3857                "handle": handle,
3858            }));
3859        }
3860        DialTarget::LocalSister { session_name, .. } => {
3861            steps.push(json!({
3862                "step": "resolved",
3863                "kind": "local_sister",
3864                "session": session_name,
3865            }));
3866            // Drive the bilateral pair via the disk-read sister path.
3867            // cmd_add_local_sister already handles "already paired"
3868            // gracefully (its internal state.peers check returns the
3869            // existing pin instead of re-issuing a pair_drop), so
3870            // re-dialling is idempotent.
3871            cmd_add_local_sister(session_name, true).map_err(|e| {
3872                anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3873            })?;
3874            steps.push(json!({
3875                "step": "paired",
3876                "via": "local_sister",
3877            }));
3878        }
3879    }
3880
3881    let send_handle = match &resolution {
3882        DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3883        DialTarget::LocalSister { handle, .. } => handle.clone(),
3884    };
3885
3886    let send_result = if let Some(msg) = message {
3887        let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3888        match &r {
3889            Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3890            Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3891        }
3892        Some(r)
3893    } else {
3894        None
3895    };
3896
3897    if as_json {
3898        println!(
3899            "{}",
3900            serde_json::to_string(&json!({
3901                "name_input": name,
3902                "resolved_handle": send_handle,
3903                "steps": steps,
3904            }))?
3905        );
3906    } else {
3907        println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3908        for s in &steps {
3909            let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3910            println!("  - {step}");
3911        }
3912        if message.is_some() {
3913            println!("  (use `wire tail {send_handle}` to read replies)");
3914        }
3915    }
3916    if let Some(Err(e)) = send_result {
3917        return Err(e);
3918    }
3919    Ok(())
3920}
3921
3922/// `wire whois <name>` — resolve any local name (nickname/session/
3923/// handle/DID) to the full identity row. The inspector for the
3924/// canonical addressing layer. For federation `handle@relay-domain`
3925/// resolution see `cmd_whois` (line 5536+) — the dispatcher chooses
3926/// based on whether the input contains `@`.
3927fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3928    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3929    // success (exit 0) with `{found: false, candidates: [...]}` so
3930    // agents don't need try/catch around `wire whois <name>`. In
3931    // human mode, the bail's did-you-mean line points at the
3932    // closest candidate.
3933    let resolution = match resolve_name_to_target(name) {
3934        Ok(r) => r,
3935        Err(e) if as_json => {
3936            let pool = known_local_names();
3937            let suggestions = closest_candidates(name, &pool, 3, 3);
3938            println!(
3939                "{}",
3940                serde_json::to_string(&json!({
3941                    "name_input": name,
3942                    "found": false,
3943                    "candidates": suggestions,
3944                    "error": format!("{e:#}"),
3945                }))?
3946            );
3947            return Ok(());
3948        }
3949        Err(e) => return Err(e),
3950    };
3951    match resolution {
3952        DialTarget::PinnedPeer {
3953            handle,
3954            did,
3955            nickname,
3956            emoji,
3957            tier,
3958        } => {
3959            // v0.14: re-read trust to pull the pinned peer's card for op
3960            // claims surfacing. Pinned ⇒ card lives in trust.json (no
3961            // network round-trip). Older peers ⇒ no op_* fields ⇒ empty.
3962            let op_claims = config::read_trust()
3963                .ok()
3964                .and_then(|t| {
3965                    t.get("agents")
3966                        .and_then(Value::as_object)
3967                        .and_then(|m| m.get(&handle))
3968                        .and_then(|a| a.get("card").cloned())
3969                })
3970                .map(|c| op_claims_from_card(&c))
3971                .unwrap_or_default();
3972
3973            if as_json {
3974                let mut payload = serde_json::Map::new();
3975                payload.insert("kind".into(), json!("pinned_peer"));
3976                payload.insert("handle".into(), json!(handle));
3977                payload.insert("did".into(), json!(did));
3978                payload.insert("nickname".into(), json!(nickname));
3979                payload.insert("emoji".into(), json!(emoji));
3980                payload.insert("tier".into(), json!(tier));
3981                for (k, v) in &op_claims {
3982                    payload.insert(k.clone(), v.clone());
3983                }
3984                println!("{}", serde_json::to_string(&payload)?);
3985            } else {
3986                let n = nickname.as_deref().unwrap_or("(no character)");
3987                let e = emoji.as_deref().unwrap_or("?");
3988                println!("{e} {n}");
3989                println!("  handle:   {handle}");
3990                println!("  did:      {did}");
3991                println!("  tier:     {tier}");
3992                // v0.14: surface peer's op_did when the pinned card
3993                // carries one. Silent for pre-v0.14 peers.
3994                if let Some(op_did) = op_claims.get("op_did").and_then(Value::as_str) {
3995                    println!("  op_did:   {op_did}");
3996                }
3997                println!("  reach:    pinned peer (already in trust ring + slot pinned)");
3998            }
3999        }
4000        DialTarget::LocalSister {
4001            session_name,
4002            handle,
4003            did,
4004            nickname,
4005            emoji,
4006        } => {
4007            if as_json {
4008                println!(
4009                    "{}",
4010                    serde_json::to_string(&json!({
4011                        "kind": "local_sister",
4012                        "session_name": session_name,
4013                        "handle": handle,
4014                        "did": did,
4015                        "nickname": nickname,
4016                        "emoji": emoji,
4017                    }))?
4018                );
4019            } else {
4020                let n = nickname.as_deref().unwrap_or("(no character)");
4021                let e = emoji.as_deref().unwrap_or("?");
4022                println!("{e} {n}");
4023                println!("  session:  {session_name}");
4024                println!("  handle:   {handle}");
4025                println!(
4026                    "  did:      {}",
4027                    did.as_deref().unwrap_or("(card unreadable)")
4028                );
4029                println!("  reach:    local sister on this machine — `wire dial {n}` pairs us");
4030            }
4031        }
4032    }
4033    Ok(())
4034}
4035
4036pub(crate) enum DialTarget {
4037    PinnedPeer {
4038        handle: String,
4039        did: String,
4040        nickname: Option<String>,
4041        emoji: Option<String>,
4042        tier: String,
4043    },
4044    LocalSister {
4045        session_name: String,
4046        handle: String,
4047        did: Option<String>,
4048        nickname: Option<String>,
4049        emoji: Option<String>,
4050    },
4051}
4052
4053/// Resolution order: pinned peers first (already in our trust ring),
4054/// then local sister sessions (on-disk discovery). Case-insensitive
4055/// match against handle, character nickname, session name, or DID.
4056///
4057/// `pub(crate)` so the MCP `tool_whois` surface mirrors the CLI's
4058/// bare-nick resolution (closes the known `missing '@' separator`
4059/// rejection on bare nicks — agents reading via MCP now resolve
4060/// pinned peers + local sisters identically to operators reading via
4061/// CLI).
4062pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
4063    let needle = name.trim();
4064    if needle.is_empty() {
4065        bail!("empty name");
4066    }
4067
4068    // 1. Pinned peers — `wire peers` data. trust.agents is an object
4069    // keyed by handle (not an array); iterate as a map.
4070    if config::is_initialized().unwrap_or(false) {
4071        let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
4072        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
4073            for (handle_key, agent) in agents {
4074                let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4075                if did.is_empty() {
4076                    continue;
4077                }
4078                let handle = handle_key.clone();
4079                let character = crate::character::Character::from_did(did);
4080                let tier = agent
4081                    .get("tier")
4082                    .and_then(Value::as_str)
4083                    .unwrap_or("UNKNOWN")
4084                    .to_string();
4085                let matches = handle.eq_ignore_ascii_case(needle)
4086                    || did.eq_ignore_ascii_case(needle)
4087                    || character.nickname.eq_ignore_ascii_case(needle);
4088                if matches {
4089                    return Ok(DialTarget::PinnedPeer {
4090                        handle,
4091                        did: did.to_string(),
4092                        nickname: Some(character.nickname),
4093                        emoji: Some(character.emoji.to_string()),
4094                        tier,
4095                    });
4096                }
4097            }
4098        }
4099    }
4100
4101    // 2. Local sister sessions.
4102    if let Some(session_name) = crate::session::resolve_local_sister(needle) {
4103        let sessions = crate::session::list_sessions().unwrap_or_default();
4104        let s = sessions.iter().find(|s| s.name == session_name);
4105        if let Some(s) = s {
4106            return Ok(DialTarget::LocalSister {
4107                session_name: s.name.clone(),
4108                handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
4109                did: s.did.clone(),
4110                nickname: s.character.as_ref().map(|c| c.nickname.clone()),
4111                emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
4112            });
4113        }
4114    }
4115
4116    // v0.9.2: fuzzy did-you-mean suggestion on resolution miss. Walks
4117    // the union of pinned-peer handles + character nicknames + sister
4118    // session names + sister character nicknames, returns up to 3 names
4119    // within Levenshtein distance 3 of the operator's typed name.
4120    let pool = known_local_names();
4121    let suggestions = closest_candidates(name, &pool, 3, 3);
4122    if suggestions.is_empty() {
4123        bail!(
4124            "no peer matched `{name}`.\n\
4125             Tried: pinned peers (`wire peers`) + local sister sessions \
4126             (`wire session list-local`).\n\
4127             For cross-machine federation: `wire dial <handle>@<relay-domain>`."
4128        );
4129    }
4130    bail!(
4131        "no peer matched `{name}`.\n\
4132         Did you mean: {}?\n\
4133         List all: `wire peers`, `wire session list-local`.",
4134        suggestions
4135            .iter()
4136            .map(|s| format!("`{s}`"))
4137            .collect::<Vec<_>>()
4138            .join(", ")
4139    );
4140}
4141
4142// ---------- tail ----------
4143
4144/// Print recent events from this agent's inbox.
4145///
4146/// **Orientation (wire #79):** defaults to NEWEST-N — with `limit > 0`, the
4147/// last `limit` events across all matched peer jsonl files are returned,
4148/// sorted chronologically (by `timestamp`, then by per-file append order as
4149/// tiebreaker) and printed oldest-of-window first / newest last. This matches
4150/// `tail -n` semantics on log files; previously `wire tail --limit N` returned
4151/// the OLDEST N which silently hid live-context for any agent harness that
4152/// re-tailed an established inbox.
4153///
4154/// `oldest=true` flips back to FIFO (first-N) for operators who need the
4155/// original orientation (e.g. replaying an inbox from the start). `limit=0`
4156/// prints every event in chronological order.
4157fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize, oldest: bool) -> Result<()> {
4158    let inbox = config::inbox_dir()?;
4159    if !inbox.exists() {
4160        if !as_json {
4161            eprintln!("no inbox yet — daemon hasn't run, or no events received");
4162        }
4163        return Ok(());
4164    }
4165    let trust = config::read_trust()?;
4166
4167    let entries: Vec<_> = std::fs::read_dir(&inbox)?
4168        .filter_map(|e| e.ok())
4169        .map(|e| e.path())
4170        .filter(|p| {
4171            p.extension().map(|x| x == "jsonl").unwrap_or(false)
4172                && match peer {
4173                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
4174                    None => true,
4175                }
4176        })
4177        .collect();
4178
4179    // Collect every parseable event across all matched peer files. Each entry
4180    // carries a sort key `(timestamp, line_idx)` so multi-peer interleaving
4181    // sorts deterministically by event time, with append-order as the
4182    // tiebreaker for events that share a timestamp (or for events with no
4183    // timestamp string at all).
4184    let mut events: Vec<(String, usize, Value)> = Vec::new();
4185    for path in &entries {
4186        let body = std::fs::read_to_string(path)?;
4187        for (idx, line) in body.lines().enumerate() {
4188            let event: Value = match serde_json::from_str(line) {
4189                Ok(v) => v,
4190                Err(_) => continue,
4191            };
4192            let ts = event
4193                .get("timestamp")
4194                .and_then(Value::as_str)
4195                .unwrap_or("")
4196                .to_string();
4197            events.push((ts, idx, event));
4198        }
4199    }
4200    events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
4201
4202    // Pick the window. limit=0 → all events; oldest → first N; default → last N.
4203    let total = events.len();
4204    let window: &[(String, usize, Value)] = if limit == 0 {
4205        &events[..]
4206    } else if oldest {
4207        &events[..limit.min(total)]
4208    } else {
4209        let start = total.saturating_sub(limit);
4210        &events[start..]
4211    };
4212
4213    for (_, _, event) in window {
4214        let verified = verify_message_v31(event, &trust).is_ok();
4215        if as_json {
4216            let mut event_with_meta = event.clone();
4217            if let Some(obj) = event_with_meta.as_object_mut() {
4218                obj.insert("verified".into(), json!(verified));
4219            }
4220            println!("{}", serde_json::to_string(&event_with_meta)?);
4221        } else {
4222            let ts = event
4223                .get("timestamp")
4224                .and_then(Value::as_str)
4225                .unwrap_or("?");
4226            let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
4227            let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
4228            let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
4229            let summary = event
4230                .get("body")
4231                .map(|b| match b {
4232                    Value::String(s) => s.clone(),
4233                    _ => b.to_string(),
4234                })
4235                .unwrap_or_default();
4236            let mark = if verified { "✓" } else { "✗" };
4237            let deadline = event
4238                .get("time_sensitive_until")
4239                .and_then(Value::as_str)
4240                .map(|d| format!(" deadline: {d}"))
4241                .unwrap_or_default();
4242            println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
4243        }
4244    }
4245    Ok(())
4246}
4247
4248// ---------- monitor (live-tail across all peers, harness-friendly) ----------
4249
4250/// Events filtered out of `wire monitor` by default — pair handshake +
4251/// liveness pings. Operators almost never want these surfaced; an explicit
4252/// `--include-handshake` brings them back.
4253fn monitor_is_noise_kind(kind: &str) -> bool {
4254    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
4255}
4256
4257/// Resolve a pinned peer's persona (the DID-derived nickname + emoji,
4258/// respecting an advertised override on their card). `None` if the peer
4259/// isn't in trust or can't be resolved — callers fall back to the handle.
4260fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
4261    let trust = config::read_trust().ok()?;
4262    let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
4263    if let Some(card) = agent.get("card") {
4264        Some(crate::character::Character::from_card(card))
4265    } else {
4266        let did = agent.get("did").and_then(Value::as_str)?;
4267        Some(crate::character::Character::from_did(did))
4268    }
4269}
4270
4271/// "emoji nickname" label for a peer, falling back to the raw handle.
4272fn persona_label(peer_handle: &str) -> String {
4273    match resolve_persona(peer_handle) {
4274        Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
4275        None => peer_handle.to_string(),
4276    }
4277}
4278
4279/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
4280/// full structured event for tooling consumption; the plain form is a tight
4281/// one-line summary suitable as a harness stream-watcher notification.
4282///
4283/// Kept PURE (no trust I/O) so it stays deterministic and cheap per event.
4284/// Persona enrichment for `--json` belongs at InboxEvent construction in
4285/// `inbox_watch` (a follow-up), not here.
4286fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
4287    if as_json {
4288        Ok(serde_json::to_string(e)?)
4289    } else {
4290        let eid_short: String = e.event_id.chars().take(12).collect();
4291        let body = e.body_preview.replace('\n', " ");
4292        let ts: String = e.timestamp.chars().take(19).collect();
4293        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
4294    }
4295}
4296
4297/// `wire monitor` — long-running line-per-event stream of new inbox events.
4298///
4299/// Built for agent harnesses that have an "every stdout line is a chat
4300/// notification" stream watcher (Claude Code Monitor tool, etc.). One
4301/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
4302/// | python parse | grep -v pair_drop` pipeline operators improvise on day
4303/// one of every wire session.
4304///
4305/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
4306/// pure handshake / liveness noise that operators almost never want
4307/// surfaced. Pass `--include-handshake` if you do.
4308///
4309/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
4310/// doesn't drown the operator in replay), with optional `--replay N` to
4311/// emit the last N events first.
4312fn cmd_monitor(
4313    peer_filter: Option<&str>,
4314    as_json: bool,
4315    include_handshake: bool,
4316    interval_ms: u64,
4317    replay: usize,
4318) -> Result<()> {
4319    let inbox_dir = config::inbox_dir()?;
4320    if !inbox_dir.exists() && !as_json {
4321        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
4322    }
4323    // v0.13.x identity work: monitor owns the inbox cursor across the
4324    // long-running poll loop; collision with another wire process under
4325    // the same WIRE_HOME causes "I'm not seeing X's events" debugging
4326    // rabbit holes. Warn at startup so the operator catches it fast.
4327    crate::session::warn_on_identity_collision(std::process::id(), "monitor");
4328    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
4329
4330    // Optional replay — read existing files and emit the last `replay` events
4331    // (post-filter) before going live. Useful when the harness restarts and
4332    // wants recent context.
4333    if replay > 0 && inbox_dir.exists() {
4334        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
4335        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
4336            let path = entry.path();
4337            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4338                continue;
4339            }
4340            let peer = match path.file_stem().and_then(|s| s.to_str()) {
4341                Some(s) => s.to_string(),
4342                None => continue,
4343            };
4344            if let Some(filter) = peer_filter
4345                && peer != filter
4346            {
4347                continue;
4348            }
4349            let body = std::fs::read_to_string(&path).unwrap_or_default();
4350            for line in body.lines() {
4351                let line = line.trim();
4352                if line.is_empty() {
4353                    continue;
4354                }
4355                let signed: Value = match serde_json::from_str(line) {
4356                    Ok(v) => v,
4357                    Err(_) => continue,
4358                };
4359                let ev = crate::inbox_watch::InboxEvent::from_signed(
4360                    &peer, signed, /* verified */ true,
4361                );
4362                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4363                    continue;
4364                }
4365                all.push(ev);
4366            }
4367        }
4368        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
4369        // chronological for same-zoned timestamps).
4370        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
4371        let start = all.len().saturating_sub(replay);
4372        for ev in &all[start..] {
4373            println!("{}", monitor_render(ev, as_json)?);
4374        }
4375        use std::io::Write;
4376        std::io::stdout().flush().ok();
4377    }
4378
4379    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
4380    // the first poll only returns events that arrived AFTER startup.
4381    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
4382    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
4383
4384    loop {
4385        // Never die silently. wisp-blossom (Win10) saw `wire monitor` exit 1
4386        // with ZERO bytes on stdout+stderr when a cursor-block (untrusted
4387        // signer's pair event) tripped the watcher — a silent death looks
4388        // identical to "still watching" and breaks the sister-collab model.
4389        // Surface the reason and KEEP watching instead of propagating a fatal
4390        // `?` that some callers swallow.
4391        let events = match w.poll() {
4392            Ok(evs) => evs,
4393            Err(e) => {
4394                eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
4395                std::thread::sleep(sleep_dur);
4396                continue;
4397            }
4398        };
4399        let mut wrote = false;
4400        for ev in events {
4401            if let Some(filter) = peer_filter
4402                && ev.peer != filter
4403            {
4404                continue;
4405            }
4406            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4407                continue;
4408            }
4409            println!("{}", monitor_render(&ev, as_json)?);
4410            wrote = true;
4411        }
4412        if wrote {
4413            use std::io::Write;
4414            std::io::stdout().flush().ok();
4415        }
4416        std::thread::sleep(sleep_dur);
4417    }
4418}
4419
4420#[cfg(test)]
4421mod tier_tests {
4422    use super::*;
4423    use serde_json::json;
4424
4425    fn trust_with(handle: &str, tier: &str) -> Value {
4426        json!({
4427            "version": 1,
4428            "agents": {
4429                handle: {
4430                    "tier": tier,
4431                    "did": format!("did:wire:{handle}"),
4432                    "card": {"capabilities": ["wire/v3.1"]}
4433                }
4434            }
4435        })
4436    }
4437
4438    #[test]
4439    fn pending_ack_when_verified_but_no_slot_token() {
4440        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
4441        // slot_token hasn't arrived yet. Display PENDING_ACK so the
4442        // operator knows wire send won't work yet.
4443        let trust = trust_with("willard", "VERIFIED");
4444        let relay_state = json!({
4445            "peers": {
4446                "willard": {
4447                    "relay_url": "https://relay",
4448                    "slot_id": "abc",
4449                    "slot_token": "",
4450                }
4451            }
4452        });
4453        assert_eq!(
4454            effective_peer_tier(&trust, &relay_state, "willard"),
4455            "PENDING_ACK"
4456        );
4457    }
4458
4459    #[test]
4460    fn verified_when_slot_token_present() {
4461        let trust = trust_with("willard", "VERIFIED");
4462        let relay_state = json!({
4463            "peers": {
4464                "willard": {
4465                    "relay_url": "https://relay",
4466                    "slot_id": "abc",
4467                    "slot_token": "tok123",
4468                }
4469            }
4470        });
4471        assert_eq!(
4472            effective_peer_tier(&trust, &relay_state, "willard"),
4473            "VERIFIED"
4474        );
4475    }
4476
4477    #[test]
4478    fn raw_tier_passes_through_for_non_verified() {
4479        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
4480        // UNTRUSTED regardless of slot_token state.
4481        let trust = trust_with("willard", "UNTRUSTED");
4482        let relay_state = json!({
4483            "peers": {"willard": {"slot_token": ""}}
4484        });
4485        assert_eq!(
4486            effective_peer_tier(&trust, &relay_state, "willard"),
4487            "UNTRUSTED"
4488        );
4489    }
4490
4491    #[test]
4492    fn pending_ack_when_relay_state_missing_peer() {
4493        // After wire add, trust gets updated BEFORE relay_state.peers does.
4494        // If relay_state has no entry for the peer at all, the operator
4495        // still hasn't completed the bilateral pin — show PENDING_ACK.
4496        let trust = trust_with("willard", "VERIFIED");
4497        let relay_state = json!({"peers": {}});
4498        assert_eq!(
4499            effective_peer_tier(&trust, &relay_state, "willard"),
4500            "PENDING_ACK"
4501        );
4502    }
4503}
4504
4505#[cfg(test)]
4506mod monitor_tests {
4507    use super::*;
4508    use crate::inbox_watch::InboxEvent;
4509    use serde_json::Value;
4510
4511    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4512        InboxEvent {
4513            peer: peer.to_string(),
4514            event_id: "abcd1234567890ef".to_string(),
4515            kind: kind.to_string(),
4516            body_preview: body.to_string(),
4517            verified: true,
4518            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4519            raw: Value::Null,
4520        }
4521    }
4522
4523    #[test]
4524    fn monitor_filter_drops_handshake_kinds_by_default() {
4525        // The whole point: pair_drop / pair_drop_ack / heartbeat are
4526        // protocol noise. If they leak into the operator's chat stream by
4527        // default, the recipe is useless ("wire monitor talks too much,
4528        // disabled it"). Burn this rule in.
4529        assert!(monitor_is_noise_kind("pair_drop"));
4530        assert!(monitor_is_noise_kind("pair_drop_ack"));
4531        assert!(monitor_is_noise_kind("heartbeat"));
4532
4533        // Real-payload kinds — operator wants every one.
4534        assert!(!monitor_is_noise_kind("claim"));
4535        assert!(!monitor_is_noise_kind("decision"));
4536        assert!(!monitor_is_noise_kind("ack"));
4537        assert!(!monitor_is_noise_kind("request"));
4538        assert!(!monitor_is_noise_kind("note"));
4539        // Unknown future kinds shouldn't be filtered as noise either —
4540        // operator probably wants to see something they don't recognise,
4541        // not have it silently dropped (the P0.1 lesson at the UX layer).
4542        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4543    }
4544
4545    #[test]
4546    fn monitor_render_plain_is_one_short_line() {
4547        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4548        let line = monitor_render(&e, false).unwrap();
4549        // Must be single-line.
4550        assert!(!line.contains('\n'), "render must be one line: {line}");
4551        // Must include peer, kind, body fragment, short event_id.
4552        assert!(line.contains("willard"));
4553        assert!(line.contains("claim"));
4554        assert!(line.contains("real v8 train"));
4555        // Short event id (first 12 chars).
4556        assert!(line.contains("abcd12345678"));
4557        assert!(
4558            !line.contains("abcd1234567890ef"),
4559            "should truncate full id"
4560        );
4561        // RFC3339-ish second precision.
4562        assert!(line.contains("2026-05-15T23:14:07"));
4563    }
4564
4565    #[test]
4566    fn monitor_render_strips_newlines_from_body() {
4567        // Multi-line bodies (markdown lists, code, etc.) must collapse to
4568        // one line — otherwise a single message produces multiple
4569        // notifications in the harness, ruining the "one event = one line"
4570        // contract the Monitor tool relies on.
4571        let e = ev("spark", "claim", "line one\nline two\nline three");
4572        let line = monitor_render(&e, false).unwrap();
4573        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4574        assert!(line.contains("line one line two line three"));
4575    }
4576
4577    #[test]
4578    fn monitor_render_json_is_valid_jsonl() {
4579        let e = ev("spark", "claim", "hi");
4580        let line = monitor_render(&e, true).unwrap();
4581        assert!(!line.contains('\n'));
4582        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4583        assert_eq!(parsed["peer"], "spark");
4584        assert_eq!(parsed["kind"], "claim");
4585        assert_eq!(parsed["body_preview"], "hi");
4586    }
4587
4588    #[test]
4589    fn monitor_does_not_drop_on_verified_null() {
4590        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
4591        // `select(.verified == true)` against inbox JSONL. Daemon writes
4592        // events with verified=null (verification happens at tail-time, not
4593        // write-time), so the filter silently rejected everything — same
4594        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
4595        // never surfaced for ~30min.
4596        //
4597        // wire monitor's render path must NOT consult `.verified` for any
4598        // filter decision. Lock that in here so a future "be conservative,
4599        // only emit verified" patch can't quietly land.
4600        let mut e = ev("spark", "claim", "from disk with verified=null");
4601        e.verified = false; // worst case — even if disk says unverified, emit
4602        let line = monitor_render(&e, false).unwrap();
4603        assert!(line.contains("from disk with verified=null"));
4604        // Noise filter operates purely on kind, never on verified.
4605        assert!(!monitor_is_noise_kind("claim"));
4606    }
4607}
4608
4609// ---------- verify ----------
4610
4611fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4612    let body = if path == "-" {
4613        let mut buf = String::new();
4614        use std::io::Read;
4615        std::io::stdin().read_to_string(&mut buf)?;
4616        buf
4617    } else {
4618        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4619    };
4620    let event: Value = serde_json::from_str(&body)?;
4621    let trust = config::read_trust()?;
4622    match verify_message_v31(&event, &trust) {
4623        Ok(()) => {
4624            if as_json {
4625                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4626            } else {
4627                println!("verified ✓");
4628            }
4629            Ok(())
4630        }
4631        Err(e) => {
4632            let reason = e.to_string();
4633            if as_json {
4634                println!(
4635                    "{}",
4636                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4637                );
4638            } else {
4639                eprintln!("FAILED: {reason}");
4640            }
4641            std::process::exit(1);
4642        }
4643    }
4644}
4645
4646// ---------- mcp / relay-server stubs ----------
4647
4648fn cmd_mcp() -> Result<()> {
4649    crate::mcp::run()
4650}
4651
4652fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4653    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
4654    // overriding --bind. Implies --local-only semantics. Routed to a
4655    // separate serve_uds entry point with a manual hyper accept loop
4656    // (axum 0.7's `serve` is TcpListener-only).
4657    if let Some(socket_path) = uds {
4658        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4659            std::path::PathBuf::from(home)
4660                .join("state")
4661                .join("wire-relay")
4662                .join("uds")
4663        } else {
4664            dirs::state_dir()
4665                .or_else(dirs::data_local_dir)
4666                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4667                .join("wire-relay")
4668                .join("uds")
4669        };
4670        let runtime = tokio::runtime::Builder::new_multi_thread()
4671            .enable_all()
4672            .build()?;
4673        return runtime.block_on(crate::relay_server::serve_uds(
4674            socket_path.to_path_buf(),
4675            base,
4676        ));
4677    }
4678    // v0.5.17: --local-only refuses non-loopback binds. Catches the
4679    // "wait did I just bind a publicly-reachable local-only relay" mistake
4680    // at startup rather than discovering it via an empty phonebook later.
4681    if local_only {
4682        validate_loopback_bind(bind)?;
4683    }
4684    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
4685    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
4686    // so a single user can run both client and server on one machine.
4687    // For --local-only, suffix with /local so a single operator can run
4688    // both a federation relay and a local-only relay without state collision.
4689    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4690        std::path::PathBuf::from(home)
4691            .join("state")
4692            .join("wire-relay")
4693    } else {
4694        dirs::state_dir()
4695            .or_else(dirs::data_local_dir)
4696            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4697            .join("wire-relay")
4698    };
4699    let state_dir = if local_only { base.join("local") } else { base };
4700    let runtime = tokio::runtime::Builder::new_multi_thread()
4701        .enable_all()
4702        .build()?;
4703    runtime.block_on(crate::relay_server::serve_with_mode(
4704        bind,
4705        state_dir,
4706        crate::relay_server::ServerMode { local_only },
4707    ))
4708}
4709
4710/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
4711/// resolves to something outside `127.0.0.0/8` or `::1`.
4712///
4713/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
4714/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
4715/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
4716///
4717/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
4718/// is the IP range Tailscale uses for tailnet addresses. Lets operators
4719/// pair wire across machines using their tailnet IPs (e.g. Mac at
4720/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
4721/// auth + encryption + NAT traversal, wire handles protocol + identity.
4722/// Sidesteps host firewall config entirely (utun interface bypass).
4723///
4724/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
4725/// multicast, broadcast. Those would publish a "local-only" relay to
4726/// the global internet — the v0.5.17 security gate's whole point.
4727fn validate_loopback_bind(bind: &str) -> Result<()> {
4728    // Split host:port. IPv6 literals use `[::]:port` form.
4729    let host = if let Some(stripped) = bind.strip_prefix('[') {
4730        let close = stripped
4731            .find(']')
4732            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4733        stripped[..close].to_string()
4734    } else {
4735        bind.rsplit_once(':')
4736            .map(|(h, _)| h.to_string())
4737            .unwrap_or_else(|| bind.to_string())
4738    };
4739    use std::net::{IpAddr, ToSocketAddrs};
4740    let probe = format!("{host}:0");
4741    let resolved: Vec<_> = probe
4742        .to_socket_addrs()
4743        .with_context(|| format!("resolving bind host {host:?}"))?
4744        .collect();
4745    if resolved.is_empty() {
4746        bail!("--local-only: bind host {host:?} resolved to no addresses");
4747    }
4748    for addr in &resolved {
4749        let ip = addr.ip();
4750        let is_acceptable = match ip {
4751            IpAddr::V4(v4) => {
4752                v4.is_loopback() || v4.is_private() || {
4753                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
4754                    let octets = v4.octets();
4755                    octets[0] == 100 && (64..=127).contains(&octets[1])
4756                }
4757            }
4758            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
4759        };
4760        if !is_acceptable {
4761            bail!(
4762                "--local-only refuses non-private bind: {host:?} resolves to {ip} \
4763                 which is not loopback (127/8, ::1), RFC 1918 private \
4764                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4765                 (100.64.0.0/10). Remove --local-only to bind publicly."
4766            );
4767        }
4768    }
4769    Ok(())
4770}
4771
4772// ---------- bind-relay ----------
4773
4774fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4775    use crate::endpoints::EndpointScope;
4776    match s.to_lowercase().as_str() {
4777        "federation" | "fed" => Ok(EndpointScope::Federation),
4778        "local" => Ok(EndpointScope::Local),
4779        "lan" => Ok(EndpointScope::Lan),
4780        "uds" => Ok(EndpointScope::Uds),
4781        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4782    }
4783}
4784
4785/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
4786/// appended to `self.endpoints[]`, keeping any existing slots so an agent
4787/// can hold a local relay AND a federation relay simultaneously without
4788/// black-holing pinned peers. `--replace` restores the pre-v0.12
4789/// destructive single-slot behavior (guarded by issue #7).
4790fn cmd_bind_relay(
4791    url: &str,
4792    scope: Option<&str>,
4793    replace: bool,
4794    migrate_pinned: bool,
4795    as_json: bool,
4796) -> Result<()> {
4797    use crate::endpoints::{Endpoint, self_endpoints};
4798
4799    if !config::is_initialized()? {
4800        bail!("not initialized — run `wire init <handle>` first");
4801    }
4802    let card = config::read_agent_card()?;
4803    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4804    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4805
4806    let normalized_raw = url.trim_end_matches('/');
4807    // Refuse to record/publish a relay endpoint that embeds userinfo —
4808    // `https://<handle>@<host>` 4xxes every inbound event POST. Strip and
4809    // warn so operators learn the right shape without losing the call.
4810    let normalized_owned = strip_relay_url_userinfo(normalized_raw);
4811    let normalized = normalized_owned.as_str();
4812    // Belt-and-suspenders: confirm the post-strip URL is clean before any
4813    // persist / publish. A future code path that bypasses the strip filter
4814    // MUST NOT be able to leak userinfo into the signed agent-card.
4815    assert_relay_url_clean_for_publish(normalized)?;
4816    let new_scope = match scope {
4817        Some(s) => parse_scope(s)?,
4818        None => crate::endpoints::infer_scope_from_url(normalized),
4819    };
4820
4821    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4822    let pinned: Vec<String> = existing
4823        .get("peers")
4824        .and_then(|p| p.as_object())
4825        .map(|o| o.keys().cloned().collect())
4826        .unwrap_or_default();
4827
4828    let existing_eps = self_endpoints(&existing);
4829    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4830
4831    // Destructive paths that black-hole pinned peers (issue #7):
4832    //   • `--replace` drops every other slot.
4833    //   • re-binding the SAME relay rotates that slot in place.
4834    // An additive bind of a NEW relay keeps existing slots, so peers stay
4835    // reachable — no acknowledgement required. This is the v0.12 default
4836    // that unblocks simultaneous local + remote.
4837    let destructive = replace || is_rebind_same;
4838    if destructive && !pinned.is_empty() && !migrate_pinned {
4839        let list = pinned.join(", ");
4840        let why = if replace {
4841            "`--replace` drops your other slot(s)"
4842        } else {
4843            "re-binding the same relay rotates its slot"
4844        };
4845        bail!(
4846            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4847             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4848             read.\n\n\
4849             SAFE PATHS:\n\
4850             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4851             slots — no black-hole.\n\
4852             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4853             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4854             peer out-of-band.\n\n\
4855             Issue #7 (silent black-hole on relay change) caught this.",
4856            n = pinned.len(),
4857        );
4858    }
4859
4860    let client = crate::relay_client::RelayClient::new(normalized);
4861    client.check_healthz()?;
4862    let alloc = client.allocate_slot(Some(&handle))?;
4863
4864    if destructive && !pinned.is_empty() {
4865        eprintln!(
4866            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4867             until they re-pin: {peers}",
4868            mode = if replace { "replacing" } else { "rotating" },
4869            n = pinned.len(),
4870            peers = pinned.join(", "),
4871        );
4872    }
4873
4874    // Write the new slot via the single source of truth for the self-slot
4875    // shape. Additive by default; --replace starts from an empty self so
4876    // only this slot remains.
4877    let mut state = existing;
4878    if replace {
4879        state["self"] = Value::Null;
4880    }
4881    crate::endpoints::upsert_self_endpoint(
4882        &mut state,
4883        Endpoint {
4884            relay_url: normalized.to_string(),
4885            slot_id: alloc.slot_id.clone(),
4886            slot_token: alloc.slot_token.clone(),
4887            scope: new_scope,
4888        },
4889    );
4890    config::write_relay_state(&state)?;
4891    let eps = self_endpoints(&state);
4892
4893    let scope_str = format!("{new_scope:?}").to_lowercase();
4894    if as_json {
4895        println!(
4896            "{}",
4897            serde_json::to_string(&json!({
4898                "relay_url": normalized,
4899                "slot_id": alloc.slot_id,
4900                "scope": scope_str,
4901                "endpoints": eps.len(),
4902                "additive": !replace,
4903                "slot_token_present": true,
4904            }))?
4905        );
4906    } else {
4907        println!(
4908            "bound {scope_str} slot on {normalized} (slot {})",
4909            alloc.slot_id
4910        );
4911        println!(
4912            "self now has {n} endpoint(s): {list}",
4913            n = eps.len(),
4914            list = eps
4915                .iter()
4916                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4917                .collect::<Vec<_>>()
4918                .join(", "),
4919        );
4920    }
4921    Ok(())
4922}
4923
4924// ---------- add-peer-slot ----------
4925
4926fn cmd_add_peer_slot(
4927    handle: &str,
4928    url: &str,
4929    slot_id: &str,
4930    slot_token: &str,
4931    as_json: bool,
4932) -> Result<()> {
4933    use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
4934    let mut state = config::read_relay_state()?;
4935
4936    // E3 (v0.13.2): ADD this slot to the peer's endpoint set — don't REPLACE
4937    // the whole entry. The old flat `peers.insert` clobbered an existing
4938    // peer's federation endpoint when pinning a local slot, silently dropping
4939    // the federation route (glossy-magnolia + wisp-blossom repro: pinning a
4940    // loopback slot made the peer flat loopback-only). Mirror bind-relay's
4941    // additive semantics: upsert by relay_url into the peer's endpoints[].
4942    let new_ep = Endpoint {
4943        relay_url: url.to_string(),
4944        slot_id: slot_id.to_string(),
4945        slot_token: slot_token.to_string(),
4946        scope: infer_scope_from_url(url),
4947    };
4948    let mut endpoints: Vec<Endpoint> = state
4949        .get("peers")
4950        .and_then(|p| p.get(handle))
4951        .and_then(|e| e.get("endpoints"))
4952        .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
4953        .unwrap_or_default();
4954    // Back-compat: seed from legacy flat fields when the peer predates endpoints[].
4955    if endpoints.is_empty()
4956        && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
4957        && let (Some(ru), Some(si), Some(st)) = (
4958            peer.get("relay_url").and_then(Value::as_str),
4959            peer.get("slot_id").and_then(Value::as_str),
4960            peer.get("slot_token").and_then(Value::as_str),
4961        )
4962    {
4963        endpoints.push(Endpoint {
4964            relay_url: ru.to_string(),
4965            slot_id: si.to_string(),
4966            slot_token: st.to_string(),
4967            scope: infer_scope_from_url(ru),
4968        });
4969    }
4970    // Upsert by relay_url: refresh in place if already pinned, else append.
4971    if let Some(existing) = endpoints
4972        .iter_mut()
4973        .find(|e| e.relay_url == new_ep.relay_url)
4974    {
4975        *existing = new_ep;
4976    } else {
4977        endpoints.push(new_ep);
4978    }
4979    let n = endpoints.len();
4980    pin_peer_endpoints(&mut state, handle, &endpoints)?;
4981    config::write_relay_state(&state)?;
4982    if as_json {
4983        println!(
4984            "{}",
4985            serde_json::to_string(&json!({
4986                "handle": handle,
4987                "relay_url": url,
4988                "slot_id": slot_id,
4989                "added": true,
4990                "endpoint_count": n,
4991            }))?
4992        );
4993    } else {
4994        println!(
4995            "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
4996        );
4997    }
4998    Ok(())
4999}
5000
5001// ---------- push ----------
5002
5003fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
5004    let mut state = config::read_relay_state()?;
5005    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5006    if peers.is_empty() {
5007        bail!(
5008            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
5009        );
5010    }
5011    let outbox_dir = config::outbox_dir()?;
5012    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
5013    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
5014    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
5015    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
5016    if outbox_dir.exists() {
5017        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
5018        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
5019            let path = entry.path();
5020            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5021                continue;
5022            }
5023            let stem = match path.file_stem().and_then(|s| s.to_str()) {
5024                Some(s) => s.to_string(),
5025                None => continue,
5026            };
5027            if pinned.contains(&stem) {
5028                continue;
5029            }
5030            // Try the bare-handle of the orphaned stem — if THAT matches a
5031            // pinned peer, the stem is a stale FQDN-suffixed file.
5032            let bare = crate::agent_card::bare_handle(&stem);
5033            if pinned.contains(bare) {
5034                eprintln!(
5035                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
5036                     Merge with: `cat {} >> {}` then delete the FQDN file.",
5037                    stem,
5038                    path.display(),
5039                    outbox_dir.join(format!("{bare}.jsonl")).display(),
5040                );
5041            }
5042        }
5043    }
5044    if !outbox_dir.exists() {
5045        if as_json {
5046            println!(
5047                "{}",
5048                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
5049            );
5050        } else {
5051            println!("phyllis: nothing to dial out — write a message first with `wire send`");
5052        }
5053        return Ok(());
5054    }
5055
5056    let mut pushed = Vec::new();
5057    let mut skipped = Vec::new();
5058
5059    // Issue #15: track which peers we've already re-resolved this push call
5060    // so we don't whois more than once per peer per push (the rate limit the
5061    // issue specifies). Lifetime is the whole `cmd_push` invocation; clears
5062    // every time the operator (or daemon) runs `wire push` again.
5063    let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
5064    // Track whether we mutated `state` so we can write it back exactly
5065    // once at the end (avoids a write per peer).
5066    let mut state_dirty = false;
5067
5068    // v0.5.17: walk each peer's pinned endpoints in priority order (local
5069    // first if we share a local relay, federation second). Try POST on the
5070    // first endpoint; on transport failure, fall through to the next.
5071    // Falls back to the v0.5.16 legacy single-endpoint code path when the
5072    // peer record carries no `endpoints[]` array (back-compat).
5073    for (peer_handle, _) in peers.iter() {
5074        if let Some(want) = peer_filter
5075            && peer_handle != want
5076        {
5077            continue;
5078        }
5079        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5080        if !outbox.exists() {
5081            continue;
5082        }
5083        let mut ordered_endpoints =
5084            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
5085        if ordered_endpoints.is_empty() {
5086            // Unreachable peer (no federation endpoint AND our local
5087            // relay doesn't match the peer's). Skip with a loud reason
5088            // rather than silently dropping events.
5089            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
5090                let event: Value = match serde_json::from_str(line) {
5091                    Ok(v) => v,
5092                    Err(_) => continue,
5093                };
5094                let event_id = event
5095                    .get("event_id")
5096                    .and_then(Value::as_str)
5097                    .unwrap_or("")
5098                    .to_string();
5099                skipped.push(json!({
5100                    "peer": peer_handle,
5101                    "event_id": event_id,
5102                    "reason": "no reachable endpoint pinned for peer",
5103                }));
5104            }
5105            continue;
5106        }
5107        let body = std::fs::read_to_string(&outbox)?;
5108        for line in body.lines() {
5109            let event: Value = match serde_json::from_str(line) {
5110                Ok(v) => v,
5111                Err(_) => continue,
5112            };
5113            let event_id = event
5114                .get("event_id")
5115                .and_then(Value::as_str)
5116                .unwrap_or("")
5117                .to_string();
5118
5119            // Capture the most recent per-endpoint error reason via a RefCell
5120            // so we can preserve cmd_push's pre-existing "last-error wins"
5121            // semantics for the skipped-with-reason path. The shared
5122            // try_post_event_with_failover helper (from #62) handles iteration,
5123            // priority order, and early-return on first success; the closure
5124            // applies the existing `format_transport_error` formatting on
5125            // each individual error so the operator sees the same diagnostic
5126            // text as before the dedup.
5127            let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
5128            match crate::relay_client::try_post_event_with_failover(
5129                &ordered_endpoints,
5130                &event,
5131                |endpoint, ev| {
5132                    let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5133                    match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
5134                        Ok(resp) => Ok(resp),
5135                        Err(e) => {
5136                            *last_err.borrow_mut() =
5137                                Some(crate::relay_client::format_transport_error(&e));
5138                            Err(e)
5139                        }
5140                    }
5141                },
5142            ) {
5143                Ok((endpoint, resp)) => {
5144                    if resp.status == "duplicate" {
5145                        skipped.push(json!({
5146                            "peer": peer_handle,
5147                            "event_id": event_id,
5148                            "reason": "duplicate",
5149                            "endpoint": endpoint.relay_url,
5150                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5151                        }));
5152                    } else {
5153                        pushed.push(json!({
5154                            "peer": peer_handle,
5155                            "event_id": event_id,
5156                            "endpoint": endpoint.relay_url,
5157                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5158                        }));
5159                    }
5160                }
5161                Err(_) => {
5162                    // Issue #15: before reporting the event as skipped, see
5163                    // if the failure smelled like a slot-rotation (4xx 404 /
5164                    // 410). If yes AND we haven't already re-resolved this
5165                    // peer in this push call, attempt one whois lookup. On
5166                    // a real rotation, the helper updates `state.peers[peer]`
5167                    // in place; we refresh `ordered_endpoints` from the
5168                    // mutated state and retry the same event once. Composes
5169                    // with the doctor #14 staleness check from PR #68: #14
5170                    // surfaces the symptom, #15 closes the loop.
5171                    let last_err_text = last_err.borrow().clone().unwrap_or_default();
5172                    let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
5173                    match try_reresolve_peer_on_slot_4xx(
5174                        &mut state,
5175                        peer_handle,
5176                        &last_err_text,
5177                        &rotated_this_push,
5178                    ) {
5179                        Ok(true) => {
5180                            // Mark this peer as already re-resolved this push.
5181                            rotated_this_push.insert(peer_handle.clone());
5182                            state_dirty = true;
5183                            // Refresh endpoints from the updated state and
5184                            // retry exactly once. last_err is also reset so
5185                            // the retry's error (if any) replaces the prior
5186                            // one in the eventual skipped reason.
5187                            ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
5188                                &state,
5189                                peer_handle,
5190                            );
5191                            *last_err.borrow_mut() = None;
5192                            if let Ok((endpoint, resp)) =
5193                                crate::relay_client::try_post_event_with_failover(
5194                                    &ordered_endpoints,
5195                                    &event,
5196                                    |endpoint, ev| {
5197                                        let client = crate::relay_client::RelayClient::new(
5198                                            &endpoint.relay_url,
5199                                        );
5200                                        match client.post_event(
5201                                            &endpoint.slot_id,
5202                                            &endpoint.slot_token,
5203                                            ev,
5204                                        ) {
5205                                            Ok(resp) => Ok(resp),
5206                                            Err(e) => {
5207                                                *last_err.borrow_mut() = Some(
5208                                                    crate::relay_client::format_transport_error(&e),
5209                                                );
5210                                                Err(e)
5211                                            }
5212                                        }
5213                                    },
5214                                )
5215                            {
5216                                delivered_via_retry = Some((endpoint, resp));
5217                            }
5218                        }
5219                        Ok(false) => {
5220                            // Either not a slot-rotation shape, or already
5221                            // re-resolved this push, or slot id unchanged —
5222                            // fall through to the original skipped path.
5223                        }
5224                        Err(e) => {
5225                            // Re-resolve itself failed (DNS down, relay 5xx,
5226                            // handle unclaimed, etc.). Don't fail the push —
5227                            // fall through to skipped with the resolve error
5228                            // appended for diagnostic context.
5229                            *last_err.borrow_mut() = Some(format!(
5230                                "{}; re-resolve also failed: {e:#}",
5231                                last_err.borrow().clone().unwrap_or_default()
5232                            ));
5233                            // Mark as tried so we don't loop on the next event.
5234                            rotated_this_push.insert(peer_handle.clone());
5235                        }
5236                    }
5237                    if let Some((endpoint, resp)) = delivered_via_retry {
5238                        if resp.status == "duplicate" {
5239                            skipped.push(json!({
5240                                "peer": peer_handle,
5241                                "event_id": event_id,
5242                                "reason": "duplicate",
5243                                "endpoint": endpoint.relay_url,
5244                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5245                                "via": "slot_reresolve_retry",
5246                            }));
5247                        } else {
5248                            pushed.push(json!({
5249                                "peer": peer_handle,
5250                                "event_id": event_id,
5251                                "endpoint": endpoint.relay_url,
5252                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5253                                "via": "slot_reresolve_retry",
5254                            }));
5255                        }
5256                    } else {
5257                        // Every endpoint failed even after (any) retry.
5258                        // Preserve the prior "last reason is what gets
5259                        // reported" UX (the closure captured the last per-
5260                        // endpoint error via `last_err`).
5261                        skipped.push(json!({
5262                            "peer": peer_handle,
5263                            "event_id": event_id,
5264                            "reason": last_err
5265                                .borrow()
5266                                .clone()
5267                                .unwrap_or_else(|| "all endpoints failed".to_string()),
5268                        }));
5269                    }
5270                }
5271            }
5272        }
5273    }
5274
5275    // Issue #15: persist any in-place slot rotations from the per-peer loop
5276    // exactly once at the end. Best-effort: if the write fails the operator
5277    // still gets a valid push report, and the next push will re-attempt the
5278    // resolve (cheap) before retrying delivery.
5279    if state_dirty && let Err(e) = config::write_relay_state(&state) {
5280        eprintln!(
5281            "wire push: WARN failed to persist rotated peer slots: {e:#}. \
5282             Slot rotation will be re-attempted on next push."
5283        );
5284    }
5285
5286    if as_json {
5287        println!(
5288            "{}",
5289            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
5290        );
5291    } else {
5292        println!(
5293            "pushed {} event(s); skipped {} ({})",
5294            pushed.len(),
5295            skipped.len(),
5296            if skipped.is_empty() {
5297                "none"
5298            } else {
5299                "see --json for detail"
5300            }
5301        );
5302    }
5303    Ok(())
5304}
5305
5306// ---------- pull ----------
5307
5308fn cmd_pull(as_json: bool) -> Result<()> {
5309    let state = config::read_relay_state()?;
5310    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5311    if self_state.is_null() {
5312        bail!("self slot not bound — run `wire bind-relay <url>` first");
5313    }
5314
5315    // v0.5.17: pull from every endpoint in self.endpoints (federation +
5316    // optional local). Each endpoint has its own per-scope cursor so we
5317    // don't re-pull events we've already seen on that path. Events from
5318    // all endpoints feed into the same inbox JSONL via process_events;
5319    // dedup by event_id is the last line of defense.
5320    // Falls back to a single federation endpoint synthesized from the
5321    // top-level legacy fields when self.endpoints is absent (v0.5.16
5322    // back-compat).
5323    let endpoints = crate::endpoints::self_endpoints(&state);
5324    if endpoints.is_empty() {
5325        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
5326    }
5327
5328    let inbox_dir = config::inbox_dir()?;
5329    config::ensure_dirs()?;
5330
5331    let mut total_seen = 0usize;
5332    let mut all_written: Vec<Value> = Vec::new();
5333    let mut all_rejected: Vec<Value> = Vec::new();
5334    let mut all_blocked = false;
5335    let mut all_advance_cursor_to: Option<String> = None;
5336
5337    for endpoint in &endpoints {
5338        let cursor_key = endpoint_cursor_key(endpoint.scope);
5339        let last_event_id = self_state
5340            .get(&cursor_key)
5341            .and_then(Value::as_str)
5342            .map(str::to_string);
5343        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5344        let events = match client.list_events(
5345            &endpoint.slot_id,
5346            &endpoint.slot_token,
5347            last_event_id.as_deref(),
5348            Some(1000),
5349        ) {
5350            Ok(ev) => ev,
5351            Err(e) => {
5352                // One endpoint's failure shouldn't kill the whole pull.
5353                // The local-relay-down case in particular needs to
5354                // gracefully continue against federation.
5355                eprintln!(
5356                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
5357                    endpoint.relay_url,
5358                    endpoint.scope,
5359                    crate::relay_client::format_transport_error(&e),
5360                );
5361                continue;
5362            }
5363        };
5364        total_seen += events.len();
5365        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
5366        all_written.extend(result.written.iter().cloned());
5367        all_rejected.extend(result.rejected.iter().cloned());
5368        if result.blocked {
5369            all_blocked = true;
5370        }
5371        // Advance per-endpoint cursor. The cursor key is scope-specific
5372        // so federation and local don't trample each other.
5373        if let Some(eid) = result.advance_cursor_to.clone() {
5374            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
5375                all_advance_cursor_to = Some(eid.clone());
5376            }
5377            let key = cursor_key.clone();
5378            config::update_relay_state(|state| {
5379                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5380                    self_obj.insert(key, Value::String(eid));
5381                }
5382                Ok(())
5383            })?;
5384        }
5385    }
5386
5387    // Compatibility shim for the legacy single-cursor code paths below:
5388    // `result` used to come from one process_events call; we now have
5389    // per-endpoint results aggregated into the all_* accumulators.
5390    // Reconstruct a synthetic result for the remaining display logic.
5391    let result = crate::pull::PullResult {
5392        written: all_written,
5393        rejected: all_rejected,
5394        blocked: all_blocked,
5395        advance_cursor_to: all_advance_cursor_to,
5396    };
5397    let events_len = total_seen;
5398
5399    // Cursor advance happened per-endpoint above; no aggregate cursor
5400    // write needed here.
5401
5402    if as_json {
5403        println!(
5404            "{}",
5405            serde_json::to_string(&json!({
5406                "written": result.written,
5407                "rejected": result.rejected,
5408                "total_seen": events_len,
5409                "cursor_blocked": result.blocked,
5410                "cursor_advanced_to": result.advance_cursor_to,
5411            }))?
5412        );
5413    } else {
5414        let blocking = result
5415            .rejected
5416            .iter()
5417            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
5418            .count();
5419        if blocking > 0 {
5420            println!(
5421                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
5422                events_len,
5423                result.written.len(),
5424                result.rejected.len(),
5425                blocking,
5426            );
5427        } else {
5428            println!(
5429                "pulled {} event(s); wrote {}; rejected {}",
5430                events_len,
5431                result.written.len(),
5432                result.rejected.len(),
5433            );
5434        }
5435    }
5436    Ok(())
5437}
5438
5439/// v0.5.17: cursor key for an endpoint's per-scope read position.
5440/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
5441/// back-compat with on-disk relay_state files; local uses a
5442/// `_local` suffix.
5443fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
5444    match scope {
5445        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
5446        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
5447        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
5448        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
5449    }
5450}
5451
5452// ---------- rotate-slot ----------
5453
5454fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
5455    if !config::is_initialized()? {
5456        bail!("not initialized — run `wire init <handle>` first");
5457    }
5458    let mut state = config::read_relay_state()?;
5459    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5460    if self_state.is_null() {
5461        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
5462    }
5463    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
5464    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
5465    // top-level legacy fields directly and bailed for those sessions.
5466    let primary = crate::endpoints::self_primary_endpoint(&state)
5467        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
5468    let url = primary.relay_url.clone();
5469    let old_slot_id = primary.slot_id.clone();
5470    let old_slot_token = primary.slot_token.clone();
5471
5472    // Read identity to sign the announcement.
5473    let card = config::read_agent_card()?;
5474    let did = card
5475        .get("did")
5476        .and_then(Value::as_str)
5477        .unwrap_or("")
5478        .to_string();
5479    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
5480    let pk_b64 = card
5481        .get("verify_keys")
5482        .and_then(Value::as_object)
5483        .and_then(|m| m.values().next())
5484        .and_then(|v| v.get("key"))
5485        .and_then(Value::as_str)
5486        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
5487        .to_string();
5488    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
5489    let sk_seed = config::read_private_key()?;
5490
5491    // Allocate new slot on the same relay.
5492    let normalized = url.trim_end_matches('/').to_string();
5493    let client = crate::relay_client::RelayClient::new(&normalized);
5494    client
5495        .check_healthz()
5496        .context("aborting rotation; old slot still valid")?;
5497    let alloc = client.allocate_slot(Some(&handle))?;
5498    let new_slot_id = alloc.slot_id.clone();
5499    let new_slot_token = alloc.slot_token.clone();
5500
5501    // Optionally announce the rotation to every paired peer via the OLD slot.
5502    // Each peer's recipient-side `wire pull` will pick up this event before
5503    // their daemon next polls the new slot — but auto-update of peer's
5504    // relay.json from a wire_close event is a v0.2 daemon feature; for now
5505    // peers see the event and an operator must manually `add-peer-slot` the
5506    // new coords, OR re-pair via SAS.
5507    let mut announced: Vec<String> = Vec::new();
5508    if !no_announce {
5509        let now = time::OffsetDateTime::now_utc()
5510            .format(&time::format_description::well_known::Rfc3339)
5511            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
5512        let body = json!({
5513            "reason": "operator-initiated slot rotation",
5514            "new_relay_url": url,
5515            "new_slot_id": new_slot_id,
5516            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
5517            // In v0.1 slot tokens are bilateral-shared, so peer can post via
5518            // existing add-peer-slot flow if operator chooses to re-issue.
5519        });
5520        let peers = state["peers"].as_object().cloned().unwrap_or_default();
5521        for (peer_handle, _peer_info) in peers.iter() {
5522            let event = json!({
5523                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5524                "timestamp": now.clone(),
5525                "from": did,
5526                "to": format!("did:wire:{peer_handle}"),
5527                "type": "wire_close",
5528                "kind": 1201,
5529                "body": body.clone(),
5530            });
5531            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
5532                Ok(s) => s,
5533                Err(e) => {
5534                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
5535                    continue;
5536                }
5537            };
5538            // Post to OUR old slot (we're announcing on our own slot, NOT
5539            // peer's slot — peer reads from us). Wait, this is wrong: peers
5540            // read from THEIR OWN slot via wire pull. To reach peer A, we
5541            // post to peer A's slot. Use the existing per-peer slot mapping.
5542            let peer_info = match state["peers"].get(peer_handle) {
5543                Some(p) => p.clone(),
5544                None => continue,
5545            };
5546            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
5547            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
5548            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
5549            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
5550                continue;
5551            }
5552            let peer_client = if peer_url == url {
5553                client.clone()
5554            } else {
5555                crate::relay_client::RelayClient::new(peer_url)
5556            };
5557            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
5558                Ok(_) => announced.push(peer_handle.clone()),
5559                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
5560            }
5561        }
5562    }
5563
5564    // Swap the self-slot to the new one.
5565    state["self"] = json!({
5566        "relay_url": url,
5567        "slot_id": new_slot_id,
5568        "slot_token": new_slot_token,
5569    });
5570    config::write_relay_state(&state)?;
5571
5572    if as_json {
5573        println!(
5574            "{}",
5575            serde_json::to_string(&json!({
5576                "rotated": true,
5577                "old_slot_id": old_slot_id,
5578                "new_slot_id": new_slot_id,
5579                "relay_url": url,
5580                "announced_to": announced,
5581            }))?
5582        );
5583    } else {
5584        println!("rotated slot on {url}");
5585        println!(
5586            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
5587        );
5588        println!("  new slot_id: {new_slot_id}");
5589        if !announced.is_empty() {
5590            println!(
5591                "  announced wire_close (kind=1201) to: {}",
5592                announced.join(", ")
5593            );
5594        }
5595        println!();
5596        println!("next steps:");
5597        println!("  - peers see the wire_close event in their next `wire pull`");
5598        println!(
5599            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
5600        );
5601        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
5602        println!("  - until they do, you'll receive but they won't be able to reach you");
5603        // Suppress unused warning
5604        let _ = old_slot_token;
5605    }
5606    Ok(())
5607}
5608
5609// ---------- forget-peer ----------
5610
5611fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
5612    let mut trust = config::read_trust()?;
5613    let mut removed_from_trust = false;
5614    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
5615        && agents.remove(handle).is_some()
5616    {
5617        removed_from_trust = true;
5618    }
5619    config::write_trust(&trust)?;
5620
5621    let mut state = config::read_relay_state()?;
5622    let mut removed_from_relay = false;
5623    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
5624        && peers.remove(handle).is_some()
5625    {
5626        removed_from_relay = true;
5627    }
5628    config::write_relay_state(&state)?;
5629
5630    let mut purged: Vec<String> = Vec::new();
5631    if purge {
5632        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
5633            let path = dir.join(format!("{handle}.jsonl"));
5634            if path.exists() {
5635                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
5636                purged.push(path.to_string_lossy().into());
5637            }
5638        }
5639    }
5640
5641    if !removed_from_trust && !removed_from_relay {
5642        if as_json {
5643            println!(
5644                "{}",
5645                serde_json::to_string(&json!({
5646                    "removed": false,
5647                    "reason": format!("peer {handle:?} not pinned"),
5648                }))?
5649            );
5650        } else {
5651            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
5652        }
5653        return Ok(());
5654    }
5655
5656    if as_json {
5657        println!(
5658            "{}",
5659            serde_json::to_string(&json!({
5660                "handle": handle,
5661                "removed_from_trust": removed_from_trust,
5662                "removed_from_relay_state": removed_from_relay,
5663                "purged_files": purged,
5664            }))?
5665        );
5666    } else {
5667        println!("forgot peer {handle:?}");
5668        if removed_from_trust {
5669            println!("  - removed from trust.json");
5670        }
5671        if removed_from_relay {
5672            println!("  - removed from relay.json");
5673        }
5674        if !purged.is_empty() {
5675            for p in &purged {
5676                println!("  - deleted {p}");
5677            }
5678        } else if !purge {
5679            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
5680        }
5681    }
5682    Ok(())
5683}
5684
5685// ---------- daemon (long-lived push+pull sync) ----------
5686
5687fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5688    if !config::is_initialized()? {
5689        bail!("not initialized — run `wire init <handle>` first");
5690    }
5691    // v0.13.x identity work: a long-running daemon racing another wire
5692    // process for the same inbox cursor silently loses messages. Surface
5693    // the collision the same way `wire mcp` does. Skipped under `--once`:
5694    // a single sync cycle is atomic and doesn't own the cursor.
5695    if !once {
5696        crate::session::warn_on_identity_collision(std::process::id(), "daemon");
5697    }
5698    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5699
5700    if !as_json {
5701        if once {
5702            eprintln!("wire daemon: single sync cycle, then exit");
5703        } else {
5704            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5705        }
5706    }
5707
5708    // Recover from prior crash: any pending pair in transient state had its
5709    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
5710    // the relay slots and mark the files so the operator can re-issue.
5711    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5712        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5713    }
5714
5715    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
5716    // to our slot, the subscriber signals `wake_rx`; we use it as the
5717    // sleep-or-wake gate of the polling loop. Polling stays as the
5718    // safety net — stream errors fall back transparently to the existing
5719    // interval-based cadence.
5720    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5721    if !once {
5722        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5723    }
5724
5725    loop {
5726        let pushed = run_sync_push().unwrap_or_else(|e| {
5727            eprintln!("daemon: push error: {e:#}");
5728            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5729        });
5730        let pulled = run_sync_pull().unwrap_or_else(|e| {
5731            eprintln!("daemon: pull error: {e:#}");
5732            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5733        });
5734        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5735            eprintln!("daemon: pending-pair tick error: {e:#}");
5736            json!({"transitions": []})
5737        });
5738
5739        if as_json {
5740            println!(
5741                "{}",
5742                serde_json::to_string(&json!({
5743                    "ts": time::OffsetDateTime::now_utc()
5744                        .format(&time::format_description::well_known::Rfc3339)
5745                        .unwrap_or_default(),
5746                    "push": pushed,
5747                    "pull": pulled,
5748                    "pairs": pairs,
5749                }))?
5750            );
5751        } else {
5752            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5753            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5754            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5755            let pair_transitions = pairs["transitions"]
5756                .as_array()
5757                .map(|a| a.len())
5758                .unwrap_or(0);
5759            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5760                eprintln!(
5761                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5762                );
5763            }
5764            // Loud per-transition logging so operator sees pair progress live.
5765            if let Some(arr) = pairs["transitions"].as_array() {
5766                for t in arr {
5767                    eprintln!(
5768                        "  pair {} : {} → {}",
5769                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
5770                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
5771                        t.get("to").and_then(Value::as_str).unwrap_or("?")
5772                    );
5773                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
5774                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5775                    {
5776                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
5777                        eprintln!(
5778                            "    Run: wire pair-confirm {} {}",
5779                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
5780                            sas
5781                        );
5782                    }
5783                }
5784            }
5785        }
5786
5787        if once {
5788            return Ok(());
5789        }
5790        // Wait either for the next poll-interval tick OR for a stream
5791        // wake signal — whichever comes first. Drain any additional
5792        // wake-ups that accumulated during the previous cycle since one
5793        // pull catches up everything.
5794        //
5795        // v0.13.2 (wisp-blossom): if the stream subscriber thread has gone
5796        // away, `wake_rx` is Disconnected and `recv_timeout` returns
5797        // INSTANTLY — which would busy-spin the sync loop (hammering push/pull
5798        // + the relay with zero delay). Fall back to a plain sleep so a dead
5799        // stream degrades to normal polling and never kills or pegs the
5800        // daemon. (Realizes the "decouple stream from sync" hardening — a
5801        // stream failure must never affect the push/pull loop.)
5802        match wake_rx.recv_timeout(interval) {
5803            Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
5804            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
5805                std::thread::sleep(interval);
5806            }
5807        }
5808        while wake_rx.try_recv().is_ok() {}
5809    }
5810}
5811
5812/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
5813/// shape `wire push --json` emits.
5814fn run_sync_push() -> Result<Value> {
5815    let state = config::read_relay_state()?;
5816    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5817    if peers.is_empty() {
5818        return Ok(json!({"pushed": [], "skipped": []}));
5819    }
5820    let outbox_dir = config::outbox_dir()?;
5821    if !outbox_dir.exists() {
5822        return Ok(json!({"pushed": [], "skipped": []}));
5823    }
5824    let mut pushed = Vec::new();
5825    let mut skipped = Vec::new();
5826    for (peer_handle, slot_info) in peers.iter() {
5827        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5828        if !outbox.exists() {
5829            continue;
5830        }
5831        let url = slot_info["relay_url"].as_str().unwrap_or("");
5832        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5833        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5834        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5835            continue;
5836        }
5837        let client = crate::relay_client::RelayClient::new(url);
5838        let body = std::fs::read_to_string(&outbox)?;
5839        for line in body.lines() {
5840            let event: Value = match serde_json::from_str(line) {
5841                Ok(v) => v,
5842                Err(_) => continue,
5843            };
5844            let event_id = event
5845                .get("event_id")
5846                .and_then(Value::as_str)
5847                .unwrap_or("")
5848                .to_string();
5849            match client.post_event(slot_id, slot_token, &event) {
5850                Ok(resp) => {
5851                    if resp.status == "duplicate" {
5852                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5853                    } else {
5854                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5855                    }
5856                }
5857                Err(e) => {
5858                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
5859                    // errors aren't hidden behind the topmost-context URL string.
5860                    // Issue #6 highest-impact silent-fail fix.
5861                    let reason = crate::relay_client::format_transport_error(&e);
5862                    skipped
5863                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5864                }
5865            }
5866        }
5867    }
5868    Ok(json!({"pushed": pushed, "skipped": skipped}))
5869}
5870
5871/// Programmatic pull. Same shape as `wire pull --json`.
5872///
5873/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
5874/// created via `wire session new --with-local` (which only writes
5875/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
5876/// Pre-v0.9 this function read only the top-level fields and silently
5877/// returned `{}` for any v0.5.17+ session.
5878fn run_sync_pull() -> Result<Value> {
5879    let state = config::read_relay_state()?;
5880    if state.get("self").map(Value::is_null).unwrap_or(true) {
5881        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5882    }
5883    // E2 (v0.13.2): pull EVERY self endpoint, not just the primary. A session
5884    // that bound a local slot (additive) alongside its federation slot used to
5885    // have the daemon pull ONLY the primary (federation) endpoint — the local
5886    // slot was never serviced, so same-box loopback delivery silently never
5887    // happened until a manual restart re-seeded the (startup-only) stream
5888    // subscriber. Now each endpoint is pulled with its OWN cursor.
5889    let endpoints = crate::endpoints::self_endpoints(&state);
5890    if endpoints.is_empty() {
5891        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5892    }
5893    let inbox_dir = config::inbox_dir()?;
5894    config::ensure_dirs()?;
5895
5896    // Per-slot cursors live at `self.cursors.<slot_id>`. The legacy global
5897    // `self.last_pulled_event_id` is migrated as the cursor for the PRIMARY
5898    // slot only (a federation event id won't match a local slot's log); other
5899    // slots start from None and `process_events` dedups against the inbox.
5900    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
5901    let legacy_cursor = self_obj
5902        .get("last_pulled_event_id")
5903        .and_then(Value::as_str)
5904        .map(str::to_string);
5905    let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
5906    let mut cursors: serde_json::Map<String, Value> = self_obj
5907        .get("cursors")
5908        .and_then(Value::as_object)
5909        .cloned()
5910        .unwrap_or_default();
5911
5912    let mut all_written: Vec<Value> = Vec::new();
5913    let mut all_rejected: Vec<Value> = Vec::new();
5914    let mut total_seen = 0usize;
5915    let mut blocked_any = false;
5916
5917    for ep in &endpoints {
5918        if ep.relay_url.is_empty() {
5919            continue;
5920        }
5921        let cursor = cursors
5922            .get(&ep.slot_id)
5923            .and_then(Value::as_str)
5924            .map(str::to_string)
5925            .or_else(|| {
5926                if Some(&ep.slot_id) == primary_slot.as_ref() {
5927                    legacy_cursor.clone()
5928                } else {
5929                    None
5930                }
5931            });
5932        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
5933        // One endpoint erroring (relay down, slot gone) must NOT stop the
5934        // others — a dead local relay shouldn't black-hole federation pulls.
5935        let events =
5936            match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
5937                Ok(e) => e,
5938                Err(e) => {
5939                    eprintln!(
5940                        "daemon: pull error on {} slot {} (continuing): {e:#}",
5941                        ep.relay_url, ep.slot_id
5942                    );
5943                    continue;
5944                }
5945            };
5946        total_seen += events.len();
5947        // P0.1 shared cursor-blocking logic (matches `wire pull`). A block on
5948        // one slot only stalls THAT slot's cursor; other slots keep flowing.
5949        let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
5950        if let Some(eid) = &result.advance_cursor_to {
5951            cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
5952        }
5953        blocked_any |= result.blocked;
5954        all_written.extend(result.written);
5955        all_rejected.extend(result.rejected);
5956    }
5957
5958    // P0.3 flock-protected RMW: persist per-slot cursors + keep the legacy
5959    // global cursor in sync with the primary slot for back-compat with older
5960    // binaries that only read `last_pulled_event_id`.
5961    let primary_cursor = primary_slot
5962        .as_ref()
5963        .and_then(|s| cursors.get(s))
5964        .and_then(Value::as_str)
5965        .map(str::to_string);
5966    config::update_relay_state(|state| {
5967        if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5968            self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
5969            if let Some(pc) = &primary_cursor {
5970                self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
5971            }
5972        }
5973        Ok(())
5974    })?;
5975
5976    Ok(json!({
5977        "written": all_written,
5978        "rejected": all_rejected,
5979        "total_seen": total_seen,
5980        "cursor_blocked": blocked_any,
5981        "endpoints_pulled": endpoints.len(),
5982    }))
5983}
5984
5985// ---------- pin (manual out-of-band peer pairing) ----------
5986
5987fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5988    let body =
5989        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5990    let card: Value =
5991        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5992    crate::agent_card::verify_agent_card(&card)
5993        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5994
5995    let mut trust = config::read_trust()?;
5996    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5997
5998    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5999    let handle = crate::agent_card::display_handle_from_did(did).to_string();
6000    config::write_trust(&trust)?;
6001
6002    if as_json {
6003        println!(
6004            "{}",
6005            serde_json::to_string(&json!({
6006                "handle": handle,
6007                "did": did,
6008                "tier": "VERIFIED",
6009                "pinned": true,
6010            }))?
6011        );
6012    } else {
6013        println!("pinned {handle} ({did}) at tier VERIFIED");
6014    }
6015    Ok(())
6016}
6017
6018// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
6019
6020fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
6021    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
6022}
6023
6024fn cmd_pair_join(
6025    code_phrase: &str,
6026    relay_url: &str,
6027    auto_yes: bool,
6028    timeout_secs: u64,
6029) -> Result<()> {
6030    pair_orchestrate(
6031        relay_url,
6032        Some(code_phrase),
6033        "guest",
6034        auto_yes,
6035        timeout_secs,
6036    )
6037}
6038
6039/// Shared orchestration for both sides of the SAS pairing.
6040///
6041/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
6042/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
6043/// `pair_session_confirm_sas` instead.
6044fn pair_orchestrate(
6045    relay_url: &str,
6046    code_in: Option<&str>,
6047    role: &str,
6048    auto_yes: bool,
6049    timeout_secs: u64,
6050) -> Result<()> {
6051    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
6052
6053    let mut s = pair_session_open(role, relay_url, code_in)?;
6054
6055    if role == "host" {
6056        eprintln!();
6057        eprintln!("share this code phrase with your peer:");
6058        eprintln!();
6059        eprintln!("    {}", s.code);
6060        eprintln!();
6061        eprintln!(
6062            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
6063            s.code
6064        );
6065    } else {
6066        eprintln!();
6067        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
6068    }
6069
6070    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
6071    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
6072    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
6073    // see the process is alive while the other side connects.
6074    const HEARTBEAT_SECS: u64 = 10;
6075    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
6076    let started = std::time::Instant::now();
6077    let mut last_heartbeat = started;
6078    let formatted = loop {
6079        if let Some(sas) = pair_session_try_sas(&mut s)? {
6080            break sas;
6081        }
6082        let now = std::time::Instant::now();
6083        if now >= deadline {
6084            return Err(anyhow!(
6085                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
6086            ));
6087        }
6088        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
6089            let elapsed = now.duration_since(started).as_secs();
6090            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
6091            last_heartbeat = now;
6092        }
6093        std::thread::sleep(std::time::Duration::from_millis(250));
6094    };
6095
6096    eprintln!();
6097    eprintln!("SAS digits (must match peer's terminal):");
6098    eprintln!();
6099    eprintln!("    {formatted}");
6100    eprintln!();
6101
6102    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
6103    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
6104    if !auto_yes {
6105        eprint!("does this match your peer's terminal? [y/N]: ");
6106        use std::io::Write;
6107        std::io::stderr().flush().ok();
6108        let mut input = String::new();
6109        std::io::stdin().read_line(&mut input)?;
6110        let trimmed = input.trim().to_lowercase();
6111        if trimmed != "y" && trimmed != "yes" {
6112            bail!("SAS confirmation declined — aborting pairing");
6113        }
6114    }
6115    s.sas_confirmed = true;
6116
6117    // Stage 4 — seal+exchange bootstrap, pin peer.
6118    let result = pair_session_finalize(&mut s, timeout_secs)?;
6119
6120    let peer_did = result["paired_with"].as_str().unwrap_or("");
6121    let peer_role = if role == "host" { "guest" } else { "host" };
6122    eprintln!("paired with {peer_did} (peer role: {peer_role})");
6123    eprintln!("peer card pinned at tier VERIFIED");
6124    eprintln!(
6125        "peer relay slot saved to {}",
6126        config::relay_state_path()?.display()
6127    );
6128
6129    println!("{}", serde_json::to_string(&result)?);
6130    Ok(())
6131}
6132
6133// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
6134// and pair_session_finalize, both of which inline their own deadline loops.)
6135
6136// ---------- pair — single-shot init + pair-* + setup ----------
6137
6138fn cmd_pair(
6139    handle: &str,
6140    code: Option<&str>,
6141    relay: &str,
6142    auto_yes: bool,
6143    timeout_secs: u64,
6144    no_setup: bool,
6145) -> Result<()> {
6146    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
6147    // bails loudly if a different handle is already set (operator must explicitly delete).
6148    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
6149    let did = init_result
6150        .get("did")
6151        .and_then(|v| v.as_str())
6152        .unwrap_or("(unknown)")
6153        .to_string();
6154    let already = init_result
6155        .get("already_initialized")
6156        .and_then(|v| v.as_bool())
6157        .unwrap_or(false);
6158    if already {
6159        println!("(identity {did} already initialized — reusing)");
6160    } else {
6161        println!("initialized {did}");
6162    }
6163    println!();
6164
6165    // Step 2 — pair-host or pair-join based on code presence.
6166    match code {
6167        None => {
6168            println!("hosting pair on {relay} (no code = host) ...");
6169            cmd_pair_host(relay, auto_yes, timeout_secs)?;
6170        }
6171        Some(c) => {
6172            println!("joining pair with code {c} on {relay} ...");
6173            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
6174        }
6175    }
6176
6177    // Step 3 — register wire as MCP server in detected client configs (idempotent).
6178    if !no_setup {
6179        println!();
6180        println!("registering wire as MCP server in detected client configs ...");
6181        if let Err(e) = cmd_setup(true) {
6182            // Non-fatal — pair succeeded, just print the warning.
6183            eprintln!("warn: setup --apply failed: {e}");
6184            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
6185        }
6186    }
6187
6188    println!();
6189    println!("pair complete. Next steps:");
6190    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
6191    println!("  wire send <peer> claim <msg>   # send your peer something");
6192    println!("  wire tail                      # watch incoming events");
6193    Ok(())
6194}
6195
6196// ---------- detached pair (daemon-orchestrated) ----------
6197
6198/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
6199/// pair-host/-join into a single command. The non-detached variant lives in
6200/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
6201fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
6202    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
6203    let did = init_result
6204        .get("did")
6205        .and_then(|v| v.as_str())
6206        .unwrap_or("(unknown)")
6207        .to_string();
6208    let already = init_result
6209        .get("already_initialized")
6210        .and_then(|v| v.as_bool())
6211        .unwrap_or(false);
6212    if already {
6213        println!("(identity {did} already initialized — reusing)");
6214    } else {
6215        println!("initialized {did}");
6216    }
6217    println!();
6218    match code {
6219        None => cmd_pair_host_detach(relay, false),
6220        Some(c) => cmd_pair_join_detach(c, relay, false),
6221    }
6222}
6223
6224fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
6225    if !config::is_initialized()? {
6226        bail!("not initialized — run `wire init <handle>` first");
6227    }
6228    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
6229        Ok(b) => b,
6230        Err(e) => {
6231            if !as_json {
6232                eprintln!(
6233                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
6234                );
6235            }
6236            false
6237        }
6238    };
6239    let code = crate::sas::generate_code_phrase();
6240    let code_hash = crate::pair_session::derive_code_hash(&code);
6241    let now = time::OffsetDateTime::now_utc()
6242        .format(&time::format_description::well_known::Rfc3339)
6243        .unwrap_or_default();
6244    let p = crate::pending_pair::PendingPair {
6245        code: code.clone(),
6246        code_hash,
6247        role: "host".to_string(),
6248        relay_url: relay_url.to_string(),
6249        status: "request_host".to_string(),
6250        sas: None,
6251        peer_did: None,
6252        created_at: now,
6253        last_error: None,
6254        pair_id: None,
6255        our_slot_id: None,
6256        our_slot_token: None,
6257        spake2_seed_b64: None,
6258    };
6259    crate::pending_pair::write_pending(&p)?;
6260    if as_json {
6261        println!(
6262            "{}",
6263            serde_json::to_string(&json!({
6264                "state": "queued",
6265                "code_phrase": code,
6266                "relay_url": relay_url,
6267                "role": "host",
6268                "daemon_spawned": daemon_spawned,
6269            }))?
6270        );
6271    } else {
6272        if daemon_spawned {
6273            println!("(started wire daemon in background)");
6274        }
6275        println!("detached pair-host queued. Share this code with your peer:\n");
6276        println!("    {code}\n");
6277        println!("Next steps:");
6278        println!("  wire pair-list                                # check status");
6279        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
6280        println!("  wire pair-cancel  {code}            # to abort");
6281    }
6282    Ok(())
6283}
6284
6285fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
6286    if !config::is_initialized()? {
6287        bail!("not initialized — run `wire init <handle>` first");
6288    }
6289    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
6290        Ok(b) => b,
6291        Err(e) => {
6292            if !as_json {
6293                eprintln!(
6294                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
6295                );
6296            }
6297            false
6298        }
6299    };
6300    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6301    let code_hash = crate::pair_session::derive_code_hash(&code);
6302    let now = time::OffsetDateTime::now_utc()
6303        .format(&time::format_description::well_known::Rfc3339)
6304        .unwrap_or_default();
6305    let p = crate::pending_pair::PendingPair {
6306        code: code.clone(),
6307        code_hash,
6308        role: "guest".to_string(),
6309        relay_url: relay_url.to_string(),
6310        status: "request_guest".to_string(),
6311        sas: None,
6312        peer_did: None,
6313        created_at: now,
6314        last_error: None,
6315        pair_id: None,
6316        our_slot_id: None,
6317        our_slot_token: None,
6318        spake2_seed_b64: None,
6319    };
6320    crate::pending_pair::write_pending(&p)?;
6321    if as_json {
6322        println!(
6323            "{}",
6324            serde_json::to_string(&json!({
6325                "state": "queued",
6326                "code_phrase": code,
6327                "relay_url": relay_url,
6328                "role": "guest",
6329                "daemon_spawned": daemon_spawned,
6330            }))?
6331        );
6332    } else {
6333        if daemon_spawned {
6334            println!("(started wire daemon in background)");
6335        }
6336        println!("detached pair-join queued for code {code}.");
6337        println!(
6338            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
6339        );
6340    }
6341    Ok(())
6342}
6343
6344fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
6345    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6346    let typed: String = typed_digits
6347        .chars()
6348        .filter(|c| c.is_ascii_digit())
6349        .collect();
6350    if typed.len() != 6 {
6351        bail!(
6352            "expected 6 digits (got {} after stripping non-digits)",
6353            typed.len()
6354        );
6355    }
6356    let mut p = crate::pending_pair::read_pending(&code)?
6357        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
6358    if p.status != "sas_ready" {
6359        bail!(
6360            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
6361            p.status
6362        );
6363    }
6364    let stored = p
6365        .sas
6366        .as_ref()
6367        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
6368        .clone();
6369    if stored == typed {
6370        p.status = "confirmed".to_string();
6371        crate::pending_pair::write_pending(&p)?;
6372        if as_json {
6373            println!(
6374                "{}",
6375                serde_json::to_string(&json!({
6376                    "state": "confirmed",
6377                    "code_phrase": code,
6378                }))?
6379            );
6380        } else {
6381            println!("digits match. Daemon will finalize the handshake on its next tick.");
6382            println!("Run `wire peers` after a few seconds to confirm.");
6383        }
6384    } else {
6385        p.status = "aborted".to_string();
6386        p.last_error = Some(format!(
6387            "SAS digit mismatch (typed {typed}, expected {stored})"
6388        ));
6389        let client = crate::relay_client::RelayClient::new(&p.relay_url);
6390        let _ = client.pair_abandon(&p.code_hash);
6391        crate::pending_pair::write_pending(&p)?;
6392        crate::os_notify::toast(
6393            &format!("wire — pair aborted ({})", p.code),
6394            p.last_error.as_deref().unwrap_or("digits mismatch"),
6395        );
6396        if as_json {
6397            println!(
6398                "{}",
6399                serde_json::to_string(&json!({
6400                    "state": "aborted",
6401                    "code_phrase": code,
6402                    "error": "digits mismatch",
6403                }))?
6404            );
6405        }
6406        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
6407    }
6408    Ok(())
6409}
6410
6411fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
6412    if watch {
6413        return cmd_pair_list_watch(watch_interval_secs);
6414    }
6415    let spake2_items = crate::pending_pair::list_pending()?;
6416    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
6417    if as_json {
6418        // Backwards-compat: flat SPAKE2 array (the shape every existing
6419        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
6420        // surface programmatically via `wire pair-list-inbound --json`
6421        // and via `wire status --json` `pending_pairs.inbound_*` fields.
6422        println!("{}", serde_json::to_string(&spake2_items)?);
6423        return Ok(());
6424    }
6425    if spake2_items.is_empty() && inbound_items.is_empty() {
6426        println!("no pending pair sessions.");
6427        return Ok(());
6428    }
6429    // v0.5.14: inbound section first — these need operator action right now.
6430    // SPAKE2 sessions are typically already mid-flow.
6431    if !inbound_items.is_empty() {
6432        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
6433        println!(
6434            "{:<20} {:<35} {:<25} NEXT STEP",
6435            "PEER", "RELAY", "RECEIVED"
6436        );
6437        for p in &inbound_items {
6438            println!(
6439                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
6440                p.peer_handle,
6441                p.peer_relay_url,
6442                p.received_at,
6443                peer = p.peer_handle,
6444            );
6445        }
6446        println!();
6447    }
6448    if !spake2_items.is_empty() {
6449        println!("SPAKE2 SESSIONS");
6450        println!(
6451            "{:<15} {:<8} {:<18} {:<10} NOTE",
6452            "CODE", "ROLE", "STATUS", "SAS"
6453        );
6454        for p in spake2_items {
6455            let sas = p
6456                .sas
6457                .as_ref()
6458                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
6459                .unwrap_or_else(|| "—".to_string());
6460            let note = p
6461                .last_error
6462                .as_deref()
6463                .or(p.peer_did.as_deref())
6464                .unwrap_or("");
6465            println!(
6466                "{:<15} {:<8} {:<18} {:<10} {}",
6467                p.code, p.role, p.status, sas, note
6468            );
6469        }
6470    }
6471    Ok(())
6472}
6473
6474/// Stream-mode pair-list: never exits. Diffs per-code state every
6475/// `interval_secs` and prints one JSON line per transition (creation,
6476/// status flip, deletion). Useful for shell pipelines:
6477///
6478/// ```text
6479/// wire pair-list --watch | while read line; do
6480///     CODE=$(echo "$line" | jq -r .code)
6481///     STATUS=$(echo "$line" | jq -r .status)
6482///     ...
6483/// done
6484/// ```
6485fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
6486    use std::collections::HashMap;
6487    use std::io::Write;
6488    let interval = std::time::Duration::from_secs(interval_secs.max(1));
6489    // Emit a snapshot synthetic event for every currently-pending pair on
6490    // startup so a consumer that arrives mid-flight sees the current state.
6491    let mut prev: HashMap<String, String> = HashMap::new();
6492    {
6493        let items = crate::pending_pair::list_pending()?;
6494        for p in &items {
6495            println!("{}", serde_json::to_string(&p)?);
6496            prev.insert(p.code.clone(), p.status.clone());
6497        }
6498        // Flush so the consumer's `while read` gets the snapshot promptly.
6499        let _ = std::io::stdout().flush();
6500    }
6501    loop {
6502        std::thread::sleep(interval);
6503        let items = match crate::pending_pair::list_pending() {
6504            Ok(v) => v,
6505            Err(_) => continue,
6506        };
6507        let mut cur: HashMap<String, String> = HashMap::new();
6508        for p in &items {
6509            cur.insert(p.code.clone(), p.status.clone());
6510            match prev.get(&p.code) {
6511                None => {
6512                    // New code appeared.
6513                    println!("{}", serde_json::to_string(&p)?);
6514                }
6515                Some(prev_status) if prev_status != &p.status => {
6516                    // Status flipped.
6517                    println!("{}", serde_json::to_string(&p)?);
6518                }
6519                _ => {}
6520            }
6521        }
6522        for code in prev.keys() {
6523            if !cur.contains_key(code) {
6524                // File disappeared → finalized or cancelled. Emit a synthetic
6525                // "removed" marker so the consumer sees the terminal event.
6526                println!(
6527                    "{}",
6528                    serde_json::to_string(&json!({
6529                        "code": code,
6530                        "status": "removed",
6531                        "_synthetic": true,
6532                    }))?
6533                );
6534            }
6535        }
6536        let _ = std::io::stdout().flush();
6537        prev = cur;
6538    }
6539}
6540
6541/// Block until a pending pair reaches `target_status` or terminates. Process
6542/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
6543/// timeout) so shell scripts can branch directly.
6544fn cmd_pair_watch(
6545    code_phrase: &str,
6546    target_status: &str,
6547    timeout_secs: u64,
6548    as_json: bool,
6549) -> Result<()> {
6550    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6551    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
6552    let mut last_seen_status: Option<String> = None;
6553    loop {
6554        let p_opt = crate::pending_pair::read_pending(&code)?;
6555        let now = std::time::Instant::now();
6556        match p_opt {
6557            None => {
6558                // File gone — either finalized (success if target=sas_ready
6559                // since finalization implies it passed sas_ready) or never
6560                // existed. Distinguish by whether we ever saw it.
6561                if last_seen_status.is_some() {
6562                    if as_json {
6563                        println!(
6564                            "{}",
6565                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
6566                        );
6567                    } else {
6568                        println!("pair {code} finalized (file removed)");
6569                    }
6570                    return Ok(());
6571                } else {
6572                    if as_json {
6573                        println!(
6574                            "{}",
6575                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
6576                        );
6577                    }
6578                    std::process::exit(1);
6579                }
6580            }
6581            Some(p) => {
6582                let cur = p.status.clone();
6583                if Some(cur.clone()) != last_seen_status {
6584                    if as_json {
6585                        // Emit per-transition line so scripts can stream.
6586                        println!("{}", serde_json::to_string(&p)?);
6587                    }
6588                    last_seen_status = Some(cur.clone());
6589                }
6590                if cur == target_status {
6591                    if !as_json {
6592                        let sas_str = p
6593                            .sas
6594                            .as_ref()
6595                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
6596                            .unwrap_or_else(|| "—".to_string());
6597                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
6598                    }
6599                    return Ok(());
6600                }
6601                if cur == "aborted" || cur == "aborted_restart" {
6602                    if !as_json {
6603                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
6604                        eprintln!("pair {code} {cur}: {err}");
6605                    }
6606                    std::process::exit(1);
6607                }
6608            }
6609        }
6610        if now >= deadline {
6611            if !as_json {
6612                eprintln!(
6613                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
6614                );
6615            }
6616            std::process::exit(2);
6617        }
6618        std::thread::sleep(std::time::Duration::from_millis(250));
6619    }
6620}
6621
6622fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
6623    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6624    let p = crate::pending_pair::read_pending(&code)?
6625        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
6626    let client = crate::relay_client::RelayClient::new(&p.relay_url);
6627    let _ = client.pair_abandon(&p.code_hash);
6628    crate::pending_pair::delete_pending(&code)?;
6629    if as_json {
6630        println!(
6631            "{}",
6632            serde_json::to_string(&json!({
6633                "state": "cancelled",
6634                "code_phrase": code,
6635            }))?
6636        );
6637    } else {
6638        println!("cancelled pending pair {code} (relay slot released, file removed).");
6639    }
6640    Ok(())
6641}
6642
6643// ---------- pair-abandon — release stuck pair-slot ----------
6644
6645fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
6646    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
6647    // typed — normalize via the existing parser.
6648    let code = crate::sas::parse_code_phrase(code_phrase)?;
6649    let code_hash = crate::pair_session::derive_code_hash(code);
6650    let client = crate::relay_client::RelayClient::new(relay_url);
6651    client.pair_abandon(&code_hash)?;
6652    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
6653    println!("host can now issue a fresh code; guest can re-join.");
6654    Ok(())
6655}
6656
6657// ---------- invite / accept — one-paste pair (v0.4.0) ----------
6658
6659fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6660    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6661
6662    // If --share, register the invite at the relay's short-URL endpoint and
6663    // build the one-curl onboarding line for the peer to paste.
6664    let share_payload: Option<Value> = if share {
6665        let client = reqwest::blocking::Client::new();
6666        let single_use = if uses == 1 { Some(1u32) } else { None };
6667        let body = json!({
6668            "invite_url": url,
6669            "ttl_seconds": ttl,
6670            "uses": single_use,
6671        });
6672        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6673        let resp = client.post(&endpoint).json(&body).send()?;
6674        if !resp.status().is_success() {
6675            let code = resp.status();
6676            let txt = resp.text().unwrap_or_default();
6677            bail!("relay {code} on /v1/invite/register: {txt}");
6678        }
6679        let parsed: Value = resp.json()?;
6680        let token = parsed
6681            .get("token")
6682            .and_then(Value::as_str)
6683            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6684            .to_string();
6685        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6686        let curl_line = format!("curl -fsSL {share_url} | sh");
6687        Some(json!({
6688            "token": token,
6689            "share_url": share_url,
6690            "curl": curl_line,
6691            "expires_unix": parsed.get("expires_unix"),
6692        }))
6693    } else {
6694        None
6695    };
6696
6697    if as_json {
6698        let mut out = json!({
6699            "invite_url": url,
6700            "ttl_secs": ttl,
6701            "uses": uses,
6702            "relay": relay,
6703        });
6704        if let Some(s) = &share_payload {
6705            out["share"] = s.clone();
6706        }
6707        println!("{}", serde_json::to_string(&out)?);
6708    } else if let Some(s) = share_payload {
6709        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6710        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6711        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6712        println!("{curl}");
6713    } else {
6714        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6715        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6716        println!("{url}");
6717    }
6718    Ok(())
6719}
6720
6721fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6722    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
6723    // resolve it to the underlying wire://pair?... URL via ?format=url before
6724    // accepting. Saves them from having to know which URL shape goes where.
6725    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6726        let sep = if url.contains('?') { '&' } else { '?' };
6727        let resolve_url = format!("{url}{sep}format=url");
6728        let client = reqwest::blocking::Client::new();
6729        let resp = client
6730            .get(&resolve_url)
6731            .send()
6732            .with_context(|| format!("GET {resolve_url}"))?;
6733        if !resp.status().is_success() {
6734            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6735        }
6736        let body = resp.text().unwrap_or_default().trim().to_string();
6737        if !body.starts_with("wire://pair?") {
6738            bail!(
6739                "short URL {url} did not resolve to a wire:// invite. \
6740                 (got: {}{})",
6741                body.chars().take(80).collect::<String>(),
6742                if body.chars().count() > 80 { "…" } else { "" }
6743            );
6744        }
6745        body
6746    } else {
6747        url.to_string()
6748    };
6749
6750    let result = crate::pair_invite::accept_invite(&resolved)?;
6751    if as_json {
6752        println!("{}", serde_json::to_string(&result)?);
6753    } else {
6754        let did = result
6755            .get("paired_with")
6756            .and_then(Value::as_str)
6757            .unwrap_or("?");
6758        println!("paired with {did}");
6759        println!(
6760            "you can now: wire send {} <kind> <body>",
6761            crate::agent_card::display_handle_from_did(did)
6762        );
6763    }
6764    Ok(())
6765}
6766
6767// ---------- whois / profile (v0.5) ----------
6768
6769fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6770    if let Some(h) = handle {
6771        let parsed = crate::pair_profile::parse_handle(h)?;
6772        // Special-case: if the supplied handle matches our own, skip the
6773        // network round-trip and print local.
6774        if config::is_initialized()? {
6775            let card = config::read_agent_card()?;
6776            let local_handle = card
6777                .get("profile")
6778                .and_then(|p| p.get("handle"))
6779                .and_then(Value::as_str)
6780                .map(str::to_string);
6781            if local_handle.as_deref() == Some(h) {
6782                return cmd_whois(None, as_json, None);
6783            }
6784        }
6785        // Remote resolution via .well-known/wire/agent on the handle's domain.
6786        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6787        if as_json {
6788            println!("{}", serde_json::to_string(&resolved)?);
6789        } else {
6790            print_resolved_profile(&resolved);
6791        }
6792        return Ok(());
6793    }
6794    let card = config::read_agent_card()?;
6795    if as_json {
6796        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6797        let mut payload = serde_json::Map::new();
6798        payload.insert(
6799            "did".into(),
6800            card.get("did").cloned().unwrap_or(Value::Null),
6801        );
6802        payload.insert("profile".into(), profile);
6803        // v0.14: surface inline op claims on self-whois too, for parity
6804        // with `wire whoami --json`. Single mental model across read
6805        // verbs; absent ⇒ not enrolled.
6806        for (k, v) in op_claims_from_card(&card) {
6807            payload.insert(k, v);
6808        }
6809        println!("{}", serde_json::to_string(&payload)?);
6810    } else {
6811        print!("{}", crate::pair_profile::render_self_summary()?);
6812    }
6813    Ok(())
6814}
6815
6816fn print_resolved_profile(resolved: &Value) {
6817    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6818    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6819    let relay = resolved
6820        .get("relay_url")
6821        .and_then(Value::as_str)
6822        .unwrap_or("");
6823    let slot = resolved
6824        .get("slot_id")
6825        .and_then(Value::as_str)
6826        .unwrap_or("");
6827    let profile = resolved
6828        .get("card")
6829        .and_then(|c| c.get("profile"))
6830        .cloned()
6831        .unwrap_or(Value::Null);
6832    println!("{did}");
6833    println!("  nick:         {nick}");
6834    if !relay.is_empty() {
6835        println!("  relay_url:    {relay}");
6836    }
6837    if !slot.is_empty() {
6838        println!("  slot_id:      {slot}");
6839    }
6840    let pick =
6841        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6842    if let Some(s) = pick("display_name") {
6843        println!("  display_name: {s}");
6844    }
6845    if let Some(s) = pick("emoji") {
6846        println!("  emoji:        {s}");
6847    }
6848    if let Some(s) = pick("motto") {
6849        println!("  motto:        {s}");
6850    }
6851    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6852        let joined: Vec<String> = arr
6853            .iter()
6854            .filter_map(|v| v.as_str().map(str::to_string))
6855            .collect();
6856        println!("  vibe:         {}", joined.join(", "));
6857    }
6858    if let Some(s) = pick("pronouns") {
6859        println!("  pronouns:     {s}");
6860    }
6861}
6862
6863/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
6864/// signed pair_drop event with our card + slot coords, deliver via the
6865/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
6866/// Peer's daemon completes the bilateral pin on its next pull and emits a
6867/// pair_drop_ack carrying their slot_token so we can send back.
6868/// Extract just the host portion from `https://host:port/path` → `host`.
6869/// Returns empty string if the URL is malformed.
6870fn host_of_url(url: &str) -> String {
6871    let no_scheme = url
6872        .trim_start_matches("https://")
6873        .trim_start_matches("http://");
6874    no_scheme
6875        .split('/')
6876        .next()
6877        .unwrap_or("")
6878        .split(':')
6879        .next()
6880        .unwrap_or("")
6881        .to_string()
6882}
6883
6884/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
6885/// operator's own relay? Used to suppress the cross-relay phishing
6886/// warning in `wire add` for the happy path.
6887fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6888    // Hard-coded known-good list. wireup.net is the default relay.
6889    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6890    let peer_domain = peer_domain.trim().to_ascii_lowercase();
6891    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6892        return true;
6893    }
6894    // Operator's OWN relay is implicitly trusted — they're already
6895    // bound to it; pairing same-relay peers is the common case.
6896    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6897    if !our_host.is_empty() && our_host == peer_domain {
6898        return true;
6899    }
6900    false
6901}
6902
6903/// v0.6.6: pair with a sister session on this machine without federation.
6904/// Reads the sister's agent-card + endpoints from disk, pins them into our
6905/// trust + relay_state, builds the same `pair_drop` event the federation
6906/// path would emit, then POSTs it directly to the sister's local-relay slot.
6907/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
6908/// the cwd-derived `wire`) are addressable because the local relay never
6909/// needed a public claim for sister coordination.
6910/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
6911/// to a local sister session.
6912///
6913/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
6914/// either form. Exact session-name matches always win; nickname matches
6915/// are a fallback so operators can type "winter-bay" instead of "wire".
6916/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
6917/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
6918/// with the candidate list so the caller can surface a disambiguation
6919/// hint instead of silently picking one.
6920fn resolve_local_session<'a>(
6921    sessions: &'a [crate::session::SessionInfo],
6922    input: &str,
6923) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6924    // Exact session-name match always wins, even if a nickname elsewhere
6925    // also matches. Predictable for scripts and operator muscle memory.
6926    if let Some(s) = sessions.iter().find(|s| s.name == input) {
6927        return Ok(s);
6928    }
6929    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6930        .iter()
6931        .filter(|s| {
6932            s.character
6933                .as_ref()
6934                .map(|c| c.nickname == input)
6935                .unwrap_or(false)
6936        })
6937        .collect();
6938    match nick_matches.len() {
6939        0 => Err(ResolveError::NotFound),
6940        1 => Ok(nick_matches[0]),
6941        _ => Err(ResolveError::Ambiguous(
6942            nick_matches.iter().map(|s| s.name.clone()).collect(),
6943        )),
6944    }
6945}
6946
6947#[derive(Debug)]
6948enum ResolveError {
6949    NotFound,
6950    Ambiguous(Vec<String>),
6951}
6952
6953/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
6954/// to a pinned peer's canonical handle.
6955///
6956/// `wire send <peer>` accepts either the handle the peer registered with
6957/// or their character nickname (DID-hash-derived). Exact handle match
6958/// always wins. When a nickname matches multiple peers (theoretically
6959/// possible via DID-hash collision in the (adj, noun) space), returns
6960/// `Ambiguous` so the caller can surface a disambiguation hint instead
6961/// of silently picking one.
6962///
6963/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
6964/// overrides on the peer's side live in their local `display.json` and
6965/// aren't yet published via agent-card. (That's the v0.7+ federation
6966/// lifecycle work — peers publishing overrides so we resolve by what
6967/// they call themselves, not just what their DID hashes to.)
6968fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6969    let trust = match config::read_trust() {
6970        Ok(t) => t,
6971        Err(_) => return Ok(None),
6972    };
6973    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6974        Some(a) => a,
6975        None => return Ok(None),
6976    };
6977    if agents.contains_key(input) {
6978        return Ok(Some(input.to_string()));
6979    }
6980    let mut nick_matches: Vec<String> = Vec::new();
6981    for (handle, agent) in agents.iter() {
6982        // v0.7.0-alpha.6: prefer peer's published display nickname over
6983        // auto-derived. Allows `wire send <their-chosen-name>` not just
6984        // `wire send <their-did-hash-derived-name>`.
6985        let character = match agent.get("card") {
6986            Some(card) => crate::character::Character::from_card(card),
6987            None => match agent.get("did").and_then(Value::as_str) {
6988                Some(did) => crate::character::Character::from_did(did),
6989                None => continue,
6990            },
6991        };
6992        if character.nickname == input {
6993            nick_matches.push(handle.clone());
6994        }
6995    }
6996    match nick_matches.len() {
6997        0 => Ok(None),
6998        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6999        _ => Err(ResolveError::Ambiguous(nick_matches)),
7000    }
7001}
7002
7003fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
7004    // 1. Locate sister session by name OR character nickname.
7005    let sessions = crate::session::list_sessions()?;
7006    let sister = match resolve_local_session(&sessions, sister_name) {
7007        Ok(s) => s,
7008        Err(ResolveError::NotFound) => bail!(
7009            "no sister session named `{sister_name}` (matched by session name or character nickname). \
7010             Run `wire session list` to see what's available."
7011        ),
7012        Err(ResolveError::Ambiguous(candidates)) => bail!(
7013            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
7014             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
7015            candidates.len(),
7016            candidates.join(", ")
7017        ),
7018    };
7019    // If we matched via nickname (not exact name), surface that so the
7020    // operator sees what we resolved to. Quiet when names match exactly.
7021    if sister.name != sister_name {
7022        eprintln!(
7023            "wire add: resolved nickname `{sister_name}` → session `{}`",
7024            sister.name
7025        );
7026    }
7027
7028    // 2. Refuse self-pair — operator owns both sides, but a self-loop
7029    // breaks the bilateral state machine.
7030    let our_card = config::read_agent_card()
7031        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
7032    let our_did = our_card
7033        .get("did")
7034        .and_then(Value::as_str)
7035        .ok_or_else(|| anyhow!("agent-card missing did"))?
7036        .to_string();
7037    if let Some(sister_did) = sister.did.as_deref()
7038        && sister_did == our_did
7039    {
7040        bail!("refusing to add self (`{sister_name}` is this very session)");
7041    }
7042
7043    // 3. Read sister's agent-card + relay state from disk.
7044    let sister_card_path = sister
7045        .home_dir
7046        .join("config")
7047        .join("wire")
7048        .join("agent-card.json");
7049    let sister_card: Value = serde_json::from_slice(
7050        &std::fs::read(&sister_card_path)
7051            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
7052    )
7053    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
7054    let sister_relay_state: Value = std::fs::read(
7055        sister
7056            .home_dir
7057            .join("config")
7058            .join("wire")
7059            .join("relay.json"),
7060    )
7061    .ok()
7062    .and_then(|b| serde_json::from_slice(&b).ok())
7063    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7064
7065    let sister_did = sister_card
7066        .get("did")
7067        .and_then(Value::as_str)
7068        .ok_or_else(|| anyhow!("sister card missing did"))?
7069        .to_string();
7070    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
7071
7072    // Pull sister's full endpoint set; we want the local one for delivery
7073    // and we'll pin all of them so OUR pushes prefer local-first per the
7074    // existing routing logic.
7075    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
7076    if sister_endpoints.is_empty() {
7077        bail!(
7078            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
7079        );
7080    }
7081    let sister_local = sister_endpoints
7082        .iter()
7083        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
7084    let delivery_endpoint = match sister_local {
7085        Some(e) => e.clone(),
7086        None => sister_endpoints[0].clone(),
7087    };
7088
7089    // 4. Ensure WE have a slot to advertise back. For local-only sessions
7090    // this is the local slot; for dual-slot sessions, federation is fine.
7091    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
7092    // for pure local-only — instead, pick our own existing federation
7093    // endpoint if present, else fall back to whatever's first.
7094    let our_relay_state = config::read_relay_state()?;
7095    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7096    if our_endpoints.is_empty() {
7097        bail!(
7098            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
7099        );
7100    }
7101    let our_advertised = our_endpoints
7102        .iter()
7103        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
7104        .cloned()
7105        .unwrap_or_else(|| our_endpoints[0].clone());
7106
7107    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
7108    // relay_state.peers with their full endpoint set. slot_token lands
7109    // via pair_drop_ack as usual.
7110    let mut trust = config::read_trust()?;
7111    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
7112    config::write_trust(&trust)?;
7113    let mut relay_state = config::read_relay_state()?;
7114    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
7115    config::write_relay_state(&relay_state)?;
7116
7117    // 6. Build the same pair_drop event the federation path emits, with
7118    // our card + endpoints in the body so the sister can pin us back.
7119    let sk_seed = config::read_private_key()?;
7120    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7121    let pk_b64 = our_card
7122        .get("verify_keys")
7123        .and_then(Value::as_object)
7124        .and_then(|m| m.values().next())
7125        .and_then(|v| v.get("key"))
7126        .and_then(Value::as_str)
7127        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7128    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7129    let now = time::OffsetDateTime::now_utc()
7130        .format(&time::format_description::well_known::Rfc3339)
7131        .unwrap_or_default();
7132    let mut body = json!({
7133        "card": our_card,
7134        "relay_url": our_advertised.relay_url,
7135        "slot_id": our_advertised.slot_id,
7136        "slot_token": our_advertised.slot_token,
7137    });
7138    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7139    let event = json!({
7140        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7141        "timestamp": now,
7142        "from": our_did,
7143        "to": sister_did,
7144        "type": "pair_drop",
7145        "kind": 1100u32,
7146        "body": body,
7147    });
7148    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7149    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7150
7151    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
7152    // (the federation handle indexer) — we already know the slot coords
7153    // from disk, so post_event is sufficient.
7154    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
7155    client
7156        .post_event(
7157            &delivery_endpoint.slot_id,
7158            &delivery_endpoint.slot_token,
7159            &signed,
7160        )
7161        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
7162
7163    if as_json {
7164        println!(
7165            "{}",
7166            serde_json::to_string(&json!({
7167                "handle": sister_name,
7168                "paired_with": sister_did,
7169                "peer_handle": sister_handle,
7170                "event_id": event_id,
7171                "delivered_via": match delivery_endpoint.scope {
7172                    crate::endpoints::EndpointScope::Local => "local",
7173                    crate::endpoints::EndpointScope::Lan => "lan",
7174                    crate::endpoints::EndpointScope::Uds => "uds",
7175                    crate::endpoints::EndpointScope::Federation => "federation",
7176                },
7177                "status": "drop_sent",
7178            }))?
7179        );
7180    } else {
7181        let scope = match delivery_endpoint.scope {
7182            crate::endpoints::EndpointScope::Local => "local",
7183            crate::endpoints::EndpointScope::Lan => "lan",
7184            crate::endpoints::EndpointScope::Uds => "uds",
7185            crate::endpoints::EndpointScope::Federation => "federation",
7186        };
7187        println!(
7188            "→ 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.",
7189            delivery_endpoint.relay_url
7190        );
7191    }
7192    Ok(())
7193}
7194
7195fn cmd_add(
7196    handle_arg: &str,
7197    relay_override: Option<&str>,
7198    local_sister: bool,
7199    as_json: bool,
7200) -> Result<()> {
7201    // v0.7.4: nickname-friendly local-sister resolution. Whether the
7202    // operator passed `--local-sister` explicitly OR just typed a bare
7203    // name (no `@<relay>`), try to resolve through the local sessions
7204    // registry so character nicknames AND session names AND card
7205    // handles all work as input. Closes the "I only know this peer by
7206    // its character name" ergonomic gap that forced operators into
7207    // `wire session list-local | grep <nick> | awk` dances.
7208    if local_sister {
7209        let resolved = crate::session::resolve_local_sister(handle_arg)
7210            .unwrap_or_else(|| handle_arg.to_string());
7211        return cmd_add_local_sister(&resolved, as_json);
7212    }
7213    if !handle_arg.contains('@')
7214        && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
7215    {
7216        eprintln!(
7217            "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
7218             — routing via --local-sister (disk-read card, no relay lookup)."
7219        );
7220        return cmd_add_local_sister(&resolved, as_json);
7221    }
7222    if !handle_arg.contains('@') {
7223        bail!(
7224            "`{handle_arg}` doesn't match any local sister session and has no \
7225             @<relay> suffix for federation.\n\
7226             — Local sisters: `wire session list-local` (operator types name OR \
7227             character nickname)\n\
7228             — Federation:    `wire add <handle>@<relay-domain>` (e.g. \
7229             `wire add alice@wireup.net`)"
7230        );
7231    }
7232    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
7233
7234    // 1. Auto-init self if needed + ensure a relay slot.
7235    let (our_did, our_relay, our_slot_id, our_slot_token) =
7236        crate::pair_invite::ensure_self_with_relay(relay_override)?;
7237    if our_did == format!("did:wire:{}", parsed.nick) {
7238        // Lazy guard — actual self-add would also be caught by FCFS later.
7239        bail!("refusing to add self (handle matches own DID)");
7240    }
7241
7242    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
7243    // already sitting in pending-inbound, the operator is now accepting it.
7244    // Pin trust, save relay coords + slot_token from the stored drop, ship
7245    // our own slot_token back via pair_drop_ack, delete the pending record.
7246    //
7247    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
7248    // receiver-side auto-promote was removed there; operator consent flows
7249    // through here. After this branch returns, both sides are bilaterally
7250    // pinned and capability flows in both directions.
7251    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
7252        return cmd_add_accept_pending(
7253            handle_arg,
7254            &parsed.nick,
7255            &pending,
7256            &our_relay,
7257            &our_slot_id,
7258            &our_slot_token,
7259            as_json,
7260        );
7261    }
7262
7263    // v0.5.19 (#9.4): cross-relay phishing guardrail.
7264    //
7265    // Threat: operator wants to add `boss@wireup.net` but types
7266    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
7267    // The .well-known resolution returns whoever claimed the nick on the
7268    // *typo* relay, the bilateral gate still completes (the attacker
7269    // accepts the pair on their side), and the operator pins the
7270    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
7271    // there's no asymmetry to detect when the attacker WANTS to be
7272    // paired.
7273    //
7274    // Mitigation: warn loudly when the peer's relay domain is novel
7275    // (not the operator's own relay, not in a small known-good set).
7276    // Doesn't block — operators have legitimate reasons to pair across
7277    // relays. The signal lands in shell history so a phished operator
7278    // can find it in retrospect.
7279    if !is_known_relay_domain(&parsed.domain, &our_relay) {
7280        eprintln!(
7281            "wire add: WARN unfamiliar relay domain `{}`.",
7282            parsed.domain
7283        );
7284        eprintln!(
7285            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
7286            host_of_url(&our_relay)
7287        );
7288        eprintln!(
7289            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
7290            parsed.nick
7291        );
7292        eprintln!(
7293            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
7294            parsed.nick
7295        );
7296        eprintln!("  peer out-of-band that they actually run a relay at this domain");
7297        eprintln!("  before relying on the pair. (See issue #9.4.)");
7298    }
7299
7300    // 2. Resolve peer via .well-known on their relay.
7301    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
7302    let peer_card = resolved
7303        .get("card")
7304        .cloned()
7305        .ok_or_else(|| anyhow!("resolved missing card"))?;
7306    let peer_did = resolved
7307        .get("did")
7308        .and_then(Value::as_str)
7309        .ok_or_else(|| anyhow!("resolved missing did"))?
7310        .to_string();
7311    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
7312
7313    // Self-pair guard (issue #30, explicit "Optional" ask). Refuses loudly
7314    // when the resolved peer DID matches our own. See
7315    // `reject_self_pair_after_resolution` for the full failure-mode and
7316    // remediation rationale.
7317    reject_self_pair_after_resolution(&our_did, &peer_did)?;
7318
7319    let peer_slot_id = resolved
7320        .get("slot_id")
7321        .and_then(Value::as_str)
7322        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
7323        .to_string();
7324    let peer_relay = resolved
7325        .get("relay_url")
7326        .and_then(Value::as_str)
7327        .map(str::to_string)
7328        .or_else(|| relay_override.map(str::to_string))
7329        .unwrap_or_else(|| format!("https://{}", parsed.domain));
7330
7331    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
7332    let mut trust = config::read_trust()?;
7333    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
7334    config::write_trust(&trust)?;
7335    let mut relay_state = config::read_relay_state()?;
7336    // Additive re-pin (v0.13.2, E3 token-bleed fix). The old code REPLACED the
7337    // whole peer entry with a flat federation-only one, seeding the token from
7338    // the entry's TOP-LEVEL `slot_token`. Two bugs (glossy-magnolia repro):
7339    //   1. re-dialing a peer that had a local endpoint (from add-peer-slot)
7340    //      CLOBBERED that local endpoint.
7341    //   2. after a local add-peer-slot the top-level token was the LOCAL token,
7342    //      so the federation endpoint inherited a stale LOCAL bearer →
7343    //      federation delivery would 401.
7344    // Fix: merge the federation endpoint into the peer's endpoints[] (preserve
7345    // the local one), and seed its token ONLY from a prior FEDERATION endpoint
7346    // on the same relay (re-dialing an already-acked peer), never a local one —
7347    // empty until the pair_drop_ack lands otherwise.
7348    let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
7349        .get("peers")
7350        .and_then(|p| p.get(&peer_handle))
7351        .and_then(|e| e.get("endpoints"))
7352        .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
7353        .unwrap_or_default();
7354    let fed_token = endpoints
7355        .iter()
7356        .find(|e| {
7357            e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
7358        })
7359        .map(|e| e.slot_token.clone())
7360        .unwrap_or_default();
7361    let fed_ep = crate::endpoints::Endpoint {
7362        relay_url: peer_relay.clone(),
7363        slot_id: peer_slot_id.clone(),
7364        slot_token: fed_token, // empty until pair_drop_ack lands
7365        scope: crate::endpoints::EndpointScope::Federation,
7366    };
7367    if let Some(existing) = endpoints
7368        .iter_mut()
7369        .find(|e| e.relay_url == fed_ep.relay_url)
7370    {
7371        *existing = fed_ep;
7372    } else {
7373        endpoints.push(fed_ep);
7374    }
7375    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
7376    config::write_relay_state(&relay_state)?;
7377
7378    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
7379    // is the v0.5 zero-paste open-mode path).
7380    let our_card = config::read_agent_card()?;
7381    let sk_seed = config::read_private_key()?;
7382    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7383    let pk_b64 = our_card
7384        .get("verify_keys")
7385        .and_then(Value::as_object)
7386        .and_then(|m| m.values().next())
7387        .and_then(|v| v.get("key"))
7388        .and_then(Value::as_str)
7389        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7390    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7391    let now = time::OffsetDateTime::now_utc()
7392        .format(&time::format_description::well_known::Rfc3339)
7393        .unwrap_or_default();
7394    // v0.5.17: advertise all our endpoints (federation + optional local)
7395    // to the peer in the pair_drop body. Back-compat: top-level
7396    // relay_url/slot_id/slot_token still point at the federation
7397    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
7398    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
7399    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7400    let mut body = json!({
7401        "card": our_card,
7402        "relay_url": our_relay,
7403        "slot_id": our_slot_id,
7404        "slot_token": our_slot_token,
7405    });
7406    if !our_endpoints.is_empty() {
7407        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7408    }
7409    let event = json!({
7410        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7411        "timestamp": now,
7412        "from": our_did,
7413        "to": peer_did,
7414        "type": "pair_drop",
7415        "kind": 1100u32,
7416        "body": body,
7417    });
7418    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7419
7420    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
7421    let client = crate::relay_client::RelayClient::new(&peer_relay);
7422    let resp = client.handle_intro(&parsed.nick, &signed)?;
7423    let event_id = signed
7424        .get("event_id")
7425        .and_then(Value::as_str)
7426        .unwrap_or("")
7427        .to_string();
7428
7429    if as_json {
7430        println!(
7431            "{}",
7432            serde_json::to_string(&json!({
7433                "handle": handle_arg,
7434                "paired_with": peer_did,
7435                "peer_handle": peer_handle,
7436                "event_id": event_id,
7437                "drop_response": resp,
7438                "status": "drop_sent",
7439            }))?
7440        );
7441    } else {
7442        println!(
7443            "→ 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."
7444        );
7445    }
7446    Ok(())
7447}
7448
7449/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
7450/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
7451/// coords + slot_token from the stored drop, ship our slot_token back via
7452/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
7453/// invite-URL path (which is already bilateral by virtue of the pre-shared
7454/// nonce).
7455fn cmd_add_accept_pending(
7456    handle_arg: &str,
7457    peer_nick: &str,
7458    pending: &crate::pending_inbound_pair::PendingInboundPair,
7459    _our_relay: &str,
7460    _our_slot_id: &str,
7461    _our_slot_token: &str,
7462    as_json: bool,
7463) -> Result<()> {
7464    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
7465    //    `wire add` against this handle while a drop was waiting.
7466    let mut trust = config::read_trust()?;
7467    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
7468    config::write_trust(&trust)?;
7469
7470    // 2. Record peer's relay coords + slot_token (already shipped to us in
7471    //    the original drop body; held back until now).
7472    // v0.5.17: pin all advertised endpoints (federation + optional local).
7473    // Falls back to a single federation entry when the record was written
7474    // by v0.5.16-era code that didn't carry endpoints[].
7475    let mut relay_state = config::read_relay_state()?;
7476    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
7477        vec![crate::endpoints::Endpoint::federation(
7478            pending.peer_relay_url.clone(),
7479            pending.peer_slot_id.clone(),
7480            pending.peer_slot_token.clone(),
7481        )]
7482    } else {
7483        pending.peer_endpoints.clone()
7484    };
7485    crate::endpoints::pin_peer_endpoints(
7486        &mut relay_state,
7487        &pending.peer_handle,
7488        &endpoints_to_pin,
7489    )?;
7490    config::write_relay_state(&relay_state)?;
7491
7492    // 3. Ship our slot_token to peer via pair_drop_ack — try every advertised
7493    //    peer endpoint in priority order (Bug 2). `endpoints_to_pin` was
7494    //    already built from `pending.peer_endpoints` (with legacy-triple
7495    //    fallback) just above, so we reuse it rather than rebuilding.
7496    crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
7497        || {
7498            format!(
7499                "pair_drop_ack send to {} (across {} endpoint(s)) failed",
7500                pending.peer_handle,
7501                endpoints_to_pin.len()
7502            )
7503        },
7504    )?;
7505
7506    // 4. Delete the pending-inbound record now that bilateral is complete.
7507    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
7508
7509    if as_json {
7510        println!(
7511            "{}",
7512            serde_json::to_string(&json!({
7513                "handle": handle_arg,
7514                "paired_with": pending.peer_did,
7515                "peer_handle": pending.peer_handle,
7516                "status": "bilateral_accepted",
7517                "via": "pending_inbound",
7518            }))?
7519        );
7520    } else {
7521        println!(
7522            "→ 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} \"...\"`.",
7523            peer = pending.peer_handle,
7524        );
7525    }
7526    Ok(())
7527}
7528
7529/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
7530/// for a pending-inbound pair request. Pin trust, write relay_state from the
7531/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
7532/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
7533/// when a pending-inbound record exists, but without needing to remember
7534/// the peer's relay domain.
7535fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
7536    let nick = crate::agent_card::bare_handle(peer_nick);
7537    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
7538        anyhow!(
7539            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
7540             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
7541        )
7542    })?;
7543    let (_our_did, our_relay, our_slot_id, our_slot_token) =
7544        crate::pair_invite::ensure_self_with_relay(None)?;
7545    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
7546    cmd_add_accept_pending(
7547        &handle_arg,
7548        nick,
7549        &pending,
7550        &our_relay,
7551        &our_slot_id,
7552        &our_slot_token,
7553        as_json,
7554    )
7555}
7556
7557/// v0.5.14: programmatic access to pending-inbound for scripts.
7558/// `wire pair-list-inbound --json` returns a flat array of records.
7559fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
7560    let items = crate::pending_inbound_pair::list_pending_inbound()?;
7561    if as_json {
7562        println!("{}", serde_json::to_string(&items)?);
7563        return Ok(());
7564    }
7565    if items.is_empty() {
7566        println!("no pending pair requests — your inbox is clear.");
7567        return Ok(());
7568    }
7569    // v0.9.3: conversational output. Tabular data is for --json. Humans
7570    // get one short sentence per pending peer, each rendered with the
7571    // peer's character (DID-derived emoji + nickname) so they can match
7572    // the speaker against their statusline / mesh-status view at a
7573    // glance. The "next step" sentence at the bottom names the exact
7574    // verbs to run.
7575    let plural = if items.len() == 1 { "" } else { "s" };
7576    println!("{} pending pair request{plural}:\n", items.len());
7577    for p in &items {
7578        let ch = crate::character::Character::from_did(&p.peer_did);
7579        let glyph = crate::character::emoji_with_fallback(&ch);
7580        // ASCII-friendly arrow if the operator's terminal can't render
7581        // emoji (the same routine drives the fallback).
7582        println!(
7583            "  {glyph} {nick}  ({handle})  wants to pair with you",
7584            nick = ch.nickname,
7585            handle = p.peer_handle,
7586        );
7587    }
7588    println!();
7589    println!(
7590        "→ to accept any: `wire accept <name>`  (e.g. `wire accept {first}`)",
7591        first = items
7592            .first()
7593            .map(|p| {
7594                let ch = crate::character::Character::from_did(&p.peer_did);
7595                ch.nickname
7596            })
7597            .unwrap_or_else(|| "<name>".to_string())
7598    );
7599    println!("→ to refuse:    `wire reject <name>`");
7600    Ok(())
7601}
7602
7603/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
7604/// without pairing. No event is sent back to the peer; their side stays
7605/// pending until they time out or the operator-side data ages out.
7606fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
7607    let nick = crate::agent_card::bare_handle(peer_nick);
7608    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
7609    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
7610
7611    if as_json {
7612        println!(
7613            "{}",
7614            serde_json::to_string(&json!({
7615                "peer": nick,
7616                "rejected": existed.is_some(),
7617                "had_pending": existed.is_some(),
7618            }))?
7619        );
7620    } else if existed.is_some() {
7621        println!(
7622            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
7623        );
7624    } else {
7625        println!("no pending pair from {nick} — nothing to reject");
7626    }
7627    Ok(())
7628}
7629
7630// ---------- session (v0.5.16) ----------
7631//
7632// Multi-session wire on one machine. See src/session.rs for the storage
7633// layout + naming rules. The CLI dispatcher here orchestrates child
7634// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
7635// each session-local `init` / `claim` / `daemon` runs in its own world
7636// without cross-contamination via env vars in this process.
7637
7638// ---------- group chat (v0.13.3) ----------
7639
7640fn cmd_group(cmd: GroupCommand) -> Result<()> {
7641    match cmd {
7642        GroupCommand::Create { name, json } => cmd_group_create(&name, json),
7643        GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
7644        GroupCommand::Send {
7645            group,
7646            message,
7647            json,
7648        } => cmd_group_send(&group, &message, json),
7649        GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
7650        GroupCommand::List { json } => cmd_group_list(json),
7651        GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
7652        GroupCommand::Join { code, json } => cmd_group_join(&code, json),
7653    }
7654}
7655
7656/// This agent's (did, handle) from its signed card.
7657/// This agent's signing identity for group ops: (did, handle, key_id, pk_b64).
7658fn group_self() -> Result<(String, String, String, String)> {
7659    let card = config::read_agent_card()?;
7660    let did = card
7661        .get("did")
7662        .and_then(Value::as_str)
7663        .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
7664        .to_string();
7665    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7666    let pk_b64 = card
7667        .get("verify_keys")
7668        .and_then(Value::as_object)
7669        .and_then(|m| m.values().next())
7670        .and_then(|v| v.get("key"))
7671        .and_then(Value::as_str)
7672        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
7673        .to_string();
7674    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7675    let key_id = make_key_id(&handle, &pk_bytes);
7676    Ok((did, handle, key_id, pk_b64))
7677}
7678
7679/// Relay to host a group room on — prefer the federation endpoint (remote
7680/// members can reach it), fall back to LAN, then local, then any.
7681fn group_room_relay_url() -> Result<String> {
7682    use crate::endpoints::EndpointScope;
7683    let state = config::read_relay_state()?;
7684    let eps = crate::endpoints::self_endpoints(&state);
7685    let pick = eps
7686        .iter()
7687        .find(|e| e.scope == EndpointScope::Federation)
7688        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
7689        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
7690        .or_else(|| eps.first());
7691    match pick {
7692        Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
7693        _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
7694    }
7695}
7696
7697/// Sign a `group_invite` (carrying the full creator-signed Group) and queue it
7698/// to every other member's outbox. The daemon/push delivers; the recipient's
7699/// `ingest_group_invites` materializes the room + introduce-pins members.
7700fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
7701    let (_, self_handle, _, pk_b64) = group_self()?;
7702    let sk_seed = config::read_private_key()?;
7703    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7704    let now_iso = time::OffsetDateTime::now_utc()
7705        .format(&time::format_description::well_known::Rfc3339)
7706        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7707    let group_json = serde_json::to_value(group)?;
7708    let mut delivered = 0usize;
7709    for handle in group.other_member_handles(self_did) {
7710        let event = json!({
7711            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7712            "timestamp": now_iso,
7713            "from": self_did,
7714            "to": format!("did:wire:{handle}"),
7715            "type": "group_invite",
7716            "kind": parse_kind("group_invite")?,
7717            "body": group_json,
7718        });
7719        let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7720            .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
7721        let line = serde_json::to_vec(&signed)?;
7722        if config::append_outbox_record(&handle, &line).is_ok() {
7723            delivered += 1;
7724        }
7725    }
7726    Ok(delivered)
7727}
7728
7729/// Introduce-pin a member's key on the creator's vouch: ensure
7730/// `trust.agents[handle]` carries this key so the member's group messages
7731/// verify, WITHOUT granting bilateral trust. Never lowers an existing tier
7732/// (a directly-VERIFIED peer stays VERIFIED); only adds the key if missing.
7733/// Returns `true` iff it actually changed `trust` (new entry or added key) —
7734/// callers use this to decide whether to persist.
7735fn introduce_pin(
7736    trust: &mut Value,
7737    handle: &str,
7738    did: &str,
7739    key_id: &str,
7740    key: &str,
7741    group_id: &str,
7742) -> bool {
7743    let now = time::OffsetDateTime::now_utc()
7744        .format(&time::format_description::well_known::Rfc3339)
7745        .unwrap_or_default();
7746    let agents = trust
7747        .as_object_mut()
7748        .expect("trust is an object")
7749        .entry("agents")
7750        .or_insert_with(|| json!({}));
7751    let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
7752    match agents.get_mut(handle) {
7753        Some(existing) => {
7754            // Already pinned (maybe at a higher bilateral tier) — just ensure
7755            // the key is present. Do NOT touch the tier.
7756            let keys = existing
7757                .as_object_mut()
7758                .and_then(|o| o.get_mut("public_keys"))
7759                .and_then(Value::as_array_mut);
7760            if let Some(keys) = keys {
7761                let have = keys
7762                    .iter()
7763                    .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
7764                if !have {
7765                    keys.push(key_rec);
7766                    return true;
7767                }
7768            }
7769            false
7770        }
7771        None => {
7772            // First sight — pin at bilateral UNTRUSTED (disjoint from GroupTier).
7773            agents[handle] = json!({
7774                "tier": "UNTRUSTED",
7775                "did": did,
7776                "public_keys": [key_rec],
7777                "introduced_via": group_id,
7778                "pinned_at": now,
7779            });
7780            true
7781        }
7782    }
7783}
7784
7785/// Scan the inbox for `group_invite` events from pinned creators, verify them
7786/// (event signature + roster `creator_sig`), materialize/refresh the local
7787/// group at its highest epoch, and introduce-pin every other member. Lazy:
7788/// runs at the top of group send/tail/list so a member just-pulled an invite
7789/// is immediately usable. Skips groups this agent created.
7790fn ingest_group_invites() -> Result<()> {
7791    let inbox = config::inbox_dir()?;
7792    if !inbox.exists() {
7793        return Ok(());
7794    }
7795    let (self_did, ..) = group_self()?;
7796    let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7797    // group_id -> highest-epoch verified roster seen in the inbox.
7798    let mut best: std::collections::HashMap<String, crate::group::Group> =
7799        std::collections::HashMap::new();
7800
7801    for entry in std::fs::read_dir(&inbox)?.flatten() {
7802        let path = entry.path();
7803        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7804            continue;
7805        }
7806        for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
7807            let event: Value = match serde_json::from_str(line) {
7808                Ok(v) => v,
7809                Err(_) => continue,
7810            };
7811            if event.get("type").and_then(Value::as_str) != Some("group_invite") {
7812                continue;
7813            }
7814            // Event-level: the invite must be from a pinned peer (the creator)
7815            // with a valid signature.
7816            if verify_message_v31(&event, &trust_now).is_err() {
7817                continue;
7818            }
7819            let Some(body) = event.get("body") else {
7820                continue;
7821            };
7822            let group: crate::group::Group = match serde_json::from_value(body.clone()) {
7823                Ok(g) => g,
7824                Err(_) => continue,
7825            };
7826            if group.creator_did == self_did {
7827                continue; // never overwrite a group I created
7828            }
7829            // The invite's sender must be the group's creator.
7830            let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7831            if from_did != group.creator_did {
7832                continue;
7833            }
7834            // Roster integrity: creator_sig must verify against the creator's
7835            // independently-pinned key (we paired with the creator → have it).
7836            let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
7837            let creator_key = trust_now
7838                .get("agents")
7839                .and_then(|a| a.get(creator_handle))
7840                .and_then(|a| a.get("public_keys"))
7841                .and_then(Value::as_array)
7842                .and_then(|ks| ks.first())
7843                .and_then(|k| k.get("key"))
7844                .and_then(Value::as_str)
7845                .and_then(|b| crate::signing::b64decode(b).ok());
7846            let Some(creator_key) = creator_key else {
7847                continue;
7848            };
7849            if !group.verify(&creator_key) {
7850                continue;
7851            }
7852            match best.get(&group.id) {
7853                Some(prev) if prev.epoch >= group.epoch => {}
7854                _ => {
7855                    best.insert(group.id.clone(), group);
7856                }
7857            }
7858        }
7859    }
7860
7861    if best.is_empty() {
7862        return Ok(());
7863    }
7864    let mut trust = config::read_trust()?;
7865    for group in best.values() {
7866        // Don't regress a locally-known group to a stale epoch.
7867        if let Ok(local) = crate::group::load_group(&group.id)
7868            && local.epoch >= group.epoch
7869        {
7870            continue;
7871        }
7872        crate::group::save_group(group)?;
7873        for m in &group.members {
7874            if m.did == self_did || m.key.is_empty() {
7875                continue;
7876            }
7877            introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7878        }
7879    }
7880    config::write_trust(&trust)?;
7881    Ok(())
7882}
7883
7884fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
7885    if !config::is_initialized()? {
7886        bail!("not initialized — run `wire up` first");
7887    }
7888    let (did, handle, key_id, pk_b64) = group_self()?;
7889    let relay_url = group_room_relay_url()?;
7890    // Allocate the shared group-room slot on the relay.
7891    let client = crate::relay_client::RelayClient::new(&relay_url);
7892    let room = client
7893        .allocate_slot(Some(&format!("group:{name}")))
7894        .with_context(|| format!("allocating group room on {relay_url}"))?;
7895    let id = format!("g{:016x}", rand::random::<u64>());
7896    let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
7897    group.set_room(relay_url, room.slot_id, room.slot_token);
7898    group.set_member_keys(&did, key_id, pk_b64)?;
7899    let sk = config::read_private_key()?;
7900    group.sign(&sk)?;
7901    crate::group::save_group(&group)?;
7902    if as_json {
7903        println!(
7904            "{}",
7905            serde_json::to_string(&json!({
7906                "id": id, "name": name, "members": 1, "relay_url": group.relay_url
7907            }))?
7908        );
7909    } else {
7910        println!(
7911            "created group `{name}` (id {id}) — room on {}. You are the creator.",
7912            group.relay_url
7913        );
7914        println!("  add peers: `wire group add {id} <peer>`   talk: `wire group send {id} \"hi\"`");
7915    }
7916    Ok(())
7917}
7918
7919fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
7920    let (self_did, ..) = group_self()?;
7921    let mut group = crate::group::resolve_group(group_ref)?;
7922    if group.creator_did != self_did {
7923        bail!("only the group creator can add members (the creator signs the roster)");
7924    }
7925    // T22 consent: a Member must be a peer you bilaterally VERIFIED.
7926    let bare = crate::agent_card::bare_handle(peer).to_string();
7927    let trust = config::read_trust()?;
7928    let agent = trust
7929        .get("agents")
7930        .and_then(|a| a.get(&bare))
7931        .ok_or_else(|| {
7932            anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
7933        })?;
7934    let tier = agent
7935        .get("tier")
7936        .and_then(Value::as_str)
7937        .unwrap_or("UNTRUSTED");
7938    if tier != "VERIFIED" {
7939        bail!(
7940            "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
7941        );
7942    }
7943    let peer_did = agent
7944        .get("did")
7945        .and_then(Value::as_str)
7946        .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
7947        .to_string();
7948    // Capture the peer's signing key from trust so the creator can vouch for it
7949    // in the signed roster (members introduce-pin it to verify this peer).
7950    let key = agent
7951        .get("public_keys")
7952        .and_then(Value::as_array)
7953        .and_then(|ks| {
7954            ks.iter()
7955                .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
7956        })
7957        .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
7958    let peer_key_id = key
7959        .get("key_id")
7960        .and_then(Value::as_str)
7961        .unwrap_or_default()
7962        .to_string();
7963    let peer_pk = key
7964        .get("key")
7965        .and_then(Value::as_str)
7966        .unwrap_or_default()
7967        .to_string();
7968
7969    group.add_member(
7970        bare.clone(),
7971        peer_did.clone(),
7972        crate::group::GroupTier::Member,
7973    )?;
7974    group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
7975    let sk = config::read_private_key()?;
7976    group.sign(&sk)?;
7977    crate::group::save_group(&group)?;
7978    // Distribute the refreshed signed roster (room coords + everyone's keys) to
7979    // ALL members so each can post + verify the others.
7980    let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
7981    if as_json {
7982        println!(
7983            "{}",
7984            serde_json::to_string(&json!({
7985                "group": group.id, "added": bare, "epoch": group.epoch,
7986                "members": group.members.len(), "invites_queued": delivered
7987            }))?
7988        );
7989    } else {
7990        println!(
7991            "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
7992            group.name,
7993            group.members.len(),
7994            group.epoch
7995        );
7996    }
7997    Ok(())
7998}
7999
8000fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
8001    if !config::is_initialized()? {
8002        bail!("not initialized — run `wire up` first");
8003    }
8004    ingest_group_invites()?;
8005    let (self_did, self_handle, _, pk_b64) = group_self()?;
8006    let group = crate::group::resolve_group(group_ref)?;
8007    // Membership for SEND is room-token possession: having the group locally
8008    // (with its slot_token) is the capability. The signed roster gates who you
8009    // can VERIFY, not whether you may post — a code-redeemed joiner isn't in the
8010    // creator-signed roster but legitimately holds the room key.
8011    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8012        bail!(
8013            "group `{}` has no room slot (legacy/partial group)",
8014            group.name
8015        );
8016    }
8017    let sk_seed = config::read_private_key()?;
8018    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8019    let now_iso = time::OffsetDateTime::now_utc()
8020        .format(&time::format_description::well_known::Rfc3339)
8021        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8022    let event = json!({
8023        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8024        "timestamp": now_iso,
8025        "from": self_did,
8026        "to": format!("did:wire:group:{}", group.id),
8027        "type": "group_msg",
8028        "kind": parse_kind("group_msg")?,
8029        "body": {
8030            "group_id": group.id,
8031            "group_name": group.name,
8032            "epoch": group.epoch,
8033            "text": message,
8034        },
8035    });
8036    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8037        .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
8038    // Post the one message to the shared group slot.
8039    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8040    client
8041        .post_event(&group.slot_id, &group.slot_token, &signed)
8042        .with_context(|| {
8043            format!(
8044                "posting to group room {} on {}",
8045                group.slot_id, group.relay_url
8046            )
8047        })?;
8048    if as_json {
8049        println!(
8050            "{}",
8051            serde_json::to_string(&json!({
8052                "group": group.id, "epoch": group.epoch, "status": "posted",
8053                "members": group.members.len()
8054            }))?
8055        );
8056    } else {
8057        println!(
8058            "group `{}`: posted to the room ({} member(s))",
8059            group.name,
8060            group.members.len()
8061        );
8062    }
8063    Ok(())
8064}
8065
8066fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
8067    ingest_group_invites()?;
8068    let group = crate::group::resolve_group(group_ref)?;
8069    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8070        bail!(
8071            "group `{}` has no room slot (legacy/partial group)",
8072            group.name
8073        );
8074    }
8075    let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
8076    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8077    // Pull the shared room; cap generously then show the last `limit`.
8078    let fetch = if limit == 0 {
8079        1000
8080    } else {
8081        (limit * 4).min(1000)
8082    };
8083    let events = client
8084        .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
8085        .with_context(|| {
8086            format!(
8087                "pulling group room {} on {}",
8088                group.slot_id, group.relay_url
8089            )
8090        })?;
8091
8092    // Pass 1: introduce-pin anyone who announced a join. A `group_join` carries
8093    // the joiner's card and must self-consistently sign under it; posting to the
8094    // room requires the room token, so possession is the authorization (pinned
8095    // at bilateral UNTRUSTED, group tier Introduced). This lets their later
8096    // group messages verify even though they're not in the creator-signed roster.
8097    let mut trust_changed = false;
8098    for event in &events {
8099        if event.get("type").and_then(Value::as_str) != Some("group_join") {
8100            continue;
8101        }
8102        if let Some((h, did, kid, key)) = group_join_pin_material(event)
8103            && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
8104        {
8105            trust_changed = true;
8106        }
8107    }
8108    if trust_changed {
8109        let _ = config::write_trust(&trust);
8110    }
8111
8112    // Pass 2: build the timeline — group messages (verified against the
8113    // now-augmented trust) interleaved with join notices.
8114    enum Line {
8115        Msg {
8116            from: String,
8117            text: String,
8118            verified: bool,
8119        },
8120        Join {
8121            who: String,
8122        },
8123    }
8124    let mut timeline: Vec<(String, Line)> = Vec::new();
8125    for event in &events {
8126        let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
8127        let body = match event.get("body") {
8128            Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
8129            Some(v) => Some(v.clone()),
8130            None => None,
8131        };
8132        let Some(body) = body else { continue };
8133        if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
8134            continue;
8135        }
8136        let ts = event
8137            .get("timestamp")
8138            .and_then(Value::as_str)
8139            .unwrap_or("")
8140            .to_string();
8141        let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
8142        let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
8143        match ty {
8144            "group_msg" => {
8145                let text = body
8146                    .get("text")
8147                    .and_then(Value::as_str)
8148                    .unwrap_or("")
8149                    .to_string();
8150                let verified = verify_message_v31(event, &trust).is_ok();
8151                timeline.push((
8152                    ts,
8153                    Line::Msg {
8154                        from: from_handle,
8155                        text,
8156                        verified,
8157                    },
8158                ));
8159            }
8160            "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
8161            _ => {}
8162        }
8163    }
8164    timeline.sort_by(|a, b| a.0.cmp(&b.0));
8165    let start = if limit > 0 {
8166        timeline.len().saturating_sub(limit)
8167    } else {
8168        0
8169    };
8170    let recent = &timeline[start..];
8171    if as_json {
8172        let arr: Vec<Value> = recent
8173            .iter()
8174            .map(|(ts, l)| match l {
8175                Line::Msg {
8176                    from,
8177                    text,
8178                    verified,
8179                } => {
8180                    json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
8181                }
8182                Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
8183            })
8184            .collect();
8185        println!(
8186            "{}",
8187            serde_json::to_string(
8188                &json!({"group": group.id, "name": group.name, "messages": arr})
8189            )?
8190        );
8191    } else if recent.is_empty() {
8192        println!("group `{}`: no messages yet", group.name);
8193    } else {
8194        for (ts, l) in recent {
8195            let short_ts: String = ts.chars().take(19).collect();
8196            match l {
8197                Line::Msg {
8198                    from,
8199                    text,
8200                    verified,
8201                } => {
8202                    let mark = if *verified { "✓" } else { "✗" };
8203                    println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
8204                }
8205                Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
8206            }
8207        }
8208    }
8209    Ok(())
8210}
8211
8212/// Validate a `group_join` room event and extract the joiner's pin material:
8213/// (handle, did, key_id, key_b64). The event MUST self-consistently sign under
8214/// the key in the card it carries — so a forged join (card A, signed by key B)
8215/// is rejected. Authorization to be in the room is proven by the post itself
8216/// (it required the room token).
8217fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
8218    let body = match event.get("body") {
8219        Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
8220        Some(v) => v.clone(),
8221        None => return None,
8222    };
8223    let card = body.get("joiner_card")?;
8224    // Verify the event signs under the card it carries (one-entry trust).
8225    let mut tmp = json!({"agents": {}});
8226    crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
8227    if verify_message_v31(event, &tmp).is_err() {
8228        return None;
8229    }
8230    let did = card.get("did").and_then(Value::as_str)?.to_string();
8231    let handle = card
8232        .get("handle")
8233        .and_then(Value::as_str)
8234        .map(str::to_string)
8235        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8236    let (kid_full, krec) = card
8237        .get("verify_keys")
8238        .and_then(Value::as_object)
8239        .and_then(|m| m.iter().next())?;
8240    let key_id = kid_full
8241        .strip_prefix("ed25519:")
8242        .unwrap_or(kid_full)
8243        .to_string();
8244    let key = krec.get("key").and_then(Value::as_str)?.to_string();
8245    Some((handle, did, key_id, key))
8246}
8247
8248/// `wire group invite <group>` — mint a self-contained join code (the serialized
8249/// signed group: room coords + roster + member keys). The code IS the room key.
8250fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
8251    let group = crate::group::resolve_group(group_ref)?;
8252    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8253        bail!(
8254            "group `{}` has no room slot — nothing to invite into",
8255            group.name
8256        );
8257    }
8258    if group.creator_sig.is_empty() {
8259        bail!(
8260            "group `{}` roster is unsigned — add a member or recreate before inviting",
8261            group.name
8262        );
8263    }
8264    let payload = serde_json::to_vec(&group)?;
8265    let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
8266    if as_json {
8267        println!(
8268            "{}",
8269            serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
8270        );
8271    } else {
8272        println!(
8273            "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
8274            group.name
8275        );
8276        println!("{code}\n");
8277        println!("they run:  wire group join <code>");
8278    }
8279    Ok(())
8280}
8281
8282/// `wire group join <code>` — redeem a join code: verify the roster, materialize
8283/// the room locally, introduce-pin existing members, and announce ourselves to
8284/// the room so members verify our messages. Lands at group tier Introduced.
8285fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
8286    if !config::is_initialized()? {
8287        bail!("not initialized — run `wire up` first");
8288    }
8289    let raw = code.trim();
8290    let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
8291    let payload =
8292        crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
8293    let group: crate::group::Group = serde_json::from_slice(&payload)
8294        .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
8295    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8296        bail!("join code carries no room coords");
8297    }
8298    // Verify the roster against the creator's key carried IN the roster (TOFU on
8299    // the code — you obtained it over a trusted channel). Rejects a tampered code.
8300    let creator_key = group
8301        .members
8302        .iter()
8303        .find(|m| m.did == group.creator_did)
8304        .map(|m| m.key.clone())
8305        .filter(|k| !k.is_empty())
8306        .and_then(|k| crate::signing::b64decode(&k).ok())
8307        .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
8308    if !group.verify(&creator_key) {
8309        bail!("join code failed its signature check (tampered or corrupt)");
8310    }
8311    let (self_did, self_handle, _, _) = group_self()?;
8312    if group.creator_did == self_did {
8313        bail!("you created group `{}` — you're already in it", group.name);
8314    }
8315
8316    // Materialize locally + introduce-pin existing members so we can verify them.
8317    crate::group::save_group(&group)?;
8318    let mut trust = config::read_trust()?;
8319    for m in &group.members {
8320        if m.did == self_did || m.key.is_empty() {
8321            continue;
8322        }
8323        introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
8324    }
8325    config::write_trust(&trust)?;
8326
8327    // Announce ourselves to the room (carry our card) so members introduce-pin us.
8328    let card = config::read_agent_card()?;
8329    let sk_seed = config::read_private_key()?;
8330    let pk_b64 = card
8331        .get("verify_keys")
8332        .and_then(Value::as_object)
8333        .and_then(|m| m.values().next())
8334        .and_then(|v| v.get("key"))
8335        .and_then(Value::as_str)
8336        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8337    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8338    let now_iso = time::OffsetDateTime::now_utc()
8339        .format(&time::format_description::well_known::Rfc3339)
8340        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8341    let event = json!({
8342        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8343        "timestamp": now_iso,
8344        "from": self_did,
8345        "to": format!("did:wire:group:{}", group.id),
8346        "type": "group_join",
8347        "kind": parse_kind("group_join")?,
8348        "body": {
8349            "group_id": group.id,
8350            "group_name": group.name,
8351            "epoch": group.epoch,
8352            "joiner_card": card,
8353            "text": "joined",
8354        },
8355    });
8356    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8357        .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
8358    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8359    let announced = client
8360        .post_event(&group.slot_id, &group.slot_token, &signed)
8361        .is_ok();
8362
8363    if as_json {
8364        println!(
8365            "{}",
8366            serde_json::to_string(&json!({
8367                "group": group.id, "name": group.name, "joined": true,
8368                "members": group.members.len(), "announced": announced
8369            }))?
8370        );
8371    } else {
8372        println!(
8373            "joined group `{}` ({} member(s)) at Introduced tier.",
8374            group.name,
8375            group.members.len()
8376        );
8377        if announced {
8378            println!("  announced to the room — members will verify your messages.");
8379        } else {
8380            println!(
8381                "  ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
8382            );
8383        }
8384        println!(
8385            "  read: `wire group tail {}`   talk: `wire group send {} \"hi\"`",
8386            group.id, group.id
8387        );
8388    }
8389    Ok(())
8390}
8391
8392fn cmd_group_list(as_json: bool) -> Result<()> {
8393    let groups = crate::group::list_groups()?;
8394    if as_json {
8395        let arr: Vec<Value> = groups
8396            .iter()
8397            .map(|g| {
8398                json!({
8399                    "id": g.id,
8400                    "name": g.name,
8401                    "epoch": g.epoch,
8402                    "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
8403                })
8404            })
8405            .collect();
8406        println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
8407    } else if groups.is_empty() {
8408        println!("no groups yet — create one with `wire group create <name>`");
8409    } else {
8410        for g in &groups {
8411            println!(
8412                "{} ({}) — {} member(s), epoch {}",
8413                g.name,
8414                g.id,
8415                g.members.len(),
8416                g.epoch
8417            );
8418            for m in &g.members {
8419                println!("    {} [{}]", m.handle, m.tier.as_str());
8420            }
8421        }
8422    }
8423    Ok(())
8424}
8425
8426/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
8427/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
8428fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
8429    match cmd {
8430        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
8431        MeshCommand::Broadcast {
8432            kind,
8433            scope,
8434            exclude,
8435            noreply,
8436            body,
8437            json,
8438        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
8439        MeshCommand::Role { action } => cmd_mesh_role(action),
8440        MeshCommand::Route {
8441            role,
8442            strategy,
8443            exclude,
8444            kind,
8445            body,
8446            json,
8447        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
8448    }
8449}
8450
8451/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
8452/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
8453/// picks ONE via the requested strategy, then signs + pushes the event
8454/// to that peer. Pinned-peers-only by construction (same as broadcast).
8455fn cmd_mesh_route(
8456    role: &str,
8457    strategy: &str,
8458    exclude: &[String],
8459    kind: &str,
8460    body_arg: &str,
8461    as_json: bool,
8462) -> Result<()> {
8463    use std::time::Instant;
8464
8465    if !config::is_initialized()? {
8466        bail!("not initialized — run `wire init <handle>` first");
8467    }
8468    let strategy = strategy.to_ascii_lowercase();
8469    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
8470        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
8471    }
8472
8473    // Our pinned-peer set: only these handles are addressable. mesh-route
8474    // refuses to invent a recipient, same posture as broadcast.
8475    let state = config::read_relay_state()?;
8476    let pinned: std::collections::BTreeSet<String> = state["peers"]
8477        .as_object()
8478        .map(|m| m.keys().cloned().collect())
8479        .unwrap_or_default();
8480
8481    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8482
8483    // Enumerate every sister on the box, read each one's role from its
8484    // signed agent-card. Filter: matching role AND pinned AND not
8485    // excluded. `list_sessions` returns the cross-session view (using the
8486    // v0.6.4 inside-session sessions_root fallback).
8487    let sessions = crate::session::list_sessions()?;
8488    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
8489    for s in &sessions {
8490        let handle = match s.handle.as_ref() {
8491            Some(h) => h.clone(),
8492            None => continue,
8493        };
8494        if exclude_set.contains(handle.as_str()) {
8495            continue;
8496        }
8497        if !pinned.contains(&handle) {
8498            continue;
8499        }
8500        let card_path = s
8501            .home_dir
8502            .join("config")
8503            .join("wire")
8504            .join("agent-card.json");
8505        let card_role = std::fs::read(&card_path)
8506            .ok()
8507            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8508            .and_then(|c| {
8509                c.get("profile")
8510                    .and_then(|p| p.get("role"))
8511                    .and_then(Value::as_str)
8512                    .map(str::to_string)
8513            });
8514        if card_role.as_deref() == Some(role) {
8515            candidates.push((handle, s.did.clone()));
8516        }
8517    }
8518
8519    candidates.sort_by(|a, b| a.0.cmp(&b.0));
8520    candidates.dedup_by(|a, b| a.0 == b.0);
8521
8522    if candidates.is_empty() {
8523        bail!(
8524            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
8525        );
8526    }
8527
8528    let chosen = match strategy.as_str() {
8529        "first" => candidates[0].clone(),
8530        "random" => {
8531            use rand::Rng;
8532            let idx = rand::thread_rng().gen_range(0..candidates.len());
8533            candidates[idx].clone()
8534        }
8535        "round-robin" => {
8536            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
8537            // `{role: last_picked_handle}`. Next pick = first candidate
8538            // alphabetically AFTER last_picked, wrapping around when no
8539            // candidate is greater.
8540            let cursor_path = mesh_route_cursor_path()?;
8541            let mut cursors: std::collections::BTreeMap<String, String> =
8542                read_mesh_route_cursors(&cursor_path);
8543            let last = cursors.get(role).cloned();
8544            let pick = match last {
8545                None => candidates[0].clone(),
8546                Some(last_h) => candidates
8547                    .iter()
8548                    .find(|(h, _)| h.as_str() > last_h.as_str())
8549                    .cloned()
8550                    .unwrap_or_else(|| candidates[0].clone()),
8551            };
8552            cursors.insert(role.to_string(), pick.0.clone());
8553            write_mesh_route_cursors(&cursor_path, &cursors)?;
8554            pick
8555        }
8556        _ => unreachable!(),
8557    };
8558
8559    let (chosen_handle, _chosen_did) = chosen;
8560
8561    // Body parsing follows wire send / mesh broadcast.
8562    let body_value: Value = if body_arg == "-" {
8563        use std::io::Read;
8564        let mut raw = String::new();
8565        std::io::stdin()
8566            .read_to_string(&mut raw)
8567            .with_context(|| "reading body from stdin")?;
8568        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8569    } else if let Some(path) = body_arg.strip_prefix('@') {
8570        let raw =
8571            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8572        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8573    } else {
8574        Value::String(body_arg.to_string())
8575    };
8576
8577    let sk_seed = config::read_private_key()?;
8578    let card = config::read_agent_card()?;
8579    let did = card
8580        .get("did")
8581        .and_then(Value::as_str)
8582        .ok_or_else(|| anyhow!("agent-card missing did"))?
8583        .to_string();
8584    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8585    let pk_b64 = card
8586        .get("verify_keys")
8587        .and_then(Value::as_object)
8588        .and_then(|m| m.values().next())
8589        .and_then(|v| v.get("key"))
8590        .and_then(Value::as_str)
8591        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8592    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8593
8594    let kind_id = parse_kind(kind)?;
8595    let now_iso = time::OffsetDateTime::now_utc()
8596        .format(&time::format_description::well_known::Rfc3339)
8597        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8598
8599    let event = json!({
8600        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8601        "timestamp": now_iso,
8602        "from": did,
8603        "to": format!("did:wire:{chosen_handle}"),
8604        "type": kind,
8605        "kind": kind_id,
8606        "body": json!({
8607            "content": body_value,
8608            "routed_via": {
8609                "role": role,
8610                "strategy": strategy,
8611            },
8612        }),
8613    });
8614    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8615        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
8616    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8617
8618    let line = serde_json::to_vec(&signed)?;
8619    config::append_outbox_record(&chosen_handle, &line)?;
8620
8621    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
8622    if endpoints.is_empty() {
8623        bail!(
8624            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
8625        );
8626    }
8627    let start = Instant::now();
8628    let mut delivered = false;
8629    let mut last_err: Option<String> = None;
8630    let mut via_scope: Option<String> = None;
8631    for ep in &endpoints {
8632        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
8633        // route via uds_request, others via reqwest. Allows peers with
8634        // UDS-tagged endpoints in their agent-card to receive events
8635        // over the local socket instead of loopback HTTP.
8636        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8637            Ok(_) => {
8638                delivered = true;
8639                via_scope = Some(
8640                    match ep.scope {
8641                        crate::endpoints::EndpointScope::Local => "local",
8642                        crate::endpoints::EndpointScope::Lan => "lan",
8643                        crate::endpoints::EndpointScope::Uds => "uds",
8644                        crate::endpoints::EndpointScope::Federation => "federation",
8645                    }
8646                    .to_string(),
8647                );
8648                break;
8649            }
8650            Err(e) => last_err = Some(format!("{e:#}")),
8651        }
8652    }
8653    let rtt_ms = start.elapsed().as_millis() as u64;
8654
8655    let summary = json!({
8656        "role": role,
8657        "strategy": strategy,
8658        "routed_to": chosen_handle,
8659        "event_id": event_id,
8660        "delivered": delivered,
8661        "delivered_via": via_scope,
8662        "rtt_ms": rtt_ms,
8663        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
8664        "error": last_err,
8665    });
8666
8667    if as_json {
8668        println!("{}", serde_json::to_string(&summary)?);
8669    } else if delivered {
8670        let via = via_scope.as_deref().unwrap_or("?");
8671        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
8672    } else {
8673        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
8674        bail!("delivery to `{chosen_handle}` failed: {err}");
8675    }
8676    Ok(())
8677}
8678
8679fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
8680    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
8681}
8682
8683fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
8684    std::fs::read(path)
8685        .ok()
8686        .and_then(|b| serde_json::from_slice(&b).ok())
8687        .unwrap_or_default()
8688}
8689
8690fn write_mesh_route_cursors(
8691    path: &std::path::Path,
8692    cursors: &std::collections::BTreeMap<String, String>,
8693) -> Result<()> {
8694    if let Some(parent) = path.parent() {
8695        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
8696    }
8697    let body = serde_json::to_vec_pretty(cursors)?;
8698    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
8699    Ok(())
8700}
8701
8702/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
8703/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
8704/// behind a discoverability-friendlier surface, plus cross-session
8705/// enumeration for the list path.
8706fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
8707    match action {
8708        MeshRoleAction::Set { role, json } => {
8709            validate_role_tag(&role)?;
8710            let new_profile =
8711                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
8712            if json {
8713                println!(
8714                    "{}",
8715                    serde_json::to_string(&json!({
8716                        "role": role,
8717                        "profile": new_profile,
8718                    }))?
8719                );
8720            } else {
8721                println!("self role = {role} (signed into agent-card)");
8722            }
8723        }
8724        MeshRoleAction::Get { peer, json } => {
8725            let (who, role) = match peer.as_deref() {
8726                None => {
8727                    let card = config::read_agent_card()?;
8728                    let role = card
8729                        .get("profile")
8730                        .and_then(|p| p.get("role"))
8731                        .and_then(Value::as_str)
8732                        .map(str::to_string);
8733                    let who = card
8734                        .get("did")
8735                        .and_then(Value::as_str)
8736                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
8737                        .unwrap_or_else(|| "self".to_string());
8738                    (who, role)
8739                }
8740                Some(handle) => {
8741                    let bare = crate::agent_card::bare_handle(handle).to_string();
8742                    let trust = config::read_trust()?;
8743                    let role = trust
8744                        .get("agents")
8745                        .and_then(|a| a.get(&bare))
8746                        .and_then(|a| a.get("card"))
8747                        .and_then(|c| c.get("profile"))
8748                        .and_then(|p| p.get("role"))
8749                        .and_then(Value::as_str)
8750                        .map(str::to_string);
8751                    (bare, role)
8752                }
8753            };
8754            if json {
8755                println!(
8756                    "{}",
8757                    serde_json::to_string(&json!({
8758                        "handle": who,
8759                        "role": role,
8760                    }))?
8761                );
8762            } else {
8763                match role {
8764                    Some(r) => println!("{who}: {r}"),
8765                    None => println!("{who}: (unset)"),
8766                }
8767            }
8768        }
8769        MeshRoleAction::List { json } => {
8770            let mut self_did: Option<String> = None;
8771            if let Ok(card) = config::read_agent_card() {
8772                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
8773            }
8774            let sessions = crate::session::list_sessions()?;
8775            let mut rows: Vec<Value> = Vec::new();
8776            for s in &sessions {
8777                let card_path = s
8778                    .home_dir
8779                    .join("config")
8780                    .join("wire")
8781                    .join("agent-card.json");
8782                let role = std::fs::read(&card_path)
8783                    .ok()
8784                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8785                    .and_then(|c| {
8786                        c.get("profile")
8787                            .and_then(|p| p.get("role"))
8788                            .and_then(Value::as_str)
8789                            .map(str::to_string)
8790                    });
8791                let is_self = match (&self_did, &s.did) {
8792                    (Some(a), Some(b)) => a == b,
8793                    _ => false,
8794                };
8795                rows.push(json!({
8796                    "name": s.name,
8797                    "handle": s.handle,
8798                    "role": role,
8799                    "self": is_self,
8800                }));
8801            }
8802            rows.sort_by(|a, b| {
8803                a["name"]
8804                    .as_str()
8805                    .unwrap_or("")
8806                    .cmp(b["name"].as_str().unwrap_or(""))
8807            });
8808            if json {
8809                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
8810            } else if rows.is_empty() {
8811                println!("no sister sessions on this machine.");
8812            } else {
8813                println!("SISTER ROLES (this machine):");
8814                for r in &rows {
8815                    let name = r["name"].as_str().unwrap_or("?");
8816                    let role = r["role"].as_str().unwrap_or("(unset)");
8817                    let marker = if r["self"].as_bool().unwrap_or(false) {
8818                        "    ← you"
8819                    } else {
8820                        ""
8821                    };
8822                    println!("  {name:<24} {role}{marker}");
8823                }
8824            }
8825        }
8826        MeshRoleAction::Clear { json } => {
8827            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
8828            if json {
8829                println!(
8830                    "{}",
8831                    serde_json::to_string(&json!({
8832                        "cleared": true,
8833                        "profile": new_profile,
8834                    }))?
8835                );
8836            } else {
8837                println!("self role cleared");
8838            }
8839        }
8840    }
8841    Ok(())
8842}
8843
8844/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
8845/// No vocabulary check — operators choose the taxonomy (planner /
8846/// reviewer / dispatcher / your-custom-tag). The constraint is purely
8847/// to keep the tag safe for filenames / URLs / shell args.
8848fn validate_role_tag(role: &str) -> Result<()> {
8849    if role.is_empty() {
8850        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
8851    }
8852    if role.len() > 32 {
8853        bail!("role too long ({} chars; max 32)", role.len());
8854    }
8855    for c in role.chars() {
8856        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
8857            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
8858        }
8859    }
8860    Ok(())
8861}
8862
8863/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
8864///
8865/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
8866/// canonical event including `to:`, so per-recipient signing is required;
8867/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
8868/// Per-recipient pushes happen in parallel via `std::thread::scope` so
8869/// broadcast-to-5 takes ~1× RTT, not 5×.
8870///
8871/// **Scope filter.** Default `local` — only peers reachable via a same-
8872/// machine local relay (priority-1 endpoint has `scope=local`). This is
8873/// the lowest-blast-radius default: local-only broadcasts cannot escape
8874/// the operator's machine. `federation` flips to public-relay peers
8875/// only; `both` removes the filter.
8876///
8877/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
8878/// resolution, never trust["agents"] expansion. Closes #8-class
8879/// phonebook-scrape vectors by construction: an attacker pinning a
8880/// hostile handle has to first be pinned bidirectionally by the
8881/// operator, and even then `--exclude` is the loud opt-out.
8882fn cmd_mesh_broadcast(
8883    kind: &str,
8884    scope_str: &str,
8885    exclude: &[String],
8886    _noreply: bool,
8887    body_arg: &str,
8888    as_json: bool,
8889) -> Result<()> {
8890    use std::time::Instant;
8891
8892    if !config::is_initialized()? {
8893        bail!("not initialized — run `wire init <handle>` first");
8894    }
8895
8896    let scope = match scope_str {
8897        "local" => crate::endpoints::EndpointScope::Local,
8898        "federation" => crate::endpoints::EndpointScope::Federation,
8899        "both" => {
8900            // Sentinel: we don't actually have a `Both` variant on the
8901            // scope enum; use a tri-state below. Treat as Local for the
8902            // typed match and special-case it via the bool below.
8903            crate::endpoints::EndpointScope::Local
8904        }
8905        other => bail!("unknown scope `{other}` — use local | federation | both"),
8906    };
8907    let any_scope = scope_str == "both";
8908
8909    let state = config::read_relay_state()?;
8910    let peers = state["peers"].as_object().cloned().unwrap_or_default();
8911    if peers.is_empty() {
8912        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
8913    }
8914
8915    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8916
8917    // Walk the pinned-peer set, filter by scope + exclude. Keep the
8918    // priority-ordered endpoint list for each match so the push can
8919    // try local first then fall through to federation (when scope=both).
8920    struct Target {
8921        handle: String,
8922        endpoints: Vec<crate::endpoints::Endpoint>,
8923    }
8924    let mut targets: Vec<Target> = Vec::new();
8925    let mut skipped_wrong_scope: Vec<String> = Vec::new();
8926    let mut skipped_excluded: Vec<String> = Vec::new();
8927    for handle in peers.keys() {
8928        if exclude_set.contains(handle.as_str()) {
8929            skipped_excluded.push(handle.clone());
8930            continue;
8931        }
8932        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
8933        let filtered: Vec<crate::endpoints::Endpoint> = ordered
8934            .into_iter()
8935            .filter(|ep| any_scope || ep.scope == scope)
8936            .collect();
8937        if filtered.is_empty() {
8938            skipped_wrong_scope.push(handle.clone());
8939            continue;
8940        }
8941        targets.push(Target {
8942            handle: handle.clone(),
8943            endpoints: filtered,
8944        });
8945    }
8946
8947    if targets.is_empty() {
8948        bail!(
8949            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
8950            skipped_excluded.len(),
8951            skipped_wrong_scope.len()
8952        );
8953    }
8954
8955    // Load signing material once; share across per-peer signatures.
8956    let sk_seed = config::read_private_key()?;
8957    let card = config::read_agent_card()?;
8958    let did = card
8959        .get("did")
8960        .and_then(Value::as_str)
8961        .ok_or_else(|| anyhow!("agent-card missing did"))?
8962        .to_string();
8963    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8964    let pk_b64 = card
8965        .get("verify_keys")
8966        .and_then(Value::as_object)
8967        .and_then(|m| m.values().next())
8968        .and_then(|v| v.get("key"))
8969        .and_then(Value::as_str)
8970        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8971    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8972
8973    let body_value: Value = if body_arg == "-" {
8974        use std::io::Read;
8975        let mut raw = String::new();
8976        std::io::stdin()
8977            .read_to_string(&mut raw)
8978            .with_context(|| "reading body from stdin")?;
8979        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8980    } else if let Some(path) = body_arg.strip_prefix('@') {
8981        let raw =
8982            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8983        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8984    } else {
8985        Value::String(body_arg.to_string())
8986    };
8987
8988    let kind_id = parse_kind(kind)?;
8989    let now_iso = time::OffsetDateTime::now_utc()
8990        .format(&time::format_description::well_known::Rfc3339)
8991        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8992
8993    let broadcast_id = generate_broadcast_id();
8994    let target_count = targets.len();
8995
8996    // Build + sign every event up front (sequential, ~50µs/sig). Then
8997    // queue to outbox + push to relay in parallel per-peer. Returns
8998    // a per-peer outcome we then sort by handle for deterministic output.
8999    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
9000        Vec::with_capacity(targets.len());
9001    for t in &targets {
9002        let body = json!({
9003            "content": body_value,
9004            "broadcast_id": broadcast_id,
9005            "broadcast_target_count": target_count,
9006        });
9007        let event = json!({
9008            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9009            "timestamp": now_iso,
9010            "from": did,
9011            "to": format!("did:wire:{}", t.handle),
9012            "type": kind,
9013            "kind": kind_id,
9014            "body": body,
9015        });
9016        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
9017            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
9018        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
9019        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
9020    }
9021
9022    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
9023    // holds a per-path mutex; writes are independent across handles but
9024    // we want the side-effect ordering deterministic).
9025    for (peer, _, signed, _) in &signed_per_peer {
9026        let line = serde_json::to_vec(signed)?;
9027        config::append_outbox_record(peer, &line)?;
9028    }
9029
9030    // Per-peer parallel push. Each thread tries the priority-ordered
9031    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
9032    // error_opt) over a channel.
9033    use std::sync::mpsc;
9034    let (tx, rx) = mpsc::channel::<Value>();
9035    std::thread::scope(|s| {
9036        for (peer, endpoints, signed, event_id) in &signed_per_peer {
9037            let tx = tx.clone();
9038            let peer = peer.clone();
9039            let event_id = event_id.clone();
9040            let endpoints = endpoints.clone();
9041            let signed = signed.clone();
9042            s.spawn(move || {
9043                let start = Instant::now();
9044                let mut delivered = false;
9045                let mut last_err: Option<String> = None;
9046                let mut delivered_via: Option<String> = None;
9047                for ep in &endpoints {
9048                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
9049                    // uds_request, else reqwest). Same as cmd_send's
9050                    // single-peer path above; this is the parallel
9051                    // multi-peer broadcast loop.
9052                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
9053                        Ok(_) => {
9054                            delivered = true;
9055                            delivered_via = Some(
9056                                match ep.scope {
9057                                    crate::endpoints::EndpointScope::Local => "local",
9058                                    crate::endpoints::EndpointScope::Lan => "lan",
9059                                    crate::endpoints::EndpointScope::Uds => "uds",
9060                                    crate::endpoints::EndpointScope::Federation => "federation",
9061                                }
9062                                .to_string(),
9063                            );
9064                            break;
9065                        }
9066                        Err(e) => last_err = Some(format!("{e:#}")),
9067                    }
9068                }
9069                let rtt_ms = start.elapsed().as_millis() as u64;
9070                let _ = tx.send(json!({
9071                    "peer": peer,
9072                    "event_id": event_id,
9073                    "delivered": delivered,
9074                    "delivered_via": delivered_via,
9075                    "rtt_ms": rtt_ms,
9076                    "error": last_err,
9077                }));
9078            });
9079        }
9080    });
9081    drop(tx);
9082
9083    let mut results: Vec<Value> = rx.iter().collect();
9084    results.sort_by(|a, b| {
9085        a["peer"]
9086            .as_str()
9087            .unwrap_or("")
9088            .cmp(b["peer"].as_str().unwrap_or(""))
9089    });
9090
9091    let delivered = results
9092        .iter()
9093        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
9094        .count();
9095    let failed = results.len() - delivered;
9096
9097    let summary = json!({
9098        "broadcast_id": broadcast_id,
9099        "kind": kind,
9100        "scope": scope_str,
9101        "target_count": target_count,
9102        "delivered": delivered,
9103        "failed": failed,
9104        "skipped_excluded": skipped_excluded,
9105        "skipped_wrong_scope": skipped_wrong_scope,
9106        "results": results,
9107    });
9108
9109    if as_json {
9110        println!("{}", serde_json::to_string(&summary)?);
9111        return Ok(());
9112    }
9113
9114    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
9115    for r in &results {
9116        let peer = r["peer"].as_str().unwrap_or("?");
9117        let delivered = r["delivered"].as_bool().unwrap_or(false);
9118        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
9119        let via = r["delivered_via"].as_str().unwrap_or("");
9120        if delivered {
9121            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
9122        } else {
9123            let err = r["error"].as_str().unwrap_or("?");
9124            println!("  {peer:<24} ✗ failed — {err}");
9125        }
9126    }
9127    if !skipped_excluded.is_empty() {
9128        println!("  excluded: {}", skipped_excluded.join(", "));
9129    }
9130    if !skipped_wrong_scope.is_empty() {
9131        println!(
9132            "  skipped (wrong scope): {}",
9133            skipped_wrong_scope.join(", ")
9134        );
9135    }
9136    println!("broadcast_id: {broadcast_id}");
9137    Ok(())
9138}
9139
9140/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
9141/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
9142/// correlate by string equality, the shape is for human readability.
9143fn generate_broadcast_id() -> String {
9144    use rand::RngCore;
9145    let mut buf = [0u8; 16];
9146    rand::thread_rng().fill_bytes(&mut buf);
9147    let h = hex::encode(buf);
9148    format!(
9149        "{}-{}-{}-{}-{}",
9150        &h[0..8],
9151        &h[8..12],
9152        &h[12..16],
9153        &h[16..20],
9154        &h[20..32],
9155    )
9156}
9157
9158fn cmd_session(cmd: SessionCommand) -> Result<()> {
9159    match cmd {
9160        SessionCommand::New {
9161            name,
9162            relay,
9163            with_local,
9164            local_relay,
9165            with_lan,
9166            lan_relay,
9167            with_uds,
9168            uds_socket,
9169            no_daemon,
9170            local_only,
9171            json,
9172        } => cmd_session_new(
9173            name.as_deref(),
9174            &relay,
9175            with_local,
9176            &local_relay,
9177            with_lan,
9178            lan_relay.as_deref(),
9179            with_uds,
9180            uds_socket.as_deref(),
9181            no_daemon,
9182            local_only,
9183            json,
9184        ),
9185        SessionCommand::List { json } => cmd_session_list(json),
9186        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
9187        SessionCommand::PairAllLocal {
9188            settle_secs,
9189            federation_relay,
9190            json,
9191        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
9192        SessionCommand::MeshStatus { stale_secs, json } => {
9193            cmd_session_mesh_status(stale_secs, json)
9194        }
9195        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
9196        SessionCommand::Current { json } => cmd_session_current(json),
9197        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
9198        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
9199    }
9200}
9201
9202fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
9203    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9204    let cwd_str = crate::session::normalize_cwd_key(&cwd);
9205
9206    let resolved_name = match name_arg {
9207        Some(n) => crate::session::sanitize_name(n),
9208        None => crate::session::sanitize_name(
9209            cwd.file_name()
9210                .and_then(|s| s.to_str())
9211                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
9212        ),
9213    };
9214
9215    let session_home = crate::session::session_dir(&resolved_name)?;
9216    if !session_home.exists() {
9217        bail!(
9218            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
9219            session_home.display()
9220        );
9221    }
9222
9223    let prior = crate::session::read_registry()
9224        .ok()
9225        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
9226    if prior.as_deref() == Some(resolved_name.as_str()) {
9227        if json {
9228            println!(
9229                "{}",
9230                serde_json::to_string(&json!({
9231                    "cwd": cwd_str,
9232                    "session": resolved_name,
9233                    "changed": false,
9234                }))?
9235            );
9236        } else {
9237            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
9238        }
9239        return Ok(());
9240    }
9241    if let Some(prior_name) = &prior {
9242        eprintln!(
9243            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
9244        );
9245    }
9246
9247    crate::session::update_registry(|reg| {
9248        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
9249        Ok(())
9250    })?;
9251
9252    if json {
9253        println!(
9254            "{}",
9255            serde_json::to_string(&json!({
9256                "cwd": cwd_str,
9257                "session": resolved_name,
9258                "changed": true,
9259                "previous": prior,
9260            }))?
9261        );
9262    } else {
9263        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
9264        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
9265    }
9266    Ok(())
9267}
9268
9269fn resolve_session_name(name: Option<&str>) -> Result<String> {
9270    if let Some(n) = name {
9271        return Ok(crate::session::sanitize_name(n));
9272    }
9273    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9274    let registry = crate::session::read_registry().unwrap_or_default();
9275    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
9276}
9277
9278#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
9279// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
9280// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
9281fn cmd_session_new(
9282    name_arg: Option<&str>,
9283    relay: &str,
9284    with_local: bool,
9285    local_relay: &str,
9286    with_lan: bool,
9287    lan_relay: Option<&str>,
9288    with_uds: bool,
9289    uds_socket: Option<&std::path::Path>,
9290    no_daemon: bool,
9291    local_only: bool,
9292    as_json: bool,
9293) -> Result<()> {
9294    // v0.6.6: --local-only implies --with-local (a federation-free
9295    // session with no endpoints at all would be unaddressable).
9296    let with_local = with_local || local_only;
9297    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
9298    if with_lan && lan_relay.is_none() {
9299        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
9300    }
9301    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
9302    if with_uds && uds_socket.is_none() {
9303        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
9304    }
9305    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9306    let mut registry = crate::session::read_registry().unwrap_or_default();
9307    let name = match name_arg {
9308        Some(n) => crate::session::sanitize_name(n),
9309        None => crate::session::derive_name_from_cwd(&cwd, &registry),
9310    };
9311    let session_home = crate::session::session_dir(&name)?;
9312
9313    let already_exists = session_home.exists()
9314        && session_home
9315            .join("config")
9316            .join("wire")
9317            .join("agent-card.json")
9318            .exists();
9319    if already_exists {
9320        // Idempotent: re-register the cwd (if not already), refresh the
9321        // daemon if requested, surface the env-var line. Do not re-init
9322        // identity — that would clobber the keypair.
9323        registry
9324            .by_cwd
9325            .insert(cwd.to_string_lossy().into_owned(), name.clone());
9326        crate::session::write_registry(&registry)?;
9327        let info = render_session_info(&name, &session_home, &cwd)?;
9328        emit_session_new_result(&info, "already_exists", as_json)?;
9329        if !no_daemon {
9330            ensure_session_daemon(&session_home)?;
9331        }
9332        return Ok(());
9333    }
9334
9335    std::fs::create_dir_all(&session_home)
9336        .with_context(|| format!("creating session dir {session_home:?}"))?;
9337
9338    // Phase 1: init identity in the new session's WIRE_HOME. For
9339    // federation-bound sessions we pass `--relay` so init also
9340    // allocates a federation slot in the same step; for `--local-only`
9341    // we run init with `--offline` (v0.9 requires explicit reachability
9342    // acknowledgement at init time) because cmd_session_new allocates
9343    // the local-relay slot itself via try_allocate_local_slot below.
9344    // The session is not actually slotless — init is just deferred to
9345    // the subsequent allocation pass.
9346    let init_args: Vec<&str> = if local_only {
9347        vec!["init", &name, "--offline"]
9348    } else {
9349        vec!["init", &name, "--relay", relay]
9350    };
9351    let init_status = run_wire_with_home(&session_home, &init_args)?;
9352    if !init_status.success() {
9353        let how = if local_only {
9354            format!("`wire init {name}` (local-only)")
9355        } else {
9356            format!("`wire init {name} --relay {relay}`")
9357        };
9358        bail!("{how} failed inside session dir {session_home:?}");
9359    }
9360
9361    // Phase 2: claim the handle on the federation relay — SKIPPED when
9362    // `--local-only`. Local-only sessions have no public address and
9363    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
9364    // tries to publish them.
9365    let effective_handle = if local_only {
9366        name.clone()
9367    } else {
9368        let mut claim_attempt = 0u32;
9369        let mut effective = name.clone();
9370        loop {
9371            claim_attempt += 1;
9372            let status =
9373                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
9374            if status.success() {
9375                break;
9376            }
9377            if claim_attempt >= 5 {
9378                bail!(
9379                    "5 failed attempts to claim a handle on {relay} for session {name}. \
9380                     Try `wire session destroy {name} --force` and re-run with a different name, \
9381                     or use `--local-only` if you don't need a federation address."
9382                );
9383            }
9384            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
9385            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
9386            let token = suffix
9387                .rsplit('-')
9388                .next()
9389                .filter(|t| t.len() == 4)
9390                .map(str::to_string)
9391                .unwrap_or_else(|| format!("{claim_attempt}"));
9392            effective = format!("{name}-{token}");
9393        }
9394        effective
9395    };
9396
9397    // Persist the cwd → name mapping NOW so subsequent invocations from
9398    // this directory short-circuit to the "already_exists" branch.
9399    registry
9400        .by_cwd
9401        .insert(cwd.to_string_lossy().into_owned(), name.clone());
9402    crate::session::write_registry(&registry)?;
9403
9404    // v0.5.17: --with-local probes the local relay and, if it's
9405    // reachable, allocates a second slot there. The session's
9406    // relay_state.json grows a `self.endpoints[]` array carrying both
9407    // endpoints; routing layer (cmd_push) prefers local for sister-
9408    // session peers that also have a local slot.
9409    //
9410    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
9411    // allocation; a failed probe leaves the session with no endpoints,
9412    // which we surface as a hard error (the operator asked for local-
9413    // only but the local relay isn't running — fix that first).
9414    if with_local {
9415        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
9416        if local_only {
9417            // Verify the local slot landed. If the local relay was
9418            // unreachable, the session would be unreachable from
9419            // anywhere — surface that loudly instead of leaving an
9420            // orphaned session dir.
9421            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
9422            let state: Value = std::fs::read(&relay_state_path)
9423                .ok()
9424                .and_then(|b| serde_json::from_slice(&b).ok())
9425                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9426            let endpoints = crate::endpoints::self_endpoints(&state);
9427            let has_local = endpoints
9428                .iter()
9429                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
9430            if !has_local {
9431                bail!(
9432                    "--local-only requested but local-relay probe at {local_relay} failed — \
9433                     ensure the local relay is running (`wire service install --local-relay`), \
9434                     then re-run `wire session new {name} --local-only`."
9435                );
9436            }
9437        }
9438    }
9439
9440    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
9441    // Sits AFTER local because cmd_session_new's flow is "add endpoints
9442    // alongside existing self.endpoints[]" — order independent post-init.
9443    if with_lan && let Some(lan_url) = lan_relay {
9444        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
9445    }
9446    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
9447    if with_uds && let Some(socket_path) = uds_socket {
9448        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
9449    }
9450
9451    if !no_daemon {
9452        ensure_session_daemon(&session_home)?;
9453    }
9454
9455    let info = render_session_info(&name, &session_home, &cwd)?;
9456    emit_session_new_result(&info, "created", as_json)
9457}
9458
9459/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
9460/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
9461/// sister sessions can route over the local socket instead of loopback
9462/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
9463/// alpha.17 — reqwest has no UDS support.
9464///
9465/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
9466/// and try_allocate_lan_slot semantics): session stays at existing
9467/// endpoint mix, operator can retry once the UDS relay is up.
9468#[cfg(unix)]
9469fn try_allocate_uds_slot(
9470    session_home: &std::path::Path,
9471    handle: &str,
9472    uds_socket: &std::path::Path,
9473) {
9474    // Probe healthz first so we fail fast with a clear stderr if the
9475    // socket doesn't exist OR isn't a wire relay.
9476    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
9477        Ok((200, _)) => true,
9478        Ok((status, body)) => {
9479            eprintln!(
9480                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
9481                String::from_utf8_lossy(&body)
9482            );
9483            return;
9484        }
9485        Err(e) => {
9486            eprintln!(
9487                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
9488                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
9489            );
9490            return;
9491        }
9492    };
9493    if !healthz {
9494        return;
9495    }
9496
9497    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
9498    let alloc_body = serde_json::json!({"handle": handle}).to_string();
9499    let (status, body) = match crate::relay_client::uds_request(
9500        uds_socket,
9501        "POST",
9502        "/v1/slot/allocate",
9503        &[("Content-Type", "application/json")],
9504        alloc_body.as_bytes(),
9505    ) {
9506        Ok(r) => r,
9507        Err(e) => {
9508            eprintln!(
9509                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
9510            );
9511            return;
9512        }
9513    };
9514    if status >= 300 {
9515        eprintln!(
9516            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
9517            String::from_utf8_lossy(&body)
9518        );
9519        return;
9520    }
9521    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
9522        Ok(a) => a,
9523        Err(e) => {
9524            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
9525            return;
9526        }
9527    };
9528
9529    let state_path = session_home.join("config").join("wire").join("relay.json");
9530    let mut state: serde_json::Value = std::fs::read(&state_path)
9531        .ok()
9532        .and_then(|b| serde_json::from_slice(&b).ok())
9533        .unwrap_or_else(|| serde_json::json!({}));
9534
9535    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9536        .get("self")
9537        .and_then(|s| s.get("endpoints"))
9538        .and_then(|e| e.as_array())
9539        .map(|arr| {
9540            arr.iter()
9541                .filter_map(|v| {
9542                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9543                })
9544                .collect()
9545        })
9546        .unwrap_or_default();
9547    endpoints.push(crate::endpoints::Endpoint::uds(
9548        format!("unix://{}", uds_socket.display()),
9549        alloc.slot_id.clone(),
9550        alloc.slot_token.clone(),
9551    ));
9552
9553    let self_obj = state
9554        .as_object_mut()
9555        .expect("relay_state root is an object")
9556        .entry("self")
9557        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9558    if !self_obj.is_object() {
9559        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9560    }
9561    if let Some(obj) = self_obj.as_object_mut() {
9562        obj.insert(
9563            "endpoints".into(),
9564            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9565        );
9566    }
9567    if let Err(e) = std::fs::write(
9568        &state_path,
9569        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9570    ) {
9571        eprintln!("wire session new: failed to write {state_path:?}: {e}");
9572        return;
9573    }
9574    eprintln!(
9575        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
9576        uds_socket.display(),
9577        alloc.slot_id
9578    );
9579}
9580
9581#[cfg(not(unix))]
9582fn try_allocate_uds_slot(
9583    _session_home: &std::path::Path,
9584    _handle: &str,
9585    _uds_socket: &std::path::Path,
9586) {
9587    eprintln!(
9588        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
9589    );
9590}
9591
9592/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
9593/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
9594/// pulling the agent-card see a third reachable address.
9595///
9596/// Mirrors `try_allocate_local_slot` but tags the endpoint
9597/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
9598/// session stays at whatever endpoint mix it already had — operators
9599/// can retry with `wire session new --with-lan --lan-relay <url>` once
9600/// the LAN relay is up.
9601fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
9602    let probe = match crate::relay_client::build_blocking_client(Some(
9603        std::time::Duration::from_millis(500),
9604    )) {
9605        Ok(c) => c,
9606        Err(e) => {
9607            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
9608            return;
9609        }
9610    };
9611    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
9612    match probe.get(&healthz_url).send() {
9613        Ok(resp) if resp.status().is_success() => {}
9614        Ok(resp) => {
9615            eprintln!(
9616                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
9617                resp.status()
9618            );
9619            return;
9620        }
9621        Err(e) => {
9622            eprintln!(
9623                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
9624                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
9625                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9626            );
9627            return;
9628        }
9629    };
9630
9631    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
9632    let alloc = match lan_client.allocate_slot(Some(handle)) {
9633        Ok(a) => a,
9634        Err(e) => {
9635            eprintln!(
9636                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
9637            );
9638            return;
9639        }
9640    };
9641
9642    let state_path = session_home.join("config").join("wire").join("relay.json");
9643    let mut state: serde_json::Value = std::fs::read(&state_path)
9644        .ok()
9645        .and_then(|b| serde_json::from_slice(&b).ok())
9646        .unwrap_or_else(|| serde_json::json!({}));
9647
9648    // Read existing endpoints array and add the LAN one. Preserve
9649    // federation / local entries already there.
9650    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9651        .get("self")
9652        .and_then(|s| s.get("endpoints"))
9653        .and_then(|e| e.as_array())
9654        .map(|arr| {
9655            arr.iter()
9656                .filter_map(|v| {
9657                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9658                })
9659                .collect()
9660        })
9661        .unwrap_or_default();
9662    endpoints.push(crate::endpoints::Endpoint::lan(
9663        lan_relay.trim_end_matches('/').to_string(),
9664        alloc.slot_id.clone(),
9665        alloc.slot_token.clone(),
9666    ));
9667
9668    let self_obj = state
9669        .as_object_mut()
9670        .expect("relay_state root is an object")
9671        .entry("self")
9672        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9673    if !self_obj.is_object() {
9674        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9675    }
9676    if let Some(obj) = self_obj.as_object_mut() {
9677        obj.insert(
9678            "endpoints".into(),
9679            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9680        );
9681    }
9682    if let Err(e) = std::fs::write(
9683        &state_path,
9684        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9685    ) {
9686        eprintln!("wire session new: failed to write {state_path:?}: {e}");
9687        return;
9688    }
9689    eprintln!(
9690        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
9691        alloc.slot_id
9692    );
9693}
9694
9695/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
9696/// a short timeout, allocate a slot there and update the session's
9697/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
9698///
9699/// Failure to reach the local relay is NOT fatal — the session stays
9700/// federation-only. Logs to stderr on failure so operators can tell
9701/// the local relay isn't running, but doesn't abort the bootstrap.
9702fn try_allocate_local_slot(
9703    session_home: &std::path::Path,
9704    handle: &str,
9705    _federation_relay: &str,
9706    local_relay: &str,
9707) {
9708    // Probe healthz with a tight timeout. Use a fresh client (don't
9709    // share the daemon-wide one) so the timeout is local to this call.
9710    let probe = match crate::relay_client::build_blocking_client(Some(
9711        std::time::Duration::from_millis(500),
9712    )) {
9713        Ok(c) => c,
9714        Err(e) => {
9715            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
9716            return;
9717        }
9718    };
9719    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
9720    match probe.get(&healthz_url).send() {
9721        Ok(resp) if resp.status().is_success() => {}
9722        Ok(resp) => {
9723            eprintln!(
9724                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
9725                resp.status()
9726            );
9727            return;
9728        }
9729        Err(e) => {
9730            eprintln!(
9731                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
9732                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
9733                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9734            );
9735            return;
9736        }
9737    };
9738
9739    // Allocate a slot on the local relay.
9740    let local_client = crate::relay_client::RelayClient::new(local_relay);
9741    let alloc = match local_client.allocate_slot(Some(handle)) {
9742        Ok(a) => a,
9743        Err(e) => {
9744            eprintln!(
9745                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
9746            );
9747            return;
9748        }
9749    };
9750
9751    // Merge into the session's relay.json. We invoke wire via
9752    // run_wire_with_home for federation calls (subprocess isolation),
9753    // but relay.json is a simple file we can edit directly
9754    // — and need to, because there's no `wire bind-relay --add-local`
9755    // command yet (could add later; out of scope for v0.5.17 MVP).
9756    //
9757    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
9758    // does not exist (canonical filename is `relay.json` per
9759    // `config::relay_state_path`). The mis-named file write succeeded
9760    // but landed in a sibling path nothing else reads. Every
9761    // `wire session new --with-local` invocation silently degraded to
9762    // federation-only despite the "local slot allocated" stderr line.
9763    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
9764    // session's relay.json — it had only the federation endpoint.
9765    let state_path = session_home.join("config").join("wire").join("relay.json");
9766    let mut state: serde_json::Value = std::fs::read(&state_path)
9767        .ok()
9768        .and_then(|b| serde_json::from_slice(&b).ok())
9769        .unwrap_or_else(|| serde_json::json!({}));
9770    // Read the existing federation self info (already written by
9771    // `wire init` + `wire bind-relay` path during session bootstrap).
9772    let fed_endpoint = state.get("self").and_then(|s| {
9773        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
9774        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
9775        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
9776        Some(crate::endpoints::Endpoint::federation(
9777            url.to_string(),
9778            slot_id.to_string(),
9779            slot_token.to_string(),
9780        ))
9781    });
9782
9783    let local_endpoint = crate::endpoints::Endpoint::local(
9784        local_relay.trim_end_matches('/').to_string(),
9785        alloc.slot_id.clone(),
9786        alloc.slot_token.clone(),
9787    );
9788
9789    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
9790    if let Some(f) = fed_endpoint.clone() {
9791        endpoints.push(f);
9792    }
9793    endpoints.push(local_endpoint);
9794
9795    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
9796    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
9797    // `slot_token` fields must point at the LOCAL endpoint so callers
9798    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
9799    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
9800    // still find a valid slot. Pre-v0.6.6 this branch wrote
9801    // `relay_url: federation_relay` with no slot_id, which produced
9802    // half-populated self state that broke pair-accept on local-only
9803    // sessions.
9804    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
9805        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
9806        None => (
9807            local_relay.trim_end_matches('/').to_string(),
9808            alloc.slot_id.clone(),
9809            alloc.slot_token.clone(),
9810        ),
9811    };
9812    let self_obj = state
9813        .as_object_mut()
9814        .expect("relay_state root is an object")
9815        .entry("self")
9816        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9817    // The entry might be Value::Null (left by read_relay_state's default
9818    // template) — replace with an object before mutating.
9819    if !self_obj.is_object() {
9820        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9821    }
9822    if let Some(obj) = self_obj.as_object_mut() {
9823        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
9824        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
9825        obj.insert(
9826            "slot_token".into(),
9827            serde_json::Value::String(legacy_slot_token),
9828        );
9829        obj.insert(
9830            "endpoints".into(),
9831            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9832        );
9833    }
9834
9835    if let Err(e) = std::fs::write(
9836        &state_path,
9837        serde_json::to_vec_pretty(&state).unwrap_or_default(),
9838    ) {
9839        eprintln!(
9840            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
9841        );
9842        return;
9843    }
9844    eprintln!(
9845        "wire session new: local slot allocated on {local_relay} (slot_id={})",
9846        alloc.slot_id
9847    );
9848}
9849
9850fn render_session_info(
9851    name: &str,
9852    session_home: &std::path::Path,
9853    cwd: &std::path::Path,
9854) -> Result<serde_json::Value> {
9855    let card_path = session_home
9856        .join("config")
9857        .join("wire")
9858        .join("agent-card.json");
9859    let (did, handle) = if card_path.exists() {
9860        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
9861        let did = card
9862            .get("did")
9863            .and_then(Value::as_str)
9864            .unwrap_or("")
9865            .to_string();
9866        let handle = card
9867            .get("handle")
9868            .and_then(Value::as_str)
9869            .map(str::to_string)
9870            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9871        (did, handle)
9872    } else {
9873        (String::new(), String::new())
9874    };
9875    Ok(json!({
9876        "name": name,
9877        "home_dir": session_home.to_string_lossy(),
9878        "cwd": cwd.to_string_lossy(),
9879        "did": did,
9880        "handle": handle,
9881        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9882    }))
9883}
9884
9885fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
9886    if as_json {
9887        let mut obj = info.clone();
9888        obj["status"] = json!(status);
9889        println!("{}", serde_json::to_string(&obj)?);
9890    } else {
9891        let name = info["name"].as_str().unwrap_or("?");
9892        let handle = info["handle"].as_str().unwrap_or("?");
9893        let home = info["home_dir"].as_str().unwrap_or("?");
9894        let did = info["did"].as_str().unwrap_or("?");
9895        let export = info["export"].as_str().unwrap_or("?");
9896        let prefix = if status == "already_exists" {
9897            "session already exists (re-registered cwd)"
9898        } else {
9899            "session created"
9900        };
9901        println!(
9902            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
9903        );
9904    }
9905    Ok(())
9906}
9907
9908fn run_wire_with_home(
9909    session_home: &std::path::Path,
9910    args: &[&str],
9911) -> Result<std::process::ExitStatus> {
9912    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9913    let status = std::process::Command::new(&bin)
9914        .env("WIRE_HOME", session_home)
9915        .env_remove("RUST_LOG")
9916        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
9917        // We already own the session; nested init would clobber state.
9918        .env("WIRE_AUTO_INIT", "0")
9919        .args(args)
9920        .status()
9921        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9922    Ok(status)
9923}
9924
9925/// v0.7.0-alpha.2: idempotent per-cwd session creation.
9926///
9927/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
9928/// registered session for the current cwd — including via parent-walk —
9929/// this creates one inline so every Claude tab in a fresh project gets
9930/// its own wire identity rather than collapsing onto the machine-wide
9931/// default. Without this, multiple Claudes in unwired cwds all render
9932/// the same character (the default identity's character), defeating the
9933/// "every session looks different" promise.
9934///
9935/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
9936/// `run_wire_with_home` subprocess context).
9937///
9938/// Best-effort: any failure (no home dir, name collision pathology,
9939/// `wire init` subprocess crash) is logged to stderr and we fall back
9940/// to default identity. Must not block MCP startup.
9941///
9942/// MUST be called BEFORE worker thread spawn (env::set_var safety).
9943pub fn maybe_auto_init_cwd_session(label: &str) {
9944    if std::env::var("WIRE_HOME").is_ok() {
9945        return; // explicit override OR auto-detect already won
9946    }
9947    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
9948        return; // operator opt-out
9949    }
9950    let cwd = match std::env::current_dir() {
9951        Ok(c) => c,
9952        Err(_) => return,
9953    };
9954    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
9955    // already runs but we want to be robust to ordering).
9956    if crate::session::detect_session_wire_home(&cwd).is_some() {
9957        return;
9958    }
9959
9960    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
9961    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
9962    // Two different cwds with the same basename (e.g. /a/projx +
9963    // /b/projx) used to race outside the lock: both read empty
9964    // registry, both derived name="projx", per-name lock didn't help
9965    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
9966    //
9967    // Single lock serializes ALL auto-init across the sessions_root.
9968    // Inside the lock: re-read registry, derive_name_from_cwd which
9969    // adds path-hash suffix when basename is occupied by another cwd
9970    // already committed to the registry. Different cwds get DIFFERENT
9971    // names guaranteed.
9972    //
9973    // Cost: parallel auto-inits in different cwds now serialize
9974    // (~hundreds of ms each when local relay is up). Acceptable —
9975    // auto-init runs once per cwd per machine; not a hot path.
9976    use fs2::FileExt;
9977    let sessions_root = match crate::session::sessions_root() {
9978        Ok(r) => r,
9979        Err(_) => return,
9980    };
9981    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
9982        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
9983        return;
9984    }
9985    let lock_path = sessions_root.join(".auto-init.lock");
9986    let lock_file = match std::fs::OpenOptions::new()
9987        .create(true)
9988        .truncate(false)
9989        .read(true)
9990        .write(true)
9991        .open(&lock_path)
9992    {
9993        Ok(f) => f,
9994        Err(e) => {
9995            eprintln!(
9996                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
9997            );
9998            return;
9999        }
10000    };
10001    if let Err(e) = lock_file.lock_exclusive() {
10002        eprintln!(
10003            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
10004        );
10005        return;
10006    }
10007    // Lock acquired. Read registry + derive name now that all parallel
10008    // racers serialize through us — derive_name_from_cwd adds a
10009    // path-hash suffix if the basename is already claimed by another
10010    // cwd in the (now-stable) registry.
10011    let registry = crate::session::read_registry().unwrap_or_default();
10012    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
10013    let session_home = match crate::session::session_dir(&name) {
10014        Ok(h) => h,
10015        Err(_) => {
10016            let _ = fs2::FileExt::unlock(&lock_file);
10017            return;
10018        }
10019    };
10020    let agent_card_path = session_home
10021        .join("config")
10022        .join("wire")
10023        .join("agent-card.json");
10024    let needs_init = !agent_card_path.exists();
10025
10026    if needs_init {
10027        if let Err(e) = std::fs::create_dir_all(&session_home) {
10028            eprintln!(
10029                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
10030            );
10031            let _ = fs2::FileExt::unlock(&lock_file);
10032            return;
10033        }
10034        // v0.9: --offline; the surrounding session-spawn path runs
10035        // try_allocate_local_slot afterward to attach an inbound slot
10036        // when a local relay is available. Init itself stays slotless
10037        // because it's a precursor step, not the final state.
10038        match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
10039            Ok(status) if status.success() => {}
10040            Ok(status) => {
10041                eprintln!(
10042                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
10043                );
10044                let _ = fs2::FileExt::unlock(&lock_file);
10045                return;
10046            }
10047            Err(e) => {
10048                eprintln!(
10049                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
10050                );
10051                let _ = fs2::FileExt::unlock(&lock_file);
10052                return;
10053            }
10054        }
10055        // Best-effort: allocate a local-relay slot so this auto-init'd
10056        // session is addressable by sister sessions. Skipped silently when
10057        // the local relay isn't running (the function itself reports to
10058        // stderr). Auto-init'd sessions without endpoints can still
10059        // surface their character but cannot receive pair_drops until the
10060        // operator runs `wire bind-relay` or restarts the local relay.
10061        try_allocate_local_slot(
10062            &session_home,
10063            &name,
10064            "https://wireup.net",
10065            "http://127.0.0.1:8771",
10066        );
10067    } else {
10068        // Race loser path: peer already created the session. Surface
10069        // this honestly so the operator can see we adopted rather than
10070        // double-initialized.
10071        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10072            eprintln!(
10073                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
10074            );
10075        }
10076    }
10077    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
10078    // BEFORE releasing the auto-init lock. Pre-fix released the lock
10079    // here and committed the registry update afterward — racers in
10080    // OTHER cwds with the same basename would acquire the lock,
10081    // read the registry (still without our entry), and derive the
10082    // SAME name we just claimed. Live regression test caught it:
10083    // two cwds /a/projx + /b/projx both got name "projx", both
10084    // mapped to the same identity. Update the registry WHILE STILL
10085    // holding the auto-init lock so the next racer sees our claim.
10086    let cwd_key = crate::session::normalize_cwd_key(&cwd);
10087    let name_for_reg = name.clone();
10088    if let Err(e) = crate::session::update_registry(|reg| {
10089        reg.by_cwd.insert(cwd_key, name_for_reg);
10090        Ok(())
10091    }) {
10092        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
10093        // proceed — env var still gets set below
10094    }
10095    // NOW release the lock — racers waiting will see our registry
10096    // entry on their re-read.
10097    let _ = fs2::FileExt::unlock(&lock_file);
10098
10099    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10100        eprintln!(
10101            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
10102            cwd.display(),
10103            session_home.display()
10104        );
10105    }
10106    // SAFETY: caller contract is "before any thread spawn." MCP::run
10107    // calls this immediately after `maybe_adopt_session_wire_home`.
10108    unsafe {
10109        std::env::set_var("WIRE_HOME", &session_home);
10110    }
10111}
10112
10113fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
10114    // Check if a daemon is already alive in this session's WIRE_HOME.
10115    // If so, no-op (let the existing process keep running).
10116    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10117    if pidfile.exists() {
10118        let bytes = std::fs::read(&pidfile).unwrap_or_default();
10119        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10120            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10121        } else {
10122            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10123        };
10124        if let Some(p) = pid {
10125            let alive = {
10126                #[cfg(target_os = "linux")]
10127                {
10128                    std::path::Path::new(&format!("/proc/{p}")).exists()
10129                }
10130                #[cfg(not(target_os = "linux"))]
10131                {
10132                    std::process::Command::new("kill")
10133                        .args(["-0", &p.to_string()])
10134                        .output()
10135                        .map(|o| o.status.success())
10136                        .unwrap_or(false)
10137                }
10138            };
10139            if alive {
10140                return Ok(());
10141            }
10142        }
10143    }
10144
10145    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
10146    // versioned pidfile; we just kick it off and return.
10147    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
10148    let log_path = session_home.join("state").join("wire").join("daemon.log");
10149    if let Some(parent) = log_path.parent() {
10150        std::fs::create_dir_all(parent).ok();
10151    }
10152    let log_file = std::fs::OpenOptions::new()
10153        .create(true)
10154        .append(true)
10155        .open(&log_path)
10156        .with_context(|| format!("opening daemon log {log_path:?}"))?;
10157    let log_err = log_file.try_clone()?;
10158    std::process::Command::new(&bin)
10159        .env("WIRE_HOME", session_home)
10160        .env_remove("RUST_LOG")
10161        .args(["daemon", "--interval", "5"])
10162        .stdout(log_file)
10163        .stderr(log_err)
10164        .stdin(std::process::Stdio::null())
10165        .spawn()
10166        .with_context(|| "spawning session-local `wire daemon`")?;
10167    Ok(())
10168}
10169
10170fn cmd_session_list(as_json: bool) -> Result<()> {
10171    let items = crate::session::list_sessions()?;
10172    if as_json {
10173        println!("{}", serde_json::to_string(&items)?);
10174        return Ok(());
10175    }
10176    if items.is_empty() {
10177        println!("no sessions on this machine. `wire session new` to create one.");
10178        return Ok(());
10179    }
10180    println!(
10181        "{:<22} {:<24} {:<24} {:<10} CWD",
10182        "PERSONA", "NAME", "HANDLE", "DAEMON"
10183    );
10184    for s in items {
10185        // ANSI-escape-wrapped character takes more visual width than its
10186        // displayed glyph count; pad based on the plain-text form, then
10187        // wrap in escapes so the column lines up across rows.
10188        let plain = s
10189            .character
10190            .as_ref()
10191            .map(|c| c.short())
10192            .unwrap_or_else(|| "?".to_string());
10193        let colored = s
10194            .character
10195            .as_ref()
10196            .map(|c| c.colored())
10197            .unwrap_or_else(|| "?".to_string());
10198        // Approximate display width: emoji renders as ~2 cells in most
10199        // terminals; the rest are 1 cell each. We pad to 18 displayed
10200        // chars (≈22 byte slots when counting emoji).
10201        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
10202        let pad = 22usize.saturating_sub(displayed_width);
10203        println!(
10204            "{}{}  {:<24} {:<24} {:<10} {}",
10205            colored,
10206            " ".repeat(pad),
10207            s.name,
10208            s.handle.as_deref().unwrap_or("?"),
10209            if s.daemon_running { "running" } else { "down" },
10210            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10211        );
10212    }
10213    Ok(())
10214}
10215
10216/// v0.5.19: `wire session list-local` — sister-session discovery.
10217///
10218/// For each on-disk session, read its `relay-state.json` and surface
10219/// the ones that have a Local-scope endpoint (allocated via
10220/// `wire session new --with-local`). Group by the local-relay URL so
10221/// the operator can see at a glance which sessions are mutually
10222/// reachable over the same loopback relay.
10223///
10224/// Read-only, no daemon contact. Useful as the prelude to teaming /
10225/// pairing same-box sister claudes (see also `wire session
10226/// pair-all-local` once implemented).
10227fn cmd_session_list_local(as_json: bool) -> Result<()> {
10228    let listing = crate::session::list_local_sessions()?;
10229    if as_json {
10230        println!("{}", serde_json::to_string(&listing)?);
10231        return Ok(());
10232    }
10233
10234    if listing.local.is_empty() && listing.federation_only.is_empty() {
10235        println!(
10236            "no sessions on this machine. `wire session new --with-local` to create one \
10237             with a local-relay endpoint (start the relay first: \
10238             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
10239        );
10240        return Ok(());
10241    }
10242
10243    if listing.local.is_empty() {
10244        println!(
10245            "no sister sessions reachable via a local relay. \
10246             Re-run `wire session new --with-local` to add a Local endpoint, or \
10247             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
10248        );
10249    } else {
10250        // Stable iteration order: sort the relay URLs.
10251        let mut keys: Vec<&String> = listing.local.keys().collect();
10252        keys.sort();
10253        for relay_url in keys {
10254            let group = &listing.local[relay_url];
10255            println!("LOCAL RELAY: {relay_url}");
10256            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
10257            for s in group {
10258                println!(
10259                    "  {:<24} {:<32} {:<10} {}",
10260                    s.name,
10261                    s.handle.as_deref().unwrap_or("?"),
10262                    if s.daemon_running { "running" } else { "down" },
10263                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10264                );
10265            }
10266            println!();
10267        }
10268    }
10269
10270    if !listing.federation_only.is_empty() {
10271        println!("federation-only (no local endpoint):");
10272        for s in &listing.federation_only {
10273            println!(
10274                "  {:<24} {:<32} {}",
10275                s.name,
10276                s.handle.as_deref().unwrap_or("?"),
10277                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10278            );
10279        }
10280    }
10281    Ok(())
10282}
10283
10284/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
10285/// session that has a Local-scope endpoint. Skips already-paired
10286/// pairs; reports a per-pair outcome JSON suitable for scripting.
10287///
10288/// Same-uid trust anchor: the caller owns every session enumerated by
10289/// `list_local_sessions`, so the operator running this command IS the
10290/// consent for both sides. The bilateral SAS / network-level handshake
10291/// assumes strangers; same-uid sister sessions are not strangers.
10292///
10293/// Per-pair flow (sequential to keep relay-side load + log clarity):
10294///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
10295///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
10296///   3. sleep settle_secs                       (pair_drop reaches B)
10297///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
10298///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
10299///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
10300///   7. sleep settle_secs                       (ack reaches A)
10301///   8. WIRE_HOME=A wire pull --json            (A pins B)
10302fn cmd_session_pair_all_local(
10303    settle_secs: u64,
10304    federation_relay: &str,
10305    as_json: bool,
10306) -> Result<()> {
10307    use std::collections::BTreeSet;
10308    use std::time::Duration;
10309
10310    let listing = crate::session::list_local_sessions()?;
10311    // Flatten + dedup by session NAME (same session can appear under
10312    // multiple local-relay URLs if it advertises two local endpoints;
10313    // rare, but pair each pair exactly once).
10314    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
10315        Default::default();
10316    for group in listing.local.into_values() {
10317        for s in group {
10318            by_name.entry(s.name.clone()).or_insert(s);
10319        }
10320    }
10321    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10322
10323    if sessions.len() < 2 {
10324        let msg = format!(
10325            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
10326            sessions.len()
10327        );
10328        if as_json {
10329            println!(
10330                "{}",
10331                serde_json::to_string(&json!({
10332                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
10333                    "pairs_attempted": 0,
10334                    "pairs_succeeded": 0,
10335                    "pairs_skipped_already_paired": 0,
10336                    "pairs_failed": 0,
10337                    "note": msg,
10338                }))?
10339            );
10340        } else {
10341            println!("{msg}");
10342            if let Some(s) = sessions.first() {
10343                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
10344            }
10345            println!("Use `wire session new --with-local` to add more.");
10346        }
10347        return Ok(());
10348    }
10349
10350    let fed_host = host_of_url(federation_relay);
10351    if fed_host.is_empty() {
10352        bail!(
10353            "federation_relay `{federation_relay}` has no parseable host — \
10354             pass a full URL like `https://wireup.net`."
10355        );
10356    }
10357
10358    // Enumerate unordered pairs deterministically by session name.
10359    let mut attempted = 0u32;
10360    let mut succeeded = 0u32;
10361    let mut skipped_already = 0u32;
10362    let mut failed = 0u32;
10363    let mut per_pair: Vec<Value> = Vec::new();
10364
10365    for i in 0..sessions.len() {
10366        for j in (i + 1)..sessions.len() {
10367            let a = &sessions[i];
10368            let b = &sessions[j];
10369            attempted += 1;
10370
10371            // Already-paired check: if A's relay-state has B's CARD
10372            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
10373            // are character handles (not session names), so we use
10374            // each side's handle field (already on the LocalSessionView)
10375            // for the lookup rather than the session name.
10376            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
10377            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
10378            let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
10379            let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
10380            if a_pinned_b && b_pinned_a {
10381                skipped_already += 1;
10382                per_pair.push(json!({
10383                    "from": a.name,
10384                    "to": b.name,
10385                    "status": "already_paired",
10386                }));
10387                continue;
10388            }
10389
10390            let pair_result = drive_bilateral_pair(
10391                &a.home_dir,
10392                &a.name,
10393                &b.home_dir,
10394                &b.name,
10395                &fed_host,
10396                federation_relay,
10397                settle_secs,
10398            );
10399
10400            match pair_result {
10401                Ok(()) => {
10402                    succeeded += 1;
10403                    per_pair.push(json!({
10404                        "from": a.name,
10405                        "to": b.name,
10406                        "status": "paired",
10407                    }));
10408                }
10409                Err(e) => {
10410                    failed += 1;
10411                    let detail = format!("{e:#}");
10412                    per_pair.push(json!({
10413                        "from": a.name,
10414                        "to": b.name,
10415                        "status": "failed",
10416                        "error": detail,
10417                    }));
10418                }
10419            }
10420
10421            // Brief settle between pairs so we don't slam the relay
10422            // with N(N-1) parallel requests.
10423            std::thread::sleep(Duration::from_millis(200));
10424        }
10425    }
10426
10427    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
10428    let summary = json!({
10429        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
10430        "pairs_attempted": attempted,
10431        "pairs_succeeded": succeeded,
10432        "pairs_skipped_already_paired": skipped_already,
10433        "pairs_failed": failed,
10434        "results": per_pair,
10435    });
10436    if as_json {
10437        println!("{}", serde_json::to_string(&summary)?);
10438    } else {
10439        println!(
10440            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
10441            sessions.len(),
10442            attempted
10443        );
10444        println!("  paired:                 {succeeded}");
10445        println!("  skipped (already pinned): {skipped_already}");
10446        println!("  failed:                 {failed}");
10447        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
10448            let from = entry["from"].as_str().unwrap_or("?");
10449            let to = entry["to"].as_str().unwrap_or("?");
10450            let status = entry["status"].as_str().unwrap_or("?");
10451            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
10452            if err.is_empty() {
10453                println!("  {from:<24} ↔ {to:<24} {status}");
10454            } else {
10455                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
10456            }
10457        }
10458    }
10459    Ok(())
10460}
10461
10462/// Check whether `session_home`'s `relay.json` already lists `peer_name`
10463/// under `state.peers`. Best-effort — any read/parse error → false.
10464fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
10465    val_session_relay_state(session_home)
10466        .and_then(|v| v.get("peers").cloned())
10467        .and_then(|p| p.get(peer_name).cloned())
10468        .is_some()
10469}
10470
10471/// Read a session's `relay.json` directly without mutating the process'
10472/// WIRE_HOME env (which would race other threads / processes). Returns
10473/// `None` on any read or parse error — callers treat missing state as
10474/// "no peers / no endpoints" rather than aborting.
10475fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
10476    let path = session_home.join("config").join("wire").join("relay.json");
10477    let bytes = std::fs::read(&path).ok()?;
10478    serde_json::from_slice(&bytes).ok()
10479}
10480
10481/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
10482/// One probe per directed edge against the relay backing that edge's
10483/// priority-1 endpoint; output groups by undirected pair.
10484fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
10485    use std::collections::BTreeMap;
10486
10487    // Flatten by session NAME — same dedup logic as pair-all-local so a
10488    // session advertising two local endpoints doesn't get double-counted.
10489    let listing = crate::session::list_local_sessions()?;
10490    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
10491    for group in listing.local.into_values() {
10492        for s in group {
10493            by_name.entry(s.name.clone()).or_insert(s);
10494        }
10495    }
10496    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10497    let federation_only = listing.federation_only;
10498
10499    if sessions.is_empty() {
10500        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
10501        if as_json {
10502            println!(
10503                "{}",
10504                serde_json::to_string(&json!({
10505                    "sessions": [],
10506                    "edges": [],
10507                    "local_relay": null,
10508                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10509                    "summary": {
10510                        "session_count": 0,
10511                        "edge_count": 0,
10512                        "healthy": 0,
10513                        "stale": 0,
10514                        "asymmetric": 0,
10515                    },
10516                    "note": msg,
10517                }))?
10518            );
10519        } else {
10520            println!("{msg}");
10521            println!("Use `wire session new --with-local` to create one.");
10522        }
10523        return Ok(());
10524    }
10525
10526    // Build a name → session-state map: relay_state + reachable handle set.
10527    struct SessionState {
10528        view: crate::session::LocalSessionView,
10529        relay_state: Value,
10530        local_relay_url: Option<String>,
10531    }
10532    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
10533    for s in sessions {
10534        let relay_state = val_session_relay_state(&s.home_dir)
10535            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
10536        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
10537        sstates.push(SessionState {
10538            view: s,
10539            relay_state,
10540            local_relay_url,
10541        });
10542    }
10543
10544    // Probe each unique local-relay URL once for healthz so the operator
10545    // sees one liveness line per local relay, not one per edge.
10546    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
10547    for s in &sstates {
10548        if let Some(url) = &s.local_relay_url
10549            && !local_relays.contains_key(url)
10550        {
10551            let healthy = probe_relay_healthz(url);
10552            local_relays.insert(url.clone(), healthy);
10553        }
10554    }
10555
10556    let now = std::time::SystemTime::now()
10557        .duration_since(std::time::UNIX_EPOCH)
10558        .map(|d| d.as_secs())
10559        .unwrap_or(0);
10560
10561    // Edges: walk every unordered pair, surface bilateral state + each
10562    // direction's last_pull. Probe priority-1 endpoint (local preferred
10563    // by `peer_endpoints_in_priority_order`).
10564    let mut edges: Vec<Value> = Vec::new();
10565    let mut healthy_count = 0u32;
10566    let mut stale_count = 0u32;
10567    let mut asymmetric_count = 0u32;
10568
10569    for i in 0..sstates.len() {
10570        for j in (i + 1)..sstates.len() {
10571            let a = &sstates[i];
10572            let b = &sstates[j];
10573            // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
10574            // (DID-derived character), not the session name. Look the
10575            // peer up by its handle (with a session-name fallback for
10576            // pre-v0.11 sessions that haven't re-init'd yet).
10577            let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
10578            let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
10579            let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
10580            let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
10581
10582            let bilateral = a_to_b.pinned && b_to_a.pinned;
10583            // Scope = the most-local scope available in either direction.
10584            // (If a→b is local and b→a is federation, the asymmetric
10585            // detail surfaces below; the headline scope is the better.)
10586            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
10587                (Some("local"), _) | (_, Some("local")) => "local",
10588                (Some("federation"), _) | (_, Some("federation")) => "federation",
10589                _ => "unknown",
10590            };
10591
10592            // Health: stale if either direction's last_pull is older than
10593            // `stale_secs`, or never observed when both sides are pinned.
10594            let mut status = if bilateral { "healthy" } else { "asymmetric" };
10595            if bilateral {
10596                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
10597                    Some(s) => s > stale_secs,
10598                    None => d.probed,
10599                });
10600                if either_stale {
10601                    status = "stale";
10602                }
10603            }
10604
10605            match status {
10606                "healthy" => healthy_count += 1,
10607                "stale" => stale_count += 1,
10608                "asymmetric" => asymmetric_count += 1,
10609                _ => {}
10610            }
10611
10612            edges.push(json!({
10613                "from": a.view.name,
10614                "to": b.view.name,
10615                "bilateral": bilateral,
10616                "scope": scope,
10617                "status": status,
10618                "directions": {
10619                    a.view.name.clone(): direction_summary(&a_to_b),
10620                    b.view.name.clone(): direction_summary(&b_to_a),
10621                },
10622            }));
10623        }
10624    }
10625
10626    let summary = json!({
10627        "sessions": sstates.iter().map(|s| json!({
10628            "name": s.view.name,
10629            "handle": s.view.handle,
10630            "cwd": s.view.cwd,
10631            "daemon_running": s.view.daemon_running,
10632            "local_relay": s.local_relay_url,
10633        })).collect::<Vec<_>>(),
10634        "edges": edges,
10635        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
10636            "url": url,
10637            "healthy": healthy,
10638        })).collect::<Vec<_>>(),
10639        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10640        "summary": {
10641            "session_count": sstates.len(),
10642            "edge_count": edges.len(),
10643            "healthy": healthy_count,
10644            "stale": stale_count,
10645            "asymmetric": asymmetric_count,
10646            "stale_threshold_secs": stale_secs,
10647        },
10648    });
10649
10650    if as_json {
10651        println!("{}", serde_json::to_string(&summary)?);
10652        return Ok(());
10653    }
10654
10655    println!(
10656        "wire mesh: {} session(s), {} edge(s)",
10657        sstates.len(),
10658        edges.len()
10659    );
10660    for (url, healthy) in &local_relays {
10661        let tick = if *healthy { "✓" } else { "✗" };
10662        println!("  local-relay {url} {tick}");
10663    }
10664    if !federation_only.is_empty() {
10665        print!("  federation-only sessions:");
10666        for f in &federation_only {
10667            print!(" {}", f.name);
10668        }
10669        println!();
10670    }
10671
10672    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
10673    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
10674    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
10675    print!("\n{:>col_w$}", "", col_w = col_w);
10676    for n in &names {
10677        print!("{n:>col_w$}");
10678    }
10679    println!();
10680    for (i, row) in names.iter().enumerate() {
10681        print!("{row:>col_w$}");
10682        for (j, col) in names.iter().enumerate() {
10683            let cell = if i == j {
10684                "self".to_string()
10685            } else {
10686                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
10687                match d.scope.as_deref() {
10688                    Some("local") => "local".to_string(),
10689                    Some("federation") => "fed".to_string(),
10690                    _ => "—".to_string(),
10691                }
10692            };
10693            print!("{cell:>col_w$}");
10694        }
10695        println!();
10696    }
10697
10698    println!("\nHealth (stale threshold: {stale_secs}s):");
10699    for e in &edges {
10700        let from = e["from"].as_str().unwrap_or("?");
10701        let to = e["to"].as_str().unwrap_or("?");
10702        let scope = e["scope"].as_str().unwrap_or("?");
10703        let status = e["status"].as_str().unwrap_or("?");
10704        let mark = match status {
10705            "healthy" => "✓",
10706            "stale" => "⚠",
10707            "asymmetric" => "!",
10708            _ => "?",
10709        };
10710        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
10711        let mut details: Vec<String> = Vec::new();
10712        for (who, d) in &dirs {
10713            let silent = d.get("silent_secs").and_then(Value::as_u64);
10714            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
10715            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
10716            let label = match (pinned, probed, silent) {
10717                (false, _, _) => format!("{who} has not pinned"),
10718                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
10719                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
10720                (true, true, Some(s)) => format!("{who} silent {s}s"),
10721                (true, true, None) => format!("{who} never pulled"),
10722            };
10723            details.push(label);
10724        }
10725        println!(
10726            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
10727            details.join(" | ")
10728        );
10729    }
10730    Ok(())
10731}
10732
10733#[derive(Default)]
10734struct DirectedEdge {
10735    pinned: bool,
10736    scope: Option<String>,
10737    last_pull_at_unix: Option<u64>,
10738    silent_secs: Option<u64>,
10739    probed: bool,
10740    event_count: usize,
10741}
10742
10743/// Probe a single directed edge from `from_state`'s view of `to_name`.
10744/// Picks the priority-1 endpoint (local preferred when reachable) and
10745/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
10746/// failure (the function records `probed = true`, `last_pull = None`,
10747/// which the caller treats as "never pulled, route exists" = stale).
10748fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
10749    let pinned = from_state
10750        .get("peers")
10751        .and_then(|p| p.get(to_name))
10752        .is_some();
10753    if !pinned {
10754        return DirectedEdge::default();
10755    }
10756    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
10757    let ep = match endpoints.into_iter().next() {
10758        Some(e) => e,
10759        None => {
10760            return DirectedEdge {
10761                pinned: true,
10762                ..Default::default()
10763            };
10764        }
10765    };
10766    let scope = Some(
10767        match ep.scope {
10768            crate::endpoints::EndpointScope::Local => "local",
10769            crate::endpoints::EndpointScope::Lan => "lan",
10770            crate::endpoints::EndpointScope::Uds => "uds",
10771            crate::endpoints::EndpointScope::Federation => "federation",
10772        }
10773        .to_string(),
10774    );
10775    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
10776    let (count, last) = client
10777        .slot_state(&ep.slot_id, &ep.slot_token)
10778        .unwrap_or((0, None));
10779    let silent = last.map(|t| now.saturating_sub(t));
10780    DirectedEdge {
10781        pinned: true,
10782        scope,
10783        last_pull_at_unix: last,
10784        silent_secs: silent,
10785        probed: true,
10786        event_count: count,
10787    }
10788}
10789
10790fn direction_summary(d: &DirectedEdge) -> Value {
10791    json!({
10792        "pinned": d.pinned,
10793        "scope": d.scope,
10794        "probed": d.probed,
10795        "last_pull_at_unix": d.last_pull_at_unix,
10796        "silent_secs": d.silent_secs,
10797        "event_count": d.event_count,
10798    })
10799}
10800
10801/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
10802fn probe_relay_healthz(url: &str) -> bool {
10803    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
10804    let client = match reqwest::blocking::Client::builder()
10805        .timeout(std::time::Duration::from_millis(500))
10806        .build()
10807    {
10808        Ok(c) => c,
10809        Err(_) => return false,
10810    };
10811    match client.get(&probe_url).send() {
10812        Ok(r) => r.status().is_success(),
10813        Err(_) => false,
10814    }
10815}
10816
10817/// Drive one bilateral pair handshake between two sister sessions
10818/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
10819/// flow so failures bubble up at the offending step, not buried in
10820/// a parallel race. See `cmd_session_pair_all_local` docstring.
10821///
10822/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
10823/// federation `.well-known/wire/agent` resolution. Reads B's card +
10824/// endpoints directly off disk under `b_home` and pins them. This
10825/// makes pair-all-local work for sister sessions whose federation
10826/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
10827/// for sessions created with `wire session new --local-only`
10828/// (no federation slot at all). The `_federation_relay` / `_fed_host`
10829/// parameters are retained for callers that want to log them but
10830/// the handshake itself no longer touches federation.
10831fn drive_bilateral_pair(
10832    a_home: &std::path::Path,
10833    a_name: &str,
10834    b_home: &std::path::Path,
10835    b_name: &str,
10836    _fed_host: &str,
10837    _federation_relay: &str,
10838    settle_secs: u64,
10839) -> Result<()> {
10840    use std::time::Duration;
10841    let bin = std::env::current_exe().context("locating self exe")?;
10842
10843    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
10844        let out = std::process::Command::new(&bin)
10845            .env("WIRE_HOME", home)
10846            .env_remove("RUST_LOG")
10847            .args(args)
10848            .output()
10849            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10850        if !out.status.success() {
10851            bail!(
10852                "`wire {}` failed: stderr={}",
10853                args.join(" "),
10854                String::from_utf8_lossy(&out.stderr).trim()
10855            );
10856        }
10857        Ok(())
10858    };
10859
10860    // v0.11: each session's agent-card.handle is the DID-derived
10861    // character, not the session name. pair-accept lookups key on the
10862    // CARD HANDLE, so we discover each side's canonical handle from
10863    // its agent-card on disk before driving the pair flow.
10864    let read_card_handle = |home: &std::path::Path| -> Result<String> {
10865        let card_path = home.join("config").join("wire").join("agent-card.json");
10866        let bytes = std::fs::read(&card_path)
10867            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
10868        let card: Value = serde_json::from_slice(&bytes)?;
10869        card.get("handle")
10870            .and_then(Value::as_str)
10871            .map(str::to_string)
10872            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
10873    };
10874    let a_handle = read_card_handle(a_home)
10875        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
10876    let b_handle = read_card_handle(b_home)
10877        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
10878
10879    // 1. A initiates via --local-sister (uses the session NAME for
10880    // the registry lookup; cmd_add_local_sister auto-resolves
10881    // session→handle internally).
10882    run(a_home, &["add", b_name, "--local-sister", "--json"])
10883        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
10884
10885    // 3. settle so pair_drop reaches B's slot
10886    std::thread::sleep(Duration::from_secs(settle_secs));
10887
10888    // 4. B pulls pair_drop → 5. B pair-accept (pins A by CARD HANDLE,
10889    // not by session name — under v0.11 these differ) → 6. B push ack
10890    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
10891    run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
10892        format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
10893    })?;
10894    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
10895
10896    // 7. settle so ack reaches A's slot
10897    std::thread::sleep(Duration::from_secs(settle_secs));
10898
10899    // 8. A pulls ack (pins B by CARD HANDLE)
10900    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
10901    // suppress unused warning when both handles are consumed
10902    let _ = &b_handle;
10903
10904    Ok(())
10905}
10906
10907fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
10908    let name = resolve_session_name(name_arg)?;
10909    let session_home = crate::session::session_dir(&name)?;
10910    if !session_home.exists() {
10911        bail!(
10912            "no session named {name:?} on this machine. `wire session list` to enumerate, \
10913             `wire session new {name}` to create."
10914        );
10915    }
10916    if as_json {
10917        println!(
10918            "{}",
10919            serde_json::to_string(&json!({
10920                "name": name,
10921                "home_dir": session_home.to_string_lossy(),
10922                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
10923            }))?
10924        );
10925    } else {
10926        println!("export WIRE_HOME={}", session_home.to_string_lossy());
10927    }
10928    Ok(())
10929}
10930
10931fn cmd_session_current(as_json: bool) -> Result<()> {
10932    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10933    let registry = crate::session::read_registry().unwrap_or_default();
10934    let cwd_key = crate::session::normalize_cwd_key(&cwd);
10935    // Backward-compat: O(n) normalized scan on read-miss. Mirrors the
10936    // same pattern in session::derive_name_from_cwd /
10937    // detect_session_wire_home — handles both consistent-casing and
10938    // cross-casing upgraders (see session.rs for the full rationale).
10939    let name = registry
10940        .by_cwd
10941        .get(&cwd_key)
10942        .or_else(|| {
10943            registry
10944                .by_cwd
10945                .iter()
10946                .find(|(k, _)| {
10947                    crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
10948                })
10949                .map(|(_, v)| v)
10950        })
10951        .cloned();
10952    if as_json {
10953        println!(
10954            "{}",
10955            serde_json::to_string(&json!({
10956                "cwd": cwd_key,
10957                "session": name,
10958            }))?
10959        );
10960    } else if let Some(n) = name {
10961        println!("{n}");
10962    } else {
10963        println!("(no session registered for this cwd)");
10964    }
10965    Ok(())
10966}
10967
10968fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
10969    let name = crate::session::sanitize_name(name_arg);
10970    let session_home = crate::session::session_dir(&name)?;
10971    if !session_home.exists() {
10972        if as_json {
10973            println!(
10974                "{}",
10975                serde_json::to_string(&json!({
10976                    "name": name,
10977                    "destroyed": false,
10978                    "reason": "no such session",
10979                }))?
10980            );
10981        } else {
10982            println!("no session named {name:?} — nothing to destroy.");
10983        }
10984        return Ok(());
10985    }
10986    if !force {
10987        bail!(
10988            "destroying session {name:?} would delete its keypair + state irrecoverably. \
10989             Pass --force to confirm."
10990        );
10991    }
10992
10993    // Kill the session-local daemon if alive.
10994    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10995    if let Ok(bytes) = std::fs::read(&pidfile) {
10996        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10997            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10998        } else {
10999            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
11000        };
11001        if let Some(p) = pid {
11002            let _ = std::process::Command::new("kill")
11003                .args(["-TERM", &p.to_string()])
11004                .output();
11005        }
11006    }
11007
11008    std::fs::remove_dir_all(&session_home)
11009        .with_context(|| format!("removing session dir {session_home:?}"))?;
11010
11011    // Strip from registry.
11012    let mut registry = crate::session::read_registry().unwrap_or_default();
11013    registry.by_cwd.retain(|_, v| v != &name);
11014    crate::session::write_registry(&registry)?;
11015
11016    if as_json {
11017        println!(
11018            "{}",
11019            serde_json::to_string(&json!({
11020                "name": name,
11021                "destroyed": true,
11022            }))?
11023        );
11024    } else {
11025        println!("destroyed session {name:?}.");
11026    }
11027    Ok(())
11028}
11029
11030// ---------- diag (structured trace) ----------
11031
11032fn cmd_diag(action: DiagAction) -> Result<()> {
11033    let state = config::state_dir()?;
11034    let knob = state.join("diag.enabled");
11035    let log_path = state.join("diag.jsonl");
11036    match action {
11037        DiagAction::Tail { limit, json } => {
11038            let entries = crate::diag::tail(limit);
11039            if json {
11040                for e in entries {
11041                    println!("{}", serde_json::to_string(&e)?);
11042                }
11043            } else if entries.is_empty() {
11044                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
11045            } else {
11046                for e in entries {
11047                    let ts = e["ts"].as_u64().unwrap_or(0);
11048                    let ty = e["type"].as_str().unwrap_or("?");
11049                    let pid = e["pid"].as_u64().unwrap_or(0);
11050                    let payload = e["payload"].to_string();
11051                    println!("[{ts}] pid={pid} {ty} {payload}");
11052                }
11053            }
11054        }
11055        DiagAction::Enable => {
11056            config::ensure_dirs()?;
11057            std::fs::write(&knob, "1")?;
11058            println!("wire diag: enabled at {knob:?}");
11059        }
11060        DiagAction::Disable => {
11061            if knob.exists() {
11062                std::fs::remove_file(&knob)?;
11063            }
11064            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
11065        }
11066        DiagAction::Status { json } => {
11067            let enabled = crate::diag::is_enabled();
11068            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
11069            if json {
11070                println!(
11071                    "{}",
11072                    serde_json::to_string(&serde_json::json!({
11073                        "enabled": enabled,
11074                        "log_path": log_path,
11075                        "log_size_bytes": size,
11076                    }))?
11077                );
11078            } else {
11079                println!("wire diag status");
11080                println!("  enabled:    {enabled}");
11081                println!("  log:        {log_path:?}");
11082                println!("  log size:   {size} bytes");
11083            }
11084        }
11085    }
11086    Ok(())
11087}
11088
11089// ---------- service (install / uninstall / status) ----------
11090
11091fn cmd_service(action: ServiceAction) -> Result<()> {
11092    let kind = |local_relay: bool| {
11093        if local_relay {
11094            crate::service::ServiceKind::LocalRelay
11095        } else {
11096            crate::service::ServiceKind::Daemon
11097        }
11098    };
11099    let (report, as_json) = match action {
11100        ServiceAction::Install { local_relay, json } => {
11101            (crate::service::install_kind(kind(local_relay))?, json)
11102        }
11103        ServiceAction::Uninstall { local_relay, json } => {
11104            (crate::service::uninstall_kind(kind(local_relay))?, json)
11105        }
11106        ServiceAction::Status { local_relay, json } => {
11107            (crate::service::status_kind(kind(local_relay))?, json)
11108        }
11109    };
11110    if as_json {
11111        println!("{}", serde_json::to_string(&report)?);
11112    } else {
11113        println!("wire service {}", report.action);
11114        println!("  platform:  {}", report.platform);
11115        println!("  unit:      {}", report.unit_path);
11116        println!("  status:    {}", report.status);
11117        println!("  detail:    {}", report.detail);
11118    }
11119    Ok(())
11120}
11121
11122// ---------- update (self-update from crates.io / prebuilt release) ----------
11123
11124const CRATE_NAME: &str = "slancha-wire";
11125
11126/// (target-triple, binary-extension) of the GitHub release asset for THIS
11127/// platform — names mirror `.github/workflows/release.yml`. `None` if no
11128/// prebuilt is published for this target.
11129fn release_asset_triple() -> Option<(&'static str, &'static str)> {
11130    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
11131    {
11132        return Some(("x86_64-pc-windows-msvc", ".exe"));
11133    }
11134    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
11135    {
11136        return Some(("aarch64-apple-darwin", ""));
11137    }
11138    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
11139    {
11140        return Some(("x86_64-apple-darwin", ""));
11141    }
11142    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
11143    {
11144        return Some(("x86_64-unknown-linux-musl", ""));
11145    }
11146    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
11147    {
11148        return Some(("aarch64-unknown-linux-musl", ""));
11149    }
11150    #[allow(unreachable_code)]
11151    None
11152}
11153
11154/// Latest stable version published on crates.io.
11155fn fetch_latest_published_version() -> Result<String> {
11156    let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
11157    let client = reqwest::blocking::Client::builder()
11158        .timeout(std::time::Duration::from_secs(20))
11159        .build()?;
11160    let resp = client
11161        .get(&url)
11162        // crates.io rejects requests without a descriptive User-Agent (403).
11163        .header(
11164            "User-Agent",
11165            format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
11166        )
11167        .send()?;
11168    if !resp.status().is_success() {
11169        bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
11170    }
11171    let v: Value = resp.json()?;
11172    v.get("crate")
11173        .and_then(|c| {
11174            c.get("max_stable_version")
11175                .or_else(|| c.get("newest_version"))
11176        })
11177        .and_then(Value::as_str)
11178        .map(str::to_string)
11179        .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
11180}
11181
11182/// True iff `latest` is strictly newer than `current` (numeric major.minor.patch;
11183/// pre-release suffixes ignored).
11184fn version_is_newer(latest: &str, current: &str) -> bool {
11185    let parse = |s: &str| -> (u64, u64, u64) {
11186        let core = s.split('-').next().unwrap_or(s);
11187        let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
11188        (
11189            it.next().unwrap_or(0),
11190            it.next().unwrap_or(0),
11191            it.next().unwrap_or(0),
11192        )
11193    };
11194    parse(latest) > parse(current)
11195}
11196
11197fn cargo_on_path() -> bool {
11198    std::process::Command::new("cargo")
11199        .arg("--version")
11200        .stdout(std::process::Stdio::null())
11201        .stderr(std::process::Stdio::null())
11202        .status()
11203        .map(|s| s.success())
11204        .unwrap_or(false)
11205}
11206
11207/// Download the prebuilt release binary for `latest` and replace THIS binary
11208/// in place — the toolchain-free update path (for boxes with no `cargo`).
11209fn self_update_from_release(latest: &str) -> Result<()> {
11210    let (triple, ext) = release_asset_triple().ok_or_else(|| {
11211        anyhow!(
11212            "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
11213             or `cargo install {CRATE_NAME}`"
11214        )
11215    })?;
11216    let base =
11217        format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
11218    let client = reqwest::blocking::Client::builder()
11219        .timeout(std::time::Duration::from_secs(120))
11220        .build()?;
11221    let resp = client
11222        .get(&base)
11223        .header("User-Agent", "wire-self-update")
11224        .send()?;
11225    if !resp.status().is_success() {
11226        bail!("downloading {base} returned {}", resp.status());
11227    }
11228    let bytes = resp.bytes()?;
11229
11230    // Verify the SHA-256 sidecar if present (best-effort; absence is non-fatal).
11231    if let Ok(sha) = client
11232        .get(format!("{base}.sha256"))
11233        .header("User-Agent", "wire-self-update")
11234        .send()
11235        && sha.status().is_success()
11236    {
11237        let expected = sha
11238            .text()?
11239            .split_whitespace()
11240            .next()
11241            .unwrap_or("")
11242            .to_string();
11243        if !expected.is_empty() {
11244            use sha2::{Digest, Sha256};
11245            let mut h = Sha256::new();
11246            h.update(&bytes);
11247            let actual = hex::encode(h.finalize());
11248            if expected != actual {
11249                bail!(
11250                    "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
11251                );
11252            }
11253        }
11254    }
11255
11256    let exe = std::env::current_exe().context("locating current exe")?;
11257    let dir = exe
11258        .parent()
11259        .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
11260    let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
11261    std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
11262    #[cfg(unix)]
11263    {
11264        use std::os::unix::fs::PermissionsExt;
11265        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
11266        // Unix: rename over the running binary — the running process keeps the
11267        // old inode; the new file takes the path for the next invocation.
11268        std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
11269    }
11270    #[cfg(windows)]
11271    {
11272        // Windows can't overwrite a running .exe — rename it aside first
11273        // (allowed even while running), then move the new one into place.
11274        let old = exe.with_extension("old");
11275        let _ = std::fs::remove_file(&old);
11276        std::fs::rename(&exe, &old)
11277            .with_context(|| format!("renaming running exe {exe:?} aside"))?;
11278        std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
11279    }
11280    Ok(())
11281}
11282
11283/// Outcome of the crates.io self-update step (the front half of `wire upgrade`).
11284struct UpdateOutcome {
11285    current: String,
11286    latest: String,
11287    /// A newer stable version is published.
11288    available: bool,
11289    /// We actually installed it this run.
11290    installed: bool,
11291    /// How it was installed ("cargo install" / "prebuilt release binary").
11292    via: Option<&'static str>,
11293}
11294
11295/// Check crates.io for a newer published wire and, when `install` is true,
11296/// self-install it (cargo if a toolchain is on PATH, else the prebuilt release
11297/// binary). The front half of `wire upgrade`; `install=false` is check-only.
11298fn self_update_step(install: bool) -> Result<UpdateOutcome> {
11299    let current = env!("CARGO_PKG_VERSION").to_string();
11300    let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
11301    let available = version_is_newer(&latest, &current);
11302    if !install || !available {
11303        return Ok(UpdateOutcome {
11304            current,
11305            latest,
11306            available,
11307            installed: false,
11308            via: None,
11309        });
11310    }
11311    let via = if cargo_on_path() {
11312        eprintln!(
11313            "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
11314        );
11315        let status = std::process::Command::new("cargo")
11316            .args([
11317                "install",
11318                CRATE_NAME,
11319                "--version",
11320                &latest,
11321                "--force",
11322                "--locked",
11323            ])
11324            .status()
11325            .context("running cargo install")?;
11326        if !status.success() {
11327            bail!("`cargo install {CRATE_NAME}` failed");
11328        }
11329        "cargo install"
11330    } else {
11331        eprintln!(
11332            "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
11333        );
11334        self_update_from_release(&latest)?;
11335        "prebuilt release binary"
11336    };
11337    Ok(UpdateOutcome {
11338        current,
11339        latest,
11340        available,
11341        installed: true,
11342        via: Some(via),
11343    })
11344}
11345
11346// ---------- upgrade (atomic daemon swap) ----------
11347
11348/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
11349/// fresh one from the currently-installed binary, write a new versioned
11350/// pidfile. The fix for today's exact failure mode: a daemon process that
11351/// kept running OLD binary text in memory under a symlink that had since
11352/// been repointed at a NEW binary on disk.
11353///
11354/// Idempotent. If no stale daemon is running, just starts a fresh one
11355/// (same as `wire daemon &` but with the wait-until-alive guard from
11356/// ensure_up::ensure_daemon_running).
11357///
11358/// `--check` mode reports drift without acting — lists the processes
11359/// that WOULD be killed and the binary version of each.
11360///
11361/// Session-scoped upgrade kill set (v0.13.2, B fix): THIS session's own daemon
11362/// (`my_pid`, from its pidfile — reliable even when the OS process scan can't
11363/// see it, as on Windows) plus TRUE orphans (found `wire daemon` pids owned by
11364/// no session), EXCLUDING sibling sessions' daemons. Pure + unit-tested so the
11365/// session-scoping is locked — the box-wide predecessor accumulated daemons.
11366fn upgrade_kill_set(
11367    my_pid: Option<u32>,
11368    found_daemon_pids: &[u32],
11369    owned_session_pids: &std::collections::HashSet<u32>,
11370) -> Vec<u32> {
11371    let mut k: Vec<u32> = Vec::new();
11372    if let Some(p) = my_pid {
11373        k.push(p);
11374    }
11375    for &p in found_daemon_pids {
11376        if !owned_session_pids.contains(&p) && Some(p) != my_pid {
11377            k.push(p); // true orphan — owned by no session
11378        }
11379    }
11380    k.sort_unstable();
11381    k.dedup();
11382    k
11383}
11384
11385/// One distinct `wire` binary discovered on `$PATH`, with enrichment used by
11386/// the `wire upgrade` PATH-shadowing diagnostic (issue #80).
11387///
11388/// "Distinct" = unique canonical path; symlink chains collapse to a single
11389/// entry at the FIRST PATH position that surfaced them. This is what
11390/// `which -a` would show modulo symlink dedup.
11391#[derive(Debug, Clone)]
11392struct PathWireBinary {
11393    /// PATH entry under which this binary was discovered (NOT canonicalized,
11394    /// so the operator sees the path they wrote in their shell config).
11395    path: std::path::PathBuf,
11396    /// Canonical filesystem path (symlinks resolved). Used for dedup so a
11397    /// symlink that points at the real binary doesn't show up as a second
11398    /// "distinct" entry.
11399    canonical: std::path::PathBuf,
11400    /// SHA-256 hex of the binary contents. `None` if unreadable (rare; would
11401    /// require a race or perms change after the existence check).
11402    sha256: Option<String>,
11403    /// Last-modified time of the binary. `None` if metadata unreadable.
11404    mtime: Option<std::time::SystemTime>,
11405    /// Zero-based PATH position (after dedup). `0` = the binary bare `wire`
11406    /// resolves to (the winner of PATH precedence).
11407    path_index: usize,
11408    /// True iff this is the binary currently executing the running `wire
11409    /// upgrade` process (i.e. `std::env::current_exe()` canonicalized matches).
11410    /// When this is NOT the `path_index == 0` entry, the operator just ran
11411    /// `wire upgrade` against a SHADOWED binary and bare `wire` will continue
11412    /// to use the active one — the central footgun #80 exists to catch.
11413    is_current_exe: bool,
11414}
11415
11416impl PathWireBinary {
11417    /// True iff bare `wire` resolves here (the PATH-precedence winner).
11418    fn is_active(&self) -> bool {
11419        self.path_index == 0
11420    }
11421    /// Short sha256 (first 8 hex chars) for compact display; `?` filler when
11422    /// the hash couldn't be computed.
11423    fn sha256_short(&self) -> String {
11424        self.sha256
11425            .as_deref()
11426            .map(|s| s[..s.len().min(8)].to_string())
11427            .unwrap_or_else(|| "????????".to_string())
11428    }
11429    /// Pretty mtime in UTC RFC3339 seconds; `?` when missing or unrepresentable.
11430    fn mtime_display(&self) -> String {
11431        let Some(ts) = self.mtime else {
11432            return "?".to_string();
11433        };
11434        let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
11435            Ok(d) => d.as_secs() as i64,
11436            Err(_) => return "?".to_string(),
11437        };
11438        time::OffsetDateTime::from_unix_timestamp(secs)
11439            .ok()
11440            .and_then(|dt| {
11441                dt.format(&time::format_description::well_known::Rfc3339)
11442                    .ok()
11443            })
11444            .unwrap_or_else(|| "?".to_string())
11445    }
11446}
11447
11448/// SHA-256 hex of a file's contents (streamed; safe for any size).
11449fn sha256_file(p: &std::path::Path) -> Result<String> {
11450    use sha2::{Digest, Sha256};
11451    let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
11452    let mut h = Sha256::new();
11453    std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
11454    Ok(hex::encode(h.finalize()))
11455}
11456
11457/// Walk `$PATH` left-to-right, find all distinct files named `wire` (plus
11458/// `wire.exe` on Windows), and return them in PATH order with sha256+mtime
11459/// enrichment. Issue #80.
11460///
11461/// Invariants:
11462/// - First entry (`path_index == 0`) is what bare `wire` resolves to.
11463/// - Symlink chains collapse: only the first PATH position surfaces; later
11464///   entries pointing at the same canonical file are dropped (NOT counted
11465///   as a "shadow").
11466/// - Best-effort: I/O errors degrade to `None` on per-binary fields,
11467///   never abort the whole walk.
11468/// - Empty / missing PATH → empty Vec (NOT an error; the caller is already
11469///   running, so SOMETHING resolved this binary, just not via PATH).
11470fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
11471    let path = std::env::var("PATH").unwrap_or_default();
11472    let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
11473        .ok()
11474        .and_then(|p| p.canonicalize().ok());
11475    enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
11476}
11477
11478/// Pure (testable) inner of [`enumerate_path_wire_binaries`]: takes the PATH
11479/// string and an optional already-canonicalized `current_exe` so tests don't
11480/// have to mutate process-wide environment (which would race with any other
11481/// test that reads PATH).
11482fn enumerate_path_wire_binaries_from(
11483    path: &str,
11484    current_exe_canon: Option<&std::path::Path>,
11485) -> Vec<PathWireBinary> {
11486    if path.is_empty() {
11487        return Vec::new();
11488    }
11489    // Unix splits PATH on ':', Windows on ';'. We don't use
11490    // `std::env::split_paths` because we want to be explicit and consistent
11491    // with the existing v0.6.8 detection that this helper replaces (which
11492    // used `.split(':')` unconditionally — a Unix-only bug; fixed here).
11493    let separator = if cfg!(windows) { ';' } else { ':' };
11494    let names: &[&str] = if cfg!(windows) {
11495        // Try .exe first — that's what CreateProcess resolves bare `wire` to
11496        // under PATHEXT. A plain `wire` script (e.g. msys) only wins if
11497        // there's no wire.exe in the same directory.
11498        &["wire.exe", "wire"]
11499    } else {
11500        &["wire"]
11501    };
11502
11503    let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
11504    let mut out: Vec<PathWireBinary> = Vec::new();
11505    for dir in path.split(separator) {
11506        if dir.is_empty() {
11507            continue;
11508        }
11509        for name in names {
11510            let candidate = std::path::PathBuf::from(dir).join(name);
11511            // `is_file()` (not `.exists()`) so a directory named `wire`
11512            // doesn't false-positive — `.exists()` returns true for dirs.
11513            if !candidate.is_file() {
11514                continue;
11515            }
11516            let canon = candidate
11517                .canonicalize()
11518                .unwrap_or_else(|_| candidate.clone());
11519            if !seen.insert(canon.clone()) {
11520                // An earlier PATH entry already surfaced this canonical file
11521                // (symlink chain). Don't double-count as a shadow.
11522                break;
11523            }
11524            let meta = std::fs::metadata(&canon).ok();
11525            let mtime = meta.as_ref().and_then(|m| m.modified().ok());
11526            let sha256 = sha256_file(&canon).ok();
11527            let is_current_exe = current_exe_canon
11528                .map(|c| c == canon.as_path())
11529                .unwrap_or(false);
11530            let path_index = out.len();
11531            out.push(PathWireBinary {
11532                path: candidate,
11533                canonical: canon,
11534                sha256,
11535                mtime,
11536                path_index,
11537                is_current_exe,
11538            });
11539            // One entry per PATH dir — don't surface both wire AND wire.exe
11540            // from the same directory.
11541            break;
11542        }
11543    }
11544    out
11545}
11546
11547/// Render a multi-line WARN message for the PATH-shadow case, or `None` if
11548/// there's nothing to warn about. Issue #80.
11549///
11550/// Triggers (any one fires the warning):
11551/// - `>= 2 distinct wire binaries` on PATH (classic shadow case).
11552/// - Exactly 1 binary on PATH AND that binary isn't the one currently
11553///   running this `wire upgrade` (operator ran an off-PATH binary; bare
11554///   `wire` would resolve to a DIFFERENT binary that this upgrade just
11555///   bypassed).
11556/// - `0 binaries` on PATH at all (this `wire upgrade` ran via an absolute
11557///   path; bare `wire` would fail in any future shell).
11558fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
11559    let any_current = bins.iter().any(|b| b.is_current_exe);
11560    let multi = bins.len() >= 2;
11561    let off_path = !bins.is_empty() && !any_current;
11562    let none_on_path = bins.is_empty();
11563    if !multi && !off_path && !none_on_path {
11564        return None;
11565    }
11566    let mut out = String::new();
11567    if multi {
11568        out.push_str(&format!(
11569            "WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
11570            bins.len()
11571        ));
11572        for b in bins {
11573            let mut tags: Vec<&str> = Vec::new();
11574            if b.is_active() {
11575                tags.push("ACTIVE (bare `wire` resolves here)");
11576            }
11577            if b.is_current_exe {
11578                tags.push("THIS upgrade ran against this binary");
11579            }
11580            let tag_str = if tags.is_empty() {
11581                String::new()
11582            } else {
11583                format!("  ← {}", tags.join("; "))
11584            };
11585            out.push_str(&format!(
11586                "  [{}] {}  (sha256:{}  mtime:{}){}\n",
11587                b.path_index,
11588                b.path.display(),
11589                b.sha256_short(),
11590                b.mtime_display(),
11591                tag_str,
11592            ));
11593        }
11594        if !any_current {
11595            out.push_str(
11596                "  NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
11597            );
11598            out.push_str(
11599                "        Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
11600            );
11601        } else if !bins[0].is_current_exe {
11602            out.push_str(
11603                "  Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
11604            );
11605            out.push_str(
11606                "  ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
11607            );
11608            out.push_str(&format!(
11609                "    - rm {}  (or symlink it to the upgraded binary)\n",
11610                bins[0].path.display(),
11611            ));
11612            out.push_str(
11613                "    - or reorder PATH so the upgraded binary's directory precedes the active one\n",
11614            );
11615            out.push_str("  Verify with: which -a wire\n");
11616        }
11617    } else if off_path {
11618        // Single PATH binary, but THIS upgrade ran against a different file.
11619        let active = &bins[0];
11620        out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
11621        out.push_str(&format!(
11622            "      bare `wire` resolves to {} (sha256:{}),\n",
11623            active.path.display(),
11624            active.sha256_short(),
11625        ));
11626        out.push_str(
11627            "      which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
11628        );
11629        out.push_str("      will continue to invoke the old binary.\n");
11630    } else if none_on_path {
11631        out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
11632        out.push_str("      This upgrade ran against an absolute-path invocation only.\n");
11633    }
11634    Some(out.trim_end().to_string())
11635}
11636
11637#[cfg(test)]
11638mod upgrade_tests {
11639    use super::*;
11640    use std::collections::HashSet;
11641
11642    #[test]
11643    fn upgrade_kill_set_is_session_scoped() {
11644        // owned: my daemon 100, sibling session daemon 200.
11645        let owned: HashSet<u32> = [100, 200].into_iter().collect();
11646        // found by the process scan: mine (100), sibling (200), a true orphan (999).
11647        let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
11648        assert!(k.contains(&100), "must kill my own daemon (to replace it)");
11649        assert!(k.contains(&999), "must sweep a true orphan");
11650        assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
11651
11652        // CRITICAL: even when the process scan returns EMPTY (Windows CIM can't
11653        // match the quoted command line), my own daemon is still killed via its
11654        // pidfile pid — this is the B-accumulation fix.
11655        assert_eq!(
11656            upgrade_kill_set(Some(100), &[], &owned),
11657            vec![100],
11658            "own daemon killed even when the process scan is empty"
11659        );
11660
11661        // Uninitialized session (no own daemon): only true orphans.
11662        assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
11663    }
11664
11665    // ----- issue #80: PATH-shadow detection -----
11666    //
11667    // We test the pure inner `enumerate_path_wire_binaries_from(path, cur)`
11668    // so we never mutate the process-wide PATH — that would race with any
11669    // other test in the binary that reads PATH (e.g. `process_alive_self`
11670    // resolving the test binary via PATH).
11671
11672    fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
11673        use std::io::Write;
11674        let p = dir.join("wire");
11675        let mut f = std::fs::File::create(&p).expect("create fake wire");
11676        f.write_all(body).expect("write fake wire");
11677        drop(f);
11678        #[cfg(unix)]
11679        {
11680            use std::os::unix::fs::PermissionsExt;
11681            let mut perm = std::fs::metadata(&p).unwrap().permissions();
11682            perm.set_mode(0o755);
11683            std::fs::set_permissions(&p, perm).unwrap();
11684        }
11685        p
11686    }
11687
11688    #[test]
11689    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11690    fn enumerate_finds_no_binaries_when_path_empty() {
11691        let bins = enumerate_path_wire_binaries_from("", None);
11692        assert!(
11693            bins.is_empty(),
11694            "empty PATH yields no binaries, got {bins:?}"
11695        );
11696    }
11697
11698    #[test]
11699    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11700    fn enumerate_detects_two_distinct_binaries_in_path_order() {
11701        let d1 = tempfile::tempdir().unwrap();
11702        let d2 = tempfile::tempdir().unwrap();
11703        let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
11704        let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
11705        let path = format!("{}:{}", d1.path().display(), d2.path().display());
11706
11707        let bins = enumerate_path_wire_binaries_from(&path, None);
11708        assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
11709        assert_eq!(bins[0].path_index, 0);
11710        assert_eq!(bins[1].path_index, 1);
11711        assert!(bins[0].is_active(), "first PATH entry is active");
11712        assert!(!bins[1].is_active(), "second PATH entry is not active");
11713        // sha256 differs because contents differ.
11714        assert_ne!(
11715            bins[0].sha256, bins[1].sha256,
11716            "distinct contents must hash differently"
11717        );
11718        // path field is the un-canonicalized PATH-relative shape.
11719        assert_eq!(bins[0].path, p1);
11720        assert_eq!(bins[1].path, p2);
11721    }
11722
11723    #[test]
11724    #[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
11725    fn enumerate_collapses_symlink_chains_to_one_entry() {
11726        let real_dir = tempfile::tempdir().unwrap();
11727        let link_dir = tempfile::tempdir().unwrap();
11728        let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
11729        let link = link_dir.path().join("wire");
11730        #[cfg(unix)]
11731        std::os::unix::fs::symlink(&real, &link).unwrap();
11732
11733        // Put the SYMLINK first in PATH; the real binary second. Both
11734        // resolve to the same canonical file — should collapse to ONE entry
11735        // at the first PATH position.
11736        let path = format!(
11737            "{}:{}",
11738            link_dir.path().display(),
11739            real_dir.path().display()
11740        );
11741        let bins = enumerate_path_wire_binaries_from(&path, None);
11742        assert_eq!(
11743            bins.len(),
11744            1,
11745            "symlink chain must collapse to a single entry: {bins:?}"
11746        );
11747        assert!(bins[0].is_active());
11748        // path is the symlink (what the operator wrote), canonical is the real file.
11749        assert_eq!(bins[0].path, link);
11750        assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
11751    }
11752
11753    #[test]
11754    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11755    fn shadow_warning_off_path_when_current_exe_not_on_path() {
11756        // One binary on PATH, but current_exe points somewhere else.
11757        // The off-PATH branch fires.
11758        let d = tempfile::tempdir().unwrap();
11759        write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
11760        let elsewhere = tempfile::tempdir().unwrap();
11761        let cur = elsewhere.path().join("not-on-path-wire");
11762        let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
11763        assert_eq!(bins.len(), 1);
11764        assert!(!bins[0].is_current_exe);
11765        let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
11766        assert!(
11767            warn.contains("off-PATH binary"),
11768            "off-path WARN must mention off-PATH; got: {warn}"
11769        );
11770    }
11771
11772    #[test]
11773    fn shadow_warning_fires_when_no_binaries_at_all() {
11774        let bins: Vec<PathWireBinary> = Vec::new();
11775        let warn = path_shadow_warning(&bins).expect("empty must warn");
11776        assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
11777    }
11778
11779    #[test]
11780    #[cfg_attr(windows, ignore = "PATH separator differs")]
11781    fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
11782        let d1 = tempfile::tempdir().unwrap();
11783        let d2 = tempfile::tempdir().unwrap();
11784        write_fake_wire(d1.path(), b"published\n");
11785        write_fake_wire(d2.path(), b"head\n");
11786        let path = format!("{}:{}", d1.path().display(), d2.path().display());
11787        let bins = enumerate_path_wire_binaries_from(&path, None);
11788        let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
11789        assert!(warn.contains("2 distinct"), "got: {warn}");
11790        assert!(warn.contains("ACTIVE"), "must mark the active binary");
11791        assert!(
11792            warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
11793            "must guide the operator to a fix; got: {warn}"
11794        );
11795    }
11796}
11797
11798fn cmd_upgrade(check_only: bool, local: bool, as_json: bool) -> Result<()> {
11799    // 0. (v0.13.3 — merged `update`) ALWAYS check crates.io first and, unless
11800    // this is a --check or --local run, self-install a newer release BEFORE the
11801    // daemon swap below — the respawn then picks up the new on-disk binary. A
11802    // crates.io/network failure must NOT block the restart, so it degrades to a
11803    // warning. `--local` skips it entirely (offline / local dev build).
11804    let update: Option<UpdateOutcome> = if local {
11805        None
11806    } else {
11807        match self_update_step(!check_only) {
11808            Ok(o) => Some(o),
11809            Err(e) => {
11810                if !check_only {
11811                    eprintln!("wire upgrade: update check skipped — {e:#}");
11812                }
11813                None
11814            }
11815        }
11816    };
11817    if let Some(o) = &update
11818        && o.installed
11819    {
11820        eprintln!(
11821            "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
11822            o.latest,
11823            o.current,
11824            o.via.unwrap_or("self-update")
11825        );
11826    }
11827
11828    // 1. Identify all running wire processes. v0.7.3: walks `pgrep -f`
11829    // on unix / `Get-CimInstance Win32_Process` on Windows via the
11830    // shared `platform::find_processes_by_cmdline`. Covers both the
11831    // long-lived sync `wire daemon` *and* the `wire relay-server`
11832    // local-only loopback — the pre-v0.7.3 upgrade only swept daemons
11833    // and left stale relay-server children pinned on the old binary,
11834    // forcing operators to `pkill -f relay-server` manually after
11835    // every version bump.
11836    let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
11837    let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
11838    // v0.14.x: also enumerate `wire mcp` server subprocesses. These are
11839    // pinned by their MCP host (Claude Code / Claude.app desktop), NOT
11840    // in wire's pidfile registry. We do NOT kill them — that would
11841    // disconnect every Claude tab's wire MCP toolset until each session
11842    // explicitly `/mcp` reconnects — but we surface their count so the
11843    // operator knows their sister sessions still run pre-upgrade code
11844    // until they reconnect. See `feedback_wire_upgrade_skips_mcp_servers`.
11845    let mcp_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire mcp");
11846    let running_pids: Vec<u32> = daemon_pids
11847        .iter()
11848        .chain(relay_pids.iter())
11849        .copied()
11850        .collect();
11851
11852    // 2. Read pidfile to surface what the daemon THINKS it is.
11853    let record = crate::ensure_up::read_pid_record("daemon");
11854    let recorded_version: Option<String> = match &record {
11855        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
11856        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
11857        _ => None,
11858    };
11859    let cli_version = env!("CARGO_PKG_VERSION").to_string();
11860
11861    // 2b. v0.13.2 (B fix — session-scoped upgrade). `wire upgrade` now
11862    // refreshes THIS session's daemon, not the whole box. The old box-wide
11863    // design (kill every `wire daemon` process, wipe every session's pidfile,
11864    // respawn every session) was wrong for a multi-session / shared-relay box
11865    // AND broke on Windows: the CIM scan can't match the quoted
11866    // `"...\wire.exe" daemon` command line (no contiguous `wire daemon`), so it
11867    // found nothing to kill, then the respawn loop ACCUMULATED daemons
11868    // (glossy-magnolia: 2->5->8->11). The kill set is now:
11869    //   (a) THIS session's own daemon, via its pidfile pid — reliable and
11870    //       CIM-independent; plus
11871    //   (b) TRUE orphans: `wire daemon` pids owned by NO session.
11872    // It SPARES sibling sessions' daemons AND the shared loopback relay-server
11873    // (killing it would break every same-box session's routing).
11874    let my_daemon_pid = record.pid();
11875    let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
11876        .unwrap_or_default()
11877        .iter()
11878        .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
11879        .collect();
11880    let kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
11881    // relay_pids are intentionally NOT killed — the local relay is shared.
11882
11883    if check_only {
11884        // v0.6.8: also surface session-level state + PATH dupes in --check.
11885        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
11886            .unwrap_or_default()
11887            .iter()
11888            .filter(|s| s.daemon_running)
11889            .map(|s| s.name.clone())
11890            .collect();
11891        let path_bins = enumerate_path_wire_binaries();
11892        let path_dupes: Vec<String> = path_bins
11893            .iter()
11894            .map(|b| b.canonical.to_string_lossy().into_owned())
11895            .collect();
11896        let path_binaries_detail: Vec<serde_json::Value> = path_bins
11897            .iter()
11898            .map(|b| {
11899                json!({
11900                    "path": b.path.to_string_lossy(),
11901                    "canonical": b.canonical.to_string_lossy(),
11902                    "sha256": b.sha256,
11903                    "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
11904                    "path_index": b.path_index,
11905                    "is_active": b.is_active(),
11906                    "is_current_exe": b.is_current_exe,
11907                })
11908            })
11909            .collect();
11910        let path_warning_check = path_shadow_warning(&path_bins);
11911        // v0.7.3: enumerate which service units WOULD be refreshed.
11912        // Read-only — `status_kind` doesn't touch anything.
11913        let installed_service_kinds: Vec<&'static str> = [
11914            (crate::service::ServiceKind::Daemon, "daemon"),
11915            (crate::service::ServiceKind::LocalRelay, "local-relay"),
11916        ]
11917        .into_iter()
11918        .filter_map(|(k, label)| {
11919            crate::service::status_kind(k)
11920                .ok()
11921                .filter(|r| r.status != "absent")
11922                .map(|_| label)
11923        })
11924        .collect();
11925        let (update_latest, update_available) = match &update {
11926            Some(o) => (Some(o.latest.clone()), o.available),
11927            None => (None, false),
11928        };
11929        let report = json!({
11930            "running_pids": running_pids,
11931            "running_daemons": daemon_pids,
11932            "running_relay_servers": relay_pids,
11933            // v0.14.x: surface stale `wire mcp` host-pinned server count
11934            // so JSON consumers can drive their own /mcp-reconnect UX.
11935            // `would_warn_stale_mcp_servers` is true iff there ARE any —
11936            // distinct from `would_kill` because we never kill them.
11937            "running_mcp_servers": mcp_pids,
11938            "would_warn_stale_mcp_servers": !mcp_pids.is_empty(),
11939            "pidfile_version": recorded_version,
11940            "cli_version": cli_version,
11941            "latest_published": update_latest,
11942            "update_available": update_available,
11943            "would_kill": kill_set,
11944            "would_refresh_services": installed_service_kinds,
11945            "session_daemons_running": sessions_with_daemons,
11946            "path_binaries": path_dupes,
11947            "path_binaries_detail": path_binaries_detail,
11948            "path_duplicate_warning": path_dupes.len() > 1,
11949            "path_warning": path_warning_check,
11950        });
11951        if as_json {
11952            println!("{}", serde_json::to_string(&report)?);
11953        } else {
11954            println!("wire upgrade --check");
11955            println!("  cli version:      {cli_version}");
11956            match (&update_latest, update_available) {
11957                (Some(l), true) => println!("  latest published: {l}  (UPDATE AVAILABLE)"),
11958                (Some(l), false) => println!("  latest published: {l}  (up to date)"),
11959                (None, _) => println!("  latest published: (crates.io check skipped)"),
11960            }
11961            println!(
11962                "  pidfile version:  {}",
11963                recorded_version.as_deref().unwrap_or("(missing)")
11964            );
11965            if running_pids.is_empty() {
11966                println!("  running daemons:  none");
11967                println!("  running relays:   none");
11968            } else {
11969                if daemon_pids.is_empty() {
11970                    println!("  running daemons:  none");
11971                } else {
11972                    let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
11973                    println!("  running daemons:  pids {}", p.join(", "));
11974                }
11975                if relay_pids.is_empty() {
11976                    println!("  running relays:   none");
11977                } else {
11978                    let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
11979                    println!("  running relays:   pids {}", p.join(", "));
11980                }
11981                println!("  would kill all + spawn fresh");
11982            }
11983            // v0.14.x: surface the MCP-server pin gotcha in `--check` too
11984            // so an operator probing "what will this do?" sees the full
11985            // story BEFORE running the actual upgrade. The `wire mcp`
11986            // procs are NEVER killed by `wire upgrade`; their Claude tab
11987            // must `/mcp` reconnect after the swap.
11988            if !mcp_pids.is_empty() {
11989                let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
11990                println!(
11991                    "  wire mcp servers: pids {} (NOT killed; each Claude tab must `/mcp` reconnect to load new binary)",
11992                    p.join(", ")
11993                );
11994            }
11995            if !installed_service_kinds.is_empty() {
11996                println!(
11997                    "  would refresh:    {} installed service unit(s) → new binary path",
11998                    installed_service_kinds.join(", ")
11999                );
12000            }
12001            if !sessions_with_daemons.is_empty() {
12002                println!(
12003                    "  session daemons:  {} (would respawn under new binary)",
12004                    sessions_with_daemons.join(", ")
12005                );
12006            }
12007            if let Some(w) = &path_warning_check {
12008                println!("  PATH check:");
12009                for line in w.lines() {
12010                    println!("    {line}");
12011                }
12012            }
12013        }
12014        return Ok(());
12015    }
12016
12017    // 3. Terminate the kill set. Graceful first, then FORCE-kill any survivor.
12018    //
12019    // v0.13.2 (B fix #2): the force-kill must NOT be gated on graceful having
12020    // "succeeded". On Windows, `taskkill /PID /T` WITHOUT `/F` is a no-op for a
12021    // windowless daemon (it returns failure), so the rc9 logic — which only
12022    // force-killed pids that graceful had reported killing — force-killed
12023    // NOTHING, and the daemon survived every `wire upgrade` (glossy: pidfile
12024    // pids 3676/25236/24660 all survived → accumulation). Now we attempt
12025    // graceful best-effort, grace-wait, then force-kill EVERY pid still alive
12026    // regardless of the graceful result. Force-kill (`taskkill /F /T` /
12027    // SIGKILL) is the load-bearing step.
12028    for pid in &kill_set {
12029        let _ = crate::platform::kill_process(*pid, false); // best-effort graceful
12030    }
12031    if !kill_set.is_empty() {
12032        // Brief grace for platforms where graceful works (Unix SIGTERM).
12033        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
12034        while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
12035        {
12036            std::thread::sleep(std::time::Duration::from_millis(50));
12037        }
12038        // Force-kill every survivor — this is what actually kills the
12039        // windowless daemon on Windows.
12040        for pid in &kill_set {
12041            if process_alive_pid(*pid) {
12042                let _ = crate::platform::kill_process(*pid, true);
12043            }
12044        }
12045        std::thread::sleep(std::time::Duration::from_millis(200)); // settle
12046    }
12047    // Report what's actually gone (drives the "no stale" message + JSON).
12048    let killed: Vec<u32> = kill_set
12049        .iter()
12050        .copied()
12051        .filter(|p| !process_alive_pid(*p))
12052        .collect();
12053
12054    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
12055    //    old daemon is still owning it.
12056    let pidfile = config::state_dir()?.join("daemon.pid");
12057    if pidfile.exists() {
12058        let _ = std::fs::remove_file(&pidfile);
12059    }
12060
12061    // 4b. v0.13.2: session-scoped — only THIS session's pidfile is wiped
12062    // (already removed at step 4 above). We deliberately DO NOT touch sibling
12063    // sessions' pidfiles: their daemons were spared, so wiping their pidfiles
12064    // would make them look down and the old box-wide respawn would spawn
12065    // duplicates (the accumulation bug). Each sibling refreshes itself on its
12066    // own `wire upgrade`.
12067
12068    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
12069    // multiple distinct files on $PATH, surface the conflict — operators
12070    // get bitten when an old binary at /usr/local/bin shadows a fresh
12071    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
12072    let path_bins = enumerate_path_wire_binaries();
12073    let path_dupes: Vec<String> = path_bins
12074        .iter()
12075        .map(|b| b.canonical.to_string_lossy().into_owned())
12076        .collect();
12077    let path_binaries_detail: Vec<Value> = path_bins
12078        .iter()
12079        .map(|b| {
12080            json!({
12081                "path": b.path.to_string_lossy(),
12082                "canonical": b.canonical.to_string_lossy(),
12083                "sha256": b.sha256,
12084                "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
12085                "path_index": b.path_index,
12086                "is_active": b.is_active(),
12087                "is_current_exe": b.is_current_exe,
12088            })
12089        })
12090        .collect();
12091    let path_warning = path_shadow_warning(&path_bins);
12092
12093    // 4d. v0.7.3 NEW: refresh installed service units so they point at
12094    // the freshly-installed binary path. Without this step, an upgrade
12095    // would: kill the old daemon, leave the launchd plist /
12096    // systemd unit / Windows scheduled task pointing at the OLD
12097    // binary path (or, worse, an old binary location that's been
12098    // unlinked), and then the OS's auto-respawn would either fail or
12099    // bring the OLD binary back from the dead. Reinstalling rewrites
12100    // the unit with `std::env::current_exe()` (the freshly-resolved
12101    // path of the running upgrade-driver process) and re-bootstraps /
12102    // re-enables / re-registers so the next OS-driven start uses it.
12103    //
12104    // Only refreshes units that are already installed — does NOT
12105    // install services the operator never opted into.
12106    let mut service_refreshes: Vec<Value> = Vec::new();
12107    for kind in [
12108        crate::service::ServiceKind::Daemon,
12109        crate::service::ServiceKind::LocalRelay,
12110    ] {
12111        let already_installed = crate::service::status_kind(kind)
12112            .map(|r| r.status != "absent")
12113            .unwrap_or(false);
12114        if !already_installed {
12115            continue;
12116        }
12117        match crate::service::install_kind(kind) {
12118            Ok(rep) => service_refreshes.push(json!({
12119                "kind": rep.kind,
12120                "platform": rep.platform,
12121                "status": rep.status,
12122                "unit_path": rep.unit_path,
12123                "action": "refreshed",
12124            })),
12125            Err(e) => service_refreshes.push(json!({
12126                "kind": format!("{kind:?}"),
12127                "action": "refresh_failed",
12128                "error": format!("{e:#}"),
12129            })),
12130        }
12131    }
12132
12133    // 5. Spawn fresh daemon via ensure_up — atomically waits for
12134    //    process_alive + writes the versioned pidfile. (If the Daemon
12135    //    service was refreshed above, it has already started a fresh
12136    //    process under the new binary; ensure_daemon_running notices
12137    //    and short-circuits to "already running".)
12138    let spawned = crate::ensure_up::ensure_daemon_running()?;
12139
12140    // 5b. v0.13.2: session-scoped — no sibling respawn. `ensure_daemon_running`
12141    // above already respawned THIS session's daemon; sibling sessions were
12142    // spared (never killed), so there is nothing to respawn for them. Each
12143    // refreshes itself on its own `wire upgrade`.
12144    let session_respawns: Vec<Value> = Vec::new();
12145
12146    let new_record = crate::ensure_up::read_pid_record("daemon");
12147    let new_pid = new_record.pid();
12148    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
12149        Some(d.version.clone())
12150    } else {
12151        None
12152    };
12153
12154    if as_json {
12155        println!(
12156            "{}",
12157            serde_json::to_string(&json!({
12158                "killed": killed,
12159                "found_daemons": daemon_pids,
12160                "spared_relay_servers": relay_pids,
12161                // v0.14.x: same surface as `--check` — JSON consumers
12162                // get the stale-MCP-server pid list so they can drive
12163                // operator UX (e.g., a tab-restart prompt). The MCP
12164                // procs were NEVER candidates for the kill set; this
12165                // is purely informational. `stale_mcp_warning` is the
12166                // human-readable line, present iff there are stale
12167                // MCP procs.
12168                "stale_mcp_server_pids": mcp_pids,
12169                "stale_mcp_warning": if mcp_pids.is_empty() {
12170                    Value::Null
12171                } else {
12172                    json!(format!(
12173                        "{} `wire mcp` server subprocess(es) still on pre-upgrade code; each Claude tab must `/mcp` reconnect to pick up the new binary",
12174                        mcp_pids.len()
12175                    ))
12176                },
12177                "service_refreshes": service_refreshes,
12178                "spawned_fresh_daemon": spawned,
12179                "new_pid": new_pid,
12180                "new_version": new_version,
12181                "cli_version": cli_version,
12182                "session_respawns": session_respawns,
12183                "path_binaries": path_dupes,
12184                "path_binaries_detail": path_binaries_detail,
12185                "path_warning": path_warning,
12186            }))?
12187        );
12188    } else {
12189        if killed.is_empty() {
12190            println!("wire upgrade: no stale wire processes running");
12191        } else {
12192            let killed_list = killed
12193                .iter()
12194                .map(|p| p.to_string())
12195                .collect::<Vec<_>>()
12196                .join(", ");
12197            // Session-scoped: report what was actually killed, and that the
12198            // shared relay-server was SPARED (not killed) — the old wording
12199            // lumped the spared relay into the killed count and read like it
12200            // had been terminated (glossy-magnolia nit).
12201            if relay_pids.is_empty() {
12202                println!(
12203                    "wire upgrade: killed {} daemon(s) [{killed_list}]",
12204                    killed.len()
12205                );
12206            } else {
12207                let relay_list = relay_pids
12208                    .iter()
12209                    .map(|p| p.to_string())
12210                    .collect::<Vec<_>>()
12211                    .join(", ");
12212                println!(
12213                    "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
12214                    killed.len(),
12215                    relay_pids.len()
12216                );
12217            }
12218        }
12219        if !service_refreshes.is_empty() {
12220            println!(
12221                "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
12222                service_refreshes.len()
12223            );
12224            for r in &service_refreshes {
12225                let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
12226                let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
12227                let status = r.get("status").and_then(Value::as_str).unwrap_or("");
12228                let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
12229                if action == "refreshed" {
12230                    println!("                    - {kind}: {action} ({status}, {platform})");
12231                } else {
12232                    let err = r.get("error").and_then(Value::as_str).unwrap_or("");
12233                    println!("                    - {kind}: {action} ({err})");
12234                }
12235            }
12236        }
12237        if spawned {
12238            println!(
12239                "wire upgrade: spawned fresh daemon (pid {} v{})",
12240                new_pid
12241                    .map(|p| p.to_string())
12242                    .unwrap_or_else(|| "?".to_string()),
12243                new_version.as_deref().unwrap_or(&cli_version),
12244            );
12245        } else {
12246            println!("wire upgrade: daemon was already running on current binary");
12247        }
12248        if !session_respawns.is_empty() {
12249            println!(
12250                "wire upgrade: refreshed {} session daemon(s):",
12251                session_respawns.len()
12252            );
12253            for r in &session_respawns {
12254                let h = r["session_home"].as_str().unwrap_or("?");
12255                let s = r["status"].as_str().unwrap_or("?");
12256                let label = std::path::Path::new(h)
12257                    .file_name()
12258                    .map(|f| f.to_string_lossy().into_owned())
12259                    .unwrap_or_else(|| h.to_string());
12260                println!("  {label:<24} {s}");
12261            }
12262        }
12263        if let Some(msg) = &path_warning {
12264            eprintln!("wire upgrade: {msg}");
12265        }
12266        // v0.14.x: warn the operator about MCP-server subprocesses we
12267        // can't swap. Without this line, sister Claude tabs silently
12268        // keep running pre-upgrade code until each one explicitly
12269        // `/mcp` reconnects — leading to "fix shipped but my sister
12270        // session still shows the old behavior" support pings (the
12271        // very pattern that surfaced this gap, fixed by #117 toast
12272        // kill switch and now visible because of #114/#115 read-path
12273        // landings).
12274        if !mcp_pids.is_empty() {
12275            let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12276            eprintln!(
12277                "wire upgrade: NOTE — {} `wire mcp` server subprocess(es) [{}] still on pre-upgrade code (Claude Code / Claude.app pin these at session start). Each Claude tab must `/mcp` reconnect (or restart the host app) to pick up the new binary.",
12278                mcp_pids.len(),
12279                p.join(", ")
12280            );
12281        }
12282    }
12283    Ok(())
12284}
12285
12286/// v0.9.1: should this command emit JSON by default?
12287///
12288/// - `explicit=true` → operator passed `--json`, always JSON.
12289/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
12290///   captured output parses cleanly without operators remembering to
12291///   append `--json`. Mirrors `gh`, `kubectl`, etc.
12292/// - interactive TTY → human format (false).
12293/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
12294///   that parsed the human text by accident).
12295fn json_default(explicit: bool) -> bool {
12296    if explicit {
12297        return true;
12298    }
12299    if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
12300        return false;
12301    }
12302    use std::io::IsTerminal;
12303    !std::io::stdout().is_terminal()
12304}
12305
12306fn process_alive_pid(pid: u32) -> bool {
12307    // v0.7.3: delegate to the cross-platform helper. See
12308    // `platform::process_alive` for the per-OS dispatch — Windows now
12309    // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
12310    // gave a hard-coded false on Windows pre-v0.7.3.
12311    crate::platform::process_alive(pid)
12312}
12313
12314// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
12315
12316/// Iterative Levenshtein distance between two strings, case-insensitive.
12317/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
12318/// resolves against (typically <30 chars).
12319fn levenshtein_ci(a: &str, b: &str) -> usize {
12320    let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
12321    let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
12322    let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
12323    let (m, n) = (a.len(), b.len());
12324    if m == 0 {
12325        return n;
12326    }
12327    let mut prev: Vec<usize> = (0..=m).collect();
12328    let mut curr = vec![0usize; m + 1];
12329    for j in 1..=n {
12330        curr[0] = j;
12331        for i in 1..=m {
12332            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
12333            curr[i] = std::cmp::min(
12334                std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
12335                prev[i - 1] + cost,
12336            );
12337        }
12338        std::mem::swap(&mut prev, &mut curr);
12339    }
12340    prev[m]
12341}
12342
12343/// Return up to `max_results` names from `pool` whose edit distance to
12344/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
12345/// "did you mean" suggestions on resolution miss.
12346pub fn closest_candidates(
12347    needle: &str,
12348    pool: &[String],
12349    max_distance: usize,
12350    max_results: usize,
12351) -> Vec<String> {
12352    let mut scored: Vec<(usize, &String)> = pool
12353        .iter()
12354        .map(|c| (levenshtein_ci(needle, c), c))
12355        .filter(|(d, _)| *d <= max_distance)
12356        .collect();
12357    scored.sort_by_key(|(d, _)| *d);
12358    scored
12359        .into_iter()
12360        .take(max_results)
12361        .map(|(_, c)| c.clone())
12362        .collect()
12363}
12364
12365/// Collect every name that `resolve_name_to_target` would currently
12366/// match: pinned-peer handles, pinned-peer character nicknames, sister
12367/// session names, sister character nicknames, sister handles. Used for
12368/// the `did_you_mean` pool on resolution miss.
12369fn known_local_names() -> Vec<String> {
12370    let mut names: Vec<String> = Vec::new();
12371    if let Ok(trust) = config::read_trust() {
12372        // (debug eprintln removed; left bug-trail in commit message)
12373        // trust.agents is an object keyed by handle, NOT an array —
12374        // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
12375        // the object's keys (which ARE the handles) plus each entry's
12376        // did for the DID-derived character nickname.
12377        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
12378            for (handle, agent) in agents {
12379                names.push(handle.clone());
12380                if let Some(did) = agent.get("did").and_then(Value::as_str) {
12381                    let ch = crate::character::Character::from_did(did);
12382                    names.push(ch.nickname);
12383                }
12384            }
12385        }
12386    }
12387    if let Ok(sessions) = crate::session::list_sessions() {
12388        for s in sessions {
12389            names.push(s.name.clone());
12390            if let Some(h) = &s.handle {
12391                names.push(h.clone());
12392            }
12393            if let Some(ch) = &s.character {
12394                names.push(ch.nickname.clone());
12395            }
12396        }
12397    }
12398    names.sort();
12399    names.dedup();
12400    names
12401}
12402
12403/// v0.9.2 deprecation banner with two ergonomic guards:
12404/// 1. Suppress in JSON mode (the caller is expected to fold the
12405///    deprecation note into its JSON output instead).
12406/// 2. Cache once-per-shell-session via a marker env var; subsequent
12407///    invocations in the same shell stay silent.
12408///
12409/// `verb` is the legacy verb name, `replacement` is the canonical one.
12410fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
12411    if json_mode {
12412        return;
12413    }
12414    // Pull a marker from environment of THIS process. Persistent across
12415    // multiple wire invocations only when the shell sets and exports
12416    // WIRE_DEPRECATION_NAGGED — operators rarely do, so practically
12417    // this nags once per `wire foo` invocation. The single-process
12418    // dedup matters most for scripts that call multiple deprecated
12419    // verbs in one wire run, which is currently impossible (one verb
12420    // per process) but documented for future loop-style wire shells.
12421    let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
12422    if std::env::var(&key).is_ok() {
12423        return;
12424    }
12425    // SAFETY: deprecation_warn is called from sync dispatcher code paths
12426    // before any worker thread spawns; env::set_var in Rust 2024 is
12427    // safe at that point. Pattern matches maybe_adopt_session_wire_home.
12428    unsafe {
12429        std::env::set_var(&key, "1");
12430    }
12431    eprintln!(
12432        "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
12433         Will be removed in v1.0 (target 2026-Q3). \
12434         Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
12435        verb.replace('-', "_")
12436    );
12437}
12438
12439// ---------- doctor (single-command diagnostic) ----------
12440
12441/// One DoctorCheck = one verdict on one health dimension.
12442#[derive(Clone, Debug, serde::Serialize)]
12443pub struct DoctorCheck {
12444    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
12445    /// Stable across versions for tooling consumption.
12446    pub id: String,
12447    /// PASS / WARN / FAIL.
12448    pub status: String,
12449    /// One-line human summary.
12450    pub detail: String,
12451    /// Optional remediation hint shown after the failing line.
12452    #[serde(skip_serializing_if = "Option::is_none")]
12453    pub fix: Option<String>,
12454}
12455
12456impl DoctorCheck {
12457    fn pass(id: &str, detail: impl Into<String>) -> Self {
12458        Self {
12459            id: id.into(),
12460            status: "PASS".into(),
12461            detail: detail.into(),
12462            fix: None,
12463        }
12464    }
12465    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12466        Self {
12467            id: id.into(),
12468            status: "WARN".into(),
12469            detail: detail.into(),
12470            fix: Some(fix.into()),
12471        }
12472    }
12473    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12474        Self {
12475            id: id.into(),
12476            status: "FAIL".into(),
12477            detail: detail.into(),
12478            fix: Some(fix.into()),
12479        }
12480    }
12481}
12482
12483/// `wire doctor` — single-command diagnostic for the silent-fail classes
12484/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
12485/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
12486/// so operators don't have to know where each lives.
12487fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
12488    let checks: Vec<DoctorCheck> = vec![
12489        check_daemon_health(),
12490        check_daemon_pid_consistency(),
12491        check_relay_reachable(),
12492        check_pair_rejections(recent_rejections),
12493        check_cursor_progress(),
12494        check_peer_staleness(7),
12495        check_and_heal_self_userinfo_endpoints(),
12496    ];
12497
12498    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
12499    let warns = checks.iter().filter(|c| c.status == "WARN").count();
12500
12501    if as_json {
12502        println!(
12503            "{}",
12504            serde_json::to_string(&json!({
12505                "checks": checks,
12506                "fail_count": fails,
12507                "warn_count": warns,
12508                "ok": fails == 0,
12509            }))?
12510        );
12511    } else {
12512        println!("wire doctor — {} checks", checks.len());
12513        for c in &checks {
12514            let bullet = match c.status.as_str() {
12515                "PASS" => "✓",
12516                "WARN" => "!",
12517                "FAIL" => "✗",
12518                _ => "?",
12519            };
12520            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
12521            if let Some(fix) = &c.fix {
12522                println!("      fix: {fix}");
12523            }
12524        }
12525        println!();
12526        if fails == 0 && warns == 0 {
12527            println!("ALL GREEN");
12528        } else {
12529            println!("{fails} FAIL, {warns} WARN");
12530        }
12531    }
12532
12533    if fails > 0 {
12534        std::process::exit(1);
12535    }
12536    Ok(())
12537}
12538
12539/// Check: daemon running, exactly one instance, no orphans.
12540///
12541/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
12542/// days, advancing cursor without pinning). `wire status` lied about it.
12543/// `wire doctor` must catch THIS class: multiple daemons running, OR
12544/// pid-file claims daemon down while a process is actually up.
12545fn check_daemon_health() -> DoctorCheck {
12546    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
12547    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
12548    // hardening): every surface routes through ensure_up::daemon_liveness
12549    // so they share one view of the world. No more parallel liveness
12550    // logic to drift out of sync.
12551    let snap = crate::ensure_up::daemon_liveness();
12552    let pgrep_pids = &snap.pgrep_pids;
12553    let pidfile_pid = snap.pidfile_pid;
12554    let pidfile_alive = snap.pidfile_alive;
12555    let orphan_pids = &snap.orphan_pids;
12556
12557    let fmt_pids = |xs: &[u32]| -> String {
12558        xs.iter()
12559            .map(|p| p.to_string())
12560            .collect::<Vec<_>>()
12561            .join(", ")
12562    };
12563
12564    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
12565        (0, _, _) => DoctorCheck::fail(
12566            "daemon",
12567            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
12568            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
12569        ),
12570        // Single daemon AND it matches the pidfile → healthy.
12571        (1, true, true) => DoctorCheck::pass(
12572            "daemon",
12573            format!(
12574                "one daemon running (pid {}, matches pidfile)",
12575                pgrep_pids[0]
12576            ),
12577        ),
12578        // Pidfile is alive but pgrep ALSO sees orphan processes.
12579        (n, true, false) => DoctorCheck::fail(
12580            "daemon",
12581            format!(
12582                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
12583                 The orphans race the relay cursor — they advance past events your current binary can't process. \
12584                 (Issue #2 exact class.)",
12585                fmt_pids(pgrep_pids),
12586                pidfile_pid.unwrap(),
12587                fmt_pids(orphan_pids),
12588            ),
12589            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
12590        ),
12591        // Pidfile is dead but processes ARE running → all are orphans.
12592        (n, false, _) => DoctorCheck::fail(
12593            "daemon",
12594            format!(
12595                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
12596                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
12597                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
12598                fmt_pids(pgrep_pids),
12599                match pidfile_pid {
12600                    Some(p) => format!("claims pid {p} which is dead"),
12601                    None => "is missing".to_string(),
12602                },
12603            ),
12604            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
12605        ),
12606        // Multiple daemons all matching … impossible by construction; fall back to warn.
12607        (n, true, true) => DoctorCheck::warn(
12608            "daemon",
12609            format!(
12610                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
12611                fmt_pids(pgrep_pids)
12612            ),
12613            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
12614        ),
12615    }
12616}
12617
12618/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
12619/// check. Surfaces version mismatch (daemon running old binary text in
12620/// memory under a current symlink — today's exact bug class), schema
12621/// drift (future format bumps), and identity contamination (daemon's
12622/// recorded DID doesn't match this box's configured DID).
12623///
12624/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
12625/// JSON pid record whose recorded `pid` is no longer a live OS process.
12626/// Pre-hardening this check PASSed in that state (it only validated
12627/// content, not liveness), letting `wire status: DOWN` and
12628/// `wire doctor: PASS` disagree for 25 min in incident #2.
12629fn check_daemon_pid_consistency() -> DoctorCheck {
12630    let snap = crate::ensure_up::daemon_liveness();
12631    match &snap.record {
12632        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
12633            "daemon_pid_consistency",
12634            "no daemon.pid yet — fresh box or daemon never started",
12635        ),
12636        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
12637            "daemon_pid_consistency",
12638            format!("daemon.pid is corrupt: {reason}"),
12639            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
12640        ),
12641        crate::ensure_up::PidRecord::LegacyInt(pid) => {
12642            // Legacy pidfile: still surface liveness so a dead legacy pid
12643            // doesn't quietly PASS this check while status says DOWN.
12644            let pid = *pid;
12645            if !crate::ensure_up::pid_is_alive(pid) {
12646                return DoctorCheck::warn(
12647                    "daemon_pid_consistency",
12648                    format!(
12649                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
12650                         Stale pidfile from a crashed pre-0.5.11 daemon. \
12651                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
12652                    ),
12653                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
12654                );
12655            }
12656            DoctorCheck::warn(
12657                "daemon_pid_consistency",
12658                format!(
12659                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
12660                     Daemon was started by a pre-0.5.11 binary."
12661                ),
12662                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
12663            )
12664        }
12665        crate::ensure_up::PidRecord::Json(d) => {
12666            // v0.5.19 liveness gate: if the recorded pid is dead, the
12667            // pidfile is stale and the rest of the content drift checks
12668            // are moot — `wire upgrade` is the answer regardless.
12669            if !snap.pidfile_alive {
12670                return DoctorCheck::warn(
12671                    "daemon_pid_consistency",
12672                    format!(
12673                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
12674                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
12675                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
12676                        pid = d.pid,
12677                        version = d.version,
12678                    ),
12679                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
12680                     (kills any orphan daemon advancing the cursor without coordination)",
12681                );
12682            }
12683            let mut issues: Vec<String> = Vec::new();
12684            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
12685                issues.push(format!(
12686                    "schema={} (expected {})",
12687                    d.schema,
12688                    crate::ensure_up::DAEMON_PID_SCHEMA
12689                ));
12690            }
12691            let cli_version = env!("CARGO_PKG_VERSION");
12692            if d.version != cli_version {
12693                issues.push(format!("version daemon={} cli={cli_version}", d.version));
12694            }
12695            if !std::path::Path::new(&d.bin_path).exists() {
12696                issues.push(format!("bin_path {} missing on disk", d.bin_path));
12697            }
12698            // Cross-check DID + relay against current config (best-effort).
12699            if let Ok(card) = config::read_agent_card()
12700                && let Some(current_did) = card.get("did").and_then(Value::as_str)
12701                && let Some(recorded_did) = &d.did
12702                && recorded_did != current_did
12703            {
12704                issues.push(format!(
12705                    "did daemon={recorded_did} config={current_did} — identity drift"
12706                ));
12707            }
12708            if let Ok(state) = config::read_relay_state()
12709                && let Some(current_relay) = state
12710                    .get("self")
12711                    .and_then(|s| s.get("relay_url"))
12712                    .and_then(Value::as_str)
12713                && let Some(recorded_relay) = &d.relay_url
12714                && recorded_relay != current_relay
12715            {
12716                issues.push(format!(
12717                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
12718                ));
12719            }
12720            if issues.is_empty() {
12721                DoctorCheck::pass(
12722                    "daemon_pid_consistency",
12723                    format!(
12724                        "daemon v{} bound to {} as {}",
12725                        d.version,
12726                        d.relay_url.as_deref().unwrap_or("?"),
12727                        d.did.as_deref().unwrap_or("?")
12728                    ),
12729                )
12730            } else {
12731                DoctorCheck::warn(
12732                    "daemon_pid_consistency",
12733                    format!("daemon pidfile drift: {}", issues.join("; ")),
12734                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
12735                )
12736            }
12737        }
12738    }
12739}
12740
12741/// Check: bound relay's /healthz returns 200.
12742fn check_relay_reachable() -> DoctorCheck {
12743    let state = match config::read_relay_state() {
12744        Ok(s) => s,
12745        Err(e) => {
12746            return DoctorCheck::fail(
12747                "relay",
12748                format!("could not read relay state: {e}"),
12749                "run `wire up <handle>@<relay>` to bootstrap",
12750            );
12751        }
12752    };
12753    let url = state
12754        .get("self")
12755        .and_then(|s| s.get("relay_url"))
12756        .and_then(Value::as_str)
12757        .unwrap_or("");
12758    if url.is_empty() {
12759        return DoctorCheck::warn(
12760            "relay",
12761            "no relay bound — wire send/pull will not work",
12762            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
12763        );
12764    }
12765    let client = crate::relay_client::RelayClient::new(url);
12766    match client.check_healthz() {
12767        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
12768        Err(e) => DoctorCheck::fail(
12769            "relay",
12770            format!("{url} unreachable: {e}"),
12771            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
12772        ),
12773    }
12774}
12775
12776/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
12777/// entry there is a silent failure that, pre-0.5.11, would have left the
12778/// operator wondering why pairing didn't complete.
12779fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
12780    let path = match config::state_dir() {
12781        Ok(d) => d.join("pair-rejected.jsonl"),
12782        Err(e) => {
12783            return DoctorCheck::warn(
12784                "pair_rejections",
12785                format!("could not resolve state dir: {e}"),
12786                "set WIRE_HOME or fix XDG_STATE_HOME",
12787            );
12788        }
12789    };
12790    if !path.exists() {
12791        return DoctorCheck::pass(
12792            "pair_rejections",
12793            "no pair-rejected.jsonl — no recorded pair failures",
12794        );
12795    }
12796    let body = match std::fs::read_to_string(&path) {
12797        Ok(b) => b,
12798        Err(e) => {
12799            return DoctorCheck::warn(
12800                "pair_rejections",
12801                format!("could not read {path:?}: {e}"),
12802                "check file permissions",
12803            );
12804        }
12805    };
12806    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
12807    if lines.is_empty() {
12808        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
12809    }
12810    let total = lines.len();
12811    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
12812    let mut summary: Vec<String> = Vec::new();
12813    for line in &recent {
12814        if let Ok(rec) = serde_json::from_str::<Value>(line) {
12815            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
12816            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
12817            summary.push(format!("{peer}/{code}"));
12818        }
12819    }
12820    DoctorCheck::warn(
12821        "pair_rejections",
12822        format!(
12823            "{total} pair failures recorded. recent: [{}]",
12824            summary.join(", ")
12825        ),
12826        format!(
12827            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
12828        ),
12829    )
12830}
12831
12832/// Check: cursor isn't stuck. We can't tell without polling — but we can
12833/// report the current cursor position so operators see if it changes.
12834/// Real "stuck" detection needs two pulls separated in time; defer that
12835/// behaviour to a `wire doctor --watch` mode.
12836///
12837/// Heal stale userinfo from this agent's own published relay endpoints.
12838///
12839/// Failure mode this check closes:
12840///   PR #61 added a guard at the WRITE side that prevents NEW userinfo-
12841///   bearing endpoints (`https://<handle>@<host>`) from ever being
12842///   persisted or published. But operators who ran a pre-#61 `wire up
12843///   <handle>@<relay>` already had the malformed endpoint baked into
12844///   their on-disk `self.endpoints[]` AND their signed agent-card AND
12845///   their phonebook entry. The fix prevented the bleeding; it didn't
12846///   heal the wound. Symptoms still visible:
12847///     - Every inbound POST to the malformed endpoint (pair_drop_ack,
12848///       messages) gets a Cloudflare 400 ("missing Bearer token" /
12849///       bare 400). Peers running pre-#62 wire can't deliver to us at
12850///       all (the failover from #62 lets newer peers walk past the
12851///       bad first endpoint to a clean one if both are published —
12852///       but two-endpoint operators still get a 400 for every event
12853///       on their FIRST attempt, and operators with only the
12854///       malformed endpoint are unreachable).
12855///     - `wire pull` from our own malformed slot 400s on every cycle
12856///       (the operator sees a stderr error line every poll).
12857///     - Surfaced concretely when swift-harbor ↔ slate-lotus paired
12858///       2026-05-27: slate-lotus's pair_drop_ack 400'd; my own pulls
12859///       400'd; bilateral handshake couldn't complete via the bad
12860///       endpoint.
12861///
12862/// This is a healable failure mode — the same `strip_relay_url_userinfo`
12863/// logic from #61 can be applied to existing on-disk state. We do it
12864/// inside `wire doctor` (rather than a separate `wire heal` command)
12865/// because:
12866///   1. `wire doctor` is the canonical "what's wrong + fix it" surface
12867///      operators already know to run when something looks off.
12868///   2. The mutation is unambiguously correct — userinfo on a self-
12869///      published relay endpoint has zero legitimate cases (the
12870///      one-name rule means the handle is DID-derived, never URL
12871///      userinfo).
12872///   3. Auto-heal is consistent with what `wire bind-relay https://...`
12873///      / `wire claim` already do at the WRITE side under #61 —
12874///      this just extends the same guard to read-side cleanup.
12875///
12876/// What this check does:
12877///   - Reads `relay.json` and inspects `self.endpoints[]` plus the
12878///     legacy top-level `self.relay_url`/`slot_id`/`slot_token` triple.
12879///   - If any endpoint's `relay_url` contains userinfo, removes that
12880///     endpoint from the array AND (if the legacy top-level was the
12881///     malformed one) promotes the first clean endpoint's coords to
12882///     the legacy slots.
12883///   - Atomically writes back via `write_relay_state` (full lock +
12884///     tmp+rename, same path every other writer uses).
12885///   - Reports PASS if nothing needed healing, WARN if healing happened
12886///     (with the list of stripped URLs + a remediation pointer to
12887///     `wire claim <persona>` for re-publishing the agent-card to the
12888///     phonebook).
12889///
12890/// Re-claim is NOT auto-run here: the doctor check is read-state-bound,
12891/// and `wire claim` requires a clean agent-card resign + network
12892/// round-trip + persona arg. Operators get the explicit next step in
12893/// the WARN fix text. Two-step is the right friction: heal silently,
12894/// claim explicitly.
12895fn check_and_heal_self_userinfo_endpoints() -> DoctorCheck {
12896    let mut state = match config::read_relay_state() {
12897        Ok(s) => s,
12898        Err(_) => {
12899            return DoctorCheck::pass(
12900                "self-userinfo-endpoints",
12901                "no relay state yet — nothing published to heal".to_string(),
12902            );
12903        }
12904    };
12905    let self_block = match state.get_mut("self").and_then(Value::as_object_mut) {
12906        Some(s) => s,
12907        None => {
12908            return DoctorCheck::pass(
12909                "self-userinfo-endpoints",
12910                "no self block in relay state — nothing published to heal".to_string(),
12911            );
12912        }
12913    };
12914
12915    let mut stripped: Vec<String> = Vec::new();
12916    let mut clean_seed: Option<(String, String, String)> = None;
12917
12918    if let Some(endpoints) = self_block
12919        .get_mut("endpoints")
12920        .and_then(Value::as_array_mut)
12921    {
12922        endpoints.retain(|ep| {
12923            let url = ep.get("relay_url").and_then(Value::as_str).unwrap_or("");
12924            // Reuse the exact same authority-only userinfo detection as
12925            // #61's assert_relay_url_clean_for_publish so any future
12926            // change to that authority parse stays in lockstep.
12927            if assert_relay_url_clean_for_publish(url).is_err() {
12928                stripped.push(url.to_string());
12929                false
12930            } else {
12931                if clean_seed.is_none() {
12932                    clean_seed = Some((
12933                        url.to_string(),
12934                        ep.get("slot_id")
12935                            .and_then(Value::as_str)
12936                            .unwrap_or("")
12937                            .to_string(),
12938                        ep.get("slot_token")
12939                            .and_then(Value::as_str)
12940                            .unwrap_or("")
12941                            .to_string(),
12942                    ));
12943                }
12944                true
12945            }
12946        });
12947    }
12948
12949    // Heal the legacy top-level relay_url/slot_id/slot_token triple if it
12950    // was the malformed one. Without this, v0.5.16-era readers (and the
12951    // pair_drop_ack path that falls back to legacy fields) still pick up
12952    // the userinfo URL even after we cleaned endpoints[].
12953    let mut legacy_healed = false;
12954    let legacy_url = self_block
12955        .get("relay_url")
12956        .and_then(Value::as_str)
12957        .unwrap_or("")
12958        .to_string();
12959    if !legacy_url.is_empty() && assert_relay_url_clean_for_publish(&legacy_url).is_err() {
12960        if let Some((url, sid, tok)) = &clean_seed {
12961            self_block.insert("relay_url".to_string(), Value::String(url.clone()));
12962            self_block.insert("slot_id".to_string(), Value::String(sid.clone()));
12963            self_block.insert("slot_token".to_string(), Value::String(tok.clone()));
12964            legacy_healed = true;
12965            stripped.push(format!("(legacy top-level) {legacy_url}"));
12966        } else {
12967            // No clean endpoint exists to promote — the operator only
12968            // has malformed endpoints. We can't auto-heal this safely
12969            // (would leave them with no inbox); surface as WARN with
12970            // explicit re-bind instructions and DON'T mutate.
12971            return DoctorCheck::warn(
12972                "self-userinfo-endpoints",
12973                format!(
12974                    "your published endpoint is malformed (`{legacy_url}` — handle as URL \
12975                     userinfo, the bug PR #61 prevents going forward) AND no clean endpoint \
12976                     exists to fall back to. Inbound POSTs to this endpoint 4xx; bilateral \
12977                     pairing can't complete."
12978                ),
12979                "Bind a clean federation slot first, then re-run doctor to heal: \
12980                 `wire bind-relay https://wireup.net` (or your own relay). The bind \
12981                 adds a clean endpoint additively; the next `wire doctor` run then \
12982                 strips the malformed one safely. Finally re-publish your card with \
12983                 `wire claim <your-persona>` so the phonebook serves the clean shape."
12984                    .to_string(),
12985            );
12986        }
12987    }
12988
12989    if stripped.is_empty() && !legacy_healed {
12990        return DoctorCheck::pass(
12991            "self-userinfo-endpoints",
12992            "no malformed endpoints in self-state".to_string(),
12993        );
12994    }
12995
12996    // Persist the healed state. Best-effort: if the write fails, the
12997    // operator still sees the WARN and can run `wire claim` to re-publish;
12998    // they keep the malformed entry on disk until the next doctor cycle.
12999    if let Err(e) = config::write_relay_state(&state) {
13000        return DoctorCheck::warn(
13001            "self-userinfo-endpoints",
13002            format!(
13003                "detected {} malformed userinfo-bearing endpoint(s) in self-state but \
13004                 failed to persist the heal: {e:#}. Found: {}",
13005                stripped.len(),
13006                stripped.join(", ")
13007            ),
13008            "re-run `wire doctor` — likely a transient lock contention".to_string(),
13009        );
13010    }
13011
13012    DoctorCheck::warn(
13013        "self-userinfo-endpoints",
13014        format!(
13015            "healed {} malformed endpoint(s) in self-state on disk: {}. \
13016             These were the `https://<handle>@<host>` shape that PR #61 prevents \
13017             at the write side but couldn't retroactively scrub from existing \
13018             operators. relay.json is now clean.",
13019            stripped.len(),
13020            stripped.join(", ")
13021        ),
13022        "re-publish your agent-card to the phonebook so peers resolve to the \
13023         clean endpoint: `wire claim <your-persona>` (find your persona with \
13024         `wire whoami`)."
13025            .to_string(),
13026    )
13027}
13028
13029fn check_peer_staleness(max_silent_days: u64) -> DoctorCheck {
13030    let state = match config::read_relay_state() {
13031        Ok(s) => s,
13032        Err(_) => {
13033            return DoctorCheck::pass(
13034                "peer-staleness",
13035                "no relay state yet — nothing pinned to check".to_string(),
13036            );
13037        }
13038    };
13039    let peers = match state.get("peers").and_then(Value::as_object) {
13040        Some(p) => p,
13041        None => {
13042            return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
13043        }
13044    };
13045    if peers.is_empty() {
13046        return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
13047    }
13048    let inbox_dir = match config::inbox_dir() {
13049        Ok(d) => d,
13050        Err(_) => {
13051            return DoctorCheck::warn(
13052                "peer-staleness",
13053                "could not resolve inbox dir; skipping peer-staleness check".to_string(),
13054                "check `wire status` for state-dir resolution".to_string(),
13055            );
13056        }
13057    };
13058    let threshold = std::time::Duration::from_secs(max_silent_days * 24 * 60 * 60);
13059    let now = std::time::SystemTime::now();
13060    let mut stale: Vec<(String, u64, &'static str)> = Vec::new();
13061    for (peer, _info) in peers {
13062        let path = inbox_dir.join(format!("{peer}.jsonl"));
13063        let (age_days, kind) = match std::fs::metadata(&path) {
13064            Ok(meta) => match meta
13065                .modified()
13066                .ok()
13067                .and_then(|m| now.duration_since(m).ok())
13068            {
13069                Some(d) if d > threshold => (d.as_secs() / (24 * 60 * 60), "silent"),
13070                Some(_) => continue, // fresh — not stale
13071                None => (0, "unknown-mtime"),
13072            },
13073            Err(_) => (max_silent_days + 1, "no-inbox-file"),
13074        };
13075        stale.push((peer.clone(), age_days, kind));
13076    }
13077    if stale.is_empty() {
13078        return DoctorCheck::pass(
13079            "peer-staleness",
13080            format!(
13081                "all {} pinned peer(s) have inbox traffic within the last {max_silent_days} day(s)",
13082                peers.len()
13083            ),
13084        );
13085    }
13086    let detail = stale
13087        .iter()
13088        .map(|(p, d, k)| match *k {
13089            "no-inbox-file" => format!("{p} (no inbox file)"),
13090            "unknown-mtime" => format!("{p} (unknown last-event time)"),
13091            _ => format!("{p} ({d}d silent)"),
13092        })
13093        .collect::<Vec<_>>()
13094        .join(", ");
13095    DoctorCheck::warn(
13096        "peer-staleness",
13097        format!(
13098            "{} pinned peer(s) silent for >{max_silent_days}d: {detail}. \
13099             If the peer re-bound their relay slot, our pin is now stale — \
13100             we push successfully to a dead slot and they never see us \
13101             (asymmetric failure, both sides report green).",
13102            stale.len()
13103        ),
13104        "re-pair with `wire add <peer>@<relay>` to refresh the slot. \
13105         Once issue #15 lands, this also auto-resolves on 410 Gone."
13106            .to_string(),
13107    )
13108}
13109
13110fn check_cursor_progress() -> DoctorCheck {
13111    let state = match config::read_relay_state() {
13112        Ok(s) => s,
13113        Err(e) => {
13114            return DoctorCheck::warn(
13115                "cursor",
13116                format!("could not read relay state: {e}"),
13117                "check ~/Library/Application Support/wire/relay.json",
13118            );
13119        }
13120    };
13121    let cursor = state
13122        .get("self")
13123        .and_then(|s| s.get("last_pulled_event_id"))
13124        .and_then(Value::as_str)
13125        .map(|s| s.chars().take(16).collect::<String>())
13126        .unwrap_or_else(|| "<none>".to_string());
13127    DoctorCheck::pass(
13128        "cursor",
13129        format!(
13130            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
13131        ),
13132    )
13133}
13134
13135#[cfg(test)]
13136mod doctor_tests {
13137    use super::*;
13138
13139    #[test]
13140    fn doctor_check_constructors_set_status_correctly() {
13141        // Silent-fail-prevention rule: pass/warn/fail must be visibly
13142        // distinguishable to operators. If any constructor lets the wrong
13143        // status through, `wire doctor` lies and we're back to today's
13144        // 30-minute debug.
13145        let p = DoctorCheck::pass("x", "ok");
13146        assert_eq!(p.status, "PASS");
13147        assert_eq!(p.fix, None);
13148
13149        let w = DoctorCheck::warn("x", "watch out", "do this");
13150        assert_eq!(w.status, "WARN");
13151        assert_eq!(w.fix, Some("do this".to_string()));
13152
13153        let f = DoctorCheck::fail("x", "broken", "fix it");
13154        assert_eq!(f.status, "FAIL");
13155        assert_eq!(f.fix, Some("fix it".to_string()));
13156    }
13157
13158    #[test]
13159    fn check_pair_rejections_no_file_is_pass() {
13160        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
13161        // as a problem.
13162        config::test_support::with_temp_home(|| {
13163            config::ensure_dirs().unwrap();
13164            let c = check_pair_rejections(5);
13165            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
13166        });
13167    }
13168
13169    #[test]
13170    fn check_pair_rejections_with_entries_warns() {
13171        // Existence of rejections is itself a signal — even if each entry
13172        // is a "known good failure," the operator wants to know they
13173        // happened.
13174        config::test_support::with_temp_home(|| {
13175            config::ensure_dirs().unwrap();
13176            crate::pair_invite::record_pair_rejection(
13177                "willard",
13178                "pair_drop_ack_send_failed",
13179                "POST 502",
13180            );
13181            let c = check_pair_rejections(5);
13182            assert_eq!(c.status, "WARN");
13183            assert!(c.detail.contains("1 pair failures"));
13184            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
13185        });
13186    }
13187
13188    #[test]
13189    fn check_peer_staleness_no_peers_is_pass() {
13190        // Fresh box / no pin yet: must NOT report this as a problem
13191        // (nothing to be stale about).
13192        config::test_support::with_temp_home(|| {
13193            config::ensure_dirs().unwrap();
13194            let c = check_peer_staleness(7);
13195            assert_eq!(c.status, "PASS", "no peers should be PASS, got {c:?}");
13196        });
13197    }
13198
13199    #[test]
13200    fn check_peer_staleness_pinned_with_no_inbox_file_warns() {
13201        // Issue #14 asymmetric-stale-pin: peer is pinned but we've NEVER
13202        // received an event from them (no inbox file at all). That's
13203        // exactly the "we pushed N events, got 0 back" smell the WARN is
13204        // designed to catch.
13205        config::test_support::with_temp_home(|| {
13206            config::ensure_dirs().unwrap();
13207            // Seed a pinned peer with no corresponding inbox file.
13208            let mut state = json!({
13209                "peers": {
13210                    "stale-peer": {
13211                        "relay_url": "https://wireup.net",
13212                        "slot_id": "deadslot",
13213                        "slot_token": "tok",
13214                    }
13215                }
13216            });
13217            state["self"] = json!({});
13218            config::write_relay_state(&state).unwrap();
13219
13220            let c = check_peer_staleness(7);
13221            assert_eq!(
13222                c.status, "WARN",
13223                "pinned peer with no inbox file must surface: {c:?}"
13224            );
13225            assert!(
13226                c.detail.contains("stale-peer"),
13227                "WARN must name the silent peer so the operator can act: {}",
13228                c.detail
13229            );
13230            assert!(
13231                c.detail.contains("asymmetric")
13232                    || c.detail.contains("stale")
13233                    || c.detail.contains("dead slot"),
13234                "WARN must surface the failure-mode language so the operator \
13235                 finds the diagnosis without re-tracing: {}",
13236                c.detail
13237            );
13238            assert!(
13239                c.fix
13240                    .as_ref()
13241                    .is_some_and(|f| f.contains("wire add") && f.contains("#15")),
13242                "fix pointer must reference both the manual re-pair AND the \
13243                 follow-up issue (#15) that will automate this: {:?}",
13244                c.fix
13245            );
13246        });
13247    }
13248
13249    #[test]
13250    fn check_peer_staleness_pinned_with_fresh_inbox_is_pass() {
13251        // Negative case: pinned peer with a recent inbox event must NOT
13252        // be reported. This prevents the false-positive that would otherwise
13253        // make operators ignore the WARN.
13254        config::test_support::with_temp_home(|| {
13255            config::ensure_dirs().unwrap();
13256            let mut state = json!({
13257                "peers": {
13258                    "active-peer": {
13259                        "relay_url": "https://wireup.net",
13260                        "slot_id": "freshslot",
13261                        "slot_token": "tok",
13262                    }
13263                }
13264            });
13265            state["self"] = json!({});
13266            config::write_relay_state(&state).unwrap();
13267
13268            let inbox = config::inbox_dir().unwrap();
13269            std::fs::create_dir_all(&inbox).unwrap();
13270            std::fs::write(
13271                inbox.join("active-peer.jsonl"),
13272                "{\"event_id\":\"recent\"}\n",
13273            )
13274            .unwrap();
13275
13276            let c = check_peer_staleness(7);
13277            assert_eq!(c.status, "PASS", "fresh inbox should not warn: {c:?}");
13278        });
13279    }
13280
13281    #[test]
13282    fn check_self_userinfo_no_state_is_pass() {
13283        // Fresh box (no relay.json yet) must NOT WARN — there's nothing
13284        // published to heal, and treating a missing file as a problem
13285        // would scare every new operator on first `wire doctor` run.
13286        config::test_support::with_temp_home(|| {
13287            // Don't even call ensure_dirs — simulate truly fresh state.
13288            let c = check_and_heal_self_userinfo_endpoints();
13289            assert_eq!(c.status, "PASS", "no state should be PASS, got {c:?}");
13290        });
13291    }
13292
13293    #[test]
13294    fn check_self_userinfo_clean_state_is_pass_no_mutation() {
13295        // Negative case: clean self.endpoints[] must not trigger a heal,
13296        // must not mutate relay.json. Prevents the false-positive that
13297        // would make operators distrust the doctor.
13298        config::test_support::with_temp_home(|| {
13299            config::ensure_dirs().unwrap();
13300            let state = json!({
13301                "self": {
13302                    "endpoints": [
13303                        {
13304                            "relay_url": "https://wireup.net",
13305                            "scope": "Federation",
13306                            "slot_id": "abc",
13307                            "slot_token": "tok"
13308                        }
13309                    ],
13310                    "relay_url": "https://wireup.net",
13311                    "slot_id": "abc",
13312                    "slot_token": "tok"
13313                },
13314                "peers": {}
13315            });
13316            config::write_relay_state(&state).unwrap();
13317
13318            let c = check_and_heal_self_userinfo_endpoints();
13319            assert_eq!(c.status, "PASS", "clean state should be PASS: {c:?}");
13320
13321            // Verify state is byte-identical (no spurious write).
13322            let after = config::read_relay_state().unwrap();
13323            assert_eq!(after, state, "PASS path must NOT mutate relay.json");
13324        });
13325    }
13326
13327    #[test]
13328    fn check_self_userinfo_heals_malformed_endpoint_and_promotes_clean() {
13329        // THE regression case (swift-harbor / slate-lotus pairing 2026-05-27):
13330        // relay.json has a malformed first endpoint from before #61 AND a
13331        // clean second endpoint from a later `wire bind-relay`. The check
13332        // must (a) strip the malformed one, (b) promote the clean one's
13333        // coords to the legacy top-level triple, (c) write back, (d) emit
13334        // a WARN with the stripped URL + `wire claim` remediation pointer.
13335        config::test_support::with_temp_home(|| {
13336            config::ensure_dirs().unwrap();
13337            let state = json!({
13338                "self": {
13339                    "endpoints": [
13340                        {
13341                            "relay_url": "https://copilot-agent@wireup.net",
13342                            "scope": "Federation",
13343                            "slot_id": "stale-id",
13344                            "slot_token": "stale-token"
13345                        },
13346                        {
13347                            "relay_url": "https://wireup.net",
13348                            "scope": "Federation",
13349                            "slot_id": "clean-id",
13350                            "slot_token": "clean-token"
13351                        }
13352                    ],
13353                    "relay_url": "https://copilot-agent@wireup.net",
13354                    "slot_id": "stale-id",
13355                    "slot_token": "stale-token"
13356                },
13357                "peers": {}
13358            });
13359            config::write_relay_state(&state).unwrap();
13360
13361            let c = check_and_heal_self_userinfo_endpoints();
13362            assert_eq!(c.status, "WARN", "heal should report WARN: {c:?}");
13363            assert!(
13364                c.detail.contains("healed") && c.detail.contains("copilot-agent@wireup.net"),
13365                "WARN must name the stripped URL so the operator sees what changed: {}",
13366                c.detail
13367            );
13368            assert!(
13369                c.fix.as_ref().is_some_and(|f| f.contains("wire claim")),
13370                "fix must point at re-publishing the agent-card so the phonebook entry \
13371                 matches the healed state on disk: {:?}",
13372                c.fix
13373            );
13374
13375            // Verify the file on disk is healed:
13376            //   - endpoints[] contains ONLY the clean entry.
13377            //   - legacy top-level fields promoted from the clean entry.
13378            let after = config::read_relay_state().unwrap();
13379            let endpoints = after["self"]["endpoints"].as_array().unwrap();
13380            assert_eq!(endpoints.len(), 1, "malformed endpoint must be removed");
13381            assert_eq!(endpoints[0]["relay_url"], "https://wireup.net");
13382            assert_eq!(after["self"]["relay_url"], "https://wireup.net");
13383            assert_eq!(after["self"]["slot_id"], "clean-id");
13384            assert_eq!(after["self"]["slot_token"], "clean-token");
13385        });
13386    }
13387
13388    #[test]
13389    fn check_self_userinfo_no_clean_fallback_warns_without_mutating() {
13390        // Edge: operator only has the malformed endpoint, no clean fallback
13391        // to promote. Auto-healing would leave them with NO inbox slot at
13392        // all — strictly worse than the malformed shape (peers can at least
13393        // try the bad endpoint). Check must surface a WARN with explicit
13394        // re-bind instructions and DO NOT touch the state.
13395        config::test_support::with_temp_home(|| {
13396            config::ensure_dirs().unwrap();
13397            let state = json!({
13398                "self": {
13399                    "endpoints": [
13400                        {
13401                            "relay_url": "https://copilot-agent@wireup.net",
13402                            "scope": "Federation",
13403                            "slot_id": "stale-id",
13404                            "slot_token": "stale-token"
13405                        }
13406                    ],
13407                    "relay_url": "https://copilot-agent@wireup.net",
13408                    "slot_id": "stale-id",
13409                    "slot_token": "stale-token"
13410                },
13411                "peers": {}
13412            });
13413            config::write_relay_state(&state).unwrap();
13414
13415            let c = check_and_heal_self_userinfo_endpoints();
13416            assert_eq!(c.status, "WARN");
13417            assert!(
13418                c.fix
13419                    .as_ref()
13420                    .is_some_and(|f| f.contains("wire bind-relay") && f.contains("wire claim")),
13421                "no-clean-fallback fix must require BOTH a clean bind AND a re-claim: {:?}",
13422                c.fix
13423            );
13424
13425            // CRITICAL: state must NOT be mutated (would leave operator with
13426            // no inbox slot). Verify byte-identical.
13427            let after = config::read_relay_state().unwrap();
13428            assert_eq!(
13429                after, state,
13430                "no-clean-fallback path must NOT mutate state (would strand operator)"
13431            );
13432        });
13433    }
13434}
13435
13436// ---------- up megacommand (full bootstrap) ----------
13437
13438/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
13439/// pair. Composes the steps that today's onboarding walks operators through
13440/// one by one (init / bind-relay / claim / background daemon / arm monitor
13441/// recipe). Idempotent: every step checks current state and skips if done.
13442///
13443/// Argument parsing accepts:
13444///   - `<nick>@<relay-host>` — explicit relay
13445///   - `<nick>`              — defaults to wireup.net (the configured
13446///     public relay)
13447fn cmd_up(
13448    relay_arg: Option<&str>,
13449    name: Option<&str>,
13450    with_local: Option<&str>,
13451    no_local: bool,
13452    as_json: bool,
13453) -> Result<()> {
13454    // No nick to parse — your handle is your DID-derived persona (one-name
13455    // rule). The optional arg is only which relay to bind/claim on. Accepts
13456    // `@host`, bare `host`, or a full URL; defaults to the public relay.
13457    let relay_url = match relay_arg {
13458        Some(r) => {
13459            let r = r.trim_start_matches('@');
13460            if r.starts_with("http://") || r.starts_with("https://") {
13461                r.to_string()
13462            } else {
13463                format!("https://{r}")
13464            }
13465        }
13466        None => crate::pair_invite::DEFAULT_RELAY.to_string(),
13467    };
13468
13469    // Strip any URL userinfo (`<handle>@<host>`) before doing any state-
13470    // mutating work — otherwise the malformed endpoint gets persisted in
13471    // `relay_state` AND published in the signed agent-card, where every
13472    // inbound POST to it 4xxes. Mirrors `cmd_up`'s already-bound branch,
13473    // which has always ignored the userinfo on the "keeping existing
13474    // binding" warning path.
13475    let relay_url = strip_relay_url_userinfo(&relay_url);
13476
13477    let mut report: Vec<(String, String)> = Vec::new();
13478    let mut step = |stage: &str, detail: String| {
13479        report.push((stage.to_string(), detail.clone()));
13480        if !as_json {
13481            eprintln!("wire up: {stage} — {detail}");
13482        }
13483    };
13484
13485    // 1. init (or note existing identity). No typed name — cmd_init(None)
13486    // generates the persona from the freshly-minted keypair (one-name rule).
13487    if config::is_initialized()? {
13488        step("init", "already initialized".to_string());
13489    } else {
13490        cmd_init(
13491            None,
13492            name,
13493            Some(&relay_url),
13494            false,
13495            /* as_json */ false,
13496        )?;
13497        step("init", format!("created identity bound to {relay_url}"));
13498    }
13499
13500    // Canonical persona handle — the one name we claim and are addressed by.
13501    let canonical = {
13502        let card = config::read_agent_card()?;
13503        let did = card.get("did").and_then(Value::as_str).unwrap_or("");
13504        crate::agent_card::display_handle_from_did(did).to_string()
13505    };
13506    step("identity", format!("persona is `{canonical}`"));
13507
13508    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
13509    // already initialized we may need to bind to the requested relay
13510    // separately (operator switched relays).
13511    let relay_state = config::read_relay_state()?;
13512    let bound_relay = relay_state
13513        .get("self")
13514        .and_then(|s| s.get("relay_url"))
13515        .and_then(Value::as_str)
13516        .unwrap_or("")
13517        .to_string();
13518    if bound_relay.is_empty() {
13519        // Identity exists but never bound to a relay — bind now.
13520        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
13521        // Pass `false` so the safety check kicks in if state was non-empty.
13522        cmd_bind_relay(
13523            &relay_url, /* scope */ None, // infer from URL (federation for wireup.net)
13524            /* replace */ false, /* migrate_pinned */ false, /* as_json */ false,
13525        )?;
13526        step("bind-relay", format!("bound to {relay_url}"));
13527    } else if bound_relay != relay_url {
13528        step(
13529            "bind-relay",
13530            format!(
13531                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
13532                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
13533            ),
13534        );
13535    } else {
13536        step("bind-relay", format!("already bound to {bound_relay}"));
13537    }
13538
13539    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
13540    // re-claims are accepted by the relay.
13541    match cmd_claim(
13542        &canonical,
13543        Some(&relay_url),
13544        None,
13545        /* hidden */ false,
13546        /* as_json */ false,
13547    ) {
13548        Ok(()) => step(
13549            "claim",
13550            format!("{canonical}@{} claimed", strip_proto(&relay_url)),
13551        ),
13552        Err(e) => step(
13553            "claim",
13554            format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
13555        ),
13556    }
13557
13558    // 3b. Opportunistic local dual-slot (additive). Gives same-box sister
13559    // sessions sub-millisecond loopback routing alongside the federation
13560    // slot. Local relays carry no handle directory — nothing to claim
13561    // there; sister discovery is via `wire session list-local`.
13562    if no_local {
13563        step("local-slot", "skipped (--no-local)".to_string());
13564    } else {
13565        let local_url = with_local
13566            .unwrap_or("http://127.0.0.1:8771")
13567            .trim_end_matches('/');
13568        let already_local = crate::endpoints::self_endpoints(
13569            &config::read_relay_state().unwrap_or_else(|_| json!({})),
13570        )
13571        .iter()
13572        .any(|e| e.relay_url == local_url);
13573        if relay_url.trim_end_matches('/') == local_url || already_local {
13574            step("local-slot", "already covered".to_string());
13575        } else if crate::relay_client::RelayClient::new(local_url)
13576            .check_healthz()
13577            .is_ok()
13578        {
13579            match cmd_bind_relay(
13580                local_url,
13581                Some("local"),
13582                /* replace */ false,
13583                /* migrate_pinned */ false,
13584                /* as_json */ false,
13585            ) {
13586                Ok(()) => step(
13587                    "local-slot",
13588                    format!("dual-bound local relay {local_url} for sister routing"),
13589                ),
13590                Err(e) => step("local-slot", format!("skipped local relay: {e}")),
13591            }
13592        } else {
13593            step(
13594                "local-slot",
13595                format!(
13596                    "no local relay reachable at {local_url} — federation only \
13597                     (sisters resolve via session-list)"
13598                ),
13599            );
13600        }
13601    }
13602
13603    // 4. Background daemon — must be running for pull/push/ack to flow.
13604    match crate::ensure_up::ensure_daemon_running() {
13605        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
13606        Ok(false) => step("daemon", "already running".to_string()),
13607        Err(e) => step(
13608            "daemon",
13609            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
13610        ),
13611    }
13612
13613    // 5. Final summary — point operator at the next commands.
13614    let summary =
13615        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
13616         `wire monitor` to watch incoming events."
13617            .to_string();
13618    step("ready", summary.clone());
13619
13620    if as_json {
13621        let steps_json: Vec<_> = report
13622            .iter()
13623            .map(|(k, v)| json!({"stage": k, "detail": v}))
13624            .collect();
13625        println!(
13626            "{}",
13627            serde_json::to_string(&json!({
13628                "nick": canonical,
13629                "relay": relay_url,
13630                "steps": steps_json,
13631            }))?
13632        );
13633    }
13634    Ok(())
13635}
13636
13637/// Strip http:// or https:// prefix for display in `wire up` step output.
13638fn strip_proto(url: &str) -> String {
13639    url.trim_start_matches("https://")
13640        .trim_start_matches("http://")
13641        .to_string()
13642}
13643
13644/// Strip URL userinfo (`https://<userinfo>@<host>...`) from a relay URL,
13645/// warning to stderr if any was stripped. Returns the cleaned URL.
13646///
13647/// Bug 1 this fixes: `wire up <handle>@<relay>` and `wire bind-relay
13648/// <handle>@<relay>` previously prepended `https://` to the literal arg,
13649/// recording and publishing the endpoint as `https://<handle>@<relay>` —
13650/// handle parsed as URL userinfo. Every inbound event POST to that
13651/// endpoint (pair_drop_ack, messages) gets a 4xx (Cloudflare 400 on
13652/// wireup.net) because the upstream rejects the userinfo on plain
13653/// GETs/POSTs. Bilateral pairing can't complete; messages sit
13654/// undelivered. Also surfaced cosmetically (Bug 3) as a doubled-handle
13655/// echo at the claim step (`<nick>@<nick>@<host>`) because `strip_proto`
13656/// left the userinfo in.
13657///
13658/// Behavior: strip-and-warn rather than hard-reject. In v0.11+ the handle
13659/// is DID-derived (one-name rule), so the userinfo isn't *needed* — but
13660/// `<handle>@<relay>` is literally the wire dial-address format
13661/// (`wire dial coral-weasel@wireup.net`), so an operator who types
13662/// `wire up <handle>@<relay>` is making a natural-by-analogy mistake, not
13663/// a hostile request. Mirrors `cmd_up`'s already-bound branch, which has
13664/// always ignored the userinfo prefix when keeping an existing clean
13665/// slot. The hard invariant either way: a userinfo-bearing URL must
13666/// never reach `self.endpoints[]` or the published agent-card.
13667/// Self-pair guard (issue #30, explicit "Optional" ask).
13668///
13669/// Refuses to proceed when the resolved peer DID matches our own DID. Two
13670/// ways this fires:
13671///
13672///   1. The operator literally dialed their own handle by mistake.
13673///   2. Two terminals / agents that should be DISTINCT collapsed onto one
13674///      wire identity — either because v0.13's session-key resolution
13675///      didn't reach the wire process (env var not propagated; see #29 and
13676///      the Windows symptoms in #30) or because both terminals share a
13677///      WIRE_HOME without setting WIRE_SESSION_ID.
13678///
13679/// Pre-guard, case (2) silently produced a pair_drop targeting our own
13680/// slot — bilateral handshake could never complete and the operator could
13681/// only see "pending forever" with no diagnostic. The guard makes the
13682/// failure mode debuggable instead of silent by surfacing the exact DID
13683/// collision and pointing at the `wire whoami` / `WIRE_SESSION_ID`
13684/// diagnostic that the v0.13.5 session-key adapter introduced.
13685///
13686/// Companion to the lightweight nickname-match guard at the top of
13687/// `cmd_add` (which catches the literal `wire add <our-nick>@<relay>`
13688/// case before WebFinger). This DID-level guard is the load-bearing one
13689/// because case (2) — two collapsed terminals with DIFFERENT typed
13690/// nicknames that BOTH resolve to the shared DID — can't be caught
13691/// without the post-resolution comparison.
13692/// Issue #69 follow-up to #15: predicate "does this error smell like a
13693/// 4xx slot rotation?" — used by `try_reresolve_peer_on_slot_4xx` to
13694/// decide whether to spend a whois RTT on a re-resolve.
13695///
13696/// Original #15 implementation used `last_err.contains("410") ||
13697/// last_err.contains("404")`, which false-triggers on any unrelated
13698/// substring with `"410"`/`"404"` in it — e.g. `"slot 4101 expired"`,
13699/// `"request_id=410abc..."`, `"received 4040 bytes"`. False-trigger cost
13700/// is a single wasted whois per push call per peer (rate-limited by
13701/// `already_tried`), but it muddies the doctor diagnostic by inserting
13702/// spurious "peer slot rotated" log lines.
13703///
13704/// This predicate gates on the status code appearing as a *whole token*
13705/// — preceded by start-of-string / space / colon / tab / newline AND
13706/// followed by end-of-string / space / colon / tab / newline. That
13707/// matches both real-world shapes:
13708///
13709/// - `reqwest::StatusCode` Display, via `relay_client.rs` line ~339
13710///   `format!("post_event failed: {status}: {detail}")` →
13711///   `"post_event failed: 410 Gone: <body>"` (token `"410"` is followed
13712///   by space).
13713/// - UDS bare-`u16` Display, via `relay_client.rs` line ~227
13714///   `format!("post_event (uds {socket_path}) failed: {status}: ...")` →
13715///   `"post_event (uds /tmp/...sock) failed: 410: <body>"` (token
13716///   `"410"` is followed by colon).
13717///
13718/// And rejects the false-positive shapes documented in
13719/// `error_smells_like_slot_4xx_tests` below.
13720fn error_smells_like_slot_4xx(last_err: &str) -> bool {
13721    fn is_token_boundary(b: u8) -> bool {
13722        matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
13723    }
13724    let bytes = last_err.as_bytes();
13725    for code in ["410", "404"] {
13726        let code_bytes = code.as_bytes();
13727        let mut search_from = 0usize;
13728        while let Some(rel) = last_err[search_from..].find(code) {
13729            let abs = search_from + rel;
13730            let end = abs + code_bytes.len();
13731            let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
13732            let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
13733            if before_ok && after_ok {
13734                return true;
13735            }
13736            // Step past this candidate to find the next occurrence; using
13737            // `+ 1` (rather than `+ code_bytes.len()`) keeps the scan
13738            // cheap and guarantees forward progress even on overlap.
13739            search_from = abs + 1;
13740        }
13741    }
13742    false
13743}
13744
13745/// Issue #15: detect a 4xx-shaped push failure that smells like "slot
13746/// rotated by peer" and update the peer's pin in place with the freshly
13747/// resolved slot from the relay's handle directory.
13748///
13749/// Returns:
13750/// - `Ok(true)` — peer's pin was rotated; caller should refresh
13751///   `peer_endpoints_in_priority_order(&state, ...)` and retry.
13752/// - `Ok(false)` — re-resolve completed but the slot id was unchanged
13753///   (false-alarm 4xx, e.g. throttling); caller should NOT retry.
13754/// - `Err(e)` — re-resolve itself failed (network down, relay 5xx,
13755///   handle no longer claimed, etc.); caller should fall through to the
13756///   existing "skipped" path.
13757///
13758/// Only triggers when:
13759///   - The error string carries a 4xx slot-rotation status token (`410`/`404`)
13760///     as a *whole token* — preceded by start/space/colon/tab/newline and
13761///     followed by end/space/colon/tab/newline. This matches both the
13762///     `reqwest::StatusCode` Display shape (`": 410 Gone"`) and the UDS
13763///     bare-`u16` shape (`": 410:"`) emitted by `post_event` in
13764///     `src/relay_client.rs`, while rejecting substring false-positives
13765///     like `"slot 4101 expired"` or `"request_id=410abc..."`. See
13766///     `error_smells_like_slot_4xx` below.
13767///   - The peer has a pinned `relay_url` we can parse a handle@domain from.
13768///   - The caller hasn't already re-resolved this peer in the current push
13769///     call (caller's responsibility — pass `already_tried` from a set kept
13770///     in the outer per-peer loop). One whois per peer per push call,
13771///     exactly the rate limit the issue specifies.
13772///
13773/// Updates `state.peers[peer_handle]` in place (rotates the federation
13774/// endpoint's slot_id + slot_token to the fresh resolve), and emits a
13775/// stderr WARN so the operator can see the rotation event in their
13776/// terminal alongside the unrelated `wire push` output. Caller is
13777/// responsible for persisting `state` back to disk via
13778/// `config::write_relay_state` after all per-peer re-resolves settle.
13779fn try_reresolve_peer_on_slot_4xx(
13780    state: &mut Value,
13781    peer_handle: &str,
13782    last_err: &str,
13783    already_tried: &std::collections::HashSet<String>,
13784) -> Result<bool> {
13785    if !error_smells_like_slot_4xx(last_err) {
13786        // Not the slot-rotation shape. Don't waste a whois on this.
13787        return Ok(false);
13788    }
13789    if already_tried.contains(peer_handle) {
13790        // Rate limit: at most one whois per peer per push call.
13791        return Ok(false);
13792    }
13793    // Find the peer's pinned federation endpoint to re-resolve against.
13794    let peer_entry = state
13795        .get("peers")
13796        .and_then(|p| p.get(peer_handle))
13797        .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
13798    let peer_relay = peer_entry
13799        .get("endpoints")
13800        .and_then(Value::as_array)
13801        .and_then(|arr| {
13802            arr.iter().find(|e| {
13803                e.get("scope").and_then(Value::as_str) == Some("federation")
13804                    || e.get("scope").and_then(Value::as_str) == Some("Federation")
13805            })
13806        })
13807        .and_then(|e| e.get("relay_url").and_then(Value::as_str))
13808        .or_else(|| peer_entry.get("relay_url").and_then(Value::as_str))
13809        .ok_or_else(|| {
13810            anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
13811        })?
13812        .to_string();
13813    // Strip scheme + path to get the relay domain. Same shape parse used by
13814    // pair_profile::resolve_handle's input contract.
13815    let domain = peer_relay
13816        .trim_start_matches("https://")
13817        .trim_start_matches("http://")
13818        .split('/')
13819        .next()
13820        .unwrap_or(&peer_relay)
13821        .to_string();
13822    let handle = crate::pair_profile::Handle {
13823        nick: peer_handle.to_string(),
13824        domain,
13825    };
13826    let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
13827    let new_slot_id = resolved
13828        .get("slot_id")
13829        .and_then(Value::as_str)
13830        .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
13831        .to_string();
13832    // Compare against the currently-pinned federation slot.
13833    let peers = state
13834        .get_mut("peers")
13835        .and_then(Value::as_object_mut)
13836        .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
13837    let peer_entry = peers
13838        .get_mut(peer_handle)
13839        .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
13840    let current_slot_id = peer_entry
13841        .get("endpoints")
13842        .and_then(Value::as_array)
13843        .and_then(|arr| {
13844            arr.iter().find(|e| {
13845                let scope = e.get("scope").and_then(Value::as_str);
13846                scope == Some("federation") || scope == Some("Federation")
13847            })
13848        })
13849        .and_then(|e| e.get("slot_id").and_then(Value::as_str))
13850        .unwrap_or("")
13851        .to_string();
13852    if current_slot_id == new_slot_id {
13853        // Same slot — the 4xx was something else (rate limit, server burp).
13854        return Ok(false);
13855    }
13856    // Rotate in place. We update slot_id but DROP the slot_token: only the
13857    // peer's freshly-issued slot_token (which arrives via a new pair_drop_ack)
13858    // is valid. Sending against the new slot without a fresh token gets 401,
13859    // so the operator will see one more "skipped: 401" and the next pair
13860    // cycle (or a manual `wire add <peer>@<relay>` per the doctor #14 fix)
13861    // refreshes the token. This is the same trade-off the issue spells out:
13862    // auto-rotation closes the slot mismatch; token refresh still needs the
13863    // bilateral pair gate.
13864    if let Some(endpoints) = peer_entry
13865        .get_mut("endpoints")
13866        .and_then(Value::as_array_mut)
13867    {
13868        for ep in endpoints.iter_mut() {
13869            let scope = ep.get("scope").and_then(Value::as_str);
13870            if scope == Some("federation") || scope == Some("Federation") {
13871                ep["slot_id"] = Value::String(new_slot_id.clone());
13872                ep["slot_token"] = Value::String(String::new());
13873            }
13874        }
13875    }
13876    // Also update the legacy top-level fields for v0.5.16-era readers (the
13877    // same back-compat surface pair_drop_ack uses).
13878    peer_entry["slot_id"] = Value::String(new_slot_id.clone());
13879    peer_entry["slot_token"] = Value::String(String::new());
13880    eprintln!(
13881        "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
13882         now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
13883         {peer_handle}@<relay>` to refresh the slot_token."
13884    );
13885    Ok(true)
13886}
13887
13888fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
13889    if our_did == peer_did {
13890        bail!(
13891            "refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
13892             DID. Two terminals can collapse onto one wire identity when the per-\
13893             session key isn't reaching the wire process (issue #30 / #29).\n\n\
13894             Diagnose:\n  \
13895             • `wire whoami` in each terminal — DIDs MUST differ.\n  \
13896             • `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
13897             (PowerShell) — must be set + distinct per session.\n\n\
13898             Force distinct identities before relaunching the agent:\n  \
13899             • bash/zsh:   `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n  \
13900             • PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
13901        );
13902    }
13903    Ok(())
13904}
13905
13906fn strip_relay_url_userinfo(url: &str) -> String {
13907    // Locate the authority segment: everything after `://` (or the whole
13908    // string if there is no scheme yet), up to the first `/`, `?`, or `#`.
13909    let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
13910    let rest = &url[authority_start..];
13911    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
13912    let authority = &rest[..authority_end];
13913
13914    let Some(at_pos) = authority.find('@') else {
13915        return url.to_string();
13916    };
13917
13918    let userinfo = &authority[..at_pos];
13919    let host = &authority[at_pos + 1..];
13920    let scheme = &url[..authority_start];
13921    let tail = &rest[authority_end..];
13922    let cleaned = format!("{scheme}{host}{tail}");
13923
13924    eprintln!(
13925        "wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
13926         in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
13927         is just the bare relay. Binding to `{cleaned}` instead."
13928    );
13929
13930    cleaned
13931}
13932
13933/// Hard assertion that a URL about to be persisted to `relay_state` /
13934/// published in the signed agent-card carries no userinfo. The
13935/// `strip_relay_url_userinfo` filter at every public entry point already
13936/// removes it; this is the belt-and-suspenders check at the actual mutation
13937/// site — a future code path that bypasses the entry filter must NOT be
13938/// able to leak a malformed endpoint into a signed card or the persisted
13939/// relay state.
13940fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
13941    let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
13942    let rest = &url[authority_start..];
13943    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
13944    let authority = &rest[..authority_end];
13945    if authority.contains('@') {
13946        bail!(
13947            "internal invariant violated: relay URL `{url}` still carries userinfo at \
13948             the persist/publish boundary — `strip_relay_url_userinfo` must be called \
13949             before this point. Refusing to publish a malformed endpoint."
13950        );
13951    }
13952    Ok(())
13953}
13954
13955// ---------- pair megacommand (zero-paste handle-based) ----------
13956
13957/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
13958/// the handle is in `nick@domain` form. Wraps:
13959///
13960///   1. cmd_add — resolve, pin, drop intro
13961///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
13962///      (signalled by `peers.<handle>.slot_token` populating in relay state)
13963///   3. Verify bilateral pin: trust contains peer + relay state has token
13964///   4. Print final state — both sides VERIFIED + can `wire send`
13965///
13966/// On timeout: hard-errors with the specific stuck step so the operator
13967/// knows which side to chase. No silent partial success.
13968fn cmd_pair_megacommand(
13969    handle_arg: &str,
13970    relay_override: Option<&str>,
13971    timeout_secs: u64,
13972    _as_json: bool,
13973) -> Result<()> {
13974    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
13975    let peer_handle = parsed.nick.clone();
13976
13977    eprintln!("wire pair: resolving {handle_arg}...");
13978    cmd_add(
13979        handle_arg,
13980        relay_override,
13981        /* local_sister */ false,
13982        /* as_json */ false,
13983    )?;
13984
13985    eprintln!(
13986        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
13987         to ack (their daemon must be running + pulling)..."
13988    );
13989
13990    // Trigger an immediate daemon-style pull so we don't wait the full daemon
13991    // interval. Best-effort — if it fails, we still fall through to the
13992    // polling loop.
13993    let _ = run_sync_pull();
13994
13995    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
13996    let poll_interval = std::time::Duration::from_millis(500);
13997
13998    loop {
13999        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
14000        let _ = run_sync_pull();
14001        let relay_state = config::read_relay_state()?;
14002        let peer_entry = relay_state
14003            .get("peers")
14004            .and_then(|p| p.get(&peer_handle))
14005            .cloned();
14006        let token = peer_entry
14007            .as_ref()
14008            .and_then(|e| e.get("slot_token"))
14009            .and_then(Value::as_str)
14010            .unwrap_or("");
14011
14012        if !token.is_empty() {
14013            // Bilateral pin complete — we have their slot_token, we can send.
14014            let trust = config::read_trust()?;
14015            let pinned_in_trust = trust
14016                .get("agents")
14017                .and_then(|a| a.get(&peer_handle))
14018                .is_some();
14019            println!(
14020                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
14021                if pinned_in_trust {
14022                    "VERIFIED"
14023                } else {
14024                    "MISSING (bug)"
14025                }
14026            );
14027            return Ok(());
14028        }
14029
14030        if std::time::Instant::now() >= deadline {
14031            // Timeout — surface the EXACT stuck step. Likely culprits:
14032            //   - peer daemon not running on their box
14033            //   - peer's relay slot is offline
14034            //   - their daemon is on an older binary that doesn't know
14035            //     pair_drop kind=1100 (the P0.1 class — now visible via
14036            //     wire pull --json on their side as a blocking rejection)
14037            bail!(
14038                "wire pair: timed out after {timeout_secs}s. \
14039                 peer {peer_handle} never sent pair_drop_ack. \
14040                 likely causes: (a) their daemon is down — ask them to run \
14041                 `wire status` and `wire daemon &`; (b) their binary is older \
14042                 than 0.5.x and doesn't understand pair_drop events — ask \
14043                 them to `wire upgrade`; (c) network / relay blip — re-run \
14044                 `wire pair {handle_arg}` to retry."
14045            );
14046        }
14047
14048        std::thread::sleep(poll_interval);
14049    }
14050}
14051
14052fn cmd_claim(
14053    nick: &str,
14054    relay_override: Option<&str>,
14055    public_url: Option<&str>,
14056    hidden: bool,
14057    as_json: bool,
14058) -> Result<()> {
14059    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
14060    // + claim handle. Operator should never have to run init/bind-relay first.
14061    let (_did, relay_url, slot_id, slot_token) =
14062        crate::pair_invite::ensure_self_with_relay(relay_override)?;
14063    let card = config::read_agent_card()?;
14064
14065    // v0.13.1 one-name enforcement: the handle you claim in the phonebook
14066    // MUST equal your DID-derived persona, so the directory entry can never
14067    // drift from your agent-card handle. A typed nick that differs is ignored
14068    // (mirrors how `wire init` coerces the typed name). This closes the
14069    // claim-path reopening of the v0.11 "two names" footgun — before this,
14070    // `wire claim coffee-ghost` published coffee-ghost@relay -> your DID while
14071    // your card said e.g. outback-sandpiper. The typed `nick` arg is now
14072    // vestigial, exactly like the one `wire init` / `wire up` already accept.
14073    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
14074    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
14075    if !canonical.is_empty() && nick != canonical && !as_json {
14076        eprintln!(
14077            "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
14078        );
14079    }
14080    let nick = if canonical.is_empty() {
14081        nick
14082    } else {
14083        canonical.as_str()
14084    };
14085    if !crate::pair_profile::is_valid_nick(nick) {
14086        bail!(
14087            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
14088        );
14089    }
14090
14091    let client = crate::relay_client::RelayClient::new(&relay_url);
14092    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
14093    // (back-compat); Some(false) for `--hidden`. Relays older than
14094    // v0.5.19 ignore the field, so this is safe to always send.
14095    let discoverable = if hidden { Some(false) } else { None };
14096    let resp =
14097        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
14098
14099    if as_json {
14100        println!(
14101            "{}",
14102            serde_json::to_string(&json!({
14103                "nick": nick,
14104                "relay": relay_url,
14105                "response": resp,
14106            }))?
14107        );
14108    } else {
14109        // Best-effort: derive the public domain from the relay URL. If
14110        // operator passed --public-url that's the canonical address; else
14111        // the relay URL itself. Falls back to a placeholder if both miss.
14112        let domain = public_url
14113            .unwrap_or(&relay_url)
14114            .trim_start_matches("https://")
14115            .trim_start_matches("http://")
14116            .trim_end_matches('/')
14117            .split('/')
14118            .next()
14119            .unwrap_or("<this-relay-domain>")
14120            .to_string();
14121        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
14122        println!("verify with: wire whois {nick}@{domain}");
14123    }
14124    Ok(())
14125}
14126
14127fn cmd_profile(action: ProfileAction) -> Result<()> {
14128    match action {
14129        ProfileAction::Set { field, value, json } => {
14130            // Try parsing the value as JSON; if that fails, treat it as a
14131            // bare string. Lets operators pass either `42` or `"hello"` or
14132            // `["rust","late-night"]` without quoting hell.
14133            let parsed: Value =
14134                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
14135            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
14136            let published = republish_card_to_phonebook();
14137            if json {
14138                println!(
14139                    "{}",
14140                    serde_json::to_string(&json!({
14141                        "field": field,
14142                        "profile": new_profile,
14143                        "published_to": published,
14144                    }))?
14145                );
14146            } else {
14147                println!("profile.{field} set");
14148                print_profile_publish_result(&published);
14149            }
14150        }
14151        ProfileAction::Get { json } => return cmd_whois(None, json, None),
14152        ProfileAction::Clear { field, json } => {
14153            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
14154            let published = republish_card_to_phonebook();
14155            if json {
14156                println!(
14157                    "{}",
14158                    serde_json::to_string(&json!({
14159                        "field": field,
14160                        "cleared": true,
14161                        "profile": new_profile,
14162                        "published_to": published,
14163                    }))?
14164                );
14165            } else {
14166                println!("profile.{field} cleared");
14167                print_profile_publish_result(&published);
14168            }
14169        }
14170    }
14171    Ok(())
14172}
14173
14174/// Best-effort: re-publish the (freshly re-signed) agent-card to every relay
14175/// this identity already holds a federation slot on, so a `wire profile`
14176/// edit reaches the public phonebook immediately instead of waiting for the
14177/// next `wire up`. Silent no-op when the identity holds no federation slot
14178/// (offline / local-only). `discoverable: None` makes the relay PRESERVE the
14179/// prior setting, so a `--hidden` agent stays hidden across the re-claim.
14180/// Returns the relay URLs the card was published to.
14181fn republish_card_to_phonebook() -> Vec<String> {
14182    let Ok(card) = config::read_agent_card() else {
14183        return Vec::new();
14184    };
14185    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
14186    let persona = crate::agent_card::display_handle_from_did(did).to_string();
14187    if persona.is_empty() {
14188        return Vec::new();
14189    }
14190    let Ok(state) = config::read_relay_state() else {
14191        return Vec::new();
14192    };
14193    let mut published = Vec::new();
14194    for ep in crate::endpoints::self_endpoints(&state) {
14195        if ep.scope != crate::endpoints::EndpointScope::Federation
14196            || ep.slot_id.is_empty()
14197            || ep.slot_token.is_empty()
14198        {
14199            continue;
14200        }
14201        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
14202        if client
14203            .handle_claim_v2(&persona, &ep.slot_id, &ep.slot_token, None, &card, None)
14204            .is_ok()
14205        {
14206            published.push(ep.relay_url.clone());
14207        }
14208    }
14209    published
14210}
14211
14212fn print_profile_publish_result(published: &[String]) {
14213    if published.is_empty() {
14214        println!(
14215            "  (local only — not bound to a federation relay; run `wire up` to publish to the phonebook)"
14216        );
14217    } else {
14218        println!("  published to phonebook: {}", published.join(", "));
14219    }
14220}
14221
14222// ---------- setup — one-shot MCP host registration ----------
14223
14224fn cmd_setup(apply: bool) -> Result<()> {
14225    use std::path::PathBuf;
14226
14227    // v0.14.x: no `env` mapping. Per-session identity for Claude Code is
14228    // resolved by `crate::session::resolve_session_key`, which reads
14229    // `WIRE_SESSION_ID` then falls back to `CLAUDE_CODE_SESSION_ID`. Current
14230    // Claude Code (verified 2026-05) propagates `CLAUDE_CODE_SESSION_ID`
14231    // into every MCP subprocess by default (`ps eww` on a running
14232    // `wire mcp` PID shows it in the inherited env), so the historical
14233    // `{"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}` mapping was
14234    // redundant. Worse, it triggered the MCP Config Diagnostics validator
14235    // warning `Missing environment variables: CLAUDE_CODE_SESSION_ID`
14236    // when the validator runs in a context without that env var set,
14237    // even though the runtime invocation works fine. Dropping the env
14238    // block silences the diagnostic AND keeps per-session identity intact
14239    // via the documented fallback chain. If a future Claude Code release
14240    // stops propagating `CLAUDE_CODE_SESSION_ID`, the comment + emit
14241    // here is the one place to restore the explicit mapping.
14242    let entry = json!({
14243        "command": "wire",
14244        "args": ["mcp"]
14245    });
14246    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
14247
14248    // Detect probable MCP host config locations. Cross-platform — we only
14249    // touch the file if it already exists OR --apply was passed.
14250    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
14251    if let Some(home) = dirs::home_dir() {
14252        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
14253        // The mcpServers map lives at the top level of that file.
14254        targets.push(("Claude Code", home.join(".claude.json")));
14255        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
14256        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
14257        // Claude Desktop macOS
14258        #[cfg(target_os = "macos")]
14259        targets.push((
14260            "Claude Desktop (macOS)",
14261            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
14262        ));
14263        // Claude Desktop Windows
14264        #[cfg(target_os = "windows")]
14265        if let Ok(appdata) = std::env::var("APPDATA") {
14266            targets.push((
14267                "Claude Desktop (Windows)",
14268                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
14269            ));
14270        }
14271        // Cursor
14272        targets.push(("Cursor", home.join(".cursor/mcp.json")));
14273
14274        // GitHub Copilot (VS Code) — User settings
14275        #[cfg(target_os = "macos")]
14276        targets.push((
14277            "VS Code (GitHub Copilot)",
14278            home.join("Library/Application Support/Code/User/settings.json"),
14279        ));
14280        #[cfg(target_os = "linux")]
14281        targets.push((
14282            "VS Code (GitHub Copilot)",
14283            home.join(".config/Code/User/settings.json"),
14284        ));
14285        #[cfg(target_os = "windows")]
14286        if let Ok(appdata) = std::env::var("APPDATA") {
14287            targets.push((
14288                "VS Code (GitHub Copilot)",
14289                PathBuf::from(appdata).join("Code/User/settings.json"),
14290            ));
14291        }
14292
14293        // VS Code Insiders variant
14294        #[cfg(target_os = "macos")]
14295        targets.push((
14296            "VS Code Insiders",
14297            home.join("Library/Application Support/Code - Insiders/User/settings.json"),
14298        ));
14299        #[cfg(target_os = "linux")]
14300        targets.push((
14301            "VS Code Insiders",
14302            home.join(".config/Code - Insiders/User/settings.json"),
14303        ));
14304        #[cfg(target_os = "windows")]
14305        if let Ok(appdata) = std::env::var("APPDATA") {
14306            targets.push((
14307                "VS Code Insiders",
14308                PathBuf::from(appdata).join("Code - Insiders/User/settings.json"),
14309            ));
14310        }
14311
14312        // GitHub Copilot CLI (`gh copilot` / `copilot`). v0.13.6: standard
14313        // MCP shape (`mcpServers` root key, same as Claude Code), lives at
14314        // `~/.copilot/mcp-config.json` on all platforms — XDG-overridable
14315        // on Unix via `$XDG_CONFIG_HOME/copilot/mcp-config.json`.
14316        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
14317            targets.push((
14318                "GitHub Copilot CLI (XDG)",
14319                PathBuf::from(xdg).join("copilot/mcp-config.json"),
14320            ));
14321        }
14322        targets.push(("GitHub Copilot CLI", home.join(".copilot/mcp-config.json")));
14323    }
14324    // Workspace-local VS Code settings (GitHub Copilot workspace config)
14325    targets.push((
14326        "VS Code (workspace)",
14327        PathBuf::from(".vscode/settings.json"),
14328    ));
14329    // Project-local — works for several MCP-aware tools
14330    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
14331
14332    println!("wire setup\n");
14333    println!("MCP server snippet (add this to your client's mcpServers):");
14334    println!();
14335    println!("{entry_pretty}");
14336    println!();
14337
14338    if !apply {
14339        println!("Probable MCP host config locations on this machine:");
14340        for (name, path) in &targets {
14341            let marker = if path.exists() {
14342                "✓ found"
14343            } else {
14344                "  (would create)"
14345            };
14346            println!("  {marker:14}  {name}: {}", path.display());
14347        }
14348        println!();
14349        println!("Run `wire setup --apply` to merge wire into each config above.");
14350        println!(
14351            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
14352        );
14353        return Ok(());
14354    }
14355
14356    let mut modified: Vec<String> = Vec::new();
14357    let mut skipped: Vec<String> = Vec::new();
14358    for (name, path) in &targets {
14359        match upsert_mcp_entry(path, "wire", &entry) {
14360            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
14361            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
14362            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
14363        }
14364    }
14365    if !modified.is_empty() {
14366        println!("Modified:");
14367        for line in &modified {
14368            println!("  {line}");
14369        }
14370        println!();
14371        println!("Restart the app(s) above to load wire MCP.");
14372    }
14373    if !skipped.is_empty() {
14374        println!();
14375        println!("Skipped:");
14376        for line in &skipped {
14377            println!("  {line}");
14378        }
14379    }
14380    Ok(())
14381}
14382
14383/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
14384/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
14385///
14386/// Supports two config formats:
14387/// - Standard MCP: `{"mcpServers": {"wire": {...}}}`
14388/// - VS Code: `{"mcp": {"servers": {"wire": {...}}}}`
14389fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
14390    let mut cfg: Value = if path.exists() {
14391        let body = std::fs::read_to_string(path).context("reading config")?;
14392        if body.trim().is_empty() {
14393            json!({})
14394        } else {
14395            // Refuse to default a non-empty-but-unparseable file to `{}` —
14396            // doing so would overwrite the whole file with just our entry.
14397            // VS Code's settings.json is JSONC (// comments, trailing commas)
14398            // which serde_json can't parse; surface it so the caller lists
14399            // this target under "Skipped" and the user adds wire manually.
14400            serde_json::from_str(&body).with_context(|| {
14401                format!(
14402                    "{} is not strict JSON (comments / trailing commas?); \
14403                     add the wire MCP entry manually to avoid overwriting it",
14404                    path.display()
14405                )
14406            })?
14407        }
14408    } else {
14409        json!({})
14410    };
14411    if !cfg.is_object() {
14412        cfg = json!({});
14413    }
14414
14415    // Detect VS Code settings.json (contains "mcp.servers" instead of "mcpServers")
14416    let is_vscode = path.to_string_lossy().contains("Code/User/settings.json")
14417        || path.to_string_lossy().contains(".vscode/settings.json")
14418        || path.to_string_lossy().contains("Code - Insiders");
14419
14420    let root = cfg.as_object_mut().unwrap();
14421
14422    if is_vscode {
14423        // VS Code format: {"mcp": {"servers": {"wire": {...}}}}
14424        let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
14425        if !mcp.is_object() {
14426            *mcp = json!({});
14427        }
14428        let mcp_obj = mcp.as_object_mut().unwrap();
14429        let servers = mcp_obj
14430            .entry("servers".to_string())
14431            .or_insert_with(|| json!({}));
14432        if !servers.is_object() {
14433            *servers = json!({});
14434        }
14435        let map = servers.as_object_mut().unwrap();
14436        if map.get(server_name) == Some(entry) {
14437            return Ok(false);
14438        }
14439        map.insert(server_name.to_string(), entry.clone());
14440    } else {
14441        // Standard MCP format: {"mcpServers": {"wire": {...}}}
14442        let servers = root
14443            .entry("mcpServers".to_string())
14444            .or_insert_with(|| json!({}));
14445        if !servers.is_object() {
14446            *servers = json!({});
14447        }
14448        let map = servers.as_object_mut().unwrap();
14449        if map.get(server_name) == Some(entry) {
14450            return Ok(false);
14451        }
14452        map.insert(server_name.to_string(), entry.clone());
14453    }
14454
14455    if let Some(parent) = path.parent()
14456        && !parent.as_os_str().is_empty()
14457    {
14458        std::fs::create_dir_all(parent).context("creating parent dir")?;
14459    }
14460    let out = serde_json::to_string_pretty(&cfg)? + "\n";
14461    std::fs::write(path, out).context("writing config")?;
14462    Ok(true)
14463}
14464
14465// ---------- setup --statusline ----------
14466
14467/// Bundled Claude Code statusLine renderer (persona emoji + nickname + cwd,
14468/// pidfile+tasklist liveness). Embedded at compile time; written to the
14469/// Claude config dir on `wire setup --statusline --apply`.
14470const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
14471
14472/// `wire setup --statusline [--apply] [--remove]` — install/remove a Claude
14473/// Code statusLine that renders this session's wire persona. Honors
14474/// `$CLAUDE_CONFIG_DIR` (default `~/.claude`). Writes the renderer script and
14475/// merges a `statusLine` block into settings.json, preserving existing keys
14476/// and refusing to clobber a settings.json that exists but isn't valid JSON.
14477fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
14478    use std::path::PathBuf;
14479    let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
14480        .map(PathBuf::from)
14481        .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
14482        .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
14483    let settings_path = cfg_dir.join("settings.json");
14484    let script_path = cfg_dir.join("wire-statusline.sh");
14485    // Resolve the shell invocation. On Windows a bare `bash` resolves to
14486    // System32\bash.exe (WSL) — wrong environment, Windows paths invalid,
14487    // statusline breaks — so we emit the absolute git-bash path. On Unix a
14488    // bare `bash <script>` is correct. Script path is quoted for spaces.
14489    let (command, command_warn) = statusline_command(&script_path);
14490
14491    println!("wire setup --statusline\n");
14492    println!("Claude config dir: {}", cfg_dir.display());
14493    println!("  renderer:  {}", script_path.display());
14494    println!("  settings:  {}", settings_path.display());
14495    if let Some(w) = &command_warn {
14496        println!("  ⚠ {w}");
14497    }
14498    println!();
14499
14500    if remove {
14501        if !apply {
14502            println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
14503            println!("Run `wire setup --statusline --remove --apply` to do it.");
14504            return Ok(());
14505        }
14506        let dropped = remove_statusline_entry(&settings_path)?;
14507        let script_gone = if script_path.exists() {
14508            std::fs::remove_file(&script_path).is_ok()
14509        } else {
14510            false
14511        };
14512        println!(
14513            "Removed: statusLine key {} · renderer {}",
14514            if dropped { "dropped" } else { "absent" },
14515            if script_gone { "deleted" } else { "absent" }
14516        );
14517        return Ok(());
14518    }
14519
14520    if !apply {
14521        println!("Would write the renderer above and merge into settings.json:");
14522        println!();
14523        println!("  \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
14524        println!();
14525        println!("Resulting statusline:  ● <emoji> <nickname> · <cwd>");
14526        println!("Run `wire setup --statusline --apply` to install.");
14527        println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
14528        return Ok(());
14529    }
14530
14531    if let Some(parent) = script_path.parent() {
14532        std::fs::create_dir_all(parent).context("creating Claude config dir")?;
14533    }
14534    std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
14535    #[cfg(unix)]
14536    {
14537        use std::os::unix::fs::PermissionsExt;
14538        if let Ok(meta) = std::fs::metadata(&script_path) {
14539            let mut perms = meta.permissions();
14540            perms.set_mode(0o755);
14541            let _ = std::fs::set_permissions(&script_path, perms);
14542        }
14543    }
14544    let changed = upsert_statusline_entry(&settings_path, &command)?;
14545    println!("✓ renderer written: {}", script_path.display());
14546    if changed {
14547        println!("✓ merged statusLine into: {}", settings_path.display());
14548    } else {
14549        println!(
14550            "  settings.json already configured: {}",
14551            settings_path.display()
14552        );
14553    }
14554    println!();
14555    println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
14556    Ok(())
14557}
14558
14559/// Merge a `statusLine` command block into a Claude settings.json, preserving
14560/// all other keys. Returns Ok(true) if changed. Refuses to clobber a file that
14561/// exists but is not valid JSON.
14562fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
14563    let mut cfg: Value = if path.exists() {
14564        let body = std::fs::read_to_string(path).context("reading settings.json")?;
14565        if body.trim().is_empty() {
14566            json!({})
14567        } else {
14568            serde_json::from_str(&body).context(
14569                "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
14570            )?
14571        }
14572    } else {
14573        json!({})
14574    };
14575    if !cfg.is_object() {
14576        bail!("settings.json root is not a JSON object — refusing to clobber");
14577    }
14578    let desired = json!({"type": "command", "command": command});
14579    let root = cfg.as_object_mut().unwrap();
14580    if root.get("statusLine") == Some(&desired) {
14581        return Ok(false);
14582    }
14583    root.insert("statusLine".to_string(), desired);
14584    if let Some(parent) = path.parent()
14585        && !parent.as_os_str().is_empty()
14586    {
14587        std::fs::create_dir_all(parent).context("creating parent dir")?;
14588    }
14589    let out = serde_json::to_string_pretty(&cfg)? + "\n";
14590    std::fs::write(path, out).context("writing settings.json")?;
14591    Ok(true)
14592}
14593
14594/// Drop the `statusLine` key from settings.json. Ok(true) if a key was removed,
14595/// Ok(false) if file/key absent. Refuses to edit invalid JSON.
14596fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
14597    if !path.exists() {
14598        return Ok(false);
14599    }
14600    let body = std::fs::read_to_string(path).context("reading settings.json")?;
14601    if body.trim().is_empty() {
14602        return Ok(false);
14603    }
14604    let mut cfg: Value = serde_json::from_str(&body)
14605        .context("settings.json is not valid JSON — refusing to edit")?;
14606    let Some(root) = cfg.as_object_mut() else {
14607        return Ok(false);
14608    };
14609    if root.remove("statusLine").is_none() {
14610        return Ok(false);
14611    }
14612    let out = serde_json::to_string_pretty(&cfg)? + "\n";
14613    std::fs::write(path, out).context("writing settings.json")?;
14614    Ok(true)
14615}
14616
14617/// Build the `statusLine.command` string for this platform. Returns the
14618/// command plus an optional warning to surface to the operator.
14619fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
14620    #[cfg(windows)]
14621    {
14622        match resolve_git_bash() {
14623            Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
14624            None => (
14625                format!("bash \"{}\"", script_path.display()),
14626                Some(
14627                    "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
14628                     WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
14629                     Windows or set statusLine.command to your git-bash bash.exe path."
14630                        .to_string(),
14631                ),
14632            ),
14633        }
14634    }
14635    #[cfg(unix)]
14636    {
14637        (format!("bash \"{}\"", script_path.display()), None)
14638    }
14639}
14640
14641/// Locate the git-bash `bash.exe` on Windows, avoiding the WSL launcher at
14642/// `System32\bash.exe`. Claude Code's statusLine command needs the real
14643/// git-bash so the renderer runs in a POSIX-ish env with valid paths.
14644#[cfg(windows)]
14645fn resolve_git_bash() -> Option<String> {
14646    use std::path::PathBuf;
14647    // 1. `where.exe bash` — take the first hit that is NOT under System32
14648    //    (that one is the WSL `bash.exe` launcher).
14649    if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
14650        && out.status.success()
14651    {
14652        for line in String::from_utf8_lossy(&out.stdout).lines() {
14653            let p = line.trim();
14654            if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
14655                return Some(p.to_string());
14656            }
14657        }
14658    }
14659    // 2. Common Git-for-Windows install locations.
14660    let candidates = [
14661        std::env::var("ProgramFiles")
14662            .ok()
14663            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14664        std::env::var("ProgramFiles(x86)")
14665            .ok()
14666            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14667        std::env::var("LocalAppData")
14668            .ok()
14669            .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
14670    ];
14671    candidates
14672        .into_iter()
14673        .flatten()
14674        .find(|c| PathBuf::from(c).exists())
14675}
14676
14677#[cfg(test)]
14678mod statusline_tests {
14679    use super::*;
14680
14681    #[test]
14682    fn statusline_merge_preserves_keys_and_is_idempotent() {
14683        let dir = tempfile::tempdir().unwrap();
14684        let path = dir.path().join("settings.json");
14685        std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
14686        // First merge changes the file but keeps existing keys.
14687        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14688        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14689        assert_eq!(v["theme"], "dark");
14690        assert_eq!(v["model"], "opus");
14691        assert_eq!(v["statusLine"]["type"], "command");
14692        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14693        // Identical re-merge = no change.
14694        assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14695        // Remove drops ONLY statusLine.
14696        assert!(remove_statusline_entry(&path).unwrap());
14697        let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14698        assert_eq!(v2["theme"], "dark");
14699        assert!(v2.get("statusLine").is_none());
14700        // Remove again = no-op.
14701        assert!(!remove_statusline_entry(&path).unwrap());
14702    }
14703
14704    #[test]
14705    fn statusline_merge_refuses_to_clobber_invalid_json() {
14706        let dir = tempfile::tempdir().unwrap();
14707        let path = dir.path().join("settings.json");
14708        std::fs::write(&path, "this is not json {").unwrap();
14709        let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
14710        assert!(
14711            format!("{err:#}").contains("not valid JSON"),
14712            "err: {err:#}"
14713        );
14714        // File left untouched.
14715        assert_eq!(
14716            std::fs::read_to_string(&path).unwrap(),
14717            "this is not json {"
14718        );
14719    }
14720
14721    #[test]
14722    fn statusline_creates_settings_when_absent() {
14723        let dir = tempfile::tempdir().unwrap();
14724        let path = dir.path().join("settings.json");
14725        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14726        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14727        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14728    }
14729}
14730
14731// ---------- notify (Goal 2) ----------
14732
14733fn cmd_notify(
14734    interval_secs: u64,
14735    peer_filter: Option<&str>,
14736    once: bool,
14737    as_json: bool,
14738) -> Result<()> {
14739    use crate::inbox_watch::InboxWatcher;
14740    let cursor_path = config::state_dir()?.join("notify.cursor");
14741    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
14742    // v0.13.x identity work: a long-running notify loop racing another
14743    // wire process on the same inbox cursor silently drops toasts.
14744    // Skipped under `--once` (single sweep, no cursor ownership).
14745    if !once {
14746        crate::session::warn_on_identity_collision(std::process::id(), "notify");
14747    }
14748
14749    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
14750        let events = watcher.poll()?;
14751        for ev in events {
14752            if let Some(p) = peer_filter
14753                && ev.peer != p
14754            {
14755                continue;
14756            }
14757            if as_json {
14758                println!("{}", serde_json::to_string(&ev)?);
14759            } else {
14760                os_notify_inbox_event(&ev);
14761            }
14762        }
14763        watcher.save_cursors(&cursor_path)?;
14764        Ok(())
14765    };
14766
14767    if once {
14768        return sweep(&mut watcher);
14769    }
14770
14771    let interval = std::time::Duration::from_secs(interval_secs.max(1));
14772    loop {
14773        if let Err(e) = sweep(&mut watcher) {
14774            eprintln!("wire notify: sweep error: {e}");
14775        }
14776        std::thread::sleep(interval);
14777    }
14778}
14779
14780fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
14781    let who = persona_label(&ev.peer);
14782    let title = if ev.verified {
14783        format!("wire ← {who}")
14784    } else {
14785        format!("wire ← {who} (UNVERIFIED)")
14786    };
14787    let body = format!("{}: {}", ev.kind, ev.body_preview);
14788    // Issue #81: dedup by (peer, event_id) so that overlapping monitor
14789    // sweeps / restarts with a torn cursor don't fire the same toast over
14790    // and over. `event_id` may be empty for pre-v0.5 legacy events; fall
14791    // back to the body preview in that case so the key still varies per
14792    // event rather than collapsing every keyless event into one entry.
14793    let id = if ev.event_id.is_empty() {
14794        ev.body_preview.as_str()
14795    } else {
14796        ev.event_id.as_str()
14797    };
14798    let dedup_key = format!("inbox:{}:{}", ev.peer, id);
14799    crate::os_notify::toast_dedup(&dedup_key, &title, &body);
14800}
14801
14802#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
14803fn os_toast(title: &str, body: &str) {
14804    eprintln!("[wire notify] {title}\n  {body}");
14805}
14806
14807// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).
14808
14809#[cfg(test)]
14810mod relay_url_tests {
14811    use super::*;
14812
14813    #[test]
14814    fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
14815        // Bug 1: `wire up <handle>@<relay>` and `wire bind-relay
14816        // <handle>@<relay>` previously persisted/published the endpoint as
14817        // `https://<handle>@<relay>` — handle stuck in URL userinfo. Every
14818        // inbound event POST to that endpoint 4xxed (Cloudflare 400 on
14819        // wireup.net); bilateral pairing couldn't complete.
14820        //
14821        // Strip+warn (not hard-reject): mirrors cmd_up's already-bound
14822        // branch, which has always ignored the userinfo on the "keeping
14823        // existing binding" warning path. `<handle>@<relay>` is also
14824        // literally the wire dial-address format — natural by analogy.
14825
14826        assert_eq!(
14827            strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
14828            "https://wireup.net",
14829            "https URL with handle userinfo is stripped to the bare host"
14830        );
14831        assert_eq!(
14832            strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
14833            "http://127.0.0.1:8771",
14834            "http + port + userinfo is stripped, port preserved"
14835        );
14836        // user:password@host — both halves of userinfo are dropped.
14837        assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
14838        // Authority with port + userinfo.
14839        assert_eq!(
14840            strip_relay_url_userinfo("https://nick@host:8443"),
14841            "https://host:8443"
14842        );
14843        // Schemeless `<handle>@<host>` — strips correctly. (cmd_up's
14844        // bare-host normalize prepends https:// before calling, but the
14845        // function is robust to either input.)
14846        assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
14847        // Path / query / fragment AFTER the authority are preserved.
14848        assert_eq!(
14849            strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
14850            "https://wireup.net/v1/events?x=1#frag"
14851        );
14852    }
14853
14854    #[test]
14855    fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
14856        // Bare host (https / http, with and without port, with path / query).
14857        for ok in [
14858            "https://wireup.net",
14859            "http://wireup.net",
14860            "http://127.0.0.1:8771",
14861            "https://relay.example.com:9443/v1/wire",
14862            "https://wireup.net/?env=prod",
14863            // Path / query containing `@` is fine — it's not in the authority.
14864            "https://wireup.net/users/me@example.com",
14865            "https://wireup.net/?to=me@example.com",
14866            // Fragment with @ — fine.
14867            "https://wireup.net/#contact@me",
14868            // IPv6 literal (no @ in authority).
14869            "http://[::1]:8771",
14870            // Schemeless bare host — also fine.
14871            "wireup.net",
14872            "wireup.net:8443",
14873        ] {
14874            assert_eq!(
14875                strip_relay_url_userinfo(ok),
14876                ok,
14877                "clean URL `{ok}` must pass through unchanged"
14878            );
14879        }
14880    }
14881
14882    #[test]
14883    fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
14884        // Belt-and-suspenders: even if a future code path bypasses
14885        // strip_relay_url_userinfo at the entry, the persist/publish
14886        // boundary must refuse a userinfo URL. This is the second line
14887        // of defense that keeps a malformed endpoint out of the SIGNED
14888        // agent-card and the persisted relay_state.
14889        assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
14890        assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
14891        assert!(
14892            assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
14893        );
14894
14895        let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
14896            .unwrap_err()
14897            .to_string();
14898        assert!(
14899            err.contains("invariant violated"),
14900            "persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
14901        );
14902        assert!(
14903            err.contains("strip_relay_url_userinfo"),
14904            "error must name the upstream filter so the caller can audit the bypass: {err}"
14905        );
14906        // user:password@host is just as bad — userinfo is userinfo.
14907        assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
14908        // Authority with port + userinfo.
14909        assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
14910    }
14911
14912    #[test]
14913    fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
14914        // Bug 3 (cosmetic): `wire up <handle>@<relay>` echoed `claimed
14915        // <nick>@<nick>@<relay>` because strip_proto left the userinfo in.
14916        // With Bug 1's strip+warn in cmd_up, the claim step receives a
14917        // bare host — strip_proto returns `<host>` and the echo is
14918        // `<nick>@<host>` exactly once. Verified end-to-end here:
14919        let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
14920        assert_eq!(after_strip, "https://wireup.net");
14921        assert_eq!(strip_proto(&after_strip), "wireup.net");
14922        // And the doubled-echo failure mode that motivated the fix:
14923        assert!(
14924            strip_proto("https://nick@wireup.net").contains('@'),
14925            "strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
14926        );
14927    }
14928}
14929
14930#[cfg(test)]
14931mod self_pair_guard_tests {
14932    use super::*;
14933
14934    #[test]
14935    fn reject_self_pair_after_resolution_blocks_matching_dids() {
14936        // Issue #30 (explicit "Optional" ask): when both terminals collapse
14937        // onto one wire identity (a v0.13-era WIRE_SESSION_ID propagation
14938        // gap or a shared WIRE_HOME), the resolved peer DID matches the
14939        // local DID and pair_drop silently goes nowhere. Guard surfaces
14940        // it as a refusable error with the diagnostic remediation path.
14941
14942        let err = reject_self_pair_after_resolution(
14943            "did:wire:winter-bay-4092b577",
14944            "did:wire:winter-bay-4092b577",
14945        )
14946        .unwrap_err()
14947        .to_string();
14948        assert!(
14949            err.contains("refusing to self-pair"),
14950            "must explicitly refuse, not silently bail: {err}"
14951        );
14952        assert!(
14953            err.contains("did:wire:winter-bay-4092b577"),
14954            "must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
14955        );
14956        assert!(
14957            err.contains("issue #30") || err.contains("issue #29"),
14958            "must point at the tracking issue so historical context is one search away: {err}"
14959        );
14960        // Remediation must be copy-paste ready — both POSIX and PowerShell
14961        // (the failure mode is Windows-prevalent per #30).
14962        assert!(
14963            err.contains("WIRE_SESSION_ID"),
14964            "remediation must name the env var operators set: {err}"
14965        );
14966        assert!(
14967            err.contains("uuidgen") || err.contains("NewGuid"),
14968            "remediation must include a concrete command to mint a unique id: {err}"
14969        );
14970    }
14971
14972    #[test]
14973    fn reject_self_pair_after_resolution_allows_distinct_dids() {
14974        // Sanity: the guard must not fire for any normal pair attempt
14975        // between two distinct identities. Cover the common shapes:
14976        // adjective-noun personas (post-v0.11), bare keypair hashes, and
14977        // mixed-case DIDs that happen to share a prefix.
14978        reject_self_pair_after_resolution(
14979            "did:wire:winter-bay-4092b577",
14980            "did:wire:cedar-bayou-0616dc6c",
14981        )
14982        .unwrap();
14983        reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
14984            .unwrap();
14985        // Same persona prefix, different suffix-hash → distinct DIDs (the
14986        // suffix is the load-bearing identifier). Must NOT trigger the
14987        // guard.
14988        reject_self_pair_after_resolution(
14989            "did:wire:noble-canyon-deadbeef",
14990            "did:wire:noble-canyon-cafef00d",
14991        )
14992        .unwrap();
14993    }
14994}
14995
14996#[cfg(test)]
14997mod slot_reresolve_tests {
14998    use super::*;
14999
15000    /// Issue #15: the gating logic of try_reresolve_peer_on_slot_4xx
15001    /// must short-circuit BEFORE any network call when the error shape
15002    /// doesn't smell like slot rotation, when the peer was already
15003    /// re-resolved this push, or when there's no peer entry to work
15004    /// against. Three of those four short-circuit paths are testable
15005    /// without a mock relay; the fourth (the actual whois + slot
15006    /// comparison) requires either a live test server or a mock
15007    /// transport, so it's covered manually via the failover_tests
15008    /// helper + integration check in a separate PR.
15009    ///
15010    /// What these tests pin:
15011    ///   - 200/500/timeout-shape errors do NOT trigger a re-resolve
15012    ///     (avoids wasted whois RTTs and churn in steady-state).
15013    ///   - Same peer twice in one push call only attempts re-resolve
15014    ///     once (rate limit the issue specifies).
15015    ///   - Missing peer entry surfaces as an explicit error, NOT a
15016    ///     silent skip (operator can see the malformed state).
15017    ///   - Peer with no federation endpoint surfaces as an explicit
15018    ///     error (you can't re-resolve a slot you can't address).
15019
15020    #[test]
15021    fn try_reresolve_skips_when_error_is_not_4xx_shape() {
15022        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
15023        let already = std::collections::HashSet::new();
15024        // 200 OK shouldn't ever land in this path, but sanity check the
15025        // negative filter: any error string without "404"/"410" is a no-op.
15026        let res =
15027            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
15028                .unwrap();
15029        assert!(!res, "502 must NOT trigger a re-resolve");
15030
15031        let res =
15032            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
15033                .unwrap();
15034        assert!(!res, "transport errors must NOT trigger a re-resolve");
15035
15036        let res = try_reresolve_peer_on_slot_4xx(
15037            &mut state,
15038            "some-peer",
15039            "post failed: 401 Unauthorized",
15040            &already,
15041        )
15042        .unwrap();
15043        assert!(
15044            !res,
15045            "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
15046        );
15047    }
15048
15049    #[test]
15050    fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
15051        // The issue's rate limit: "at most one whois per peer per push call."
15052        // Caller tracks via `already_tried`; helper must honor it BEFORE
15053        // attempting any I/O (otherwise a bad-state peer would burn a
15054        // network call per event in the outbox).
15055        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
15056        let mut already = std::collections::HashSet::new();
15057        already.insert("some-peer".to_string());
15058        let res = try_reresolve_peer_on_slot_4xx(
15059            &mut state,
15060            "some-peer",
15061            "post failed: 410 Gone",
15062            &already,
15063        )
15064        .unwrap();
15065        assert!(
15066            !res,
15067            "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
15068        );
15069    }
15070
15071    #[test]
15072    fn try_reresolve_errors_when_peer_missing_from_state() {
15073        // Surface state corruption explicitly rather than silently
15074        // returning Ok(false). If a peer disappeared from relay_state
15075        // mid-loop the operator needs to see it.
15076        let mut state = json!({"peers": {}});
15077        let already = std::collections::HashSet::new();
15078        let err = try_reresolve_peer_on_slot_4xx(
15079            &mut state,
15080            "missing-peer",
15081            "post failed: 410 Gone",
15082            &already,
15083        )
15084        .unwrap_err()
15085        .to_string();
15086        assert!(
15087            err.contains("missing-peer") && err.contains("not in relay_state"),
15088            "missing-peer error must name the peer + the failure: {err}"
15089        );
15090    }
15091
15092    #[test]
15093    fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
15094        // A peer with only local-scope endpoints (UDS / 127.0.0.1) has
15095        // no relay domain to whois against. Helper must surface this as
15096        // an actionable error, not a silent skip — the operator's
15097        // remediation is "pair via federation" or "you're on the same
15098        // box, the slot can't be 410'd by a peer who controls the
15099        // socket."
15100        let mut state = json!({
15101            "peers": {
15102                "local-only": {
15103                    "endpoints": [
15104                        {
15105                            "scope": "Local",
15106                            "relay_url": "http://127.0.0.1:8771",
15107                            "slot_id": "loc",
15108                            "slot_token": "tok"
15109                        }
15110                    ]
15111                }
15112            }
15113        });
15114        let already = std::collections::HashSet::new();
15115        let err = try_reresolve_peer_on_slot_4xx(
15116            &mut state,
15117            "local-only",
15118            "post failed: 410 Gone",
15119            &already,
15120        )
15121        .unwrap_err()
15122        .to_string();
15123        assert!(
15124            err.contains("federation endpoint"),
15125            "no-federation error must name the problem: {err}"
15126        );
15127    }
15128
15129    /// Issue #69: pin the word-boundary behavior of
15130    /// `error_smells_like_slot_4xx`. Prior implementation used a bare
15131    /// `contains("410") || contains("404")` substring match, which
15132    /// false-triggered on any unrelated error string containing those
15133    /// digits — e.g. slot ids that happen to start with `410`, request
15134    /// IDs, byte counts, etc.  Each false-positive cost a wasted whois
15135    /// per peer per push and a misleading "peer slot rotated" log line.
15136    ///
15137    /// These tests pin three classes:
15138    ///   - Real reqwest StatusCode Display shapes (`": 410 Gone"`,
15139    ///     `": 404 Not Found"`) trigger.
15140    ///   - Real UDS bare-`u16` shapes (`": 410:"`, `": 404:"`) trigger.
15141    ///   - Substring lookalikes (`"slot 4101 expired"`,
15142    ///     `"request_id=410abc"`, `"received 4040 bytes"`,
15143    ///     `"event 0x4104"`) do NOT trigger.
15144    #[test]
15145    fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
15146        // reqwest::StatusCode Display is "<u16> <reason>", embedded in
15147        // the post_event failure format string as "...failed: <status>: <detail>".
15148        assert!(error_smells_like_slot_4xx(
15149            "post_event failed: 410 Gone: slot rotated by peer"
15150        ));
15151        assert!(error_smells_like_slot_4xx(
15152            "post_event failed: 404 Not Found: handle no longer claimed"
15153        ));
15154    }
15155
15156    #[test]
15157    fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
15158        // UDS path formats status as a bare u16, so the shape is
15159        // "...failed: 410: <detail>" with the status flanked by spaces
15160        // and colons (no reason phrase).
15161        assert!(error_smells_like_slot_4xx(
15162            "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
15163        ));
15164        assert!(error_smells_like_slot_4xx(
15165            "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
15166        ));
15167    }
15168
15169    #[test]
15170    fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
15171        // The bug being fixed: the prior `contains("410")` predicate
15172        // matched ALL of these, burning a whois RTT and emitting a
15173        // spurious "peer slot rotated" log line each time.
15174        let false_positives = [
15175            "push aborted: slot 4101 expired",
15176            "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
15177            "post_event failed: 500: received 4040 bytes, expected envelope",
15178            "post_event failed: 500: event 0x4104 malformed",
15179            "post_event failed: 503: backlog=4102 entries pending",
15180            // 4044 is "received bytes" or anything containing 404 mid-token.
15181            "post_event failed: 500: tx_id=4044beef",
15182            // pure digit substrings inside identifiers / hashes:
15183            "post_event failed: 500: hash=abc410def",
15184        ];
15185        for case in false_positives {
15186            assert!(
15187                !error_smells_like_slot_4xx(case),
15188                "must NOT trigger re-resolve on substring lookalike: {case:?}"
15189            );
15190        }
15191    }
15192
15193    #[test]
15194    fn error_smells_like_slot_4xx_handles_edge_positions() {
15195        // Token at start of string (no preceding char).
15196        assert!(error_smells_like_slot_4xx("410 Gone"));
15197        assert!(error_smells_like_slot_4xx("404 Not Found"));
15198        // Token at end of string (no trailing char).
15199        assert!(error_smells_like_slot_4xx("got 410"));
15200        assert!(error_smells_like_slot_4xx("got 404"));
15201        // Tab and newline as separators (logs sometimes carry these).
15202        assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
15203        assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
15204        // Pure digit-only input that IS the code — token at start AND end.
15205        assert!(error_smells_like_slot_4xx("410"));
15206        assert!(error_smells_like_slot_4xx("404"));
15207        // Empty / no-match.
15208        assert!(!error_smells_like_slot_4xx(""));
15209        assert!(!error_smells_like_slot_4xx("no relevant status"));
15210        // 411-414, 401-403, 405-409 must NOT trigger (only 410/404 are
15211        // the slot-rotation shape per issue #15).
15212        assert!(!error_smells_like_slot_4xx(
15213            "post_event failed: 401 Unauthorized"
15214        ));
15215        assert!(!error_smells_like_slot_4xx(
15216            "post_event failed: 403 Forbidden"
15217        ));
15218        assert!(!error_smells_like_slot_4xx(
15219            "post_event failed: 411 Length Required"
15220        ));
15221    }
15222}
15223
15224// v0.14: tests for op-claims surfacing on operator read verbs.
15225// Pure-over-Value helper; no I/O, no filesystem fixtures needed.
15226#[cfg(test)]
15227mod op_claims_surfacing_tests {
15228    use super::*;
15229
15230    #[test]
15231    fn op_claims_extracts_present_non_null_fields() {
15232        let card = json!({
15233            "did": "did:wire:foo-deadbeef",
15234            "handle": "foo",
15235            "op_did": "did:wire:op:foo-aaaa",
15236            "op_pubkey": "PKB64==",
15237            "op_cert": "SIGB64==",
15238            "org_memberships": [{"org_did": "did:wire:org:slancha-bbbb"}],
15239            "schema_version": "v3.2",
15240        });
15241        let claims = op_claims_from_card(&card);
15242        assert_eq!(claims.len(), 5);
15243        assert_eq!(
15244            claims.get("op_did").and_then(Value::as_str),
15245            Some("did:wire:op:foo-aaaa")
15246        );
15247        assert!(
15248            claims
15249                .get("org_memberships")
15250                .and_then(Value::as_array)
15251                .is_some()
15252        );
15253    }
15254
15255    #[test]
15256    fn op_claims_empty_on_pre_v014_card() {
15257        // A pre-v0.14 card has none of the inline op_* fields. The
15258        // helper must return an EMPTY map so older peers surface
15259        // identically on every read verb (no `null`-spam in JSON,
15260        // no new lines in human output).
15261        let card = json!({
15262            "did": "did:wire:bar-cafebabe",
15263            "handle": "bar",
15264            "capabilities": ["wire/v3.1"],
15265        });
15266        assert!(op_claims_from_card(&card).is_empty());
15267    }
15268
15269    #[test]
15270    fn op_claims_skips_explicit_null_fields() {
15271        // Defensive: a card where republish has serialized op_did as
15272        // `null` (e.g., post-unenroll rebuild) must not surface a
15273        // `null` field — operators read absence to mean "not enrolled".
15274        let card = json!({
15275            "did": "did:wire:baz-12341234",
15276            "op_did": Value::Null,
15277            "org_memberships": Value::Null,
15278            "schema_version": "v3.2",
15279        });
15280        let claims = op_claims_from_card(&card);
15281        assert_eq!(claims.len(), 1);
15282        assert!(claims.get("op_did").is_none());
15283        assert!(claims.get("org_memberships").is_none());
15284        assert_eq!(
15285            claims.get("schema_version").and_then(Value::as_str),
15286            Some("v3.2")
15287        );
15288    }
15289}