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}
906
907#[derive(Subcommand, Debug)]
908pub enum DiagAction {
909    /// Tail the last N entries from diag.jsonl.
910    Tail {
911        #[arg(long, default_value_t = 20)]
912        limit: usize,
913        #[arg(long)]
914        json: bool,
915    },
916    /// Flip the file-based knob ON. Running daemons pick this up on
917    /// the next emit call without restart.
918    Enable,
919    /// Flip the file-based knob OFF.
920    Disable,
921    /// Report whether diag is currently enabled + the file's size.
922    Status {
923        #[arg(long)]
924        json: bool,
925    },
926}
927
928/// `wire enroll …` — mint the operator/org identities + certs the offline
929/// org-membership layer (RFC-001) consumes. Keys are stored 0600 alongside
930/// `private.key`. (Publishing these claims on the agent's own card — the
931/// card-emit integration — is a separate follow-up.)
932#[derive(Subcommand, Debug)]
933pub enum EnrollCommand {
934    /// Mint this machine's operator root key (`op.key`) and print its `op_did`.
935    Op {
936        /// Operator handle (display only; the op_did commits to the key).
937        #[arg(long, default_value = "operator")]
938        handle: String,
939        #[arg(long)]
940        json: bool,
941    },
942    /// Mint an organization root key and print its `org_did` + `org_pubkey`.
943    OrgCreate {
944        /// Org handle (display only; the org_did commits to the key).
945        #[arg(long)]
946        handle: String,
947        #[arg(long)]
948        json: bool,
949    },
950    /// Issue a membership cert: the named org signs an operator's `op_did`.
951    /// Prints the `{org_did, org_pubkey, member_cert}` bundle for the operator
952    /// to add to their card's `org_memberships[]`.
953    OrgAddMember {
954        /// The operator DID to vouch for (`did:wire:op:…`).
955        op_did: String,
956        /// Which org signs (its `org_did`).
957        #[arg(long)]
958        org: String,
959        #[arg(long)]
960        json: bool,
961    },
962}
963
964#[derive(Subcommand, Debug)]
965pub enum IdentityCommand {
966    /// Print the current character (DID-derived, the only name).
967    /// Equivalent to `wire whoami --short` but scoped here for grouping.
968    Show {
969        #[arg(long)]
970        json: bool,
971    },
972    /// List all identities on this machine — one row per session, with
973    /// each session's character, DID, federation handle, and cwd. Same
974    /// shape as `wire session list`, scoped here for the v0.7+ noun-
975    /// CLI surface.
976    List {
977        #[arg(long)]
978        json: bool,
979    },
980    /// Promote this identity to FEDERATION lifecycle: claim your persona on
981    /// the relay so peers can `wire dial <persona>@<relay-domain>` you.
982    /// Re-claims with current display fields so the relay always serves the
983    /// latest signed card. Equivalent to `wire claim`.
984    ///
985    /// v0.13.1: hidden — `wire up` publishes your persona for you, and the
986    /// nick is vestigial (one-name rule). Kept callable for re-publish.
987    #[command(hide = true)]
988    Publish {
989        /// Vestigial: ignored; your handle is your DID-derived persona.
990        nick: String,
991        /// Override the relay URL. Defaults to the session's bound relay
992        /// from `wire init --relay <url>`. Public relay if unset.
993        #[arg(long)]
994        relay: Option<String>,
995        /// Public-facing URL for the agent-card location (when the relay
996        /// is behind a CDN with a different public domain).
997        #[arg(long, alias = "public")]
998        public_url: Option<String>,
999        /// Skip listing in the relay's public phonebook. The card is
1000        /// still claimable + reachable; just doesn't appear in
1001        /// `wireup.net/phonebook` for stranger-discovery.
1002        #[arg(long)]
1003        hidden: bool,
1004        #[arg(long)]
1005        json: bool,
1006    },
1007    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
1008    /// Equivalent to `wire session destroy <name>`, scoped here for the
1009    /// noun-CLI surface. Requires `--force` (the underlying command does).
1010    Destroy {
1011        /// Session name to destroy (use `wire identity list` to see).
1012        name: String,
1013        /// Bypass the confirmation prompt.
1014        #[arg(long)]
1015        force: bool,
1016        #[arg(long)]
1017        json: bool,
1018    },
1019    /// Create an identity in an EXPLICIT lifecycle state (vs. the
1020    /// implicit `wire init` + `wire claim` flow).
1021    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
1022    ///
1023    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
1024    /// next reboot). In-memory semantics not yet supported — the
1025    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
1026    /// For pure-RAM identities, see v1.0 vision.
1027    ///
1028    /// `--local` is the explicit form of today's default; identity
1029    /// persists to the machine-wide sessions root.
1030    Create {
1031        /// Session name. Defaults to derived from cwd (anonymous mode
1032        /// uses a random name).
1033        #[arg(long)]
1034        name: Option<String>,
1035        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
1036        /// reboot, no federation). Mutually exclusive with --local.
1037        #[arg(long, conflicts_with = "local")]
1038        anonymous: bool,
1039        /// Create a LOCAL identity (machine-persistent, no federation).
1040        /// Default — explicit flag for clarity.
1041        #[arg(long)]
1042        local: bool,
1043        #[arg(long)]
1044        json: bool,
1045    },
1046    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
1047    /// the machine-wide sessions root + register in the cwd map.
1048    /// After persist, the identity survives reboot.
1049    /// v0.7.0-alpha.20.
1050    Persist {
1051        /// The anonymous identity's name (from `wire identity list`).
1052        name: String,
1053        /// Optional rename during persist. Default: keep the anon name.
1054        #[arg(long = "as", value_name = "NEW_NAME")]
1055        as_name: Option<String>,
1056        #[arg(long)]
1057        json: bool,
1058    },
1059    /// Demote an identity ONE level in the lifecycle:
1060    ///   federation → local: removes the relay slot binding but keeps
1061    ///   the keypair + agent-card. Operator can later re-publish with
1062    ///   `wire identity publish`. v0.7.0-alpha.20.
1063    ///
1064    /// (local → anonymous is not exposed; the safer flow is destroy +
1065    /// recreate, since "demoting" a persistent identity to ephemeral
1066    /// has surprising semantics — what about the keypair? what about
1067    /// pinned peers? Better to be explicit with destroy.)
1068    Demote {
1069        /// Session name to demote.
1070        name: String,
1071        #[arg(long)]
1072        json: bool,
1073    },
1074}
1075
1076#[derive(Subcommand, Debug)]
1077pub enum SessionCommand {
1078    /// Bootstrap a new isolated session in this machine's sessions root.
1079    /// With no name, derives one from `basename(cwd)` and caches it in
1080    /// the registry so re-running from the same project reuses it.
1081    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1082    /// the new session's WIRE_HOME. Output includes the `export
1083    /// WIRE_HOME=...` line operators paste into their shell to activate
1084    /// it.
1085    New {
1086        /// Optional session name. Default = derived from `basename(cwd)`.
1087        name: Option<String>,
1088        /// Relay URL for the session's slot allocation + handle claim.
1089        #[arg(long, default_value = "https://wireup.net")]
1090        relay: String,
1091        /// v0.5.17: also allocate a second slot on a same-machine local
1092        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1093        /// sister-session traffic prefers this path: zero round-trip
1094        /// latency, zero metadata exposure to the public relay. Probes
1095        /// `<local-relay>/healthz` first; silently skips if the local
1096        /// relay isn't running.
1097        #[arg(long)]
1098        with_local: bool,
1099        /// v0.5.17: override the local relay URL probed by `--with-local`.
1100        /// Default is `http://127.0.0.1:8771` to match
1101        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1102        #[arg(long, default_value = "http://127.0.0.1:8771")]
1103        local_relay: String,
1104        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1105        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1106        /// Lets other machines on the same network reach this session
1107        /// directly without round-tripping the public federation relay
1108        /// at https://wireup.net. LAN endpoint is published in the
1109        /// agent-card; opt-in per session (default off).
1110        #[arg(long)]
1111        with_lan: bool,
1112        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1113        /// LAN IP — operator must type the address). Example:
1114        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1115        #[arg(long)]
1116        lan_relay: Option<String>,
1117        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1118        /// relay (must be running e.g. via `wire relay-server --uds
1119        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1120        /// bypasses the macOS firewall + Tailscale userspace-netstack
1121        /// class of issues entirely for sister-session traffic. UDS
1122        /// endpoint is published in the agent-card.
1123        #[arg(long)]
1124        with_uds: bool,
1125        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1126        /// is set. Example: `/tmp/wire.sock` or
1127        /// `~/.wire/local.sock`.
1128        #[arg(long)]
1129        uds_socket: Option<std::path::PathBuf>,
1130        /// Skip spawning the session-local daemon. Use when you want
1131        /// to drive sync explicitly from the agent or test rig.
1132        #[arg(long)]
1133        no_daemon: bool,
1134        /// v0.6.6: create a federation-free session — no nick claim on
1135        /// `--relay`, no federation slot allocation. Implies
1136        /// `--with-local`. The session exists only to coordinate with
1137        /// other sister sessions on this machine; it has no public
1138        /// address and cannot be reached from outside. Reserved nicks
1139        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1140        /// to publish them.
1141        #[arg(long)]
1142        local_only: bool,
1143        /// Emit JSON.
1144        #[arg(long)]
1145        json: bool,
1146    },
1147    /// List all sessions on this machine with their handle, DID,
1148    /// daemon liveness, and the cwd they're associated with.
1149    List {
1150        #[arg(long)]
1151        json: bool,
1152    },
1153    /// List sister sessions reachable via a same-machine local relay
1154    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1155    /// share. Sessions without a Local-scope endpoint are listed
1156    /// separately so the operator can tell which are federation-only.
1157    /// Read-only — does not probe any relay or touch daemons.
1158    ListLocal {
1159        #[arg(long)]
1160        json: bool,
1161    },
1162    /// v0.6.0 (issue #12): mesh-pair every sister session against every
1163    /// other in O(N²) handshakes. For each unordered pair (A, B) that
1164    /// is not already paired, drives the bilateral flow end-to-end:
1165    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
1166    /// B's side, then a final pull on A so the ack lands. Idempotent —
1167    /// re-running skips pairs already in `state.peers`.
1168    ///
1169    /// **Trust anchor:** the operator running this command owns every
1170    /// session listed in `wire session list-local` (they all live under
1171    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1172    /// That filesystem-permission boundary IS the consent for both
1173    /// sides — the bilateral SAS / network-level handshake assumes
1174    /// strangers; same-uid sister sessions are by definition not
1175    /// strangers. Cross-uid sister sessions are out of scope; today
1176    /// `wire session list-local` only enumerates this user's sessions.
1177    PairAllLocal {
1178        /// Seconds to wait between handshake stages for pair_drop /
1179        /// pair_drop_ack to propagate over the relay. Default 1s
1180        /// (local-relay is typically <100ms RTT). Bump if you see
1181        /// "pending-inbound never arrived" errors on a slow relay.
1182        #[arg(long, default_value_t = 1)]
1183        settle_secs: u64,
1184        /// Federation relay to bind each `wire add` against. Default
1185        /// `https://wireup.net`. Sister sessions should be bound to
1186        /// the same federation relay; the pair handshake routes through
1187        /// it for the .well-known resolution + pair_drop deposit.
1188        #[arg(long, default_value = "https://wireup.net")]
1189        federation_relay: String,
1190        #[arg(long)]
1191        json: bool,
1192    },
1193    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1194    /// machine. Enumerates every session in `wire session list-local`,
1195    /// walks each session's `relay.json#peers` to find which other sister
1196    /// sessions it has pinned, and probes the local relay for each edge's
1197    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1198    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1199    /// local_relay, summary}` so scripts can scrape.
1200    ///
1201    /// Read-only — does NOT touch peers or daemons, only the relay's
1202    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1203    /// already hold. Silent on any probe failure (degrades to "no
1204    /// signal" rather than abort) so a half-broken mesh is still
1205    /// inspectable.
1206    MeshStatus {
1207        /// Threshold in seconds for "stale" classification on an edge.
1208        /// An edge whose receiver hasn't polled their slot in this long
1209        /// is flagged. Default 300s (5 min) — same as the per-send
1210        /// `phyllis` attentiveness nag.
1211        #[arg(long, default_value_t = 300)]
1212        stale_secs: u64,
1213        #[arg(long)]
1214        json: bool,
1215    },
1216    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1217    /// can `eval $(wire session env <name>)` to activate it. With no
1218    /// name, resolves the cwd through the registry.
1219    Env {
1220        /// Session name. Default = derived from cwd via the registry.
1221        name: Option<String>,
1222        #[arg(long)]
1223        json: bool,
1224    },
1225    /// Identify which session the current cwd maps to in the registry.
1226    /// Prints `(none)` if cwd isn't registered — `wire session new`
1227    /// would create one.
1228    Current {
1229        #[arg(long)]
1230        json: bool,
1231    },
1232    /// Attach an existing session to the current cwd in the registry,
1233    /// so subsequent auto-detect from this cwd resolves to that session
1234    /// instead of walking up to an ancestor's binding. Use when an
1235    /// ancestor dir (e.g. `~/Source`) is already registered and is
1236    /// shadowing per-project identities for cwds beneath it. Idempotent;
1237    /// re-binding to the same name is a no-op. Re-binding to a different
1238    /// name overwrites the prior entry with a stderr warning.
1239    Bind {
1240        /// Session name to bind. Must already exist (run `wire session
1241        /// new <name>` first if not). With no name, auto-derives from
1242        /// `basename(cwd)` and errors if no session of that name exists.
1243        name: Option<String>,
1244        #[arg(long)]
1245        json: bool,
1246    },
1247    /// Tear down a session: kills its daemon (if running), deletes its
1248    /// state directory, and removes it from the registry. Requires
1249    /// `--force` because state loss is unrecoverable (keypair gone).
1250    Destroy {
1251        name: String,
1252        /// Confirm state-deleting operation.
1253        #[arg(long)]
1254        force: bool,
1255        #[arg(long)]
1256        json: bool,
1257    },
1258}
1259
1260/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1261/// session's view of the pinned peer set. `status` is the read-only
1262/// observability primitive (alias for `wire session mesh-status`);
1263/// Group-chat verbs (v0.13.3). Membership is a creator-signed roster
1264/// (`src/group.rs`); send fans a signed message over the member set.
1265#[derive(Subcommand, Debug)]
1266pub enum GroupCommand {
1267    /// Create a new group — you become the creator + sole member, roster signed.
1268    Create {
1269        /// Group name (human label).
1270        name: String,
1271        #[arg(long)]
1272        json: bool,
1273    },
1274    /// Add a bilaterally-VERIFIED pinned peer to a group you created (Member tier).
1275    Add {
1276        /// Group id or name.
1277        group: String,
1278        /// Peer handle (must be a VERIFIED pinned peer).
1279        peer: String,
1280        #[arg(long)]
1281        json: bool,
1282    },
1283    /// Send a message to every other member of a group (signed fan-out).
1284    Send {
1285        /// Group id or name.
1286        group: String,
1287        /// Message text.
1288        message: String,
1289        #[arg(long)]
1290        json: bool,
1291    },
1292    /// Show recent messages received for a group.
1293    Tail {
1294        /// Group id or name.
1295        group: String,
1296        /// Max messages to show.
1297        #[arg(long, default_value_t = 20)]
1298        limit: usize,
1299        #[arg(long)]
1300        json: bool,
1301    },
1302    /// List your groups + their members and tiers.
1303    List {
1304        #[arg(long)]
1305        json: bool,
1306    },
1307    /// Mint a shareable join code for a group (a self-contained token carrying
1308    /// the room coords + signed roster). Anyone you give it to can `wire group
1309    /// join <code>` to enter the room at Introduced tier. The code IS the room
1310    /// key — share it only with people you want in the room.
1311    Invite {
1312        /// Group id or name.
1313        group: String,
1314        #[arg(long)]
1315        json: bool,
1316    },
1317    /// Join a group from a code minted by `wire group invite`. Materializes the
1318    /// room locally, pins the existing members on the creator's vouch, and
1319    /// announces you to the room so members can verify your messages.
1320    Join {
1321        /// The `wire-group:` code (or bare base64 payload).
1322        code: String,
1323        #[arg(long)]
1324        json: bool,
1325    },
1326}
1327
1328/// `broadcast` fans a signed event to every pinned peer in one call.
1329#[derive(Subcommand, Debug)]
1330pub enum MeshCommand {
1331    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1332    /// per-edge health roll-up across every sister session on this machine.
1333    Status {
1334        /// Threshold in seconds for "stale" classification on an edge.
1335        #[arg(long, default_value_t = 300)]
1336        stale_secs: u64,
1337        #[arg(long)]
1338        json: bool,
1339    },
1340    /// Fan one signed event to every pinned peer. Each peer receives a
1341    /// distinct `event_id` but every copy shares the same `broadcast_id`
1342    /// UUID so receivers can correlate them as a single broadcast.
1343    ///
1344    /// `--scope local` (default) only fans to peers reachable via a same-
1345    /// machine local relay. `--scope federation` only to public-relay
1346    /// peers. `--scope both` to every pinned peer.
1347    ///
1348    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1349    /// for "ack-loop" prevention: a peer responding to a broadcast can
1350    /// exclude its own broadcaster when re-broadcasting.
1351    ///
1352    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1353    /// file, `-` reads stdin (JSON if parseable, else literal).
1354    ///
1355    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1356    /// peers — that would re-introduce the phonebook-scrape risk closed
1357    /// in v0.5.14 (T8).
1358    Broadcast {
1359        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1360        /// `heartbeat`. Same vocabulary as `wire send`.
1361        #[arg(long, default_value = "claim")]
1362        kind: String,
1363        /// `local`, `federation`, or `both`. Default `local`.
1364        #[arg(long, default_value = "local")]
1365        scope: String,
1366        /// Skip a specific peer handle. Repeatable.
1367        #[arg(long)]
1368        exclude: Vec<String>,
1369        /// Drop the broadcast event ID from the relay-side attentiveness
1370        /// nag (`phyllis`) — useful when broadcasting to many peers and
1371        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1372        #[arg(long)]
1373        noreply: bool,
1374        /// Body — string, `@/path` for a file, or `-` for stdin.
1375        body: String,
1376        #[arg(long)]
1377        json: bool,
1378    },
1379    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1380    /// capability-aware addressing. Stored as `profile.role` on the
1381    /// signed agent-card — propagates over the existing pair / .well-
1382    /// known plumbing, no new persistence.
1383    ///
1384    /// First slice of the Layer-2 capability metadata umbrella (#13).
1385    /// `wire mesh route` (issue #21) will consume these tags to pick
1386    /// the right sister for a task.
1387    Role {
1388        #[command(subcommand)]
1389        action: MeshRoleAction,
1390    },
1391    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1392    /// to one sister session and deliver an event to that one peer.
1393    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1394    /// can now address "the reviewer" instead of hard-coding a handle.
1395    ///
1396    /// Strategies:
1397    ///   - `round-robin` (default): per-role cursor, persisted at
1398    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1399    ///   - `first`: alphabetically-first matching sister. Deterministic.
1400    ///   - `random`: uniform random among matches. Stateless.
1401    ///
1402    /// Pinned-peers-only by construction (same posture as `broadcast`).
1403    /// Caller must already have the target sister pinned in
1404    /// `state.peers` — otherwise we can't sign + push. Run
1405    /// `wire session pair-all-local` first if the mesh isn't wired.
1406    Route {
1407        /// Role to match (operator-defined tag from `wire mesh role set`).
1408        role: String,
1409        /// `round-robin` (default), `first`, or `random`.
1410        #[arg(long, default_value = "round-robin")]
1411        strategy: String,
1412        /// Skip a specific sister handle. Repeatable.
1413        #[arg(long)]
1414        exclude: Vec<String>,
1415        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1416        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1417        #[arg(long, default_value = "claim")]
1418        kind: String,
1419        /// Body — string, `@/path` for a file, or `-` for stdin.
1420        body: String,
1421        #[arg(long)]
1422        json: bool,
1423    },
1424}
1425
1426/// v0.6.4: subcommands of `wire mesh role`.
1427#[derive(Subcommand, Debug)]
1428pub enum MeshRoleAction {
1429    /// Assign self to a role. Role is a free-form ASCII string
1430    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1431    /// the vocabulary out-of-band — common starters: `planner`,
1432    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1433    Set {
1434        role: String,
1435        #[arg(long)]
1436        json: bool,
1437    },
1438    /// Read self or a peer's role. With no arg, prints self. With a
1439    /// handle, reads from the peer's pinned agent-card.
1440    Get {
1441        peer: Option<String>,
1442        #[arg(long)]
1443        json: bool,
1444    },
1445    /// List roles across every sister session on this machine. Reads
1446    /// each session's agent-card by path — no network, no env mutation.
1447    List {
1448        #[arg(long)]
1449        json: bool,
1450    },
1451    /// Remove self from any assigned role. Re-signs the card with
1452    /// `profile.role: null`.
1453    Clear {
1454        #[arg(long)]
1455        json: bool,
1456    },
1457}
1458
1459#[derive(Subcommand, Debug)]
1460pub enum ServiceAction {
1461    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1462    /// load it. Idempotent — re-running re-bootstraps an existing service.
1463    ///
1464    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1465    /// process). Pass `--local-relay` to install the loopback relay
1466    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1467    /// transport sister-Claudes use to coordinate on the same machine
1468    /// (v0.5.17 dual-slot). The two services have distinct labels +
1469    /// log files, so you can install both.
1470    Install {
1471        /// Install the local-relay service instead of the daemon.
1472        #[arg(long)]
1473        local_relay: bool,
1474        #[arg(long)]
1475        json: bool,
1476    },
1477    /// Unload + delete the service unit. Daemon keeps running until the
1478    /// next reboot or `wire upgrade`; this only changes the boot-time
1479    /// behaviour.
1480    Uninstall {
1481        /// Uninstall the local-relay service instead of the daemon.
1482        #[arg(long)]
1483        local_relay: bool,
1484        #[arg(long)]
1485        json: bool,
1486    },
1487    /// Report whether the unit is installed + active.
1488    Status {
1489        /// Show status of the local-relay service instead of the daemon.
1490        #[arg(long)]
1491        local_relay: bool,
1492        #[arg(long)]
1493        json: bool,
1494    },
1495}
1496
1497#[derive(Subcommand, Debug)]
1498pub enum ResponderCommand {
1499    /// Publish this agent's auto-responder health.
1500    Set {
1501        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1502        status: String,
1503        /// Optional operator-facing reason.
1504        #[arg(long)]
1505        reason: Option<String>,
1506        /// Emit JSON.
1507        #[arg(long)]
1508        json: bool,
1509    },
1510    /// Read responder health for self, or for a paired peer.
1511    Get {
1512        /// Optional peer handle; omitted means this agent's own slot.
1513        peer: Option<String>,
1514        /// Emit JSON.
1515        #[arg(long)]
1516        json: bool,
1517    },
1518}
1519
1520#[derive(Subcommand, Debug)]
1521pub enum ProfileAction {
1522    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1523    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1524    /// (JSON array) and `now` (JSON object).
1525    Set {
1526        field: String,
1527        value: String,
1528        #[arg(long)]
1529        json: bool,
1530    },
1531    /// Show all profile fields. Equivalent to `wire whois`.
1532    Get {
1533        #[arg(long)]
1534        json: bool,
1535    },
1536    /// Clear a profile field.
1537    Clear {
1538        field: String,
1539        #[arg(long)]
1540        json: bool,
1541    },
1542}
1543
1544/// Entry point — parse and dispatch.
1545pub fn run() -> Result<()> {
1546    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1547    // the session registry and adopt that session's home for this
1548    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1549    // detect — `wire whoami` / `wire monitor` from a project cwd now
1550    // resolve to that project's session identity, not the machine
1551    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1552    //
1553    // MUST run before any thread spawn — call it FIRST, before
1554    // `Cli::parse` (which uses clap internals only) and before any
1555    // command dispatch (which may spawn workers).
1556    crate::session::maybe_adopt_session_wire_home("cli");
1557    let cli = Cli::parse();
1558    match cli.command {
1559        Command::Init {
1560            handle,
1561            name,
1562            relay,
1563            offline,
1564            json,
1565        } => cmd_init(
1566            Some(&handle),
1567            name.as_deref(),
1568            relay.as_deref(),
1569            offline,
1570            json,
1571        ),
1572        Command::Status { peer, json } => {
1573            if let Some(peer) = peer {
1574                cmd_status_peer(&peer, json)
1575            } else {
1576                cmd_status(json)
1577            }
1578        }
1579        Command::Whoami {
1580            json,
1581            short,
1582            colored,
1583        } => cmd_whoami(json_default(json), short, colored),
1584        Command::Peers { json } => cmd_peers(json_default(json)),
1585        Command::Here { json } => cmd_here(json_default(json)),
1586        Command::Completions { shell } => {
1587            // v0.9.5: print shell completion script to stdout. Operator
1588            // pipes into their shell's completion dir; tab completion
1589            // covers verbs (dial, send, pending, accept, etc.) AND
1590            // their flags. Peer-name dynamic completion is a future
1591            // shell-side enhancement; clap_complete only ships the
1592            // static grammar.
1593            use clap::CommandFactory;
1594            let mut cmd = Cli::command();
1595            clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1596            Ok(())
1597        }
1598        Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1599        Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1600        Command::Send {
1601            peer,
1602            kind_or_body,
1603            body,
1604            deadline,
1605            no_auto_pair,
1606            json,
1607        } => {
1608            // P0.S: smart-positional API. `wire send peer body` =
1609            // kind=claim. `wire send peer kind body` = explicit kind.
1610            let (kind, body) = match body {
1611                Some(real_body) => (kind_or_body, real_body),
1612                None => ("claim".to_string(), kind_or_body),
1613            };
1614            cmd_send(
1615                &peer,
1616                &kind,
1617                &body,
1618                deadline.as_deref(),
1619                no_auto_pair,
1620                json_default(json),
1621            )
1622        }
1623        Command::Dial {
1624            name,
1625            message,
1626            json,
1627        } => cmd_dial(&name, message.as_deref(), json_default(json)),
1628        Command::Tail {
1629            peer,
1630            json,
1631            limit,
1632            oldest,
1633        } => cmd_tail(peer.as_deref(), json, limit, oldest),
1634        Command::Monitor {
1635            peer,
1636            json,
1637            include_handshake,
1638            interval_ms,
1639            replay,
1640        } => cmd_monitor(
1641            peer.as_deref(),
1642            json,
1643            include_handshake,
1644            interval_ms,
1645            replay,
1646        ),
1647        Command::Verify { path, json } => cmd_verify(&path, json),
1648        Command::Responder { command } => match command {
1649            ResponderCommand::Set {
1650                status,
1651                reason,
1652                json,
1653            } => cmd_responder_set(&status, reason.as_deref(), json),
1654            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1655        },
1656        Command::Mcp => cmd_mcp(),
1657        Command::RelayServer {
1658            bind,
1659            local_only,
1660            uds,
1661        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1662        Command::BindRelay {
1663            url,
1664            scope,
1665            replace,
1666            migrate_pinned,
1667            json,
1668        } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1669        Command::AddPeerSlot {
1670            handle,
1671            url,
1672            slot_id,
1673            slot_token,
1674            json,
1675        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1676        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1677        Command::Pull { json } => cmd_pull(json),
1678        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1679        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1680        Command::ForgetPeer {
1681            handle,
1682            purge,
1683            json,
1684        } => cmd_forget_peer(&handle, purge, json),
1685        Command::Daemon {
1686            interval,
1687            once,
1688            json,
1689        } => cmd_daemon(interval, once, json),
1690        Command::PairHost {
1691            relay,
1692            yes,
1693            timeout,
1694            detach,
1695            json,
1696        } => {
1697            if detach {
1698                cmd_pair_host_detach(&relay, json)
1699            } else {
1700                cmd_pair_host(&relay, yes, timeout)
1701            }
1702        }
1703        Command::PairJoin {
1704            code_phrase,
1705            relay,
1706            yes,
1707            timeout,
1708            detach,
1709            json,
1710        } => {
1711            if detach {
1712                cmd_pair_join_detach(&code_phrase, &relay, json)
1713            } else {
1714                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1715            }
1716        }
1717        Command::PairConfirm {
1718            code_phrase,
1719            digits,
1720            json,
1721        } => cmd_pair_confirm(&code_phrase, &digits, json),
1722        Command::PairList {
1723            json,
1724            watch,
1725            watch_interval,
1726        } => cmd_pair_list(json, watch, watch_interval),
1727        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1728        Command::PairWatch {
1729            code_phrase,
1730            status,
1731            timeout,
1732            json,
1733        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1734        Command::Pair {
1735            handle,
1736            code,
1737            relay,
1738            yes,
1739            timeout,
1740            no_setup,
1741            detach,
1742        } => {
1743            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1744            // the zero-paste megacommand path — `wire pair slancha-spark@
1745            // wireup.net` does add + poll-for-ack + verify in one shot. The
1746            // SAS / code-based pair flow stays available for handles without
1747            // `@` (bootstrap pairing between two boxes that don't yet share a
1748            // relay directory).
1749            if handle.contains('@') && code.is_none() {
1750                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1751            } else if detach {
1752                cmd_pair_detach(&handle, code.as_deref(), &relay)
1753            } else {
1754                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1755            }
1756        }
1757        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1758        Command::PairAccept { peer, json } => {
1759            let j = json_default(json);
1760            deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1761            cmd_pair_accept(&peer, j)
1762        }
1763        Command::PairReject { peer, json } => {
1764            let j = json_default(json);
1765            deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1766            cmd_pair_reject(&peer, j)
1767        }
1768        Command::PairListInbound { json } => {
1769            let j = json_default(json);
1770            deprecation_warn("pair-list-inbound", "pending", j);
1771            cmd_pair_list_inbound(j)
1772        }
1773        Command::Session(cmd) => cmd_session(cmd),
1774        Command::Identity { cmd } => cmd_identity(cmd),
1775        Command::Mesh(cmd) => cmd_mesh(cmd),
1776        Command::Group(cmd) => cmd_group(cmd),
1777        Command::Enroll(cmd) => cmd_enroll(cmd),
1778        Command::Invite {
1779            relay,
1780            ttl,
1781            uses,
1782            share,
1783            json,
1784        } => cmd_invite(&relay, ttl, uses, share, json),
1785        Command::Accept { target, json } => {
1786            // v0.9.4: smart-dispatch retired. `wire accept` always means
1787            // pair-accept by name. URL-shaped input gets a deprecation
1788            // banner pointing at `wire accept-invite <URL>` and then
1789            // (for back-compat with v0.9 scripts) routes to the invite
1790            // accept path one last time. v1.0 will reject URLs here.
1791            let j = json_default(json);
1792            if target.starts_with("wire://pair?") {
1793                deprecation_warn("accept-url", "accept-invite <url>", j);
1794                cmd_accept(&target, j)
1795            } else {
1796                cmd_pair_accept(&target, j)
1797            }
1798        }
1799        Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1800        Command::Whois {
1801            handle,
1802            json,
1803            relay,
1804        } => {
1805            // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1806            // resolves through the local identity layer (pinned peers
1807            // + local sister sessions). `wire whois <nick>@<relay>`
1808            // keeps the existing federation `.well-known/wire/agent`
1809            // path. `wire whois` (no arg) prints self via the original
1810            // path. The character nickname is the canonical operator-
1811            // facing name as of v0.8 — most callers should hit the
1812            // local route.
1813            match handle.as_deref() {
1814                Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1815                other => cmd_whois(other, json, relay.as_deref()),
1816            }
1817        }
1818        Command::Add {
1819            handle,
1820            relay,
1821            local_sister,
1822            json,
1823        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1824        Command::Up {
1825            relay,
1826            name,
1827            with_local,
1828            no_local,
1829            json,
1830        } => cmd_up(
1831            relay.as_deref(),
1832            name.as_deref(),
1833            with_local.as_deref(),
1834            no_local,
1835            json,
1836        ),
1837        Command::Doctor {
1838            json,
1839            recent_rejections,
1840        } => cmd_doctor(json, recent_rejections),
1841        Command::Upgrade { check, local, json } => cmd_upgrade(check, local, json),
1842        Command::Service { action } => cmd_service(action),
1843        Command::Diag { action } => cmd_diag(action),
1844        Command::Claim {
1845            nick,
1846            relay,
1847            public_url,
1848            hidden,
1849            json,
1850        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1851        Command::Profile { action } => cmd_profile(action),
1852        Command::Setup {
1853            apply,
1854            statusline,
1855            remove,
1856        } => {
1857            if statusline {
1858                cmd_setup_statusline(apply, remove)
1859            } else {
1860                cmd_setup(apply)
1861            }
1862        }
1863        Command::Notify {
1864            interval,
1865            peer,
1866            once,
1867            json,
1868        } => cmd_notify(interval, peer.as_deref(), once, json),
1869    }
1870}
1871
1872// ---------- init ----------
1873
1874fn cmd_init(
1875    handle: Option<&str>,
1876    name: Option<&str>,
1877    relay: Option<&str>,
1878    offline: bool,
1879    as_json: bool,
1880) -> Result<()> {
1881    // One-name rule: a typed handle (if any) is only a vanity seed — the
1882    // persona is derived from the keypair fingerprint, so it has no effect
1883    // on the resulting identity. `wire up` passes None (there is no name to
1884    // type); an explicit `wire init <handle>` passes Some and we surface the
1885    // "ignored in favor of persona" notice for transparency.
1886    if let Some(h) = handle
1887        && !h
1888            .chars()
1889            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1890    {
1891        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
1892    }
1893    if config::is_initialized()? {
1894        bail!(
1895            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1896            config::config_dir()?
1897        );
1898    }
1899    // v0.9.1 smart-default reachability. If the operator passed neither
1900    // --relay nor --offline, probe the conventional local relay at
1901    // http://127.0.0.1:8771 and auto-attach if healthy. Closes the
1902    // silent-slotless footgun WITHOUT the v0.9 rejection wall, which
1903    // forced operators through a three-flag decision tree on first
1904    // invocation. Bare `wire init <handle>` is now ergonomic again
1905    // whenever a local relay is running (the common dev setup).
1906    //
1907    // Probe order:
1908    //   1. --relay <url>          → use it
1909    //   2. --offline               → skip slot allocation (rare power-user)
1910    //   3. local relay reachable  → auto-attach + log to stderr
1911    //   4. otherwise               → bail with actionable options
1912    let mut resolved_relay: Option<String> = relay.map(str::to_string);
1913    if resolved_relay.is_none() && !offline {
1914        let default_local = "http://127.0.0.1:8771";
1915        let client = crate::relay_client::RelayClient::new(default_local);
1916        if client.check_healthz().is_ok() {
1917            eprintln!(
1918                "wire init: local relay at {default_local} reachable — auto-attaching. \
1919                 Use --relay <url> to pick a different relay, --offline to skip."
1920            );
1921            resolved_relay = Some(default_local.to_string());
1922        } else {
1923            // v0.9.5: interactive prompt for first-time operators
1924            // when the smart-default can't auto-attach. Detect TTY on
1925            // stdin AND stderr — only prompt for humans. CI / agents
1926            // / non-interactive shells fall through to the explicit
1927            // error wall (unchanged behavior since v0.9.1).
1928            use std::io::{BufRead, IsTerminal, Write};
1929            let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1930            if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1931                eprintln!("wire init: no local relay reachable at {default_local}.");
1932                eprint!(
1933                    "  Bind to public federation relay https://wireup.net instead? \
1934                     [Y/n/offline/url]: "
1935                );
1936                let _ = std::io::stderr().flush();
1937                let mut input = String::new();
1938                let _ = std::io::stdin().lock().read_line(&mut input);
1939                let answer = input.trim();
1940                match answer {
1941                    "" | "y" | "Y" | "yes" | "YES" => {
1942                        eprintln!("wire init: binding to https://wireup.net");
1943                        resolved_relay = Some("https://wireup.net".to_string());
1944                    }
1945                    "n" | "N" | "no" | "NO" => {
1946                        bail!(
1947                            "wire init: declined federation default; re-run with --relay <url> or --offline."
1948                        );
1949                    }
1950                    "offline" | "OFFLINE" => {
1951                        eprintln!(
1952                            "wire init: proceeding offline. \
1953                             Run `wire bind-relay <url>` before pairing."
1954                        );
1955                        // Fall through with resolved_relay still None;
1956                        // the `offline` flag is conceptually set but
1957                        // the caller's local doesn't need updating —
1958                        // resolved_relay = None + offline behavior
1959                        // is identical for the rest of cmd_init.
1960                    }
1961                    url if url.starts_with("http://") || url.starts_with("https://") => {
1962                        eprintln!("wire init: binding to {url}");
1963                        resolved_relay = Some(url.to_string());
1964                    }
1965                    other => {
1966                        bail!(
1967                            "wire init: unrecognized answer `{other}` — \
1968                             expected Y/n/offline/<url>. Re-run with --relay or --offline."
1969                        );
1970                    }
1971                }
1972            } else {
1973                bail!(
1974                    "wire init: no relay specified and no local relay reachable at \
1975                     http://127.0.0.1:8771.\n\
1976                     Pick one (or just run `wire up`):\n\
1977                     • `wire service install --local-relay` — start the local relay, then re-run\n\
1978                     • `wire up @wireup.net` — bind to public federation in one command\n\
1979                     • `wire init --offline` — generate keypair only \
1980                     (peers cannot reach you until you `wire bind-relay <url>` later)"
1981                );
1982            }
1983        }
1984    }
1985    let relay = resolved_relay.as_deref();
1986
1987    config::ensure_dirs()?;
1988    let (sk_seed, pk_bytes) = generate_keypair();
1989    config::write_private_key(&sk_seed)?;
1990
1991    // v0.11 ONE-NAME: derive the character nickname from a synthetic DID
1992    // using the freshly-generated pubkey, then USE THE CHARACTER as the
1993    // canonical handle. The operator-typed `handle` arg becomes either:
1994    //   - identical to character (already-canonical input — no-op), OR
1995    //   - overridden in favor of character (operator-typed name was a
1996    //     vanity layer that would never have been federation-reachable).
1997    // Either way, agent-card.handle ends up == character, and every
1998    // downstream surface (relay phonebook, .well-known, dial/send) keys
1999    // on the same name an operator sees in their statusline.
2000    //
2001    // Per the v0.11 directive: "If you can't call someone via a name,
2002    // don't let them have it as a name." Operator-typed handles violated
2003    // that rule because the character was the displayed name but the
2004    // handle was the addressable one. Now they're the same string.
2005    // The seed string only fills the (immediately-discarded) handle portion
2006    // of a synthetic DID; the persona derives from the fp suffix regardless,
2007    // so any seed yields the same identity.
2008    let seed = handle.unwrap_or("agent");
2009    let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
2010    let character = crate::character::Character::from_did(&synth_did);
2011    let canonical_handle: &str = &character.nickname;
2012    if let Some(typed) = handle
2013        && typed != canonical_handle
2014    {
2015        eprintln!(
2016            "wire init: one-name rule — typed `{typed}` ignored in favor of \
2017             DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
2018        );
2019    }
2020
2021    let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
2022    // Card-emit (RFC-001 Phase 1b): attach operator/org claims if enrolled
2023    // (fail-soft no-op otherwise; signed below so the sig covers the claims).
2024    let card = crate::enroll::with_op_claims_if_enrolled(card)?;
2025    let signed = sign_agent_card(&card, &sk_seed);
2026    config::write_agent_card(&signed)?;
2027
2028    let mut trust = empty_trust();
2029    add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
2030    config::write_trust(&trust)?;
2031
2032    let fp = fingerprint(&pk_bytes);
2033    let key_id = make_key_id(canonical_handle, &pk_bytes);
2034    // Rebind `handle` for the rest of cmd_init so downstream prints,
2035    // relay-state writes, etc. all reference the canonical name.
2036    let handle = canonical_handle;
2037
2038    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
2039    let mut relay_info: Option<(String, String)> = None;
2040    if let Some(url) = relay {
2041        let normalized = url.trim_end_matches('/');
2042        let client = crate::relay_client::RelayClient::new(normalized);
2043        client.check_healthz()?;
2044        let alloc = client.allocate_slot(Some(handle))?;
2045        let mut state = config::read_relay_state()?;
2046        state["self"] = json!({
2047            "relay_url": normalized,
2048            "slot_id": alloc.slot_id.clone(),
2049            "slot_token": alloc.slot_token,
2050        });
2051        config::write_relay_state(&state)?;
2052        relay_info = Some((normalized.to_string(), alloc.slot_id));
2053    }
2054
2055    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
2056    if as_json {
2057        let mut out = json!({
2058            "did": did_str.clone(),
2059            "fingerprint": fp,
2060            "key_id": key_id,
2061            "config_dir": config::config_dir()?.to_string_lossy(),
2062        });
2063        if let Some((url, slot_id)) = &relay_info {
2064            out["relay_url"] = json!(url);
2065            out["slot_id"] = json!(slot_id);
2066        }
2067        println!("{}", serde_json::to_string(&out)?);
2068    } else {
2069        println!("generated {did_str} (ed25519:{key_id})");
2070        println!(
2071            "config written to {}",
2072            config::config_dir()?.to_string_lossy()
2073        );
2074        if let Some((url, slot_id)) = &relay_info {
2075            println!("bound to relay {url} (slot {slot_id})");
2076            println!();
2077            println!(
2078                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
2079            );
2080        } else {
2081            println!();
2082            println!(
2083                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
2084            );
2085        }
2086    }
2087    Ok(())
2088}
2089
2090// ---------- status ----------
2091
2092fn cmd_status(as_json: bool) -> Result<()> {
2093    let initialized = config::is_initialized()?;
2094
2095    let mut summary = json!({
2096        "initialized": initialized,
2097    });
2098
2099    if initialized {
2100        let card = config::read_agent_card()?;
2101        let did = card
2102            .get("did")
2103            .and_then(Value::as_str)
2104            .unwrap_or("")
2105            .to_string();
2106        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
2107        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
2108        // legacy cards.
2109        let handle = card
2110            .get("handle")
2111            .and_then(Value::as_str)
2112            .map(str::to_string)
2113            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2114        let pk_b64 = card
2115            .get("verify_keys")
2116            .and_then(Value::as_object)
2117            .and_then(|m| m.values().next())
2118            .and_then(|v| v.get("key"))
2119            .and_then(Value::as_str)
2120            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2121        let pk_bytes = crate::signing::b64decode(pk_b64)?;
2122        summary["did"] = json!(did);
2123        summary["handle"] = json!(handle);
2124        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2125        summary["capabilities"] = card
2126            .get("capabilities")
2127            .cloned()
2128            .unwrap_or_else(|| json!([]));
2129
2130        let trust = config::read_trust()?;
2131        let relay_state_for_tier =
2132            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2133        let mut peers = Vec::new();
2134        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2135            for (peer_handle, _agent) in agents {
2136                if peer_handle == &handle {
2137                    continue; // self
2138                }
2139                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
2140                // for peers we've pinned but never received a pair_drop_ack
2141                // from, so the operator sees the "we can't send to them yet"
2142                // state instead of seeing a misleading VERIFIED.
2143                peers.push(json!({
2144                    "handle": peer_handle,
2145                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2146                }));
2147            }
2148        }
2149        summary["peers"] = json!(peers);
2150
2151        let relay_state = config::read_relay_state()?;
2152        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2153        if !summary["self_relay"].is_null() {
2154            // Hide slot_token from default view.
2155            if let Some(obj) = summary["self_relay"].as_object_mut() {
2156                obj.remove("slot_token");
2157            }
2158        }
2159        summary["peer_slots_count"] = json!(
2160            relay_state
2161                .get("peers")
2162                .and_then(Value::as_object)
2163                .map(|m| m.len())
2164                .unwrap_or(0)
2165        );
2166
2167        // Outbox / inbox queue depth (file count + total events)
2168        let outbox = config::outbox_dir()?;
2169        let inbox = config::inbox_dir()?;
2170        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2171        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2172
2173        // v0.5.19: liveness snapshot through a single helper so this
2174        // surface and `wire doctor` agree by construction. Issue #2:
2175        // doctor PASSed while status said DOWN for 25 min because each
2176        // computed liveness independently. ensure_up::daemon_liveness
2177        // is the only path now.
2178        let snap = crate::ensure_up::daemon_liveness();
2179        let mut daemon = json!({
2180            "running": snap.pidfile_alive,
2181            "pid": snap.pidfile_pid,
2182            "all_running_pids": snap.pgrep_pids,
2183            "orphans": snap.orphan_pids,
2184        });
2185        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2186            daemon["version"] = json!(d.version);
2187            daemon["bin_path"] = json!(d.bin_path);
2188            daemon["did"] = json!(d.did);
2189            daemon["relay_url"] = json!(d.relay_url);
2190            daemon["started_at"] = json!(d.started_at);
2191            daemon["schema"] = json!(d.schema);
2192            if d.version != env!("CARGO_PKG_VERSION") {
2193                daemon["version_mismatch"] = json!({
2194                    "daemon": d.version.clone(),
2195                    "cli": env!("CARGO_PKG_VERSION"),
2196                });
2197            }
2198        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2199            daemon["pidfile_form"] = json!("legacy-int");
2200            daemon["version_mismatch"] = json!({
2201                "daemon": "<pre-0.5.11>",
2202                "cli": env!("CARGO_PKG_VERSION"),
2203            });
2204        }
2205        summary["daemon"] = daemon;
2206
2207        // Pending pair sessions — counts by status.
2208        let pending = crate::pending_pair::list_pending().unwrap_or_default();
2209        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2210        for p in &pending {
2211            *counts.entry(p.status.clone()).or_default() += 1;
2212        }
2213        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
2214        let pending_inbound =
2215            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2216        let inbound_handles: Vec<&str> = pending_inbound
2217            .iter()
2218            .map(|p| p.peer_handle.as_str())
2219            .collect();
2220        summary["pending_pairs"] = json!({
2221            "total": pending.len(),
2222            "by_status": counts,
2223            "inbound_count": pending_inbound.len(),
2224            "inbound_handles": inbound_handles,
2225        });
2226    }
2227
2228    if as_json {
2229        println!("{}", serde_json::to_string(&summary)?);
2230    } else if !initialized {
2231        println!("not initialized — run `wire init <handle>` first");
2232    } else {
2233        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
2234        println!(
2235            "fingerprint:   {}",
2236            summary["fingerprint"].as_str().unwrap_or("?")
2237        );
2238        println!("capabilities:  {}", summary["capabilities"]);
2239        if !summary["self_relay"].is_null() {
2240            println!(
2241                "self relay:    {} (slot {})",
2242                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2243                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2244            );
2245        } else {
2246            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
2247        }
2248        println!(
2249            "peers:         {}",
2250            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2251        );
2252        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2253            println!(
2254                "  - {:<20} tier={}",
2255                p["handle"].as_str().unwrap_or(""),
2256                p["tier"].as_str().unwrap_or("?")
2257            );
2258        }
2259        println!(
2260            "outbox:        {} file(s), {} event(s) queued",
2261            summary["outbox"]["files"].as_u64().unwrap_or(0),
2262            summary["outbox"]["events"].as_u64().unwrap_or(0)
2263        );
2264        println!(
2265            "inbox:         {} file(s), {} event(s) received",
2266            summary["inbox"]["files"].as_u64().unwrap_or(0),
2267            summary["inbox"]["events"].as_u64().unwrap_or(0)
2268        );
2269        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2270        let daemon_pid = summary["daemon"]["pid"]
2271            .as_u64()
2272            .map(|p| p.to_string())
2273            .unwrap_or_else(|| "—".to_string());
2274        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2275        let version_suffix = if !daemon_version.is_empty() {
2276            format!(" v{daemon_version}")
2277        } else {
2278            String::new()
2279        };
2280        println!(
2281            "daemon:        {} (pid {}{})",
2282            if daemon_running { "running" } else { "DOWN" },
2283            daemon_pid,
2284            version_suffix,
2285        );
2286        // P1.7: surface version mismatch + orphan procs loudly.
2287        if let Some(mm) = summary["daemon"].get("version_mismatch") {
2288            println!(
2289                "               !! version mismatch: daemon={} CLI={}. \
2290                 run `wire upgrade` to swap atomically.",
2291                mm["daemon"].as_str().unwrap_or("?"),
2292                mm["cli"].as_str().unwrap_or("?"),
2293            );
2294        }
2295        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2296            && !orphans.is_empty()
2297        {
2298            let pids: Vec<String> = orphans
2299                .iter()
2300                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2301                .collect();
2302            println!(
2303                "               !! orphan daemon process(es): pids {}. \
2304                 pgrep saw them but pidfile didn't — likely stale process from \
2305                 prior install. Multiple daemons race the relay cursor.",
2306                pids.join(", ")
2307            );
2308        }
2309        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2310        let inbound_count = summary["pending_pairs"]["inbound_count"]
2311            .as_u64()
2312            .unwrap_or(0);
2313        if pending_total > 0 {
2314            print!("pending pairs: {pending_total}");
2315            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2316                let parts: Vec<String> = obj
2317                    .iter()
2318                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2319                    .collect();
2320                if !parts.is_empty() {
2321                    print!(" ({})", parts.join(", "));
2322                }
2323            }
2324            println!();
2325        } else if inbound_count == 0 {
2326            println!("pending pairs: none");
2327        }
2328        // v0.5.14: separate line for pending-inbound zero-paste requests.
2329        // Loud because each one is awaiting an operator gesture and the
2330        // capability hasn't flowed yet.
2331        if inbound_count > 0 {
2332            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2333                .as_array()
2334                .map(|a| {
2335                    a.iter()
2336                        .filter_map(|v| v.as_str().map(str::to_string))
2337                        .collect()
2338                })
2339                .unwrap_or_default();
2340            println!(
2341                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2342                handles.join(", "),
2343            );
2344        }
2345    }
2346    Ok(())
2347}
2348
2349fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2350    if !dir.exists() {
2351        return Ok(json!({"files": 0, "events": 0}));
2352    }
2353    let mut files = 0usize;
2354    let mut events = 0usize;
2355    for entry in std::fs::read_dir(dir)? {
2356        let path = entry?.path();
2357        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2358            files += 1;
2359            if let Ok(body) = std::fs::read_to_string(&path) {
2360                events += body.lines().filter(|l| !l.trim().is_empty()).count();
2361            }
2362        }
2363    }
2364    Ok(json!({"files": files, "events": events}))
2365}
2366
2367// ---------- responder health ----------
2368
2369fn responder_status_allowed(status: &str) -> bool {
2370    matches!(
2371        status,
2372        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2373    )
2374}
2375
2376fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2377    let state = config::read_relay_state()?;
2378    let (label, slot_info) = match peer {
2379        Some(peer) => (
2380            peer.to_string(),
2381            state
2382                .get("peers")
2383                .and_then(|p| p.get(peer))
2384                .ok_or_else(|| {
2385                    anyhow!(
2386                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
2387                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
2388                         (`wire peers` lists who you've already paired with.)"
2389                    )
2390                })?,
2391        ),
2392        None => (
2393            "self".to_string(),
2394            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2395                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2396            })?,
2397        ),
2398    };
2399    let relay_url = slot_info["relay_url"]
2400        .as_str()
2401        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2402        .to_string();
2403    let slot_id = slot_info["slot_id"]
2404        .as_str()
2405        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2406        .to_string();
2407    let slot_token = slot_info["slot_token"]
2408        .as_str()
2409        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2410        .to_string();
2411    Ok((label, relay_url, slot_id, slot_token))
2412}
2413
2414fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2415    if !responder_status_allowed(status) {
2416        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2417    }
2418    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2419    let now = time::OffsetDateTime::now_utc()
2420        .format(&time::format_description::well_known::Rfc3339)
2421        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2422    let mut record = json!({
2423        "status": status,
2424        "set_at": now,
2425    });
2426    if let Some(reason) = reason {
2427        record["reason"] = json!(reason);
2428    }
2429    if status == "online" {
2430        record["last_success_at"] = json!(now);
2431    }
2432    let client = crate::relay_client::RelayClient::new(&relay_url);
2433    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2434    if as_json {
2435        println!("{}", serde_json::to_string(&saved)?);
2436    } else {
2437        let reason = saved
2438            .get("reason")
2439            .and_then(Value::as_str)
2440            .map(|r| format!(" — {r}"))
2441            .unwrap_or_default();
2442        println!(
2443            "responder {}{}",
2444            saved
2445                .get("status")
2446                .and_then(Value::as_str)
2447                .unwrap_or(status),
2448            reason
2449        );
2450    }
2451    Ok(())
2452}
2453
2454fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2455    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2456    let client = crate::relay_client::RelayClient::new(&relay_url);
2457    let health = client.responder_health_get(&slot_id, &slot_token)?;
2458    if as_json {
2459        println!(
2460            "{}",
2461            serde_json::to_string(&json!({
2462                "target": label,
2463                "responder_health": health,
2464            }))?
2465        );
2466    } else if health.is_null() {
2467        println!("{label}: responder health not reported");
2468    } else {
2469        let status = health
2470            .get("status")
2471            .and_then(Value::as_str)
2472            .unwrap_or("unknown");
2473        let reason = health
2474            .get("reason")
2475            .and_then(Value::as_str)
2476            .map(|r| format!(" — {r}"))
2477            .unwrap_or_default();
2478        let last_success = health
2479            .get("last_success_at")
2480            .and_then(Value::as_str)
2481            .map(|t| format!(" (last_success: {t})"))
2482            .unwrap_or_default();
2483        println!("{label}: {status}{reason}{last_success}");
2484    }
2485    Ok(())
2486}
2487
2488fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2489    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2490    let client = crate::relay_client::RelayClient::new(&relay_url);
2491
2492    let started = std::time::Instant::now();
2493    let transport_ok = client.healthz().unwrap_or(false);
2494    let latency_ms = started.elapsed().as_millis() as u64;
2495
2496    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2497    let now = std::time::SystemTime::now()
2498        .duration_since(std::time::UNIX_EPOCH)
2499        .map(|d| d.as_secs())
2500        .unwrap_or(0);
2501    let attention = match last_pull_at_unix {
2502        Some(last) if now.saturating_sub(last) <= 300 => json!({
2503            "status": "ok",
2504            "last_pull_at_unix": last,
2505            "age_seconds": now.saturating_sub(last),
2506            "event_count": event_count,
2507        }),
2508        Some(last) => json!({
2509            "status": "stale",
2510            "last_pull_at_unix": last,
2511            "age_seconds": now.saturating_sub(last),
2512            "event_count": event_count,
2513        }),
2514        None => json!({
2515            "status": "never_pulled",
2516            "last_pull_at_unix": Value::Null,
2517            "event_count": event_count,
2518        }),
2519    };
2520
2521    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2522    let responder = if responder_health.is_null() {
2523        json!({"status": "not_reported", "record": Value::Null})
2524    } else {
2525        json!({
2526            "status": responder_health
2527                .get("status")
2528                .and_then(Value::as_str)
2529                .unwrap_or("unknown"),
2530            "record": responder_health,
2531        })
2532    };
2533
2534    let report = json!({
2535        "peer": peer,
2536        "transport": {
2537            "status": if transport_ok { "ok" } else { "error" },
2538            "relay_url": relay_url,
2539            "latency_ms": latency_ms,
2540        },
2541        "attention": attention,
2542        "responder": responder,
2543    });
2544
2545    if as_json {
2546        println!("{}", serde_json::to_string(&report)?);
2547    } else {
2548        let transport_line = if transport_ok {
2549            format!("ok relay reachable ({latency_ms}ms)")
2550        } else {
2551            "error relay unreachable".to_string()
2552        };
2553        println!("transport      {transport_line}");
2554        match report["attention"]["status"].as_str().unwrap_or("unknown") {
2555            "ok" => println!(
2556                "attention      ok last pull {}s ago",
2557                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2558            ),
2559            "stale" => println!(
2560                "attention      stale last pull {}m ago",
2561                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2562            ),
2563            "never_pulled" => println!("attention      never pulled since relay reset"),
2564            other => println!("attention      {other}"),
2565        }
2566        if report["responder"]["status"] == "not_reported" {
2567            println!("auto-responder not reported");
2568        } else {
2569            let record = &report["responder"]["record"];
2570            let status = record
2571                .get("status")
2572                .and_then(Value::as_str)
2573                .unwrap_or("unknown");
2574            let reason = record
2575                .get("reason")
2576                .and_then(Value::as_str)
2577                .map(|r| format!(" — {r}"))
2578                .unwrap_or_default();
2579            println!("auto-responder {status}{reason}");
2580        }
2581    }
2582    Ok(())
2583}
2584
2585// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
2586
2587// ---------- whoami ----------
2588
2589/// Return the current cwd with the user's home dir abbreviated to `~/`.
2590/// Used in whoami `--short` / `--colored` output so multi-window operators
2591/// see *what project* each Claude is working in alongside the character.
2592fn current_cwd_display() -> String {
2593    let cwd = match std::env::current_dir() {
2594        Ok(c) => c,
2595        Err(_) => return String::from("?"),
2596    };
2597    if let Some(home) = dirs::home_dir()
2598        && let Ok(rel) = cwd.strip_prefix(&home)
2599    {
2600        // strip_prefix returns "" for cwd == home itself; show "~" then.
2601        let rel_str = rel.to_string_lossy();
2602        if rel_str.is_empty() {
2603            return String::from("~");
2604        }
2605        return format!("~/{}", rel_str);
2606    }
2607    cwd.to_string_lossy().into_owned()
2608}
2609
2610fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2611    if !config::is_initialized()? {
2612        bail!("not initialized — run `wire init <handle>` first");
2613    }
2614    let card = config::read_agent_card()?;
2615    let did = card
2616        .get("did")
2617        .and_then(Value::as_str)
2618        .unwrap_or("")
2619        .to_string();
2620    let handle = card
2621        .get("handle")
2622        .and_then(Value::as_str)
2623        .map(str::to_string)
2624        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2625    // v0.11: character is purely DID-derived. No overrides — the
2626    // operator-rename verb is gone and display.json reads are stripped
2627    // because they introduced a second name that peers couldn't find.
2628    let character = crate::character::Character::from_did(&did);
2629
2630    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
2631    // so operators tab-flipping between multiple Claude windows see both
2632    // *who* this session is (character) and *what* it's working on (cwd).
2633    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
2634    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
2635    let cwd_display = current_cwd_display();
2636
2637    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
2638    // beyond did — these calls are hot (statusline polls ~300ms).
2639    if short {
2640        println!("{} · {}", character.short(), cwd_display);
2641        return Ok(());
2642    }
2643    if colored {
2644        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2645        return Ok(());
2646    }
2647
2648    let pk_b64 = card
2649        .get("verify_keys")
2650        .and_then(Value::as_object)
2651        .and_then(|m| m.values().next())
2652        .and_then(|v| v.get("key"))
2653        .and_then(Value::as_str)
2654        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2655    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2656    let fp = fingerprint(&pk_bytes);
2657    let key_id = make_key_id(&handle, &pk_bytes);
2658    let capabilities = card
2659        .get("capabilities")
2660        .cloned()
2661        .unwrap_or_else(|| json!(["wire/v3.1"]));
2662
2663    if as_json {
2664        // v0.11: character_override is always false now (no rename verb,
2665        // no display.json reads). Field stays for back-compat with v0.10
2666        // JSON consumers that key off it.
2667        let has_override = false;
2668        println!(
2669            "{}",
2670            serde_json::to_string(&json!({
2671                "did": did,
2672                "handle": handle,
2673                "fingerprint": fp,
2674                "key_id": key_id,
2675                "public_key_b64": pk_b64,
2676                "capabilities": capabilities,
2677                "config_dir": config::config_dir()?.to_string_lossy(),
2678                "persona": character,
2679                "persona_override": has_override,
2680            }))?
2681        );
2682    } else {
2683        println!("{}", character.colored());
2684        println!("{did} (ed25519:{key_id})");
2685        println!("fingerprint: {fp}");
2686        println!("capabilities: {capabilities}");
2687    }
2688    Ok(())
2689}
2690
2691// ---------- identity (v0.7.0-alpha.3) ----------
2692
2693fn cmd_enroll(cmd: EnrollCommand) -> Result<()> {
2694    match cmd {
2695        EnrollCommand::Op { handle, json } => {
2696            let (sk, pk) = crate::signing::generate_keypair();
2697            crate::config::write_op_key(&sk)?;
2698            crate::config::write_op_handle(&handle)?;
2699            let op_did = crate::agent_card::did_for_op(&handle, &pk);
2700            let op_pubkey = crate::signing::b64encode(&pk);
2701            if json {
2702                println!(
2703                    "{}",
2704                    serde_json::to_string(&json!({"op_did": op_did, "op_pubkey": op_pubkey}))?
2705                );
2706            } else {
2707                println!(
2708                    "→ operator enrolled\n  op_did:    {op_did}\n  op_pubkey: {op_pubkey}\n  key saved 0600 at {:?}",
2709                    crate::config::op_key_path()?
2710                );
2711            }
2712            Ok(())
2713        }
2714        EnrollCommand::OrgCreate { handle, json } => {
2715            let (sk, pk) = crate::signing::generate_keypair();
2716            let org_did = crate::agent_card::did_for_org(&handle, &pk);
2717            crate::config::write_org_key(&org_did, &sk)?;
2718            let org_pubkey = crate::signing::b64encode(&pk);
2719            if json {
2720                println!(
2721                    "{}",
2722                    serde_json::to_string(&json!({"org_did": org_did, "org_pubkey": org_pubkey}))?
2723                );
2724            } else {
2725                println!(
2726                    "→ organization created\n  org_did:    {org_did}\n  org_pubkey: {org_pubkey}\n  key saved 0600 at {:?}",
2727                    crate::config::org_key_path(&org_did)?
2728                );
2729            }
2730            Ok(())
2731        }
2732        EnrollCommand::OrgAddMember { op_did, org, json } => {
2733            if !crate::agent_card::is_op_did(&op_did) {
2734                bail!("not a valid operator DID (did:wire:op:<handle>-<32hex>): {op_did}");
2735            }
2736            let org_sk = crate::config::read_org_key(&org).with_context(|| {
2737                format!("no stored key for org {org} — run `wire enroll org-create` first")
2738            })?;
2739            let org_pk = ed25519_dalek::SigningKey::from_bytes(&org_sk)
2740                .verifying_key()
2741                .to_bytes();
2742            let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did)?;
2743            let org_pubkey = crate::signing::b64encode(&org_pk);
2744            // Store locally so card-emit can attach it (same-machine operator);
2745            // also printed below for the cross-machine share case.
2746            crate::config::add_membership(&org, &org_pubkey, &member_cert)?;
2747            if json {
2748                println!(
2749                    "{}",
2750                    serde_json::to_string(&json!({
2751                        "org_did": org, "org_pubkey": org_pubkey, "member_cert": member_cert
2752                    }))?
2753                );
2754            } else {
2755                println!(
2756                    "→ 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}\"}}"
2757                );
2758            }
2759            Ok(())
2760        }
2761    }
2762}
2763
2764fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2765    match cmd {
2766        // v0.11: IdentityCommand::Rename deleted. The character is the
2767        // one canonical name (DID-derived); a local-display rename
2768        // would create a second name peers can't find, violating the
2769        // "names must be findable" invariant. Aliases (if needed
2770        // later) become relay-claimed entries that ARE findable —
2771        // a different architectural shape from rename.
2772        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2773        IdentityCommand::List { json } => cmd_session_list(json),
2774        IdentityCommand::Publish {
2775            nick,
2776            relay,
2777            public_url,
2778            hidden,
2779            json,
2780        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2781        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2782        IdentityCommand::Create {
2783            name,
2784            anonymous,
2785            local: _,
2786            json,
2787        } => cmd_identity_create(name.as_deref(), anonymous, json),
2788        IdentityCommand::Persist {
2789            name,
2790            as_name,
2791            json,
2792        } => cmd_identity_persist(&name, as_name.as_deref(), json),
2793        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2794    }
2795}
2796
2797/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
2798/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
2799/// paste into their shell; the identity lives there until reboot
2800/// clears /tmp. Persist promotes it to the real sessions root.
2801fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2802    if anonymous {
2803        // Generate a unique tmpdir for this anonymous identity.
2804        let rand_suffix = format!("{:08x}", rand::random::<u32>());
2805        let anon_name = name
2806            .map(crate::session::sanitize_name)
2807            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2808        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2809        std::fs::create_dir_all(&anon_root)
2810            .with_context(|| format!("creating anon root {anon_root:?}"))?;
2811        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
2812        let session_home = anon_root.join("sessions").join(&anon_name);
2813        std::fs::create_dir_all(&session_home)?;
2814        let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2815        if !status.success() {
2816            bail!("anonymous identity init failed: {status}");
2817        }
2818        // Register the anonymous name in a SIDE registry so persist
2819        // can find it later. Stored at <anon_root>/anon-marker.json.
2820        let marker = anon_root.join("anon-marker.json");
2821        std::fs::write(
2822            &marker,
2823            serde_json::to_vec_pretty(&serde_json::json!({
2824                "name": anon_name,
2825                "session_home": session_home.to_string_lossy(),
2826                "created_at": time::OffsetDateTime::now_utc()
2827                    .format(&time::format_description::well_known::Rfc3339)
2828                    .unwrap_or_default(),
2829                "kind": "anonymous",
2830            }))?,
2831        )?;
2832        let card = serde_json::from_slice::<Value>(&std::fs::read(
2833            session_home
2834                .join("config")
2835                .join("wire")
2836                .join("agent-card.json"),
2837        )?)?;
2838        let did = card
2839            .get("did")
2840            .and_then(Value::as_str)
2841            .unwrap_or("")
2842            .to_string();
2843        if as_json {
2844            println!(
2845                "{}",
2846                serde_json::to_string(&json!({
2847                    "kind": "anonymous",
2848                    "name": anon_name,
2849                    "did": did,
2850                    "session_home": session_home.to_string_lossy(),
2851                    "anon_root": anon_root.to_string_lossy(),
2852                }))?
2853            );
2854        } else {
2855            println!("created anonymous identity `{anon_name}` ({did})");
2856            println!(
2857                "  session_home: {} (dies on reboot — /tmp)",
2858                session_home.display()
2859            );
2860            println!();
2861            println!("activate in this shell:");
2862            println!("  export WIRE_HOME={}", session_home.display());
2863            println!();
2864            println!("promote to persistent later with:");
2865            println!("  wire identity persist {anon_name}");
2866        }
2867        return Ok(());
2868    }
2869    // --local (or default): delegate to existing session new flow.
2870    let name_arg = name.map(|s| s.to_string());
2871    cmd_session_new(
2872        name_arg.as_deref(),
2873        "https://wireup.net",
2874        false,
2875        "http://127.0.0.1:8771",
2876        false,
2877        None,
2878        false,
2879        None,
2880        true, // no_daemon: identity create just allocates the identity, no daemon
2881        true, // local_only: explicit lifecycle
2882        as_json,
2883    )
2884}
2885
2886/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
2887/// tmpdir to the persistent sessions root + registers in the cwd map.
2888fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2889    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
2890    let temp = std::env::temp_dir();
2891    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2892    for entry in std::fs::read_dir(&temp)?.flatten() {
2893        let path = entry.path();
2894        if !path
2895            .file_name()
2896            .and_then(|s| s.to_str())
2897            .map(|s| s.starts_with("wire-anon-"))
2898            .unwrap_or(false)
2899        {
2900            continue;
2901        }
2902        let marker = path.join("anon-marker.json");
2903        if let Ok(bytes) = std::fs::read(&marker)
2904            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2905            && json.get("name").and_then(Value::as_str) == Some(name)
2906        {
2907            let session_home = json
2908                .get("session_home")
2909                .and_then(Value::as_str)
2910                .map(std::path::PathBuf::from)
2911                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2912            found = Some((path, session_home));
2913            break;
2914        }
2915    }
2916    let (anon_root, anon_session_home) = found.ok_or_else(|| {
2917        anyhow!(
2918            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2919             run `wire identity list` to see available identities"
2920        )
2921    })?;
2922
2923    let new_name = as_name.unwrap_or(name);
2924    let new_session_home = crate::session::session_dir(new_name)?;
2925    if new_session_home.exists() {
2926        bail!(
2927            "target session `{new_name}` already exists at {new_session_home:?} — \
2928             pick a different name with --as <new-name>"
2929        );
2930    }
2931
2932    // Move the session dir from tmpdir to persistent root.
2933    if let Some(parent) = new_session_home.parent() {
2934        std::fs::create_dir_all(parent)?;
2935    }
2936    std::fs::rename(&anon_session_home, &new_session_home)
2937        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2938
2939    // Clean up the (now-empty) anon root + marker.
2940    let _ = std::fs::remove_dir_all(&anon_root);
2941
2942    // Register cwd → new_name (operator may have cd'd elsewhere; use the
2943    // session_home's grandparent as the conceptual "cwd" if no other).
2944    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2945    let cwd_key = crate::session::normalize_cwd_key(&cwd);
2946    let new_name_for_reg = new_name.to_string();
2947    if let Err(e) = crate::session::update_registry(|reg| {
2948        reg.by_cwd.insert(cwd_key, new_name_for_reg);
2949        Ok(())
2950    }) {
2951        eprintln!("wire identity persist: failed to update registry: {e:#}");
2952    }
2953
2954    if as_json {
2955        println!(
2956            "{}",
2957            serde_json::to_string(&json!({
2958                "kind": "persisted",
2959                "from_name": name,
2960                "to_name": new_name,
2961                "session_home": new_session_home.to_string_lossy(),
2962            }))?
2963        );
2964    } else {
2965        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2966        println!(
2967            "  session_home: {} (survives reboot)",
2968            new_session_home.display()
2969        );
2970        println!("  registered cwd: {}", cwd.display());
2971    }
2972    Ok(())
2973}
2974
2975/// v0.7.0-alpha.20: demote federation → local. Removes the federation
2976/// slot binding from relay.json (and the legacy top-level fields). Keeps
2977/// the keypair + agent-card so re-publish later just calls `wire identity
2978/// publish` again. local → anonymous is NOT supported; destroy + recreate
2979/// is the safer path for that step-down.
2980fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2981    let sessions = crate::session::list_sessions()?;
2982    let session = sessions
2983        .iter()
2984        .find(|s| s.name == name)
2985        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2986    let relay_state_path = session
2987        .home_dir
2988        .join("config")
2989        .join("wire")
2990        .join("relay.json");
2991    if !relay_state_path.exists() {
2992        bail!("session `{name}` has no relay state — already demoted?");
2993    }
2994    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2995    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2996    let had_fed = self_obj
2997        .get("relay_url")
2998        .and_then(Value::as_str)
2999        .map(|u| {
3000            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
3001        })
3002        .unwrap_or(false);
3003    if !had_fed {
3004        if as_json {
3005            println!(
3006                "{}",
3007                serde_json::to_string(
3008                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
3009                )?
3010            );
3011        } else {
3012            println!("session `{name}` has no federation slot — nothing to demote");
3013        }
3014        return Ok(());
3015    }
3016    // Strip federation: remove top-level relay_url/slot_id/slot_token,
3017    // remove federation-scope entries from endpoints[].
3018    if let Some(self_mut) = state
3019        .as_object_mut()
3020        .and_then(|m| m.get_mut("self"))
3021        .and_then(|s| s.as_object_mut())
3022    {
3023        self_mut.remove("relay_url");
3024        self_mut.remove("slot_id");
3025        self_mut.remove("slot_token");
3026        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
3027            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
3028        }
3029    }
3030    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
3031
3032    if as_json {
3033        println!(
3034            "{}",
3035            serde_json::to_string(
3036                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
3037            )?
3038        );
3039    } else {
3040        println!("demoted `{name}` from federation → local");
3041        println!("  relay slot binding removed; keypair + agent-card retained");
3042        println!("  re-publish with `wire identity publish <nick>`");
3043    }
3044    Ok(())
3045}
3046
3047fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
3048    let raw = crate::trust::get_tier(trust, handle);
3049    if raw != "VERIFIED" {
3050        return raw.to_string();
3051    }
3052    let token = relay_state
3053        .get("peers")
3054        .and_then(|p| p.get(handle))
3055        .and_then(|p| p.get("slot_token"))
3056        .and_then(Value::as_str)
3057        .unwrap_or("");
3058    if token.is_empty() {
3059        "PENDING_ACK".to_string()
3060    } else {
3061        raw.to_string()
3062    }
3063}
3064
3065fn cmd_peers(as_json: bool) -> Result<()> {
3066    let trust = config::read_trust()?;
3067    let agents = trust
3068        .get("agents")
3069        .and_then(Value::as_object)
3070        .cloned()
3071        .unwrap_or_default();
3072    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
3073
3074    let mut self_did: Option<String> = None;
3075    if let Ok(card) = config::read_agent_card() {
3076        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
3077    }
3078
3079    let mut peers = Vec::new();
3080    for (handle, agent) in agents.iter() {
3081        let did = agent
3082            .get("did")
3083            .and_then(Value::as_str)
3084            .unwrap_or("")
3085            .to_string();
3086        if Some(did.as_str()) == self_did.as_deref() {
3087            continue; // skip self-attestation
3088        }
3089        let tier = effective_peer_tier(&trust, &relay_state, handle);
3090        let capabilities = agent
3091            .get("card")
3092            .and_then(|c| c.get("capabilities"))
3093            .cloned()
3094            .unwrap_or_else(|| json!([]));
3095        // v0.7.0-alpha.6: prefer peer's published character override
3096        // (display.nickname / display.emoji on their pinned agent-card).
3097        // Falls back to auto-derived if peer hasn't renamed themselves
3098        // OR runs an older wire that doesn't publish the field.
3099        let character = if did.is_empty() {
3100            None
3101        } else {
3102            let card_obj = agent.get("card");
3103            Some(match card_obj {
3104                Some(card) => crate::character::Character::from_card(card),
3105                None => crate::character::Character::from_did(&did),
3106            })
3107        };
3108        peers.push(json!({
3109            "handle": handle,
3110            "did": did,
3111            "tier": tier,
3112            "capabilities": capabilities,
3113            "persona": character,
3114        }));
3115    }
3116
3117    if as_json {
3118        println!("{}", serde_json::to_string(&peers)?);
3119    } else if peers.is_empty() {
3120        println!("no peers pinned (run `wire join <code>` to pair)");
3121    } else {
3122        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
3123        // computed above (from peer's agent-card, honoring override) so
3124        // text and JSON output never diverge. Pre-alpha.8 the text loop
3125        // recomputed via Character::from_did (no override) — operators
3126        // saw different identities depending on --json flag.
3127        for p in &peers {
3128            let char_json = &p["persona"];
3129            let (colored_char, plain_len): (String, usize) = match char_json {
3130                serde_json::Value::Null => ("?".to_string(), 1),
3131                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3132                    Ok(c) => {
3133                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
3134                        (c.colored(), plain)
3135                    }
3136                    Err(_) => ("?".to_string(), 1),
3137                },
3138            };
3139            let pad = 22usize.saturating_sub(plain_len);
3140            println!(
3141                "{}{}  {:<20} {:<10} {}",
3142                colored_char,
3143                " ".repeat(pad),
3144                p["handle"].as_str().unwrap_or(""),
3145                p["tier"].as_str().unwrap_or(""),
3146                p["did"].as_str().unwrap_or(""),
3147            );
3148        }
3149    }
3150    Ok(())
3151}
3152
3153// ---------- send ----------
3154
3155/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
3156///
3157/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
3158/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
3159/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
3160/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
3161/// hasn't crossed two heartbeats means probably degraded.
3162fn maybe_warn_peer_attentiveness(peer: &str) {
3163    let state = match config::read_relay_state() {
3164        Ok(s) => s,
3165        Err(_) => return,
3166    };
3167    let p = state.get("peers").and_then(|p| p.get(peer));
3168    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3169        Some(s) if !s.is_empty() => s,
3170        _ => return,
3171    };
3172    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3173        Some(s) if !s.is_empty() => s,
3174        _ => return,
3175    };
3176    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3177        Some(s) if !s.is_empty() => s.to_string(),
3178        _ => match state
3179            .get("self")
3180            .and_then(|s| s.get("relay_url"))
3181            .and_then(Value::as_str)
3182        {
3183            Some(s) if !s.is_empty() => s.to_string(),
3184            _ => return,
3185        },
3186    };
3187    let client = crate::relay_client::RelayClient::new(&relay_url);
3188    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3189        Ok(t) => t,
3190        Err(_) => return,
3191    };
3192    let now = std::time::SystemTime::now()
3193        .duration_since(std::time::UNIX_EPOCH)
3194        .map(|d| d.as_secs())
3195        .unwrap_or(0);
3196    match last_pull {
3197        None => {
3198            eprintln!(
3199                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3200            );
3201        }
3202        Some(t) if now.saturating_sub(t) > 300 => {
3203            let mins = now.saturating_sub(t) / 60;
3204            eprintln!(
3205                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3206            );
3207        }
3208        _ => {}
3209    }
3210}
3211
3212pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3213    let trimmed = input.trim();
3214    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3215    {
3216        return Ok(trimmed.to_string());
3217    }
3218    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3219    let n: i64 = amount
3220        .parse()
3221        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3222    if n <= 0 {
3223        bail!("deadline duration must be positive: {input:?}");
3224    }
3225    let duration = match unit {
3226        "m" => time::Duration::minutes(n),
3227        "h" => time::Duration::hours(n),
3228        "d" => time::Duration::days(n),
3229        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3230    };
3231    Ok((time::OffsetDateTime::now_utc() + duration)
3232        .format(&time::format_description::well_known::Rfc3339)
3233        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3234}
3235
3236fn cmd_send(
3237    peer: &str,
3238    kind: &str,
3239    body_arg: &str,
3240    deadline: Option<&str>,
3241    // v0.10: when true, refuse to auto-pair on miss; fail loudly so
3242    // scripts can branch on the error instead of accepting an implicit
3243    // side effect.
3244    no_auto_pair: bool,
3245    as_json: bool,
3246) -> Result<()> {
3247    if !config::is_initialized()? {
3248        bail!("not initialized — run `wire init <handle>` first");
3249    }
3250    let peer_in = crate::agent_card::bare_handle(peer).to_string();
3251    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
3252    // match wins; nickname (DID-hash auto-derived) is the fallback.
3253    // Ambiguous nicknames (two pinned peers DID-hash to the same
3254    // adj-noun pair) fail loudly with disambiguation; unknown handles
3255    // pass through (matches existing `wire send` semantics — queue
3256    // first, deliver best-effort).
3257    let peer = match resolve_peer_handle(&peer_in) {
3258        Ok(Some(resolved)) if resolved != peer_in => {
3259            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3260            resolved
3261        }
3262        Ok(Some(canonical)) => canonical, // exact handle match
3263        Ok(None) => peer_in,              // unknown — pass through, downstream errors
3264        Err(ResolveError::Ambiguous(candidates)) => bail!(
3265            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3266             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3267            candidates.len(),
3268            candidates.join(", ")
3269        ),
3270        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
3271    };
3272
3273    // v0.9 auto-pair-on-miss: if the resolved peer isn't pinned yet but
3274    // matches a local sister session, pair first (disk-read --local-sister
3275    // path) then continue. Closes the "wire send returns queued but
3276    // peer never receives because we were never paired" silent-fail
3277    // class. Equivalent to `wire dial <name>` followed by `wire send
3278    // <name> ...` in one step.
3279    let peer_is_pinned = config::read_relay_state()
3280        .ok()
3281        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3282        .map(|peers| peers.contains_key(&peer))
3283        .unwrap_or(false);
3284    if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3285        if no_auto_pair {
3286            bail!(
3287                "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3288                 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3289                 then re-run send."
3290            );
3291        }
3292        eprintln!(
3293            "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3294             Pass --no-auto-pair to refuse implicit dialing."
3295        );
3296        cmd_add_local_sister(&sister_name, true).map_err(|e| {
3297            anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3298        })?;
3299    }
3300
3301    let peer = peer.as_str();
3302    let sk_seed = config::read_private_key()?;
3303    let card = config::read_agent_card()?;
3304    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3305    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3306    let pk_b64 = card
3307        .get("verify_keys")
3308        .and_then(Value::as_object)
3309        .and_then(|m| m.values().next())
3310        .and_then(|v| v.get("key"))
3311        .and_then(Value::as_str)
3312        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3313    let pk_bytes = crate::signing::b64decode(pk_b64)?;
3314
3315    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
3316    // P0.S (0.5.11): stdin support lets shells pipe in long content
3317    // without quoting/escaping ceremony, and supports heredocs naturally:
3318    //   wire send peer - <<EOF ... EOF
3319    let body_value: Value = if body_arg == "-" {
3320        use std::io::Read;
3321        let mut raw = String::new();
3322        std::io::stdin()
3323            .read_to_string(&mut raw)
3324            .with_context(|| "reading body from stdin")?;
3325        // Try parsing as JSON first; fall back to string literal for
3326        // plain-text bodies.
3327        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3328    } else if let Some(path) = body_arg.strip_prefix('@') {
3329        let raw =
3330            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3331        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3332    } else {
3333        Value::String(body_arg.to_string())
3334    };
3335
3336    let kind_id = parse_kind(kind)?;
3337
3338    let now = time::OffsetDateTime::now_utc()
3339        .format(&time::format_description::well_known::Rfc3339)
3340        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3341
3342    let mut event = json!({
3343        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3344        "timestamp": now,
3345        "from": did,
3346        "to": format!("did:wire:{peer}"),
3347        "type": kind,
3348        "kind": kind_id,
3349        "body": body_value,
3350    });
3351    if let Some(deadline) = deadline {
3352        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3353    }
3354    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3355    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3356
3357    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
3358    // coords in relay-state and ask the relay how recently the peer pulled.
3359    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
3360    // Never blocks the send — the event still queues to outbox.
3361    maybe_warn_peer_attentiveness(peer);
3362
3363    // For now we append to outbox JSONL and rely on a future daemon to push
3364    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
3365    // Append goes through `config::append_outbox_record` which holds a per-
3366    // path mutex so concurrent senders cannot interleave bytes mid-line.
3367    let line = serde_json::to_vec(&signed)?;
3368    let outbox = config::append_outbox_record(peer, &line)?;
3369
3370    if as_json {
3371        println!(
3372            "{}",
3373            serde_json::to_string(&json!({
3374                "event_id": event_id,
3375                "status": "queued",
3376                "peer": peer,
3377                "outbox": outbox.to_string_lossy(),
3378            }))?
3379        );
3380    } else {
3381        println!(
3382            "queued event {event_id} → {peer} (outbox: {})",
3383            outbox.display()
3384        );
3385    }
3386    Ok(())
3387}
3388
3389fn parse_kind(s: &str) -> Result<u32> {
3390    if let Ok(n) = s.parse::<u32>() {
3391        return Ok(n);
3392    }
3393    for (id, name) in crate::signing::kinds() {
3394        if *name == s {
3395            return Ok(*id);
3396        }
3397    }
3398    // Unknown name — default to kind 1 (decision) for v0.1.
3399    Ok(1)
3400}
3401
3402// ---------- here (v0.9.3 you-are-here view) ----------
3403
3404/// `wire here` — one-screen "you are this session, your neighbors are
3405/// these." Combines what `wire whoami`, `wire peers`, and `wire session
3406/// list-local` would otherwise force the operator to call separately.
3407fn cmd_here(as_json: bool) -> Result<()> {
3408    let initialized = config::is_initialized().unwrap_or(false);
3409
3410    // Self identity.
3411    let (self_did, self_handle, self_character) = if initialized {
3412        let card = config::read_agent_card().ok();
3413        let did = card
3414            .as_ref()
3415            .and_then(|c| c.get("did").and_then(Value::as_str))
3416            .unwrap_or("")
3417            .to_string();
3418        let handle = if did.is_empty() {
3419            String::new()
3420        } else {
3421            crate::agent_card::display_handle_from_did(&did).to_string()
3422        };
3423        let character = if did.is_empty() {
3424            None
3425        } else {
3426            // v0.11: DID-derived only. No display.json overrides.
3427            Some(crate::character::Character::from_did(&did))
3428        };
3429        (did, handle, character)
3430    } else {
3431        (String::new(), String::new(), None)
3432    };
3433
3434    let cwd = std::env::current_dir()
3435        .map(|p| p.to_string_lossy().into_owned())
3436        .unwrap_or_default();
3437    let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3438
3439    // Sister sessions (same-machine).
3440    let mut sisters: Vec<Value> = Vec::new();
3441    if let Ok(listing) = crate::session::list_local_sessions() {
3442        for group in listing.local.values() {
3443            for s in group {
3444                if s.handle.as_deref() == Some(self_handle.as_str()) {
3445                    continue; // skip self
3446                }
3447                let ch = s.did.as_deref().map(crate::character::Character::from_did);
3448                sisters.push(json!({
3449                    "session": s.name,
3450                    "handle": s.handle,
3451                    "persona": ch,
3452                }));
3453            }
3454        }
3455    }
3456
3457    // Pinned peers (trust ring agents).
3458    let mut peers: Vec<Value> = Vec::new();
3459    if initialized
3460        && let Ok(trust) = config::read_trust()
3461        && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3462    {
3463        for (handle, agent) in agents {
3464            if handle == &self_handle {
3465                continue; // skip self
3466            }
3467            let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3468            let ch = if did.is_empty() {
3469                None
3470            } else {
3471                Some(crate::character::Character::from_did(did))
3472            };
3473            peers.push(json!({
3474                "handle": handle,
3475                "did": did,
3476                "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3477                "persona": ch,
3478            }));
3479        }
3480    }
3481
3482    if as_json {
3483        println!(
3484            "{}",
3485            serde_json::to_string(&json!({
3486                "self": {
3487                    "handle": self_handle,
3488                    "did": self_did,
3489                    "persona": self_character,
3490                    "cwd": cwd,
3491                    "wire_home": wire_home,
3492                },
3493                "sister_sessions": sisters,
3494                "pinned_peers": peers,
3495            }))?
3496        );
3497        return Ok(());
3498    }
3499
3500    // Human format.
3501    if !initialized {
3502        println!("not initialized — run `wire init <handle>` to bootstrap.");
3503        return Ok(());
3504    }
3505    let glyph = self_character
3506        .as_ref()
3507        .map(crate::character::emoji_with_fallback)
3508        .unwrap_or_else(|| "?".to_string());
3509    let nick = self_character
3510        .as_ref()
3511        .map(|c| c.nickname.clone())
3512        .unwrap_or_default();
3513    println!("you are {glyph} {nick}  ({self_handle})");
3514    if !cwd.is_empty() {
3515        println!("  cwd:    {cwd}");
3516    }
3517    // Helper closure that mirrors emoji_with_fallback over a JSON-encoded
3518    // character object (because we already collected sisters/peers into
3519    // Value rows above). Looks up the canonical emoji-name and falls
3520    // back to that — never repeats the nickname inside the brackets.
3521    let render_glyph = |character: &Value| -> String {
3522        let emoji = character
3523            .get("emoji")
3524            .and_then(Value::as_str)
3525            .unwrap_or("?");
3526        let nickname = character
3527            .get("nickname")
3528            .and_then(Value::as_str)
3529            .unwrap_or("?");
3530        if crate::character::terminal_supports_emoji() {
3531            return emoji.to_string();
3532        }
3533        // Synthesize a minimal Character so emoji_with_fallback's
3534        // lookup table picks the right ASCII tag.
3535        let synth = crate::character::Character {
3536            nickname: nickname.to_string(),
3537            emoji: emoji.to_string(),
3538            palette: crate::character::Palette {
3539                primary_hex: String::new(),
3540                accent_hex: String::new(),
3541                ansi256_primary: 0,
3542                ansi256_accent: 0,
3543            },
3544        };
3545        crate::character::emoji_with_fallback(&synth)
3546    };
3547    if !sisters.is_empty() {
3548        println!();
3549        println!("sister sessions on this machine:");
3550        for s in &sisters {
3551            let session = s["session"].as_str().unwrap_or("?");
3552            let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3553            let glyph = render_glyph(&s["persona"]);
3554            println!("  {glyph} {ch_nick}  ({session})");
3555        }
3556    }
3557    if !peers.is_empty() {
3558        println!();
3559        println!("pinned peers:");
3560        for p in &peers {
3561            let handle = p["handle"].as_str().unwrap_or("?");
3562            let tier = p["tier"].as_str().unwrap_or("");
3563            let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3564            let glyph = render_glyph(&p["persona"]);
3565            println!("  {glyph} {ch_nick}  ({handle})  [{tier}]");
3566        }
3567    }
3568    if sisters.is_empty() && peers.is_empty() {
3569        println!();
3570        println!(
3571            "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3572        );
3573    }
3574    Ok(())
3575}
3576
3577// ---------- dial / whois (v0.8 canonical addressing) ----------
3578
3579/// `wire dial <name> [message]` — the one verb operators reach for.
3580/// Resolves any name (nickname/handle/session/DID) to a peer and
3581/// drives the right pair flow + optional first message. See the
3582/// `Command::Dial` doc for the resolution ladder.
3583///
3584/// v0.9: when `name` contains `@<relay>`, route through the federation
3585/// `wire add <handle>@<relay>` path (`.well-known/wire/agent` resolution
3586/// plus cross-machine pair_drop). No more bail with "federation isn't
3587/// implemented yet" — one verb across both orbits.
3588fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3589    if name.contains('@') {
3590        // Federation path. cmd_add already auto-detects (per v0.7.4)
3591        // when input has `@` and routes through the .well-known
3592        // resolver + pair_drop deposit. After it returns, the peer
3593        // is in pending-outbound; bilateral completes when the peer
3594        // accepts. Optionally send the first message after the add.
3595        cmd_add(name, None, false, true)
3596            .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3597        if let Some(msg) = message {
3598            // Peer handle for send = the nick part before the `@`.
3599            let bare = name.split('@').next().unwrap_or(name);
3600            cmd_send(bare, "claim", msg, None, false, as_json)?;
3601        }
3602        return Ok(());
3603    }
3604
3605    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3606    // success with `{found: false, candidates: [...]}` instead of
3607    // erroring. Agents can branch on `found` without wrapping in a
3608    // try/catch.
3609    let resolution = match resolve_name_to_target(name) {
3610        Ok(r) => r,
3611        Err(e) if as_json => {
3612            let pool = known_local_names();
3613            let suggestions = closest_candidates(name, &pool, 3, 3);
3614            println!(
3615                "{}",
3616                serde_json::to_string(&json!({
3617                    "name_input": name,
3618                    "found": false,
3619                    "candidates": suggestions,
3620                    "error": format!("{e:#}"),
3621                }))?
3622            );
3623            return Ok(());
3624        }
3625        Err(e) => return Err(e),
3626    };
3627    let mut steps: Vec<Value> = Vec::new();
3628
3629    match &resolution {
3630        DialTarget::PinnedPeer { handle, .. } => {
3631            steps.push(json!({
3632                "step": "resolved",
3633                "kind": "already_pinned",
3634                "handle": handle,
3635            }));
3636        }
3637        DialTarget::LocalSister { session_name, .. } => {
3638            steps.push(json!({
3639                "step": "resolved",
3640                "kind": "local_sister",
3641                "session": session_name,
3642            }));
3643            // Drive the bilateral pair via the disk-read sister path.
3644            // cmd_add_local_sister already handles "already paired"
3645            // gracefully (its internal state.peers check returns the
3646            // existing pin instead of re-issuing a pair_drop), so
3647            // re-dialling is idempotent.
3648            cmd_add_local_sister(session_name, true).map_err(|e| {
3649                anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3650            })?;
3651            steps.push(json!({
3652                "step": "paired",
3653                "via": "local_sister",
3654            }));
3655        }
3656    }
3657
3658    let send_handle = match &resolution {
3659        DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3660        DialTarget::LocalSister { handle, .. } => handle.clone(),
3661    };
3662
3663    let send_result = if let Some(msg) = message {
3664        let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3665        match &r {
3666            Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3667            Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3668        }
3669        Some(r)
3670    } else {
3671        None
3672    };
3673
3674    if as_json {
3675        println!(
3676            "{}",
3677            serde_json::to_string(&json!({
3678                "name_input": name,
3679                "resolved_handle": send_handle,
3680                "steps": steps,
3681            }))?
3682        );
3683    } else {
3684        println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3685        for s in &steps {
3686            let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3687            println!("  - {step}");
3688        }
3689        if message.is_some() {
3690            println!("  (use `wire tail {send_handle}` to read replies)");
3691        }
3692    }
3693    if let Some(Err(e)) = send_result {
3694        return Err(e);
3695    }
3696    Ok(())
3697}
3698
3699/// `wire whois <name>` — resolve any local name (nickname/session/
3700/// handle/DID) to the full identity row. The inspector for the
3701/// canonical addressing layer. For federation `handle@relay-domain`
3702/// resolution see `cmd_whois` (line 5536+) — the dispatcher chooses
3703/// based on whether the input contains `@`.
3704fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3705    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3706    // success (exit 0) with `{found: false, candidates: [...]}` so
3707    // agents don't need try/catch around `wire whois <name>`. In
3708    // human mode, the bail's did-you-mean line points at the
3709    // closest candidate.
3710    let resolution = match resolve_name_to_target(name) {
3711        Ok(r) => r,
3712        Err(e) if as_json => {
3713            let pool = known_local_names();
3714            let suggestions = closest_candidates(name, &pool, 3, 3);
3715            println!(
3716                "{}",
3717                serde_json::to_string(&json!({
3718                    "name_input": name,
3719                    "found": false,
3720                    "candidates": suggestions,
3721                    "error": format!("{e:#}"),
3722                }))?
3723            );
3724            return Ok(());
3725        }
3726        Err(e) => return Err(e),
3727    };
3728    match resolution {
3729        DialTarget::PinnedPeer {
3730            handle,
3731            did,
3732            nickname,
3733            emoji,
3734            tier,
3735        } => {
3736            if as_json {
3737                println!(
3738                    "{}",
3739                    serde_json::to_string(&json!({
3740                        "kind": "pinned_peer",
3741                        "handle": handle,
3742                        "did": did,
3743                        "nickname": nickname,
3744                        "emoji": emoji,
3745                        "tier": tier,
3746                    }))?
3747                );
3748            } else {
3749                let n = nickname.as_deref().unwrap_or("(no character)");
3750                let e = emoji.as_deref().unwrap_or("?");
3751                println!("{e} {n}");
3752                println!("  handle:   {handle}");
3753                println!("  did:      {did}");
3754                println!("  tier:     {tier}");
3755                println!("  reach:    pinned peer (already in trust ring + slot pinned)");
3756            }
3757        }
3758        DialTarget::LocalSister {
3759            session_name,
3760            handle,
3761            did,
3762            nickname,
3763            emoji,
3764        } => {
3765            if as_json {
3766                println!(
3767                    "{}",
3768                    serde_json::to_string(&json!({
3769                        "kind": "local_sister",
3770                        "session_name": session_name,
3771                        "handle": handle,
3772                        "did": did,
3773                        "nickname": nickname,
3774                        "emoji": emoji,
3775                    }))?
3776                );
3777            } else {
3778                let n = nickname.as_deref().unwrap_or("(no character)");
3779                let e = emoji.as_deref().unwrap_or("?");
3780                println!("{e} {n}");
3781                println!("  session:  {session_name}");
3782                println!("  handle:   {handle}");
3783                println!(
3784                    "  did:      {}",
3785                    did.as_deref().unwrap_or("(card unreadable)")
3786                );
3787                println!("  reach:    local sister on this machine — `wire dial {n}` pairs us");
3788            }
3789        }
3790    }
3791    Ok(())
3792}
3793
3794enum DialTarget {
3795    PinnedPeer {
3796        handle: String,
3797        did: String,
3798        nickname: Option<String>,
3799        emoji: Option<String>,
3800        tier: String,
3801    },
3802    LocalSister {
3803        session_name: String,
3804        handle: String,
3805        did: Option<String>,
3806        nickname: Option<String>,
3807        emoji: Option<String>,
3808    },
3809}
3810
3811/// Resolution order: pinned peers first (already in our trust ring),
3812/// then local sister sessions (on-disk discovery). Case-insensitive
3813/// match against handle, character nickname, session name, or DID.
3814fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3815    let needle = name.trim();
3816    if needle.is_empty() {
3817        bail!("empty name");
3818    }
3819
3820    // 1. Pinned peers — `wire peers` data. trust.agents is an object
3821    // keyed by handle (not an array); iterate as a map.
3822    if config::is_initialized().unwrap_or(false) {
3823        let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3824        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3825            for (handle_key, agent) in agents {
3826                let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3827                if did.is_empty() {
3828                    continue;
3829                }
3830                let handle = handle_key.clone();
3831                let character = crate::character::Character::from_did(did);
3832                let tier = agent
3833                    .get("tier")
3834                    .and_then(Value::as_str)
3835                    .unwrap_or("UNKNOWN")
3836                    .to_string();
3837                let matches = handle.eq_ignore_ascii_case(needle)
3838                    || did.eq_ignore_ascii_case(needle)
3839                    || character.nickname.eq_ignore_ascii_case(needle);
3840                if matches {
3841                    return Ok(DialTarget::PinnedPeer {
3842                        handle,
3843                        did: did.to_string(),
3844                        nickname: Some(character.nickname),
3845                        emoji: Some(character.emoji.to_string()),
3846                        tier,
3847                    });
3848                }
3849            }
3850        }
3851    }
3852
3853    // 2. Local sister sessions.
3854    if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3855        let sessions = crate::session::list_sessions().unwrap_or_default();
3856        let s = sessions.iter().find(|s| s.name == session_name);
3857        if let Some(s) = s {
3858            return Ok(DialTarget::LocalSister {
3859                session_name: s.name.clone(),
3860                handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3861                did: s.did.clone(),
3862                nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3863                emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3864            });
3865        }
3866    }
3867
3868    // v0.9.2: fuzzy did-you-mean suggestion on resolution miss. Walks
3869    // the union of pinned-peer handles + character nicknames + sister
3870    // session names + sister character nicknames, returns up to 3 names
3871    // within Levenshtein distance 3 of the operator's typed name.
3872    let pool = known_local_names();
3873    let suggestions = closest_candidates(name, &pool, 3, 3);
3874    if suggestions.is_empty() {
3875        bail!(
3876            "no peer matched `{name}`.\n\
3877             Tried: pinned peers (`wire peers`) + local sister sessions \
3878             (`wire session list-local`).\n\
3879             For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3880        );
3881    }
3882    bail!(
3883        "no peer matched `{name}`.\n\
3884         Did you mean: {}?\n\
3885         List all: `wire peers`, `wire session list-local`.",
3886        suggestions
3887            .iter()
3888            .map(|s| format!("`{s}`"))
3889            .collect::<Vec<_>>()
3890            .join(", ")
3891    );
3892}
3893
3894// ---------- tail ----------
3895
3896/// Print recent events from this agent's inbox.
3897///
3898/// **Orientation (wire #79):** defaults to NEWEST-N — with `limit > 0`, the
3899/// last `limit` events across all matched peer jsonl files are returned,
3900/// sorted chronologically (by `timestamp`, then by per-file append order as
3901/// tiebreaker) and printed oldest-of-window first / newest last. This matches
3902/// `tail -n` semantics on log files; previously `wire tail --limit N` returned
3903/// the OLDEST N which silently hid live-context for any agent harness that
3904/// re-tailed an established inbox.
3905///
3906/// `oldest=true` flips back to FIFO (first-N) for operators who need the
3907/// original orientation (e.g. replaying an inbox from the start). `limit=0`
3908/// prints every event in chronological order.
3909fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize, oldest: bool) -> Result<()> {
3910    let inbox = config::inbox_dir()?;
3911    if !inbox.exists() {
3912        if !as_json {
3913            eprintln!("no inbox yet — daemon hasn't run, or no events received");
3914        }
3915        return Ok(());
3916    }
3917    let trust = config::read_trust()?;
3918
3919    let entries: Vec<_> = std::fs::read_dir(&inbox)?
3920        .filter_map(|e| e.ok())
3921        .map(|e| e.path())
3922        .filter(|p| {
3923            p.extension().map(|x| x == "jsonl").unwrap_or(false)
3924                && match peer {
3925                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3926                    None => true,
3927                }
3928        })
3929        .collect();
3930
3931    // Collect every parseable event across all matched peer files. Each entry
3932    // carries a sort key `(timestamp, line_idx)` so multi-peer interleaving
3933    // sorts deterministically by event time, with append-order as the
3934    // tiebreaker for events that share a timestamp (or for events with no
3935    // timestamp string at all).
3936    let mut events: Vec<(String, usize, Value)> = Vec::new();
3937    for path in &entries {
3938        let body = std::fs::read_to_string(path)?;
3939        for (idx, line) in body.lines().enumerate() {
3940            let event: Value = match serde_json::from_str(line) {
3941                Ok(v) => v,
3942                Err(_) => continue,
3943            };
3944            let ts = event
3945                .get("timestamp")
3946                .and_then(Value::as_str)
3947                .unwrap_or("")
3948                .to_string();
3949            events.push((ts, idx, event));
3950        }
3951    }
3952    events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
3953
3954    // Pick the window. limit=0 → all events; oldest → first N; default → last N.
3955    let total = events.len();
3956    let window: &[(String, usize, Value)] = if limit == 0 {
3957        &events[..]
3958    } else if oldest {
3959        &events[..limit.min(total)]
3960    } else {
3961        let start = total.saturating_sub(limit);
3962        &events[start..]
3963    };
3964
3965    for (_, _, event) in window {
3966        let verified = verify_message_v31(event, &trust).is_ok();
3967        if as_json {
3968            let mut event_with_meta = event.clone();
3969            if let Some(obj) = event_with_meta.as_object_mut() {
3970                obj.insert("verified".into(), json!(verified));
3971            }
3972            println!("{}", serde_json::to_string(&event_with_meta)?);
3973        } else {
3974            let ts = event
3975                .get("timestamp")
3976                .and_then(Value::as_str)
3977                .unwrap_or("?");
3978            let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3979            let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3980            let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3981            let summary = event
3982                .get("body")
3983                .map(|b| match b {
3984                    Value::String(s) => s.clone(),
3985                    _ => b.to_string(),
3986                })
3987                .unwrap_or_default();
3988            let mark = if verified { "✓" } else { "✗" };
3989            let deadline = event
3990                .get("time_sensitive_until")
3991                .and_then(Value::as_str)
3992                .map(|d| format!(" deadline: {d}"))
3993                .unwrap_or_default();
3994            println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3995        }
3996    }
3997    Ok(())
3998}
3999
4000// ---------- monitor (live-tail across all peers, harness-friendly) ----------
4001
4002/// Events filtered out of `wire monitor` by default — pair handshake +
4003/// liveness pings. Operators almost never want these surfaced; an explicit
4004/// `--include-handshake` brings them back.
4005fn monitor_is_noise_kind(kind: &str) -> bool {
4006    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
4007}
4008
4009/// Resolve a pinned peer's persona (the DID-derived nickname + emoji,
4010/// respecting an advertised override on their card). `None` if the peer
4011/// isn't in trust or can't be resolved — callers fall back to the handle.
4012fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
4013    let trust = config::read_trust().ok()?;
4014    let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
4015    if let Some(card) = agent.get("card") {
4016        Some(crate::character::Character::from_card(card))
4017    } else {
4018        let did = agent.get("did").and_then(Value::as_str)?;
4019        Some(crate::character::Character::from_did(did))
4020    }
4021}
4022
4023/// "emoji nickname" label for a peer, falling back to the raw handle.
4024fn persona_label(peer_handle: &str) -> String {
4025    match resolve_persona(peer_handle) {
4026        Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
4027        None => peer_handle.to_string(),
4028    }
4029}
4030
4031/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
4032/// full structured event for tooling consumption; the plain form is a tight
4033/// one-line summary suitable as a harness stream-watcher notification.
4034///
4035/// Kept PURE (no trust I/O) so it stays deterministic and cheap per event.
4036/// Persona enrichment for `--json` belongs at InboxEvent construction in
4037/// `inbox_watch` (a follow-up), not here.
4038fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
4039    if as_json {
4040        Ok(serde_json::to_string(e)?)
4041    } else {
4042        let eid_short: String = e.event_id.chars().take(12).collect();
4043        let body = e.body_preview.replace('\n', " ");
4044        let ts: String = e.timestamp.chars().take(19).collect();
4045        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
4046    }
4047}
4048
4049/// `wire monitor` — long-running line-per-event stream of new inbox events.
4050///
4051/// Built for agent harnesses that have an "every stdout line is a chat
4052/// notification" stream watcher (Claude Code Monitor tool, etc.). One
4053/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
4054/// | python parse | grep -v pair_drop` pipeline operators improvise on day
4055/// one of every wire session.
4056///
4057/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
4058/// pure handshake / liveness noise that operators almost never want
4059/// surfaced. Pass `--include-handshake` if you do.
4060///
4061/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
4062/// doesn't drown the operator in replay), with optional `--replay N` to
4063/// emit the last N events first.
4064fn cmd_monitor(
4065    peer_filter: Option<&str>,
4066    as_json: bool,
4067    include_handshake: bool,
4068    interval_ms: u64,
4069    replay: usize,
4070) -> Result<()> {
4071    let inbox_dir = config::inbox_dir()?;
4072    if !inbox_dir.exists() && !as_json {
4073        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
4074    }
4075    // v0.13.x identity work: monitor owns the inbox cursor across the
4076    // long-running poll loop; collision with another wire process under
4077    // the same WIRE_HOME causes "I'm not seeing X's events" debugging
4078    // rabbit holes. Warn at startup so the operator catches it fast.
4079    crate::session::warn_on_identity_collision(std::process::id(), "monitor");
4080    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
4081
4082    // Optional replay — read existing files and emit the last `replay` events
4083    // (post-filter) before going live. Useful when the harness restarts and
4084    // wants recent context.
4085    if replay > 0 && inbox_dir.exists() {
4086        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
4087        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
4088            let path = entry.path();
4089            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4090                continue;
4091            }
4092            let peer = match path.file_stem().and_then(|s| s.to_str()) {
4093                Some(s) => s.to_string(),
4094                None => continue,
4095            };
4096            if let Some(filter) = peer_filter
4097                && peer != filter
4098            {
4099                continue;
4100            }
4101            let body = std::fs::read_to_string(&path).unwrap_or_default();
4102            for line in body.lines() {
4103                let line = line.trim();
4104                if line.is_empty() {
4105                    continue;
4106                }
4107                let signed: Value = match serde_json::from_str(line) {
4108                    Ok(v) => v,
4109                    Err(_) => continue,
4110                };
4111                let ev = crate::inbox_watch::InboxEvent::from_signed(
4112                    &peer, signed, /* verified */ true,
4113                );
4114                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4115                    continue;
4116                }
4117                all.push(ev);
4118            }
4119        }
4120        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
4121        // chronological for same-zoned timestamps).
4122        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
4123        let start = all.len().saturating_sub(replay);
4124        for ev in &all[start..] {
4125            println!("{}", monitor_render(ev, as_json)?);
4126        }
4127        use std::io::Write;
4128        std::io::stdout().flush().ok();
4129    }
4130
4131    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
4132    // the first poll only returns events that arrived AFTER startup.
4133    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
4134    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
4135
4136    loop {
4137        // Never die silently. wisp-blossom (Win10) saw `wire monitor` exit 1
4138        // with ZERO bytes on stdout+stderr when a cursor-block (untrusted
4139        // signer's pair event) tripped the watcher — a silent death looks
4140        // identical to "still watching" and breaks the sister-collab model.
4141        // Surface the reason and KEEP watching instead of propagating a fatal
4142        // `?` that some callers swallow.
4143        let events = match w.poll() {
4144            Ok(evs) => evs,
4145            Err(e) => {
4146                eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
4147                std::thread::sleep(sleep_dur);
4148                continue;
4149            }
4150        };
4151        let mut wrote = false;
4152        for ev in events {
4153            if let Some(filter) = peer_filter
4154                && ev.peer != filter
4155            {
4156                continue;
4157            }
4158            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4159                continue;
4160            }
4161            println!("{}", monitor_render(&ev, as_json)?);
4162            wrote = true;
4163        }
4164        if wrote {
4165            use std::io::Write;
4166            std::io::stdout().flush().ok();
4167        }
4168        std::thread::sleep(sleep_dur);
4169    }
4170}
4171
4172#[cfg(test)]
4173mod tier_tests {
4174    use super::*;
4175    use serde_json::json;
4176
4177    fn trust_with(handle: &str, tier: &str) -> Value {
4178        json!({
4179            "version": 1,
4180            "agents": {
4181                handle: {
4182                    "tier": tier,
4183                    "did": format!("did:wire:{handle}"),
4184                    "card": {"capabilities": ["wire/v3.1"]}
4185                }
4186            }
4187        })
4188    }
4189
4190    #[test]
4191    fn pending_ack_when_verified_but_no_slot_token() {
4192        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
4193        // slot_token hasn't arrived yet. Display PENDING_ACK so the
4194        // operator knows wire send won't work yet.
4195        let trust = trust_with("willard", "VERIFIED");
4196        let relay_state = json!({
4197            "peers": {
4198                "willard": {
4199                    "relay_url": "https://relay",
4200                    "slot_id": "abc",
4201                    "slot_token": "",
4202                }
4203            }
4204        });
4205        assert_eq!(
4206            effective_peer_tier(&trust, &relay_state, "willard"),
4207            "PENDING_ACK"
4208        );
4209    }
4210
4211    #[test]
4212    fn verified_when_slot_token_present() {
4213        let trust = trust_with("willard", "VERIFIED");
4214        let relay_state = json!({
4215            "peers": {
4216                "willard": {
4217                    "relay_url": "https://relay",
4218                    "slot_id": "abc",
4219                    "slot_token": "tok123",
4220                }
4221            }
4222        });
4223        assert_eq!(
4224            effective_peer_tier(&trust, &relay_state, "willard"),
4225            "VERIFIED"
4226        );
4227    }
4228
4229    #[test]
4230    fn raw_tier_passes_through_for_non_verified() {
4231        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
4232        // UNTRUSTED regardless of slot_token state.
4233        let trust = trust_with("willard", "UNTRUSTED");
4234        let relay_state = json!({
4235            "peers": {"willard": {"slot_token": ""}}
4236        });
4237        assert_eq!(
4238            effective_peer_tier(&trust, &relay_state, "willard"),
4239            "UNTRUSTED"
4240        );
4241    }
4242
4243    #[test]
4244    fn pending_ack_when_relay_state_missing_peer() {
4245        // After wire add, trust gets updated BEFORE relay_state.peers does.
4246        // If relay_state has no entry for the peer at all, the operator
4247        // still hasn't completed the bilateral pin — show PENDING_ACK.
4248        let trust = trust_with("willard", "VERIFIED");
4249        let relay_state = json!({"peers": {}});
4250        assert_eq!(
4251            effective_peer_tier(&trust, &relay_state, "willard"),
4252            "PENDING_ACK"
4253        );
4254    }
4255}
4256
4257#[cfg(test)]
4258mod monitor_tests {
4259    use super::*;
4260    use crate::inbox_watch::InboxEvent;
4261    use serde_json::Value;
4262
4263    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4264        InboxEvent {
4265            peer: peer.to_string(),
4266            event_id: "abcd1234567890ef".to_string(),
4267            kind: kind.to_string(),
4268            body_preview: body.to_string(),
4269            verified: true,
4270            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4271            raw: Value::Null,
4272        }
4273    }
4274
4275    #[test]
4276    fn monitor_filter_drops_handshake_kinds_by_default() {
4277        // The whole point: pair_drop / pair_drop_ack / heartbeat are
4278        // protocol noise. If they leak into the operator's chat stream by
4279        // default, the recipe is useless ("wire monitor talks too much,
4280        // disabled it"). Burn this rule in.
4281        assert!(monitor_is_noise_kind("pair_drop"));
4282        assert!(monitor_is_noise_kind("pair_drop_ack"));
4283        assert!(monitor_is_noise_kind("heartbeat"));
4284
4285        // Real-payload kinds — operator wants every one.
4286        assert!(!monitor_is_noise_kind("claim"));
4287        assert!(!monitor_is_noise_kind("decision"));
4288        assert!(!monitor_is_noise_kind("ack"));
4289        assert!(!monitor_is_noise_kind("request"));
4290        assert!(!monitor_is_noise_kind("note"));
4291        // Unknown future kinds shouldn't be filtered as noise either —
4292        // operator probably wants to see something they don't recognise,
4293        // not have it silently dropped (the P0.1 lesson at the UX layer).
4294        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4295    }
4296
4297    #[test]
4298    fn monitor_render_plain_is_one_short_line() {
4299        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4300        let line = monitor_render(&e, false).unwrap();
4301        // Must be single-line.
4302        assert!(!line.contains('\n'), "render must be one line: {line}");
4303        // Must include peer, kind, body fragment, short event_id.
4304        assert!(line.contains("willard"));
4305        assert!(line.contains("claim"));
4306        assert!(line.contains("real v8 train"));
4307        // Short event id (first 12 chars).
4308        assert!(line.contains("abcd12345678"));
4309        assert!(
4310            !line.contains("abcd1234567890ef"),
4311            "should truncate full id"
4312        );
4313        // RFC3339-ish second precision.
4314        assert!(line.contains("2026-05-15T23:14:07"));
4315    }
4316
4317    #[test]
4318    fn monitor_render_strips_newlines_from_body() {
4319        // Multi-line bodies (markdown lists, code, etc.) must collapse to
4320        // one line — otherwise a single message produces multiple
4321        // notifications in the harness, ruining the "one event = one line"
4322        // contract the Monitor tool relies on.
4323        let e = ev("spark", "claim", "line one\nline two\nline three");
4324        let line = monitor_render(&e, false).unwrap();
4325        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4326        assert!(line.contains("line one line two line three"));
4327    }
4328
4329    #[test]
4330    fn monitor_render_json_is_valid_jsonl() {
4331        let e = ev("spark", "claim", "hi");
4332        let line = monitor_render(&e, true).unwrap();
4333        assert!(!line.contains('\n'));
4334        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4335        assert_eq!(parsed["peer"], "spark");
4336        assert_eq!(parsed["kind"], "claim");
4337        assert_eq!(parsed["body_preview"], "hi");
4338    }
4339
4340    #[test]
4341    fn monitor_does_not_drop_on_verified_null() {
4342        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
4343        // `select(.verified == true)` against inbox JSONL. Daemon writes
4344        // events with verified=null (verification happens at tail-time, not
4345        // write-time), so the filter silently rejected everything — same
4346        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
4347        // never surfaced for ~30min.
4348        //
4349        // wire monitor's render path must NOT consult `.verified` for any
4350        // filter decision. Lock that in here so a future "be conservative,
4351        // only emit verified" patch can't quietly land.
4352        let mut e = ev("spark", "claim", "from disk with verified=null");
4353        e.verified = false; // worst case — even if disk says unverified, emit
4354        let line = monitor_render(&e, false).unwrap();
4355        assert!(line.contains("from disk with verified=null"));
4356        // Noise filter operates purely on kind, never on verified.
4357        assert!(!monitor_is_noise_kind("claim"));
4358    }
4359}
4360
4361// ---------- verify ----------
4362
4363fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4364    let body = if path == "-" {
4365        let mut buf = String::new();
4366        use std::io::Read;
4367        std::io::stdin().read_to_string(&mut buf)?;
4368        buf
4369    } else {
4370        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4371    };
4372    let event: Value = serde_json::from_str(&body)?;
4373    let trust = config::read_trust()?;
4374    match verify_message_v31(&event, &trust) {
4375        Ok(()) => {
4376            if as_json {
4377                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4378            } else {
4379                println!("verified ✓");
4380            }
4381            Ok(())
4382        }
4383        Err(e) => {
4384            let reason = e.to_string();
4385            if as_json {
4386                println!(
4387                    "{}",
4388                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4389                );
4390            } else {
4391                eprintln!("FAILED: {reason}");
4392            }
4393            std::process::exit(1);
4394        }
4395    }
4396}
4397
4398// ---------- mcp / relay-server stubs ----------
4399
4400fn cmd_mcp() -> Result<()> {
4401    crate::mcp::run()
4402}
4403
4404fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4405    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
4406    // overriding --bind. Implies --local-only semantics. Routed to a
4407    // separate serve_uds entry point with a manual hyper accept loop
4408    // (axum 0.7's `serve` is TcpListener-only).
4409    if let Some(socket_path) = uds {
4410        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4411            std::path::PathBuf::from(home)
4412                .join("state")
4413                .join("wire-relay")
4414                .join("uds")
4415        } else {
4416            dirs::state_dir()
4417                .or_else(dirs::data_local_dir)
4418                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4419                .join("wire-relay")
4420                .join("uds")
4421        };
4422        let runtime = tokio::runtime::Builder::new_multi_thread()
4423            .enable_all()
4424            .build()?;
4425        return runtime.block_on(crate::relay_server::serve_uds(
4426            socket_path.to_path_buf(),
4427            base,
4428        ));
4429    }
4430    // v0.5.17: --local-only refuses non-loopback binds. Catches the
4431    // "wait did I just bind a publicly-reachable local-only relay" mistake
4432    // at startup rather than discovering it via an empty phonebook later.
4433    if local_only {
4434        validate_loopback_bind(bind)?;
4435    }
4436    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
4437    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
4438    // so a single user can run both client and server on one machine.
4439    // For --local-only, suffix with /local so a single operator can run
4440    // both a federation relay and a local-only relay without state collision.
4441    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4442        std::path::PathBuf::from(home)
4443            .join("state")
4444            .join("wire-relay")
4445    } else {
4446        dirs::state_dir()
4447            .or_else(dirs::data_local_dir)
4448            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4449            .join("wire-relay")
4450    };
4451    let state_dir = if local_only { base.join("local") } else { base };
4452    let runtime = tokio::runtime::Builder::new_multi_thread()
4453        .enable_all()
4454        .build()?;
4455    runtime.block_on(crate::relay_server::serve_with_mode(
4456        bind,
4457        state_dir,
4458        crate::relay_server::ServerMode { local_only },
4459    ))
4460}
4461
4462/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
4463/// resolves to something outside `127.0.0.0/8` or `::1`.
4464///
4465/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
4466/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
4467/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
4468///
4469/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
4470/// is the IP range Tailscale uses for tailnet addresses. Lets operators
4471/// pair wire across machines using their tailnet IPs (e.g. Mac at
4472/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
4473/// auth + encryption + NAT traversal, wire handles protocol + identity.
4474/// Sidesteps host firewall config entirely (utun interface bypass).
4475///
4476/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
4477/// multicast, broadcast. Those would publish a "local-only" relay to
4478/// the global internet — the v0.5.17 security gate's whole point.
4479fn validate_loopback_bind(bind: &str) -> Result<()> {
4480    // Split host:port. IPv6 literals use `[::]:port` form.
4481    let host = if let Some(stripped) = bind.strip_prefix('[') {
4482        let close = stripped
4483            .find(']')
4484            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4485        stripped[..close].to_string()
4486    } else {
4487        bind.rsplit_once(':')
4488            .map(|(h, _)| h.to_string())
4489            .unwrap_or_else(|| bind.to_string())
4490    };
4491    use std::net::{IpAddr, ToSocketAddrs};
4492    let probe = format!("{host}:0");
4493    let resolved: Vec<_> = probe
4494        .to_socket_addrs()
4495        .with_context(|| format!("resolving bind host {host:?}"))?
4496        .collect();
4497    if resolved.is_empty() {
4498        bail!("--local-only: bind host {host:?} resolved to no addresses");
4499    }
4500    for addr in &resolved {
4501        let ip = addr.ip();
4502        let is_acceptable = match ip {
4503            IpAddr::V4(v4) => {
4504                v4.is_loopback() || v4.is_private() || {
4505                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
4506                    let octets = v4.octets();
4507                    octets[0] == 100 && (64..=127).contains(&octets[1])
4508                }
4509            }
4510            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
4511        };
4512        if !is_acceptable {
4513            bail!(
4514                "--local-only refuses non-private bind: {host:?} resolves to {} \
4515                 which is not loopback (127/8, ::1), RFC 1918 private \
4516                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4517                 (100.64.0.0/10). Remove --local-only to bind publicly.",
4518                ip
4519            );
4520        }
4521    }
4522    Ok(())
4523}
4524
4525// ---------- bind-relay ----------
4526
4527fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4528    use crate::endpoints::EndpointScope;
4529    match s.to_lowercase().as_str() {
4530        "federation" | "fed" => Ok(EndpointScope::Federation),
4531        "local" => Ok(EndpointScope::Local),
4532        "lan" => Ok(EndpointScope::Lan),
4533        "uds" => Ok(EndpointScope::Uds),
4534        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4535    }
4536}
4537
4538/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
4539/// appended to `self.endpoints[]`, keeping any existing slots so an agent
4540/// can hold a local relay AND a federation relay simultaneously without
4541/// black-holing pinned peers. `--replace` restores the pre-v0.12
4542/// destructive single-slot behavior (guarded by issue #7).
4543fn cmd_bind_relay(
4544    url: &str,
4545    scope: Option<&str>,
4546    replace: bool,
4547    migrate_pinned: bool,
4548    as_json: bool,
4549) -> Result<()> {
4550    use crate::endpoints::{Endpoint, self_endpoints};
4551
4552    if !config::is_initialized()? {
4553        bail!("not initialized — run `wire init <handle>` first");
4554    }
4555    let card = config::read_agent_card()?;
4556    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4557    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4558
4559    let normalized_raw = url.trim_end_matches('/');
4560    // Refuse to record/publish a relay endpoint that embeds userinfo —
4561    // `https://<handle>@<host>` 4xxes every inbound event POST. Strip and
4562    // warn so operators learn the right shape without losing the call.
4563    let normalized_owned = strip_relay_url_userinfo(normalized_raw);
4564    let normalized = normalized_owned.as_str();
4565    // Belt-and-suspenders: confirm the post-strip URL is clean before any
4566    // persist / publish. A future code path that bypasses the strip filter
4567    // MUST NOT be able to leak userinfo into the signed agent-card.
4568    assert_relay_url_clean_for_publish(normalized)?;
4569    let new_scope = match scope {
4570        Some(s) => parse_scope(s)?,
4571        None => crate::endpoints::infer_scope_from_url(normalized),
4572    };
4573
4574    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4575    let pinned: Vec<String> = existing
4576        .get("peers")
4577        .and_then(|p| p.as_object())
4578        .map(|o| o.keys().cloned().collect())
4579        .unwrap_or_default();
4580
4581    let existing_eps = self_endpoints(&existing);
4582    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4583
4584    // Destructive paths that black-hole pinned peers (issue #7):
4585    //   • `--replace` drops every other slot.
4586    //   • re-binding the SAME relay rotates that slot in place.
4587    // An additive bind of a NEW relay keeps existing slots, so peers stay
4588    // reachable — no acknowledgement required. This is the v0.12 default
4589    // that unblocks simultaneous local + remote.
4590    let destructive = replace || is_rebind_same;
4591    if destructive && !pinned.is_empty() && !migrate_pinned {
4592        let list = pinned.join(", ");
4593        let why = if replace {
4594            "`--replace` drops your other slot(s)"
4595        } else {
4596            "re-binding the same relay rotates its slot"
4597        };
4598        bail!(
4599            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4600             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4601             read.\n\n\
4602             SAFE PATHS:\n\
4603             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4604             slots — no black-hole.\n\
4605             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4606             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4607             peer out-of-band.\n\n\
4608             Issue #7 (silent black-hole on relay change) caught this.",
4609            n = pinned.len(),
4610        );
4611    }
4612
4613    let client = crate::relay_client::RelayClient::new(normalized);
4614    client.check_healthz()?;
4615    let alloc = client.allocate_slot(Some(&handle))?;
4616
4617    if destructive && !pinned.is_empty() {
4618        eprintln!(
4619            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4620             until they re-pin: {peers}",
4621            mode = if replace { "replacing" } else { "rotating" },
4622            n = pinned.len(),
4623            peers = pinned.join(", "),
4624        );
4625    }
4626
4627    // Write the new slot via the single source of truth for the self-slot
4628    // shape. Additive by default; --replace starts from an empty self so
4629    // only this slot remains.
4630    let mut state = existing;
4631    if replace {
4632        state["self"] = Value::Null;
4633    }
4634    crate::endpoints::upsert_self_endpoint(
4635        &mut state,
4636        Endpoint {
4637            relay_url: normalized.to_string(),
4638            slot_id: alloc.slot_id.clone(),
4639            slot_token: alloc.slot_token.clone(),
4640            scope: new_scope,
4641        },
4642    );
4643    config::write_relay_state(&state)?;
4644    let eps = self_endpoints(&state);
4645
4646    let scope_str = format!("{new_scope:?}").to_lowercase();
4647    if as_json {
4648        println!(
4649            "{}",
4650            serde_json::to_string(&json!({
4651                "relay_url": normalized,
4652                "slot_id": alloc.slot_id,
4653                "scope": scope_str,
4654                "endpoints": eps.len(),
4655                "additive": !replace,
4656                "slot_token_present": true,
4657            }))?
4658        );
4659    } else {
4660        println!(
4661            "bound {scope_str} slot on {normalized} (slot {})",
4662            alloc.slot_id
4663        );
4664        println!(
4665            "self now has {n} endpoint(s): {list}",
4666            n = eps.len(),
4667            list = eps
4668                .iter()
4669                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4670                .collect::<Vec<_>>()
4671                .join(", "),
4672        );
4673    }
4674    Ok(())
4675}
4676
4677// ---------- add-peer-slot ----------
4678
4679fn cmd_add_peer_slot(
4680    handle: &str,
4681    url: &str,
4682    slot_id: &str,
4683    slot_token: &str,
4684    as_json: bool,
4685) -> Result<()> {
4686    use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
4687    let mut state = config::read_relay_state()?;
4688
4689    // E3 (v0.13.2): ADD this slot to the peer's endpoint set — don't REPLACE
4690    // the whole entry. The old flat `peers.insert` clobbered an existing
4691    // peer's federation endpoint when pinning a local slot, silently dropping
4692    // the federation route (glossy-magnolia + wisp-blossom repro: pinning a
4693    // loopback slot made the peer flat loopback-only). Mirror bind-relay's
4694    // additive semantics: upsert by relay_url into the peer's endpoints[].
4695    let new_ep = Endpoint {
4696        relay_url: url.to_string(),
4697        slot_id: slot_id.to_string(),
4698        slot_token: slot_token.to_string(),
4699        scope: infer_scope_from_url(url),
4700    };
4701    let mut endpoints: Vec<Endpoint> = state
4702        .get("peers")
4703        .and_then(|p| p.get(handle))
4704        .and_then(|e| e.get("endpoints"))
4705        .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
4706        .unwrap_or_default();
4707    // Back-compat: seed from legacy flat fields when the peer predates endpoints[].
4708    if endpoints.is_empty()
4709        && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
4710        && let (Some(ru), Some(si), Some(st)) = (
4711            peer.get("relay_url").and_then(Value::as_str),
4712            peer.get("slot_id").and_then(Value::as_str),
4713            peer.get("slot_token").and_then(Value::as_str),
4714        )
4715    {
4716        endpoints.push(Endpoint {
4717            relay_url: ru.to_string(),
4718            slot_id: si.to_string(),
4719            slot_token: st.to_string(),
4720            scope: infer_scope_from_url(ru),
4721        });
4722    }
4723    // Upsert by relay_url: refresh in place if already pinned, else append.
4724    if let Some(existing) = endpoints
4725        .iter_mut()
4726        .find(|e| e.relay_url == new_ep.relay_url)
4727    {
4728        *existing = new_ep;
4729    } else {
4730        endpoints.push(new_ep);
4731    }
4732    let n = endpoints.len();
4733    pin_peer_endpoints(&mut state, handle, &endpoints)?;
4734    config::write_relay_state(&state)?;
4735    if as_json {
4736        println!(
4737            "{}",
4738            serde_json::to_string(&json!({
4739                "handle": handle,
4740                "relay_url": url,
4741                "slot_id": slot_id,
4742                "added": true,
4743                "endpoint_count": n,
4744            }))?
4745        );
4746    } else {
4747        println!(
4748            "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
4749        );
4750    }
4751    Ok(())
4752}
4753
4754// ---------- push ----------
4755
4756fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4757    let mut state = config::read_relay_state()?;
4758    let peers = state["peers"].as_object().cloned().unwrap_or_default();
4759    if peers.is_empty() {
4760        bail!(
4761            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4762        );
4763    }
4764    let outbox_dir = config::outbox_dir()?;
4765    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
4766    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
4767    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
4768    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
4769    if outbox_dir.exists() {
4770        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4771        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4772            let path = entry.path();
4773            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4774                continue;
4775            }
4776            let stem = match path.file_stem().and_then(|s| s.to_str()) {
4777                Some(s) => s.to_string(),
4778                None => continue,
4779            };
4780            if pinned.contains(&stem) {
4781                continue;
4782            }
4783            // Try the bare-handle of the orphaned stem — if THAT matches a
4784            // pinned peer, the stem is a stale FQDN-suffixed file.
4785            let bare = crate::agent_card::bare_handle(&stem);
4786            if pinned.contains(bare) {
4787                eprintln!(
4788                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4789                     Merge with: `cat {} >> {}` then delete the FQDN file.",
4790                    stem,
4791                    path.display(),
4792                    outbox_dir.join(format!("{bare}.jsonl")).display(),
4793                );
4794            }
4795        }
4796    }
4797    if !outbox_dir.exists() {
4798        if as_json {
4799            println!(
4800                "{}",
4801                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4802            );
4803        } else {
4804            println!("phyllis: nothing to dial out — write a message first with `wire send`");
4805        }
4806        return Ok(());
4807    }
4808
4809    let mut pushed = Vec::new();
4810    let mut skipped = Vec::new();
4811
4812    // Issue #15: track which peers we've already re-resolved this push call
4813    // so we don't whois more than once per peer per push (the rate limit the
4814    // issue specifies). Lifetime is the whole `cmd_push` invocation; clears
4815    // every time the operator (or daemon) runs `wire push` again.
4816    let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
4817    // Track whether we mutated `state` so we can write it back exactly
4818    // once at the end (avoids a write per peer).
4819    let mut state_dirty = false;
4820
4821    // v0.5.17: walk each peer's pinned endpoints in priority order (local
4822    // first if we share a local relay, federation second). Try POST on the
4823    // first endpoint; on transport failure, fall through to the next.
4824    // Falls back to the v0.5.16 legacy single-endpoint code path when the
4825    // peer record carries no `endpoints[]` array (back-compat).
4826    for (peer_handle, _) in peers.iter() {
4827        if let Some(want) = peer_filter
4828            && peer_handle != want
4829        {
4830            continue;
4831        }
4832        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4833        if !outbox.exists() {
4834            continue;
4835        }
4836        let mut ordered_endpoints =
4837            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4838        if ordered_endpoints.is_empty() {
4839            // Unreachable peer (no federation endpoint AND our local
4840            // relay doesn't match the peer's). Skip with a loud reason
4841            // rather than silently dropping events.
4842            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4843                let event: Value = match serde_json::from_str(line) {
4844                    Ok(v) => v,
4845                    Err(_) => continue,
4846                };
4847                let event_id = event
4848                    .get("event_id")
4849                    .and_then(Value::as_str)
4850                    .unwrap_or("")
4851                    .to_string();
4852                skipped.push(json!({
4853                    "peer": peer_handle,
4854                    "event_id": event_id,
4855                    "reason": "no reachable endpoint pinned for peer",
4856                }));
4857            }
4858            continue;
4859        }
4860        let body = std::fs::read_to_string(&outbox)?;
4861        for line in body.lines() {
4862            let event: Value = match serde_json::from_str(line) {
4863                Ok(v) => v,
4864                Err(_) => continue,
4865            };
4866            let event_id = event
4867                .get("event_id")
4868                .and_then(Value::as_str)
4869                .unwrap_or("")
4870                .to_string();
4871
4872            // Capture the most recent per-endpoint error reason via a RefCell
4873            // so we can preserve cmd_push's pre-existing "last-error wins"
4874            // semantics for the skipped-with-reason path. The shared
4875            // try_post_event_with_failover helper (from #62) handles iteration,
4876            // priority order, and early-return on first success; the closure
4877            // applies the existing `format_transport_error` formatting on
4878            // each individual error so the operator sees the same diagnostic
4879            // text as before the dedup.
4880            let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
4881            match crate::relay_client::try_post_event_with_failover(
4882                &ordered_endpoints,
4883                &event,
4884                |endpoint, ev| {
4885                    let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4886                    match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
4887                        Ok(resp) => Ok(resp),
4888                        Err(e) => {
4889                            *last_err.borrow_mut() =
4890                                Some(crate::relay_client::format_transport_error(&e));
4891                            Err(e)
4892                        }
4893                    }
4894                },
4895            ) {
4896                Ok((endpoint, resp)) => {
4897                    if resp.status == "duplicate" {
4898                        skipped.push(json!({
4899                            "peer": peer_handle,
4900                            "event_id": event_id,
4901                            "reason": "duplicate",
4902                            "endpoint": endpoint.relay_url,
4903                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4904                        }));
4905                    } else {
4906                        pushed.push(json!({
4907                            "peer": peer_handle,
4908                            "event_id": event_id,
4909                            "endpoint": endpoint.relay_url,
4910                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4911                        }));
4912                    }
4913                }
4914                Err(_) => {
4915                    // Issue #15: before reporting the event as skipped, see
4916                    // if the failure smelled like a slot-rotation (4xx 404 /
4917                    // 410). If yes AND we haven't already re-resolved this
4918                    // peer in this push call, attempt one whois lookup. On
4919                    // a real rotation, the helper updates `state.peers[peer]`
4920                    // in place; we refresh `ordered_endpoints` from the
4921                    // mutated state and retry the same event once. Composes
4922                    // with the doctor #14 staleness check from PR #68: #14
4923                    // surfaces the symptom, #15 closes the loop.
4924                    let last_err_text = last_err.borrow().clone().unwrap_or_default();
4925                    let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
4926                    match try_reresolve_peer_on_slot_4xx(
4927                        &mut state,
4928                        peer_handle,
4929                        &last_err_text,
4930                        &rotated_this_push,
4931                    ) {
4932                        Ok(true) => {
4933                            // Mark this peer as already re-resolved this push.
4934                            rotated_this_push.insert(peer_handle.clone());
4935                            state_dirty = true;
4936                            // Refresh endpoints from the updated state and
4937                            // retry exactly once. last_err is also reset so
4938                            // the retry's error (if any) replaces the prior
4939                            // one in the eventual skipped reason.
4940                            ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
4941                                &state,
4942                                peer_handle,
4943                            );
4944                            *last_err.borrow_mut() = None;
4945                            if let Ok((endpoint, resp)) =
4946                                crate::relay_client::try_post_event_with_failover(
4947                                    &ordered_endpoints,
4948                                    &event,
4949                                    |endpoint, ev| {
4950                                        let client = crate::relay_client::RelayClient::new(
4951                                            &endpoint.relay_url,
4952                                        );
4953                                        match client.post_event(
4954                                            &endpoint.slot_id,
4955                                            &endpoint.slot_token,
4956                                            ev,
4957                                        ) {
4958                                            Ok(resp) => Ok(resp),
4959                                            Err(e) => {
4960                                                *last_err.borrow_mut() = Some(
4961                                                    crate::relay_client::format_transport_error(&e),
4962                                                );
4963                                                Err(e)
4964                                            }
4965                                        }
4966                                    },
4967                                )
4968                            {
4969                                delivered_via_retry = Some((endpoint, resp));
4970                            }
4971                        }
4972                        Ok(false) => {
4973                            // Either not a slot-rotation shape, or already
4974                            // re-resolved this push, or slot id unchanged —
4975                            // fall through to the original skipped path.
4976                        }
4977                        Err(e) => {
4978                            // Re-resolve itself failed (DNS down, relay 5xx,
4979                            // handle unclaimed, etc.). Don't fail the push —
4980                            // fall through to skipped with the resolve error
4981                            // appended for diagnostic context.
4982                            *last_err.borrow_mut() = Some(format!(
4983                                "{}; re-resolve also failed: {e:#}",
4984                                last_err.borrow().clone().unwrap_or_default()
4985                            ));
4986                            // Mark as tried so we don't loop on the next event.
4987                            rotated_this_push.insert(peer_handle.clone());
4988                        }
4989                    }
4990                    if let Some((endpoint, resp)) = delivered_via_retry {
4991                        if resp.status == "duplicate" {
4992                            skipped.push(json!({
4993                                "peer": peer_handle,
4994                                "event_id": event_id,
4995                                "reason": "duplicate",
4996                                "endpoint": endpoint.relay_url,
4997                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4998                                "via": "slot_reresolve_retry",
4999                            }));
5000                        } else {
5001                            pushed.push(json!({
5002                                "peer": peer_handle,
5003                                "event_id": event_id,
5004                                "endpoint": endpoint.relay_url,
5005                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5006                                "via": "slot_reresolve_retry",
5007                            }));
5008                        }
5009                    } else {
5010                        // Every endpoint failed even after (any) retry.
5011                        // Preserve the prior "last reason is what gets
5012                        // reported" UX (the closure captured the last per-
5013                        // endpoint error via `last_err`).
5014                        skipped.push(json!({
5015                            "peer": peer_handle,
5016                            "event_id": event_id,
5017                            "reason": last_err
5018                                .borrow()
5019                                .clone()
5020                                .unwrap_or_else(|| "all endpoints failed".to_string()),
5021                        }));
5022                    }
5023                }
5024            }
5025        }
5026    }
5027
5028    // Issue #15: persist any in-place slot rotations from the per-peer loop
5029    // exactly once at the end. Best-effort: if the write fails the operator
5030    // still gets a valid push report, and the next push will re-attempt the
5031    // resolve (cheap) before retrying delivery.
5032    if state_dirty && let Err(e) = config::write_relay_state(&state) {
5033        eprintln!(
5034            "wire push: WARN failed to persist rotated peer slots: {e:#}. \
5035             Slot rotation will be re-attempted on next push."
5036        );
5037    }
5038
5039    if as_json {
5040        println!(
5041            "{}",
5042            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
5043        );
5044    } else {
5045        println!(
5046            "pushed {} event(s); skipped {} ({})",
5047            pushed.len(),
5048            skipped.len(),
5049            if skipped.is_empty() {
5050                "none"
5051            } else {
5052                "see --json for detail"
5053            }
5054        );
5055    }
5056    Ok(())
5057}
5058
5059// ---------- pull ----------
5060
5061fn cmd_pull(as_json: bool) -> Result<()> {
5062    let state = config::read_relay_state()?;
5063    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5064    if self_state.is_null() {
5065        bail!("self slot not bound — run `wire bind-relay <url>` first");
5066    }
5067
5068    // v0.5.17: pull from every endpoint in self.endpoints (federation +
5069    // optional local). Each endpoint has its own per-scope cursor so we
5070    // don't re-pull events we've already seen on that path. Events from
5071    // all endpoints feed into the same inbox JSONL via process_events;
5072    // dedup by event_id is the last line of defense.
5073    // Falls back to a single federation endpoint synthesized from the
5074    // top-level legacy fields when self.endpoints is absent (v0.5.16
5075    // back-compat).
5076    let endpoints = crate::endpoints::self_endpoints(&state);
5077    if endpoints.is_empty() {
5078        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
5079    }
5080
5081    let inbox_dir = config::inbox_dir()?;
5082    config::ensure_dirs()?;
5083
5084    let mut total_seen = 0usize;
5085    let mut all_written: Vec<Value> = Vec::new();
5086    let mut all_rejected: Vec<Value> = Vec::new();
5087    let mut all_blocked = false;
5088    let mut all_advance_cursor_to: Option<String> = None;
5089
5090    for endpoint in &endpoints {
5091        let cursor_key = endpoint_cursor_key(endpoint.scope);
5092        let last_event_id = self_state
5093            .get(&cursor_key)
5094            .and_then(Value::as_str)
5095            .map(str::to_string);
5096        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5097        let events = match client.list_events(
5098            &endpoint.slot_id,
5099            &endpoint.slot_token,
5100            last_event_id.as_deref(),
5101            Some(1000),
5102        ) {
5103            Ok(ev) => ev,
5104            Err(e) => {
5105                // One endpoint's failure shouldn't kill the whole pull.
5106                // The local-relay-down case in particular needs to
5107                // gracefully continue against federation.
5108                eprintln!(
5109                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
5110                    endpoint.relay_url,
5111                    endpoint.scope,
5112                    crate::relay_client::format_transport_error(&e),
5113                );
5114                continue;
5115            }
5116        };
5117        total_seen += events.len();
5118        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
5119        all_written.extend(result.written.iter().cloned());
5120        all_rejected.extend(result.rejected.iter().cloned());
5121        if result.blocked {
5122            all_blocked = true;
5123        }
5124        // Advance per-endpoint cursor. The cursor key is scope-specific
5125        // so federation and local don't trample each other.
5126        if let Some(eid) = result.advance_cursor_to.clone() {
5127            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
5128                all_advance_cursor_to = Some(eid.clone());
5129            }
5130            let key = cursor_key.clone();
5131            config::update_relay_state(|state| {
5132                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5133                    self_obj.insert(key, Value::String(eid));
5134                }
5135                Ok(())
5136            })?;
5137        }
5138    }
5139
5140    // Compatibility shim for the legacy single-cursor code paths below:
5141    // `result` used to come from one process_events call; we now have
5142    // per-endpoint results aggregated into the all_* accumulators.
5143    // Reconstruct a synthetic result for the remaining display logic.
5144    let result = crate::pull::PullResult {
5145        written: all_written,
5146        rejected: all_rejected,
5147        blocked: all_blocked,
5148        advance_cursor_to: all_advance_cursor_to,
5149    };
5150    let events_len = total_seen;
5151
5152    // Cursor advance happened per-endpoint above; no aggregate cursor
5153    // write needed here.
5154
5155    if as_json {
5156        println!(
5157            "{}",
5158            serde_json::to_string(&json!({
5159                "written": result.written,
5160                "rejected": result.rejected,
5161                "total_seen": events_len,
5162                "cursor_blocked": result.blocked,
5163                "cursor_advanced_to": result.advance_cursor_to,
5164            }))?
5165        );
5166    } else {
5167        let blocking = result
5168            .rejected
5169            .iter()
5170            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
5171            .count();
5172        if blocking > 0 {
5173            println!(
5174                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
5175                events_len,
5176                result.written.len(),
5177                result.rejected.len(),
5178                blocking,
5179            );
5180        } else {
5181            println!(
5182                "pulled {} event(s); wrote {}; rejected {}",
5183                events_len,
5184                result.written.len(),
5185                result.rejected.len(),
5186            );
5187        }
5188    }
5189    Ok(())
5190}
5191
5192/// v0.5.17: cursor key for an endpoint's per-scope read position.
5193/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
5194/// back-compat with on-disk relay_state files; local uses a
5195/// `_local` suffix.
5196fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
5197    match scope {
5198        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
5199        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
5200        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
5201        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
5202    }
5203}
5204
5205// ---------- rotate-slot ----------
5206
5207fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
5208    if !config::is_initialized()? {
5209        bail!("not initialized — run `wire init <handle>` first");
5210    }
5211    let mut state = config::read_relay_state()?;
5212    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5213    if self_state.is_null() {
5214        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
5215    }
5216    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
5217    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
5218    // top-level legacy fields directly and bailed for those sessions.
5219    let primary = crate::endpoints::self_primary_endpoint(&state)
5220        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
5221    let url = primary.relay_url.clone();
5222    let old_slot_id = primary.slot_id.clone();
5223    let old_slot_token = primary.slot_token.clone();
5224
5225    // Read identity to sign the announcement.
5226    let card = config::read_agent_card()?;
5227    let did = card
5228        .get("did")
5229        .and_then(Value::as_str)
5230        .unwrap_or("")
5231        .to_string();
5232    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
5233    let pk_b64 = card
5234        .get("verify_keys")
5235        .and_then(Value::as_object)
5236        .and_then(|m| m.values().next())
5237        .and_then(|v| v.get("key"))
5238        .and_then(Value::as_str)
5239        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
5240        .to_string();
5241    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
5242    let sk_seed = config::read_private_key()?;
5243
5244    // Allocate new slot on the same relay.
5245    let normalized = url.trim_end_matches('/').to_string();
5246    let client = crate::relay_client::RelayClient::new(&normalized);
5247    client
5248        .check_healthz()
5249        .context("aborting rotation; old slot still valid")?;
5250    let alloc = client.allocate_slot(Some(&handle))?;
5251    let new_slot_id = alloc.slot_id.clone();
5252    let new_slot_token = alloc.slot_token.clone();
5253
5254    // Optionally announce the rotation to every paired peer via the OLD slot.
5255    // Each peer's recipient-side `wire pull` will pick up this event before
5256    // their daemon next polls the new slot — but auto-update of peer's
5257    // relay.json from a wire_close event is a v0.2 daemon feature; for now
5258    // peers see the event and an operator must manually `add-peer-slot` the
5259    // new coords, OR re-pair via SAS.
5260    let mut announced: Vec<String> = Vec::new();
5261    if !no_announce {
5262        let now = time::OffsetDateTime::now_utc()
5263            .format(&time::format_description::well_known::Rfc3339)
5264            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
5265        let body = json!({
5266            "reason": "operator-initiated slot rotation",
5267            "new_relay_url": url,
5268            "new_slot_id": new_slot_id,
5269            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
5270            // In v0.1 slot tokens are bilateral-shared, so peer can post via
5271            // existing add-peer-slot flow if operator chooses to re-issue.
5272        });
5273        let peers = state["peers"].as_object().cloned().unwrap_or_default();
5274        for (peer_handle, _peer_info) in peers.iter() {
5275            let event = json!({
5276                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5277                "timestamp": now.clone(),
5278                "from": did,
5279                "to": format!("did:wire:{peer_handle}"),
5280                "type": "wire_close",
5281                "kind": 1201,
5282                "body": body.clone(),
5283            });
5284            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
5285                Ok(s) => s,
5286                Err(e) => {
5287                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
5288                    continue;
5289                }
5290            };
5291            // Post to OUR old slot (we're announcing on our own slot, NOT
5292            // peer's slot — peer reads from us). Wait, this is wrong: peers
5293            // read from THEIR OWN slot via wire pull. To reach peer A, we
5294            // post to peer A's slot. Use the existing per-peer slot mapping.
5295            let peer_info = match state["peers"].get(peer_handle) {
5296                Some(p) => p.clone(),
5297                None => continue,
5298            };
5299            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
5300            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
5301            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
5302            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
5303                continue;
5304            }
5305            let peer_client = if peer_url == url {
5306                client.clone()
5307            } else {
5308                crate::relay_client::RelayClient::new(peer_url)
5309            };
5310            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
5311                Ok(_) => announced.push(peer_handle.clone()),
5312                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
5313            }
5314        }
5315    }
5316
5317    // Swap the self-slot to the new one.
5318    state["self"] = json!({
5319        "relay_url": url,
5320        "slot_id": new_slot_id,
5321        "slot_token": new_slot_token,
5322    });
5323    config::write_relay_state(&state)?;
5324
5325    if as_json {
5326        println!(
5327            "{}",
5328            serde_json::to_string(&json!({
5329                "rotated": true,
5330                "old_slot_id": old_slot_id,
5331                "new_slot_id": new_slot_id,
5332                "relay_url": url,
5333                "announced_to": announced,
5334            }))?
5335        );
5336    } else {
5337        println!("rotated slot on {url}");
5338        println!(
5339            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
5340        );
5341        println!("  new slot_id: {new_slot_id}");
5342        if !announced.is_empty() {
5343            println!(
5344                "  announced wire_close (kind=1201) to: {}",
5345                announced.join(", ")
5346            );
5347        }
5348        println!();
5349        println!("next steps:");
5350        println!("  - peers see the wire_close event in their next `wire pull`");
5351        println!(
5352            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
5353        );
5354        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
5355        println!("  - until they do, you'll receive but they won't be able to reach you");
5356        // Suppress unused warning
5357        let _ = old_slot_token;
5358    }
5359    Ok(())
5360}
5361
5362// ---------- forget-peer ----------
5363
5364fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
5365    let mut trust = config::read_trust()?;
5366    let mut removed_from_trust = false;
5367    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
5368        && agents.remove(handle).is_some()
5369    {
5370        removed_from_trust = true;
5371    }
5372    config::write_trust(&trust)?;
5373
5374    let mut state = config::read_relay_state()?;
5375    let mut removed_from_relay = false;
5376    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
5377        && peers.remove(handle).is_some()
5378    {
5379        removed_from_relay = true;
5380    }
5381    config::write_relay_state(&state)?;
5382
5383    let mut purged: Vec<String> = Vec::new();
5384    if purge {
5385        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
5386            let path = dir.join(format!("{handle}.jsonl"));
5387            if path.exists() {
5388                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
5389                purged.push(path.to_string_lossy().into());
5390            }
5391        }
5392    }
5393
5394    if !removed_from_trust && !removed_from_relay {
5395        if as_json {
5396            println!(
5397                "{}",
5398                serde_json::to_string(&json!({
5399                    "removed": false,
5400                    "reason": format!("peer {handle:?} not pinned"),
5401                }))?
5402            );
5403        } else {
5404            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
5405        }
5406        return Ok(());
5407    }
5408
5409    if as_json {
5410        println!(
5411            "{}",
5412            serde_json::to_string(&json!({
5413                "handle": handle,
5414                "removed_from_trust": removed_from_trust,
5415                "removed_from_relay_state": removed_from_relay,
5416                "purged_files": purged,
5417            }))?
5418        );
5419    } else {
5420        println!("forgot peer {handle:?}");
5421        if removed_from_trust {
5422            println!("  - removed from trust.json");
5423        }
5424        if removed_from_relay {
5425            println!("  - removed from relay.json");
5426        }
5427        if !purged.is_empty() {
5428            for p in &purged {
5429                println!("  - deleted {p}");
5430            }
5431        } else if !purge {
5432            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
5433        }
5434    }
5435    Ok(())
5436}
5437
5438// ---------- daemon (long-lived push+pull sync) ----------
5439
5440fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5441    if !config::is_initialized()? {
5442        bail!("not initialized — run `wire init <handle>` first");
5443    }
5444    // v0.13.x identity work: a long-running daemon racing another wire
5445    // process for the same inbox cursor silently loses messages. Surface
5446    // the collision the same way `wire mcp` does. Skipped under `--once`:
5447    // a single sync cycle is atomic and doesn't own the cursor.
5448    if !once {
5449        crate::session::warn_on_identity_collision(std::process::id(), "daemon");
5450    }
5451    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5452
5453    if !as_json {
5454        if once {
5455            eprintln!("wire daemon: single sync cycle, then exit");
5456        } else {
5457            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5458        }
5459    }
5460
5461    // Recover from prior crash: any pending pair in transient state had its
5462    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
5463    // the relay slots and mark the files so the operator can re-issue.
5464    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5465        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5466    }
5467
5468    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
5469    // to our slot, the subscriber signals `wake_rx`; we use it as the
5470    // sleep-or-wake gate of the polling loop. Polling stays as the
5471    // safety net — stream errors fall back transparently to the existing
5472    // interval-based cadence.
5473    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5474    if !once {
5475        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5476    }
5477
5478    loop {
5479        let pushed = run_sync_push().unwrap_or_else(|e| {
5480            eprintln!("daemon: push error: {e:#}");
5481            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5482        });
5483        let pulled = run_sync_pull().unwrap_or_else(|e| {
5484            eprintln!("daemon: pull error: {e:#}");
5485            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5486        });
5487        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5488            eprintln!("daemon: pending-pair tick error: {e:#}");
5489            json!({"transitions": []})
5490        });
5491
5492        if as_json {
5493            println!(
5494                "{}",
5495                serde_json::to_string(&json!({
5496                    "ts": time::OffsetDateTime::now_utc()
5497                        .format(&time::format_description::well_known::Rfc3339)
5498                        .unwrap_or_default(),
5499                    "push": pushed,
5500                    "pull": pulled,
5501                    "pairs": pairs,
5502                }))?
5503            );
5504        } else {
5505            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5506            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5507            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5508            let pair_transitions = pairs["transitions"]
5509                .as_array()
5510                .map(|a| a.len())
5511                .unwrap_or(0);
5512            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5513                eprintln!(
5514                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5515                );
5516            }
5517            // Loud per-transition logging so operator sees pair progress live.
5518            if let Some(arr) = pairs["transitions"].as_array() {
5519                for t in arr {
5520                    eprintln!(
5521                        "  pair {} : {} → {}",
5522                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
5523                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
5524                        t.get("to").and_then(Value::as_str).unwrap_or("?")
5525                    );
5526                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
5527                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5528                    {
5529                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
5530                        eprintln!(
5531                            "    Run: wire pair-confirm {} {}",
5532                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
5533                            sas
5534                        );
5535                    }
5536                }
5537            }
5538        }
5539
5540        if once {
5541            return Ok(());
5542        }
5543        // Wait either for the next poll-interval tick OR for a stream
5544        // wake signal — whichever comes first. Drain any additional
5545        // wake-ups that accumulated during the previous cycle since one
5546        // pull catches up everything.
5547        //
5548        // v0.13.2 (wisp-blossom): if the stream subscriber thread has gone
5549        // away, `wake_rx` is Disconnected and `recv_timeout` returns
5550        // INSTANTLY — which would busy-spin the sync loop (hammering push/pull
5551        // + the relay with zero delay). Fall back to a plain sleep so a dead
5552        // stream degrades to normal polling and never kills or pegs the
5553        // daemon. (Realizes the "decouple stream from sync" hardening — a
5554        // stream failure must never affect the push/pull loop.)
5555        match wake_rx.recv_timeout(interval) {
5556            Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
5557            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
5558                std::thread::sleep(interval);
5559            }
5560        }
5561        while wake_rx.try_recv().is_ok() {}
5562    }
5563}
5564
5565/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
5566/// shape `wire push --json` emits.
5567fn run_sync_push() -> Result<Value> {
5568    let state = config::read_relay_state()?;
5569    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5570    if peers.is_empty() {
5571        return Ok(json!({"pushed": [], "skipped": []}));
5572    }
5573    let outbox_dir = config::outbox_dir()?;
5574    if !outbox_dir.exists() {
5575        return Ok(json!({"pushed": [], "skipped": []}));
5576    }
5577    let mut pushed = Vec::new();
5578    let mut skipped = Vec::new();
5579    for (peer_handle, slot_info) in peers.iter() {
5580        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5581        if !outbox.exists() {
5582            continue;
5583        }
5584        let url = slot_info["relay_url"].as_str().unwrap_or("");
5585        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5586        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5587        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5588            continue;
5589        }
5590        let client = crate::relay_client::RelayClient::new(url);
5591        let body = std::fs::read_to_string(&outbox)?;
5592        for line in body.lines() {
5593            let event: Value = match serde_json::from_str(line) {
5594                Ok(v) => v,
5595                Err(_) => continue,
5596            };
5597            let event_id = event
5598                .get("event_id")
5599                .and_then(Value::as_str)
5600                .unwrap_or("")
5601                .to_string();
5602            match client.post_event(slot_id, slot_token, &event) {
5603                Ok(resp) => {
5604                    if resp.status == "duplicate" {
5605                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5606                    } else {
5607                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5608                    }
5609                }
5610                Err(e) => {
5611                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
5612                    // errors aren't hidden behind the topmost-context URL string.
5613                    // Issue #6 highest-impact silent-fail fix.
5614                    let reason = crate::relay_client::format_transport_error(&e);
5615                    skipped
5616                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5617                }
5618            }
5619        }
5620    }
5621    Ok(json!({"pushed": pushed, "skipped": skipped}))
5622}
5623
5624/// Programmatic pull. Same shape as `wire pull --json`.
5625///
5626/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
5627/// created via `wire session new --with-local` (which only writes
5628/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
5629/// Pre-v0.9 this function read only the top-level fields and silently
5630/// returned `{}` for any v0.5.17+ session.
5631fn run_sync_pull() -> Result<Value> {
5632    let state = config::read_relay_state()?;
5633    if state.get("self").map(Value::is_null).unwrap_or(true) {
5634        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5635    }
5636    // E2 (v0.13.2): pull EVERY self endpoint, not just the primary. A session
5637    // that bound a local slot (additive) alongside its federation slot used to
5638    // have the daemon pull ONLY the primary (federation) endpoint — the local
5639    // slot was never serviced, so same-box loopback delivery silently never
5640    // happened until a manual restart re-seeded the (startup-only) stream
5641    // subscriber. Now each endpoint is pulled with its OWN cursor.
5642    let endpoints = crate::endpoints::self_endpoints(&state);
5643    if endpoints.is_empty() {
5644        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5645    }
5646    let inbox_dir = config::inbox_dir()?;
5647    config::ensure_dirs()?;
5648
5649    // Per-slot cursors live at `self.cursors.<slot_id>`. The legacy global
5650    // `self.last_pulled_event_id` is migrated as the cursor for the PRIMARY
5651    // slot only (a federation event id won't match a local slot's log); other
5652    // slots start from None and `process_events` dedups against the inbox.
5653    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
5654    let legacy_cursor = self_obj
5655        .get("last_pulled_event_id")
5656        .and_then(Value::as_str)
5657        .map(str::to_string);
5658    let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
5659    let mut cursors: serde_json::Map<String, Value> = self_obj
5660        .get("cursors")
5661        .and_then(Value::as_object)
5662        .cloned()
5663        .unwrap_or_default();
5664
5665    let mut all_written: Vec<Value> = Vec::new();
5666    let mut all_rejected: Vec<Value> = Vec::new();
5667    let mut total_seen = 0usize;
5668    let mut blocked_any = false;
5669
5670    for ep in &endpoints {
5671        if ep.relay_url.is_empty() {
5672            continue;
5673        }
5674        let cursor = cursors
5675            .get(&ep.slot_id)
5676            .and_then(Value::as_str)
5677            .map(str::to_string)
5678            .or_else(|| {
5679                if Some(&ep.slot_id) == primary_slot.as_ref() {
5680                    legacy_cursor.clone()
5681                } else {
5682                    None
5683                }
5684            });
5685        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
5686        // One endpoint erroring (relay down, slot gone) must NOT stop the
5687        // others — a dead local relay shouldn't black-hole federation pulls.
5688        let events =
5689            match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
5690                Ok(e) => e,
5691                Err(e) => {
5692                    eprintln!(
5693                        "daemon: pull error on {} slot {} (continuing): {e:#}",
5694                        ep.relay_url, ep.slot_id
5695                    );
5696                    continue;
5697                }
5698            };
5699        total_seen += events.len();
5700        // P0.1 shared cursor-blocking logic (matches `wire pull`). A block on
5701        // one slot only stalls THAT slot's cursor; other slots keep flowing.
5702        let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
5703        if let Some(eid) = &result.advance_cursor_to {
5704            cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
5705        }
5706        blocked_any |= result.blocked;
5707        all_written.extend(result.written);
5708        all_rejected.extend(result.rejected);
5709    }
5710
5711    // P0.3 flock-protected RMW: persist per-slot cursors + keep the legacy
5712    // global cursor in sync with the primary slot for back-compat with older
5713    // binaries that only read `last_pulled_event_id`.
5714    let primary_cursor = primary_slot
5715        .as_ref()
5716        .and_then(|s| cursors.get(s))
5717        .and_then(Value::as_str)
5718        .map(str::to_string);
5719    config::update_relay_state(|state| {
5720        if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5721            self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
5722            if let Some(pc) = &primary_cursor {
5723                self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
5724            }
5725        }
5726        Ok(())
5727    })?;
5728
5729    Ok(json!({
5730        "written": all_written,
5731        "rejected": all_rejected,
5732        "total_seen": total_seen,
5733        "cursor_blocked": blocked_any,
5734        "endpoints_pulled": endpoints.len(),
5735    }))
5736}
5737
5738// ---------- pin (manual out-of-band peer pairing) ----------
5739
5740fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5741    let body =
5742        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5743    let card: Value =
5744        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5745    crate::agent_card::verify_agent_card(&card)
5746        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5747
5748    let mut trust = config::read_trust()?;
5749    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5750
5751    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5752    let handle = crate::agent_card::display_handle_from_did(did).to_string();
5753    config::write_trust(&trust)?;
5754
5755    if as_json {
5756        println!(
5757            "{}",
5758            serde_json::to_string(&json!({
5759                "handle": handle,
5760                "did": did,
5761                "tier": "VERIFIED",
5762                "pinned": true,
5763            }))?
5764        );
5765    } else {
5766        println!("pinned {handle} ({did}) at tier VERIFIED");
5767    }
5768    Ok(())
5769}
5770
5771// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
5772
5773fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5774    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5775}
5776
5777fn cmd_pair_join(
5778    code_phrase: &str,
5779    relay_url: &str,
5780    auto_yes: bool,
5781    timeout_secs: u64,
5782) -> Result<()> {
5783    pair_orchestrate(
5784        relay_url,
5785        Some(code_phrase),
5786        "guest",
5787        auto_yes,
5788        timeout_secs,
5789    )
5790}
5791
5792/// Shared orchestration for both sides of the SAS pairing.
5793///
5794/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
5795/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
5796/// `pair_session_confirm_sas` instead.
5797fn pair_orchestrate(
5798    relay_url: &str,
5799    code_in: Option<&str>,
5800    role: &str,
5801    auto_yes: bool,
5802    timeout_secs: u64,
5803) -> Result<()> {
5804    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5805
5806    let mut s = pair_session_open(role, relay_url, code_in)?;
5807
5808    if role == "host" {
5809        eprintln!();
5810        eprintln!("share this code phrase with your peer:");
5811        eprintln!();
5812        eprintln!("    {}", s.code);
5813        eprintln!();
5814        eprintln!(
5815            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5816            s.code
5817        );
5818    } else {
5819        eprintln!();
5820        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5821    }
5822
5823    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
5824    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
5825    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
5826    // see the process is alive while the other side connects.
5827    const HEARTBEAT_SECS: u64 = 10;
5828    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5829    let started = std::time::Instant::now();
5830    let mut last_heartbeat = started;
5831    let formatted = loop {
5832        if let Some(sas) = pair_session_try_sas(&mut s)? {
5833            break sas;
5834        }
5835        let now = std::time::Instant::now();
5836        if now >= deadline {
5837            return Err(anyhow!(
5838                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5839            ));
5840        }
5841        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5842            let elapsed = now.duration_since(started).as_secs();
5843            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
5844            last_heartbeat = now;
5845        }
5846        std::thread::sleep(std::time::Duration::from_millis(250));
5847    };
5848
5849    eprintln!();
5850    eprintln!("SAS digits (must match peer's terminal):");
5851    eprintln!();
5852    eprintln!("    {formatted}");
5853    eprintln!();
5854
5855    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
5856    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
5857    if !auto_yes {
5858        eprint!("does this match your peer's terminal? [y/N]: ");
5859        use std::io::Write;
5860        std::io::stderr().flush().ok();
5861        let mut input = String::new();
5862        std::io::stdin().read_line(&mut input)?;
5863        let trimmed = input.trim().to_lowercase();
5864        if trimmed != "y" && trimmed != "yes" {
5865            bail!("SAS confirmation declined — aborting pairing");
5866        }
5867    }
5868    s.sas_confirmed = true;
5869
5870    // Stage 4 — seal+exchange bootstrap, pin peer.
5871    let result = pair_session_finalize(&mut s, timeout_secs)?;
5872
5873    let peer_did = result["paired_with"].as_str().unwrap_or("");
5874    let peer_role = if role == "host" { "guest" } else { "host" };
5875    eprintln!("paired with {peer_did} (peer role: {peer_role})");
5876    eprintln!("peer card pinned at tier VERIFIED");
5877    eprintln!(
5878        "peer relay slot saved to {}",
5879        config::relay_state_path()?.display()
5880    );
5881
5882    println!("{}", serde_json::to_string(&result)?);
5883    Ok(())
5884}
5885
5886// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
5887// and pair_session_finalize, both of which inline their own deadline loops.)
5888
5889// ---------- pair — single-shot init + pair-* + setup ----------
5890
5891fn cmd_pair(
5892    handle: &str,
5893    code: Option<&str>,
5894    relay: &str,
5895    auto_yes: bool,
5896    timeout_secs: u64,
5897    no_setup: bool,
5898) -> Result<()> {
5899    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
5900    // bails loudly if a different handle is already set (operator must explicitly delete).
5901    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5902    let did = init_result
5903        .get("did")
5904        .and_then(|v| v.as_str())
5905        .unwrap_or("(unknown)")
5906        .to_string();
5907    let already = init_result
5908        .get("already_initialized")
5909        .and_then(|v| v.as_bool())
5910        .unwrap_or(false);
5911    if already {
5912        println!("(identity {did} already initialized — reusing)");
5913    } else {
5914        println!("initialized {did}");
5915    }
5916    println!();
5917
5918    // Step 2 — pair-host or pair-join based on code presence.
5919    match code {
5920        None => {
5921            println!("hosting pair on {relay} (no code = host) ...");
5922            cmd_pair_host(relay, auto_yes, timeout_secs)?;
5923        }
5924        Some(c) => {
5925            println!("joining pair with code {c} on {relay} ...");
5926            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5927        }
5928    }
5929
5930    // Step 3 — register wire as MCP server in detected client configs (idempotent).
5931    if !no_setup {
5932        println!();
5933        println!("registering wire as MCP server in detected client configs ...");
5934        if let Err(e) = cmd_setup(true) {
5935            // Non-fatal — pair succeeded, just print the warning.
5936            eprintln!("warn: setup --apply failed: {e}");
5937            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
5938        }
5939    }
5940
5941    println!();
5942    println!("pair complete. Next steps:");
5943    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
5944    println!("  wire send <peer> claim <msg>   # send your peer something");
5945    println!("  wire tail                      # watch incoming events");
5946    Ok(())
5947}
5948
5949// ---------- detached pair (daemon-orchestrated) ----------
5950
5951/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
5952/// pair-host/-join into a single command. The non-detached variant lives in
5953/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
5954fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5955    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5956    let did = init_result
5957        .get("did")
5958        .and_then(|v| v.as_str())
5959        .unwrap_or("(unknown)")
5960        .to_string();
5961    let already = init_result
5962        .get("already_initialized")
5963        .and_then(|v| v.as_bool())
5964        .unwrap_or(false);
5965    if already {
5966        println!("(identity {did} already initialized — reusing)");
5967    } else {
5968        println!("initialized {did}");
5969    }
5970    println!();
5971    match code {
5972        None => cmd_pair_host_detach(relay, false),
5973        Some(c) => cmd_pair_join_detach(c, relay, false),
5974    }
5975}
5976
5977fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5978    if !config::is_initialized()? {
5979        bail!("not initialized — run `wire init <handle>` first");
5980    }
5981    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5982        Ok(b) => b,
5983        Err(e) => {
5984            if !as_json {
5985                eprintln!(
5986                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5987                );
5988            }
5989            false
5990        }
5991    };
5992    let code = crate::sas::generate_code_phrase();
5993    let code_hash = crate::pair_session::derive_code_hash(&code);
5994    let now = time::OffsetDateTime::now_utc()
5995        .format(&time::format_description::well_known::Rfc3339)
5996        .unwrap_or_default();
5997    let p = crate::pending_pair::PendingPair {
5998        code: code.clone(),
5999        code_hash,
6000        role: "host".to_string(),
6001        relay_url: relay_url.to_string(),
6002        status: "request_host".to_string(),
6003        sas: None,
6004        peer_did: None,
6005        created_at: now,
6006        last_error: None,
6007        pair_id: None,
6008        our_slot_id: None,
6009        our_slot_token: None,
6010        spake2_seed_b64: None,
6011    };
6012    crate::pending_pair::write_pending(&p)?;
6013    if as_json {
6014        println!(
6015            "{}",
6016            serde_json::to_string(&json!({
6017                "state": "queued",
6018                "code_phrase": code,
6019                "relay_url": relay_url,
6020                "role": "host",
6021                "daemon_spawned": daemon_spawned,
6022            }))?
6023        );
6024    } else {
6025        if daemon_spawned {
6026            println!("(started wire daemon in background)");
6027        }
6028        println!("detached pair-host queued. Share this code with your peer:\n");
6029        println!("    {code}\n");
6030        println!("Next steps:");
6031        println!("  wire pair-list                                # check status");
6032        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
6033        println!("  wire pair-cancel  {code}            # to abort");
6034    }
6035    Ok(())
6036}
6037
6038fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
6039    if !config::is_initialized()? {
6040        bail!("not initialized — run `wire init <handle>` first");
6041    }
6042    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
6043        Ok(b) => b,
6044        Err(e) => {
6045            if !as_json {
6046                eprintln!(
6047                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
6048                );
6049            }
6050            false
6051        }
6052    };
6053    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6054    let code_hash = crate::pair_session::derive_code_hash(&code);
6055    let now = time::OffsetDateTime::now_utc()
6056        .format(&time::format_description::well_known::Rfc3339)
6057        .unwrap_or_default();
6058    let p = crate::pending_pair::PendingPair {
6059        code: code.clone(),
6060        code_hash,
6061        role: "guest".to_string(),
6062        relay_url: relay_url.to_string(),
6063        status: "request_guest".to_string(),
6064        sas: None,
6065        peer_did: None,
6066        created_at: now,
6067        last_error: None,
6068        pair_id: None,
6069        our_slot_id: None,
6070        our_slot_token: None,
6071        spake2_seed_b64: None,
6072    };
6073    crate::pending_pair::write_pending(&p)?;
6074    if as_json {
6075        println!(
6076            "{}",
6077            serde_json::to_string(&json!({
6078                "state": "queued",
6079                "code_phrase": code,
6080                "relay_url": relay_url,
6081                "role": "guest",
6082                "daemon_spawned": daemon_spawned,
6083            }))?
6084        );
6085    } else {
6086        if daemon_spawned {
6087            println!("(started wire daemon in background)");
6088        }
6089        println!("detached pair-join queued for code {code}.");
6090        println!(
6091            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
6092        );
6093    }
6094    Ok(())
6095}
6096
6097fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
6098    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6099    let typed: String = typed_digits
6100        .chars()
6101        .filter(|c| c.is_ascii_digit())
6102        .collect();
6103    if typed.len() != 6 {
6104        bail!(
6105            "expected 6 digits (got {} after stripping non-digits)",
6106            typed.len()
6107        );
6108    }
6109    let mut p = crate::pending_pair::read_pending(&code)?
6110        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
6111    if p.status != "sas_ready" {
6112        bail!(
6113            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
6114            p.status
6115        );
6116    }
6117    let stored = p
6118        .sas
6119        .as_ref()
6120        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
6121        .clone();
6122    if stored == typed {
6123        p.status = "confirmed".to_string();
6124        crate::pending_pair::write_pending(&p)?;
6125        if as_json {
6126            println!(
6127                "{}",
6128                serde_json::to_string(&json!({
6129                    "state": "confirmed",
6130                    "code_phrase": code,
6131                }))?
6132            );
6133        } else {
6134            println!("digits match. Daemon will finalize the handshake on its next tick.");
6135            println!("Run `wire peers` after a few seconds to confirm.");
6136        }
6137    } else {
6138        p.status = "aborted".to_string();
6139        p.last_error = Some(format!(
6140            "SAS digit mismatch (typed {typed}, expected {stored})"
6141        ));
6142        let client = crate::relay_client::RelayClient::new(&p.relay_url);
6143        let _ = client.pair_abandon(&p.code_hash);
6144        crate::pending_pair::write_pending(&p)?;
6145        crate::os_notify::toast(
6146            &format!("wire — pair aborted ({})", p.code),
6147            p.last_error.as_deref().unwrap_or("digits mismatch"),
6148        );
6149        if as_json {
6150            println!(
6151                "{}",
6152                serde_json::to_string(&json!({
6153                    "state": "aborted",
6154                    "code_phrase": code,
6155                    "error": "digits mismatch",
6156                }))?
6157            );
6158        }
6159        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
6160    }
6161    Ok(())
6162}
6163
6164fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
6165    if watch {
6166        return cmd_pair_list_watch(watch_interval_secs);
6167    }
6168    let spake2_items = crate::pending_pair::list_pending()?;
6169    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
6170    if as_json {
6171        // Backwards-compat: flat SPAKE2 array (the shape every existing
6172        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
6173        // surface programmatically via `wire pair-list-inbound --json`
6174        // and via `wire status --json` `pending_pairs.inbound_*` fields.
6175        println!("{}", serde_json::to_string(&spake2_items)?);
6176        return Ok(());
6177    }
6178    if spake2_items.is_empty() && inbound_items.is_empty() {
6179        println!("no pending pair sessions.");
6180        return Ok(());
6181    }
6182    // v0.5.14: inbound section first — these need operator action right now.
6183    // SPAKE2 sessions are typically already mid-flow.
6184    if !inbound_items.is_empty() {
6185        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
6186        println!(
6187            "{:<20} {:<35} {:<25} NEXT STEP",
6188            "PEER", "RELAY", "RECEIVED"
6189        );
6190        for p in &inbound_items {
6191            println!(
6192                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
6193                p.peer_handle,
6194                p.peer_relay_url,
6195                p.received_at,
6196                peer = p.peer_handle,
6197            );
6198        }
6199        println!();
6200    }
6201    if !spake2_items.is_empty() {
6202        println!("SPAKE2 SESSIONS");
6203        println!(
6204            "{:<15} {:<8} {:<18} {:<10} NOTE",
6205            "CODE", "ROLE", "STATUS", "SAS"
6206        );
6207        for p in spake2_items {
6208            let sas = p
6209                .sas
6210                .as_ref()
6211                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
6212                .unwrap_or_else(|| "—".to_string());
6213            let note = p
6214                .last_error
6215                .as_deref()
6216                .or(p.peer_did.as_deref())
6217                .unwrap_or("");
6218            println!(
6219                "{:<15} {:<8} {:<18} {:<10} {}",
6220                p.code, p.role, p.status, sas, note
6221            );
6222        }
6223    }
6224    Ok(())
6225}
6226
6227/// Stream-mode pair-list: never exits. Diffs per-code state every
6228/// `interval_secs` and prints one JSON line per transition (creation,
6229/// status flip, deletion). Useful for shell pipelines:
6230///
6231/// ```text
6232/// wire pair-list --watch | while read line; do
6233///     CODE=$(echo "$line" | jq -r .code)
6234///     STATUS=$(echo "$line" | jq -r .status)
6235///     ...
6236/// done
6237/// ```
6238fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
6239    use std::collections::HashMap;
6240    use std::io::Write;
6241    let interval = std::time::Duration::from_secs(interval_secs.max(1));
6242    // Emit a snapshot synthetic event for every currently-pending pair on
6243    // startup so a consumer that arrives mid-flight sees the current state.
6244    let mut prev: HashMap<String, String> = HashMap::new();
6245    {
6246        let items = crate::pending_pair::list_pending()?;
6247        for p in &items {
6248            println!("{}", serde_json::to_string(&p)?);
6249            prev.insert(p.code.clone(), p.status.clone());
6250        }
6251        // Flush so the consumer's `while read` gets the snapshot promptly.
6252        let _ = std::io::stdout().flush();
6253    }
6254    loop {
6255        std::thread::sleep(interval);
6256        let items = match crate::pending_pair::list_pending() {
6257            Ok(v) => v,
6258            Err(_) => continue,
6259        };
6260        let mut cur: HashMap<String, String> = HashMap::new();
6261        for p in &items {
6262            cur.insert(p.code.clone(), p.status.clone());
6263            match prev.get(&p.code) {
6264                None => {
6265                    // New code appeared.
6266                    println!("{}", serde_json::to_string(&p)?);
6267                }
6268                Some(prev_status) if prev_status != &p.status => {
6269                    // Status flipped.
6270                    println!("{}", serde_json::to_string(&p)?);
6271                }
6272                _ => {}
6273            }
6274        }
6275        for code in prev.keys() {
6276            if !cur.contains_key(code) {
6277                // File disappeared → finalized or cancelled. Emit a synthetic
6278                // "removed" marker so the consumer sees the terminal event.
6279                println!(
6280                    "{}",
6281                    serde_json::to_string(&json!({
6282                        "code": code,
6283                        "status": "removed",
6284                        "_synthetic": true,
6285                    }))?
6286                );
6287            }
6288        }
6289        let _ = std::io::stdout().flush();
6290        prev = cur;
6291    }
6292}
6293
6294/// Block until a pending pair reaches `target_status` or terminates. Process
6295/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
6296/// timeout) so shell scripts can branch directly.
6297fn cmd_pair_watch(
6298    code_phrase: &str,
6299    target_status: &str,
6300    timeout_secs: u64,
6301    as_json: bool,
6302) -> Result<()> {
6303    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6304    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
6305    let mut last_seen_status: Option<String> = None;
6306    loop {
6307        let p_opt = crate::pending_pair::read_pending(&code)?;
6308        let now = std::time::Instant::now();
6309        match p_opt {
6310            None => {
6311                // File gone — either finalized (success if target=sas_ready
6312                // since finalization implies it passed sas_ready) or never
6313                // existed. Distinguish by whether we ever saw it.
6314                if last_seen_status.is_some() {
6315                    if as_json {
6316                        println!(
6317                            "{}",
6318                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
6319                        );
6320                    } else {
6321                        println!("pair {code} finalized (file removed)");
6322                    }
6323                    return Ok(());
6324                } else {
6325                    if as_json {
6326                        println!(
6327                            "{}",
6328                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
6329                        );
6330                    }
6331                    std::process::exit(1);
6332                }
6333            }
6334            Some(p) => {
6335                let cur = p.status.clone();
6336                if Some(cur.clone()) != last_seen_status {
6337                    if as_json {
6338                        // Emit per-transition line so scripts can stream.
6339                        println!("{}", serde_json::to_string(&p)?);
6340                    }
6341                    last_seen_status = Some(cur.clone());
6342                }
6343                if cur == target_status {
6344                    if !as_json {
6345                        let sas_str = p
6346                            .sas
6347                            .as_ref()
6348                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
6349                            .unwrap_or_else(|| "—".to_string());
6350                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
6351                    }
6352                    return Ok(());
6353                }
6354                if cur == "aborted" || cur == "aborted_restart" {
6355                    if !as_json {
6356                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
6357                        eprintln!("pair {code} {cur}: {err}");
6358                    }
6359                    std::process::exit(1);
6360                }
6361            }
6362        }
6363        if now >= deadline {
6364            if !as_json {
6365                eprintln!(
6366                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
6367                );
6368            }
6369            std::process::exit(2);
6370        }
6371        std::thread::sleep(std::time::Duration::from_millis(250));
6372    }
6373}
6374
6375fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
6376    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6377    let p = crate::pending_pair::read_pending(&code)?
6378        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
6379    let client = crate::relay_client::RelayClient::new(&p.relay_url);
6380    let _ = client.pair_abandon(&p.code_hash);
6381    crate::pending_pair::delete_pending(&code)?;
6382    if as_json {
6383        println!(
6384            "{}",
6385            serde_json::to_string(&json!({
6386                "state": "cancelled",
6387                "code_phrase": code,
6388            }))?
6389        );
6390    } else {
6391        println!("cancelled pending pair {code} (relay slot released, file removed).");
6392    }
6393    Ok(())
6394}
6395
6396// ---------- pair-abandon — release stuck pair-slot ----------
6397
6398fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
6399    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
6400    // typed — normalize via the existing parser.
6401    let code = crate::sas::parse_code_phrase(code_phrase)?;
6402    let code_hash = crate::pair_session::derive_code_hash(code);
6403    let client = crate::relay_client::RelayClient::new(relay_url);
6404    client.pair_abandon(&code_hash)?;
6405    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
6406    println!("host can now issue a fresh code; guest can re-join.");
6407    Ok(())
6408}
6409
6410// ---------- invite / accept — one-paste pair (v0.4.0) ----------
6411
6412fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6413    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6414
6415    // If --share, register the invite at the relay's short-URL endpoint and
6416    // build the one-curl onboarding line for the peer to paste.
6417    let share_payload: Option<Value> = if share {
6418        let client = reqwest::blocking::Client::new();
6419        let single_use = if uses == 1 { Some(1u32) } else { None };
6420        let body = json!({
6421            "invite_url": url,
6422            "ttl_seconds": ttl,
6423            "uses": single_use,
6424        });
6425        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6426        let resp = client.post(&endpoint).json(&body).send()?;
6427        if !resp.status().is_success() {
6428            let code = resp.status();
6429            let txt = resp.text().unwrap_or_default();
6430            bail!("relay {code} on /v1/invite/register: {txt}");
6431        }
6432        let parsed: Value = resp.json()?;
6433        let token = parsed
6434            .get("token")
6435            .and_then(Value::as_str)
6436            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6437            .to_string();
6438        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6439        let curl_line = format!("curl -fsSL {share_url} | sh");
6440        Some(json!({
6441            "token": token,
6442            "share_url": share_url,
6443            "curl": curl_line,
6444            "expires_unix": parsed.get("expires_unix"),
6445        }))
6446    } else {
6447        None
6448    };
6449
6450    if as_json {
6451        let mut out = json!({
6452            "invite_url": url,
6453            "ttl_secs": ttl,
6454            "uses": uses,
6455            "relay": relay,
6456        });
6457        if let Some(s) = &share_payload {
6458            out["share"] = s.clone();
6459        }
6460        println!("{}", serde_json::to_string(&out)?);
6461    } else if let Some(s) = share_payload {
6462        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6463        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6464        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6465        println!("{curl}");
6466    } else {
6467        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6468        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6469        println!("{url}");
6470    }
6471    Ok(())
6472}
6473
6474fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6475    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
6476    // resolve it to the underlying wire://pair?... URL via ?format=url before
6477    // accepting. Saves them from having to know which URL shape goes where.
6478    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6479        let sep = if url.contains('?') { '&' } else { '?' };
6480        let resolve_url = format!("{url}{sep}format=url");
6481        let client = reqwest::blocking::Client::new();
6482        let resp = client
6483            .get(&resolve_url)
6484            .send()
6485            .with_context(|| format!("GET {resolve_url}"))?;
6486        if !resp.status().is_success() {
6487            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6488        }
6489        let body = resp.text().unwrap_or_default().trim().to_string();
6490        if !body.starts_with("wire://pair?") {
6491            bail!(
6492                "short URL {url} did not resolve to a wire:// invite. \
6493                 (got: {}{})",
6494                body.chars().take(80).collect::<String>(),
6495                if body.chars().count() > 80 { "…" } else { "" }
6496            );
6497        }
6498        body
6499    } else {
6500        url.to_string()
6501    };
6502
6503    let result = crate::pair_invite::accept_invite(&resolved)?;
6504    if as_json {
6505        println!("{}", serde_json::to_string(&result)?);
6506    } else {
6507        let did = result
6508            .get("paired_with")
6509            .and_then(Value::as_str)
6510            .unwrap_or("?");
6511        println!("paired with {did}");
6512        println!(
6513            "you can now: wire send {} <kind> <body>",
6514            crate::agent_card::display_handle_from_did(did)
6515        );
6516    }
6517    Ok(())
6518}
6519
6520// ---------- whois / profile (v0.5) ----------
6521
6522fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6523    if let Some(h) = handle {
6524        let parsed = crate::pair_profile::parse_handle(h)?;
6525        // Special-case: if the supplied handle matches our own, skip the
6526        // network round-trip and print local.
6527        if config::is_initialized()? {
6528            let card = config::read_agent_card()?;
6529            let local_handle = card
6530                .get("profile")
6531                .and_then(|p| p.get("handle"))
6532                .and_then(Value::as_str)
6533                .map(str::to_string);
6534            if local_handle.as_deref() == Some(h) {
6535                return cmd_whois(None, as_json, None);
6536            }
6537        }
6538        // Remote resolution via .well-known/wire/agent on the handle's domain.
6539        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6540        if as_json {
6541            println!("{}", serde_json::to_string(&resolved)?);
6542        } else {
6543            print_resolved_profile(&resolved);
6544        }
6545        return Ok(());
6546    }
6547    let card = config::read_agent_card()?;
6548    if as_json {
6549        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6550        println!(
6551            "{}",
6552            serde_json::to_string(&json!({
6553                "did": card.get("did").cloned().unwrap_or(Value::Null),
6554                "profile": profile,
6555            }))?
6556        );
6557    } else {
6558        print!("{}", crate::pair_profile::render_self_summary()?);
6559    }
6560    Ok(())
6561}
6562
6563fn print_resolved_profile(resolved: &Value) {
6564    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6565    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6566    let relay = resolved
6567        .get("relay_url")
6568        .and_then(Value::as_str)
6569        .unwrap_or("");
6570    let slot = resolved
6571        .get("slot_id")
6572        .and_then(Value::as_str)
6573        .unwrap_or("");
6574    let profile = resolved
6575        .get("card")
6576        .and_then(|c| c.get("profile"))
6577        .cloned()
6578        .unwrap_or(Value::Null);
6579    println!("{did}");
6580    println!("  nick:         {nick}");
6581    if !relay.is_empty() {
6582        println!("  relay_url:    {relay}");
6583    }
6584    if !slot.is_empty() {
6585        println!("  slot_id:      {slot}");
6586    }
6587    let pick =
6588        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6589    if let Some(s) = pick("display_name") {
6590        println!("  display_name: {s}");
6591    }
6592    if let Some(s) = pick("emoji") {
6593        println!("  emoji:        {s}");
6594    }
6595    if let Some(s) = pick("motto") {
6596        println!("  motto:        {s}");
6597    }
6598    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6599        let joined: Vec<String> = arr
6600            .iter()
6601            .filter_map(|v| v.as_str().map(str::to_string))
6602            .collect();
6603        println!("  vibe:         {}", joined.join(", "));
6604    }
6605    if let Some(s) = pick("pronouns") {
6606        println!("  pronouns:     {s}");
6607    }
6608}
6609
6610/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
6611/// signed pair_drop event with our card + slot coords, deliver via the
6612/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
6613/// Peer's daemon completes the bilateral pin on its next pull and emits a
6614/// pair_drop_ack carrying their slot_token so we can send back.
6615/// Extract just the host portion from `https://host:port/path` → `host`.
6616/// Returns empty string if the URL is malformed.
6617fn host_of_url(url: &str) -> String {
6618    let no_scheme = url
6619        .trim_start_matches("https://")
6620        .trim_start_matches("http://");
6621    no_scheme
6622        .split('/')
6623        .next()
6624        .unwrap_or("")
6625        .split(':')
6626        .next()
6627        .unwrap_or("")
6628        .to_string()
6629}
6630
6631/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
6632/// operator's own relay? Used to suppress the cross-relay phishing
6633/// warning in `wire add` for the happy path.
6634fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6635    // Hard-coded known-good list. wireup.net is the default relay.
6636    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6637    let peer_domain = peer_domain.trim().to_ascii_lowercase();
6638    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6639        return true;
6640    }
6641    // Operator's OWN relay is implicitly trusted — they're already
6642    // bound to it; pairing same-relay peers is the common case.
6643    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6644    if !our_host.is_empty() && our_host == peer_domain {
6645        return true;
6646    }
6647    false
6648}
6649
6650/// v0.6.6: pair with a sister session on this machine without federation.
6651/// Reads the sister's agent-card + endpoints from disk, pins them into our
6652/// trust + relay_state, builds the same `pair_drop` event the federation
6653/// path would emit, then POSTs it directly to the sister's local-relay slot.
6654/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
6655/// the cwd-derived `wire`) are addressable because the local relay never
6656/// needed a public claim for sister coordination.
6657/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
6658/// to a local sister session.
6659///
6660/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
6661/// either form. Exact session-name matches always win; nickname matches
6662/// are a fallback so operators can type "winter-bay" instead of "wire".
6663/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
6664/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
6665/// with the candidate list so the caller can surface a disambiguation
6666/// hint instead of silently picking one.
6667fn resolve_local_session<'a>(
6668    sessions: &'a [crate::session::SessionInfo],
6669    input: &str,
6670) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6671    // Exact session-name match always wins, even if a nickname elsewhere
6672    // also matches. Predictable for scripts and operator muscle memory.
6673    if let Some(s) = sessions.iter().find(|s| s.name == input) {
6674        return Ok(s);
6675    }
6676    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6677        .iter()
6678        .filter(|s| {
6679            s.character
6680                .as_ref()
6681                .map(|c| c.nickname == input)
6682                .unwrap_or(false)
6683        })
6684        .collect();
6685    match nick_matches.len() {
6686        0 => Err(ResolveError::NotFound),
6687        1 => Ok(nick_matches[0]),
6688        _ => Err(ResolveError::Ambiguous(
6689            nick_matches.iter().map(|s| s.name.clone()).collect(),
6690        )),
6691    }
6692}
6693
6694#[derive(Debug)]
6695enum ResolveError {
6696    NotFound,
6697    Ambiguous(Vec<String>),
6698}
6699
6700/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
6701/// to a pinned peer's canonical handle.
6702///
6703/// `wire send <peer>` accepts either the handle the peer registered with
6704/// or their character nickname (DID-hash-derived). Exact handle match
6705/// always wins. When a nickname matches multiple peers (theoretically
6706/// possible via DID-hash collision in the (adj, noun) space), returns
6707/// `Ambiguous` so the caller can surface a disambiguation hint instead
6708/// of silently picking one.
6709///
6710/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
6711/// overrides on the peer's side live in their local `display.json` and
6712/// aren't yet published via agent-card. (That's the v0.7+ federation
6713/// lifecycle work — peers publishing overrides so we resolve by what
6714/// they call themselves, not just what their DID hashes to.)
6715fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6716    let trust = match config::read_trust() {
6717        Ok(t) => t,
6718        Err(_) => return Ok(None),
6719    };
6720    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6721        Some(a) => a,
6722        None => return Ok(None),
6723    };
6724    if agents.contains_key(input) {
6725        return Ok(Some(input.to_string()));
6726    }
6727    let mut nick_matches: Vec<String> = Vec::new();
6728    for (handle, agent) in agents.iter() {
6729        // v0.7.0-alpha.6: prefer peer's published display nickname over
6730        // auto-derived. Allows `wire send <their-chosen-name>` not just
6731        // `wire send <their-did-hash-derived-name>`.
6732        let character = match agent.get("card") {
6733            Some(card) => crate::character::Character::from_card(card),
6734            None => match agent.get("did").and_then(Value::as_str) {
6735                Some(did) => crate::character::Character::from_did(did),
6736                None => continue,
6737            },
6738        };
6739        if character.nickname == input {
6740            nick_matches.push(handle.clone());
6741        }
6742    }
6743    match nick_matches.len() {
6744        0 => Ok(None),
6745        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6746        _ => Err(ResolveError::Ambiguous(nick_matches)),
6747    }
6748}
6749
6750fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6751    // 1. Locate sister session by name OR character nickname.
6752    let sessions = crate::session::list_sessions()?;
6753    let sister = match resolve_local_session(&sessions, sister_name) {
6754        Ok(s) => s,
6755        Err(ResolveError::NotFound) => bail!(
6756            "no sister session named `{sister_name}` (matched by session name or character nickname). \
6757             Run `wire session list` to see what's available."
6758        ),
6759        Err(ResolveError::Ambiguous(candidates)) => bail!(
6760            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6761             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6762            candidates.len(),
6763            candidates.join(", ")
6764        ),
6765    };
6766    // If we matched via nickname (not exact name), surface that so the
6767    // operator sees what we resolved to. Quiet when names match exactly.
6768    if sister.name != sister_name {
6769        eprintln!(
6770            "wire add: resolved nickname `{sister_name}` → session `{}`",
6771            sister.name
6772        );
6773    }
6774
6775    // 2. Refuse self-pair — operator owns both sides, but a self-loop
6776    // breaks the bilateral state machine.
6777    let our_card = config::read_agent_card()
6778        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6779    let our_did = our_card
6780        .get("did")
6781        .and_then(Value::as_str)
6782        .ok_or_else(|| anyhow!("agent-card missing did"))?
6783        .to_string();
6784    if let Some(sister_did) = sister.did.as_deref()
6785        && sister_did == our_did
6786    {
6787        bail!("refusing to add self (`{sister_name}` is this very session)");
6788    }
6789
6790    // 3. Read sister's agent-card + relay state from disk.
6791    let sister_card_path = sister
6792        .home_dir
6793        .join("config")
6794        .join("wire")
6795        .join("agent-card.json");
6796    let sister_card: Value = serde_json::from_slice(
6797        &std::fs::read(&sister_card_path)
6798            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6799    )
6800    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6801    let sister_relay_state: Value = std::fs::read(
6802        sister
6803            .home_dir
6804            .join("config")
6805            .join("wire")
6806            .join("relay.json"),
6807    )
6808    .ok()
6809    .and_then(|b| serde_json::from_slice(&b).ok())
6810    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6811
6812    let sister_did = sister_card
6813        .get("did")
6814        .and_then(Value::as_str)
6815        .ok_or_else(|| anyhow!("sister card missing did"))?
6816        .to_string();
6817    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6818
6819    // Pull sister's full endpoint set; we want the local one for delivery
6820    // and we'll pin all of them so OUR pushes prefer local-first per the
6821    // existing routing logic.
6822    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6823    if sister_endpoints.is_empty() {
6824        bail!(
6825            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6826        );
6827    }
6828    let sister_local = sister_endpoints
6829        .iter()
6830        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6831    let delivery_endpoint = match sister_local {
6832        Some(e) => e.clone(),
6833        None => sister_endpoints[0].clone(),
6834    };
6835
6836    // 4. Ensure WE have a slot to advertise back. For local-only sessions
6837    // this is the local slot; for dual-slot sessions, federation is fine.
6838    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
6839    // for pure local-only — instead, pick our own existing federation
6840    // endpoint if present, else fall back to whatever's first.
6841    let our_relay_state = config::read_relay_state()?;
6842    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6843    if our_endpoints.is_empty() {
6844        bail!(
6845            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6846        );
6847    }
6848    let our_advertised = our_endpoints
6849        .iter()
6850        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6851        .cloned()
6852        .unwrap_or_else(|| our_endpoints[0].clone());
6853
6854    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
6855    // relay_state.peers with their full endpoint set. slot_token lands
6856    // via pair_drop_ack as usual.
6857    let mut trust = config::read_trust()?;
6858    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6859    config::write_trust(&trust)?;
6860    let mut relay_state = config::read_relay_state()?;
6861    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6862    config::write_relay_state(&relay_state)?;
6863
6864    // 6. Build the same pair_drop event the federation path emits, with
6865    // our card + endpoints in the body so the sister can pin us back.
6866    let sk_seed = config::read_private_key()?;
6867    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6868    let pk_b64 = our_card
6869        .get("verify_keys")
6870        .and_then(Value::as_object)
6871        .and_then(|m| m.values().next())
6872        .and_then(|v| v.get("key"))
6873        .and_then(Value::as_str)
6874        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6875    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6876    let now = time::OffsetDateTime::now_utc()
6877        .format(&time::format_description::well_known::Rfc3339)
6878        .unwrap_or_default();
6879    let mut body = json!({
6880        "card": our_card,
6881        "relay_url": our_advertised.relay_url,
6882        "slot_id": our_advertised.slot_id,
6883        "slot_token": our_advertised.slot_token,
6884    });
6885    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6886    let event = json!({
6887        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6888        "timestamp": now,
6889        "from": our_did,
6890        "to": sister_did,
6891        "type": "pair_drop",
6892        "kind": 1100u32,
6893        "body": body,
6894    });
6895    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6896    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6897
6898    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
6899    // (the federation handle indexer) — we already know the slot coords
6900    // from disk, so post_event is sufficient.
6901    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6902    client
6903        .post_event(
6904            &delivery_endpoint.slot_id,
6905            &delivery_endpoint.slot_token,
6906            &signed,
6907        )
6908        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6909
6910    if as_json {
6911        println!(
6912            "{}",
6913            serde_json::to_string(&json!({
6914                "handle": sister_name,
6915                "paired_with": sister_did,
6916                "peer_handle": sister_handle,
6917                "event_id": event_id,
6918                "delivered_via": match delivery_endpoint.scope {
6919                    crate::endpoints::EndpointScope::Local => "local",
6920                    crate::endpoints::EndpointScope::Lan => "lan",
6921                    crate::endpoints::EndpointScope::Uds => "uds",
6922                    crate::endpoints::EndpointScope::Federation => "federation",
6923                },
6924                "status": "drop_sent",
6925            }))?
6926        );
6927    } else {
6928        let scope = match delivery_endpoint.scope {
6929            crate::endpoints::EndpointScope::Local => "local",
6930            crate::endpoints::EndpointScope::Lan => "lan",
6931            crate::endpoints::EndpointScope::Uds => "uds",
6932            crate::endpoints::EndpointScope::Federation => "federation",
6933        };
6934        println!(
6935            "→ 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.",
6936            delivery_endpoint.relay_url
6937        );
6938    }
6939    Ok(())
6940}
6941
6942fn cmd_add(
6943    handle_arg: &str,
6944    relay_override: Option<&str>,
6945    local_sister: bool,
6946    as_json: bool,
6947) -> Result<()> {
6948    // v0.7.4: nickname-friendly local-sister resolution. Whether the
6949    // operator passed `--local-sister` explicitly OR just typed a bare
6950    // name (no `@<relay>`), try to resolve through the local sessions
6951    // registry so character nicknames AND session names AND card
6952    // handles all work as input. Closes the "I only know this peer by
6953    // its character name" ergonomic gap that forced operators into
6954    // `wire session list-local | grep <nick> | awk` dances.
6955    if local_sister {
6956        let resolved = crate::session::resolve_local_sister(handle_arg)
6957            .unwrap_or_else(|| handle_arg.to_string());
6958        return cmd_add_local_sister(&resolved, as_json);
6959    }
6960    if !handle_arg.contains('@')
6961        && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6962    {
6963        eprintln!(
6964            "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6965             — routing via --local-sister (disk-read card, no relay lookup)."
6966        );
6967        return cmd_add_local_sister(&resolved, as_json);
6968    }
6969    if !handle_arg.contains('@') {
6970        bail!(
6971            "`{handle_arg}` doesn't match any local sister session and has no \
6972             @<relay> suffix for federation.\n\
6973             — Local sisters: `wire session list-local` (operator types name OR \
6974             character nickname)\n\
6975             — Federation:    `wire add <handle>@<relay-domain>` (e.g. \
6976             `wire add alice@wireup.net`)"
6977        );
6978    }
6979    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6980
6981    // 1. Auto-init self if needed + ensure a relay slot.
6982    let (our_did, our_relay, our_slot_id, our_slot_token) =
6983        crate::pair_invite::ensure_self_with_relay(relay_override)?;
6984    if our_did == format!("did:wire:{}", parsed.nick) {
6985        // Lazy guard — actual self-add would also be caught by FCFS later.
6986        bail!("refusing to add self (handle matches own DID)");
6987    }
6988
6989    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
6990    // already sitting in pending-inbound, the operator is now accepting it.
6991    // Pin trust, save relay coords + slot_token from the stored drop, ship
6992    // our own slot_token back via pair_drop_ack, delete the pending record.
6993    //
6994    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
6995    // receiver-side auto-promote was removed there; operator consent flows
6996    // through here. After this branch returns, both sides are bilaterally
6997    // pinned and capability flows in both directions.
6998    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6999        return cmd_add_accept_pending(
7000            handle_arg,
7001            &parsed.nick,
7002            &pending,
7003            &our_relay,
7004            &our_slot_id,
7005            &our_slot_token,
7006            as_json,
7007        );
7008    }
7009
7010    // v0.5.19 (#9.4): cross-relay phishing guardrail.
7011    //
7012    // Threat: operator wants to add `boss@wireup.net` but types
7013    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
7014    // The .well-known resolution returns whoever claimed the nick on the
7015    // *typo* relay, the bilateral gate still completes (the attacker
7016    // accepts the pair on their side), and the operator pins the
7017    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
7018    // there's no asymmetry to detect when the attacker WANTS to be
7019    // paired.
7020    //
7021    // Mitigation: warn loudly when the peer's relay domain is novel
7022    // (not the operator's own relay, not in a small known-good set).
7023    // Doesn't block — operators have legitimate reasons to pair across
7024    // relays. The signal lands in shell history so a phished operator
7025    // can find it in retrospect.
7026    if !is_known_relay_domain(&parsed.domain, &our_relay) {
7027        eprintln!(
7028            "wire add: WARN unfamiliar relay domain `{}`.",
7029            parsed.domain
7030        );
7031        eprintln!(
7032            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
7033            host_of_url(&our_relay)
7034        );
7035        eprintln!(
7036            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
7037            parsed.nick
7038        );
7039        eprintln!(
7040            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
7041            parsed.nick
7042        );
7043        eprintln!("  peer out-of-band that they actually run a relay at this domain");
7044        eprintln!("  before relying on the pair. (See issue #9.4.)");
7045    }
7046
7047    // 2. Resolve peer via .well-known on their relay.
7048    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
7049    let peer_card = resolved
7050        .get("card")
7051        .cloned()
7052        .ok_or_else(|| anyhow!("resolved missing card"))?;
7053    let peer_did = resolved
7054        .get("did")
7055        .and_then(Value::as_str)
7056        .ok_or_else(|| anyhow!("resolved missing did"))?
7057        .to_string();
7058    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
7059
7060    // Self-pair guard (issue #30, explicit "Optional" ask). Refuses loudly
7061    // when the resolved peer DID matches our own. See
7062    // `reject_self_pair_after_resolution` for the full failure-mode and
7063    // remediation rationale.
7064    reject_self_pair_after_resolution(&our_did, &peer_did)?;
7065
7066    let peer_slot_id = resolved
7067        .get("slot_id")
7068        .and_then(Value::as_str)
7069        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
7070        .to_string();
7071    let peer_relay = resolved
7072        .get("relay_url")
7073        .and_then(Value::as_str)
7074        .map(str::to_string)
7075        .or_else(|| relay_override.map(str::to_string))
7076        .unwrap_or_else(|| format!("https://{}", parsed.domain));
7077
7078    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
7079    let mut trust = config::read_trust()?;
7080    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
7081    config::write_trust(&trust)?;
7082    let mut relay_state = config::read_relay_state()?;
7083    // Additive re-pin (v0.13.2, E3 token-bleed fix). The old code REPLACED the
7084    // whole peer entry with a flat federation-only one, seeding the token from
7085    // the entry's TOP-LEVEL `slot_token`. Two bugs (glossy-magnolia repro):
7086    //   1. re-dialing a peer that had a local endpoint (from add-peer-slot)
7087    //      CLOBBERED that local endpoint.
7088    //   2. after a local add-peer-slot the top-level token was the LOCAL token,
7089    //      so the federation endpoint inherited a stale LOCAL bearer →
7090    //      federation delivery would 401.
7091    // Fix: merge the federation endpoint into the peer's endpoints[] (preserve
7092    // the local one), and seed its token ONLY from a prior FEDERATION endpoint
7093    // on the same relay (re-dialing an already-acked peer), never a local one —
7094    // empty until the pair_drop_ack lands otherwise.
7095    let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
7096        .get("peers")
7097        .and_then(|p| p.get(&peer_handle))
7098        .and_then(|e| e.get("endpoints"))
7099        .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
7100        .unwrap_or_default();
7101    let fed_token = endpoints
7102        .iter()
7103        .find(|e| {
7104            e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
7105        })
7106        .map(|e| e.slot_token.clone())
7107        .unwrap_or_default();
7108    let fed_ep = crate::endpoints::Endpoint {
7109        relay_url: peer_relay.clone(),
7110        slot_id: peer_slot_id.clone(),
7111        slot_token: fed_token, // empty until pair_drop_ack lands
7112        scope: crate::endpoints::EndpointScope::Federation,
7113    };
7114    if let Some(existing) = endpoints
7115        .iter_mut()
7116        .find(|e| e.relay_url == fed_ep.relay_url)
7117    {
7118        *existing = fed_ep;
7119    } else {
7120        endpoints.push(fed_ep);
7121    }
7122    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
7123    config::write_relay_state(&relay_state)?;
7124
7125    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
7126    // is the v0.5 zero-paste open-mode path).
7127    let our_card = config::read_agent_card()?;
7128    let sk_seed = config::read_private_key()?;
7129    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7130    let pk_b64 = our_card
7131        .get("verify_keys")
7132        .and_then(Value::as_object)
7133        .and_then(|m| m.values().next())
7134        .and_then(|v| v.get("key"))
7135        .and_then(Value::as_str)
7136        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7137    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7138    let now = time::OffsetDateTime::now_utc()
7139        .format(&time::format_description::well_known::Rfc3339)
7140        .unwrap_or_default();
7141    // v0.5.17: advertise all our endpoints (federation + optional local)
7142    // to the peer in the pair_drop body. Back-compat: top-level
7143    // relay_url/slot_id/slot_token still point at the federation
7144    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
7145    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
7146    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7147    let mut body = json!({
7148        "card": our_card,
7149        "relay_url": our_relay,
7150        "slot_id": our_slot_id,
7151        "slot_token": our_slot_token,
7152    });
7153    if !our_endpoints.is_empty() {
7154        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7155    }
7156    let event = json!({
7157        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7158        "timestamp": now,
7159        "from": our_did,
7160        "to": peer_did,
7161        "type": "pair_drop",
7162        "kind": 1100u32,
7163        "body": body,
7164    });
7165    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7166
7167    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
7168    let client = crate::relay_client::RelayClient::new(&peer_relay);
7169    let resp = client.handle_intro(&parsed.nick, &signed)?;
7170    let event_id = signed
7171        .get("event_id")
7172        .and_then(Value::as_str)
7173        .unwrap_or("")
7174        .to_string();
7175
7176    if as_json {
7177        println!(
7178            "{}",
7179            serde_json::to_string(&json!({
7180                "handle": handle_arg,
7181                "paired_with": peer_did,
7182                "peer_handle": peer_handle,
7183                "event_id": event_id,
7184                "drop_response": resp,
7185                "status": "drop_sent",
7186            }))?
7187        );
7188    } else {
7189        println!(
7190            "→ 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."
7191        );
7192    }
7193    Ok(())
7194}
7195
7196/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
7197/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
7198/// coords + slot_token from the stored drop, ship our slot_token back via
7199/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
7200/// invite-URL path (which is already bilateral by virtue of the pre-shared
7201/// nonce).
7202fn cmd_add_accept_pending(
7203    handle_arg: &str,
7204    peer_nick: &str,
7205    pending: &crate::pending_inbound_pair::PendingInboundPair,
7206    _our_relay: &str,
7207    _our_slot_id: &str,
7208    _our_slot_token: &str,
7209    as_json: bool,
7210) -> Result<()> {
7211    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
7212    //    `wire add` against this handle while a drop was waiting.
7213    let mut trust = config::read_trust()?;
7214    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
7215    config::write_trust(&trust)?;
7216
7217    // 2. Record peer's relay coords + slot_token (already shipped to us in
7218    //    the original drop body; held back until now).
7219    // v0.5.17: pin all advertised endpoints (federation + optional local).
7220    // Falls back to a single federation entry when the record was written
7221    // by v0.5.16-era code that didn't carry endpoints[].
7222    let mut relay_state = config::read_relay_state()?;
7223    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
7224        vec![crate::endpoints::Endpoint::federation(
7225            pending.peer_relay_url.clone(),
7226            pending.peer_slot_id.clone(),
7227            pending.peer_slot_token.clone(),
7228        )]
7229    } else {
7230        pending.peer_endpoints.clone()
7231    };
7232    crate::endpoints::pin_peer_endpoints(
7233        &mut relay_state,
7234        &pending.peer_handle,
7235        &endpoints_to_pin,
7236    )?;
7237    config::write_relay_state(&relay_state)?;
7238
7239    // 3. Ship our slot_token to peer via pair_drop_ack — try every advertised
7240    //    peer endpoint in priority order (Bug 2). `endpoints_to_pin` was
7241    //    already built from `pending.peer_endpoints` (with legacy-triple
7242    //    fallback) just above, so we reuse it rather than rebuilding.
7243    crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
7244        || {
7245            format!(
7246                "pair_drop_ack send to {} (across {} endpoint(s)) failed",
7247                pending.peer_handle,
7248                endpoints_to_pin.len()
7249            )
7250        },
7251    )?;
7252
7253    // 4. Delete the pending-inbound record now that bilateral is complete.
7254    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
7255
7256    if as_json {
7257        println!(
7258            "{}",
7259            serde_json::to_string(&json!({
7260                "handle": handle_arg,
7261                "paired_with": pending.peer_did,
7262                "peer_handle": pending.peer_handle,
7263                "status": "bilateral_accepted",
7264                "via": "pending_inbound",
7265            }))?
7266        );
7267    } else {
7268        println!(
7269            "→ 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} \"...\"`.",
7270            peer = pending.peer_handle,
7271        );
7272    }
7273    Ok(())
7274}
7275
7276/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
7277/// for a pending-inbound pair request. Pin trust, write relay_state from the
7278/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
7279/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
7280/// when a pending-inbound record exists, but without needing to remember
7281/// the peer's relay domain.
7282fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
7283    let nick = crate::agent_card::bare_handle(peer_nick);
7284    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
7285        anyhow!(
7286            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
7287             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
7288        )
7289    })?;
7290    let (_our_did, our_relay, our_slot_id, our_slot_token) =
7291        crate::pair_invite::ensure_self_with_relay(None)?;
7292    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
7293    cmd_add_accept_pending(
7294        &handle_arg,
7295        nick,
7296        &pending,
7297        &our_relay,
7298        &our_slot_id,
7299        &our_slot_token,
7300        as_json,
7301    )
7302}
7303
7304/// v0.5.14: programmatic access to pending-inbound for scripts.
7305/// `wire pair-list-inbound --json` returns a flat array of records.
7306fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
7307    let items = crate::pending_inbound_pair::list_pending_inbound()?;
7308    if as_json {
7309        println!("{}", serde_json::to_string(&items)?);
7310        return Ok(());
7311    }
7312    if items.is_empty() {
7313        println!("no pending pair requests — your inbox is clear.");
7314        return Ok(());
7315    }
7316    // v0.9.3: conversational output. Tabular data is for --json. Humans
7317    // get one short sentence per pending peer, each rendered with the
7318    // peer's character (DID-derived emoji + nickname) so they can match
7319    // the speaker against their statusline / mesh-status view at a
7320    // glance. The "next step" sentence at the bottom names the exact
7321    // verbs to run.
7322    let plural = if items.len() == 1 { "" } else { "s" };
7323    println!("{} pending pair request{plural}:\n", items.len());
7324    for p in &items {
7325        let ch = crate::character::Character::from_did(&p.peer_did);
7326        let glyph = crate::character::emoji_with_fallback(&ch);
7327        // ASCII-friendly arrow if the operator's terminal can't render
7328        // emoji (the same routine drives the fallback).
7329        println!(
7330            "  {glyph} {nick}  ({handle})  wants to pair with you",
7331            nick = ch.nickname,
7332            handle = p.peer_handle,
7333        );
7334    }
7335    println!();
7336    println!(
7337        "→ to accept any: `wire accept <name>`  (e.g. `wire accept {first}`)",
7338        first = items
7339            .first()
7340            .map(|p| {
7341                let ch = crate::character::Character::from_did(&p.peer_did);
7342                ch.nickname
7343            })
7344            .unwrap_or_else(|| "<name>".to_string())
7345    );
7346    println!("→ to refuse:    `wire reject <name>`");
7347    Ok(())
7348}
7349
7350/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
7351/// without pairing. No event is sent back to the peer; their side stays
7352/// pending until they time out or the operator-side data ages out.
7353fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
7354    let nick = crate::agent_card::bare_handle(peer_nick);
7355    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
7356    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
7357
7358    if as_json {
7359        println!(
7360            "{}",
7361            serde_json::to_string(&json!({
7362                "peer": nick,
7363                "rejected": existed.is_some(),
7364                "had_pending": existed.is_some(),
7365            }))?
7366        );
7367    } else if existed.is_some() {
7368        println!(
7369            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
7370        );
7371    } else {
7372        println!("no pending pair from {nick} — nothing to reject");
7373    }
7374    Ok(())
7375}
7376
7377// ---------- session (v0.5.16) ----------
7378//
7379// Multi-session wire on one machine. See src/session.rs for the storage
7380// layout + naming rules. The CLI dispatcher here orchestrates child
7381// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
7382// each session-local `init` / `claim` / `daemon` runs in its own world
7383// without cross-contamination via env vars in this process.
7384
7385// ---------- group chat (v0.13.3) ----------
7386
7387fn cmd_group(cmd: GroupCommand) -> Result<()> {
7388    match cmd {
7389        GroupCommand::Create { name, json } => cmd_group_create(&name, json),
7390        GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
7391        GroupCommand::Send {
7392            group,
7393            message,
7394            json,
7395        } => cmd_group_send(&group, &message, json),
7396        GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
7397        GroupCommand::List { json } => cmd_group_list(json),
7398        GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
7399        GroupCommand::Join { code, json } => cmd_group_join(&code, json),
7400    }
7401}
7402
7403/// This agent's (did, handle) from its signed card.
7404/// This agent's signing identity for group ops: (did, handle, key_id, pk_b64).
7405fn group_self() -> Result<(String, String, String, String)> {
7406    let card = config::read_agent_card()?;
7407    let did = card
7408        .get("did")
7409        .and_then(Value::as_str)
7410        .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
7411        .to_string();
7412    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7413    let pk_b64 = card
7414        .get("verify_keys")
7415        .and_then(Value::as_object)
7416        .and_then(|m| m.values().next())
7417        .and_then(|v| v.get("key"))
7418        .and_then(Value::as_str)
7419        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
7420        .to_string();
7421    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7422    let key_id = make_key_id(&handle, &pk_bytes);
7423    Ok((did, handle, key_id, pk_b64))
7424}
7425
7426/// Relay to host a group room on — prefer the federation endpoint (remote
7427/// members can reach it), fall back to LAN, then local, then any.
7428fn group_room_relay_url() -> Result<String> {
7429    use crate::endpoints::EndpointScope;
7430    let state = config::read_relay_state()?;
7431    let eps = crate::endpoints::self_endpoints(&state);
7432    let pick = eps
7433        .iter()
7434        .find(|e| e.scope == EndpointScope::Federation)
7435        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
7436        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
7437        .or_else(|| eps.first());
7438    match pick {
7439        Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
7440        _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
7441    }
7442}
7443
7444/// Sign a `group_invite` (carrying the full creator-signed Group) and queue it
7445/// to every other member's outbox. The daemon/push delivers; the recipient's
7446/// `ingest_group_invites` materializes the room + introduce-pins members.
7447fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
7448    let (_, self_handle, _, pk_b64) = group_self()?;
7449    let sk_seed = config::read_private_key()?;
7450    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7451    let now_iso = time::OffsetDateTime::now_utc()
7452        .format(&time::format_description::well_known::Rfc3339)
7453        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7454    let group_json = serde_json::to_value(group)?;
7455    let mut delivered = 0usize;
7456    for handle in group.other_member_handles(self_did) {
7457        let event = json!({
7458            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7459            "timestamp": now_iso,
7460            "from": self_did,
7461            "to": format!("did:wire:{handle}"),
7462            "type": "group_invite",
7463            "kind": parse_kind("group_invite")?,
7464            "body": group_json,
7465        });
7466        let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7467            .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
7468        let line = serde_json::to_vec(&signed)?;
7469        if config::append_outbox_record(&handle, &line).is_ok() {
7470            delivered += 1;
7471        }
7472    }
7473    Ok(delivered)
7474}
7475
7476/// Introduce-pin a member's key on the creator's vouch: ensure
7477/// `trust.agents[handle]` carries this key so the member's group messages
7478/// verify, WITHOUT granting bilateral trust. Never lowers an existing tier
7479/// (a directly-VERIFIED peer stays VERIFIED); only adds the key if missing.
7480/// Returns `true` iff it actually changed `trust` (new entry or added key) —
7481/// callers use this to decide whether to persist.
7482fn introduce_pin(
7483    trust: &mut Value,
7484    handle: &str,
7485    did: &str,
7486    key_id: &str,
7487    key: &str,
7488    group_id: &str,
7489) -> bool {
7490    let now = time::OffsetDateTime::now_utc()
7491        .format(&time::format_description::well_known::Rfc3339)
7492        .unwrap_or_default();
7493    let agents = trust
7494        .as_object_mut()
7495        .expect("trust is an object")
7496        .entry("agents")
7497        .or_insert_with(|| json!({}));
7498    let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
7499    match agents.get_mut(handle) {
7500        Some(existing) => {
7501            // Already pinned (maybe at a higher bilateral tier) — just ensure
7502            // the key is present. Do NOT touch the tier.
7503            let keys = existing
7504                .as_object_mut()
7505                .and_then(|o| o.get_mut("public_keys"))
7506                .and_then(Value::as_array_mut);
7507            if let Some(keys) = keys {
7508                let have = keys
7509                    .iter()
7510                    .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
7511                if !have {
7512                    keys.push(key_rec);
7513                    return true;
7514                }
7515            }
7516            false
7517        }
7518        None => {
7519            // First sight — pin at bilateral UNTRUSTED (disjoint from GroupTier).
7520            agents[handle] = json!({
7521                "tier": "UNTRUSTED",
7522                "did": did,
7523                "public_keys": [key_rec],
7524                "introduced_via": group_id,
7525                "pinned_at": now,
7526            });
7527            true
7528        }
7529    }
7530}
7531
7532/// Scan the inbox for `group_invite` events from pinned creators, verify them
7533/// (event signature + roster `creator_sig`), materialize/refresh the local
7534/// group at its highest epoch, and introduce-pin every other member. Lazy:
7535/// runs at the top of group send/tail/list so a member just-pulled an invite
7536/// is immediately usable. Skips groups this agent created.
7537fn ingest_group_invites() -> Result<()> {
7538    let inbox = config::inbox_dir()?;
7539    if !inbox.exists() {
7540        return Ok(());
7541    }
7542    let (self_did, ..) = group_self()?;
7543    let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7544    // group_id -> highest-epoch verified roster seen in the inbox.
7545    let mut best: std::collections::HashMap<String, crate::group::Group> =
7546        std::collections::HashMap::new();
7547
7548    for entry in std::fs::read_dir(&inbox)?.flatten() {
7549        let path = entry.path();
7550        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7551            continue;
7552        }
7553        for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
7554            let event: Value = match serde_json::from_str(line) {
7555                Ok(v) => v,
7556                Err(_) => continue,
7557            };
7558            if event.get("type").and_then(Value::as_str) != Some("group_invite") {
7559                continue;
7560            }
7561            // Event-level: the invite must be from a pinned peer (the creator)
7562            // with a valid signature.
7563            if verify_message_v31(&event, &trust_now).is_err() {
7564                continue;
7565            }
7566            let Some(body) = event.get("body") else {
7567                continue;
7568            };
7569            let group: crate::group::Group = match serde_json::from_value(body.clone()) {
7570                Ok(g) => g,
7571                Err(_) => continue,
7572            };
7573            if group.creator_did == self_did {
7574                continue; // never overwrite a group I created
7575            }
7576            // The invite's sender must be the group's creator.
7577            let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7578            if from_did != group.creator_did {
7579                continue;
7580            }
7581            // Roster integrity: creator_sig must verify against the creator's
7582            // independently-pinned key (we paired with the creator → have it).
7583            let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
7584            let creator_key = trust_now
7585                .get("agents")
7586                .and_then(|a| a.get(creator_handle))
7587                .and_then(|a| a.get("public_keys"))
7588                .and_then(Value::as_array)
7589                .and_then(|ks| ks.first())
7590                .and_then(|k| k.get("key"))
7591                .and_then(Value::as_str)
7592                .and_then(|b| crate::signing::b64decode(b).ok());
7593            let Some(creator_key) = creator_key else {
7594                continue;
7595            };
7596            if !group.verify(&creator_key) {
7597                continue;
7598            }
7599            match best.get(&group.id) {
7600                Some(prev) if prev.epoch >= group.epoch => {}
7601                _ => {
7602                    best.insert(group.id.clone(), group);
7603                }
7604            }
7605        }
7606    }
7607
7608    if best.is_empty() {
7609        return Ok(());
7610    }
7611    let mut trust = config::read_trust()?;
7612    for group in best.values() {
7613        // Don't regress a locally-known group to a stale epoch.
7614        if let Ok(local) = crate::group::load_group(&group.id)
7615            && local.epoch >= group.epoch
7616        {
7617            continue;
7618        }
7619        crate::group::save_group(group)?;
7620        for m in &group.members {
7621            if m.did == self_did || m.key.is_empty() {
7622                continue;
7623            }
7624            introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7625        }
7626    }
7627    config::write_trust(&trust)?;
7628    Ok(())
7629}
7630
7631fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
7632    if !config::is_initialized()? {
7633        bail!("not initialized — run `wire up` first");
7634    }
7635    let (did, handle, key_id, pk_b64) = group_self()?;
7636    let relay_url = group_room_relay_url()?;
7637    // Allocate the shared group-room slot on the relay.
7638    let client = crate::relay_client::RelayClient::new(&relay_url);
7639    let room = client
7640        .allocate_slot(Some(&format!("group:{name}")))
7641        .with_context(|| format!("allocating group room on {relay_url}"))?;
7642    let id = format!("g{:016x}", rand::random::<u64>());
7643    let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
7644    group.set_room(relay_url, room.slot_id, room.slot_token);
7645    group.set_member_keys(&did, key_id, pk_b64)?;
7646    let sk = config::read_private_key()?;
7647    group.sign(&sk)?;
7648    crate::group::save_group(&group)?;
7649    if as_json {
7650        println!(
7651            "{}",
7652            serde_json::to_string(&json!({
7653                "id": id, "name": name, "members": 1, "relay_url": group.relay_url
7654            }))?
7655        );
7656    } else {
7657        println!(
7658            "created group `{name}` (id {id}) — room on {}. You are the creator.",
7659            group.relay_url
7660        );
7661        println!("  add peers: `wire group add {id} <peer>`   talk: `wire group send {id} \"hi\"`");
7662    }
7663    Ok(())
7664}
7665
7666fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
7667    let (self_did, ..) = group_self()?;
7668    let mut group = crate::group::resolve_group(group_ref)?;
7669    if group.creator_did != self_did {
7670        bail!("only the group creator can add members (the creator signs the roster)");
7671    }
7672    // T22 consent: a Member must be a peer you bilaterally VERIFIED.
7673    let bare = crate::agent_card::bare_handle(peer).to_string();
7674    let trust = config::read_trust()?;
7675    let agent = trust
7676        .get("agents")
7677        .and_then(|a| a.get(&bare))
7678        .ok_or_else(|| {
7679            anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
7680        })?;
7681    let tier = agent
7682        .get("tier")
7683        .and_then(Value::as_str)
7684        .unwrap_or("UNTRUSTED");
7685    if tier != "VERIFIED" {
7686        bail!(
7687            "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
7688        );
7689    }
7690    let peer_did = agent
7691        .get("did")
7692        .and_then(Value::as_str)
7693        .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
7694        .to_string();
7695    // Capture the peer's signing key from trust so the creator can vouch for it
7696    // in the signed roster (members introduce-pin it to verify this peer).
7697    let key = agent
7698        .get("public_keys")
7699        .and_then(Value::as_array)
7700        .and_then(|ks| {
7701            ks.iter()
7702                .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
7703        })
7704        .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
7705    let peer_key_id = key
7706        .get("key_id")
7707        .and_then(Value::as_str)
7708        .unwrap_or_default()
7709        .to_string();
7710    let peer_pk = key
7711        .get("key")
7712        .and_then(Value::as_str)
7713        .unwrap_or_default()
7714        .to_string();
7715
7716    group.add_member(
7717        bare.clone(),
7718        peer_did.clone(),
7719        crate::group::GroupTier::Member,
7720    )?;
7721    group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
7722    let sk = config::read_private_key()?;
7723    group.sign(&sk)?;
7724    crate::group::save_group(&group)?;
7725    // Distribute the refreshed signed roster (room coords + everyone's keys) to
7726    // ALL members so each can post + verify the others.
7727    let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
7728    if as_json {
7729        println!(
7730            "{}",
7731            serde_json::to_string(&json!({
7732                "group": group.id, "added": bare, "epoch": group.epoch,
7733                "members": group.members.len(), "invites_queued": delivered
7734            }))?
7735        );
7736    } else {
7737        println!(
7738            "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
7739            group.name,
7740            group.members.len(),
7741            group.epoch
7742        );
7743    }
7744    Ok(())
7745}
7746
7747fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
7748    if !config::is_initialized()? {
7749        bail!("not initialized — run `wire up` first");
7750    }
7751    ingest_group_invites()?;
7752    let (self_did, self_handle, _, pk_b64) = group_self()?;
7753    let group = crate::group::resolve_group(group_ref)?;
7754    // Membership for SEND is room-token possession: having the group locally
7755    // (with its slot_token) is the capability. The signed roster gates who you
7756    // can VERIFY, not whether you may post — a code-redeemed joiner isn't in the
7757    // creator-signed roster but legitimately holds the room key.
7758    if group.slot_id.is_empty() || group.relay_url.is_empty() {
7759        bail!(
7760            "group `{}` has no room slot (legacy/partial group)",
7761            group.name
7762        );
7763    }
7764    let sk_seed = config::read_private_key()?;
7765    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7766    let now_iso = time::OffsetDateTime::now_utc()
7767        .format(&time::format_description::well_known::Rfc3339)
7768        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7769    let event = json!({
7770        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7771        "timestamp": now_iso,
7772        "from": self_did,
7773        "to": format!("did:wire:group:{}", group.id),
7774        "type": "group_msg",
7775        "kind": parse_kind("group_msg")?,
7776        "body": {
7777            "group_id": group.id,
7778            "group_name": group.name,
7779            "epoch": group.epoch,
7780            "text": message,
7781        },
7782    });
7783    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7784        .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
7785    // Post the one message to the shared group slot.
7786    let client = crate::relay_client::RelayClient::new(&group.relay_url);
7787    client
7788        .post_event(&group.slot_id, &group.slot_token, &signed)
7789        .with_context(|| {
7790            format!(
7791                "posting to group room {} on {}",
7792                group.slot_id, group.relay_url
7793            )
7794        })?;
7795    if as_json {
7796        println!(
7797            "{}",
7798            serde_json::to_string(&json!({
7799                "group": group.id, "epoch": group.epoch, "status": "posted",
7800                "members": group.members.len()
7801            }))?
7802        );
7803    } else {
7804        println!(
7805            "group `{}`: posted to the room ({} member(s))",
7806            group.name,
7807            group.members.len()
7808        );
7809    }
7810    Ok(())
7811}
7812
7813fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
7814    ingest_group_invites()?;
7815    let group = crate::group::resolve_group(group_ref)?;
7816    if group.slot_id.is_empty() || group.relay_url.is_empty() {
7817        bail!(
7818            "group `{}` has no room slot (legacy/partial group)",
7819            group.name
7820        );
7821    }
7822    let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7823    let client = crate::relay_client::RelayClient::new(&group.relay_url);
7824    // Pull the shared room; cap generously then show the last `limit`.
7825    let fetch = if limit == 0 {
7826        1000
7827    } else {
7828        (limit * 4).min(1000)
7829    };
7830    let events = client
7831        .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
7832        .with_context(|| {
7833            format!(
7834                "pulling group room {} on {}",
7835                group.slot_id, group.relay_url
7836            )
7837        })?;
7838
7839    // Pass 1: introduce-pin anyone who announced a join. A `group_join` carries
7840    // the joiner's card and must self-consistently sign under it; posting to the
7841    // room requires the room token, so possession is the authorization (pinned
7842    // at bilateral UNTRUSTED, group tier Introduced). This lets their later
7843    // group messages verify even though they're not in the creator-signed roster.
7844    let mut trust_changed = false;
7845    for event in &events {
7846        if event.get("type").and_then(Value::as_str) != Some("group_join") {
7847            continue;
7848        }
7849        if let Some((h, did, kid, key)) = group_join_pin_material(event)
7850            && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
7851        {
7852            trust_changed = true;
7853        }
7854    }
7855    if trust_changed {
7856        let _ = config::write_trust(&trust);
7857    }
7858
7859    // Pass 2: build the timeline — group messages (verified against the
7860    // now-augmented trust) interleaved with join notices.
7861    enum Line {
7862        Msg {
7863            from: String,
7864            text: String,
7865            verified: bool,
7866        },
7867        Join {
7868            who: String,
7869        },
7870    }
7871    let mut timeline: Vec<(String, Line)> = Vec::new();
7872    for event in &events {
7873        let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
7874        let body = match event.get("body") {
7875            Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
7876            Some(v) => Some(v.clone()),
7877            None => None,
7878        };
7879        let Some(body) = body else { continue };
7880        if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
7881            continue;
7882        }
7883        let ts = event
7884            .get("timestamp")
7885            .and_then(Value::as_str)
7886            .unwrap_or("")
7887            .to_string();
7888        let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7889        let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
7890        match ty {
7891            "group_msg" => {
7892                let text = body
7893                    .get("text")
7894                    .and_then(Value::as_str)
7895                    .unwrap_or("")
7896                    .to_string();
7897                let verified = verify_message_v31(event, &trust).is_ok();
7898                timeline.push((
7899                    ts,
7900                    Line::Msg {
7901                        from: from_handle,
7902                        text,
7903                        verified,
7904                    },
7905                ));
7906            }
7907            "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
7908            _ => {}
7909        }
7910    }
7911    timeline.sort_by(|a, b| a.0.cmp(&b.0));
7912    let start = if limit > 0 {
7913        timeline.len().saturating_sub(limit)
7914    } else {
7915        0
7916    };
7917    let recent = &timeline[start..];
7918    if as_json {
7919        let arr: Vec<Value> = recent
7920            .iter()
7921            .map(|(ts, l)| match l {
7922                Line::Msg {
7923                    from,
7924                    text,
7925                    verified,
7926                } => {
7927                    json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
7928                }
7929                Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
7930            })
7931            .collect();
7932        println!(
7933            "{}",
7934            serde_json::to_string(
7935                &json!({"group": group.id, "name": group.name, "messages": arr})
7936            )?
7937        );
7938    } else if recent.is_empty() {
7939        println!("group `{}`: no messages yet", group.name);
7940    } else {
7941        for (ts, l) in recent {
7942            let short_ts: String = ts.chars().take(19).collect();
7943            match l {
7944                Line::Msg {
7945                    from,
7946                    text,
7947                    verified,
7948                } => {
7949                    let mark = if *verified { "✓" } else { "✗" };
7950                    println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
7951                }
7952                Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
7953            }
7954        }
7955    }
7956    Ok(())
7957}
7958
7959/// Validate a `group_join` room event and extract the joiner's pin material:
7960/// (handle, did, key_id, key_b64). The event MUST self-consistently sign under
7961/// the key in the card it carries — so a forged join (card A, signed by key B)
7962/// is rejected. Authorization to be in the room is proven by the post itself
7963/// (it required the room token).
7964fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
7965    let body = match event.get("body") {
7966        Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
7967        Some(v) => v.clone(),
7968        None => return None,
7969    };
7970    let card = body.get("joiner_card")?;
7971    // Verify the event signs under the card it carries (one-entry trust).
7972    let mut tmp = json!({"agents": {}});
7973    crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
7974    if verify_message_v31(event, &tmp).is_err() {
7975        return None;
7976    }
7977    let did = card.get("did").and_then(Value::as_str)?.to_string();
7978    let handle = card
7979        .get("handle")
7980        .and_then(Value::as_str)
7981        .map(str::to_string)
7982        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
7983    let (kid_full, krec) = card
7984        .get("verify_keys")
7985        .and_then(Value::as_object)
7986        .and_then(|m| m.iter().next())?;
7987    let key_id = kid_full
7988        .strip_prefix("ed25519:")
7989        .unwrap_or(kid_full)
7990        .to_string();
7991    let key = krec.get("key").and_then(Value::as_str)?.to_string();
7992    Some((handle, did, key_id, key))
7993}
7994
7995/// `wire group invite <group>` — mint a self-contained join code (the serialized
7996/// signed group: room coords + roster + member keys). The code IS the room key.
7997fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
7998    let group = crate::group::resolve_group(group_ref)?;
7999    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8000        bail!(
8001            "group `{}` has no room slot — nothing to invite into",
8002            group.name
8003        );
8004    }
8005    if group.creator_sig.is_empty() {
8006        bail!(
8007            "group `{}` roster is unsigned — add a member or recreate before inviting",
8008            group.name
8009        );
8010    }
8011    let payload = serde_json::to_vec(&group)?;
8012    let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
8013    if as_json {
8014        println!(
8015            "{}",
8016            serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
8017        );
8018    } else {
8019        println!(
8020            "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
8021            group.name
8022        );
8023        println!("{code}\n");
8024        println!("they run:  wire group join <code>");
8025    }
8026    Ok(())
8027}
8028
8029/// `wire group join <code>` — redeem a join code: verify the roster, materialize
8030/// the room locally, introduce-pin existing members, and announce ourselves to
8031/// the room so members verify our messages. Lands at group tier Introduced.
8032fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
8033    if !config::is_initialized()? {
8034        bail!("not initialized — run `wire up` first");
8035    }
8036    let raw = code.trim();
8037    let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
8038    let payload =
8039        crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
8040    let group: crate::group::Group = serde_json::from_slice(&payload)
8041        .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
8042    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8043        bail!("join code carries no room coords");
8044    }
8045    // Verify the roster against the creator's key carried IN the roster (TOFU on
8046    // the code — you obtained it over a trusted channel). Rejects a tampered code.
8047    let creator_key = group
8048        .members
8049        .iter()
8050        .find(|m| m.did == group.creator_did)
8051        .map(|m| m.key.clone())
8052        .filter(|k| !k.is_empty())
8053        .and_then(|k| crate::signing::b64decode(&k).ok())
8054        .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
8055    if !group.verify(&creator_key) {
8056        bail!("join code failed its signature check (tampered or corrupt)");
8057    }
8058    let (self_did, self_handle, _, _) = group_self()?;
8059    if group.creator_did == self_did {
8060        bail!("you created group `{}` — you're already in it", group.name);
8061    }
8062
8063    // Materialize locally + introduce-pin existing members so we can verify them.
8064    crate::group::save_group(&group)?;
8065    let mut trust = config::read_trust()?;
8066    for m in &group.members {
8067        if m.did == self_did || m.key.is_empty() {
8068            continue;
8069        }
8070        introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
8071    }
8072    config::write_trust(&trust)?;
8073
8074    // Announce ourselves to the room (carry our card) so members introduce-pin us.
8075    let card = config::read_agent_card()?;
8076    let sk_seed = config::read_private_key()?;
8077    let pk_b64 = card
8078        .get("verify_keys")
8079        .and_then(Value::as_object)
8080        .and_then(|m| m.values().next())
8081        .and_then(|v| v.get("key"))
8082        .and_then(Value::as_str)
8083        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8084    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8085    let now_iso = time::OffsetDateTime::now_utc()
8086        .format(&time::format_description::well_known::Rfc3339)
8087        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8088    let event = json!({
8089        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8090        "timestamp": now_iso,
8091        "from": self_did,
8092        "to": format!("did:wire:group:{}", group.id),
8093        "type": "group_join",
8094        "kind": parse_kind("group_join")?,
8095        "body": {
8096            "group_id": group.id,
8097            "group_name": group.name,
8098            "epoch": group.epoch,
8099            "joiner_card": card,
8100            "text": "joined",
8101        },
8102    });
8103    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8104        .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
8105    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8106    let announced = client
8107        .post_event(&group.slot_id, &group.slot_token, &signed)
8108        .is_ok();
8109
8110    if as_json {
8111        println!(
8112            "{}",
8113            serde_json::to_string(&json!({
8114                "group": group.id, "name": group.name, "joined": true,
8115                "members": group.members.len(), "announced": announced
8116            }))?
8117        );
8118    } else {
8119        println!(
8120            "joined group `{}` ({} member(s)) at Introduced tier.",
8121            group.name,
8122            group.members.len()
8123        );
8124        if announced {
8125            println!("  announced to the room — members will verify your messages.");
8126        } else {
8127            println!(
8128                "  ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
8129            );
8130        }
8131        println!(
8132            "  read: `wire group tail {}`   talk: `wire group send {} \"hi\"`",
8133            group.id, group.id
8134        );
8135    }
8136    Ok(())
8137}
8138
8139fn cmd_group_list(as_json: bool) -> Result<()> {
8140    let groups = crate::group::list_groups()?;
8141    if as_json {
8142        let arr: Vec<Value> = groups
8143            .iter()
8144            .map(|g| {
8145                json!({
8146                    "id": g.id,
8147                    "name": g.name,
8148                    "epoch": g.epoch,
8149                    "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
8150                })
8151            })
8152            .collect();
8153        println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
8154    } else if groups.is_empty() {
8155        println!("no groups yet — create one with `wire group create <name>`");
8156    } else {
8157        for g in &groups {
8158            println!(
8159                "{} ({}) — {} member(s), epoch {}",
8160                g.name,
8161                g.id,
8162                g.members.len(),
8163                g.epoch
8164            );
8165            for m in &g.members {
8166                println!("    {} [{}]", m.handle, m.tier.as_str());
8167            }
8168        }
8169    }
8170    Ok(())
8171}
8172
8173/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
8174/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
8175fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
8176    match cmd {
8177        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
8178        MeshCommand::Broadcast {
8179            kind,
8180            scope,
8181            exclude,
8182            noreply,
8183            body,
8184            json,
8185        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
8186        MeshCommand::Role { action } => cmd_mesh_role(action),
8187        MeshCommand::Route {
8188            role,
8189            strategy,
8190            exclude,
8191            kind,
8192            body,
8193            json,
8194        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
8195    }
8196}
8197
8198/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
8199/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
8200/// picks ONE via the requested strategy, then signs + pushes the event
8201/// to that peer. Pinned-peers-only by construction (same as broadcast).
8202fn cmd_mesh_route(
8203    role: &str,
8204    strategy: &str,
8205    exclude: &[String],
8206    kind: &str,
8207    body_arg: &str,
8208    as_json: bool,
8209) -> Result<()> {
8210    use std::time::Instant;
8211
8212    if !config::is_initialized()? {
8213        bail!("not initialized — run `wire init <handle>` first");
8214    }
8215    let strategy = strategy.to_ascii_lowercase();
8216    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
8217        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
8218    }
8219
8220    // Our pinned-peer set: only these handles are addressable. mesh-route
8221    // refuses to invent a recipient, same posture as broadcast.
8222    let state = config::read_relay_state()?;
8223    let pinned: std::collections::BTreeSet<String> = state["peers"]
8224        .as_object()
8225        .map(|m| m.keys().cloned().collect())
8226        .unwrap_or_default();
8227
8228    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8229
8230    // Enumerate every sister on the box, read each one's role from its
8231    // signed agent-card. Filter: matching role AND pinned AND not
8232    // excluded. `list_sessions` returns the cross-session view (using the
8233    // v0.6.4 inside-session sessions_root fallback).
8234    let sessions = crate::session::list_sessions()?;
8235    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
8236    for s in &sessions {
8237        let handle = match s.handle.as_ref() {
8238            Some(h) => h.clone(),
8239            None => continue,
8240        };
8241        if exclude_set.contains(handle.as_str()) {
8242            continue;
8243        }
8244        if !pinned.contains(&handle) {
8245            continue;
8246        }
8247        let card_path = s
8248            .home_dir
8249            .join("config")
8250            .join("wire")
8251            .join("agent-card.json");
8252        let card_role = std::fs::read(&card_path)
8253            .ok()
8254            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8255            .and_then(|c| {
8256                c.get("profile")
8257                    .and_then(|p| p.get("role"))
8258                    .and_then(Value::as_str)
8259                    .map(str::to_string)
8260            });
8261        if card_role.as_deref() == Some(role) {
8262            candidates.push((handle, s.did.clone()));
8263        }
8264    }
8265
8266    candidates.sort_by(|a, b| a.0.cmp(&b.0));
8267    candidates.dedup_by(|a, b| a.0 == b.0);
8268
8269    if candidates.is_empty() {
8270        bail!(
8271            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
8272        );
8273    }
8274
8275    let chosen = match strategy.as_str() {
8276        "first" => candidates[0].clone(),
8277        "random" => {
8278            use rand::Rng;
8279            let idx = rand::thread_rng().gen_range(0..candidates.len());
8280            candidates[idx].clone()
8281        }
8282        "round-robin" => {
8283            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
8284            // `{role: last_picked_handle}`. Next pick = first candidate
8285            // alphabetically AFTER last_picked, wrapping around when no
8286            // candidate is greater.
8287            let cursor_path = mesh_route_cursor_path()?;
8288            let mut cursors: std::collections::BTreeMap<String, String> =
8289                read_mesh_route_cursors(&cursor_path);
8290            let last = cursors.get(role).cloned();
8291            let pick = match last {
8292                None => candidates[0].clone(),
8293                Some(last_h) => candidates
8294                    .iter()
8295                    .find(|(h, _)| h.as_str() > last_h.as_str())
8296                    .cloned()
8297                    .unwrap_or_else(|| candidates[0].clone()),
8298            };
8299            cursors.insert(role.to_string(), pick.0.clone());
8300            write_mesh_route_cursors(&cursor_path, &cursors)?;
8301            pick
8302        }
8303        _ => unreachable!(),
8304    };
8305
8306    let (chosen_handle, _chosen_did) = chosen;
8307
8308    // Body parsing follows wire send / mesh broadcast.
8309    let body_value: Value = if body_arg == "-" {
8310        use std::io::Read;
8311        let mut raw = String::new();
8312        std::io::stdin()
8313            .read_to_string(&mut raw)
8314            .with_context(|| "reading body from stdin")?;
8315        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8316    } else if let Some(path) = body_arg.strip_prefix('@') {
8317        let raw =
8318            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8319        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8320    } else {
8321        Value::String(body_arg.to_string())
8322    };
8323
8324    let sk_seed = config::read_private_key()?;
8325    let card = config::read_agent_card()?;
8326    let did = card
8327        .get("did")
8328        .and_then(Value::as_str)
8329        .ok_or_else(|| anyhow!("agent-card missing did"))?
8330        .to_string();
8331    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8332    let pk_b64 = card
8333        .get("verify_keys")
8334        .and_then(Value::as_object)
8335        .and_then(|m| m.values().next())
8336        .and_then(|v| v.get("key"))
8337        .and_then(Value::as_str)
8338        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8339    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8340
8341    let kind_id = parse_kind(kind)?;
8342    let now_iso = time::OffsetDateTime::now_utc()
8343        .format(&time::format_description::well_known::Rfc3339)
8344        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8345
8346    let event = json!({
8347        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8348        "timestamp": now_iso,
8349        "from": did,
8350        "to": format!("did:wire:{chosen_handle}"),
8351        "type": kind,
8352        "kind": kind_id,
8353        "body": json!({
8354            "content": body_value,
8355            "routed_via": {
8356                "role": role,
8357                "strategy": strategy,
8358            },
8359        }),
8360    });
8361    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8362        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
8363    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8364
8365    let line = serde_json::to_vec(&signed)?;
8366    config::append_outbox_record(&chosen_handle, &line)?;
8367
8368    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
8369    if endpoints.is_empty() {
8370        bail!(
8371            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
8372        );
8373    }
8374    let start = Instant::now();
8375    let mut delivered = false;
8376    let mut last_err: Option<String> = None;
8377    let mut via_scope: Option<String> = None;
8378    for ep in &endpoints {
8379        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
8380        // route via uds_request, others via reqwest. Allows peers with
8381        // UDS-tagged endpoints in their agent-card to receive events
8382        // over the local socket instead of loopback HTTP.
8383        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8384            Ok(_) => {
8385                delivered = true;
8386                via_scope = Some(
8387                    match ep.scope {
8388                        crate::endpoints::EndpointScope::Local => "local",
8389                        crate::endpoints::EndpointScope::Lan => "lan",
8390                        crate::endpoints::EndpointScope::Uds => "uds",
8391                        crate::endpoints::EndpointScope::Federation => "federation",
8392                    }
8393                    .to_string(),
8394                );
8395                break;
8396            }
8397            Err(e) => last_err = Some(format!("{e:#}")),
8398        }
8399    }
8400    let rtt_ms = start.elapsed().as_millis() as u64;
8401
8402    let summary = json!({
8403        "role": role,
8404        "strategy": strategy,
8405        "routed_to": chosen_handle,
8406        "event_id": event_id,
8407        "delivered": delivered,
8408        "delivered_via": via_scope,
8409        "rtt_ms": rtt_ms,
8410        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
8411        "error": last_err,
8412    });
8413
8414    if as_json {
8415        println!("{}", serde_json::to_string(&summary)?);
8416    } else if delivered {
8417        let via = via_scope.as_deref().unwrap_or("?");
8418        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
8419    } else {
8420        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
8421        bail!("delivery to `{chosen_handle}` failed: {err}");
8422    }
8423    Ok(())
8424}
8425
8426fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
8427    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
8428}
8429
8430fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
8431    std::fs::read(path)
8432        .ok()
8433        .and_then(|b| serde_json::from_slice(&b).ok())
8434        .unwrap_or_default()
8435}
8436
8437fn write_mesh_route_cursors(
8438    path: &std::path::Path,
8439    cursors: &std::collections::BTreeMap<String, String>,
8440) -> Result<()> {
8441    if let Some(parent) = path.parent() {
8442        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
8443    }
8444    let body = serde_json::to_vec_pretty(cursors)?;
8445    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
8446    Ok(())
8447}
8448
8449/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
8450/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
8451/// behind a discoverability-friendlier surface, plus cross-session
8452/// enumeration for the list path.
8453fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
8454    match action {
8455        MeshRoleAction::Set { role, json } => {
8456            validate_role_tag(&role)?;
8457            let new_profile =
8458                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
8459            if json {
8460                println!(
8461                    "{}",
8462                    serde_json::to_string(&json!({
8463                        "role": role,
8464                        "profile": new_profile,
8465                    }))?
8466                );
8467            } else {
8468                println!("self role = {role} (signed into agent-card)");
8469            }
8470        }
8471        MeshRoleAction::Get { peer, json } => {
8472            let (who, role) = match peer.as_deref() {
8473                None => {
8474                    let card = config::read_agent_card()?;
8475                    let role = card
8476                        .get("profile")
8477                        .and_then(|p| p.get("role"))
8478                        .and_then(Value::as_str)
8479                        .map(str::to_string);
8480                    let who = card
8481                        .get("did")
8482                        .and_then(Value::as_str)
8483                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
8484                        .unwrap_or_else(|| "self".to_string());
8485                    (who, role)
8486                }
8487                Some(handle) => {
8488                    let bare = crate::agent_card::bare_handle(handle).to_string();
8489                    let trust = config::read_trust()?;
8490                    let role = trust
8491                        .get("agents")
8492                        .and_then(|a| a.get(&bare))
8493                        .and_then(|a| a.get("card"))
8494                        .and_then(|c| c.get("profile"))
8495                        .and_then(|p| p.get("role"))
8496                        .and_then(Value::as_str)
8497                        .map(str::to_string);
8498                    (bare, role)
8499                }
8500            };
8501            if json {
8502                println!(
8503                    "{}",
8504                    serde_json::to_string(&json!({
8505                        "handle": who,
8506                        "role": role,
8507                    }))?
8508                );
8509            } else {
8510                match role {
8511                    Some(r) => println!("{who}: {r}"),
8512                    None => println!("{who}: (unset)"),
8513                }
8514            }
8515        }
8516        MeshRoleAction::List { json } => {
8517            let mut self_did: Option<String> = None;
8518            if let Ok(card) = config::read_agent_card() {
8519                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
8520            }
8521            let sessions = crate::session::list_sessions()?;
8522            let mut rows: Vec<Value> = Vec::new();
8523            for s in &sessions {
8524                let card_path = s
8525                    .home_dir
8526                    .join("config")
8527                    .join("wire")
8528                    .join("agent-card.json");
8529                let role = std::fs::read(&card_path)
8530                    .ok()
8531                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8532                    .and_then(|c| {
8533                        c.get("profile")
8534                            .and_then(|p| p.get("role"))
8535                            .and_then(Value::as_str)
8536                            .map(str::to_string)
8537                    });
8538                let is_self = match (&self_did, &s.did) {
8539                    (Some(a), Some(b)) => a == b,
8540                    _ => false,
8541                };
8542                rows.push(json!({
8543                    "name": s.name,
8544                    "handle": s.handle,
8545                    "role": role,
8546                    "self": is_self,
8547                }));
8548            }
8549            rows.sort_by(|a, b| {
8550                a["name"]
8551                    .as_str()
8552                    .unwrap_or("")
8553                    .cmp(b["name"].as_str().unwrap_or(""))
8554            });
8555            if json {
8556                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
8557            } else if rows.is_empty() {
8558                println!("no sister sessions on this machine.");
8559            } else {
8560                println!("SISTER ROLES (this machine):");
8561                for r in &rows {
8562                    let name = r["name"].as_str().unwrap_or("?");
8563                    let role = r["role"].as_str().unwrap_or("(unset)");
8564                    let marker = if r["self"].as_bool().unwrap_or(false) {
8565                        "    ← you"
8566                    } else {
8567                        ""
8568                    };
8569                    println!("  {name:<24} {role}{marker}");
8570                }
8571            }
8572        }
8573        MeshRoleAction::Clear { json } => {
8574            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
8575            if json {
8576                println!(
8577                    "{}",
8578                    serde_json::to_string(&json!({
8579                        "cleared": true,
8580                        "profile": new_profile,
8581                    }))?
8582                );
8583            } else {
8584                println!("self role cleared");
8585            }
8586        }
8587    }
8588    Ok(())
8589}
8590
8591/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
8592/// No vocabulary check — operators choose the taxonomy (planner /
8593/// reviewer / dispatcher / your-custom-tag). The constraint is purely
8594/// to keep the tag safe for filenames / URLs / shell args.
8595fn validate_role_tag(role: &str) -> Result<()> {
8596    if role.is_empty() {
8597        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
8598    }
8599    if role.len() > 32 {
8600        bail!("role too long ({} chars; max 32)", role.len());
8601    }
8602    for c in role.chars() {
8603        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
8604            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
8605        }
8606    }
8607    Ok(())
8608}
8609
8610/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
8611///
8612/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
8613/// canonical event including `to:`, so per-recipient signing is required;
8614/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
8615/// Per-recipient pushes happen in parallel via `std::thread::scope` so
8616/// broadcast-to-5 takes ~1× RTT, not 5×.
8617///
8618/// **Scope filter.** Default `local` — only peers reachable via a same-
8619/// machine local relay (priority-1 endpoint has `scope=local`). This is
8620/// the lowest-blast-radius default: local-only broadcasts cannot escape
8621/// the operator's machine. `federation` flips to public-relay peers
8622/// only; `both` removes the filter.
8623///
8624/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
8625/// resolution, never trust["agents"] expansion. Closes #8-class
8626/// phonebook-scrape vectors by construction: an attacker pinning a
8627/// hostile handle has to first be pinned bidirectionally by the
8628/// operator, and even then `--exclude` is the loud opt-out.
8629fn cmd_mesh_broadcast(
8630    kind: &str,
8631    scope_str: &str,
8632    exclude: &[String],
8633    _noreply: bool,
8634    body_arg: &str,
8635    as_json: bool,
8636) -> Result<()> {
8637    use std::time::Instant;
8638
8639    if !config::is_initialized()? {
8640        bail!("not initialized — run `wire init <handle>` first");
8641    }
8642
8643    let scope = match scope_str {
8644        "local" => crate::endpoints::EndpointScope::Local,
8645        "federation" => crate::endpoints::EndpointScope::Federation,
8646        "both" => {
8647            // Sentinel: we don't actually have a `Both` variant on the
8648            // scope enum; use a tri-state below. Treat as Local for the
8649            // typed match and special-case it via the bool below.
8650            crate::endpoints::EndpointScope::Local
8651        }
8652        other => bail!("unknown scope `{other}` — use local | federation | both"),
8653    };
8654    let any_scope = scope_str == "both";
8655
8656    let state = config::read_relay_state()?;
8657    let peers = state["peers"].as_object().cloned().unwrap_or_default();
8658    if peers.is_empty() {
8659        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
8660    }
8661
8662    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8663
8664    // Walk the pinned-peer set, filter by scope + exclude. Keep the
8665    // priority-ordered endpoint list for each match so the push can
8666    // try local first then fall through to federation (when scope=both).
8667    struct Target {
8668        handle: String,
8669        endpoints: Vec<crate::endpoints::Endpoint>,
8670    }
8671    let mut targets: Vec<Target> = Vec::new();
8672    let mut skipped_wrong_scope: Vec<String> = Vec::new();
8673    let mut skipped_excluded: Vec<String> = Vec::new();
8674    for handle in peers.keys() {
8675        if exclude_set.contains(handle.as_str()) {
8676            skipped_excluded.push(handle.clone());
8677            continue;
8678        }
8679        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
8680        let filtered: Vec<crate::endpoints::Endpoint> = ordered
8681            .into_iter()
8682            .filter(|ep| any_scope || ep.scope == scope)
8683            .collect();
8684        if filtered.is_empty() {
8685            skipped_wrong_scope.push(handle.clone());
8686            continue;
8687        }
8688        targets.push(Target {
8689            handle: handle.clone(),
8690            endpoints: filtered,
8691        });
8692    }
8693
8694    if targets.is_empty() {
8695        bail!(
8696            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
8697            skipped_excluded.len(),
8698            skipped_wrong_scope.len()
8699        );
8700    }
8701
8702    // Load signing material once; share across per-peer signatures.
8703    let sk_seed = config::read_private_key()?;
8704    let card = config::read_agent_card()?;
8705    let did = card
8706        .get("did")
8707        .and_then(Value::as_str)
8708        .ok_or_else(|| anyhow!("agent-card missing did"))?
8709        .to_string();
8710    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8711    let pk_b64 = card
8712        .get("verify_keys")
8713        .and_then(Value::as_object)
8714        .and_then(|m| m.values().next())
8715        .and_then(|v| v.get("key"))
8716        .and_then(Value::as_str)
8717        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8718    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8719
8720    let body_value: Value = if body_arg == "-" {
8721        use std::io::Read;
8722        let mut raw = String::new();
8723        std::io::stdin()
8724            .read_to_string(&mut raw)
8725            .with_context(|| "reading body from stdin")?;
8726        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8727    } else if let Some(path) = body_arg.strip_prefix('@') {
8728        let raw =
8729            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8730        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8731    } else {
8732        Value::String(body_arg.to_string())
8733    };
8734
8735    let kind_id = parse_kind(kind)?;
8736    let now_iso = time::OffsetDateTime::now_utc()
8737        .format(&time::format_description::well_known::Rfc3339)
8738        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8739
8740    let broadcast_id = generate_broadcast_id();
8741    let target_count = targets.len();
8742
8743    // Build + sign every event up front (sequential, ~50µs/sig). Then
8744    // queue to outbox + push to relay in parallel per-peer. Returns
8745    // a per-peer outcome we then sort by handle for deterministic output.
8746    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
8747        Vec::with_capacity(targets.len());
8748    for t in &targets {
8749        let body = json!({
8750            "content": body_value,
8751            "broadcast_id": broadcast_id,
8752            "broadcast_target_count": target_count,
8753        });
8754        let event = json!({
8755            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8756            "timestamp": now_iso,
8757            "from": did,
8758            "to": format!("did:wire:{}", t.handle),
8759            "type": kind,
8760            "kind": kind_id,
8761            "body": body,
8762        });
8763        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8764            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
8765        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8766        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
8767    }
8768
8769    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
8770    // holds a per-path mutex; writes are independent across handles but
8771    // we want the side-effect ordering deterministic).
8772    for (peer, _, signed, _) in &signed_per_peer {
8773        let line = serde_json::to_vec(signed)?;
8774        config::append_outbox_record(peer, &line)?;
8775    }
8776
8777    // Per-peer parallel push. Each thread tries the priority-ordered
8778    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
8779    // error_opt) over a channel.
8780    use std::sync::mpsc;
8781    let (tx, rx) = mpsc::channel::<Value>();
8782    std::thread::scope(|s| {
8783        for (peer, endpoints, signed, event_id) in &signed_per_peer {
8784            let tx = tx.clone();
8785            let peer = peer.clone();
8786            let event_id = event_id.clone();
8787            let endpoints = endpoints.clone();
8788            let signed = signed.clone();
8789            s.spawn(move || {
8790                let start = Instant::now();
8791                let mut delivered = false;
8792                let mut last_err: Option<String> = None;
8793                let mut delivered_via: Option<String> = None;
8794                for ep in &endpoints {
8795                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
8796                    // uds_request, else reqwest). Same as cmd_send's
8797                    // single-peer path above; this is the parallel
8798                    // multi-peer broadcast loop.
8799                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8800                        Ok(_) => {
8801                            delivered = true;
8802                            delivered_via = Some(
8803                                match ep.scope {
8804                                    crate::endpoints::EndpointScope::Local => "local",
8805                                    crate::endpoints::EndpointScope::Lan => "lan",
8806                                    crate::endpoints::EndpointScope::Uds => "uds",
8807                                    crate::endpoints::EndpointScope::Federation => "federation",
8808                                }
8809                                .to_string(),
8810                            );
8811                            break;
8812                        }
8813                        Err(e) => last_err = Some(format!("{e:#}")),
8814                    }
8815                }
8816                let rtt_ms = start.elapsed().as_millis() as u64;
8817                let _ = tx.send(json!({
8818                    "peer": peer,
8819                    "event_id": event_id,
8820                    "delivered": delivered,
8821                    "delivered_via": delivered_via,
8822                    "rtt_ms": rtt_ms,
8823                    "error": last_err,
8824                }));
8825            });
8826        }
8827    });
8828    drop(tx);
8829
8830    let mut results: Vec<Value> = rx.iter().collect();
8831    results.sort_by(|a, b| {
8832        a["peer"]
8833            .as_str()
8834            .unwrap_or("")
8835            .cmp(b["peer"].as_str().unwrap_or(""))
8836    });
8837
8838    let delivered = results
8839        .iter()
8840        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
8841        .count();
8842    let failed = results.len() - delivered;
8843
8844    let summary = json!({
8845        "broadcast_id": broadcast_id,
8846        "kind": kind,
8847        "scope": scope_str,
8848        "target_count": target_count,
8849        "delivered": delivered,
8850        "failed": failed,
8851        "skipped_excluded": skipped_excluded,
8852        "skipped_wrong_scope": skipped_wrong_scope,
8853        "results": results,
8854    });
8855
8856    if as_json {
8857        println!("{}", serde_json::to_string(&summary)?);
8858        return Ok(());
8859    }
8860
8861    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
8862    for r in &results {
8863        let peer = r["peer"].as_str().unwrap_or("?");
8864        let delivered = r["delivered"].as_bool().unwrap_or(false);
8865        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
8866        let via = r["delivered_via"].as_str().unwrap_or("");
8867        if delivered {
8868            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
8869        } else {
8870            let err = r["error"].as_str().unwrap_or("?");
8871            println!("  {peer:<24} ✗ failed — {err}");
8872        }
8873    }
8874    if !skipped_excluded.is_empty() {
8875        println!("  excluded: {}", skipped_excluded.join(", "));
8876    }
8877    if !skipped_wrong_scope.is_empty() {
8878        println!(
8879            "  skipped (wrong scope): {}",
8880            skipped_wrong_scope.join(", ")
8881        );
8882    }
8883    println!("broadcast_id: {broadcast_id}");
8884    Ok(())
8885}
8886
8887/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
8888/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
8889/// correlate by string equality, the shape is for human readability.
8890fn generate_broadcast_id() -> String {
8891    use rand::RngCore;
8892    let mut buf = [0u8; 16];
8893    rand::thread_rng().fill_bytes(&mut buf);
8894    let h = hex::encode(buf);
8895    format!(
8896        "{}-{}-{}-{}-{}",
8897        &h[0..8],
8898        &h[8..12],
8899        &h[12..16],
8900        &h[16..20],
8901        &h[20..32],
8902    )
8903}
8904
8905fn cmd_session(cmd: SessionCommand) -> Result<()> {
8906    match cmd {
8907        SessionCommand::New {
8908            name,
8909            relay,
8910            with_local,
8911            local_relay,
8912            with_lan,
8913            lan_relay,
8914            with_uds,
8915            uds_socket,
8916            no_daemon,
8917            local_only,
8918            json,
8919        } => cmd_session_new(
8920            name.as_deref(),
8921            &relay,
8922            with_local,
8923            &local_relay,
8924            with_lan,
8925            lan_relay.as_deref(),
8926            with_uds,
8927            uds_socket.as_deref(),
8928            no_daemon,
8929            local_only,
8930            json,
8931        ),
8932        SessionCommand::List { json } => cmd_session_list(json),
8933        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
8934        SessionCommand::PairAllLocal {
8935            settle_secs,
8936            federation_relay,
8937            json,
8938        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
8939        SessionCommand::MeshStatus { stale_secs, json } => {
8940            cmd_session_mesh_status(stale_secs, json)
8941        }
8942        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
8943        SessionCommand::Current { json } => cmd_session_current(json),
8944        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
8945        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
8946    }
8947}
8948
8949fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
8950    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8951    let cwd_str = crate::session::normalize_cwd_key(&cwd);
8952
8953    let resolved_name = match name_arg {
8954        Some(n) => crate::session::sanitize_name(n),
8955        None => crate::session::sanitize_name(
8956            cwd.file_name()
8957                .and_then(|s| s.to_str())
8958                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
8959        ),
8960    };
8961
8962    let session_home = crate::session::session_dir(&resolved_name)?;
8963    if !session_home.exists() {
8964        bail!(
8965            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
8966            session_home.display()
8967        );
8968    }
8969
8970    let prior = crate::session::read_registry()
8971        .ok()
8972        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
8973    if prior.as_deref() == Some(resolved_name.as_str()) {
8974        if json {
8975            println!(
8976                "{}",
8977                serde_json::to_string(&json!({
8978                    "cwd": cwd_str,
8979                    "session": resolved_name,
8980                    "changed": false,
8981                }))?
8982            );
8983        } else {
8984            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
8985        }
8986        return Ok(());
8987    }
8988    if let Some(prior_name) = &prior {
8989        eprintln!(
8990            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
8991        );
8992    }
8993
8994    crate::session::update_registry(|reg| {
8995        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
8996        Ok(())
8997    })?;
8998
8999    if json {
9000        println!(
9001            "{}",
9002            serde_json::to_string(&json!({
9003                "cwd": cwd_str,
9004                "session": resolved_name,
9005                "changed": true,
9006                "previous": prior,
9007            }))?
9008        );
9009    } else {
9010        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
9011        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
9012    }
9013    Ok(())
9014}
9015
9016fn resolve_session_name(name: Option<&str>) -> Result<String> {
9017    if let Some(n) = name {
9018        return Ok(crate::session::sanitize_name(n));
9019    }
9020    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9021    let registry = crate::session::read_registry().unwrap_or_default();
9022    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
9023}
9024
9025#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
9026// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
9027// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
9028fn cmd_session_new(
9029    name_arg: Option<&str>,
9030    relay: &str,
9031    with_local: bool,
9032    local_relay: &str,
9033    with_lan: bool,
9034    lan_relay: Option<&str>,
9035    with_uds: bool,
9036    uds_socket: Option<&std::path::Path>,
9037    no_daemon: bool,
9038    local_only: bool,
9039    as_json: bool,
9040) -> Result<()> {
9041    // v0.6.6: --local-only implies --with-local (a federation-free
9042    // session with no endpoints at all would be unaddressable).
9043    let with_local = with_local || local_only;
9044    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
9045    if with_lan && lan_relay.is_none() {
9046        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
9047    }
9048    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
9049    if with_uds && uds_socket.is_none() {
9050        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
9051    }
9052    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9053    let mut registry = crate::session::read_registry().unwrap_or_default();
9054    let name = match name_arg {
9055        Some(n) => crate::session::sanitize_name(n),
9056        None => crate::session::derive_name_from_cwd(&cwd, &registry),
9057    };
9058    let session_home = crate::session::session_dir(&name)?;
9059
9060    let already_exists = session_home.exists()
9061        && session_home
9062            .join("config")
9063            .join("wire")
9064            .join("agent-card.json")
9065            .exists();
9066    if already_exists {
9067        // Idempotent: re-register the cwd (if not already), refresh the
9068        // daemon if requested, surface the env-var line. Do not re-init
9069        // identity — that would clobber the keypair.
9070        registry
9071            .by_cwd
9072            .insert(cwd.to_string_lossy().into_owned(), name.clone());
9073        crate::session::write_registry(&registry)?;
9074        let info = render_session_info(&name, &session_home, &cwd)?;
9075        emit_session_new_result(&info, "already_exists", as_json)?;
9076        if !no_daemon {
9077            ensure_session_daemon(&session_home)?;
9078        }
9079        return Ok(());
9080    }
9081
9082    std::fs::create_dir_all(&session_home)
9083        .with_context(|| format!("creating session dir {session_home:?}"))?;
9084
9085    // Phase 1: init identity in the new session's WIRE_HOME. For
9086    // federation-bound sessions we pass `--relay` so init also
9087    // allocates a federation slot in the same step; for `--local-only`
9088    // we run init with `--offline` (v0.9 requires explicit reachability
9089    // acknowledgement at init time) because cmd_session_new allocates
9090    // the local-relay slot itself via try_allocate_local_slot below.
9091    // The session is not actually slotless — init is just deferred to
9092    // the subsequent allocation pass.
9093    let init_args: Vec<&str> = if local_only {
9094        vec!["init", &name, "--offline"]
9095    } else {
9096        vec!["init", &name, "--relay", relay]
9097    };
9098    let init_status = run_wire_with_home(&session_home, &init_args)?;
9099    if !init_status.success() {
9100        let how = if local_only {
9101            format!("`wire init {name}` (local-only)")
9102        } else {
9103            format!("`wire init {name} --relay {relay}`")
9104        };
9105        bail!("{how} failed inside session dir {session_home:?}");
9106    }
9107
9108    // Phase 2: claim the handle on the federation relay — SKIPPED when
9109    // `--local-only`. Local-only sessions have no public address and
9110    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
9111    // tries to publish them.
9112    let effective_handle = if local_only {
9113        name.clone()
9114    } else {
9115        let mut claim_attempt = 0u32;
9116        let mut effective = name.clone();
9117        loop {
9118            claim_attempt += 1;
9119            let status =
9120                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
9121            if status.success() {
9122                break;
9123            }
9124            if claim_attempt >= 5 {
9125                bail!(
9126                    "5 failed attempts to claim a handle on {relay} for session {name}. \
9127                     Try `wire session destroy {name} --force` and re-run with a different name, \
9128                     or use `--local-only` if you don't need a federation address."
9129                );
9130            }
9131            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
9132            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
9133            let token = suffix
9134                .rsplit('-')
9135                .next()
9136                .filter(|t| t.len() == 4)
9137                .map(str::to_string)
9138                .unwrap_or_else(|| format!("{claim_attempt}"));
9139            effective = format!("{name}-{token}");
9140        }
9141        effective
9142    };
9143
9144    // Persist the cwd → name mapping NOW so subsequent invocations from
9145    // this directory short-circuit to the "already_exists" branch.
9146    registry
9147        .by_cwd
9148        .insert(cwd.to_string_lossy().into_owned(), name.clone());
9149    crate::session::write_registry(&registry)?;
9150
9151    // v0.5.17: --with-local probes the local relay and, if it's
9152    // reachable, allocates a second slot there. The session's
9153    // relay_state.json grows a `self.endpoints[]` array carrying both
9154    // endpoints; routing layer (cmd_push) prefers local for sister-
9155    // session peers that also have a local slot.
9156    //
9157    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
9158    // allocation; a failed probe leaves the session with no endpoints,
9159    // which we surface as a hard error (the operator asked for local-
9160    // only but the local relay isn't running — fix that first).
9161    if with_local {
9162        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
9163        if local_only {
9164            // Verify the local slot landed. If the local relay was
9165            // unreachable, the session would be unreachable from
9166            // anywhere — surface that loudly instead of leaving an
9167            // orphaned session dir.
9168            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
9169            let state: Value = std::fs::read(&relay_state_path)
9170                .ok()
9171                .and_then(|b| serde_json::from_slice(&b).ok())
9172                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9173            let endpoints = crate::endpoints::self_endpoints(&state);
9174            let has_local = endpoints
9175                .iter()
9176                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
9177            if !has_local {
9178                bail!(
9179                    "--local-only requested but local-relay probe at {local_relay} failed — \
9180                     ensure the local relay is running (`wire service install --local-relay`), \
9181                     then re-run `wire session new {name} --local-only`."
9182                );
9183            }
9184        }
9185    }
9186
9187    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
9188    // Sits AFTER local because cmd_session_new's flow is "add endpoints
9189    // alongside existing self.endpoints[]" — order independent post-init.
9190    if with_lan && let Some(lan_url) = lan_relay {
9191        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
9192    }
9193    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
9194    if with_uds && let Some(socket_path) = uds_socket {
9195        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
9196    }
9197
9198    if !no_daemon {
9199        ensure_session_daemon(&session_home)?;
9200    }
9201
9202    let info = render_session_info(&name, &session_home, &cwd)?;
9203    emit_session_new_result(&info, "created", as_json)
9204}
9205
9206/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
9207/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
9208/// sister sessions can route over the local socket instead of loopback
9209/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
9210/// alpha.17 — reqwest has no UDS support.
9211///
9212/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
9213/// and try_allocate_lan_slot semantics): session stays at existing
9214/// endpoint mix, operator can retry once the UDS relay is up.
9215#[cfg(unix)]
9216fn try_allocate_uds_slot(
9217    session_home: &std::path::Path,
9218    handle: &str,
9219    uds_socket: &std::path::Path,
9220) {
9221    // Probe healthz first so we fail fast with a clear stderr if the
9222    // socket doesn't exist OR isn't a wire relay.
9223    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
9224        Ok((200, _)) => true,
9225        Ok((status, body)) => {
9226            eprintln!(
9227                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
9228                String::from_utf8_lossy(&body)
9229            );
9230            return;
9231        }
9232        Err(e) => {
9233            eprintln!(
9234                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
9235                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
9236            );
9237            return;
9238        }
9239    };
9240    if !healthz {
9241        return;
9242    }
9243
9244    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
9245    let alloc_body = serde_json::json!({"handle": handle}).to_string();
9246    let (status, body) = match crate::relay_client::uds_request(
9247        uds_socket,
9248        "POST",
9249        "/v1/slot/allocate",
9250        &[("Content-Type", "application/json")],
9251        alloc_body.as_bytes(),
9252    ) {
9253        Ok(r) => r,
9254        Err(e) => {
9255            eprintln!(
9256                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
9257            );
9258            return;
9259        }
9260    };
9261    if status >= 300 {
9262        eprintln!(
9263            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
9264            String::from_utf8_lossy(&body)
9265        );
9266        return;
9267    }
9268    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
9269        Ok(a) => a,
9270        Err(e) => {
9271            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
9272            return;
9273        }
9274    };
9275
9276    let state_path = session_home.join("config").join("wire").join("relay.json");
9277    let mut state: serde_json::Value = std::fs::read(&state_path)
9278        .ok()
9279        .and_then(|b| serde_json::from_slice(&b).ok())
9280        .unwrap_or_else(|| serde_json::json!({}));
9281
9282    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9283        .get("self")
9284        .and_then(|s| s.get("endpoints"))
9285        .and_then(|e| e.as_array())
9286        .map(|arr| {
9287            arr.iter()
9288                .filter_map(|v| {
9289                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9290                })
9291                .collect()
9292        })
9293        .unwrap_or_default();
9294    endpoints.push(crate::endpoints::Endpoint::uds(
9295        format!("unix://{}", uds_socket.display()),
9296        alloc.slot_id.clone(),
9297        alloc.slot_token.clone(),
9298    ));
9299
9300    let self_obj = state
9301        .as_object_mut()
9302        .expect("relay_state root is an object")
9303        .entry("self")
9304        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9305    if !self_obj.is_object() {
9306        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9307    }
9308    if let Some(obj) = self_obj.as_object_mut() {
9309        obj.insert(
9310            "endpoints".into(),
9311            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9312        );
9313    }
9314    if let Err(e) = std::fs::write(
9315        &state_path,
9316        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9317    ) {
9318        eprintln!("wire session new: failed to write {state_path:?}: {e}");
9319        return;
9320    }
9321    eprintln!(
9322        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
9323        uds_socket.display(),
9324        alloc.slot_id
9325    );
9326}
9327
9328#[cfg(not(unix))]
9329fn try_allocate_uds_slot(
9330    _session_home: &std::path::Path,
9331    _handle: &str,
9332    _uds_socket: &std::path::Path,
9333) {
9334    eprintln!(
9335        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
9336    );
9337}
9338
9339/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
9340/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
9341/// pulling the agent-card see a third reachable address.
9342///
9343/// Mirrors `try_allocate_local_slot` but tags the endpoint
9344/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
9345/// session stays at whatever endpoint mix it already had — operators
9346/// can retry with `wire session new --with-lan --lan-relay <url>` once
9347/// the LAN relay is up.
9348fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
9349    let probe = match crate::relay_client::build_blocking_client(Some(
9350        std::time::Duration::from_millis(500),
9351    )) {
9352        Ok(c) => c,
9353        Err(e) => {
9354            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
9355            return;
9356        }
9357    };
9358    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
9359    match probe.get(&healthz_url).send() {
9360        Ok(resp) if resp.status().is_success() => {}
9361        Ok(resp) => {
9362            eprintln!(
9363                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
9364                resp.status()
9365            );
9366            return;
9367        }
9368        Err(e) => {
9369            eprintln!(
9370                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
9371                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
9372                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9373            );
9374            return;
9375        }
9376    };
9377
9378    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
9379    let alloc = match lan_client.allocate_slot(Some(handle)) {
9380        Ok(a) => a,
9381        Err(e) => {
9382            eprintln!(
9383                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
9384            );
9385            return;
9386        }
9387    };
9388
9389    let state_path = session_home.join("config").join("wire").join("relay.json");
9390    let mut state: serde_json::Value = std::fs::read(&state_path)
9391        .ok()
9392        .and_then(|b| serde_json::from_slice(&b).ok())
9393        .unwrap_or_else(|| serde_json::json!({}));
9394
9395    // Read existing endpoints array and add the LAN one. Preserve
9396    // federation / local entries already there.
9397    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9398        .get("self")
9399        .and_then(|s| s.get("endpoints"))
9400        .and_then(|e| e.as_array())
9401        .map(|arr| {
9402            arr.iter()
9403                .filter_map(|v| {
9404                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9405                })
9406                .collect()
9407        })
9408        .unwrap_or_default();
9409    endpoints.push(crate::endpoints::Endpoint::lan(
9410        lan_relay.trim_end_matches('/').to_string(),
9411        alloc.slot_id.clone(),
9412        alloc.slot_token.clone(),
9413    ));
9414
9415    let self_obj = state
9416        .as_object_mut()
9417        .expect("relay_state root is an object")
9418        .entry("self")
9419        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9420    if !self_obj.is_object() {
9421        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9422    }
9423    if let Some(obj) = self_obj.as_object_mut() {
9424        obj.insert(
9425            "endpoints".into(),
9426            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9427        );
9428    }
9429    if let Err(e) = std::fs::write(
9430        &state_path,
9431        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9432    ) {
9433        eprintln!("wire session new: failed to write {state_path:?}: {e}");
9434        return;
9435    }
9436    eprintln!(
9437        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
9438        alloc.slot_id
9439    );
9440}
9441
9442/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
9443/// a short timeout, allocate a slot there and update the session's
9444/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
9445///
9446/// Failure to reach the local relay is NOT fatal — the session stays
9447/// federation-only. Logs to stderr on failure so operators can tell
9448/// the local relay isn't running, but doesn't abort the bootstrap.
9449fn try_allocate_local_slot(
9450    session_home: &std::path::Path,
9451    handle: &str,
9452    _federation_relay: &str,
9453    local_relay: &str,
9454) {
9455    // Probe healthz with a tight timeout. Use a fresh client (don't
9456    // share the daemon-wide one) so the timeout is local to this call.
9457    let probe = match crate::relay_client::build_blocking_client(Some(
9458        std::time::Duration::from_millis(500),
9459    )) {
9460        Ok(c) => c,
9461        Err(e) => {
9462            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
9463            return;
9464        }
9465    };
9466    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
9467    match probe.get(&healthz_url).send() {
9468        Ok(resp) if resp.status().is_success() => {}
9469        Ok(resp) => {
9470            eprintln!(
9471                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
9472                resp.status()
9473            );
9474            return;
9475        }
9476        Err(e) => {
9477            eprintln!(
9478                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
9479                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
9480                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9481            );
9482            return;
9483        }
9484    };
9485
9486    // Allocate a slot on the local relay.
9487    let local_client = crate::relay_client::RelayClient::new(local_relay);
9488    let alloc = match local_client.allocate_slot(Some(handle)) {
9489        Ok(a) => a,
9490        Err(e) => {
9491            eprintln!(
9492                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
9493            );
9494            return;
9495        }
9496    };
9497
9498    // Merge into the session's relay.json. We invoke wire via
9499    // run_wire_with_home for federation calls (subprocess isolation),
9500    // but relay.json is a simple file we can edit directly
9501    // — and need to, because there's no `wire bind-relay --add-local`
9502    // command yet (could add later; out of scope for v0.5.17 MVP).
9503    //
9504    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
9505    // does not exist (canonical filename is `relay.json` per
9506    // `config::relay_state_path`). The mis-named file write succeeded
9507    // but landed in a sibling path nothing else reads. Every
9508    // `wire session new --with-local` invocation silently degraded to
9509    // federation-only despite the "local slot allocated" stderr line.
9510    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
9511    // session's relay.json — it had only the federation endpoint.
9512    let state_path = session_home.join("config").join("wire").join("relay.json");
9513    let mut state: serde_json::Value = std::fs::read(&state_path)
9514        .ok()
9515        .and_then(|b| serde_json::from_slice(&b).ok())
9516        .unwrap_or_else(|| serde_json::json!({}));
9517    // Read the existing federation self info (already written by
9518    // `wire init` + `wire bind-relay` path during session bootstrap).
9519    let fed_endpoint = state.get("self").and_then(|s| {
9520        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
9521        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
9522        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
9523        Some(crate::endpoints::Endpoint::federation(
9524            url.to_string(),
9525            slot_id.to_string(),
9526            slot_token.to_string(),
9527        ))
9528    });
9529
9530    let local_endpoint = crate::endpoints::Endpoint::local(
9531        local_relay.trim_end_matches('/').to_string(),
9532        alloc.slot_id.clone(),
9533        alloc.slot_token.clone(),
9534    );
9535
9536    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
9537    if let Some(f) = fed_endpoint.clone() {
9538        endpoints.push(f);
9539    }
9540    endpoints.push(local_endpoint);
9541
9542    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
9543    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
9544    // `slot_token` fields must point at the LOCAL endpoint so callers
9545    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
9546    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
9547    // still find a valid slot. Pre-v0.6.6 this branch wrote
9548    // `relay_url: federation_relay` with no slot_id, which produced
9549    // half-populated self state that broke pair-accept on local-only
9550    // sessions.
9551    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
9552        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
9553        None => (
9554            local_relay.trim_end_matches('/').to_string(),
9555            alloc.slot_id.clone(),
9556            alloc.slot_token.clone(),
9557        ),
9558    };
9559    let self_obj = state
9560        .as_object_mut()
9561        .expect("relay_state root is an object")
9562        .entry("self")
9563        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9564    // The entry might be Value::Null (left by read_relay_state's default
9565    // template) — replace with an object before mutating.
9566    if !self_obj.is_object() {
9567        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9568    }
9569    if let Some(obj) = self_obj.as_object_mut() {
9570        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
9571        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
9572        obj.insert(
9573            "slot_token".into(),
9574            serde_json::Value::String(legacy_slot_token),
9575        );
9576        obj.insert(
9577            "endpoints".into(),
9578            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9579        );
9580    }
9581
9582    if let Err(e) = std::fs::write(
9583        &state_path,
9584        serde_json::to_vec_pretty(&state).unwrap_or_default(),
9585    ) {
9586        eprintln!(
9587            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
9588        );
9589        return;
9590    }
9591    eprintln!(
9592        "wire session new: local slot allocated on {local_relay} (slot_id={})",
9593        alloc.slot_id
9594    );
9595}
9596
9597fn render_session_info(
9598    name: &str,
9599    session_home: &std::path::Path,
9600    cwd: &std::path::Path,
9601) -> Result<serde_json::Value> {
9602    let card_path = session_home
9603        .join("config")
9604        .join("wire")
9605        .join("agent-card.json");
9606    let (did, handle) = if card_path.exists() {
9607        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
9608        let did = card
9609            .get("did")
9610            .and_then(Value::as_str)
9611            .unwrap_or("")
9612            .to_string();
9613        let handle = card
9614            .get("handle")
9615            .and_then(Value::as_str)
9616            .map(str::to_string)
9617            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9618        (did, handle)
9619    } else {
9620        (String::new(), String::new())
9621    };
9622    Ok(json!({
9623        "name": name,
9624        "home_dir": session_home.to_string_lossy(),
9625        "cwd": cwd.to_string_lossy(),
9626        "did": did,
9627        "handle": handle,
9628        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9629    }))
9630}
9631
9632fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
9633    if as_json {
9634        let mut obj = info.clone();
9635        obj["status"] = json!(status);
9636        println!("{}", serde_json::to_string(&obj)?);
9637    } else {
9638        let name = info["name"].as_str().unwrap_or("?");
9639        let handle = info["handle"].as_str().unwrap_or("?");
9640        let home = info["home_dir"].as_str().unwrap_or("?");
9641        let did = info["did"].as_str().unwrap_or("?");
9642        let export = info["export"].as_str().unwrap_or("?");
9643        let prefix = if status == "already_exists" {
9644            "session already exists (re-registered cwd)"
9645        } else {
9646            "session created"
9647        };
9648        println!(
9649            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
9650        );
9651    }
9652    Ok(())
9653}
9654
9655fn run_wire_with_home(
9656    session_home: &std::path::Path,
9657    args: &[&str],
9658) -> Result<std::process::ExitStatus> {
9659    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9660    let status = std::process::Command::new(&bin)
9661        .env("WIRE_HOME", session_home)
9662        .env_remove("RUST_LOG")
9663        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
9664        // We already own the session; nested init would clobber state.
9665        .env("WIRE_AUTO_INIT", "0")
9666        .args(args)
9667        .status()
9668        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9669    Ok(status)
9670}
9671
9672/// v0.7.0-alpha.2: idempotent per-cwd session creation.
9673///
9674/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
9675/// registered session for the current cwd — including via parent-walk —
9676/// this creates one inline so every Claude tab in a fresh project gets
9677/// its own wire identity rather than collapsing onto the machine-wide
9678/// default. Without this, multiple Claudes in unwired cwds all render
9679/// the same character (the default identity's character), defeating the
9680/// "every session looks different" promise.
9681///
9682/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
9683/// `run_wire_with_home` subprocess context).
9684///
9685/// Best-effort: any failure (no home dir, name collision pathology,
9686/// `wire init` subprocess crash) is logged to stderr and we fall back
9687/// to default identity. Must not block MCP startup.
9688///
9689/// MUST be called BEFORE worker thread spawn (env::set_var safety).
9690pub fn maybe_auto_init_cwd_session(label: &str) {
9691    if std::env::var("WIRE_HOME").is_ok() {
9692        return; // explicit override OR auto-detect already won
9693    }
9694    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
9695        return; // operator opt-out
9696    }
9697    let cwd = match std::env::current_dir() {
9698        Ok(c) => c,
9699        Err(_) => return,
9700    };
9701    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
9702    // already runs but we want to be robust to ordering).
9703    if crate::session::detect_session_wire_home(&cwd).is_some() {
9704        return;
9705    }
9706
9707    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
9708    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
9709    // Two different cwds with the same basename (e.g. /a/projx +
9710    // /b/projx) used to race outside the lock: both read empty
9711    // registry, both derived name="projx", per-name lock didn't help
9712    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
9713    //
9714    // Single lock serializes ALL auto-init across the sessions_root.
9715    // Inside the lock: re-read registry, derive_name_from_cwd which
9716    // adds path-hash suffix when basename is occupied by another cwd
9717    // already committed to the registry. Different cwds get DIFFERENT
9718    // names guaranteed.
9719    //
9720    // Cost: parallel auto-inits in different cwds now serialize
9721    // (~hundreds of ms each when local relay is up). Acceptable —
9722    // auto-init runs once per cwd per machine; not a hot path.
9723    use fs2::FileExt;
9724    let sessions_root = match crate::session::sessions_root() {
9725        Ok(r) => r,
9726        Err(_) => return,
9727    };
9728    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
9729        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
9730        return;
9731    }
9732    let lock_path = sessions_root.join(".auto-init.lock");
9733    let lock_file = match std::fs::OpenOptions::new()
9734        .create(true)
9735        .truncate(false)
9736        .read(true)
9737        .write(true)
9738        .open(&lock_path)
9739    {
9740        Ok(f) => f,
9741        Err(e) => {
9742            eprintln!(
9743                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
9744            );
9745            return;
9746        }
9747    };
9748    if let Err(e) = lock_file.lock_exclusive() {
9749        eprintln!(
9750            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
9751        );
9752        return;
9753    }
9754    // Lock acquired. Read registry + derive name now that all parallel
9755    // racers serialize through us — derive_name_from_cwd adds a
9756    // path-hash suffix if the basename is already claimed by another
9757    // cwd in the (now-stable) registry.
9758    let registry = crate::session::read_registry().unwrap_or_default();
9759    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
9760    let session_home = match crate::session::session_dir(&name) {
9761        Ok(h) => h,
9762        Err(_) => {
9763            let _ = fs2::FileExt::unlock(&lock_file);
9764            return;
9765        }
9766    };
9767    let agent_card_path = session_home
9768        .join("config")
9769        .join("wire")
9770        .join("agent-card.json");
9771    let needs_init = !agent_card_path.exists();
9772
9773    if needs_init {
9774        if let Err(e) = std::fs::create_dir_all(&session_home) {
9775            eprintln!(
9776                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
9777            );
9778            let _ = fs2::FileExt::unlock(&lock_file);
9779            return;
9780        }
9781        // v0.9: --offline; the surrounding session-spawn path runs
9782        // try_allocate_local_slot afterward to attach an inbound slot
9783        // when a local relay is available. Init itself stays slotless
9784        // because it's a precursor step, not the final state.
9785        match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
9786            Ok(status) if status.success() => {}
9787            Ok(status) => {
9788                eprintln!(
9789                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
9790                );
9791                let _ = fs2::FileExt::unlock(&lock_file);
9792                return;
9793            }
9794            Err(e) => {
9795                eprintln!(
9796                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
9797                );
9798                let _ = fs2::FileExt::unlock(&lock_file);
9799                return;
9800            }
9801        }
9802        // Best-effort: allocate a local-relay slot so this auto-init'd
9803        // session is addressable by sister sessions. Skipped silently when
9804        // the local relay isn't running (the function itself reports to
9805        // stderr). Auto-init'd sessions without endpoints can still
9806        // surface their character but cannot receive pair_drops until the
9807        // operator runs `wire bind-relay` or restarts the local relay.
9808        try_allocate_local_slot(
9809            &session_home,
9810            &name,
9811            "https://wireup.net",
9812            "http://127.0.0.1:8771",
9813        );
9814    } else {
9815        // Race loser path: peer already created the session. Surface
9816        // this honestly so the operator can see we adopted rather than
9817        // double-initialized.
9818        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
9819            eprintln!(
9820                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
9821            );
9822        }
9823    }
9824    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
9825    // BEFORE releasing the auto-init lock. Pre-fix released the lock
9826    // here and committed the registry update afterward — racers in
9827    // OTHER cwds with the same basename would acquire the lock,
9828    // read the registry (still without our entry), and derive the
9829    // SAME name we just claimed. Live regression test caught it:
9830    // two cwds /a/projx + /b/projx both got name "projx", both
9831    // mapped to the same identity. Update the registry WHILE STILL
9832    // holding the auto-init lock so the next racer sees our claim.
9833    let cwd_key = crate::session::normalize_cwd_key(&cwd);
9834    let name_for_reg = name.clone();
9835    if let Err(e) = crate::session::update_registry(|reg| {
9836        reg.by_cwd.insert(cwd_key, name_for_reg);
9837        Ok(())
9838    }) {
9839        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
9840        // proceed — env var still gets set below
9841    }
9842    // NOW release the lock — racers waiting will see our registry
9843    // entry on their re-read.
9844    let _ = fs2::FileExt::unlock(&lock_file);
9845
9846    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
9847        eprintln!(
9848            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
9849            cwd.display(),
9850            session_home.display()
9851        );
9852    }
9853    // SAFETY: caller contract is "before any thread spawn." MCP::run
9854    // calls this immediately after `maybe_adopt_session_wire_home`.
9855    unsafe {
9856        std::env::set_var("WIRE_HOME", &session_home);
9857    }
9858}
9859
9860fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
9861    // Check if a daemon is already alive in this session's WIRE_HOME.
9862    // If so, no-op (let the existing process keep running).
9863    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9864    if pidfile.exists() {
9865        let bytes = std::fs::read(&pidfile).unwrap_or_default();
9866        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9867            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9868        } else {
9869            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9870        };
9871        if let Some(p) = pid {
9872            let alive = {
9873                #[cfg(target_os = "linux")]
9874                {
9875                    std::path::Path::new(&format!("/proc/{p}")).exists()
9876                }
9877                #[cfg(not(target_os = "linux"))]
9878                {
9879                    std::process::Command::new("kill")
9880                        .args(["-0", &p.to_string()])
9881                        .output()
9882                        .map(|o| o.status.success())
9883                        .unwrap_or(false)
9884                }
9885            };
9886            if alive {
9887                return Ok(());
9888            }
9889        }
9890    }
9891
9892    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
9893    // versioned pidfile; we just kick it off and return.
9894    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9895    let log_path = session_home.join("state").join("wire").join("daemon.log");
9896    if let Some(parent) = log_path.parent() {
9897        std::fs::create_dir_all(parent).ok();
9898    }
9899    let log_file = std::fs::OpenOptions::new()
9900        .create(true)
9901        .append(true)
9902        .open(&log_path)
9903        .with_context(|| format!("opening daemon log {log_path:?}"))?;
9904    let log_err = log_file.try_clone()?;
9905    std::process::Command::new(&bin)
9906        .env("WIRE_HOME", session_home)
9907        .env_remove("RUST_LOG")
9908        .args(["daemon", "--interval", "5"])
9909        .stdout(log_file)
9910        .stderr(log_err)
9911        .stdin(std::process::Stdio::null())
9912        .spawn()
9913        .with_context(|| "spawning session-local `wire daemon`")?;
9914    Ok(())
9915}
9916
9917fn cmd_session_list(as_json: bool) -> Result<()> {
9918    let items = crate::session::list_sessions()?;
9919    if as_json {
9920        println!("{}", serde_json::to_string(&items)?);
9921        return Ok(());
9922    }
9923    if items.is_empty() {
9924        println!("no sessions on this machine. `wire session new` to create one.");
9925        return Ok(());
9926    }
9927    println!(
9928        "{:<22} {:<24} {:<24} {:<10} CWD",
9929        "PERSONA", "NAME", "HANDLE", "DAEMON"
9930    );
9931    for s in items {
9932        // ANSI-escape-wrapped character takes more visual width than its
9933        // displayed glyph count; pad based on the plain-text form, then
9934        // wrap in escapes so the column lines up across rows.
9935        let plain = s
9936            .character
9937            .as_ref()
9938            .map(|c| c.short())
9939            .unwrap_or_else(|| "?".to_string());
9940        let colored = s
9941            .character
9942            .as_ref()
9943            .map(|c| c.colored())
9944            .unwrap_or_else(|| "?".to_string());
9945        // Approximate display width: emoji renders as ~2 cells in most
9946        // terminals; the rest are 1 cell each. We pad to 18 displayed
9947        // chars (≈22 byte slots when counting emoji).
9948        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
9949        let pad = 22usize.saturating_sub(displayed_width);
9950        println!(
9951            "{}{}  {:<24} {:<24} {:<10} {}",
9952            colored,
9953            " ".repeat(pad),
9954            s.name,
9955            s.handle.as_deref().unwrap_or("?"),
9956            if s.daemon_running { "running" } else { "down" },
9957            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
9958        );
9959    }
9960    Ok(())
9961}
9962
9963/// v0.5.19: `wire session list-local` — sister-session discovery.
9964///
9965/// For each on-disk session, read its `relay-state.json` and surface
9966/// the ones that have a Local-scope endpoint (allocated via
9967/// `wire session new --with-local`). Group by the local-relay URL so
9968/// the operator can see at a glance which sessions are mutually
9969/// reachable over the same loopback relay.
9970///
9971/// Read-only, no daemon contact. Useful as the prelude to teaming /
9972/// pairing same-box sister claudes (see also `wire session
9973/// pair-all-local` once implemented).
9974fn cmd_session_list_local(as_json: bool) -> Result<()> {
9975    let listing = crate::session::list_local_sessions()?;
9976    if as_json {
9977        println!("{}", serde_json::to_string(&listing)?);
9978        return Ok(());
9979    }
9980
9981    if listing.local.is_empty() && listing.federation_only.is_empty() {
9982        println!(
9983            "no sessions on this machine. `wire session new --with-local` to create one \
9984             with a local-relay endpoint (start the relay first: \
9985             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
9986        );
9987        return Ok(());
9988    }
9989
9990    if listing.local.is_empty() {
9991        println!(
9992            "no sister sessions reachable via a local relay. \
9993             Re-run `wire session new --with-local` to add a Local endpoint, or \
9994             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
9995        );
9996    } else {
9997        // Stable iteration order: sort the relay URLs.
9998        let mut keys: Vec<&String> = listing.local.keys().collect();
9999        keys.sort();
10000        for relay_url in keys {
10001            let group = &listing.local[relay_url];
10002            println!("LOCAL RELAY: {relay_url}");
10003            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
10004            for s in group {
10005                println!(
10006                    "  {:<24} {:<32} {:<10} {}",
10007                    s.name,
10008                    s.handle.as_deref().unwrap_or("?"),
10009                    if s.daemon_running { "running" } else { "down" },
10010                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10011                );
10012            }
10013            println!();
10014        }
10015    }
10016
10017    if !listing.federation_only.is_empty() {
10018        println!("federation-only (no local endpoint):");
10019        for s in &listing.federation_only {
10020            println!(
10021                "  {:<24} {:<32} {}",
10022                s.name,
10023                s.handle.as_deref().unwrap_or("?"),
10024                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10025            );
10026        }
10027    }
10028    Ok(())
10029}
10030
10031/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
10032/// session that has a Local-scope endpoint. Skips already-paired
10033/// pairs; reports a per-pair outcome JSON suitable for scripting.
10034///
10035/// Same-uid trust anchor: the caller owns every session enumerated by
10036/// `list_local_sessions`, so the operator running this command IS the
10037/// consent for both sides. The bilateral SAS / network-level handshake
10038/// assumes strangers; same-uid sister sessions are not strangers.
10039///
10040/// Per-pair flow (sequential to keep relay-side load + log clarity):
10041///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
10042///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
10043///   3. sleep settle_secs                       (pair_drop reaches B)
10044///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
10045///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
10046///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
10047///   7. sleep settle_secs                       (ack reaches A)
10048///   8. WIRE_HOME=A wire pull --json            (A pins B)
10049fn cmd_session_pair_all_local(
10050    settle_secs: u64,
10051    federation_relay: &str,
10052    as_json: bool,
10053) -> Result<()> {
10054    use std::collections::BTreeSet;
10055    use std::time::Duration;
10056
10057    let listing = crate::session::list_local_sessions()?;
10058    // Flatten + dedup by session NAME (same session can appear under
10059    // multiple local-relay URLs if it advertises two local endpoints;
10060    // rare, but pair each pair exactly once).
10061    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
10062        Default::default();
10063    for group in listing.local.into_values() {
10064        for s in group {
10065            by_name.entry(s.name.clone()).or_insert(s);
10066        }
10067    }
10068    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10069
10070    if sessions.len() < 2 {
10071        let msg = format!(
10072            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
10073            sessions.len()
10074        );
10075        if as_json {
10076            println!(
10077                "{}",
10078                serde_json::to_string(&json!({
10079                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
10080                    "pairs_attempted": 0,
10081                    "pairs_succeeded": 0,
10082                    "pairs_skipped_already_paired": 0,
10083                    "pairs_failed": 0,
10084                    "note": msg,
10085                }))?
10086            );
10087        } else {
10088            println!("{msg}");
10089            if let Some(s) = sessions.first() {
10090                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
10091            }
10092            println!("Use `wire session new --with-local` to add more.");
10093        }
10094        return Ok(());
10095    }
10096
10097    let fed_host = host_of_url(federation_relay);
10098    if fed_host.is_empty() {
10099        bail!(
10100            "federation_relay `{federation_relay}` has no parseable host — \
10101             pass a full URL like `https://wireup.net`."
10102        );
10103    }
10104
10105    // Enumerate unordered pairs deterministically by session name.
10106    let mut attempted = 0u32;
10107    let mut succeeded = 0u32;
10108    let mut skipped_already = 0u32;
10109    let mut failed = 0u32;
10110    let mut per_pair: Vec<Value> = Vec::new();
10111
10112    for i in 0..sessions.len() {
10113        for j in (i + 1)..sessions.len() {
10114            let a = &sessions[i];
10115            let b = &sessions[j];
10116            attempted += 1;
10117
10118            // Already-paired check: if A's relay-state has B's CARD
10119            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
10120            // are character handles (not session names), so we use
10121            // each side's handle field (already on the LocalSessionView)
10122            // for the lookup rather than the session name.
10123            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
10124            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
10125            let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
10126            let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
10127            if a_pinned_b && b_pinned_a {
10128                skipped_already += 1;
10129                per_pair.push(json!({
10130                    "from": a.name,
10131                    "to": b.name,
10132                    "status": "already_paired",
10133                }));
10134                continue;
10135            }
10136
10137            let pair_result = drive_bilateral_pair(
10138                &a.home_dir,
10139                &a.name,
10140                &b.home_dir,
10141                &b.name,
10142                &fed_host,
10143                federation_relay,
10144                settle_secs,
10145            );
10146
10147            match pair_result {
10148                Ok(()) => {
10149                    succeeded += 1;
10150                    per_pair.push(json!({
10151                        "from": a.name,
10152                        "to": b.name,
10153                        "status": "paired",
10154                    }));
10155                }
10156                Err(e) => {
10157                    failed += 1;
10158                    let detail = format!("{e:#}");
10159                    per_pair.push(json!({
10160                        "from": a.name,
10161                        "to": b.name,
10162                        "status": "failed",
10163                        "error": detail,
10164                    }));
10165                }
10166            }
10167
10168            // Brief settle between pairs so we don't slam the relay
10169            // with N(N-1) parallel requests.
10170            std::thread::sleep(Duration::from_millis(200));
10171        }
10172    }
10173
10174    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
10175    let summary = json!({
10176        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
10177        "pairs_attempted": attempted,
10178        "pairs_succeeded": succeeded,
10179        "pairs_skipped_already_paired": skipped_already,
10180        "pairs_failed": failed,
10181        "results": per_pair,
10182    });
10183    if as_json {
10184        println!("{}", serde_json::to_string(&summary)?);
10185    } else {
10186        println!(
10187            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
10188            sessions.len(),
10189            attempted
10190        );
10191        println!("  paired:                 {succeeded}");
10192        println!("  skipped (already pinned): {skipped_already}");
10193        println!("  failed:                 {failed}");
10194        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
10195            let from = entry["from"].as_str().unwrap_or("?");
10196            let to = entry["to"].as_str().unwrap_or("?");
10197            let status = entry["status"].as_str().unwrap_or("?");
10198            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
10199            if err.is_empty() {
10200                println!("  {from:<24} ↔ {to:<24} {status}");
10201            } else {
10202                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
10203            }
10204        }
10205    }
10206    Ok(())
10207}
10208
10209/// Check whether `session_home`'s `relay.json` already lists `peer_name`
10210/// under `state.peers`. Best-effort — any read/parse error → false.
10211fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
10212    val_session_relay_state(session_home)
10213        .and_then(|v| v.get("peers").cloned())
10214        .and_then(|p| p.get(peer_name).cloned())
10215        .is_some()
10216}
10217
10218/// Read a session's `relay.json` directly without mutating the process'
10219/// WIRE_HOME env (which would race other threads / processes). Returns
10220/// `None` on any read or parse error — callers treat missing state as
10221/// "no peers / no endpoints" rather than aborting.
10222fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
10223    let path = session_home.join("config").join("wire").join("relay.json");
10224    let bytes = std::fs::read(&path).ok()?;
10225    serde_json::from_slice(&bytes).ok()
10226}
10227
10228/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
10229/// One probe per directed edge against the relay backing that edge's
10230/// priority-1 endpoint; output groups by undirected pair.
10231fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
10232    use std::collections::BTreeMap;
10233
10234    // Flatten by session NAME — same dedup logic as pair-all-local so a
10235    // session advertising two local endpoints doesn't get double-counted.
10236    let listing = crate::session::list_local_sessions()?;
10237    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
10238    for group in listing.local.into_values() {
10239        for s in group {
10240            by_name.entry(s.name.clone()).or_insert(s);
10241        }
10242    }
10243    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10244    let federation_only = listing.federation_only;
10245
10246    if sessions.is_empty() {
10247        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
10248        if as_json {
10249            println!(
10250                "{}",
10251                serde_json::to_string(&json!({
10252                    "sessions": [],
10253                    "edges": [],
10254                    "local_relay": null,
10255                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10256                    "summary": {
10257                        "session_count": 0,
10258                        "edge_count": 0,
10259                        "healthy": 0,
10260                        "stale": 0,
10261                        "asymmetric": 0,
10262                    },
10263                    "note": msg,
10264                }))?
10265            );
10266        } else {
10267            println!("{msg}");
10268            println!("Use `wire session new --with-local` to create one.");
10269        }
10270        return Ok(());
10271    }
10272
10273    // Build a name → session-state map: relay_state + reachable handle set.
10274    struct SessionState {
10275        view: crate::session::LocalSessionView,
10276        relay_state: Value,
10277        local_relay_url: Option<String>,
10278    }
10279    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
10280    for s in sessions {
10281        let relay_state = val_session_relay_state(&s.home_dir)
10282            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
10283        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
10284        sstates.push(SessionState {
10285            view: s,
10286            relay_state,
10287            local_relay_url,
10288        });
10289    }
10290
10291    // Probe each unique local-relay URL once for healthz so the operator
10292    // sees one liveness line per local relay, not one per edge.
10293    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
10294    for s in &sstates {
10295        if let Some(url) = &s.local_relay_url
10296            && !local_relays.contains_key(url)
10297        {
10298            let healthy = probe_relay_healthz(url);
10299            local_relays.insert(url.clone(), healthy);
10300        }
10301    }
10302
10303    let now = std::time::SystemTime::now()
10304        .duration_since(std::time::UNIX_EPOCH)
10305        .map(|d| d.as_secs())
10306        .unwrap_or(0);
10307
10308    // Edges: walk every unordered pair, surface bilateral state + each
10309    // direction's last_pull. Probe priority-1 endpoint (local preferred
10310    // by `peer_endpoints_in_priority_order`).
10311    let mut edges: Vec<Value> = Vec::new();
10312    let mut healthy_count = 0u32;
10313    let mut stale_count = 0u32;
10314    let mut asymmetric_count = 0u32;
10315
10316    for i in 0..sstates.len() {
10317        for j in (i + 1)..sstates.len() {
10318            let a = &sstates[i];
10319            let b = &sstates[j];
10320            // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
10321            // (DID-derived character), not the session name. Look the
10322            // peer up by its handle (with a session-name fallback for
10323            // pre-v0.11 sessions that haven't re-init'd yet).
10324            let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
10325            let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
10326            let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
10327            let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
10328
10329            let bilateral = a_to_b.pinned && b_to_a.pinned;
10330            // Scope = the most-local scope available in either direction.
10331            // (If a→b is local and b→a is federation, the asymmetric
10332            // detail surfaces below; the headline scope is the better.)
10333            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
10334                (Some("local"), _) | (_, Some("local")) => "local",
10335                (Some("federation"), _) | (_, Some("federation")) => "federation",
10336                _ => "unknown",
10337            };
10338
10339            // Health: stale if either direction's last_pull is older than
10340            // `stale_secs`, or never observed when both sides are pinned.
10341            let mut status = if bilateral { "healthy" } else { "asymmetric" };
10342            if bilateral {
10343                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
10344                    Some(s) => s > stale_secs,
10345                    None => d.probed,
10346                });
10347                if either_stale {
10348                    status = "stale";
10349                }
10350            }
10351
10352            match status {
10353                "healthy" => healthy_count += 1,
10354                "stale" => stale_count += 1,
10355                "asymmetric" => asymmetric_count += 1,
10356                _ => {}
10357            }
10358
10359            edges.push(json!({
10360                "from": a.view.name,
10361                "to": b.view.name,
10362                "bilateral": bilateral,
10363                "scope": scope,
10364                "status": status,
10365                "directions": {
10366                    a.view.name.clone(): direction_summary(&a_to_b),
10367                    b.view.name.clone(): direction_summary(&b_to_a),
10368                },
10369            }));
10370        }
10371    }
10372
10373    let summary = json!({
10374        "sessions": sstates.iter().map(|s| json!({
10375            "name": s.view.name,
10376            "handle": s.view.handle,
10377            "cwd": s.view.cwd,
10378            "daemon_running": s.view.daemon_running,
10379            "local_relay": s.local_relay_url,
10380        })).collect::<Vec<_>>(),
10381        "edges": edges,
10382        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
10383            "url": url,
10384            "healthy": healthy,
10385        })).collect::<Vec<_>>(),
10386        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10387        "summary": {
10388            "session_count": sstates.len(),
10389            "edge_count": edges.len(),
10390            "healthy": healthy_count,
10391            "stale": stale_count,
10392            "asymmetric": asymmetric_count,
10393            "stale_threshold_secs": stale_secs,
10394        },
10395    });
10396
10397    if as_json {
10398        println!("{}", serde_json::to_string(&summary)?);
10399        return Ok(());
10400    }
10401
10402    println!(
10403        "wire mesh: {} session(s), {} edge(s)",
10404        sstates.len(),
10405        edges.len()
10406    );
10407    for (url, healthy) in &local_relays {
10408        let tick = if *healthy { "✓" } else { "✗" };
10409        println!("  local-relay {url} {tick}");
10410    }
10411    if !federation_only.is_empty() {
10412        print!("  federation-only sessions:");
10413        for f in &federation_only {
10414            print!(" {}", f.name);
10415        }
10416        println!();
10417    }
10418
10419    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
10420    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
10421    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
10422    print!("\n{:>col_w$}", "", col_w = col_w);
10423    for n in &names {
10424        print!("{:>col_w$}", n, col_w = col_w);
10425    }
10426    println!();
10427    for (i, row) in names.iter().enumerate() {
10428        print!("{:>col_w$}", row, col_w = col_w);
10429        for (j, col) in names.iter().enumerate() {
10430            let cell = if i == j {
10431                "self".to_string()
10432            } else {
10433                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
10434                match d.scope.as_deref() {
10435                    Some("local") => "local".to_string(),
10436                    Some("federation") => "fed".to_string(),
10437                    _ => "—".to_string(),
10438                }
10439            };
10440            print!("{:>col_w$}", cell, col_w = col_w);
10441        }
10442        println!();
10443    }
10444
10445    println!("\nHealth (stale threshold: {stale_secs}s):");
10446    for e in &edges {
10447        let from = e["from"].as_str().unwrap_or("?");
10448        let to = e["to"].as_str().unwrap_or("?");
10449        let scope = e["scope"].as_str().unwrap_or("?");
10450        let status = e["status"].as_str().unwrap_or("?");
10451        let mark = match status {
10452            "healthy" => "✓",
10453            "stale" => "⚠",
10454            "asymmetric" => "!",
10455            _ => "?",
10456        };
10457        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
10458        let mut details: Vec<String> = Vec::new();
10459        for (who, d) in &dirs {
10460            let silent = d.get("silent_secs").and_then(Value::as_u64);
10461            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
10462            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
10463            let label = match (pinned, probed, silent) {
10464                (false, _, _) => format!("{who} has not pinned"),
10465                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
10466                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
10467                (true, true, Some(s)) => format!("{who} silent {s}s"),
10468                (true, true, None) => format!("{who} never pulled"),
10469            };
10470            details.push(label);
10471        }
10472        println!(
10473            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
10474            details.join(" | ")
10475        );
10476    }
10477    Ok(())
10478}
10479
10480#[derive(Default)]
10481struct DirectedEdge {
10482    pinned: bool,
10483    scope: Option<String>,
10484    last_pull_at_unix: Option<u64>,
10485    silent_secs: Option<u64>,
10486    probed: bool,
10487    event_count: usize,
10488}
10489
10490/// Probe a single directed edge from `from_state`'s view of `to_name`.
10491/// Picks the priority-1 endpoint (local preferred when reachable) and
10492/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
10493/// failure (the function records `probed = true`, `last_pull = None`,
10494/// which the caller treats as "never pulled, route exists" = stale).
10495fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
10496    let pinned = from_state
10497        .get("peers")
10498        .and_then(|p| p.get(to_name))
10499        .is_some();
10500    if !pinned {
10501        return DirectedEdge::default();
10502    }
10503    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
10504    let ep = match endpoints.into_iter().next() {
10505        Some(e) => e,
10506        None => {
10507            return DirectedEdge {
10508                pinned: true,
10509                ..Default::default()
10510            };
10511        }
10512    };
10513    let scope = Some(
10514        match ep.scope {
10515            crate::endpoints::EndpointScope::Local => "local",
10516            crate::endpoints::EndpointScope::Lan => "lan",
10517            crate::endpoints::EndpointScope::Uds => "uds",
10518            crate::endpoints::EndpointScope::Federation => "federation",
10519        }
10520        .to_string(),
10521    );
10522    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
10523    let (count, last) = client
10524        .slot_state(&ep.slot_id, &ep.slot_token)
10525        .unwrap_or((0, None));
10526    let silent = last.map(|t| now.saturating_sub(t));
10527    DirectedEdge {
10528        pinned: true,
10529        scope,
10530        last_pull_at_unix: last,
10531        silent_secs: silent,
10532        probed: true,
10533        event_count: count,
10534    }
10535}
10536
10537fn direction_summary(d: &DirectedEdge) -> Value {
10538    json!({
10539        "pinned": d.pinned,
10540        "scope": d.scope,
10541        "probed": d.probed,
10542        "last_pull_at_unix": d.last_pull_at_unix,
10543        "silent_secs": d.silent_secs,
10544        "event_count": d.event_count,
10545    })
10546}
10547
10548/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
10549fn probe_relay_healthz(url: &str) -> bool {
10550    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
10551    let client = match reqwest::blocking::Client::builder()
10552        .timeout(std::time::Duration::from_millis(500))
10553        .build()
10554    {
10555        Ok(c) => c,
10556        Err(_) => return false,
10557    };
10558    match client.get(&probe_url).send() {
10559        Ok(r) => r.status().is_success(),
10560        Err(_) => false,
10561    }
10562}
10563
10564/// Drive one bilateral pair handshake between two sister sessions
10565/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
10566/// flow so failures bubble up at the offending step, not buried in
10567/// a parallel race. See `cmd_session_pair_all_local` docstring.
10568///
10569/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
10570/// federation `.well-known/wire/agent` resolution. Reads B's card +
10571/// endpoints directly off disk under `b_home` and pins them. This
10572/// makes pair-all-local work for sister sessions whose federation
10573/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
10574/// for sessions created with `wire session new --local-only`
10575/// (no federation slot at all). The `_federation_relay` / `_fed_host`
10576/// parameters are retained for callers that want to log them but
10577/// the handshake itself no longer touches federation.
10578fn drive_bilateral_pair(
10579    a_home: &std::path::Path,
10580    a_name: &str,
10581    b_home: &std::path::Path,
10582    b_name: &str,
10583    _fed_host: &str,
10584    _federation_relay: &str,
10585    settle_secs: u64,
10586) -> Result<()> {
10587    use std::time::Duration;
10588    let bin = std::env::current_exe().context("locating self exe")?;
10589
10590    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
10591        let out = std::process::Command::new(&bin)
10592            .env("WIRE_HOME", home)
10593            .env_remove("RUST_LOG")
10594            .args(args)
10595            .output()
10596            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10597        if !out.status.success() {
10598            bail!(
10599                "`wire {}` failed: stderr={}",
10600                args.join(" "),
10601                String::from_utf8_lossy(&out.stderr).trim()
10602            );
10603        }
10604        Ok(())
10605    };
10606
10607    // v0.11: each session's agent-card.handle is the DID-derived
10608    // character, not the session name. pair-accept lookups key on the
10609    // CARD HANDLE, so we discover each side's canonical handle from
10610    // its agent-card on disk before driving the pair flow.
10611    let read_card_handle = |home: &std::path::Path| -> Result<String> {
10612        let card_path = home.join("config").join("wire").join("agent-card.json");
10613        let bytes = std::fs::read(&card_path)
10614            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
10615        let card: Value = serde_json::from_slice(&bytes)?;
10616        card.get("handle")
10617            .and_then(Value::as_str)
10618            .map(str::to_string)
10619            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
10620    };
10621    let a_handle = read_card_handle(a_home)
10622        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
10623    let b_handle = read_card_handle(b_home)
10624        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
10625
10626    // 1. A initiates via --local-sister (uses the session NAME for
10627    // the registry lookup; cmd_add_local_sister auto-resolves
10628    // session→handle internally).
10629    run(a_home, &["add", b_name, "--local-sister", "--json"])
10630        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
10631
10632    // 3. settle so pair_drop reaches B's slot
10633    std::thread::sleep(Duration::from_secs(settle_secs));
10634
10635    // 4. B pulls pair_drop → 5. B pair-accept (pins A by CARD HANDLE,
10636    // not by session name — under v0.11 these differ) → 6. B push ack
10637    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
10638    run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
10639        format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
10640    })?;
10641    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
10642
10643    // 7. settle so ack reaches A's slot
10644    std::thread::sleep(Duration::from_secs(settle_secs));
10645
10646    // 8. A pulls ack (pins B by CARD HANDLE)
10647    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
10648    // suppress unused warning when both handles are consumed
10649    let _ = &b_handle;
10650
10651    Ok(())
10652}
10653
10654fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
10655    let name = resolve_session_name(name_arg)?;
10656    let session_home = crate::session::session_dir(&name)?;
10657    if !session_home.exists() {
10658        bail!(
10659            "no session named {name:?} on this machine. `wire session list` to enumerate, \
10660             `wire session new {name}` to create."
10661        );
10662    }
10663    if as_json {
10664        println!(
10665            "{}",
10666            serde_json::to_string(&json!({
10667                "name": name,
10668                "home_dir": session_home.to_string_lossy(),
10669                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
10670            }))?
10671        );
10672    } else {
10673        println!("export WIRE_HOME={}", session_home.to_string_lossy());
10674    }
10675    Ok(())
10676}
10677
10678fn cmd_session_current(as_json: bool) -> Result<()> {
10679    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10680    let registry = crate::session::read_registry().unwrap_or_default();
10681    let cwd_key = crate::session::normalize_cwd_key(&cwd);
10682    // Backward-compat: O(n) normalized scan on read-miss. Mirrors the
10683    // same pattern in session::derive_name_from_cwd /
10684    // detect_session_wire_home — handles both consistent-casing and
10685    // cross-casing upgraders (see session.rs for the full rationale).
10686    let name = registry
10687        .by_cwd
10688        .get(&cwd_key)
10689        .or_else(|| {
10690            registry
10691                .by_cwd
10692                .iter()
10693                .find(|(k, _)| {
10694                    crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
10695                })
10696                .map(|(_, v)| v)
10697        })
10698        .cloned();
10699    if as_json {
10700        println!(
10701            "{}",
10702            serde_json::to_string(&json!({
10703                "cwd": cwd_key,
10704                "session": name,
10705            }))?
10706        );
10707    } else if let Some(n) = name {
10708        println!("{n}");
10709    } else {
10710        println!("(no session registered for this cwd)");
10711    }
10712    Ok(())
10713}
10714
10715fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
10716    let name = crate::session::sanitize_name(name_arg);
10717    let session_home = crate::session::session_dir(&name)?;
10718    if !session_home.exists() {
10719        if as_json {
10720            println!(
10721                "{}",
10722                serde_json::to_string(&json!({
10723                    "name": name,
10724                    "destroyed": false,
10725                    "reason": "no such session",
10726                }))?
10727            );
10728        } else {
10729            println!("no session named {name:?} — nothing to destroy.");
10730        }
10731        return Ok(());
10732    }
10733    if !force {
10734        bail!(
10735            "destroying session {name:?} would delete its keypair + state irrecoverably. \
10736             Pass --force to confirm."
10737        );
10738    }
10739
10740    // Kill the session-local daemon if alive.
10741    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10742    if let Ok(bytes) = std::fs::read(&pidfile) {
10743        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10744            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10745        } else {
10746            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10747        };
10748        if let Some(p) = pid {
10749            let _ = std::process::Command::new("kill")
10750                .args(["-TERM", &p.to_string()])
10751                .output();
10752        }
10753    }
10754
10755    std::fs::remove_dir_all(&session_home)
10756        .with_context(|| format!("removing session dir {session_home:?}"))?;
10757
10758    // Strip from registry.
10759    let mut registry = crate::session::read_registry().unwrap_or_default();
10760    registry.by_cwd.retain(|_, v| v != &name);
10761    crate::session::write_registry(&registry)?;
10762
10763    if as_json {
10764        println!(
10765            "{}",
10766            serde_json::to_string(&json!({
10767                "name": name,
10768                "destroyed": true,
10769            }))?
10770        );
10771    } else {
10772        println!("destroyed session {name:?}.");
10773    }
10774    Ok(())
10775}
10776
10777// ---------- diag (structured trace) ----------
10778
10779fn cmd_diag(action: DiagAction) -> Result<()> {
10780    let state = config::state_dir()?;
10781    let knob = state.join("diag.enabled");
10782    let log_path = state.join("diag.jsonl");
10783    match action {
10784        DiagAction::Tail { limit, json } => {
10785            let entries = crate::diag::tail(limit);
10786            if json {
10787                for e in entries {
10788                    println!("{}", serde_json::to_string(&e)?);
10789                }
10790            } else if entries.is_empty() {
10791                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
10792            } else {
10793                for e in entries {
10794                    let ts = e["ts"].as_u64().unwrap_or(0);
10795                    let ty = e["type"].as_str().unwrap_or("?");
10796                    let pid = e["pid"].as_u64().unwrap_or(0);
10797                    let payload = e["payload"].to_string();
10798                    println!("[{ts}] pid={pid} {ty} {payload}");
10799                }
10800            }
10801        }
10802        DiagAction::Enable => {
10803            config::ensure_dirs()?;
10804            std::fs::write(&knob, "1")?;
10805            println!("wire diag: enabled at {knob:?}");
10806        }
10807        DiagAction::Disable => {
10808            if knob.exists() {
10809                std::fs::remove_file(&knob)?;
10810            }
10811            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
10812        }
10813        DiagAction::Status { json } => {
10814            let enabled = crate::diag::is_enabled();
10815            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
10816            if json {
10817                println!(
10818                    "{}",
10819                    serde_json::to_string(&serde_json::json!({
10820                        "enabled": enabled,
10821                        "log_path": log_path,
10822                        "log_size_bytes": size,
10823                    }))?
10824                );
10825            } else {
10826                println!("wire diag status");
10827                println!("  enabled:    {enabled}");
10828                println!("  log:        {log_path:?}");
10829                println!("  log size:   {size} bytes");
10830            }
10831        }
10832    }
10833    Ok(())
10834}
10835
10836// ---------- service (install / uninstall / status) ----------
10837
10838fn cmd_service(action: ServiceAction) -> Result<()> {
10839    let kind = |local_relay: bool| {
10840        if local_relay {
10841            crate::service::ServiceKind::LocalRelay
10842        } else {
10843            crate::service::ServiceKind::Daemon
10844        }
10845    };
10846    let (report, as_json) = match action {
10847        ServiceAction::Install { local_relay, json } => {
10848            (crate::service::install_kind(kind(local_relay))?, json)
10849        }
10850        ServiceAction::Uninstall { local_relay, json } => {
10851            (crate::service::uninstall_kind(kind(local_relay))?, json)
10852        }
10853        ServiceAction::Status { local_relay, json } => {
10854            (crate::service::status_kind(kind(local_relay))?, json)
10855        }
10856    };
10857    if as_json {
10858        println!("{}", serde_json::to_string(&report)?);
10859    } else {
10860        println!("wire service {}", report.action);
10861        println!("  platform:  {}", report.platform);
10862        println!("  unit:      {}", report.unit_path);
10863        println!("  status:    {}", report.status);
10864        println!("  detail:    {}", report.detail);
10865    }
10866    Ok(())
10867}
10868
10869// ---------- update (self-update from crates.io / prebuilt release) ----------
10870
10871const CRATE_NAME: &str = "slancha-wire";
10872
10873/// (target-triple, binary-extension) of the GitHub release asset for THIS
10874/// platform — names mirror `.github/workflows/release.yml`. `None` if no
10875/// prebuilt is published for this target.
10876fn release_asset_triple() -> Option<(&'static str, &'static str)> {
10877    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
10878    {
10879        return Some(("x86_64-pc-windows-msvc", ".exe"));
10880    }
10881    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
10882    {
10883        return Some(("aarch64-apple-darwin", ""));
10884    }
10885    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
10886    {
10887        return Some(("x86_64-apple-darwin", ""));
10888    }
10889    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
10890    {
10891        return Some(("x86_64-unknown-linux-musl", ""));
10892    }
10893    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
10894    {
10895        return Some(("aarch64-unknown-linux-musl", ""));
10896    }
10897    #[allow(unreachable_code)]
10898    None
10899}
10900
10901/// Latest stable version published on crates.io.
10902fn fetch_latest_published_version() -> Result<String> {
10903    let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
10904    let client = reqwest::blocking::Client::builder()
10905        .timeout(std::time::Duration::from_secs(20))
10906        .build()?;
10907    let resp = client
10908        .get(&url)
10909        // crates.io rejects requests without a descriptive User-Agent (403).
10910        .header(
10911            "User-Agent",
10912            format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
10913        )
10914        .send()?;
10915    if !resp.status().is_success() {
10916        bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
10917    }
10918    let v: Value = resp.json()?;
10919    v.get("crate")
10920        .and_then(|c| {
10921            c.get("max_stable_version")
10922                .or_else(|| c.get("newest_version"))
10923        })
10924        .and_then(Value::as_str)
10925        .map(str::to_string)
10926        .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
10927}
10928
10929/// True iff `latest` is strictly newer than `current` (numeric major.minor.patch;
10930/// pre-release suffixes ignored).
10931fn version_is_newer(latest: &str, current: &str) -> bool {
10932    let parse = |s: &str| -> (u64, u64, u64) {
10933        let core = s.split('-').next().unwrap_or(s);
10934        let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
10935        (
10936            it.next().unwrap_or(0),
10937            it.next().unwrap_or(0),
10938            it.next().unwrap_or(0),
10939        )
10940    };
10941    parse(latest) > parse(current)
10942}
10943
10944fn cargo_on_path() -> bool {
10945    std::process::Command::new("cargo")
10946        .arg("--version")
10947        .stdout(std::process::Stdio::null())
10948        .stderr(std::process::Stdio::null())
10949        .status()
10950        .map(|s| s.success())
10951        .unwrap_or(false)
10952}
10953
10954/// Download the prebuilt release binary for `latest` and replace THIS binary
10955/// in place — the toolchain-free update path (for boxes with no `cargo`).
10956fn self_update_from_release(latest: &str) -> Result<()> {
10957    let (triple, ext) = release_asset_triple().ok_or_else(|| {
10958        anyhow!(
10959            "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
10960             or `cargo install {CRATE_NAME}`"
10961        )
10962    })?;
10963    let base =
10964        format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
10965    let client = reqwest::blocking::Client::builder()
10966        .timeout(std::time::Duration::from_secs(120))
10967        .build()?;
10968    let resp = client
10969        .get(&base)
10970        .header("User-Agent", "wire-self-update")
10971        .send()?;
10972    if !resp.status().is_success() {
10973        bail!("downloading {base} returned {}", resp.status());
10974    }
10975    let bytes = resp.bytes()?;
10976
10977    // Verify the SHA-256 sidecar if present (best-effort; absence is non-fatal).
10978    if let Ok(sha) = client
10979        .get(format!("{base}.sha256"))
10980        .header("User-Agent", "wire-self-update")
10981        .send()
10982        && sha.status().is_success()
10983    {
10984        let expected = sha
10985            .text()?
10986            .split_whitespace()
10987            .next()
10988            .unwrap_or("")
10989            .to_string();
10990        if !expected.is_empty() {
10991            use sha2::{Digest, Sha256};
10992            let mut h = Sha256::new();
10993            h.update(&bytes);
10994            let actual = hex::encode(h.finalize());
10995            if expected != actual {
10996                bail!(
10997                    "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
10998                );
10999            }
11000        }
11001    }
11002
11003    let exe = std::env::current_exe().context("locating current exe")?;
11004    let dir = exe
11005        .parent()
11006        .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
11007    let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
11008    std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
11009    #[cfg(unix)]
11010    {
11011        use std::os::unix::fs::PermissionsExt;
11012        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
11013        // Unix: rename over the running binary — the running process keeps the
11014        // old inode; the new file takes the path for the next invocation.
11015        std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
11016    }
11017    #[cfg(windows)]
11018    {
11019        // Windows can't overwrite a running .exe — rename it aside first
11020        // (allowed even while running), then move the new one into place.
11021        let old = exe.with_extension("old");
11022        let _ = std::fs::remove_file(&old);
11023        std::fs::rename(&exe, &old)
11024            .with_context(|| format!("renaming running exe {exe:?} aside"))?;
11025        std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
11026    }
11027    Ok(())
11028}
11029
11030/// Outcome of the crates.io self-update step (the front half of `wire upgrade`).
11031struct UpdateOutcome {
11032    current: String,
11033    latest: String,
11034    /// A newer stable version is published.
11035    available: bool,
11036    /// We actually installed it this run.
11037    installed: bool,
11038    /// How it was installed ("cargo install" / "prebuilt release binary").
11039    via: Option<&'static str>,
11040}
11041
11042/// Check crates.io for a newer published wire and, when `install` is true,
11043/// self-install it (cargo if a toolchain is on PATH, else the prebuilt release
11044/// binary). The front half of `wire upgrade`; `install=false` is check-only.
11045fn self_update_step(install: bool) -> Result<UpdateOutcome> {
11046    let current = env!("CARGO_PKG_VERSION").to_string();
11047    let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
11048    let available = version_is_newer(&latest, &current);
11049    if !install || !available {
11050        return Ok(UpdateOutcome {
11051            current,
11052            latest,
11053            available,
11054            installed: false,
11055            via: None,
11056        });
11057    }
11058    let via = if cargo_on_path() {
11059        eprintln!(
11060            "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
11061        );
11062        let status = std::process::Command::new("cargo")
11063            .args([
11064                "install",
11065                CRATE_NAME,
11066                "--version",
11067                &latest,
11068                "--force",
11069                "--locked",
11070            ])
11071            .status()
11072            .context("running cargo install")?;
11073        if !status.success() {
11074            bail!("`cargo install {CRATE_NAME}` failed");
11075        }
11076        "cargo install"
11077    } else {
11078        eprintln!(
11079            "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
11080        );
11081        self_update_from_release(&latest)?;
11082        "prebuilt release binary"
11083    };
11084    Ok(UpdateOutcome {
11085        current,
11086        latest,
11087        available,
11088        installed: true,
11089        via: Some(via),
11090    })
11091}
11092
11093// ---------- upgrade (atomic daemon swap) ----------
11094
11095/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
11096/// fresh one from the currently-installed binary, write a new versioned
11097/// pidfile. The fix for today's exact failure mode: a daemon process that
11098/// kept running OLD binary text in memory under a symlink that had since
11099/// been repointed at a NEW binary on disk.
11100///
11101/// Idempotent. If no stale daemon is running, just starts a fresh one
11102/// (same as `wire daemon &` but with the wait-until-alive guard from
11103/// ensure_up::ensure_daemon_running).
11104///
11105/// `--check` mode reports drift without acting — lists the processes
11106/// that WOULD be killed and the binary version of each.
11107///
11108/// Session-scoped upgrade kill set (v0.13.2, B fix): THIS session's own daemon
11109/// (`my_pid`, from its pidfile — reliable even when the OS process scan can't
11110/// see it, as on Windows) plus TRUE orphans (found `wire daemon` pids owned by
11111/// no session), EXCLUDING sibling sessions' daemons. Pure + unit-tested so the
11112/// session-scoping is locked — the box-wide predecessor accumulated daemons.
11113fn upgrade_kill_set(
11114    my_pid: Option<u32>,
11115    found_daemon_pids: &[u32],
11116    owned_session_pids: &std::collections::HashSet<u32>,
11117) -> Vec<u32> {
11118    let mut k: Vec<u32> = Vec::new();
11119    if let Some(p) = my_pid {
11120        k.push(p);
11121    }
11122    for &p in found_daemon_pids {
11123        if !owned_session_pids.contains(&p) && Some(p) != my_pid {
11124            k.push(p); // true orphan — owned by no session
11125        }
11126    }
11127    k.sort_unstable();
11128    k.dedup();
11129    k
11130}
11131
11132/// One distinct `wire` binary discovered on `$PATH`, with enrichment used by
11133/// the `wire upgrade` PATH-shadowing diagnostic (issue #80).
11134///
11135/// "Distinct" = unique canonical path; symlink chains collapse to a single
11136/// entry at the FIRST PATH position that surfaced them. This is what
11137/// `which -a` would show modulo symlink dedup.
11138#[derive(Debug, Clone)]
11139struct PathWireBinary {
11140    /// PATH entry under which this binary was discovered (NOT canonicalized,
11141    /// so the operator sees the path they wrote in their shell config).
11142    path: std::path::PathBuf,
11143    /// Canonical filesystem path (symlinks resolved). Used for dedup so a
11144    /// symlink that points at the real binary doesn't show up as a second
11145    /// "distinct" entry.
11146    canonical: std::path::PathBuf,
11147    /// SHA-256 hex of the binary contents. `None` if unreadable (rare; would
11148    /// require a race or perms change after the existence check).
11149    sha256: Option<String>,
11150    /// Last-modified time of the binary. `None` if metadata unreadable.
11151    mtime: Option<std::time::SystemTime>,
11152    /// Zero-based PATH position (after dedup). `0` = the binary bare `wire`
11153    /// resolves to (the winner of PATH precedence).
11154    path_index: usize,
11155    /// True iff this is the binary currently executing the running `wire
11156    /// upgrade` process (i.e. `std::env::current_exe()` canonicalized matches).
11157    /// When this is NOT the `path_index == 0` entry, the operator just ran
11158    /// `wire upgrade` against a SHADOWED binary and bare `wire` will continue
11159    /// to use the active one — the central footgun #80 exists to catch.
11160    is_current_exe: bool,
11161}
11162
11163impl PathWireBinary {
11164    /// True iff bare `wire` resolves here (the PATH-precedence winner).
11165    fn is_active(&self) -> bool {
11166        self.path_index == 0
11167    }
11168    /// Short sha256 (first 8 hex chars) for compact display; `?` filler when
11169    /// the hash couldn't be computed.
11170    fn sha256_short(&self) -> String {
11171        self.sha256
11172            .as_deref()
11173            .map(|s| s[..s.len().min(8)].to_string())
11174            .unwrap_or_else(|| "????????".to_string())
11175    }
11176    /// Pretty mtime in UTC RFC3339 seconds; `?` when missing or unrepresentable.
11177    fn mtime_display(&self) -> String {
11178        let Some(ts) = self.mtime else {
11179            return "?".to_string();
11180        };
11181        let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
11182            Ok(d) => d.as_secs() as i64,
11183            Err(_) => return "?".to_string(),
11184        };
11185        time::OffsetDateTime::from_unix_timestamp(secs)
11186            .ok()
11187            .and_then(|dt| {
11188                dt.format(&time::format_description::well_known::Rfc3339)
11189                    .ok()
11190            })
11191            .unwrap_or_else(|| "?".to_string())
11192    }
11193}
11194
11195/// SHA-256 hex of a file's contents (streamed; safe for any size).
11196fn sha256_file(p: &std::path::Path) -> Result<String> {
11197    use sha2::{Digest, Sha256};
11198    let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
11199    let mut h = Sha256::new();
11200    std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
11201    Ok(hex::encode(h.finalize()))
11202}
11203
11204/// Walk `$PATH` left-to-right, find all distinct files named `wire` (plus
11205/// `wire.exe` on Windows), and return them in PATH order with sha256+mtime
11206/// enrichment. Issue #80.
11207///
11208/// Invariants:
11209/// - First entry (`path_index == 0`) is what bare `wire` resolves to.
11210/// - Symlink chains collapse: only the first PATH position surfaces; later
11211///   entries pointing at the same canonical file are dropped (NOT counted
11212///   as a "shadow").
11213/// - Best-effort: I/O errors degrade to `None` on per-binary fields,
11214///   never abort the whole walk.
11215/// - Empty / missing PATH → empty Vec (NOT an error; the caller is already
11216///   running, so SOMETHING resolved this binary, just not via PATH).
11217fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
11218    let path = std::env::var("PATH").unwrap_or_default();
11219    let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
11220        .ok()
11221        .and_then(|p| p.canonicalize().ok());
11222    enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
11223}
11224
11225/// Pure (testable) inner of [`enumerate_path_wire_binaries`]: takes the PATH
11226/// string and an optional already-canonicalized `current_exe` so tests don't
11227/// have to mutate process-wide environment (which would race with any other
11228/// test that reads PATH).
11229fn enumerate_path_wire_binaries_from(
11230    path: &str,
11231    current_exe_canon: Option<&std::path::Path>,
11232) -> Vec<PathWireBinary> {
11233    if path.is_empty() {
11234        return Vec::new();
11235    }
11236    // Unix splits PATH on ':', Windows on ';'. We don't use
11237    // `std::env::split_paths` because we want to be explicit and consistent
11238    // with the existing v0.6.8 detection that this helper replaces (which
11239    // used `.split(':')` unconditionally — a Unix-only bug; fixed here).
11240    let separator = if cfg!(windows) { ';' } else { ':' };
11241    let names: &[&str] = if cfg!(windows) {
11242        // Try .exe first — that's what CreateProcess resolves bare `wire` to
11243        // under PATHEXT. A plain `wire` script (e.g. msys) only wins if
11244        // there's no wire.exe in the same directory.
11245        &["wire.exe", "wire"]
11246    } else {
11247        &["wire"]
11248    };
11249
11250    let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
11251    let mut out: Vec<PathWireBinary> = Vec::new();
11252    for dir in path.split(separator) {
11253        if dir.is_empty() {
11254            continue;
11255        }
11256        for name in names {
11257            let candidate = std::path::PathBuf::from(dir).join(name);
11258            // `is_file()` (not `.exists()`) so a directory named `wire`
11259            // doesn't false-positive — `.exists()` returns true for dirs.
11260            if !candidate.is_file() {
11261                continue;
11262            }
11263            let canon = candidate
11264                .canonicalize()
11265                .unwrap_or_else(|_| candidate.clone());
11266            if !seen.insert(canon.clone()) {
11267                // An earlier PATH entry already surfaced this canonical file
11268                // (symlink chain). Don't double-count as a shadow.
11269                break;
11270            }
11271            let meta = std::fs::metadata(&canon).ok();
11272            let mtime = meta.as_ref().and_then(|m| m.modified().ok());
11273            let sha256 = sha256_file(&canon).ok();
11274            let is_current_exe = current_exe_canon
11275                .map(|c| c == canon.as_path())
11276                .unwrap_or(false);
11277            let path_index = out.len();
11278            out.push(PathWireBinary {
11279                path: candidate,
11280                canonical: canon,
11281                sha256,
11282                mtime,
11283                path_index,
11284                is_current_exe,
11285            });
11286            // One entry per PATH dir — don't surface both wire AND wire.exe
11287            // from the same directory.
11288            break;
11289        }
11290    }
11291    out
11292}
11293
11294/// Render a multi-line WARN message for the PATH-shadow case, or `None` if
11295/// there's nothing to warn about. Issue #80.
11296///
11297/// Triggers (any one fires the warning):
11298/// - `>= 2 distinct wire binaries` on PATH (classic shadow case).
11299/// - Exactly 1 binary on PATH AND that binary isn't the one currently
11300///   running this `wire upgrade` (operator ran an off-PATH binary; bare
11301///   `wire` would resolve to a DIFFERENT binary that this upgrade just
11302///   bypassed).
11303/// - `0 binaries` on PATH at all (this `wire upgrade` ran via an absolute
11304///   path; bare `wire` would fail in any future shell).
11305fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
11306    let any_current = bins.iter().any(|b| b.is_current_exe);
11307    let multi = bins.len() >= 2;
11308    let off_path = !bins.is_empty() && !any_current;
11309    let none_on_path = bins.is_empty();
11310    if !multi && !off_path && !none_on_path {
11311        return None;
11312    }
11313    let mut out = String::new();
11314    if multi {
11315        out.push_str(&format!(
11316            "WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
11317            bins.len()
11318        ));
11319        for b in bins {
11320            let mut tags: Vec<&str> = Vec::new();
11321            if b.is_active() {
11322                tags.push("ACTIVE (bare `wire` resolves here)");
11323            }
11324            if b.is_current_exe {
11325                tags.push("THIS upgrade ran against this binary");
11326            }
11327            let tag_str = if tags.is_empty() {
11328                String::new()
11329            } else {
11330                format!("  ← {}", tags.join("; "))
11331            };
11332            out.push_str(&format!(
11333                "  [{}] {}  (sha256:{}  mtime:{}){}\n",
11334                b.path_index,
11335                b.path.display(),
11336                b.sha256_short(),
11337                b.mtime_display(),
11338                tag_str,
11339            ));
11340        }
11341        if !any_current {
11342            out.push_str(
11343                "  NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
11344            );
11345            out.push_str(
11346                "        Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
11347            );
11348        } else if !bins[0].is_current_exe {
11349            out.push_str(
11350                "  Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
11351            );
11352            out.push_str(
11353                "  ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
11354            );
11355            out.push_str(&format!(
11356                "    - rm {}  (or symlink it to the upgraded binary)\n",
11357                bins[0].path.display(),
11358            ));
11359            out.push_str(
11360                "    - or reorder PATH so the upgraded binary's directory precedes the active one\n",
11361            );
11362            out.push_str("  Verify with: which -a wire\n");
11363        }
11364    } else if off_path {
11365        // Single PATH binary, but THIS upgrade ran against a different file.
11366        let active = &bins[0];
11367        out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
11368        out.push_str(&format!(
11369            "      bare `wire` resolves to {} (sha256:{}),\n",
11370            active.path.display(),
11371            active.sha256_short(),
11372        ));
11373        out.push_str(
11374            "      which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
11375        );
11376        out.push_str("      will continue to invoke the old binary.\n");
11377    } else if none_on_path {
11378        out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
11379        out.push_str("      This upgrade ran against an absolute-path invocation only.\n");
11380    }
11381    Some(out.trim_end().to_string())
11382}
11383
11384#[cfg(test)]
11385mod upgrade_tests {
11386    use super::*;
11387    use std::collections::HashSet;
11388
11389    #[test]
11390    fn upgrade_kill_set_is_session_scoped() {
11391        // owned: my daemon 100, sibling session daemon 200.
11392        let owned: HashSet<u32> = [100, 200].into_iter().collect();
11393        // found by the process scan: mine (100), sibling (200), a true orphan (999).
11394        let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
11395        assert!(k.contains(&100), "must kill my own daemon (to replace it)");
11396        assert!(k.contains(&999), "must sweep a true orphan");
11397        assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
11398
11399        // CRITICAL: even when the process scan returns EMPTY (Windows CIM can't
11400        // match the quoted command line), my own daemon is still killed via its
11401        // pidfile pid — this is the B-accumulation fix.
11402        assert_eq!(
11403            upgrade_kill_set(Some(100), &[], &owned),
11404            vec![100],
11405            "own daemon killed even when the process scan is empty"
11406        );
11407
11408        // Uninitialized session (no own daemon): only true orphans.
11409        assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
11410    }
11411
11412    // ----- issue #80: PATH-shadow detection -----
11413    //
11414    // We test the pure inner `enumerate_path_wire_binaries_from(path, cur)`
11415    // so we never mutate the process-wide PATH — that would race with any
11416    // other test in the binary that reads PATH (e.g. `process_alive_self`
11417    // resolving the test binary via PATH).
11418
11419    fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
11420        use std::io::Write;
11421        let p = dir.join("wire");
11422        let mut f = std::fs::File::create(&p).expect("create fake wire");
11423        f.write_all(body).expect("write fake wire");
11424        drop(f);
11425        #[cfg(unix)]
11426        {
11427            use std::os::unix::fs::PermissionsExt;
11428            let mut perm = std::fs::metadata(&p).unwrap().permissions();
11429            perm.set_mode(0o755);
11430            std::fs::set_permissions(&p, perm).unwrap();
11431        }
11432        p
11433    }
11434
11435    #[test]
11436    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11437    fn enumerate_finds_no_binaries_when_path_empty() {
11438        let bins = enumerate_path_wire_binaries_from("", None);
11439        assert!(
11440            bins.is_empty(),
11441            "empty PATH yields no binaries, got {bins:?}"
11442        );
11443    }
11444
11445    #[test]
11446    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11447    fn enumerate_detects_two_distinct_binaries_in_path_order() {
11448        let d1 = tempfile::tempdir().unwrap();
11449        let d2 = tempfile::tempdir().unwrap();
11450        let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
11451        let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
11452        let path = format!("{}:{}", d1.path().display(), d2.path().display());
11453
11454        let bins = enumerate_path_wire_binaries_from(&path, None);
11455        assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
11456        assert_eq!(bins[0].path_index, 0);
11457        assert_eq!(bins[1].path_index, 1);
11458        assert!(bins[0].is_active(), "first PATH entry is active");
11459        assert!(!bins[1].is_active(), "second PATH entry is not active");
11460        // sha256 differs because contents differ.
11461        assert_ne!(
11462            bins[0].sha256, bins[1].sha256,
11463            "distinct contents must hash differently"
11464        );
11465        // path field is the un-canonicalized PATH-relative shape.
11466        assert_eq!(bins[0].path, p1);
11467        assert_eq!(bins[1].path, p2);
11468    }
11469
11470    #[test]
11471    #[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
11472    fn enumerate_collapses_symlink_chains_to_one_entry() {
11473        let real_dir = tempfile::tempdir().unwrap();
11474        let link_dir = tempfile::tempdir().unwrap();
11475        let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
11476        let link = link_dir.path().join("wire");
11477        #[cfg(unix)]
11478        std::os::unix::fs::symlink(&real, &link).unwrap();
11479
11480        // Put the SYMLINK first in PATH; the real binary second. Both
11481        // resolve to the same canonical file — should collapse to ONE entry
11482        // at the first PATH position.
11483        let path = format!(
11484            "{}:{}",
11485            link_dir.path().display(),
11486            real_dir.path().display()
11487        );
11488        let bins = enumerate_path_wire_binaries_from(&path, None);
11489        assert_eq!(
11490            bins.len(),
11491            1,
11492            "symlink chain must collapse to a single entry: {bins:?}"
11493        );
11494        assert!(bins[0].is_active());
11495        // path is the symlink (what the operator wrote), canonical is the real file.
11496        assert_eq!(bins[0].path, link);
11497        assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
11498    }
11499
11500    #[test]
11501    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11502    fn shadow_warning_off_path_when_current_exe_not_on_path() {
11503        // One binary on PATH, but current_exe points somewhere else.
11504        // The off-PATH branch fires.
11505        let d = tempfile::tempdir().unwrap();
11506        write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
11507        let elsewhere = tempfile::tempdir().unwrap();
11508        let cur = elsewhere.path().join("not-on-path-wire");
11509        let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
11510        assert_eq!(bins.len(), 1);
11511        assert!(!bins[0].is_current_exe);
11512        let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
11513        assert!(
11514            warn.contains("off-PATH binary"),
11515            "off-path WARN must mention off-PATH; got: {warn}"
11516        );
11517    }
11518
11519    #[test]
11520    fn shadow_warning_fires_when_no_binaries_at_all() {
11521        let bins: Vec<PathWireBinary> = Vec::new();
11522        let warn = path_shadow_warning(&bins).expect("empty must warn");
11523        assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
11524    }
11525
11526    #[test]
11527    #[cfg_attr(windows, ignore = "PATH separator differs")]
11528    fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
11529        let d1 = tempfile::tempdir().unwrap();
11530        let d2 = tempfile::tempdir().unwrap();
11531        write_fake_wire(d1.path(), b"published\n");
11532        write_fake_wire(d2.path(), b"head\n");
11533        let path = format!("{}:{}", d1.path().display(), d2.path().display());
11534        let bins = enumerate_path_wire_binaries_from(&path, None);
11535        let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
11536        assert!(warn.contains("2 distinct"), "got: {warn}");
11537        assert!(warn.contains("ACTIVE"), "must mark the active binary");
11538        assert!(
11539            warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
11540            "must guide the operator to a fix; got: {warn}"
11541        );
11542    }
11543}
11544
11545fn cmd_upgrade(check_only: bool, local: bool, as_json: bool) -> Result<()> {
11546    // 0. (v0.13.3 — merged `update`) ALWAYS check crates.io first and, unless
11547    // this is a --check or --local run, self-install a newer release BEFORE the
11548    // daemon swap below — the respawn then picks up the new on-disk binary. A
11549    // crates.io/network failure must NOT block the restart, so it degrades to a
11550    // warning. `--local` skips it entirely (offline / local dev build).
11551    let update: Option<UpdateOutcome> = if local {
11552        None
11553    } else {
11554        match self_update_step(!check_only) {
11555            Ok(o) => Some(o),
11556            Err(e) => {
11557                if !check_only {
11558                    eprintln!("wire upgrade: update check skipped — {e:#}");
11559                }
11560                None
11561            }
11562        }
11563    };
11564    if let Some(o) = &update
11565        && o.installed
11566    {
11567        eprintln!(
11568            "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
11569            o.latest,
11570            o.current,
11571            o.via.unwrap_or("self-update")
11572        );
11573    }
11574
11575    // 1. Identify all running wire processes. v0.7.3: walks `pgrep -f`
11576    // on unix / `Get-CimInstance Win32_Process` on Windows via the
11577    // shared `platform::find_processes_by_cmdline`. Covers both the
11578    // long-lived sync `wire daemon` *and* the `wire relay-server`
11579    // local-only loopback — the pre-v0.7.3 upgrade only swept daemons
11580    // and left stale relay-server children pinned on the old binary,
11581    // forcing operators to `pkill -f relay-server` manually after
11582    // every version bump.
11583    let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
11584    let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
11585    let running_pids: Vec<u32> = daemon_pids
11586        .iter()
11587        .chain(relay_pids.iter())
11588        .copied()
11589        .collect();
11590
11591    // 2. Read pidfile to surface what the daemon THINKS it is.
11592    let record = crate::ensure_up::read_pid_record("daemon");
11593    let recorded_version: Option<String> = match &record {
11594        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
11595        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
11596        _ => None,
11597    };
11598    let cli_version = env!("CARGO_PKG_VERSION").to_string();
11599
11600    // 2b. v0.13.2 (B fix — session-scoped upgrade). `wire upgrade` now
11601    // refreshes THIS session's daemon, not the whole box. The old box-wide
11602    // design (kill every `wire daemon` process, wipe every session's pidfile,
11603    // respawn every session) was wrong for a multi-session / shared-relay box
11604    // AND broke on Windows: the CIM scan can't match the quoted
11605    // `"...\wire.exe" daemon` command line (no contiguous `wire daemon`), so it
11606    // found nothing to kill, then the respawn loop ACCUMULATED daemons
11607    // (glossy-magnolia: 2->5->8->11). The kill set is now:
11608    //   (a) THIS session's own daemon, via its pidfile pid — reliable and
11609    //       CIM-independent; plus
11610    //   (b) TRUE orphans: `wire daemon` pids owned by NO session.
11611    // It SPARES sibling sessions' daemons AND the shared loopback relay-server
11612    // (killing it would break every same-box session's routing).
11613    let my_daemon_pid = record.pid();
11614    let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
11615        .unwrap_or_default()
11616        .iter()
11617        .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
11618        .collect();
11619    let kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
11620    // relay_pids are intentionally NOT killed — the local relay is shared.
11621
11622    if check_only {
11623        // v0.6.8: also surface session-level state + PATH dupes in --check.
11624        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
11625            .unwrap_or_default()
11626            .iter()
11627            .filter(|s| s.daemon_running)
11628            .map(|s| s.name.clone())
11629            .collect();
11630        let path_bins = enumerate_path_wire_binaries();
11631        let path_dupes: Vec<String> = path_bins
11632            .iter()
11633            .map(|b| b.canonical.to_string_lossy().into_owned())
11634            .collect();
11635        let path_binaries_detail: Vec<serde_json::Value> = path_bins
11636            .iter()
11637            .map(|b| {
11638                json!({
11639                    "path": b.path.to_string_lossy(),
11640                    "canonical": b.canonical.to_string_lossy(),
11641                    "sha256": b.sha256,
11642                    "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
11643                    "path_index": b.path_index,
11644                    "is_active": b.is_active(),
11645                    "is_current_exe": b.is_current_exe,
11646                })
11647            })
11648            .collect();
11649        let path_warning_check = path_shadow_warning(&path_bins);
11650        // v0.7.3: enumerate which service units WOULD be refreshed.
11651        // Read-only — `status_kind` doesn't touch anything.
11652        let installed_service_kinds: Vec<&'static str> = [
11653            (crate::service::ServiceKind::Daemon, "daemon"),
11654            (crate::service::ServiceKind::LocalRelay, "local-relay"),
11655        ]
11656        .into_iter()
11657        .filter_map(|(k, label)| {
11658            crate::service::status_kind(k)
11659                .ok()
11660                .filter(|r| r.status != "absent")
11661                .map(|_| label)
11662        })
11663        .collect();
11664        let (update_latest, update_available) = match &update {
11665            Some(o) => (Some(o.latest.clone()), o.available),
11666            None => (None, false),
11667        };
11668        let report = json!({
11669            "running_pids": running_pids,
11670            "running_daemons": daemon_pids,
11671            "running_relay_servers": relay_pids,
11672            "pidfile_version": recorded_version,
11673            "cli_version": cli_version,
11674            "latest_published": update_latest,
11675            "update_available": update_available,
11676            "would_kill": kill_set,
11677            "would_refresh_services": installed_service_kinds,
11678            "session_daemons_running": sessions_with_daemons,
11679            "path_binaries": path_dupes,
11680            "path_binaries_detail": path_binaries_detail,
11681            "path_duplicate_warning": path_dupes.len() > 1,
11682            "path_warning": path_warning_check,
11683        });
11684        if as_json {
11685            println!("{}", serde_json::to_string(&report)?);
11686        } else {
11687            println!("wire upgrade --check");
11688            println!("  cli version:      {cli_version}");
11689            match (&update_latest, update_available) {
11690                (Some(l), true) => println!("  latest published: {l}  (UPDATE AVAILABLE)"),
11691                (Some(l), false) => println!("  latest published: {l}  (up to date)"),
11692                (None, _) => println!("  latest published: (crates.io check skipped)"),
11693            }
11694            println!(
11695                "  pidfile version:  {}",
11696                recorded_version.as_deref().unwrap_or("(missing)")
11697            );
11698            if running_pids.is_empty() {
11699                println!("  running daemons:  none");
11700                println!("  running relays:   none");
11701            } else {
11702                if daemon_pids.is_empty() {
11703                    println!("  running daemons:  none");
11704                } else {
11705                    let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
11706                    println!("  running daemons:  pids {}", p.join(", "));
11707                }
11708                if relay_pids.is_empty() {
11709                    println!("  running relays:   none");
11710                } else {
11711                    let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
11712                    println!("  running relays:   pids {}", p.join(", "));
11713                }
11714                println!("  would kill all + spawn fresh");
11715            }
11716            if !installed_service_kinds.is_empty() {
11717                println!(
11718                    "  would refresh:    {} installed service unit(s) → new binary path",
11719                    installed_service_kinds.join(", ")
11720                );
11721            }
11722            if !sessions_with_daemons.is_empty() {
11723                println!(
11724                    "  session daemons:  {} (would respawn under new binary)",
11725                    sessions_with_daemons.join(", ")
11726                );
11727            }
11728            if let Some(w) = &path_warning_check {
11729                println!("  PATH check:");
11730                for line in w.lines() {
11731                    println!("    {line}");
11732                }
11733            }
11734        }
11735        return Ok(());
11736    }
11737
11738    // 3. Terminate the kill set. Graceful first, then FORCE-kill any survivor.
11739    //
11740    // v0.13.2 (B fix #2): the force-kill must NOT be gated on graceful having
11741    // "succeeded". On Windows, `taskkill /PID /T` WITHOUT `/F` is a no-op for a
11742    // windowless daemon (it returns failure), so the rc9 logic — which only
11743    // force-killed pids that graceful had reported killing — force-killed
11744    // NOTHING, and the daemon survived every `wire upgrade` (glossy: pidfile
11745    // pids 3676/25236/24660 all survived → accumulation). Now we attempt
11746    // graceful best-effort, grace-wait, then force-kill EVERY pid still alive
11747    // regardless of the graceful result. Force-kill (`taskkill /F /T` /
11748    // SIGKILL) is the load-bearing step.
11749    for pid in &kill_set {
11750        let _ = crate::platform::kill_process(*pid, false); // best-effort graceful
11751    }
11752    if !kill_set.is_empty() {
11753        // Brief grace for platforms where graceful works (Unix SIGTERM).
11754        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
11755        while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
11756        {
11757            std::thread::sleep(std::time::Duration::from_millis(50));
11758        }
11759        // Force-kill every survivor — this is what actually kills the
11760        // windowless daemon on Windows.
11761        for pid in &kill_set {
11762            if process_alive_pid(*pid) {
11763                let _ = crate::platform::kill_process(*pid, true);
11764            }
11765        }
11766        std::thread::sleep(std::time::Duration::from_millis(200)); // settle
11767    }
11768    // Report what's actually gone (drives the "no stale" message + JSON).
11769    let killed: Vec<u32> = kill_set
11770        .iter()
11771        .copied()
11772        .filter(|p| !process_alive_pid(*p))
11773        .collect();
11774
11775    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
11776    //    old daemon is still owning it.
11777    let pidfile = config::state_dir()?.join("daemon.pid");
11778    if pidfile.exists() {
11779        let _ = std::fs::remove_file(&pidfile);
11780    }
11781
11782    // 4b. v0.13.2: session-scoped — only THIS session's pidfile is wiped
11783    // (already removed at step 4 above). We deliberately DO NOT touch sibling
11784    // sessions' pidfiles: their daemons were spared, so wiping their pidfiles
11785    // would make them look down and the old box-wide respawn would spawn
11786    // duplicates (the accumulation bug). Each sibling refreshes itself on its
11787    // own `wire upgrade`.
11788
11789    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
11790    // multiple distinct files on $PATH, surface the conflict — operators
11791    // get bitten when an old binary at /usr/local/bin shadows a fresh
11792    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
11793    let path_bins = enumerate_path_wire_binaries();
11794    let path_dupes: Vec<String> = path_bins
11795        .iter()
11796        .map(|b| b.canonical.to_string_lossy().into_owned())
11797        .collect();
11798    let path_binaries_detail: Vec<Value> = path_bins
11799        .iter()
11800        .map(|b| {
11801            json!({
11802                "path": b.path.to_string_lossy(),
11803                "canonical": b.canonical.to_string_lossy(),
11804                "sha256": b.sha256,
11805                "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
11806                "path_index": b.path_index,
11807                "is_active": b.is_active(),
11808                "is_current_exe": b.is_current_exe,
11809            })
11810        })
11811        .collect();
11812    let path_warning = path_shadow_warning(&path_bins);
11813
11814    // 4d. v0.7.3 NEW: refresh installed service units so they point at
11815    // the freshly-installed binary path. Without this step, an upgrade
11816    // would: kill the old daemon, leave the launchd plist /
11817    // systemd unit / Windows scheduled task pointing at the OLD
11818    // binary path (or, worse, an old binary location that's been
11819    // unlinked), and then the OS's auto-respawn would either fail or
11820    // bring the OLD binary back from the dead. Reinstalling rewrites
11821    // the unit with `std::env::current_exe()` (the freshly-resolved
11822    // path of the running upgrade-driver process) and re-bootstraps /
11823    // re-enables / re-registers so the next OS-driven start uses it.
11824    //
11825    // Only refreshes units that are already installed — does NOT
11826    // install services the operator never opted into.
11827    let mut service_refreshes: Vec<Value> = Vec::new();
11828    for kind in [
11829        crate::service::ServiceKind::Daemon,
11830        crate::service::ServiceKind::LocalRelay,
11831    ] {
11832        let already_installed = crate::service::status_kind(kind)
11833            .map(|r| r.status != "absent")
11834            .unwrap_or(false);
11835        if !already_installed {
11836            continue;
11837        }
11838        match crate::service::install_kind(kind) {
11839            Ok(rep) => service_refreshes.push(json!({
11840                "kind": rep.kind,
11841                "platform": rep.platform,
11842                "status": rep.status,
11843                "unit_path": rep.unit_path,
11844                "action": "refreshed",
11845            })),
11846            Err(e) => service_refreshes.push(json!({
11847                "kind": format!("{kind:?}"),
11848                "action": "refresh_failed",
11849                "error": format!("{e:#}"),
11850            })),
11851        }
11852    }
11853
11854    // 5. Spawn fresh daemon via ensure_up — atomically waits for
11855    //    process_alive + writes the versioned pidfile. (If the Daemon
11856    //    service was refreshed above, it has already started a fresh
11857    //    process under the new binary; ensure_daemon_running notices
11858    //    and short-circuits to "already running".)
11859    let spawned = crate::ensure_up::ensure_daemon_running()?;
11860
11861    // 5b. v0.13.2: session-scoped — no sibling respawn. `ensure_daemon_running`
11862    // above already respawned THIS session's daemon; sibling sessions were
11863    // spared (never killed), so there is nothing to respawn for them. Each
11864    // refreshes itself on its own `wire upgrade`.
11865    let session_respawns: Vec<Value> = Vec::new();
11866
11867    let new_record = crate::ensure_up::read_pid_record("daemon");
11868    let new_pid = new_record.pid();
11869    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
11870        Some(d.version.clone())
11871    } else {
11872        None
11873    };
11874
11875    if as_json {
11876        println!(
11877            "{}",
11878            serde_json::to_string(&json!({
11879                "killed": killed,
11880                "found_daemons": daemon_pids,
11881                "spared_relay_servers": relay_pids,
11882                "service_refreshes": service_refreshes,
11883                "spawned_fresh_daemon": spawned,
11884                "new_pid": new_pid,
11885                "new_version": new_version,
11886                "cli_version": cli_version,
11887                "session_respawns": session_respawns,
11888                "path_binaries": path_dupes,
11889                "path_binaries_detail": path_binaries_detail,
11890                "path_warning": path_warning,
11891            }))?
11892        );
11893    } else {
11894        if killed.is_empty() {
11895            println!("wire upgrade: no stale wire processes running");
11896        } else {
11897            let killed_list = killed
11898                .iter()
11899                .map(|p| p.to_string())
11900                .collect::<Vec<_>>()
11901                .join(", ");
11902            // Session-scoped: report what was actually killed, and that the
11903            // shared relay-server was SPARED (not killed) — the old wording
11904            // lumped the spared relay into the killed count and read like it
11905            // had been terminated (glossy-magnolia nit).
11906            if relay_pids.is_empty() {
11907                println!(
11908                    "wire upgrade: killed {} daemon(s) [{killed_list}]",
11909                    killed.len()
11910                );
11911            } else {
11912                let relay_list = relay_pids
11913                    .iter()
11914                    .map(|p| p.to_string())
11915                    .collect::<Vec<_>>()
11916                    .join(", ");
11917                println!(
11918                    "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
11919                    killed.len(),
11920                    relay_pids.len()
11921                );
11922            }
11923        }
11924        if !service_refreshes.is_empty() {
11925            println!(
11926                "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
11927                service_refreshes.len()
11928            );
11929            for r in &service_refreshes {
11930                let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
11931                let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
11932                let status = r.get("status").and_then(Value::as_str).unwrap_or("");
11933                let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
11934                if action == "refreshed" {
11935                    println!("                    - {kind}: {action} ({status}, {platform})");
11936                } else {
11937                    let err = r.get("error").and_then(Value::as_str).unwrap_or("");
11938                    println!("                    - {kind}: {action} ({err})");
11939                }
11940            }
11941        }
11942        if spawned {
11943            println!(
11944                "wire upgrade: spawned fresh daemon (pid {} v{})",
11945                new_pid
11946                    .map(|p| p.to_string())
11947                    .unwrap_or_else(|| "?".to_string()),
11948                new_version.as_deref().unwrap_or(&cli_version),
11949            );
11950        } else {
11951            println!("wire upgrade: daemon was already running on current binary");
11952        }
11953        if !session_respawns.is_empty() {
11954            println!(
11955                "wire upgrade: refreshed {} session daemon(s):",
11956                session_respawns.len()
11957            );
11958            for r in &session_respawns {
11959                let h = r["session_home"].as_str().unwrap_or("?");
11960                let s = r["status"].as_str().unwrap_or("?");
11961                let label = std::path::Path::new(h)
11962                    .file_name()
11963                    .map(|f| f.to_string_lossy().into_owned())
11964                    .unwrap_or_else(|| h.to_string());
11965                println!("  {label:<24} {s}");
11966            }
11967        }
11968        if let Some(msg) = &path_warning {
11969            eprintln!("wire upgrade: {msg}");
11970        }
11971    }
11972    Ok(())
11973}
11974
11975/// v0.9.1: should this command emit JSON by default?
11976///
11977/// - `explicit=true` → operator passed `--json`, always JSON.
11978/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
11979///   captured output parses cleanly without operators remembering to
11980///   append `--json`. Mirrors `gh`, `kubectl`, etc.
11981/// - interactive TTY → human format (false).
11982/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
11983///   that parsed the human text by accident).
11984fn json_default(explicit: bool) -> bool {
11985    if explicit {
11986        return true;
11987    }
11988    if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
11989        return false;
11990    }
11991    use std::io::IsTerminal;
11992    !std::io::stdout().is_terminal()
11993}
11994
11995fn process_alive_pid(pid: u32) -> bool {
11996    // v0.7.3: delegate to the cross-platform helper. See
11997    // `platform::process_alive` for the per-OS dispatch — Windows now
11998    // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
11999    // gave a hard-coded false on Windows pre-v0.7.3.
12000    crate::platform::process_alive(pid)
12001}
12002
12003// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
12004
12005/// Iterative Levenshtein distance between two strings, case-insensitive.
12006/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
12007/// resolves against (typically <30 chars).
12008fn levenshtein_ci(a: &str, b: &str) -> usize {
12009    let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
12010    let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
12011    let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
12012    let (m, n) = (a.len(), b.len());
12013    if m == 0 {
12014        return n;
12015    }
12016    let mut prev: Vec<usize> = (0..=m).collect();
12017    let mut curr = vec![0usize; m + 1];
12018    for j in 1..=n {
12019        curr[0] = j;
12020        for i in 1..=m {
12021            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
12022            curr[i] = std::cmp::min(
12023                std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
12024                prev[i - 1] + cost,
12025            );
12026        }
12027        std::mem::swap(&mut prev, &mut curr);
12028    }
12029    prev[m]
12030}
12031
12032/// Return up to `max_results` names from `pool` whose edit distance to
12033/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
12034/// "did you mean" suggestions on resolution miss.
12035pub fn closest_candidates(
12036    needle: &str,
12037    pool: &[String],
12038    max_distance: usize,
12039    max_results: usize,
12040) -> Vec<String> {
12041    let mut scored: Vec<(usize, &String)> = pool
12042        .iter()
12043        .map(|c| (levenshtein_ci(needle, c), c))
12044        .filter(|(d, _)| *d <= max_distance)
12045        .collect();
12046    scored.sort_by_key(|(d, _)| *d);
12047    scored
12048        .into_iter()
12049        .take(max_results)
12050        .map(|(_, c)| c.clone())
12051        .collect()
12052}
12053
12054/// Collect every name that `resolve_name_to_target` would currently
12055/// match: pinned-peer handles, pinned-peer character nicknames, sister
12056/// session names, sister character nicknames, sister handles. Used for
12057/// the `did_you_mean` pool on resolution miss.
12058fn known_local_names() -> Vec<String> {
12059    let mut names: Vec<String> = Vec::new();
12060    if let Ok(trust) = config::read_trust() {
12061        // (debug eprintln removed; left bug-trail in commit message)
12062        // trust.agents is an object keyed by handle, NOT an array —
12063        // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
12064        // the object's keys (which ARE the handles) plus each entry's
12065        // did for the DID-derived character nickname.
12066        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
12067            for (handle, agent) in agents {
12068                names.push(handle.clone());
12069                if let Some(did) = agent.get("did").and_then(Value::as_str) {
12070                    let ch = crate::character::Character::from_did(did);
12071                    names.push(ch.nickname);
12072                }
12073            }
12074        }
12075    }
12076    if let Ok(sessions) = crate::session::list_sessions() {
12077        for s in sessions {
12078            names.push(s.name.clone());
12079            if let Some(h) = &s.handle {
12080                names.push(h.clone());
12081            }
12082            if let Some(ch) = &s.character {
12083                names.push(ch.nickname.clone());
12084            }
12085        }
12086    }
12087    names.sort();
12088    names.dedup();
12089    names
12090}
12091
12092/// v0.9.2 deprecation banner with two ergonomic guards:
12093/// 1. Suppress in JSON mode (the caller is expected to fold the
12094///    deprecation note into its JSON output instead).
12095/// 2. Cache once-per-shell-session via a marker env var; subsequent
12096///    invocations in the same shell stay silent.
12097///
12098/// `verb` is the legacy verb name, `replacement` is the canonical one.
12099fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
12100    if json_mode {
12101        return;
12102    }
12103    // Pull a marker from environment of THIS process. Persistent across
12104    // multiple wire invocations only when the shell sets and exports
12105    // WIRE_DEPRECATION_NAGGED — operators rarely do, so practically
12106    // this nags once per `wire foo` invocation. The single-process
12107    // dedup matters most for scripts that call multiple deprecated
12108    // verbs in one wire run, which is currently impossible (one verb
12109    // per process) but documented for future loop-style wire shells.
12110    let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
12111    if std::env::var(&key).is_ok() {
12112        return;
12113    }
12114    // SAFETY: deprecation_warn is called from sync dispatcher code paths
12115    // before any worker thread spawns; env::set_var in Rust 2024 is
12116    // safe at that point. Pattern matches maybe_adopt_session_wire_home.
12117    unsafe {
12118        std::env::set_var(&key, "1");
12119    }
12120    eprintln!(
12121        "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
12122         Will be removed in v1.0 (target 2026-Q3). \
12123         Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
12124        verb.replace('-', "_")
12125    );
12126}
12127
12128// ---------- doctor (single-command diagnostic) ----------
12129
12130/// One DoctorCheck = one verdict on one health dimension.
12131#[derive(Clone, Debug, serde::Serialize)]
12132pub struct DoctorCheck {
12133    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
12134    /// Stable across versions for tooling consumption.
12135    pub id: String,
12136    /// PASS / WARN / FAIL.
12137    pub status: String,
12138    /// One-line human summary.
12139    pub detail: String,
12140    /// Optional remediation hint shown after the failing line.
12141    #[serde(skip_serializing_if = "Option::is_none")]
12142    pub fix: Option<String>,
12143}
12144
12145impl DoctorCheck {
12146    fn pass(id: &str, detail: impl Into<String>) -> Self {
12147        Self {
12148            id: id.into(),
12149            status: "PASS".into(),
12150            detail: detail.into(),
12151            fix: None,
12152        }
12153    }
12154    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12155        Self {
12156            id: id.into(),
12157            status: "WARN".into(),
12158            detail: detail.into(),
12159            fix: Some(fix.into()),
12160        }
12161    }
12162    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12163        Self {
12164            id: id.into(),
12165            status: "FAIL".into(),
12166            detail: detail.into(),
12167            fix: Some(fix.into()),
12168        }
12169    }
12170}
12171
12172/// `wire doctor` — single-command diagnostic for the silent-fail classes
12173/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
12174/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
12175/// so operators don't have to know where each lives.
12176fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
12177    let checks: Vec<DoctorCheck> = vec![
12178        check_daemon_health(),
12179        check_daemon_pid_consistency(),
12180        check_relay_reachable(),
12181        check_pair_rejections(recent_rejections),
12182        check_cursor_progress(),
12183        check_peer_staleness(7),
12184        check_and_heal_self_userinfo_endpoints(),
12185    ];
12186
12187    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
12188    let warns = checks.iter().filter(|c| c.status == "WARN").count();
12189
12190    if as_json {
12191        println!(
12192            "{}",
12193            serde_json::to_string(&json!({
12194                "checks": checks,
12195                "fail_count": fails,
12196                "warn_count": warns,
12197                "ok": fails == 0,
12198            }))?
12199        );
12200    } else {
12201        println!("wire doctor — {} checks", checks.len());
12202        for c in &checks {
12203            let bullet = match c.status.as_str() {
12204                "PASS" => "✓",
12205                "WARN" => "!",
12206                "FAIL" => "✗",
12207                _ => "?",
12208            };
12209            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
12210            if let Some(fix) = &c.fix {
12211                println!("      fix: {fix}");
12212            }
12213        }
12214        println!();
12215        if fails == 0 && warns == 0 {
12216            println!("ALL GREEN");
12217        } else {
12218            println!("{fails} FAIL, {warns} WARN");
12219        }
12220    }
12221
12222    if fails > 0 {
12223        std::process::exit(1);
12224    }
12225    Ok(())
12226}
12227
12228/// Check: daemon running, exactly one instance, no orphans.
12229///
12230/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
12231/// days, advancing cursor without pinning). `wire status` lied about it.
12232/// `wire doctor` must catch THIS class: multiple daemons running, OR
12233/// pid-file claims daemon down while a process is actually up.
12234fn check_daemon_health() -> DoctorCheck {
12235    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
12236    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
12237    // hardening): every surface routes through ensure_up::daemon_liveness
12238    // so they share one view of the world. No more parallel liveness
12239    // logic to drift out of sync.
12240    let snap = crate::ensure_up::daemon_liveness();
12241    let pgrep_pids = &snap.pgrep_pids;
12242    let pidfile_pid = snap.pidfile_pid;
12243    let pidfile_alive = snap.pidfile_alive;
12244    let orphan_pids = &snap.orphan_pids;
12245
12246    let fmt_pids = |xs: &[u32]| -> String {
12247        xs.iter()
12248            .map(|p| p.to_string())
12249            .collect::<Vec<_>>()
12250            .join(", ")
12251    };
12252
12253    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
12254        (0, _, _) => DoctorCheck::fail(
12255            "daemon",
12256            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
12257            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
12258        ),
12259        // Single daemon AND it matches the pidfile → healthy.
12260        (1, true, true) => DoctorCheck::pass(
12261            "daemon",
12262            format!(
12263                "one daemon running (pid {}, matches pidfile)",
12264                pgrep_pids[0]
12265            ),
12266        ),
12267        // Pidfile is alive but pgrep ALSO sees orphan processes.
12268        (n, true, false) => DoctorCheck::fail(
12269            "daemon",
12270            format!(
12271                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
12272                 The orphans race the relay cursor — they advance past events your current binary can't process. \
12273                 (Issue #2 exact class.)",
12274                fmt_pids(pgrep_pids),
12275                pidfile_pid.unwrap(),
12276                fmt_pids(orphan_pids),
12277            ),
12278            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
12279        ),
12280        // Pidfile is dead but processes ARE running → all are orphans.
12281        (n, false, _) => DoctorCheck::fail(
12282            "daemon",
12283            format!(
12284                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
12285                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
12286                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
12287                fmt_pids(pgrep_pids),
12288                match pidfile_pid {
12289                    Some(p) => format!("claims pid {p} which is dead"),
12290                    None => "is missing".to_string(),
12291                },
12292            ),
12293            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
12294        ),
12295        // Multiple daemons all matching … impossible by construction; fall back to warn.
12296        (n, true, true) => DoctorCheck::warn(
12297            "daemon",
12298            format!(
12299                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
12300                fmt_pids(pgrep_pids)
12301            ),
12302            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
12303        ),
12304    }
12305}
12306
12307/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
12308/// check. Surfaces version mismatch (daemon running old binary text in
12309/// memory under a current symlink — today's exact bug class), schema
12310/// drift (future format bumps), and identity contamination (daemon's
12311/// recorded DID doesn't match this box's configured DID).
12312///
12313/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
12314/// JSON pid record whose recorded `pid` is no longer a live OS process.
12315/// Pre-hardening this check PASSed in that state (it only validated
12316/// content, not liveness), letting `wire status: DOWN` and
12317/// `wire doctor: PASS` disagree for 25 min in incident #2.
12318fn check_daemon_pid_consistency() -> DoctorCheck {
12319    let snap = crate::ensure_up::daemon_liveness();
12320    match &snap.record {
12321        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
12322            "daemon_pid_consistency",
12323            "no daemon.pid yet — fresh box or daemon never started",
12324        ),
12325        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
12326            "daemon_pid_consistency",
12327            format!("daemon.pid is corrupt: {reason}"),
12328            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
12329        ),
12330        crate::ensure_up::PidRecord::LegacyInt(pid) => {
12331            // Legacy pidfile: still surface liveness so a dead legacy pid
12332            // doesn't quietly PASS this check while status says DOWN.
12333            let pid = *pid;
12334            if !crate::ensure_up::pid_is_alive(pid) {
12335                return DoctorCheck::warn(
12336                    "daemon_pid_consistency",
12337                    format!(
12338                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
12339                         Stale pidfile from a crashed pre-0.5.11 daemon. \
12340                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
12341                    ),
12342                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
12343                );
12344            }
12345            DoctorCheck::warn(
12346                "daemon_pid_consistency",
12347                format!(
12348                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
12349                     Daemon was started by a pre-0.5.11 binary."
12350                ),
12351                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
12352            )
12353        }
12354        crate::ensure_up::PidRecord::Json(d) => {
12355            // v0.5.19 liveness gate: if the recorded pid is dead, the
12356            // pidfile is stale and the rest of the content drift checks
12357            // are moot — `wire upgrade` is the answer regardless.
12358            if !snap.pidfile_alive {
12359                return DoctorCheck::warn(
12360                    "daemon_pid_consistency",
12361                    format!(
12362                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
12363                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
12364                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
12365                        pid = d.pid,
12366                        version = d.version,
12367                    ),
12368                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
12369                     (kills any orphan daemon advancing the cursor without coordination)",
12370                );
12371            }
12372            let mut issues: Vec<String> = Vec::new();
12373            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
12374                issues.push(format!(
12375                    "schema={} (expected {})",
12376                    d.schema,
12377                    crate::ensure_up::DAEMON_PID_SCHEMA
12378                ));
12379            }
12380            let cli_version = env!("CARGO_PKG_VERSION");
12381            if d.version != cli_version {
12382                issues.push(format!("version daemon={} cli={cli_version}", d.version));
12383            }
12384            if !std::path::Path::new(&d.bin_path).exists() {
12385                issues.push(format!("bin_path {} missing on disk", d.bin_path));
12386            }
12387            // Cross-check DID + relay against current config (best-effort).
12388            if let Ok(card) = config::read_agent_card()
12389                && let Some(current_did) = card.get("did").and_then(Value::as_str)
12390                && let Some(recorded_did) = &d.did
12391                && recorded_did != current_did
12392            {
12393                issues.push(format!(
12394                    "did daemon={recorded_did} config={current_did} — identity drift"
12395                ));
12396            }
12397            if let Ok(state) = config::read_relay_state()
12398                && let Some(current_relay) = state
12399                    .get("self")
12400                    .and_then(|s| s.get("relay_url"))
12401                    .and_then(Value::as_str)
12402                && let Some(recorded_relay) = &d.relay_url
12403                && recorded_relay != current_relay
12404            {
12405                issues.push(format!(
12406                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
12407                ));
12408            }
12409            if issues.is_empty() {
12410                DoctorCheck::pass(
12411                    "daemon_pid_consistency",
12412                    format!(
12413                        "daemon v{} bound to {} as {}",
12414                        d.version,
12415                        d.relay_url.as_deref().unwrap_or("?"),
12416                        d.did.as_deref().unwrap_or("?")
12417                    ),
12418                )
12419            } else {
12420                DoctorCheck::warn(
12421                    "daemon_pid_consistency",
12422                    format!("daemon pidfile drift: {}", issues.join("; ")),
12423                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
12424                )
12425            }
12426        }
12427    }
12428}
12429
12430/// Check: bound relay's /healthz returns 200.
12431fn check_relay_reachable() -> DoctorCheck {
12432    let state = match config::read_relay_state() {
12433        Ok(s) => s,
12434        Err(e) => {
12435            return DoctorCheck::fail(
12436                "relay",
12437                format!("could not read relay state: {e}"),
12438                "run `wire up <handle>@<relay>` to bootstrap",
12439            );
12440        }
12441    };
12442    let url = state
12443        .get("self")
12444        .and_then(|s| s.get("relay_url"))
12445        .and_then(Value::as_str)
12446        .unwrap_or("");
12447    if url.is_empty() {
12448        return DoctorCheck::warn(
12449            "relay",
12450            "no relay bound — wire send/pull will not work",
12451            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
12452        );
12453    }
12454    let client = crate::relay_client::RelayClient::new(url);
12455    match client.check_healthz() {
12456        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
12457        Err(e) => DoctorCheck::fail(
12458            "relay",
12459            format!("{url} unreachable: {e}"),
12460            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
12461        ),
12462    }
12463}
12464
12465/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
12466/// entry there is a silent failure that, pre-0.5.11, would have left the
12467/// operator wondering why pairing didn't complete.
12468fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
12469    let path = match config::state_dir() {
12470        Ok(d) => d.join("pair-rejected.jsonl"),
12471        Err(e) => {
12472            return DoctorCheck::warn(
12473                "pair_rejections",
12474                format!("could not resolve state dir: {e}"),
12475                "set WIRE_HOME or fix XDG_STATE_HOME",
12476            );
12477        }
12478    };
12479    if !path.exists() {
12480        return DoctorCheck::pass(
12481            "pair_rejections",
12482            "no pair-rejected.jsonl — no recorded pair failures",
12483        );
12484    }
12485    let body = match std::fs::read_to_string(&path) {
12486        Ok(b) => b,
12487        Err(e) => {
12488            return DoctorCheck::warn(
12489                "pair_rejections",
12490                format!("could not read {path:?}: {e}"),
12491                "check file permissions",
12492            );
12493        }
12494    };
12495    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
12496    if lines.is_empty() {
12497        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
12498    }
12499    let total = lines.len();
12500    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
12501    let mut summary: Vec<String> = Vec::new();
12502    for line in &recent {
12503        if let Ok(rec) = serde_json::from_str::<Value>(line) {
12504            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
12505            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
12506            summary.push(format!("{peer}/{code}"));
12507        }
12508    }
12509    DoctorCheck::warn(
12510        "pair_rejections",
12511        format!(
12512            "{total} pair failures recorded. recent: [{}]",
12513            summary.join(", ")
12514        ),
12515        format!(
12516            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
12517        ),
12518    )
12519}
12520
12521/// Check: cursor isn't stuck. We can't tell without polling — but we can
12522/// report the current cursor position so operators see if it changes.
12523/// Real "stuck" detection needs two pulls separated in time; defer that
12524/// behaviour to a `wire doctor --watch` mode.
12525///
12526/// Heal stale userinfo from this agent's own published relay endpoints.
12527///
12528/// Failure mode this check closes:
12529///   PR #61 added a guard at the WRITE side that prevents NEW userinfo-
12530///   bearing endpoints (`https://<handle>@<host>`) from ever being
12531///   persisted or published. But operators who ran a pre-#61 `wire up
12532///   <handle>@<relay>` already had the malformed endpoint baked into
12533///   their on-disk `self.endpoints[]` AND their signed agent-card AND
12534///   their phonebook entry. The fix prevented the bleeding; it didn't
12535///   heal the wound. Symptoms still visible:
12536///     - Every inbound POST to the malformed endpoint (pair_drop_ack,
12537///       messages) gets a Cloudflare 400 ("missing Bearer token" /
12538///       bare 400). Peers running pre-#62 wire can't deliver to us at
12539///       all (the failover from #62 lets newer peers walk past the
12540///       bad first endpoint to a clean one if both are published —
12541///       but two-endpoint operators still get a 400 for every event
12542///       on their FIRST attempt, and operators with only the
12543///       malformed endpoint are unreachable).
12544///     - `wire pull` from our own malformed slot 400s on every cycle
12545///       (the operator sees a stderr error line every poll).
12546///     - Surfaced concretely when swift-harbor ↔ slate-lotus paired
12547///       2026-05-27: slate-lotus's pair_drop_ack 400'd; my own pulls
12548///       400'd; bilateral handshake couldn't complete via the bad
12549///       endpoint.
12550///
12551/// This is a healable failure mode — the same `strip_relay_url_userinfo`
12552/// logic from #61 can be applied to existing on-disk state. We do it
12553/// inside `wire doctor` (rather than a separate `wire heal` command)
12554/// because:
12555///   1. `wire doctor` is the canonical "what's wrong + fix it" surface
12556///      operators already know to run when something looks off.
12557///   2. The mutation is unambiguously correct — userinfo on a self-
12558///      published relay endpoint has zero legitimate cases (the
12559///      one-name rule means the handle is DID-derived, never URL
12560///      userinfo).
12561///   3. Auto-heal is consistent with what `wire bind-relay https://...`
12562///      / `wire claim` already do at the WRITE side under #61 —
12563///      this just extends the same guard to read-side cleanup.
12564///
12565/// What this check does:
12566///   - Reads `relay.json` and inspects `self.endpoints[]` plus the
12567///     legacy top-level `self.relay_url`/`slot_id`/`slot_token` triple.
12568///   - If any endpoint's `relay_url` contains userinfo, removes that
12569///     endpoint from the array AND (if the legacy top-level was the
12570///     malformed one) promotes the first clean endpoint's coords to
12571///     the legacy slots.
12572///   - Atomically writes back via `write_relay_state` (full lock +
12573///     tmp+rename, same path every other writer uses).
12574///   - Reports PASS if nothing needed healing, WARN if healing happened
12575///     (with the list of stripped URLs + a remediation pointer to
12576///     `wire claim <persona>` for re-publishing the agent-card to the
12577///     phonebook).
12578///
12579/// Re-claim is NOT auto-run here: the doctor check is read-state-bound,
12580/// and `wire claim` requires a clean agent-card resign + network
12581/// round-trip + persona arg. Operators get the explicit next step in
12582/// the WARN fix text. Two-step is the right friction: heal silently,
12583/// claim explicitly.
12584fn check_and_heal_self_userinfo_endpoints() -> DoctorCheck {
12585    let mut state = match config::read_relay_state() {
12586        Ok(s) => s,
12587        Err(_) => {
12588            return DoctorCheck::pass(
12589                "self-userinfo-endpoints",
12590                "no relay state yet — nothing published to heal".to_string(),
12591            );
12592        }
12593    };
12594    let self_block = match state.get_mut("self").and_then(Value::as_object_mut) {
12595        Some(s) => s,
12596        None => {
12597            return DoctorCheck::pass(
12598                "self-userinfo-endpoints",
12599                "no self block in relay state — nothing published to heal".to_string(),
12600            );
12601        }
12602    };
12603
12604    let mut stripped: Vec<String> = Vec::new();
12605    let mut clean_seed: Option<(String, String, String)> = None;
12606
12607    if let Some(endpoints) = self_block
12608        .get_mut("endpoints")
12609        .and_then(Value::as_array_mut)
12610    {
12611        endpoints.retain(|ep| {
12612            let url = ep.get("relay_url").and_then(Value::as_str).unwrap_or("");
12613            // Reuse the exact same authority-only userinfo detection as
12614            // #61's assert_relay_url_clean_for_publish so any future
12615            // change to that authority parse stays in lockstep.
12616            if assert_relay_url_clean_for_publish(url).is_err() {
12617                stripped.push(url.to_string());
12618                false
12619            } else {
12620                if clean_seed.is_none() {
12621                    clean_seed = Some((
12622                        url.to_string(),
12623                        ep.get("slot_id")
12624                            .and_then(Value::as_str)
12625                            .unwrap_or("")
12626                            .to_string(),
12627                        ep.get("slot_token")
12628                            .and_then(Value::as_str)
12629                            .unwrap_or("")
12630                            .to_string(),
12631                    ));
12632                }
12633                true
12634            }
12635        });
12636    }
12637
12638    // Heal the legacy top-level relay_url/slot_id/slot_token triple if it
12639    // was the malformed one. Without this, v0.5.16-era readers (and the
12640    // pair_drop_ack path that falls back to legacy fields) still pick up
12641    // the userinfo URL even after we cleaned endpoints[].
12642    let mut legacy_healed = false;
12643    let legacy_url = self_block
12644        .get("relay_url")
12645        .and_then(Value::as_str)
12646        .unwrap_or("")
12647        .to_string();
12648    if !legacy_url.is_empty() && assert_relay_url_clean_for_publish(&legacy_url).is_err() {
12649        if let Some((url, sid, tok)) = &clean_seed {
12650            self_block.insert("relay_url".to_string(), Value::String(url.clone()));
12651            self_block.insert("slot_id".to_string(), Value::String(sid.clone()));
12652            self_block.insert("slot_token".to_string(), Value::String(tok.clone()));
12653            legacy_healed = true;
12654            stripped.push(format!("(legacy top-level) {legacy_url}"));
12655        } else {
12656            // No clean endpoint exists to promote — the operator only
12657            // has malformed endpoints. We can't auto-heal this safely
12658            // (would leave them with no inbox); surface as WARN with
12659            // explicit re-bind instructions and DON'T mutate.
12660            return DoctorCheck::warn(
12661                "self-userinfo-endpoints",
12662                format!(
12663                    "your published endpoint is malformed (`{legacy_url}` — handle as URL \
12664                     userinfo, the bug PR #61 prevents going forward) AND no clean endpoint \
12665                     exists to fall back to. Inbound POSTs to this endpoint 4xx; bilateral \
12666                     pairing can't complete."
12667                ),
12668                "Bind a clean federation slot first, then re-run doctor to heal: \
12669                 `wire bind-relay https://wireup.net` (or your own relay). The bind \
12670                 adds a clean endpoint additively; the next `wire doctor` run then \
12671                 strips the malformed one safely. Finally re-publish your card with \
12672                 `wire claim <your-persona>` so the phonebook serves the clean shape."
12673                    .to_string(),
12674            );
12675        }
12676    }
12677
12678    if stripped.is_empty() && !legacy_healed {
12679        return DoctorCheck::pass(
12680            "self-userinfo-endpoints",
12681            "no malformed endpoints in self-state".to_string(),
12682        );
12683    }
12684
12685    // Persist the healed state. Best-effort: if the write fails, the
12686    // operator still sees the WARN and can run `wire claim` to re-publish;
12687    // they keep the malformed entry on disk until the next doctor cycle.
12688    if let Err(e) = config::write_relay_state(&state) {
12689        return DoctorCheck::warn(
12690            "self-userinfo-endpoints",
12691            format!(
12692                "detected {} malformed userinfo-bearing endpoint(s) in self-state but \
12693                 failed to persist the heal: {e:#}. Found: {}",
12694                stripped.len(),
12695                stripped.join(", ")
12696            ),
12697            "re-run `wire doctor` — likely a transient lock contention".to_string(),
12698        );
12699    }
12700
12701    DoctorCheck::warn(
12702        "self-userinfo-endpoints",
12703        format!(
12704            "healed {} malformed endpoint(s) in self-state on disk: {}. \
12705             These were the `https://<handle>@<host>` shape that PR #61 prevents \
12706             at the write side but couldn't retroactively scrub from existing \
12707             operators. relay.json is now clean.",
12708            stripped.len(),
12709            stripped.join(", ")
12710        ),
12711        "re-publish your agent-card to the phonebook so peers resolve to the \
12712         clean endpoint: `wire claim <your-persona>` (find your persona with \
12713         `wire whoami`)."
12714            .to_string(),
12715    )
12716}
12717
12718fn check_peer_staleness(max_silent_days: u64) -> DoctorCheck {
12719    let state = match config::read_relay_state() {
12720        Ok(s) => s,
12721        Err(_) => {
12722            return DoctorCheck::pass(
12723                "peer-staleness",
12724                "no relay state yet — nothing pinned to check".to_string(),
12725            );
12726        }
12727    };
12728    let peers = match state.get("peers").and_then(Value::as_object) {
12729        Some(p) => p,
12730        None => {
12731            return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
12732        }
12733    };
12734    if peers.is_empty() {
12735        return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
12736    }
12737    let inbox_dir = match config::inbox_dir() {
12738        Ok(d) => d,
12739        Err(_) => {
12740            return DoctorCheck::warn(
12741                "peer-staleness",
12742                "could not resolve inbox dir; skipping peer-staleness check".to_string(),
12743                "check `wire status` for state-dir resolution".to_string(),
12744            );
12745        }
12746    };
12747    let threshold = std::time::Duration::from_secs(max_silent_days * 24 * 60 * 60);
12748    let now = std::time::SystemTime::now();
12749    let mut stale: Vec<(String, u64, &'static str)> = Vec::new();
12750    for (peer, _info) in peers {
12751        let path = inbox_dir.join(format!("{peer}.jsonl"));
12752        let (age_days, kind) = match std::fs::metadata(&path) {
12753            Ok(meta) => match meta
12754                .modified()
12755                .ok()
12756                .and_then(|m| now.duration_since(m).ok())
12757            {
12758                Some(d) if d > threshold => (d.as_secs() / (24 * 60 * 60), "silent"),
12759                Some(_) => continue, // fresh — not stale
12760                None => (0, "unknown-mtime"),
12761            },
12762            Err(_) => (max_silent_days + 1, "no-inbox-file"),
12763        };
12764        stale.push((peer.clone(), age_days, kind));
12765    }
12766    if stale.is_empty() {
12767        return DoctorCheck::pass(
12768            "peer-staleness",
12769            format!(
12770                "all {} pinned peer(s) have inbox traffic within the last {max_silent_days} day(s)",
12771                peers.len()
12772            ),
12773        );
12774    }
12775    let detail = stale
12776        .iter()
12777        .map(|(p, d, k)| match *k {
12778            "no-inbox-file" => format!("{p} (no inbox file)"),
12779            "unknown-mtime" => format!("{p} (unknown last-event time)"),
12780            _ => format!("{p} ({d}d silent)"),
12781        })
12782        .collect::<Vec<_>>()
12783        .join(", ");
12784    DoctorCheck::warn(
12785        "peer-staleness",
12786        format!(
12787            "{} pinned peer(s) silent for >{max_silent_days}d: {detail}. \
12788             If the peer re-bound their relay slot, our pin is now stale — \
12789             we push successfully to a dead slot and they never see us \
12790             (asymmetric failure, both sides report green).",
12791            stale.len()
12792        ),
12793        "re-pair with `wire add <peer>@<relay>` to refresh the slot. \
12794         Once issue #15 lands, this also auto-resolves on 410 Gone."
12795            .to_string(),
12796    )
12797}
12798
12799fn check_cursor_progress() -> DoctorCheck {
12800    let state = match config::read_relay_state() {
12801        Ok(s) => s,
12802        Err(e) => {
12803            return DoctorCheck::warn(
12804                "cursor",
12805                format!("could not read relay state: {e}"),
12806                "check ~/Library/Application Support/wire/relay.json",
12807            );
12808        }
12809    };
12810    let cursor = state
12811        .get("self")
12812        .and_then(|s| s.get("last_pulled_event_id"))
12813        .and_then(Value::as_str)
12814        .map(|s| s.chars().take(16).collect::<String>())
12815        .unwrap_or_else(|| "<none>".to_string());
12816    DoctorCheck::pass(
12817        "cursor",
12818        format!(
12819            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
12820        ),
12821    )
12822}
12823
12824#[cfg(test)]
12825mod doctor_tests {
12826    use super::*;
12827
12828    #[test]
12829    fn doctor_check_constructors_set_status_correctly() {
12830        // Silent-fail-prevention rule: pass/warn/fail must be visibly
12831        // distinguishable to operators. If any constructor lets the wrong
12832        // status through, `wire doctor` lies and we're back to today's
12833        // 30-minute debug.
12834        let p = DoctorCheck::pass("x", "ok");
12835        assert_eq!(p.status, "PASS");
12836        assert_eq!(p.fix, None);
12837
12838        let w = DoctorCheck::warn("x", "watch out", "do this");
12839        assert_eq!(w.status, "WARN");
12840        assert_eq!(w.fix, Some("do this".to_string()));
12841
12842        let f = DoctorCheck::fail("x", "broken", "fix it");
12843        assert_eq!(f.status, "FAIL");
12844        assert_eq!(f.fix, Some("fix it".to_string()));
12845    }
12846
12847    #[test]
12848    fn check_pair_rejections_no_file_is_pass() {
12849        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
12850        // as a problem.
12851        config::test_support::with_temp_home(|| {
12852            config::ensure_dirs().unwrap();
12853            let c = check_pair_rejections(5);
12854            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
12855        });
12856    }
12857
12858    #[test]
12859    fn check_pair_rejections_with_entries_warns() {
12860        // Existence of rejections is itself a signal — even if each entry
12861        // is a "known good failure," the operator wants to know they
12862        // happened.
12863        config::test_support::with_temp_home(|| {
12864            config::ensure_dirs().unwrap();
12865            crate::pair_invite::record_pair_rejection(
12866                "willard",
12867                "pair_drop_ack_send_failed",
12868                "POST 502",
12869            );
12870            let c = check_pair_rejections(5);
12871            assert_eq!(c.status, "WARN");
12872            assert!(c.detail.contains("1 pair failures"));
12873            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
12874        });
12875    }
12876
12877    #[test]
12878    fn check_peer_staleness_no_peers_is_pass() {
12879        // Fresh box / no pin yet: must NOT report this as a problem
12880        // (nothing to be stale about).
12881        config::test_support::with_temp_home(|| {
12882            config::ensure_dirs().unwrap();
12883            let c = check_peer_staleness(7);
12884            assert_eq!(c.status, "PASS", "no peers should be PASS, got {c:?}");
12885        });
12886    }
12887
12888    #[test]
12889    fn check_peer_staleness_pinned_with_no_inbox_file_warns() {
12890        // Issue #14 asymmetric-stale-pin: peer is pinned but we've NEVER
12891        // received an event from them (no inbox file at all). That's
12892        // exactly the "we pushed N events, got 0 back" smell the WARN is
12893        // designed to catch.
12894        config::test_support::with_temp_home(|| {
12895            config::ensure_dirs().unwrap();
12896            // Seed a pinned peer with no corresponding inbox file.
12897            let mut state = json!({
12898                "peers": {
12899                    "stale-peer": {
12900                        "relay_url": "https://wireup.net",
12901                        "slot_id": "deadslot",
12902                        "slot_token": "tok",
12903                    }
12904                }
12905            });
12906            state["self"] = json!({});
12907            config::write_relay_state(&state).unwrap();
12908
12909            let c = check_peer_staleness(7);
12910            assert_eq!(
12911                c.status, "WARN",
12912                "pinned peer with no inbox file must surface: {c:?}"
12913            );
12914            assert!(
12915                c.detail.contains("stale-peer"),
12916                "WARN must name the silent peer so the operator can act: {}",
12917                c.detail
12918            );
12919            assert!(
12920                c.detail.contains("asymmetric")
12921                    || c.detail.contains("stale")
12922                    || c.detail.contains("dead slot"),
12923                "WARN must surface the failure-mode language so the operator \
12924                 finds the diagnosis without re-tracing: {}",
12925                c.detail
12926            );
12927            assert!(
12928                c.fix
12929                    .as_ref()
12930                    .is_some_and(|f| f.contains("wire add") && f.contains("#15")),
12931                "fix pointer must reference both the manual re-pair AND the \
12932                 follow-up issue (#15) that will automate this: {:?}",
12933                c.fix
12934            );
12935        });
12936    }
12937
12938    #[test]
12939    fn check_peer_staleness_pinned_with_fresh_inbox_is_pass() {
12940        // Negative case: pinned peer with a recent inbox event must NOT
12941        // be reported. This prevents the false-positive that would otherwise
12942        // make operators ignore the WARN.
12943        config::test_support::with_temp_home(|| {
12944            config::ensure_dirs().unwrap();
12945            let mut state = json!({
12946                "peers": {
12947                    "active-peer": {
12948                        "relay_url": "https://wireup.net",
12949                        "slot_id": "freshslot",
12950                        "slot_token": "tok",
12951                    }
12952                }
12953            });
12954            state["self"] = json!({});
12955            config::write_relay_state(&state).unwrap();
12956
12957            let inbox = config::inbox_dir().unwrap();
12958            std::fs::create_dir_all(&inbox).unwrap();
12959            std::fs::write(
12960                inbox.join("active-peer.jsonl"),
12961                "{\"event_id\":\"recent\"}\n",
12962            )
12963            .unwrap();
12964
12965            let c = check_peer_staleness(7);
12966            assert_eq!(c.status, "PASS", "fresh inbox should not warn: {c:?}");
12967        });
12968    }
12969
12970    #[test]
12971    fn check_self_userinfo_no_state_is_pass() {
12972        // Fresh box (no relay.json yet) must NOT WARN — there's nothing
12973        // published to heal, and treating a missing file as a problem
12974        // would scare every new operator on first `wire doctor` run.
12975        config::test_support::with_temp_home(|| {
12976            // Don't even call ensure_dirs — simulate truly fresh state.
12977            let c = check_and_heal_self_userinfo_endpoints();
12978            assert_eq!(c.status, "PASS", "no state should be PASS, got {c:?}");
12979        });
12980    }
12981
12982    #[test]
12983    fn check_self_userinfo_clean_state_is_pass_no_mutation() {
12984        // Negative case: clean self.endpoints[] must not trigger a heal,
12985        // must not mutate relay.json. Prevents the false-positive that
12986        // would make operators distrust the doctor.
12987        config::test_support::with_temp_home(|| {
12988            config::ensure_dirs().unwrap();
12989            let state = json!({
12990                "self": {
12991                    "endpoints": [
12992                        {
12993                            "relay_url": "https://wireup.net",
12994                            "scope": "Federation",
12995                            "slot_id": "abc",
12996                            "slot_token": "tok"
12997                        }
12998                    ],
12999                    "relay_url": "https://wireup.net",
13000                    "slot_id": "abc",
13001                    "slot_token": "tok"
13002                },
13003                "peers": {}
13004            });
13005            config::write_relay_state(&state).unwrap();
13006
13007            let c = check_and_heal_self_userinfo_endpoints();
13008            assert_eq!(c.status, "PASS", "clean state should be PASS: {c:?}");
13009
13010            // Verify state is byte-identical (no spurious write).
13011            let after = config::read_relay_state().unwrap();
13012            assert_eq!(after, state, "PASS path must NOT mutate relay.json");
13013        });
13014    }
13015
13016    #[test]
13017    fn check_self_userinfo_heals_malformed_endpoint_and_promotes_clean() {
13018        // THE regression case (swift-harbor / slate-lotus pairing 2026-05-27):
13019        // relay.json has a malformed first endpoint from before #61 AND a
13020        // clean second endpoint from a later `wire bind-relay`. The check
13021        // must (a) strip the malformed one, (b) promote the clean one's
13022        // coords to the legacy top-level triple, (c) write back, (d) emit
13023        // a WARN with the stripped URL + `wire claim` remediation pointer.
13024        config::test_support::with_temp_home(|| {
13025            config::ensure_dirs().unwrap();
13026            let state = json!({
13027                "self": {
13028                    "endpoints": [
13029                        {
13030                            "relay_url": "https://copilot-agent@wireup.net",
13031                            "scope": "Federation",
13032                            "slot_id": "stale-id",
13033                            "slot_token": "stale-token"
13034                        },
13035                        {
13036                            "relay_url": "https://wireup.net",
13037                            "scope": "Federation",
13038                            "slot_id": "clean-id",
13039                            "slot_token": "clean-token"
13040                        }
13041                    ],
13042                    "relay_url": "https://copilot-agent@wireup.net",
13043                    "slot_id": "stale-id",
13044                    "slot_token": "stale-token"
13045                },
13046                "peers": {}
13047            });
13048            config::write_relay_state(&state).unwrap();
13049
13050            let c = check_and_heal_self_userinfo_endpoints();
13051            assert_eq!(c.status, "WARN", "heal should report WARN: {c:?}");
13052            assert!(
13053                c.detail.contains("healed") && c.detail.contains("copilot-agent@wireup.net"),
13054                "WARN must name the stripped URL so the operator sees what changed: {}",
13055                c.detail
13056            );
13057            assert!(
13058                c.fix.as_ref().is_some_and(|f| f.contains("wire claim")),
13059                "fix must point at re-publishing the agent-card so the phonebook entry \
13060                 matches the healed state on disk: {:?}",
13061                c.fix
13062            );
13063
13064            // Verify the file on disk is healed:
13065            //   - endpoints[] contains ONLY the clean entry.
13066            //   - legacy top-level fields promoted from the clean entry.
13067            let after = config::read_relay_state().unwrap();
13068            let endpoints = after["self"]["endpoints"].as_array().unwrap();
13069            assert_eq!(endpoints.len(), 1, "malformed endpoint must be removed");
13070            assert_eq!(endpoints[0]["relay_url"], "https://wireup.net");
13071            assert_eq!(after["self"]["relay_url"], "https://wireup.net");
13072            assert_eq!(after["self"]["slot_id"], "clean-id");
13073            assert_eq!(after["self"]["slot_token"], "clean-token");
13074        });
13075    }
13076
13077    #[test]
13078    fn check_self_userinfo_no_clean_fallback_warns_without_mutating() {
13079        // Edge: operator only has the malformed endpoint, no clean fallback
13080        // to promote. Auto-healing would leave them with NO inbox slot at
13081        // all — strictly worse than the malformed shape (peers can at least
13082        // try the bad endpoint). Check must surface a WARN with explicit
13083        // re-bind instructions and DO NOT touch the state.
13084        config::test_support::with_temp_home(|| {
13085            config::ensure_dirs().unwrap();
13086            let state = json!({
13087                "self": {
13088                    "endpoints": [
13089                        {
13090                            "relay_url": "https://copilot-agent@wireup.net",
13091                            "scope": "Federation",
13092                            "slot_id": "stale-id",
13093                            "slot_token": "stale-token"
13094                        }
13095                    ],
13096                    "relay_url": "https://copilot-agent@wireup.net",
13097                    "slot_id": "stale-id",
13098                    "slot_token": "stale-token"
13099                },
13100                "peers": {}
13101            });
13102            config::write_relay_state(&state).unwrap();
13103
13104            let c = check_and_heal_self_userinfo_endpoints();
13105            assert_eq!(c.status, "WARN");
13106            assert!(
13107                c.fix
13108                    .as_ref()
13109                    .is_some_and(|f| f.contains("wire bind-relay") && f.contains("wire claim")),
13110                "no-clean-fallback fix must require BOTH a clean bind AND a re-claim: {:?}",
13111                c.fix
13112            );
13113
13114            // CRITICAL: state must NOT be mutated (would leave operator with
13115            // no inbox slot). Verify byte-identical.
13116            let after = config::read_relay_state().unwrap();
13117            assert_eq!(
13118                after, state,
13119                "no-clean-fallback path must NOT mutate state (would strand operator)"
13120            );
13121        });
13122    }
13123}
13124
13125// ---------- up megacommand (full bootstrap) ----------
13126
13127/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
13128/// pair. Composes the steps that today's onboarding walks operators through
13129/// one by one (init / bind-relay / claim / background daemon / arm monitor
13130/// recipe). Idempotent: every step checks current state and skips if done.
13131///
13132/// Argument parsing accepts:
13133///   - `<nick>@<relay-host>` — explicit relay
13134///   - `<nick>`              — defaults to wireup.net (the configured
13135///     public relay)
13136fn cmd_up(
13137    relay_arg: Option<&str>,
13138    name: Option<&str>,
13139    with_local: Option<&str>,
13140    no_local: bool,
13141    as_json: bool,
13142) -> Result<()> {
13143    // No nick to parse — your handle is your DID-derived persona (one-name
13144    // rule). The optional arg is only which relay to bind/claim on. Accepts
13145    // `@host`, bare `host`, or a full URL; defaults to the public relay.
13146    let relay_url = match relay_arg {
13147        Some(r) => {
13148            let r = r.trim_start_matches('@');
13149            if r.starts_with("http://") || r.starts_with("https://") {
13150                r.to_string()
13151            } else {
13152                format!("https://{r}")
13153            }
13154        }
13155        None => crate::pair_invite::DEFAULT_RELAY.to_string(),
13156    };
13157
13158    // Strip any URL userinfo (`<handle>@<host>`) before doing any state-
13159    // mutating work — otherwise the malformed endpoint gets persisted in
13160    // `relay_state` AND published in the signed agent-card, where every
13161    // inbound POST to it 4xxes. Mirrors `cmd_up`'s already-bound branch,
13162    // which has always ignored the userinfo on the "keeping existing
13163    // binding" warning path.
13164    let relay_url = strip_relay_url_userinfo(&relay_url);
13165
13166    let mut report: Vec<(String, String)> = Vec::new();
13167    let mut step = |stage: &str, detail: String| {
13168        report.push((stage.to_string(), detail.clone()));
13169        if !as_json {
13170            eprintln!("wire up: {stage} — {detail}");
13171        }
13172    };
13173
13174    // 1. init (or note existing identity). No typed name — cmd_init(None)
13175    // generates the persona from the freshly-minted keypair (one-name rule).
13176    if config::is_initialized()? {
13177        step("init", "already initialized".to_string());
13178    } else {
13179        cmd_init(
13180            None,
13181            name,
13182            Some(&relay_url),
13183            false,
13184            /* as_json */ false,
13185        )?;
13186        step("init", format!("created identity bound to {relay_url}"));
13187    }
13188
13189    // Canonical persona handle — the one name we claim and are addressed by.
13190    let canonical = {
13191        let card = config::read_agent_card()?;
13192        let did = card.get("did").and_then(Value::as_str).unwrap_or("");
13193        crate::agent_card::display_handle_from_did(did).to_string()
13194    };
13195    step("identity", format!("persona is `{canonical}`"));
13196
13197    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
13198    // already initialized we may need to bind to the requested relay
13199    // separately (operator switched relays).
13200    let relay_state = config::read_relay_state()?;
13201    let bound_relay = relay_state
13202        .get("self")
13203        .and_then(|s| s.get("relay_url"))
13204        .and_then(Value::as_str)
13205        .unwrap_or("")
13206        .to_string();
13207    if bound_relay.is_empty() {
13208        // Identity exists but never bound to a relay — bind now.
13209        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
13210        // Pass `false` so the safety check kicks in if state was non-empty.
13211        cmd_bind_relay(
13212            &relay_url, /* scope */ None, // infer from URL (federation for wireup.net)
13213            /* replace */ false, /* migrate_pinned */ false, /* as_json */ false,
13214        )?;
13215        step("bind-relay", format!("bound to {relay_url}"));
13216    } else if bound_relay != relay_url {
13217        step(
13218            "bind-relay",
13219            format!(
13220                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
13221                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
13222            ),
13223        );
13224    } else {
13225        step("bind-relay", format!("already bound to {bound_relay}"));
13226    }
13227
13228    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
13229    // re-claims are accepted by the relay.
13230    match cmd_claim(
13231        &canonical,
13232        Some(&relay_url),
13233        None,
13234        /* hidden */ false,
13235        /* as_json */ false,
13236    ) {
13237        Ok(()) => step(
13238            "claim",
13239            format!("{canonical}@{} claimed", strip_proto(&relay_url)),
13240        ),
13241        Err(e) => step(
13242            "claim",
13243            format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
13244        ),
13245    }
13246
13247    // 3b. Opportunistic local dual-slot (additive). Gives same-box sister
13248    // sessions sub-millisecond loopback routing alongside the federation
13249    // slot. Local relays carry no handle directory — nothing to claim
13250    // there; sister discovery is via `wire session list-local`.
13251    if no_local {
13252        step("local-slot", "skipped (--no-local)".to_string());
13253    } else {
13254        let local_url = with_local
13255            .unwrap_or("http://127.0.0.1:8771")
13256            .trim_end_matches('/');
13257        let already_local = crate::endpoints::self_endpoints(
13258            &config::read_relay_state().unwrap_or_else(|_| json!({})),
13259        )
13260        .iter()
13261        .any(|e| e.relay_url == local_url);
13262        if relay_url.trim_end_matches('/') == local_url || already_local {
13263            step("local-slot", "already covered".to_string());
13264        } else if crate::relay_client::RelayClient::new(local_url)
13265            .check_healthz()
13266            .is_ok()
13267        {
13268            match cmd_bind_relay(
13269                local_url,
13270                Some("local"),
13271                /* replace */ false,
13272                /* migrate_pinned */ false,
13273                /* as_json */ false,
13274            ) {
13275                Ok(()) => step(
13276                    "local-slot",
13277                    format!("dual-bound local relay {local_url} for sister routing"),
13278                ),
13279                Err(e) => step("local-slot", format!("skipped local relay: {e}")),
13280            }
13281        } else {
13282            step(
13283                "local-slot",
13284                format!(
13285                    "no local relay reachable at {local_url} — federation only \
13286                     (sisters resolve via session-list)"
13287                ),
13288            );
13289        }
13290    }
13291
13292    // 4. Background daemon — must be running for pull/push/ack to flow.
13293    match crate::ensure_up::ensure_daemon_running() {
13294        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
13295        Ok(false) => step("daemon", "already running".to_string()),
13296        Err(e) => step(
13297            "daemon",
13298            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
13299        ),
13300    }
13301
13302    // 5. Final summary — point operator at the next commands.
13303    let summary =
13304        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
13305         `wire monitor` to watch incoming events."
13306            .to_string();
13307    step("ready", summary.clone());
13308
13309    if as_json {
13310        let steps_json: Vec<_> = report
13311            .iter()
13312            .map(|(k, v)| json!({"stage": k, "detail": v}))
13313            .collect();
13314        println!(
13315            "{}",
13316            serde_json::to_string(&json!({
13317                "nick": canonical,
13318                "relay": relay_url,
13319                "steps": steps_json,
13320            }))?
13321        );
13322    }
13323    Ok(())
13324}
13325
13326/// Strip http:// or https:// prefix for display in `wire up` step output.
13327fn strip_proto(url: &str) -> String {
13328    url.trim_start_matches("https://")
13329        .trim_start_matches("http://")
13330        .to_string()
13331}
13332
13333/// Strip URL userinfo (`https://<userinfo>@<host>...`) from a relay URL,
13334/// warning to stderr if any was stripped. Returns the cleaned URL.
13335///
13336/// Bug 1 this fixes: `wire up <handle>@<relay>` and `wire bind-relay
13337/// <handle>@<relay>` previously prepended `https://` to the literal arg,
13338/// recording and publishing the endpoint as `https://<handle>@<relay>` —
13339/// handle parsed as URL userinfo. Every inbound event POST to that
13340/// endpoint (pair_drop_ack, messages) gets a 4xx (Cloudflare 400 on
13341/// wireup.net) because the upstream rejects the userinfo on plain
13342/// GETs/POSTs. Bilateral pairing can't complete; messages sit
13343/// undelivered. Also surfaced cosmetically (Bug 3) as a doubled-handle
13344/// echo at the claim step (`<nick>@<nick>@<host>`) because `strip_proto`
13345/// left the userinfo in.
13346///
13347/// Behavior: strip-and-warn rather than hard-reject. In v0.11+ the handle
13348/// is DID-derived (one-name rule), so the userinfo isn't *needed* — but
13349/// `<handle>@<relay>` is literally the wire dial-address format
13350/// (`wire dial coral-weasel@wireup.net`), so an operator who types
13351/// `wire up <handle>@<relay>` is making a natural-by-analogy mistake, not
13352/// a hostile request. Mirrors `cmd_up`'s already-bound branch, which has
13353/// always ignored the userinfo prefix when keeping an existing clean
13354/// slot. The hard invariant either way: a userinfo-bearing URL must
13355/// never reach `self.endpoints[]` or the published agent-card.
13356/// Self-pair guard (issue #30, explicit "Optional" ask).
13357///
13358/// Refuses to proceed when the resolved peer DID matches our own DID. Two
13359/// ways this fires:
13360///
13361///   1. The operator literally dialed their own handle by mistake.
13362///   2. Two terminals / agents that should be DISTINCT collapsed onto one
13363///      wire identity — either because v0.13's session-key resolution
13364///      didn't reach the wire process (env var not propagated; see #29 and
13365///      the Windows symptoms in #30) or because both terminals share a
13366///      WIRE_HOME without setting WIRE_SESSION_ID.
13367///
13368/// Pre-guard, case (2) silently produced a pair_drop targeting our own
13369/// slot — bilateral handshake could never complete and the operator could
13370/// only see "pending forever" with no diagnostic. The guard makes the
13371/// failure mode debuggable instead of silent by surfacing the exact DID
13372/// collision and pointing at the `wire whoami` / `WIRE_SESSION_ID`
13373/// diagnostic that the v0.13.5 session-key adapter introduced.
13374///
13375/// Companion to the lightweight nickname-match guard at the top of
13376/// `cmd_add` (which catches the literal `wire add <our-nick>@<relay>`
13377/// case before WebFinger). This DID-level guard is the load-bearing one
13378/// because case (2) — two collapsed terminals with DIFFERENT typed
13379/// nicknames that BOTH resolve to the shared DID — can't be caught
13380/// without the post-resolution comparison.
13381/// Issue #69 follow-up to #15: predicate "does this error smell like a
13382/// 4xx slot rotation?" — used by `try_reresolve_peer_on_slot_4xx` to
13383/// decide whether to spend a whois RTT on a re-resolve.
13384///
13385/// Original #15 implementation used `last_err.contains("410") ||
13386/// last_err.contains("404")`, which false-triggers on any unrelated
13387/// substring with `"410"`/`"404"` in it — e.g. `"slot 4101 expired"`,
13388/// `"request_id=410abc..."`, `"received 4040 bytes"`. False-trigger cost
13389/// is a single wasted whois per push call per peer (rate-limited by
13390/// `already_tried`), but it muddies the doctor diagnostic by inserting
13391/// spurious "peer slot rotated" log lines.
13392///
13393/// This predicate gates on the status code appearing as a *whole token*
13394/// — preceded by start-of-string / space / colon / tab / newline AND
13395/// followed by end-of-string / space / colon / tab / newline. That
13396/// matches both real-world shapes:
13397///
13398/// - `reqwest::StatusCode` Display, via `relay_client.rs` line ~339
13399///   `format!("post_event failed: {status}: {detail}")` →
13400///   `"post_event failed: 410 Gone: <body>"` (token `"410"` is followed
13401///   by space).
13402/// - UDS bare-`u16` Display, via `relay_client.rs` line ~227
13403///   `format!("post_event (uds {socket_path}) failed: {status}: ...")` →
13404///   `"post_event (uds /tmp/...sock) failed: 410: <body>"` (token
13405///   `"410"` is followed by colon).
13406///
13407/// And rejects the false-positive shapes documented in
13408/// `error_smells_like_slot_4xx_tests` below.
13409fn error_smells_like_slot_4xx(last_err: &str) -> bool {
13410    fn is_token_boundary(b: u8) -> bool {
13411        matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
13412    }
13413    let bytes = last_err.as_bytes();
13414    for code in ["410", "404"] {
13415        let code_bytes = code.as_bytes();
13416        let mut search_from = 0usize;
13417        while let Some(rel) = last_err[search_from..].find(code) {
13418            let abs = search_from + rel;
13419            let end = abs + code_bytes.len();
13420            let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
13421            let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
13422            if before_ok && after_ok {
13423                return true;
13424            }
13425            // Step past this candidate to find the next occurrence; using
13426            // `+ 1` (rather than `+ code_bytes.len()`) keeps the scan
13427            // cheap and guarantees forward progress even on overlap.
13428            search_from = abs + 1;
13429        }
13430    }
13431    false
13432}
13433
13434/// Issue #15: detect a 4xx-shaped push failure that smells like "slot
13435/// rotated by peer" and update the peer's pin in place with the freshly
13436/// resolved slot from the relay's handle directory.
13437///
13438/// Returns:
13439/// - `Ok(true)` — peer's pin was rotated; caller should refresh
13440///   `peer_endpoints_in_priority_order(&state, ...)` and retry.
13441/// - `Ok(false)` — re-resolve completed but the slot id was unchanged
13442///   (false-alarm 4xx, e.g. throttling); caller should NOT retry.
13443/// - `Err(e)` — re-resolve itself failed (network down, relay 5xx,
13444///   handle no longer claimed, etc.); caller should fall through to the
13445///   existing "skipped" path.
13446///
13447/// Only triggers when:
13448///   - The error string carries a 4xx slot-rotation status token (`410`/`404`)
13449///     as a *whole token* — preceded by start/space/colon/tab/newline and
13450///     followed by end/space/colon/tab/newline. This matches both the
13451///     `reqwest::StatusCode` Display shape (`": 410 Gone"`) and the UDS
13452///     bare-`u16` shape (`": 410:"`) emitted by `post_event` in
13453///     `src/relay_client.rs`, while rejecting substring false-positives
13454///     like `"slot 4101 expired"` or `"request_id=410abc..."`. See
13455///     `error_smells_like_slot_4xx` below.
13456///   - The peer has a pinned `relay_url` we can parse a handle@domain from.
13457///   - The caller hasn't already re-resolved this peer in the current push
13458///     call (caller's responsibility — pass `already_tried` from a set kept
13459///     in the outer per-peer loop). One whois per peer per push call,
13460///     exactly the rate limit the issue specifies.
13461///
13462/// Updates `state.peers[peer_handle]` in place (rotates the federation
13463/// endpoint's slot_id + slot_token to the fresh resolve), and emits a
13464/// stderr WARN so the operator can see the rotation event in their
13465/// terminal alongside the unrelated `wire push` output. Caller is
13466/// responsible for persisting `state` back to disk via
13467/// `config::write_relay_state` after all per-peer re-resolves settle.
13468fn try_reresolve_peer_on_slot_4xx(
13469    state: &mut Value,
13470    peer_handle: &str,
13471    last_err: &str,
13472    already_tried: &std::collections::HashSet<String>,
13473) -> Result<bool> {
13474    if !error_smells_like_slot_4xx(last_err) {
13475        // Not the slot-rotation shape. Don't waste a whois on this.
13476        return Ok(false);
13477    }
13478    if already_tried.contains(peer_handle) {
13479        // Rate limit: at most one whois per peer per push call.
13480        return Ok(false);
13481    }
13482    // Find the peer's pinned federation endpoint to re-resolve against.
13483    let peer_entry = state
13484        .get("peers")
13485        .and_then(|p| p.get(peer_handle))
13486        .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
13487    let peer_relay = peer_entry
13488        .get("endpoints")
13489        .and_then(Value::as_array)
13490        .and_then(|arr| {
13491            arr.iter().find(|e| {
13492                e.get("scope").and_then(Value::as_str) == Some("federation")
13493                    || e.get("scope").and_then(Value::as_str) == Some("Federation")
13494            })
13495        })
13496        .and_then(|e| e.get("relay_url").and_then(Value::as_str))
13497        .or_else(|| peer_entry.get("relay_url").and_then(Value::as_str))
13498        .ok_or_else(|| {
13499            anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
13500        })?
13501        .to_string();
13502    // Strip scheme + path to get the relay domain. Same shape parse used by
13503    // pair_profile::resolve_handle's input contract.
13504    let domain = peer_relay
13505        .trim_start_matches("https://")
13506        .trim_start_matches("http://")
13507        .split('/')
13508        .next()
13509        .unwrap_or(&peer_relay)
13510        .to_string();
13511    let handle = crate::pair_profile::Handle {
13512        nick: peer_handle.to_string(),
13513        domain,
13514    };
13515    let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
13516    let new_slot_id = resolved
13517        .get("slot_id")
13518        .and_then(Value::as_str)
13519        .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
13520        .to_string();
13521    // Compare against the currently-pinned federation slot.
13522    let peers = state
13523        .get_mut("peers")
13524        .and_then(Value::as_object_mut)
13525        .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
13526    let peer_entry = peers
13527        .get_mut(peer_handle)
13528        .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
13529    let current_slot_id = peer_entry
13530        .get("endpoints")
13531        .and_then(Value::as_array)
13532        .and_then(|arr| {
13533            arr.iter().find(|e| {
13534                let scope = e.get("scope").and_then(Value::as_str);
13535                scope == Some("federation") || scope == Some("Federation")
13536            })
13537        })
13538        .and_then(|e| e.get("slot_id").and_then(Value::as_str))
13539        .unwrap_or("")
13540        .to_string();
13541    if current_slot_id == new_slot_id {
13542        // Same slot — the 4xx was something else (rate limit, server burp).
13543        return Ok(false);
13544    }
13545    // Rotate in place. We update slot_id but DROP the slot_token: only the
13546    // peer's freshly-issued slot_token (which arrives via a new pair_drop_ack)
13547    // is valid. Sending against the new slot without a fresh token gets 401,
13548    // so the operator will see one more "skipped: 401" and the next pair
13549    // cycle (or a manual `wire add <peer>@<relay>` per the doctor #14 fix)
13550    // refreshes the token. This is the same trade-off the issue spells out:
13551    // auto-rotation closes the slot mismatch; token refresh still needs the
13552    // bilateral pair gate.
13553    if let Some(endpoints) = peer_entry
13554        .get_mut("endpoints")
13555        .and_then(Value::as_array_mut)
13556    {
13557        for ep in endpoints.iter_mut() {
13558            let scope = ep.get("scope").and_then(Value::as_str);
13559            if scope == Some("federation") || scope == Some("Federation") {
13560                ep["slot_id"] = Value::String(new_slot_id.clone());
13561                ep["slot_token"] = Value::String(String::new());
13562            }
13563        }
13564    }
13565    // Also update the legacy top-level fields for v0.5.16-era readers (the
13566    // same back-compat surface pair_drop_ack uses).
13567    peer_entry["slot_id"] = Value::String(new_slot_id.clone());
13568    peer_entry["slot_token"] = Value::String(String::new());
13569    eprintln!(
13570        "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
13571         now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
13572         {peer_handle}@<relay>` to refresh the slot_token."
13573    );
13574    Ok(true)
13575}
13576
13577fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
13578    if our_did == peer_did {
13579        bail!(
13580            "refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
13581             DID. Two terminals can collapse onto one wire identity when the per-\
13582             session key isn't reaching the wire process (issue #30 / #29).\n\n\
13583             Diagnose:\n  \
13584             • `wire whoami` in each terminal — DIDs MUST differ.\n  \
13585             • `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
13586             (PowerShell) — must be set + distinct per session.\n\n\
13587             Force distinct identities before relaunching the agent:\n  \
13588             • bash/zsh:   `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n  \
13589             • PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
13590        );
13591    }
13592    Ok(())
13593}
13594
13595fn strip_relay_url_userinfo(url: &str) -> String {
13596    // Locate the authority segment: everything after `://` (or the whole
13597    // string if there is no scheme yet), up to the first `/`, `?`, or `#`.
13598    let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
13599    let rest = &url[authority_start..];
13600    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
13601    let authority = &rest[..authority_end];
13602
13603    let Some(at_pos) = authority.find('@') else {
13604        return url.to_string();
13605    };
13606
13607    let userinfo = &authority[..at_pos];
13608    let host = &authority[at_pos + 1..];
13609    let scheme = &url[..authority_start];
13610    let tail = &rest[authority_end..];
13611    let cleaned = format!("{scheme}{host}{tail}");
13612
13613    eprintln!(
13614        "wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
13615         in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
13616         is just the bare relay. Binding to `{cleaned}` instead."
13617    );
13618
13619    cleaned
13620}
13621
13622/// Hard assertion that a URL about to be persisted to `relay_state` /
13623/// published in the signed agent-card carries no userinfo. The
13624/// `strip_relay_url_userinfo` filter at every public entry point already
13625/// removes it; this is the belt-and-suspenders check at the actual mutation
13626/// site — a future code path that bypasses the entry filter must NOT be
13627/// able to leak a malformed endpoint into a signed card or the persisted
13628/// relay state.
13629fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
13630    let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
13631    let rest = &url[authority_start..];
13632    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
13633    let authority = &rest[..authority_end];
13634    if authority.contains('@') {
13635        bail!(
13636            "internal invariant violated: relay URL `{url}` still carries userinfo at \
13637             the persist/publish boundary — `strip_relay_url_userinfo` must be called \
13638             before this point. Refusing to publish a malformed endpoint."
13639        );
13640    }
13641    Ok(())
13642}
13643
13644// ---------- pair megacommand (zero-paste handle-based) ----------
13645
13646/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
13647/// the handle is in `nick@domain` form. Wraps:
13648///
13649///   1. cmd_add — resolve, pin, drop intro
13650///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
13651///      (signalled by `peers.<handle>.slot_token` populating in relay state)
13652///   3. Verify bilateral pin: trust contains peer + relay state has token
13653///   4. Print final state — both sides VERIFIED + can `wire send`
13654///
13655/// On timeout: hard-errors with the specific stuck step so the operator
13656/// knows which side to chase. No silent partial success.
13657fn cmd_pair_megacommand(
13658    handle_arg: &str,
13659    relay_override: Option<&str>,
13660    timeout_secs: u64,
13661    _as_json: bool,
13662) -> Result<()> {
13663    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
13664    let peer_handle = parsed.nick.clone();
13665
13666    eprintln!("wire pair: resolving {handle_arg}...");
13667    cmd_add(
13668        handle_arg,
13669        relay_override,
13670        /* local_sister */ false,
13671        /* as_json */ false,
13672    )?;
13673
13674    eprintln!(
13675        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
13676         to ack (their daemon must be running + pulling)..."
13677    );
13678
13679    // Trigger an immediate daemon-style pull so we don't wait the full daemon
13680    // interval. Best-effort — if it fails, we still fall through to the
13681    // polling loop.
13682    let _ = run_sync_pull();
13683
13684    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
13685    let poll_interval = std::time::Duration::from_millis(500);
13686
13687    loop {
13688        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
13689        let _ = run_sync_pull();
13690        let relay_state = config::read_relay_state()?;
13691        let peer_entry = relay_state
13692            .get("peers")
13693            .and_then(|p| p.get(&peer_handle))
13694            .cloned();
13695        let token = peer_entry
13696            .as_ref()
13697            .and_then(|e| e.get("slot_token"))
13698            .and_then(Value::as_str)
13699            .unwrap_or("");
13700
13701        if !token.is_empty() {
13702            // Bilateral pin complete — we have their slot_token, we can send.
13703            let trust = config::read_trust()?;
13704            let pinned_in_trust = trust
13705                .get("agents")
13706                .and_then(|a| a.get(&peer_handle))
13707                .is_some();
13708            println!(
13709                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
13710                if pinned_in_trust {
13711                    "VERIFIED"
13712                } else {
13713                    "MISSING (bug)"
13714                }
13715            );
13716            return Ok(());
13717        }
13718
13719        if std::time::Instant::now() >= deadline {
13720            // Timeout — surface the EXACT stuck step. Likely culprits:
13721            //   - peer daemon not running on their box
13722            //   - peer's relay slot is offline
13723            //   - their daemon is on an older binary that doesn't know
13724            //     pair_drop kind=1100 (the P0.1 class — now visible via
13725            //     wire pull --json on their side as a blocking rejection)
13726            bail!(
13727                "wire pair: timed out after {timeout_secs}s. \
13728                 peer {peer_handle} never sent pair_drop_ack. \
13729                 likely causes: (a) their daemon is down — ask them to run \
13730                 `wire status` and `wire daemon &`; (b) their binary is older \
13731                 than 0.5.x and doesn't understand pair_drop events — ask \
13732                 them to `wire upgrade`; (c) network / relay blip — re-run \
13733                 `wire pair {handle_arg}` to retry."
13734            );
13735        }
13736
13737        std::thread::sleep(poll_interval);
13738    }
13739}
13740
13741fn cmd_claim(
13742    nick: &str,
13743    relay_override: Option<&str>,
13744    public_url: Option<&str>,
13745    hidden: bool,
13746    as_json: bool,
13747) -> Result<()> {
13748    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
13749    // + claim handle. Operator should never have to run init/bind-relay first.
13750    let (_did, relay_url, slot_id, slot_token) =
13751        crate::pair_invite::ensure_self_with_relay(relay_override)?;
13752    let card = config::read_agent_card()?;
13753
13754    // v0.13.1 one-name enforcement: the handle you claim in the phonebook
13755    // MUST equal your DID-derived persona, so the directory entry can never
13756    // drift from your agent-card handle. A typed nick that differs is ignored
13757    // (mirrors how `wire init` coerces the typed name). This closes the
13758    // claim-path reopening of the v0.11 "two names" footgun — before this,
13759    // `wire claim coffee-ghost` published coffee-ghost@relay -> your DID while
13760    // your card said e.g. outback-sandpiper. The typed `nick` arg is now
13761    // vestigial, exactly like the one `wire init` / `wire up` already accept.
13762    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
13763    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
13764    if !canonical.is_empty() && nick != canonical && !as_json {
13765        eprintln!(
13766            "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
13767        );
13768    }
13769    let nick = if canonical.is_empty() {
13770        nick
13771    } else {
13772        canonical.as_str()
13773    };
13774    if !crate::pair_profile::is_valid_nick(nick) {
13775        bail!(
13776            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
13777        );
13778    }
13779
13780    let client = crate::relay_client::RelayClient::new(&relay_url);
13781    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
13782    // (back-compat); Some(false) for `--hidden`. Relays older than
13783    // v0.5.19 ignore the field, so this is safe to always send.
13784    let discoverable = if hidden { Some(false) } else { None };
13785    let resp =
13786        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
13787
13788    if as_json {
13789        println!(
13790            "{}",
13791            serde_json::to_string(&json!({
13792                "nick": nick,
13793                "relay": relay_url,
13794                "response": resp,
13795            }))?
13796        );
13797    } else {
13798        // Best-effort: derive the public domain from the relay URL. If
13799        // operator passed --public-url that's the canonical address; else
13800        // the relay URL itself. Falls back to a placeholder if both miss.
13801        let domain = public_url
13802            .unwrap_or(&relay_url)
13803            .trim_start_matches("https://")
13804            .trim_start_matches("http://")
13805            .trim_end_matches('/')
13806            .split('/')
13807            .next()
13808            .unwrap_or("<this-relay-domain>")
13809            .to_string();
13810        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
13811        println!("verify with: wire whois {nick}@{domain}");
13812    }
13813    Ok(())
13814}
13815
13816fn cmd_profile(action: ProfileAction) -> Result<()> {
13817    match action {
13818        ProfileAction::Set { field, value, json } => {
13819            // Try parsing the value as JSON; if that fails, treat it as a
13820            // bare string. Lets operators pass either `42` or `"hello"` or
13821            // `["rust","late-night"]` without quoting hell.
13822            let parsed: Value =
13823                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
13824            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
13825            let published = republish_card_to_phonebook();
13826            if json {
13827                println!(
13828                    "{}",
13829                    serde_json::to_string(&json!({
13830                        "field": field,
13831                        "profile": new_profile,
13832                        "published_to": published,
13833                    }))?
13834                );
13835            } else {
13836                println!("profile.{field} set");
13837                print_profile_publish_result(&published);
13838            }
13839        }
13840        ProfileAction::Get { json } => return cmd_whois(None, json, None),
13841        ProfileAction::Clear { field, json } => {
13842            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
13843            let published = republish_card_to_phonebook();
13844            if json {
13845                println!(
13846                    "{}",
13847                    serde_json::to_string(&json!({
13848                        "field": field,
13849                        "cleared": true,
13850                        "profile": new_profile,
13851                        "published_to": published,
13852                    }))?
13853                );
13854            } else {
13855                println!("profile.{field} cleared");
13856                print_profile_publish_result(&published);
13857            }
13858        }
13859    }
13860    Ok(())
13861}
13862
13863/// Best-effort: re-publish the (freshly re-signed) agent-card to every relay
13864/// this identity already holds a federation slot on, so a `wire profile`
13865/// edit reaches the public phonebook immediately instead of waiting for the
13866/// next `wire up`. Silent no-op when the identity holds no federation slot
13867/// (offline / local-only). `discoverable: None` makes the relay PRESERVE the
13868/// prior setting, so a `--hidden` agent stays hidden across the re-claim.
13869/// Returns the relay URLs the card was published to.
13870fn republish_card_to_phonebook() -> Vec<String> {
13871    let Ok(card) = config::read_agent_card() else {
13872        return Vec::new();
13873    };
13874    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
13875    let persona = crate::agent_card::display_handle_from_did(did).to_string();
13876    if persona.is_empty() {
13877        return Vec::new();
13878    }
13879    let Ok(state) = config::read_relay_state() else {
13880        return Vec::new();
13881    };
13882    let mut published = Vec::new();
13883    for ep in crate::endpoints::self_endpoints(&state) {
13884        if ep.scope != crate::endpoints::EndpointScope::Federation
13885            || ep.slot_id.is_empty()
13886            || ep.slot_token.is_empty()
13887        {
13888            continue;
13889        }
13890        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
13891        if client
13892            .handle_claim_v2(&persona, &ep.slot_id, &ep.slot_token, None, &card, None)
13893            .is_ok()
13894        {
13895            published.push(ep.relay_url.clone());
13896        }
13897    }
13898    published
13899}
13900
13901fn print_profile_publish_result(published: &[String]) {
13902    if published.is_empty() {
13903        println!(
13904            "  (local only — not bound to a federation relay; run `wire up` to publish to the phonebook)"
13905        );
13906    } else {
13907        println!("  published to phonebook: {}", published.join(", "));
13908    }
13909}
13910
13911// ---------- setup — one-shot MCP host registration ----------
13912
13913fn cmd_setup(apply: bool) -> Result<()> {
13914    use std::path::PathBuf;
13915
13916    // The `env` mapping forwards Claude Code's per-session id into the MCP
13917    // server. CRITICAL for per-session identity: the MCP server process does
13918    // NOT inherit CLAUDE_CODE_SESSION_ID (Claude Code sets it for Bash-tool
13919    // subprocesses only), and the MCP `initialize` handshake carries no session
13920    // id — so without this, the server can't tell sessions apart, falls back to
13921    // cwd-detection, and every Claude session under a shared parent dir
13922    // collapses onto ONE identity. Claude Code expands `${CLAUDE_CODE_SESSION_ID}`
13923    // from its own env at MCP launch; wire's `resolve_session_key` reads
13924    // WIRE_SESSION_ID first, so each session becomes its own `by-key/<hash>`.
13925    let entry = json!({
13926        "command": "wire",
13927        "args": ["mcp"],
13928        "env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}
13929    });
13930    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
13931
13932    // Detect probable MCP host config locations. Cross-platform — we only
13933    // touch the file if it already exists OR --apply was passed.
13934    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
13935    if let Some(home) = dirs::home_dir() {
13936        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
13937        // The mcpServers map lives at the top level of that file.
13938        targets.push(("Claude Code", home.join(".claude.json")));
13939        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
13940        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
13941        // Claude Desktop macOS
13942        #[cfg(target_os = "macos")]
13943        targets.push((
13944            "Claude Desktop (macOS)",
13945            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
13946        ));
13947        // Claude Desktop Windows
13948        #[cfg(target_os = "windows")]
13949        if let Ok(appdata) = std::env::var("APPDATA") {
13950            targets.push((
13951                "Claude Desktop (Windows)",
13952                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
13953            ));
13954        }
13955        // Cursor
13956        targets.push(("Cursor", home.join(".cursor/mcp.json")));
13957
13958        // GitHub Copilot (VS Code) — User settings
13959        #[cfg(target_os = "macos")]
13960        targets.push((
13961            "VS Code (GitHub Copilot)",
13962            home.join("Library/Application Support/Code/User/settings.json"),
13963        ));
13964        #[cfg(target_os = "linux")]
13965        targets.push((
13966            "VS Code (GitHub Copilot)",
13967            home.join(".config/Code/User/settings.json"),
13968        ));
13969        #[cfg(target_os = "windows")]
13970        if let Ok(appdata) = std::env::var("APPDATA") {
13971            targets.push((
13972                "VS Code (GitHub Copilot)",
13973                PathBuf::from(appdata).join("Code/User/settings.json"),
13974            ));
13975        }
13976
13977        // VS Code Insiders variant
13978        #[cfg(target_os = "macos")]
13979        targets.push((
13980            "VS Code Insiders",
13981            home.join("Library/Application Support/Code - Insiders/User/settings.json"),
13982        ));
13983        #[cfg(target_os = "linux")]
13984        targets.push((
13985            "VS Code Insiders",
13986            home.join(".config/Code - Insiders/User/settings.json"),
13987        ));
13988        #[cfg(target_os = "windows")]
13989        if let Ok(appdata) = std::env::var("APPDATA") {
13990            targets.push((
13991                "VS Code Insiders",
13992                PathBuf::from(appdata).join("Code - Insiders/User/settings.json"),
13993            ));
13994        }
13995
13996        // GitHub Copilot CLI (`gh copilot` / `copilot`). v0.13.6: standard
13997        // MCP shape (`mcpServers` root key, same as Claude Code), lives at
13998        // `~/.copilot/mcp-config.json` on all platforms — XDG-overridable
13999        // on Unix via `$XDG_CONFIG_HOME/copilot/mcp-config.json`.
14000        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
14001            targets.push((
14002                "GitHub Copilot CLI (XDG)",
14003                PathBuf::from(xdg).join("copilot/mcp-config.json"),
14004            ));
14005        }
14006        targets.push(("GitHub Copilot CLI", home.join(".copilot/mcp-config.json")));
14007    }
14008    // Workspace-local VS Code settings (GitHub Copilot workspace config)
14009    targets.push((
14010        "VS Code (workspace)",
14011        PathBuf::from(".vscode/settings.json"),
14012    ));
14013    // Project-local — works for several MCP-aware tools
14014    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
14015
14016    println!("wire setup\n");
14017    println!("MCP server snippet (add this to your client's mcpServers):");
14018    println!();
14019    println!("{entry_pretty}");
14020    println!();
14021
14022    if !apply {
14023        println!("Probable MCP host config locations on this machine:");
14024        for (name, path) in &targets {
14025            let marker = if path.exists() {
14026                "✓ found"
14027            } else {
14028                "  (would create)"
14029            };
14030            println!("  {marker:14}  {name}: {}", path.display());
14031        }
14032        println!();
14033        println!("Run `wire setup --apply` to merge wire into each config above.");
14034        println!(
14035            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
14036        );
14037        return Ok(());
14038    }
14039
14040    let mut modified: Vec<String> = Vec::new();
14041    let mut skipped: Vec<String> = Vec::new();
14042    for (name, path) in &targets {
14043        match upsert_mcp_entry(path, "wire", &entry) {
14044            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
14045            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
14046            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
14047        }
14048    }
14049    if !modified.is_empty() {
14050        println!("Modified:");
14051        for line in &modified {
14052            println!("  {line}");
14053        }
14054        println!();
14055        println!("Restart the app(s) above to load wire MCP.");
14056    }
14057    if !skipped.is_empty() {
14058        println!();
14059        println!("Skipped:");
14060        for line in &skipped {
14061            println!("  {line}");
14062        }
14063    }
14064    Ok(())
14065}
14066
14067/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
14068/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
14069///
14070/// Supports two config formats:
14071/// - Standard MCP: `{"mcpServers": {"wire": {...}}}`
14072/// - VS Code: `{"mcp": {"servers": {"wire": {...}}}}`
14073fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
14074    let mut cfg: Value = if path.exists() {
14075        let body = std::fs::read_to_string(path).context("reading config")?;
14076        if body.trim().is_empty() {
14077            json!({})
14078        } else {
14079            // Refuse to default a non-empty-but-unparseable file to `{}` —
14080            // doing so would overwrite the whole file with just our entry.
14081            // VS Code's settings.json is JSONC (// comments, trailing commas)
14082            // which serde_json can't parse; surface it so the caller lists
14083            // this target under "Skipped" and the user adds wire manually.
14084            serde_json::from_str(&body).with_context(|| {
14085                format!(
14086                    "{} is not strict JSON (comments / trailing commas?); \
14087                     add the wire MCP entry manually to avoid overwriting it",
14088                    path.display()
14089                )
14090            })?
14091        }
14092    } else {
14093        json!({})
14094    };
14095    if !cfg.is_object() {
14096        cfg = json!({});
14097    }
14098
14099    // Detect VS Code settings.json (contains "mcp.servers" instead of "mcpServers")
14100    let is_vscode = path.to_string_lossy().contains("Code/User/settings.json")
14101        || path.to_string_lossy().contains(".vscode/settings.json")
14102        || path.to_string_lossy().contains("Code - Insiders");
14103
14104    let root = cfg.as_object_mut().unwrap();
14105
14106    if is_vscode {
14107        // VS Code format: {"mcp": {"servers": {"wire": {...}}}}
14108        let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
14109        if !mcp.is_object() {
14110            *mcp = json!({});
14111        }
14112        let mcp_obj = mcp.as_object_mut().unwrap();
14113        let servers = mcp_obj
14114            .entry("servers".to_string())
14115            .or_insert_with(|| json!({}));
14116        if !servers.is_object() {
14117            *servers = json!({});
14118        }
14119        let map = servers.as_object_mut().unwrap();
14120        if map.get(server_name) == Some(entry) {
14121            return Ok(false);
14122        }
14123        map.insert(server_name.to_string(), entry.clone());
14124    } else {
14125        // Standard MCP format: {"mcpServers": {"wire": {...}}}
14126        let servers = root
14127            .entry("mcpServers".to_string())
14128            .or_insert_with(|| json!({}));
14129        if !servers.is_object() {
14130            *servers = json!({});
14131        }
14132        let map = servers.as_object_mut().unwrap();
14133        if map.get(server_name) == Some(entry) {
14134            return Ok(false);
14135        }
14136        map.insert(server_name.to_string(), entry.clone());
14137    }
14138
14139    if let Some(parent) = path.parent()
14140        && !parent.as_os_str().is_empty()
14141    {
14142        std::fs::create_dir_all(parent).context("creating parent dir")?;
14143    }
14144    let out = serde_json::to_string_pretty(&cfg)? + "\n";
14145    std::fs::write(path, out).context("writing config")?;
14146    Ok(true)
14147}
14148
14149// ---------- setup --statusline ----------
14150
14151/// Bundled Claude Code statusLine renderer (persona emoji + nickname + cwd,
14152/// pidfile+tasklist liveness). Embedded at compile time; written to the
14153/// Claude config dir on `wire setup --statusline --apply`.
14154const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
14155
14156/// `wire setup --statusline [--apply] [--remove]` — install/remove a Claude
14157/// Code statusLine that renders this session's wire persona. Honors
14158/// `$CLAUDE_CONFIG_DIR` (default `~/.claude`). Writes the renderer script and
14159/// merges a `statusLine` block into settings.json, preserving existing keys
14160/// and refusing to clobber a settings.json that exists but isn't valid JSON.
14161fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
14162    use std::path::PathBuf;
14163    let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
14164        .map(PathBuf::from)
14165        .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
14166        .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
14167    let settings_path = cfg_dir.join("settings.json");
14168    let script_path = cfg_dir.join("wire-statusline.sh");
14169    // Resolve the shell invocation. On Windows a bare `bash` resolves to
14170    // System32\bash.exe (WSL) — wrong environment, Windows paths invalid,
14171    // statusline breaks — so we emit the absolute git-bash path. On Unix a
14172    // bare `bash <script>` is correct. Script path is quoted for spaces.
14173    let (command, command_warn) = statusline_command(&script_path);
14174
14175    println!("wire setup --statusline\n");
14176    println!("Claude config dir: {}", cfg_dir.display());
14177    println!("  renderer:  {}", script_path.display());
14178    println!("  settings:  {}", settings_path.display());
14179    if let Some(w) = &command_warn {
14180        println!("  ⚠ {w}");
14181    }
14182    println!();
14183
14184    if remove {
14185        if !apply {
14186            println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
14187            println!("Run `wire setup --statusline --remove --apply` to do it.");
14188            return Ok(());
14189        }
14190        let dropped = remove_statusline_entry(&settings_path)?;
14191        let script_gone = if script_path.exists() {
14192            std::fs::remove_file(&script_path).is_ok()
14193        } else {
14194            false
14195        };
14196        println!(
14197            "Removed: statusLine key {} · renderer {}",
14198            if dropped { "dropped" } else { "absent" },
14199            if script_gone { "deleted" } else { "absent" }
14200        );
14201        return Ok(());
14202    }
14203
14204    if !apply {
14205        println!("Would write the renderer above and merge into settings.json:");
14206        println!();
14207        println!("  \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
14208        println!();
14209        println!("Resulting statusline:  ● <emoji> <nickname> · <cwd>");
14210        println!("Run `wire setup --statusline --apply` to install.");
14211        println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
14212        return Ok(());
14213    }
14214
14215    if let Some(parent) = script_path.parent() {
14216        std::fs::create_dir_all(parent).context("creating Claude config dir")?;
14217    }
14218    std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
14219    #[cfg(unix)]
14220    {
14221        use std::os::unix::fs::PermissionsExt;
14222        if let Ok(meta) = std::fs::metadata(&script_path) {
14223            let mut perms = meta.permissions();
14224            perms.set_mode(0o755);
14225            let _ = std::fs::set_permissions(&script_path, perms);
14226        }
14227    }
14228    let changed = upsert_statusline_entry(&settings_path, &command)?;
14229    println!("✓ renderer written: {}", script_path.display());
14230    if changed {
14231        println!("✓ merged statusLine into: {}", settings_path.display());
14232    } else {
14233        println!(
14234            "  settings.json already configured: {}",
14235            settings_path.display()
14236        );
14237    }
14238    println!();
14239    println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
14240    Ok(())
14241}
14242
14243/// Merge a `statusLine` command block into a Claude settings.json, preserving
14244/// all other keys. Returns Ok(true) if changed. Refuses to clobber a file that
14245/// exists but is not valid JSON.
14246fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
14247    let mut cfg: Value = if path.exists() {
14248        let body = std::fs::read_to_string(path).context("reading settings.json")?;
14249        if body.trim().is_empty() {
14250            json!({})
14251        } else {
14252            serde_json::from_str(&body).context(
14253                "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
14254            )?
14255        }
14256    } else {
14257        json!({})
14258    };
14259    if !cfg.is_object() {
14260        bail!("settings.json root is not a JSON object — refusing to clobber");
14261    }
14262    let desired = json!({"type": "command", "command": command});
14263    let root = cfg.as_object_mut().unwrap();
14264    if root.get("statusLine") == Some(&desired) {
14265        return Ok(false);
14266    }
14267    root.insert("statusLine".to_string(), desired);
14268    if let Some(parent) = path.parent()
14269        && !parent.as_os_str().is_empty()
14270    {
14271        std::fs::create_dir_all(parent).context("creating parent dir")?;
14272    }
14273    let out = serde_json::to_string_pretty(&cfg)? + "\n";
14274    std::fs::write(path, out).context("writing settings.json")?;
14275    Ok(true)
14276}
14277
14278/// Drop the `statusLine` key from settings.json. Ok(true) if a key was removed,
14279/// Ok(false) if file/key absent. Refuses to edit invalid JSON.
14280fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
14281    if !path.exists() {
14282        return Ok(false);
14283    }
14284    let body = std::fs::read_to_string(path).context("reading settings.json")?;
14285    if body.trim().is_empty() {
14286        return Ok(false);
14287    }
14288    let mut cfg: Value = serde_json::from_str(&body)
14289        .context("settings.json is not valid JSON — refusing to edit")?;
14290    let Some(root) = cfg.as_object_mut() else {
14291        return Ok(false);
14292    };
14293    if root.remove("statusLine").is_none() {
14294        return Ok(false);
14295    }
14296    let out = serde_json::to_string_pretty(&cfg)? + "\n";
14297    std::fs::write(path, out).context("writing settings.json")?;
14298    Ok(true)
14299}
14300
14301/// Build the `statusLine.command` string for this platform. Returns the
14302/// command plus an optional warning to surface to the operator.
14303fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
14304    #[cfg(windows)]
14305    {
14306        match resolve_git_bash() {
14307            Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
14308            None => (
14309                format!("bash \"{}\"", script_path.display()),
14310                Some(
14311                    "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
14312                     WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
14313                     Windows or set statusLine.command to your git-bash bash.exe path."
14314                        .to_string(),
14315                ),
14316            ),
14317        }
14318    }
14319    #[cfg(unix)]
14320    {
14321        (format!("bash \"{}\"", script_path.display()), None)
14322    }
14323}
14324
14325/// Locate the git-bash `bash.exe` on Windows, avoiding the WSL launcher at
14326/// `System32\bash.exe`. Claude Code's statusLine command needs the real
14327/// git-bash so the renderer runs in a POSIX-ish env with valid paths.
14328#[cfg(windows)]
14329fn resolve_git_bash() -> Option<String> {
14330    use std::path::PathBuf;
14331    // 1. `where.exe bash` — take the first hit that is NOT under System32
14332    //    (that one is the WSL `bash.exe` launcher).
14333    if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
14334        && out.status.success()
14335    {
14336        for line in String::from_utf8_lossy(&out.stdout).lines() {
14337            let p = line.trim();
14338            if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
14339                return Some(p.to_string());
14340            }
14341        }
14342    }
14343    // 2. Common Git-for-Windows install locations.
14344    let candidates = [
14345        std::env::var("ProgramFiles")
14346            .ok()
14347            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14348        std::env::var("ProgramFiles(x86)")
14349            .ok()
14350            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14351        std::env::var("LocalAppData")
14352            .ok()
14353            .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
14354    ];
14355    candidates
14356        .into_iter()
14357        .flatten()
14358        .find(|c| PathBuf::from(c).exists())
14359}
14360
14361#[cfg(test)]
14362mod statusline_tests {
14363    use super::*;
14364
14365    #[test]
14366    fn statusline_merge_preserves_keys_and_is_idempotent() {
14367        let dir = tempfile::tempdir().unwrap();
14368        let path = dir.path().join("settings.json");
14369        std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
14370        // First merge changes the file but keeps existing keys.
14371        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14372        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14373        assert_eq!(v["theme"], "dark");
14374        assert_eq!(v["model"], "opus");
14375        assert_eq!(v["statusLine"]["type"], "command");
14376        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14377        // Identical re-merge = no change.
14378        assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14379        // Remove drops ONLY statusLine.
14380        assert!(remove_statusline_entry(&path).unwrap());
14381        let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14382        assert_eq!(v2["theme"], "dark");
14383        assert!(v2.get("statusLine").is_none());
14384        // Remove again = no-op.
14385        assert!(!remove_statusline_entry(&path).unwrap());
14386    }
14387
14388    #[test]
14389    fn statusline_merge_refuses_to_clobber_invalid_json() {
14390        let dir = tempfile::tempdir().unwrap();
14391        let path = dir.path().join("settings.json");
14392        std::fs::write(&path, "this is not json {").unwrap();
14393        let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
14394        assert!(
14395            format!("{err:#}").contains("not valid JSON"),
14396            "err: {err:#}"
14397        );
14398        // File left untouched.
14399        assert_eq!(
14400            std::fs::read_to_string(&path).unwrap(),
14401            "this is not json {"
14402        );
14403    }
14404
14405    #[test]
14406    fn statusline_creates_settings_when_absent() {
14407        let dir = tempfile::tempdir().unwrap();
14408        let path = dir.path().join("settings.json");
14409        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14410        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14411        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14412    }
14413}
14414
14415// ---------- notify (Goal 2) ----------
14416
14417fn cmd_notify(
14418    interval_secs: u64,
14419    peer_filter: Option<&str>,
14420    once: bool,
14421    as_json: bool,
14422) -> Result<()> {
14423    use crate::inbox_watch::InboxWatcher;
14424    let cursor_path = config::state_dir()?.join("notify.cursor");
14425    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
14426    // v0.13.x identity work: a long-running notify loop racing another
14427    // wire process on the same inbox cursor silently drops toasts.
14428    // Skipped under `--once` (single sweep, no cursor ownership).
14429    if !once {
14430        crate::session::warn_on_identity_collision(std::process::id(), "notify");
14431    }
14432
14433    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
14434        let events = watcher.poll()?;
14435        for ev in events {
14436            if let Some(p) = peer_filter
14437                && ev.peer != p
14438            {
14439                continue;
14440            }
14441            if as_json {
14442                println!("{}", serde_json::to_string(&ev)?);
14443            } else {
14444                os_notify_inbox_event(&ev);
14445            }
14446        }
14447        watcher.save_cursors(&cursor_path)?;
14448        Ok(())
14449    };
14450
14451    if once {
14452        return sweep(&mut watcher);
14453    }
14454
14455    let interval = std::time::Duration::from_secs(interval_secs.max(1));
14456    loop {
14457        if let Err(e) = sweep(&mut watcher) {
14458            eprintln!("wire notify: sweep error: {e}");
14459        }
14460        std::thread::sleep(interval);
14461    }
14462}
14463
14464fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
14465    let who = persona_label(&ev.peer);
14466    let title = if ev.verified {
14467        format!("wire ← {who}")
14468    } else {
14469        format!("wire ← {who} (UNVERIFIED)")
14470    };
14471    let body = format!("{}: {}", ev.kind, ev.body_preview);
14472    // Issue #81: dedup by (peer, event_id) so that overlapping monitor
14473    // sweeps / restarts with a torn cursor don't fire the same toast over
14474    // and over. `event_id` may be empty for pre-v0.5 legacy events; fall
14475    // back to the body preview in that case so the key still varies per
14476    // event rather than collapsing every keyless event into one entry.
14477    let id = if ev.event_id.is_empty() {
14478        ev.body_preview.as_str()
14479    } else {
14480        ev.event_id.as_str()
14481    };
14482    let dedup_key = format!("inbox:{}:{}", ev.peer, id);
14483    crate::os_notify::toast_dedup(&dedup_key, &title, &body);
14484}
14485
14486#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
14487fn os_toast(title: &str, body: &str) {
14488    eprintln!("[wire notify] {title}\n  {body}");
14489}
14490
14491// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).
14492
14493#[cfg(test)]
14494mod relay_url_tests {
14495    use super::*;
14496
14497    #[test]
14498    fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
14499        // Bug 1: `wire up <handle>@<relay>` and `wire bind-relay
14500        // <handle>@<relay>` previously persisted/published the endpoint as
14501        // `https://<handle>@<relay>` — handle stuck in URL userinfo. Every
14502        // inbound event POST to that endpoint 4xxed (Cloudflare 400 on
14503        // wireup.net); bilateral pairing couldn't complete.
14504        //
14505        // Strip+warn (not hard-reject): mirrors cmd_up's already-bound
14506        // branch, which has always ignored the userinfo on the "keeping
14507        // existing binding" warning path. `<handle>@<relay>` is also
14508        // literally the wire dial-address format — natural by analogy.
14509
14510        assert_eq!(
14511            strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
14512            "https://wireup.net",
14513            "https URL with handle userinfo is stripped to the bare host"
14514        );
14515        assert_eq!(
14516            strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
14517            "http://127.0.0.1:8771",
14518            "http + port + userinfo is stripped, port preserved"
14519        );
14520        // user:password@host — both halves of userinfo are dropped.
14521        assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
14522        // Authority with port + userinfo.
14523        assert_eq!(
14524            strip_relay_url_userinfo("https://nick@host:8443"),
14525            "https://host:8443"
14526        );
14527        // Schemeless `<handle>@<host>` — strips correctly. (cmd_up's
14528        // bare-host normalize prepends https:// before calling, but the
14529        // function is robust to either input.)
14530        assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
14531        // Path / query / fragment AFTER the authority are preserved.
14532        assert_eq!(
14533            strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
14534            "https://wireup.net/v1/events?x=1#frag"
14535        );
14536    }
14537
14538    #[test]
14539    fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
14540        // Bare host (https / http, with and without port, with path / query).
14541        for ok in [
14542            "https://wireup.net",
14543            "http://wireup.net",
14544            "http://127.0.0.1:8771",
14545            "https://relay.example.com:9443/v1/wire",
14546            "https://wireup.net/?env=prod",
14547            // Path / query containing `@` is fine — it's not in the authority.
14548            "https://wireup.net/users/me@example.com",
14549            "https://wireup.net/?to=me@example.com",
14550            // Fragment with @ — fine.
14551            "https://wireup.net/#contact@me",
14552            // IPv6 literal (no @ in authority).
14553            "http://[::1]:8771",
14554            // Schemeless bare host — also fine.
14555            "wireup.net",
14556            "wireup.net:8443",
14557        ] {
14558            assert_eq!(
14559                strip_relay_url_userinfo(ok),
14560                ok,
14561                "clean URL `{ok}` must pass through unchanged"
14562            );
14563        }
14564    }
14565
14566    #[test]
14567    fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
14568        // Belt-and-suspenders: even if a future code path bypasses
14569        // strip_relay_url_userinfo at the entry, the persist/publish
14570        // boundary must refuse a userinfo URL. This is the second line
14571        // of defense that keeps a malformed endpoint out of the SIGNED
14572        // agent-card and the persisted relay_state.
14573        assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
14574        assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
14575        assert!(
14576            assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
14577        );
14578
14579        let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
14580            .unwrap_err()
14581            .to_string();
14582        assert!(
14583            err.contains("invariant violated"),
14584            "persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
14585        );
14586        assert!(
14587            err.contains("strip_relay_url_userinfo"),
14588            "error must name the upstream filter so the caller can audit the bypass: {err}"
14589        );
14590        // user:password@host is just as bad — userinfo is userinfo.
14591        assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
14592        // Authority with port + userinfo.
14593        assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
14594    }
14595
14596    #[test]
14597    fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
14598        // Bug 3 (cosmetic): `wire up <handle>@<relay>` echoed `claimed
14599        // <nick>@<nick>@<relay>` because strip_proto left the userinfo in.
14600        // With Bug 1's strip+warn in cmd_up, the claim step receives a
14601        // bare host — strip_proto returns `<host>` and the echo is
14602        // `<nick>@<host>` exactly once. Verified end-to-end here:
14603        let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
14604        assert_eq!(after_strip, "https://wireup.net");
14605        assert_eq!(strip_proto(&after_strip), "wireup.net");
14606        // And the doubled-echo failure mode that motivated the fix:
14607        assert!(
14608            strip_proto("https://nick@wireup.net").contains('@'),
14609            "strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
14610        );
14611    }
14612}
14613
14614#[cfg(test)]
14615mod self_pair_guard_tests {
14616    use super::*;
14617
14618    #[test]
14619    fn reject_self_pair_after_resolution_blocks_matching_dids() {
14620        // Issue #30 (explicit "Optional" ask): when both terminals collapse
14621        // onto one wire identity (a v0.13-era WIRE_SESSION_ID propagation
14622        // gap or a shared WIRE_HOME), the resolved peer DID matches the
14623        // local DID and pair_drop silently goes nowhere. Guard surfaces
14624        // it as a refusable error with the diagnostic remediation path.
14625
14626        let err = reject_self_pair_after_resolution(
14627            "did:wire:winter-bay-4092b577",
14628            "did:wire:winter-bay-4092b577",
14629        )
14630        .unwrap_err()
14631        .to_string();
14632        assert!(
14633            err.contains("refusing to self-pair"),
14634            "must explicitly refuse, not silently bail: {err}"
14635        );
14636        assert!(
14637            err.contains("did:wire:winter-bay-4092b577"),
14638            "must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
14639        );
14640        assert!(
14641            err.contains("issue #30") || err.contains("issue #29"),
14642            "must point at the tracking issue so historical context is one search away: {err}"
14643        );
14644        // Remediation must be copy-paste ready — both POSIX and PowerShell
14645        // (the failure mode is Windows-prevalent per #30).
14646        assert!(
14647            err.contains("WIRE_SESSION_ID"),
14648            "remediation must name the env var operators set: {err}"
14649        );
14650        assert!(
14651            err.contains("uuidgen") || err.contains("NewGuid"),
14652            "remediation must include a concrete command to mint a unique id: {err}"
14653        );
14654    }
14655
14656    #[test]
14657    fn reject_self_pair_after_resolution_allows_distinct_dids() {
14658        // Sanity: the guard must not fire for any normal pair attempt
14659        // between two distinct identities. Cover the common shapes:
14660        // adjective-noun personas (post-v0.11), bare keypair hashes, and
14661        // mixed-case DIDs that happen to share a prefix.
14662        reject_self_pair_after_resolution(
14663            "did:wire:winter-bay-4092b577",
14664            "did:wire:cedar-bayou-0616dc6c",
14665        )
14666        .unwrap();
14667        reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
14668            .unwrap();
14669        // Same persona prefix, different suffix-hash → distinct DIDs (the
14670        // suffix is the load-bearing identifier). Must NOT trigger the
14671        // guard.
14672        reject_self_pair_after_resolution(
14673            "did:wire:noble-canyon-deadbeef",
14674            "did:wire:noble-canyon-cafef00d",
14675        )
14676        .unwrap();
14677    }
14678}
14679
14680#[cfg(test)]
14681mod slot_reresolve_tests {
14682    use super::*;
14683
14684    /// Issue #15: the gating logic of try_reresolve_peer_on_slot_4xx
14685    /// must short-circuit BEFORE any network call when the error shape
14686    /// doesn't smell like slot rotation, when the peer was already
14687    /// re-resolved this push, or when there's no peer entry to work
14688    /// against. Three of those four short-circuit paths are testable
14689    /// without a mock relay; the fourth (the actual whois + slot
14690    /// comparison) requires either a live test server or a mock
14691    /// transport, so it's covered manually via the failover_tests
14692    /// helper + integration check in a separate PR.
14693    ///
14694    /// What these tests pin:
14695    ///   - 200/500/timeout-shape errors do NOT trigger a re-resolve
14696    ///     (avoids wasted whois RTTs and churn in steady-state).
14697    ///   - Same peer twice in one push call only attempts re-resolve
14698    ///     once (rate limit the issue specifies).
14699    ///   - Missing peer entry surfaces as an explicit error, NOT a
14700    ///     silent skip (operator can see the malformed state).
14701    ///   - Peer with no federation endpoint surfaces as an explicit
14702    ///     error (you can't re-resolve a slot you can't address).
14703
14704    #[test]
14705    fn try_reresolve_skips_when_error_is_not_4xx_shape() {
14706        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
14707        let already = std::collections::HashSet::new();
14708        // 200 OK shouldn't ever land in this path, but sanity check the
14709        // negative filter: any error string without "404"/"410" is a no-op.
14710        let res =
14711            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
14712                .unwrap();
14713        assert!(!res, "502 must NOT trigger a re-resolve");
14714
14715        let res =
14716            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
14717                .unwrap();
14718        assert!(!res, "transport errors must NOT trigger a re-resolve");
14719
14720        let res = try_reresolve_peer_on_slot_4xx(
14721            &mut state,
14722            "some-peer",
14723            "post failed: 401 Unauthorized",
14724            &already,
14725        )
14726        .unwrap();
14727        assert!(
14728            !res,
14729            "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
14730        );
14731    }
14732
14733    #[test]
14734    fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
14735        // The issue's rate limit: "at most one whois per peer per push call."
14736        // Caller tracks via `already_tried`; helper must honor it BEFORE
14737        // attempting any I/O (otherwise a bad-state peer would burn a
14738        // network call per event in the outbox).
14739        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
14740        let mut already = std::collections::HashSet::new();
14741        already.insert("some-peer".to_string());
14742        let res = try_reresolve_peer_on_slot_4xx(
14743            &mut state,
14744            "some-peer",
14745            "post failed: 410 Gone",
14746            &already,
14747        )
14748        .unwrap();
14749        assert!(
14750            !res,
14751            "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
14752        );
14753    }
14754
14755    #[test]
14756    fn try_reresolve_errors_when_peer_missing_from_state() {
14757        // Surface state corruption explicitly rather than silently
14758        // returning Ok(false). If a peer disappeared from relay_state
14759        // mid-loop the operator needs to see it.
14760        let mut state = json!({"peers": {}});
14761        let already = std::collections::HashSet::new();
14762        let err = try_reresolve_peer_on_slot_4xx(
14763            &mut state,
14764            "missing-peer",
14765            "post failed: 410 Gone",
14766            &already,
14767        )
14768        .unwrap_err()
14769        .to_string();
14770        assert!(
14771            err.contains("missing-peer") && err.contains("not in relay_state"),
14772            "missing-peer error must name the peer + the failure: {err}"
14773        );
14774    }
14775
14776    #[test]
14777    fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
14778        // A peer with only local-scope endpoints (UDS / 127.0.0.1) has
14779        // no relay domain to whois against. Helper must surface this as
14780        // an actionable error, not a silent skip — the operator's
14781        // remediation is "pair via federation" or "you're on the same
14782        // box, the slot can't be 410'd by a peer who controls the
14783        // socket."
14784        let mut state = json!({
14785            "peers": {
14786                "local-only": {
14787                    "endpoints": [
14788                        {
14789                            "scope": "Local",
14790                            "relay_url": "http://127.0.0.1:8771",
14791                            "slot_id": "loc",
14792                            "slot_token": "tok"
14793                        }
14794                    ]
14795                }
14796            }
14797        });
14798        let already = std::collections::HashSet::new();
14799        let err = try_reresolve_peer_on_slot_4xx(
14800            &mut state,
14801            "local-only",
14802            "post failed: 410 Gone",
14803            &already,
14804        )
14805        .unwrap_err()
14806        .to_string();
14807        assert!(
14808            err.contains("federation endpoint"),
14809            "no-federation error must name the problem: {err}"
14810        );
14811    }
14812
14813    /// Issue #69: pin the word-boundary behavior of
14814    /// `error_smells_like_slot_4xx`. Prior implementation used a bare
14815    /// `contains("410") || contains("404")` substring match, which
14816    /// false-triggered on any unrelated error string containing those
14817    /// digits — e.g. slot ids that happen to start with `410`, request
14818    /// IDs, byte counts, etc.  Each false-positive cost a wasted whois
14819    /// per peer per push and a misleading "peer slot rotated" log line.
14820    ///
14821    /// These tests pin three classes:
14822    ///   - Real reqwest StatusCode Display shapes (`": 410 Gone"`,
14823    ///     `": 404 Not Found"`) trigger.
14824    ///   - Real UDS bare-`u16` shapes (`": 410:"`, `": 404:"`) trigger.
14825    ///   - Substring lookalikes (`"slot 4101 expired"`,
14826    ///     `"request_id=410abc"`, `"received 4040 bytes"`,
14827    ///     `"event 0x4104"`) do NOT trigger.
14828    #[test]
14829    fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
14830        // reqwest::StatusCode Display is "<u16> <reason>", embedded in
14831        // the post_event failure format string as "...failed: <status>: <detail>".
14832        assert!(error_smells_like_slot_4xx(
14833            "post_event failed: 410 Gone: slot rotated by peer"
14834        ));
14835        assert!(error_smells_like_slot_4xx(
14836            "post_event failed: 404 Not Found: handle no longer claimed"
14837        ));
14838    }
14839
14840    #[test]
14841    fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
14842        // UDS path formats status as a bare u16, so the shape is
14843        // "...failed: 410: <detail>" with the status flanked by spaces
14844        // and colons (no reason phrase).
14845        assert!(error_smells_like_slot_4xx(
14846            "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
14847        ));
14848        assert!(error_smells_like_slot_4xx(
14849            "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
14850        ));
14851    }
14852
14853    #[test]
14854    fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
14855        // The bug being fixed: the prior `contains("410")` predicate
14856        // matched ALL of these, burning a whois RTT and emitting a
14857        // spurious "peer slot rotated" log line each time.
14858        let false_positives = [
14859            "push aborted: slot 4101 expired",
14860            "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
14861            "post_event failed: 500: received 4040 bytes, expected envelope",
14862            "post_event failed: 500: event 0x4104 malformed",
14863            "post_event failed: 503: backlog=4102 entries pending",
14864            // 4044 is "received bytes" or anything containing 404 mid-token.
14865            "post_event failed: 500: tx_id=4044beef",
14866            // pure digit substrings inside identifiers / hashes:
14867            "post_event failed: 500: hash=abc410def",
14868        ];
14869        for case in false_positives {
14870            assert!(
14871                !error_smells_like_slot_4xx(case),
14872                "must NOT trigger re-resolve on substring lookalike: {case:?}"
14873            );
14874        }
14875    }
14876
14877    #[test]
14878    fn error_smells_like_slot_4xx_handles_edge_positions() {
14879        // Token at start of string (no preceding char).
14880        assert!(error_smells_like_slot_4xx("410 Gone"));
14881        assert!(error_smells_like_slot_4xx("404 Not Found"));
14882        // Token at end of string (no trailing char).
14883        assert!(error_smells_like_slot_4xx("got 410"));
14884        assert!(error_smells_like_slot_4xx("got 404"));
14885        // Tab and newline as separators (logs sometimes carry these).
14886        assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
14887        assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
14888        // Pure digit-only input that IS the code — token at start AND end.
14889        assert!(error_smells_like_slot_4xx("410"));
14890        assert!(error_smells_like_slot_4xx("404"));
14891        // Empty / no-match.
14892        assert!(!error_smells_like_slot_4xx(""));
14893        assert!(!error_smells_like_slot_4xx("no relevant status"));
14894        // 411-414, 401-403, 405-409 must NOT trigger (only 410/404 are
14895        // the slot-rotation shape per issue #15).
14896        assert!(!error_smells_like_slot_4xx(
14897            "post_event failed: 401 Unauthorized"
14898        ));
14899        assert!(!error_smells_like_slot_4xx(
14900            "post_event failed: 403 Forbidden"
14901        ));
14902        assert!(!error_smells_like_slot_4xx(
14903            "post_event failed: 411 Length Required"
14904        ));
14905    }
14906}