Skip to main content

wire/
cli.rs

1//! `wire` CLI surface.
2//!
3//! Every subcommand emits human-readable text by default and structured JSON
4//! when `--json` is passed. Stable JSON shape is part of the API contract —
5//! see `docs/AGENT_INTEGRATION.md`.
6//!
7//! Subcommand split:
8//!   - **agent-safe**: `whoami`, `peers`, `verify`, `send`, `tail` — pure
9//!     message-layer ops, no trust establishment.
10//!   - **trust-establishing**: `init`, `pair-host`, `pair-join`. The CLI
11//!     uses interactive `y/N` prompts here. The MCP equivalents
12//!     (`wire_init`, `wire_pair_initiate`, `wire_pair_join`, `wire_pair_check`,
13//!     `wire_pair_confirm`) preserve the human gate by requiring the user to
14//!     type the 6 SAS digits back into chat — see `docs/THREAT_MODEL.md` T10/T14.
15
16use anyhow::{Context, Result, anyhow, bail};
17use clap::{Parser, Subcommand};
18use serde_json::{Value, json};
19
20use crate::{
21    agent_card::{build_agent_card, sign_agent_card},
22    config,
23    signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
24    trust::{add_self_to_trust, empty_trust},
25};
26
27/// Top-level CLI.
28#[derive(Parser, Debug)]
29#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
30pub struct Cli {
31    #[command(subcommand)]
32    pub command: Command,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Command {
37    /// Generate a keypair, write self-card, and bind an inbound slot.
38    /// (HUMAN-ONLY — DO NOT exec from agents.)
39    ///
40    /// v0.9: refuses to create a slotless session by default. Pre-v0.9
41    /// the silent slotless state caused the 2026-05-23 silent-fail
42    /// incident — pairing + sending succeeded but peers black-holed
43    /// inbound. Operators must now name how the session is reachable:
44    /// `--relay <url>` (binds a slot inline) or `--offline` (opt into
45    /// slotless, acknowledge `wire bind-relay` is required before any
46    /// pair or send).
47    ///
48    /// v0.13.1: folded into `wire up` and hidden. Your handle is your
49    /// DID-derived persona (one-name rule), so the typed `handle` arg is a
50    /// vestigial seed with no effect on identity. Kept callable for explicit
51    /// offline keygen (`wire init x --offline`); everyone else uses `wire up`.
52    #[command(hide = true)]
53    Init {
54        /// Vestigial seed — ignored; your handle is your DID-derived persona.
55        handle: String,
56        /// Optional display name (defaults to capitalized handle).
57        #[arg(long)]
58        name: Option<String>,
59        /// Relay URL — binds an inbound slot in the same step. Required
60        /// unless `--offline` is passed. Example:
61        /// `--relay http://127.0.0.1:8771` (local), `--relay https://wireup.net`
62        /// (federation).
63        #[arg(long)]
64        relay: Option<String>,
65        /// v0.9: opt into a slotless session — keypair only, no inbound
66        /// mailbox. You MUST run `wire bind-relay <url>` before any
67        /// pair / send / dial; until then peers cannot reach you.
68        /// Useful for offline keypair generation; rare in practice.
69        #[arg(long, conflicts_with = "relay")]
70        offline: bool,
71        /// Emit JSON.
72        #[arg(long)]
73        json: bool,
74    },
75    // (Old `Join` stub removed in iter 11 — superseded by `pair-join` with
76    // `join` alias. See PairJoin below.)
77    /// Print this agent's identity (DID, fingerprint, mailbox slot).
78    Whoami {
79        #[arg(long)]
80        json: bool,
81        /// Print just `<emoji> <nickname>` (e.g. `🦊 foxtrot-meadow`).
82        /// Plain text, no ANSI escapes. Useful for piping into other tools.
83        #[arg(long, conflicts_with = "json")]
84        short: bool,
85        /// Print `<emoji> <nickname>` wrapped in ANSI 256-color escapes.
86        /// Drop into a Claude Code statusline command for live identity display.
87        #[arg(long, conflicts_with_all = ["json", "short"])]
88        colored: bool,
89    },
90    /// List pinned peers with their tiers and capabilities.
91    Peers {
92        #[arg(long)]
93        json: bool,
94    },
95    /// v0.9.5: emit shell completion script to stdout. Pipe to your
96    /// shell's completion dir to enable tab-completion of wire verbs
97    /// + handles + flags.
98    ///
99    /// Example installs:
100    ///   bash:       `wire completions bash > /etc/bash_completion.d/wire`
101    ///   zsh:        `wire completions zsh > ~/.zsh/completions/_wire`
102    ///   fish:       `wire completions fish > ~/.config/fish/completions/wire.fish`
103    ///   pwsh:       `wire completions powershell > $PROFILE` (append)
104    ///   elvish:     `wire completions elvish > ~/.elvish/lib/wire.elv`
105    Completions {
106        /// Shell to generate completions for.
107        #[arg(value_enum)]
108        shell: clap_complete::Shell,
109    },
110    /// v0.9.3: one-screen "you are here" view. Prints the current
111    /// session's character + handle + cwd, plus a short list of
112    /// neighbors (sister sessions on the local relay, pinned peers).
113    /// Designed for the operator's quick "wait which Claude is this,
114    /// and who's around?" question — no `--json` shuffling, no
115    /// remembering `wire whoami` vs `wire peers` vs `wire session
116    /// list-local`.
117    Here {
118        #[arg(long)]
119        json: bool,
120    },
121    /// v0.9 canonical surface: list pending-inbound pair requests waiting
122    /// for your consent. Aliases the legacy `pair-list-inbound` verb
123    /// but with the shorter, intent-first name. Operators reach for
124    /// "what's pending?" not "what's in my pair-list-inbound table?"
125    Pending {
126        #[arg(long)]
127        json: bool,
128    },
129    /// Sign and queue an event to a peer.
130    ///
131    /// Forms (P0.S 0.5.11):
132    ///   wire send <peer> <body>              # kind defaults to "claim"
133    ///   wire send <peer> <kind> <body>       # explicit kind (back-compat)
134    ///   wire send <peer> -                   # body from stdin (kind=claim)
135    ///   wire send <peer> @/path/to/body.json # body from file
136    Send {
137        /// Peer handle (without `did:wire:` prefix).
138        peer: String,
139        /// When `<body>` is omitted, this is the event body (kind defaults
140        /// to `claim`). When both this and `<body>` are given, this is the
141        /// event kind (`decision`, `claim`, etc., or numeric kind id) and
142        /// the next positional is the body.
143        kind_or_body: String,
144        /// Event body — free-form text, `@/path/to/body.json` to load from
145        /// a file, or `-` to read from stdin. Optional; omit to use
146        /// `<kind_or_body>` as the body with kind=`claim`.
147        body: Option<String>,
148        /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
149        #[arg(long)]
150        deadline: Option<String>,
151        /// v0.10: skip the v0.9 auto-pair-on-miss behavior. Send fails
152        /// loudly if the peer isn't pinned yet. Use when you want strict
153        /// "no implicit dialing" semantics — scripts that error vs.
154        /// performing a side-effecting pair as a fallback.
155        #[arg(long)]
156        no_auto_pair: bool,
157        /// Emit JSON.
158        #[arg(long)]
159        json: bool,
160    },
161    /// v0.8 — "go talk to this name." The one verb operators reach for.
162    ///
163    /// `wire dial <name>` accepts a character nickname (`noble-slate`),
164    /// a session name (`slancha-api`), a card handle, or a DID — whichever
165    /// face you happen to know the peer by. Resolution order:
166    ///
167    /// 1. Already-pinned peer? → no-op (or send if a message was passed).
168    /// 2. Local sister session? → bilateral pair via the disk-read
169    ///    `--local-sister` path (no relay round-trip, no .well-known
170    ///    lookup, no SAS digits).
171    /// 3. Otherwise → bail with a clear hint pointing at federation
172    ///    syntax (`wire dial <handle>@<relay>` for cross-machine peers).
173    ///
174    /// With an optional message, `wire dial <name> "<msg>"` also queues
175    /// and pushes the message after the pair completes. Idempotent: re-
176    /// dialling a known peer just sends.
177    Dial {
178        /// Peer name. Character nickname (preferred), session name,
179        /// card handle, or DID — anything that identifies the peer to
180        /// you.
181        name: String,
182        /// Optional first message to send after the pair lands. Same
183        /// semantics as the body argument to `wire send`. Defaults to
184        /// kind=claim.
185        message: Option<String>,
186        /// Emit JSON.
187        #[arg(long)]
188        json: bool,
189    },
190    /// Stream signed events from peers.
191    Tail {
192        /// Optional peer filter; if omitted, tails all peers.
193        peer: Option<String>,
194        /// Emit JSONL (one event per line).
195        #[arg(long)]
196        json: bool,
197        /// Maximum events to read before exiting (0 = stream until SIGINT).
198        #[arg(long, default_value_t = 0)]
199        limit: usize,
200    },
201    /// Live tail of new inbox events across all pinned peers — one line per
202    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
203    /// by default.
204    ///
205    /// Designed to be left running in an agent harness's stream-watcher
206    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
207    /// session as they arrive, not on next manual `wire pull`.
208    ///
209    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
210    /// template.
211    Monitor {
212        /// Only show events from this peer.
213        #[arg(long)]
214        peer: Option<String>,
215        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
216        #[arg(long)]
217        json: bool,
218        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
219        /// Default filters them out as noise.
220        #[arg(long)]
221        include_handshake: bool,
222        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
223        #[arg(long, default_value_t = 500)]
224        interval_ms: u64,
225        /// Replay last N events from history before going live (0 = none).
226        #[arg(long, default_value_t = 0)]
227        replay: usize,
228    },
229    /// Verify a signed event from a JSON file or stdin (`-`).
230    Verify {
231        /// Path to event JSON, or `-` for stdin.
232        path: String,
233        /// Emit JSON.
234        #[arg(long)]
235        json: bool,
236    },
237    /// Run the MCP (Model Context Protocol) server over stdio.
238    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
239    /// `wire_send`, `wire_tail`, etc. as native tools.
240    Mcp,
241    /// Run a relay server on this host.
242    RelayServer {
243        /// Bind address (e.g. `127.0.0.1:8770`).
244        #[arg(long, default_value = "127.0.0.1:8770")]
245        bind: String,
246        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
247        /// skip `.well-known/wire/agent` serving. The relay becomes
248        /// invisible from outside the box — only same-machine processes
249        /// can pair through it. Right call for within-machine agent
250        /// coordination where you don't want metadata leaking to a
251        /// public relay. Pair this with `wire session new` which probes
252        /// `127.0.0.1:8771` and allocates a local slot automatically.
253        #[arg(long)]
254        local_only: bool,
255        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
256        /// When set, --bind is ignored. Implies --local-only semantics
257        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
258        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
259        /// sister sessions. Unix only (Windows refuses).
260        #[arg(long)]
261        uds: Option<std::path::PathBuf>,
262    },
263    /// Allocate a slot on a relay; bind it to this agent's identity.
264    ///
265    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
266    /// current slot, this command refuses by default — silent migration
267    /// silently black-holes their inbound messages. Pass
268    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
269    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
270    /// for safe rotation.
271    BindRelay {
272        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
273        url: String,
274        /// Endpoint scope: `federation` | `local` | `lan` | `uds`.
275        /// Default inferred from the URL (loopback host -> local,
276        /// `unix://` -> uds, otherwise federation). Pass explicitly when
277        /// the inference is ambiguous (e.g. a federation relay on a
278        /// loopback address in tests).
279        #[arg(long)]
280        scope: Option<String>,
281        /// DESTRUCTIVE: drop all existing self slots and bind only this
282        /// relay (the pre-v0.12 single-slot behavior). Default is
283        /// ADDITIVE — the new slot is appended to `self.endpoints[]`,
284        /// keeping any existing slots so pinned peers are not
285        /// black-holed.
286        #[arg(long)]
287        replace: bool,
288        /// Acknowledge that pinned peers will black-hole until they
289        /// re-pin manually. Required for `--replace` (and same-relay
290        /// rotation) when `state.peers` is non-empty; ignored on fresh
291        /// boxes. Use `wire rotate-slot` instead for the supported
292        /// same-relay rotation path.
293        #[arg(long)]
294        migrate_pinned: bool,
295        #[arg(long)]
296        json: bool,
297    },
298    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
299    /// real `wire join` lands in the SPAKE2 iter.)
300    AddPeerSlot {
301        /// Peer handle (becomes did:wire:<handle>).
302        handle: String,
303        /// Peer's relay base URL.
304        url: String,
305        /// Peer's slot id.
306        slot_id: String,
307        /// Slot bearer token (shared between paired peers in v0.1).
308        slot_token: String,
309        #[arg(long)]
310        json: bool,
311    },
312    /// Drain outbox JSONL files to peers' relay slots.
313    Push {
314        /// Optional peer filter; default = all peers with outbox entries.
315        peer: Option<String>,
316        #[arg(long)]
317        json: bool,
318    },
319    /// Pull events from our relay slot, verify, write to inbox.
320    Pull {
321        #[arg(long)]
322        json: bool,
323    },
324    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
325    /// Useful as a single "where am I" check.
326    Status {
327        /// Inspect a paired peer's transport / attention / responder health.
328        #[arg(long)]
329        peer: Option<String>,
330        #[arg(long)]
331        json: bool,
332    },
333    /// Publish or inspect auto-responder health for this slot.
334    Responder {
335        #[command(subcommand)]
336        command: ResponderCommand,
337    },
338    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
339    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
340    Pin {
341        /// Path to peer's signed agent-card JSON.
342        card_file: String,
343        #[arg(long)]
344        json: bool,
345    },
346    /// Allocate a NEW slot on the same relay and abandon the old one.
347    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
348    /// slot announcing the new mailbox before swapping. After rotation,
349    /// peers must re-pair (or operator runs `add-peer-slot` with the new
350    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
351    ///
352    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
353    /// abusive bearer-holder spamming your slot). Rotate → old slot is
354    /// orphaned → attacker's leverage gone. Operator pairs again with
355    /// peers they still want.
356    RotateSlot {
357        /// Skip the wire_close announcement to peers (faster but they won't know
358        /// where you went).
359        #[arg(long)]
360        no_announce: bool,
361        #[arg(long)]
362        json: bool,
363    },
364    /// Remove a peer from trust + relay state. Inbox/outbox files for that
365    /// peer are NOT deleted (operator can grep history); pass --purge to
366    /// also wipe the JSONL files.
367    ForgetPeer {
368        /// Peer handle to forget.
369        handle: String,
370        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
371        #[arg(long)]
372        purge: bool,
373        #[arg(long)]
374        json: bool,
375    },
376    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
377    /// peers' relay slots and pull inbox from our own slot. Foreground process;
378    /// background it with systemd / `&` / tmux as you prefer.
379    Daemon {
380        /// Sync interval in seconds. Default 5.
381        #[arg(long, default_value_t = 5)]
382        interval: u64,
383        /// Run a single sync cycle and exit (useful for cron-driven setups).
384        #[arg(long)]
385        once: bool,
386        #[arg(long)]
387        json: bool,
388    },
389    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
390    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
391    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
392    /// read the SAS digits aloud and confirm.)
393    #[command(hide = true)] // v0.9 deprecated
394    PairHost {
395        /// Relay base URL.
396        #[arg(long)]
397        relay: String,
398        /// Skip the SAS confirmation prompt. ONLY use when piping under
399        /// automated tests or when the SAS has already been verified by
400        /// another channel. Documented as test-only.
401        #[arg(long)]
402        yes: bool,
403        /// How long (seconds) to wait for the peer to join before timing out.
404        #[arg(long, default_value_t = 300)]
405        timeout: u64,
406        /// Detach: write a pending-pair file, print the code phrase, and exit
407        /// immediately. The running `wire daemon` does the handshake in the
408        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
409        /// `wire pair-list` shows pending sessions. Default is foreground
410        /// blocking behavior for backward compat.
411        #[arg(long)]
412        detach: bool,
413        /// Emit JSON instead of text. Currently only meaningful with --detach.
414        #[arg(long)]
415        json: bool,
416    },
417    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
418    ///
419    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
420    #[command(alias = "join")]
421    #[command(hide = true)] // v0.9 deprecated
422    PairJoin {
423        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
424        code_phrase: String,
425        /// Relay base URL (must match the host's relay).
426        #[arg(long)]
427        relay: String,
428        #[arg(long)]
429        yes: bool,
430        #[arg(long, default_value_t = 300)]
431        timeout: u64,
432        /// Detach: see `pair-host --detach`.
433        #[arg(long)]
434        detach: bool,
435        /// Emit JSON instead of text. Currently only meaningful with --detach.
436        #[arg(long)]
437        json: bool,
438    },
439    /// Confirm SAS digits for a detached pending pair. The daemon must be
440    /// running for this to do anything — it picks up the confirmation on its
441    /// next tick. Mismatch aborts the pair.
442    #[command(hide = true)] // v0.9 deprecated
443    PairConfirm {
444        /// The code phrase the original `wire pair-host --detach` printed.
445        code_phrase: String,
446        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
447        digits: String,
448        /// Emit JSON instead of human-readable text.
449        #[arg(long)]
450        json: bool,
451    },
452    /// List all pending detached pair sessions and their state.
453    #[command(hide = true)] // v0.9 deprecated
454    PairList {
455        /// Emit JSON instead of the table.
456        #[arg(long)]
457        json: bool,
458        /// Stream mode: never exit; print one JSON line per status transition
459        /// (creation, status change, deletion) across all pending pairs.
460        /// Compose with bash `while read` to react in shell. Implies --json.
461        #[arg(long)]
462        watch: bool,
463        /// Poll interval in seconds for --watch.
464        #[arg(long, default_value_t = 1)]
465        watch_interval: u64,
466    },
467    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
468    #[command(hide = true)] // v0.9 deprecated
469    PairCancel {
470        code_phrase: String,
471        #[arg(long)]
472        json: bool,
473    },
474    /// Block until a pending pair reaches a target status (default sas_ready),
475    /// or terminates (finalized = file removed, aborted, aborted_restart), or
476    /// the timeout expires. Useful for shell scripts that want to drive the
477    /// detached flow without polling pair-list themselves.
478    ///
479    /// Exit codes:
480    ///   0 — reached target status (or finalized, if target was sas_ready)
481    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
482    ///   2 — timeout
483    #[command(hide = true)] // v0.9 deprecated
484    PairWatch {
485        code_phrase: String,
486        /// Target status to wait for. Default: sas_ready.
487        #[arg(long, default_value = "sas_ready")]
488        status: String,
489        /// Max seconds to wait.
490        #[arg(long, default_value_t = 300)]
491        timeout: u64,
492        /// Emit JSON on each status change (one per line) instead of just on exit.
493        #[arg(long)]
494        json: bool,
495    },
496    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
497    /// pair-join, then registers wire as an MCP server. Single command from
498    /// nothing to paired and ready — no separate init/pair-host/setup steps.
499    /// Operator still must confirm SAS digits.
500    ///
501    /// Examples:
502    ///   wire pair paul                          # host a new pair on default relay
503    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
504    ///
505    /// v0.10: hidden from --help. Federation pair flow is now
506    /// `wire dial <handle>@<relay>` + `wire accept-invite <URL>`.
507    /// `wire pair` stays callable for back-compat scripts; v1.0 removes.
508    #[command(hide = true)] // v0.10 deprecated — use `wire dial <h>@<relay>`
509    Pair {
510        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
511        /// step if no identity exists; ignored if already initialized.
512        handle: String,
513        /// Code phrase from peer's pair-host output. Omit to be the host
514        /// (this command will print one for you to share).
515        #[arg(long)]
516        code: Option<String>,
517        /// Relay base URL. Defaults to the laulpogan public-good relay.
518        #[arg(long, default_value = "https://wireup.net")]
519        relay: String,
520        /// Skip SAS prompt. Test-only.
521        #[arg(long)]
522        yes: bool,
523        /// Pair-step timeout in seconds.
524        #[arg(long, default_value_t = 300)]
525        timeout: u64,
526        /// Skip the post-pair `setup --apply` step (don't register wire as
527        /// an MCP server in detected client configs).
528        #[arg(long)]
529        no_setup: bool,
530        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
531        /// exits immediately, daemon does the handshake). Confirm via
532        /// `wire pair-confirm <code> <digits>` from any terminal. See
533        /// `pair-host --detach` for details.
534        #[arg(long)]
535        detach: bool,
536    },
537    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
538    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
539    /// confirmation, leaving the relay-side slot stuck with "guest already
540    /// registered" or "host already registered" until the 5-minute TTL expires.
541    /// Either side can call. Idempotent.
542    #[command(hide = true)] // v0.9 deprecated
543    PairAbandon {
544        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
545        code_phrase: String,
546        /// Relay base URL.
547        #[arg(long, default_value = "https://wireup.net")]
548        relay: String,
549    },
550    /// Accept a pending-inbound pair request (v0.5.14). Explicit alias for
551    /// the bilateral-completion path that `wire add <peer>@<relay>` also
552    /// drives — but doesn't require remembering the peer's relay domain
553    /// (the relay coords come from the stored pair_drop). Errors if no
554    /// pending-inbound record exists for that peer.
555    #[command(hide = true)] // v0.9 deprecated
556    PairAccept {
557        /// Bare peer handle (without `@<relay>`).
558        peer: String,
559        /// Emit JSON.
560        #[arg(long)]
561        json: bool,
562    },
563    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
564    /// you@<your-relay>` against your handle, their signed pair_drop lands
565    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
566    /// <peer>` to delete the record without pairing. The peer never receives
567    /// our slot_token; from their side the pair stays pending until they
568    /// time out.
569    #[command(hide = true)] // v0.9 deprecated
570    PairReject {
571        /// Bare peer handle (without `@<relay>`).
572        peer: String,
573        /// Emit JSON.
574        #[arg(long)]
575        json: bool,
576    },
577    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
578    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
579    /// `pair-list --json` shape but for inbound). Use this in scripts that
580    /// need to enumerate inbound pair requests without parsing the SPAKE2
581    /// table format from `wire pair-list`.
582    #[command(hide = true)] // v0.9 deprecated
583    PairListInbound {
584        /// Emit JSON.
585        #[arg(long)]
586        json: bool,
587    },
588    /// Manage isolated wire sessions on this machine (v0.5.16).
589    ///
590    /// Each session = its own DID + handle + relay slot + daemon + inbox/
591    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
592    /// in different projects) run on the same machine — without sessions
593    /// they all share one identity and race the inbox cursor.
594    ///
595    /// Names are derived from `basename(cwd)` and cached in a registry,
596    /// so re-entering the same project reuses the same identity.
597    #[command(subcommand)]
598    Session(SessionCommand),
599    /// Manage this session's identity display layer (character override).
600    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
601    /// itself picks a custom nickname + emoji that overrides the
602    /// auto-derived hash-based defaults.
603    Identity {
604        #[command(subcommand)]
605        cmd: IdentityCommand,
606    },
607    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
608    /// sister-session mesh. `wire mesh status` is the live view of every
609    /// paired sister (alias for `wire session mesh-status`); `wire mesh
610    /// broadcast` fans one signed event to every pinned peer.
611    #[command(subcommand)]
612    Mesh(MeshCommand),
613    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
614    /// Cursor, project-local) and either print or auto-merge the wire MCP
615    /// server entry. Default prints; pass `--apply` to actually modify config
616    /// files. Idempotent — re-running is safe.
617    Setup {
618        /// Actually write the changes (default = print only).
619        #[arg(long)]
620        apply: bool,
621    },
622    /// Show an agent's profile. With no arg, prints local self. With a
623    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
624    /// endpoint and verifies the returned signed card before display.
625    Whois {
626        /// Optional handle (`nick@domain`). Omit to show self.
627        handle: Option<String>,
628        #[arg(long)]
629        json: bool,
630        /// Override the relay base URL used for resolution (default:
631        /// `https://<domain>` from the handle).
632        #[arg(long)]
633        relay: Option<String>,
634    },
635    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
636    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
637    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
638    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
639    /// their slot_token so we can `wire send` to them).
640    Add {
641        /// Peer handle (`nick@domain`), OR a bare sister-session name
642        /// when `--local-sister` is set.
643        handle: String,
644        /// Override the relay base URL used for resolution.
645        #[arg(long)]
646        relay: Option<String>,
647        /// v0.6.6: pair with a sister session on this machine without
648        /// touching federation. Looks up `handle` as a session name in
649        /// `wire session list`, reads that session's agent-card +
650        /// endpoints from disk, pins directly, then delivers the
651        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
652        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
653        /// addressable because they don't need a federation claim.
654        #[arg(long)]
655        local_sister: bool,
656        #[arg(long)]
657        json: bool,
658    },
659    /// Come online in one command — `wire up` does what used to take five
660    /// (init + bind-relay + claim your persona + background daemon +
661    /// restart-on-login). Idempotent: re-run on an already-set-up box prints
662    /// state without churn.
663    ///
664    /// There is no name to choose: your handle IS your DID-derived persona
665    /// (one-name rule). The optional argument is just which relay to use.
666    ///
667    /// Examples:
668    ///   wire up                        # default public relay (wireup.net)
669    ///   wire up @wireup.net            # explicit federation relay
670    ///   wire up http://127.0.0.1:8771  # a local / self-hosted relay
671    Up {
672        /// Relay to bind + claim your persona on: `@wireup.net`, `wireup.net`,
673        /// or a full URL. Omit for the default public relay. No nick — your
674        /// handle is your DID-derived persona.
675        relay: Option<String>,
676        /// Optional display name for your profile card (cosmetic; distinct
677        /// from your addressable handle/persona).
678        #[arg(long)]
679        name: Option<String>,
680        /// Also additively dual-bind a LOCAL relay slot for fast same-box
681        /// sister-session routing. Defaults to probing
682        /// `http://127.0.0.1:8771`; pass a URL to override. Local relays
683        /// carry no handle directory, so nothing is claimed there.
684        #[arg(long)]
685        with_local: Option<String>,
686        /// Skip the opportunistic local dual-bind entirely.
687        #[arg(long)]
688        no_local: bool,
689        #[arg(long)]
690        json: bool,
691    },
692    /// Diagnose wire setup health. Single command that surfaces every
693    /// silent-fail class — daemon down or duplicated, relay unreachable,
694    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
695    /// Replaces today's 30-minute manual debug.
696    ///
697    /// Exit code non-zero if any FAIL findings.
698    Doctor {
699        /// Emit JSON.
700        #[arg(long)]
701        json: bool,
702        /// Show last N entries from pair-rejected.jsonl in the report.
703        #[arg(long, default_value_t = 5)]
704        recent_rejections: usize,
705    },
706    /// Atomic upgrade: kill every `wire daemon` process, spawn a fresh
707    /// one from the current binary, write a new pidfile. Eliminates the
708    /// "stale binary text in memory under a fresh symlink" bug class that
709    /// burned 30 minutes today.
710    Upgrade {
711        /// Report drift without taking action (lists processes that would
712        /// be killed + the version of each).
713        #[arg(long)]
714        check: bool,
715        #[arg(long)]
716        json: bool,
717    },
718    /// Install / inspect / remove a launchd plist (macOS) or systemd
719    /// user unit (linux) that runs `wire daemon` on login + restarts
720    /// on crash. Replaces today's "background it with tmux/&/systemd
721    /// as you prefer" footgun.
722    Service {
723        #[command(subcommand)]
724        action: ServiceAction,
725    },
726    /// Inspect or toggle the structured diagnostic trace
727    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
728    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
729    /// (writes the file knob a running daemon picks up automatically).
730    Diag {
731        #[command(subcommand)]
732        action: DiagAction,
733    },
734    /// Claim your persona on a relay's handle directory. Anyone can then
735    /// reach this agent by `<persona>@<relay-domain>` via the relay's
736    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
737    ///
738    /// ONE-NAME RULE (v0.13.1): the claimed handle is always your DID-derived
739    /// persona. The `nick` arg is vestigial — if it differs it is ignored
740    /// (like the typed name `wire init` / `wire up` already ignore), so your
741    /// phonebook entry can never drift from your agent-card handle.
742    ///
743    /// v0.13.1: hidden — `wire up` claims your persona for you. Kept callable
744    /// (idempotent re-claim) but not a user verb; there is no nick to choose.
745    #[command(hide = true)]
746    Claim {
747        /// Vestigial: ignored if it differs from your DID-derived persona.
748        nick: String,
749        /// Relay to claim the nick on. Default = relay our slot is on.
750        #[arg(long)]
751        relay: Option<String>,
752        /// Public URL the relay should advertise to resolvers (default = relay).
753        #[arg(long)]
754        public_url: Option<String>,
755        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
756        /// directory listing. The handle stays claimed (FCFS still
757        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
758        /// still resolves, so peers you share the handle with out-of-band
759        /// can still pair. Bulk scrapers / phonebook crawlers will not
760        /// see the nick. Use this for handles meant for known-peer
761        /// pairing only — see issue #9.
762        #[arg(long)]
763        hidden: bool,
764        #[arg(long)]
765        json: bool,
766    },
767    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
768    /// avatar_url, handle, now). Re-signs the agent-card atomically.
769    ///
770    /// Examples:
771    ///   wire profile set motto "compiles or dies trying"
772    ///   wire profile set emoji "🦀"
773    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
774    ///   wire profile set handle "coffee-ghost@anthropic.dev"
775    ///   wire profile get
776    Profile {
777        #[command(subcommand)]
778        action: ProfileAction,
779    },
780    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
781    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
782    /// a relay slot on first use. Default TTL 24h, single-use.
783    #[command(hide = true)] // v0.9 deprecated
784    Invite {
785        /// Override the relay URL for first-time auto-allocation.
786        #[arg(long, default_value = "https://wireup.net")]
787        relay: String,
788        /// Invite lifetime in seconds (default 86400 = 24h).
789        #[arg(long, default_value_t = 86_400)]
790        ttl: u64,
791        /// Number of distinct peers that can accept this invite before it's
792        /// consumed (default 1).
793        #[arg(long, default_value_t = 1)]
794        uses: u32,
795        /// Register the invite at the relay's short-URL endpoint and print
796        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
797        /// Installs wire if missing, then accepts the invite, then pairs.
798        #[arg(long)]
799        share: bool,
800        /// Emit JSON.
801        #[arg(long)]
802        json: bool,
803    },
804    /// v0.9: accept a pending-inbound pair request by character
805    /// nickname or card handle. Replaces the verbose `wire pair-accept
806    /// <peer>`.
807    ///
808    /// v0.9.4: the URL-vs-name smart-dispatch from v0.9 is gone. To
809    /// accept a federation invite URL use `wire accept-invite <URL>`
810    /// (split out as an explicit verb to eliminate the input-shape
811    /// ambiguity). `wire accept <URL>` still works for back-compat
812    /// but emits a deprecation banner pointing at `accept-invite`.
813    Accept {
814        /// Pending peer name (character nickname or card handle).
815        target: String,
816        /// Emit JSON.
817        #[arg(long)]
818        json: bool,
819    },
820    /// v0.9.4: accept a federation invite URL minted by `wire invite`.
821    /// Pins issuer, sends signed card to issuer's slot. Auto-inits +
822    /// auto-allocates as needed.
823    ///
824    /// Split out from `wire accept` to eliminate the URL-vs-name
825    /// smart-dispatch ambiguity (peer handles can legitimately collide
826    /// with URL-shaped strings; the explicit verb removes the inference).
827    #[command(alias = "invite-accept")]
828    AcceptInvite {
829        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
830        url: String,
831        /// Emit JSON.
832        #[arg(long)]
833        json: bool,
834    },
835    /// v0.9: refuse a pending-inbound pair request without pairing. Aliases
836    /// the legacy `wire pair-reject <peer>`.
837    Reject {
838        /// Peer name (character nickname or handle) from `wire pending`.
839        peer: String,
840        /// Emit JSON.
841        #[arg(long)]
842        json: bool,
843    },
844    /// Long-running event dispatcher. Watches inbox for new verified events
845    /// and spawns the given shell command per event, passing the event JSON
846    /// on stdin. Use to wire up autonomous reply loops:
847    ///   wire reactor --on-event 'claude -p "respond via wire send"'
848    /// Cursor persisted to `$WIRE_HOME/state/wire/reactor.cursor`.
849    Reactor {
850        /// Shell command to spawn per event. Event JSON written to its stdin.
851        #[arg(long)]
852        on_event: String,
853        /// Only fire for events from this peer.
854        #[arg(long)]
855        peer: Option<String>,
856        /// Only fire for events of this kind (numeric or name, e.g. 1 / decision).
857        #[arg(long)]
858        kind: Option<String>,
859        /// Skip events whose verified flag is false (default true).
860        #[arg(long, default_value_t = true)]
861        verified_only: bool,
862        /// Poll interval in seconds.
863        #[arg(long, default_value_t = 2)]
864        interval: u64,
865        /// Process one sweep and exit.
866        #[arg(long)]
867        once: bool,
868        /// Don't actually spawn — print one JSONL line per event for smoke-testing.
869        #[arg(long)]
870        dry_run: bool,
871        /// Hard rate-limit: max events handler is fired for per peer per minute.
872        /// 0 = unlimited. Default 6 — covers normal conversational tempo, kills
873        /// LLM-vs-LLM feedback loops (which fire 10+/sec).
874        #[arg(long, default_value_t = 6)]
875        max_per_minute: u32,
876        /// Anti-loop chain depth. Track event_ids this reactor emitted; if an
877        /// incoming event body contains `(re:X)` where X is in our emitted log,
878        /// skip — that's a reply-to-our-reply, depth ≥ 2. Disable with 0.
879        #[arg(long, default_value_t = 1)]
880        max_chain_depth: u32,
881    },
882    /// Watch the inbox for new verified events and fire an OS notification per
883    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
884    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
885    /// re-emit history.
886    Notify {
887        /// Poll interval in seconds.
888        #[arg(long, default_value_t = 2)]
889        interval: u64,
890        /// Only notify for events from this peer (handle, no did: prefix).
891        #[arg(long)]
892        peer: Option<String>,
893        /// Run a single sweep and exit (useful for cron / tests).
894        #[arg(long)]
895        once: bool,
896        /// Suppress the OS notification call; print one JSON line per event to
897        /// stdout instead (for piping into other tooling or smoke-testing
898        /// without a desktop session).
899        #[arg(long)]
900        json: bool,
901    },
902}
903
904#[derive(Subcommand, Debug)]
905pub enum DiagAction {
906    /// Tail the last N entries from diag.jsonl.
907    Tail {
908        #[arg(long, default_value_t = 20)]
909        limit: usize,
910        #[arg(long)]
911        json: bool,
912    },
913    /// Flip the file-based knob ON. Running daemons pick this up on
914    /// the next emit call without restart.
915    Enable,
916    /// Flip the file-based knob OFF.
917    Disable,
918    /// Report whether diag is currently enabled + the file's size.
919    Status {
920        #[arg(long)]
921        json: bool,
922    },
923}
924
925#[derive(Subcommand, Debug)]
926pub enum IdentityCommand {
927    /// Print the current character (DID-derived, the only name).
928    /// Equivalent to `wire whoami --short` but scoped here for grouping.
929    Show {
930        #[arg(long)]
931        json: bool,
932    },
933    /// List all identities on this machine — one row per session, with
934    /// each session's character, DID, federation handle, and cwd. Same
935    /// shape as `wire session list`, scoped here for the v0.7+ noun-
936    /// CLI surface.
937    List {
938        #[arg(long)]
939        json: bool,
940    },
941    /// Promote this identity to FEDERATION lifecycle: claim your persona on
942    /// the relay so peers can `wire dial <persona>@<relay-domain>` you.
943    /// Re-claims with current display fields so the relay always serves the
944    /// latest signed card. Equivalent to `wire claim`.
945    ///
946    /// v0.13.1: hidden — `wire up` publishes your persona for you, and the
947    /// nick is vestigial (one-name rule). Kept callable for re-publish.
948    #[command(hide = true)]
949    Publish {
950        /// Vestigial: ignored; your handle is your DID-derived persona.
951        nick: String,
952        /// Override the relay URL. Defaults to the session's bound relay
953        /// from `wire init --relay <url>`. Public relay if unset.
954        #[arg(long)]
955        relay: Option<String>,
956        /// Public-facing URL for the agent-card location (when the relay
957        /// is behind a CDN with a different public domain).
958        #[arg(long, alias = "public")]
959        public_url: Option<String>,
960        /// Skip listing in the relay's public phonebook. The card is
961        /// still claimable + reachable; just doesn't appear in
962        /// `wireup.net/phonebook` for stranger-discovery.
963        #[arg(long)]
964        hidden: bool,
965        #[arg(long)]
966        json: bool,
967    },
968    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
969    /// Equivalent to `wire session destroy <name>`, scoped here for the
970    /// noun-CLI surface. Requires `--force` (the underlying command does).
971    Destroy {
972        /// Session name to destroy (use `wire identity list` to see).
973        name: String,
974        /// Bypass the confirmation prompt.
975        #[arg(long)]
976        force: bool,
977        #[arg(long)]
978        json: bool,
979    },
980    /// Create an identity in an EXPLICIT lifecycle state (vs. the
981    /// implicit `wire init` + `wire claim` flow).
982    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
983    ///
984    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
985    /// next reboot). In-memory semantics not yet supported — the
986    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
987    /// For pure-RAM identities, see v1.0 vision.
988    ///
989    /// `--local` is the explicit form of today's default; identity
990    /// persists to the machine-wide sessions root.
991    Create {
992        /// Session name. Defaults to derived from cwd (anonymous mode
993        /// uses a random name).
994        #[arg(long)]
995        name: Option<String>,
996        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
997        /// reboot, no federation). Mutually exclusive with --local.
998        #[arg(long, conflicts_with = "local")]
999        anonymous: bool,
1000        /// Create a LOCAL identity (machine-persistent, no federation).
1001        /// Default — explicit flag for clarity.
1002        #[arg(long)]
1003        local: bool,
1004        #[arg(long)]
1005        json: bool,
1006    },
1007    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
1008    /// the machine-wide sessions root + register in the cwd map.
1009    /// After persist, the identity survives reboot.
1010    /// v0.7.0-alpha.20.
1011    Persist {
1012        /// The anonymous identity's name (from `wire identity list`).
1013        name: String,
1014        /// Optional rename during persist. Default: keep the anon name.
1015        #[arg(long = "as", value_name = "NEW_NAME")]
1016        as_name: Option<String>,
1017        #[arg(long)]
1018        json: bool,
1019    },
1020    /// Demote an identity ONE level in the lifecycle:
1021    ///   federation → local: removes the relay slot binding but keeps
1022    ///   the keypair + agent-card. Operator can later re-publish with
1023    ///   `wire identity publish`. v0.7.0-alpha.20.
1024    ///
1025    /// (local → anonymous is not exposed; the safer flow is destroy +
1026    /// recreate, since "demoting" a persistent identity to ephemeral
1027    /// has surprising semantics — what about the keypair? what about
1028    /// pinned peers? Better to be explicit with destroy.)
1029    Demote {
1030        /// Session name to demote.
1031        name: String,
1032        #[arg(long)]
1033        json: bool,
1034    },
1035}
1036
1037#[derive(Subcommand, Debug)]
1038pub enum SessionCommand {
1039    /// Bootstrap a new isolated session in this machine's sessions root.
1040    /// With no name, derives one from `basename(cwd)` and caches it in
1041    /// the registry so re-running from the same project reuses it.
1042    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1043    /// the new session's WIRE_HOME. Output includes the `export
1044    /// WIRE_HOME=...` line operators paste into their shell to activate
1045    /// it.
1046    New {
1047        /// Optional session name. Default = derived from `basename(cwd)`.
1048        name: Option<String>,
1049        /// Relay URL for the session's slot allocation + handle claim.
1050        #[arg(long, default_value = "https://wireup.net")]
1051        relay: String,
1052        /// v0.5.17: also allocate a second slot on a same-machine local
1053        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1054        /// sister-session traffic prefers this path: zero round-trip
1055        /// latency, zero metadata exposure to the public relay. Probes
1056        /// `<local-relay>/healthz` first; silently skips if the local
1057        /// relay isn't running.
1058        #[arg(long)]
1059        with_local: bool,
1060        /// v0.5.17: override the local relay URL probed by `--with-local`.
1061        /// Default is `http://127.0.0.1:8771` to match
1062        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1063        #[arg(long, default_value = "http://127.0.0.1:8771")]
1064        local_relay: String,
1065        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1066        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1067        /// Lets other machines on the same network reach this session
1068        /// directly without round-tripping the public federation relay
1069        /// at https://wireup.net. LAN endpoint is published in the
1070        /// agent-card; opt-in per session (default off).
1071        #[arg(long)]
1072        with_lan: bool,
1073        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1074        /// LAN IP — operator must type the address). Example:
1075        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1076        #[arg(long)]
1077        lan_relay: Option<String>,
1078        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1079        /// relay (must be running e.g. via `wire relay-server --uds
1080        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1081        /// bypasses the macOS firewall + Tailscale userspace-netstack
1082        /// class of issues entirely for sister-session traffic. UDS
1083        /// endpoint is published in the agent-card.
1084        #[arg(long)]
1085        with_uds: bool,
1086        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1087        /// is set. Example: `/tmp/wire.sock` or
1088        /// `~/.wire/local.sock`.
1089        #[arg(long)]
1090        uds_socket: Option<std::path::PathBuf>,
1091        /// Skip spawning the session-local daemon. Use when you want
1092        /// to drive sync explicitly from the agent or test rig.
1093        #[arg(long)]
1094        no_daemon: bool,
1095        /// v0.6.6: create a federation-free session — no nick claim on
1096        /// `--relay`, no federation slot allocation. Implies
1097        /// `--with-local`. The session exists only to coordinate with
1098        /// other sister sessions on this machine; it has no public
1099        /// address and cannot be reached from outside. Reserved nicks
1100        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1101        /// to publish them.
1102        #[arg(long)]
1103        local_only: bool,
1104        /// Emit JSON.
1105        #[arg(long)]
1106        json: bool,
1107    },
1108    /// List all sessions on this machine with their handle, DID,
1109    /// daemon liveness, and the cwd they're associated with.
1110    List {
1111        #[arg(long)]
1112        json: bool,
1113    },
1114    /// List sister sessions reachable via a same-machine local relay
1115    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1116    /// share. Sessions without a Local-scope endpoint are listed
1117    /// separately so the operator can tell which are federation-only.
1118    /// Read-only — does not probe any relay or touch daemons.
1119    ListLocal {
1120        #[arg(long)]
1121        json: bool,
1122    },
1123    /// v0.6.0 (issue #12): mesh-pair every sister session against every
1124    /// other in O(N²) handshakes. For each unordered pair (A, B) that
1125    /// is not already paired, drives the bilateral flow end-to-end:
1126    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
1127    /// B's side, then a final pull on A so the ack lands. Idempotent —
1128    /// re-running skips pairs already in `state.peers`.
1129    ///
1130    /// **Trust anchor:** the operator running this command owns every
1131    /// session listed in `wire session list-local` (they all live under
1132    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1133    /// That filesystem-permission boundary IS the consent for both
1134    /// sides — the bilateral SAS / network-level handshake assumes
1135    /// strangers; same-uid sister sessions are by definition not
1136    /// strangers. Cross-uid sister sessions are out of scope; today
1137    /// `wire session list-local` only enumerates this user's sessions.
1138    PairAllLocal {
1139        /// Seconds to wait between handshake stages for pair_drop /
1140        /// pair_drop_ack to propagate over the relay. Default 1s
1141        /// (local-relay is typically <100ms RTT). Bump if you see
1142        /// "pending-inbound never arrived" errors on a slow relay.
1143        #[arg(long, default_value_t = 1)]
1144        settle_secs: u64,
1145        /// Federation relay to bind each `wire add` against. Default
1146        /// `https://wireup.net`. Sister sessions should be bound to
1147        /// the same federation relay; the pair handshake routes through
1148        /// it for the .well-known resolution + pair_drop deposit.
1149        #[arg(long, default_value = "https://wireup.net")]
1150        federation_relay: String,
1151        #[arg(long)]
1152        json: bool,
1153    },
1154    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1155    /// machine. Enumerates every session in `wire session list-local`,
1156    /// walks each session's `relay.json#peers` to find which other sister
1157    /// sessions it has pinned, and probes the local relay for each edge's
1158    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1159    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1160    /// local_relay, summary}` so scripts can scrape.
1161    ///
1162    /// Read-only — does NOT touch peers or daemons, only the relay's
1163    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1164    /// already hold. Silent on any probe failure (degrades to "no
1165    /// signal" rather than abort) so a half-broken mesh is still
1166    /// inspectable.
1167    MeshStatus {
1168        /// Threshold in seconds for "stale" classification on an edge.
1169        /// An edge whose receiver hasn't polled their slot in this long
1170        /// is flagged. Default 300s (5 min) — same as the per-send
1171        /// `phyllis` attentiveness nag.
1172        #[arg(long, default_value_t = 300)]
1173        stale_secs: u64,
1174        #[arg(long)]
1175        json: bool,
1176    },
1177    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1178    /// can `eval $(wire session env <name>)` to activate it. With no
1179    /// name, resolves the cwd through the registry.
1180    Env {
1181        /// Session name. Default = derived from cwd via the registry.
1182        name: Option<String>,
1183        #[arg(long)]
1184        json: bool,
1185    },
1186    /// Identify which session the current cwd maps to in the registry.
1187    /// Prints `(none)` if cwd isn't registered — `wire session new`
1188    /// would create one.
1189    Current {
1190        #[arg(long)]
1191        json: bool,
1192    },
1193    /// Attach an existing session to the current cwd in the registry,
1194    /// so subsequent auto-detect from this cwd resolves to that session
1195    /// instead of walking up to an ancestor's binding. Use when an
1196    /// ancestor dir (e.g. `~/Source`) is already registered and is
1197    /// shadowing per-project identities for cwds beneath it. Idempotent;
1198    /// re-binding to the same name is a no-op. Re-binding to a different
1199    /// name overwrites the prior entry with a stderr warning.
1200    Bind {
1201        /// Session name to bind. Must already exist (run `wire session
1202        /// new <name>` first if not). With no name, auto-derives from
1203        /// `basename(cwd)` and errors if no session of that name exists.
1204        name: Option<String>,
1205        #[arg(long)]
1206        json: bool,
1207    },
1208    /// Tear down a session: kills its daemon (if running), deletes its
1209    /// state directory, and removes it from the registry. Requires
1210    /// `--force` because state loss is unrecoverable (keypair gone).
1211    Destroy {
1212        name: String,
1213        /// Confirm state-deleting operation.
1214        #[arg(long)]
1215        force: bool,
1216        #[arg(long)]
1217        json: bool,
1218    },
1219}
1220
1221/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1222/// session's view of the pinned peer set. `status` is the read-only
1223/// observability primitive (alias for `wire session mesh-status`);
1224/// `broadcast` fans a signed event to every pinned peer in one call.
1225#[derive(Subcommand, Debug)]
1226pub enum MeshCommand {
1227    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1228    /// per-edge health roll-up across every sister session on this machine.
1229    Status {
1230        /// Threshold in seconds for "stale" classification on an edge.
1231        #[arg(long, default_value_t = 300)]
1232        stale_secs: u64,
1233        #[arg(long)]
1234        json: bool,
1235    },
1236    /// Fan one signed event to every pinned peer. Each peer receives a
1237    /// distinct `event_id` but every copy shares the same `broadcast_id`
1238    /// UUID so receivers can correlate them as a single broadcast.
1239    ///
1240    /// `--scope local` (default) only fans to peers reachable via a same-
1241    /// machine local relay. `--scope federation` only to public-relay
1242    /// peers. `--scope both` to every pinned peer.
1243    ///
1244    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1245    /// for "ack-loop" prevention: a peer responding to a broadcast can
1246    /// exclude its own broadcaster when re-broadcasting.
1247    ///
1248    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1249    /// file, `-` reads stdin (JSON if parseable, else literal).
1250    ///
1251    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1252    /// peers — that would re-introduce the phonebook-scrape risk closed
1253    /// in v0.5.14 (T8).
1254    Broadcast {
1255        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1256        /// `heartbeat`. Same vocabulary as `wire send`.
1257        #[arg(long, default_value = "claim")]
1258        kind: String,
1259        /// `local`, `federation`, or `both`. Default `local`.
1260        #[arg(long, default_value = "local")]
1261        scope: String,
1262        /// Skip a specific peer handle. Repeatable.
1263        #[arg(long)]
1264        exclude: Vec<String>,
1265        /// Drop the broadcast event ID from the relay-side attentiveness
1266        /// nag (`phyllis`) — useful when broadcasting to many peers and
1267        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1268        #[arg(long)]
1269        noreply: bool,
1270        /// Body — string, `@/path` for a file, or `-` for stdin.
1271        body: String,
1272        #[arg(long)]
1273        json: bool,
1274    },
1275    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1276    /// capability-aware addressing. Stored as `profile.role` on the
1277    /// signed agent-card — propagates over the existing pair / .well-
1278    /// known plumbing, no new persistence.
1279    ///
1280    /// First slice of the Layer-2 capability metadata umbrella (#13).
1281    /// `wire mesh route` (issue #21) will consume these tags to pick
1282    /// the right sister for a task.
1283    Role {
1284        #[command(subcommand)]
1285        action: MeshRoleAction,
1286    },
1287    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1288    /// to one sister session and deliver an event to that one peer.
1289    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1290    /// can now address "the reviewer" instead of hard-coding a handle.
1291    ///
1292    /// Strategies:
1293    ///   - `round-robin` (default): per-role cursor, persisted at
1294    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1295    ///   - `first`: alphabetically-first matching sister. Deterministic.
1296    ///   - `random`: uniform random among matches. Stateless.
1297    ///
1298    /// Pinned-peers-only by construction (same posture as `broadcast`).
1299    /// Caller must already have the target sister pinned in
1300    /// `state.peers` — otherwise we can't sign + push. Run
1301    /// `wire session pair-all-local` first if the mesh isn't wired.
1302    Route {
1303        /// Role to match (operator-defined tag from `wire mesh role set`).
1304        role: String,
1305        /// `round-robin` (default), `first`, or `random`.
1306        #[arg(long, default_value = "round-robin")]
1307        strategy: String,
1308        /// Skip a specific sister handle. Repeatable.
1309        #[arg(long)]
1310        exclude: Vec<String>,
1311        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1312        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1313        #[arg(long, default_value = "claim")]
1314        kind: String,
1315        /// Body — string, `@/path` for a file, or `-` for stdin.
1316        body: String,
1317        #[arg(long)]
1318        json: bool,
1319    },
1320}
1321
1322/// v0.6.4: subcommands of `wire mesh role`.
1323#[derive(Subcommand, Debug)]
1324pub enum MeshRoleAction {
1325    /// Assign self to a role. Role is a free-form ASCII string
1326    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1327    /// the vocabulary out-of-band — common starters: `planner`,
1328    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1329    Set {
1330        role: String,
1331        #[arg(long)]
1332        json: bool,
1333    },
1334    /// Read self or a peer's role. With no arg, prints self. With a
1335    /// handle, reads from the peer's pinned agent-card.
1336    Get {
1337        peer: Option<String>,
1338        #[arg(long)]
1339        json: bool,
1340    },
1341    /// List roles across every sister session on this machine. Reads
1342    /// each session's agent-card by path — no network, no env mutation.
1343    List {
1344        #[arg(long)]
1345        json: bool,
1346    },
1347    /// Remove self from any assigned role. Re-signs the card with
1348    /// `profile.role: null`.
1349    Clear {
1350        #[arg(long)]
1351        json: bool,
1352    },
1353}
1354
1355#[derive(Subcommand, Debug)]
1356pub enum ServiceAction {
1357    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1358    /// load it. Idempotent — re-running re-bootstraps an existing service.
1359    ///
1360    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1361    /// process). Pass `--local-relay` to install the loopback relay
1362    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1363    /// transport sister-Claudes use to coordinate on the same machine
1364    /// (v0.5.17 dual-slot). The two services have distinct labels +
1365    /// log files, so you can install both.
1366    Install {
1367        /// Install the local-relay service instead of the daemon.
1368        #[arg(long)]
1369        local_relay: bool,
1370        #[arg(long)]
1371        json: bool,
1372    },
1373    /// Unload + delete the service unit. Daemon keeps running until the
1374    /// next reboot or `wire upgrade`; this only changes the boot-time
1375    /// behaviour.
1376    Uninstall {
1377        /// Uninstall the local-relay service instead of the daemon.
1378        #[arg(long)]
1379        local_relay: bool,
1380        #[arg(long)]
1381        json: bool,
1382    },
1383    /// Report whether the unit is installed + active.
1384    Status {
1385        /// Show status of the local-relay service instead of the daemon.
1386        #[arg(long)]
1387        local_relay: bool,
1388        #[arg(long)]
1389        json: bool,
1390    },
1391}
1392
1393#[derive(Subcommand, Debug)]
1394pub enum ResponderCommand {
1395    /// Publish this agent's auto-responder health.
1396    Set {
1397        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1398        status: String,
1399        /// Optional operator-facing reason.
1400        #[arg(long)]
1401        reason: Option<String>,
1402        /// Emit JSON.
1403        #[arg(long)]
1404        json: bool,
1405    },
1406    /// Read responder health for self, or for a paired peer.
1407    Get {
1408        /// Optional peer handle; omitted means this agent's own slot.
1409        peer: Option<String>,
1410        /// Emit JSON.
1411        #[arg(long)]
1412        json: bool,
1413    },
1414}
1415
1416#[derive(Subcommand, Debug)]
1417pub enum ProfileAction {
1418    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1419    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1420    /// (JSON array) and `now` (JSON object).
1421    Set {
1422        field: String,
1423        value: String,
1424        #[arg(long)]
1425        json: bool,
1426    },
1427    /// Show all profile fields. Equivalent to `wire whois`.
1428    Get {
1429        #[arg(long)]
1430        json: bool,
1431    },
1432    /// Clear a profile field.
1433    Clear {
1434        field: String,
1435        #[arg(long)]
1436        json: bool,
1437    },
1438}
1439
1440/// Entry point — parse and dispatch.
1441pub fn run() -> Result<()> {
1442    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1443    // the session registry and adopt that session's home for this
1444    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1445    // detect — `wire whoami` / `wire monitor` from a project cwd now
1446    // resolve to that project's session identity, not the machine
1447    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1448    //
1449    // MUST run before any thread spawn — call it FIRST, before
1450    // `Cli::parse` (which uses clap internals only) and before any
1451    // command dispatch (which may spawn workers).
1452    crate::session::maybe_adopt_session_wire_home("cli");
1453    let cli = Cli::parse();
1454    match cli.command {
1455        Command::Init {
1456            handle,
1457            name,
1458            relay,
1459            offline,
1460            json,
1461        } => cmd_init(
1462            Some(&handle),
1463            name.as_deref(),
1464            relay.as_deref(),
1465            offline,
1466            json,
1467        ),
1468        Command::Status { peer, json } => {
1469            if let Some(peer) = peer {
1470                cmd_status_peer(&peer, json)
1471            } else {
1472                cmd_status(json)
1473            }
1474        }
1475        Command::Whoami {
1476            json,
1477            short,
1478            colored,
1479        } => cmd_whoami(json_default(json), short, colored),
1480        Command::Peers { json } => cmd_peers(json_default(json)),
1481        Command::Here { json } => cmd_here(json_default(json)),
1482        Command::Completions { shell } => {
1483            // v0.9.5: print shell completion script to stdout. Operator
1484            // pipes into their shell's completion dir; tab completion
1485            // covers verbs (dial, send, pending, accept, etc.) AND
1486            // their flags. Peer-name dynamic completion is a future
1487            // shell-side enhancement; clap_complete only ships the
1488            // static grammar.
1489            use clap::CommandFactory;
1490            let mut cmd = Cli::command();
1491            clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1492            Ok(())
1493        }
1494        Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1495        Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1496        Command::Send {
1497            peer,
1498            kind_or_body,
1499            body,
1500            deadline,
1501            no_auto_pair,
1502            json,
1503        } => {
1504            // P0.S: smart-positional API. `wire send peer body` =
1505            // kind=claim. `wire send peer kind body` = explicit kind.
1506            let (kind, body) = match body {
1507                Some(real_body) => (kind_or_body, real_body),
1508                None => ("claim".to_string(), kind_or_body),
1509            };
1510            cmd_send(
1511                &peer,
1512                &kind,
1513                &body,
1514                deadline.as_deref(),
1515                no_auto_pair,
1516                json_default(json),
1517            )
1518        }
1519        Command::Dial {
1520            name,
1521            message,
1522            json,
1523        } => cmd_dial(&name, message.as_deref(), json_default(json)),
1524        Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1525        Command::Monitor {
1526            peer,
1527            json,
1528            include_handshake,
1529            interval_ms,
1530            replay,
1531        } => cmd_monitor(
1532            peer.as_deref(),
1533            json,
1534            include_handshake,
1535            interval_ms,
1536            replay,
1537        ),
1538        Command::Verify { path, json } => cmd_verify(&path, json),
1539        Command::Responder { command } => match command {
1540            ResponderCommand::Set {
1541                status,
1542                reason,
1543                json,
1544            } => cmd_responder_set(&status, reason.as_deref(), json),
1545            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1546        },
1547        Command::Mcp => cmd_mcp(),
1548        Command::RelayServer {
1549            bind,
1550            local_only,
1551            uds,
1552        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1553        Command::BindRelay {
1554            url,
1555            scope,
1556            replace,
1557            migrate_pinned,
1558            json,
1559        } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1560        Command::AddPeerSlot {
1561            handle,
1562            url,
1563            slot_id,
1564            slot_token,
1565            json,
1566        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1567        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1568        Command::Pull { json } => cmd_pull(json),
1569        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1570        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1571        Command::ForgetPeer {
1572            handle,
1573            purge,
1574            json,
1575        } => cmd_forget_peer(&handle, purge, json),
1576        Command::Daemon {
1577            interval,
1578            once,
1579            json,
1580        } => cmd_daemon(interval, once, json),
1581        Command::PairHost {
1582            relay,
1583            yes,
1584            timeout,
1585            detach,
1586            json,
1587        } => {
1588            if detach {
1589                cmd_pair_host_detach(&relay, json)
1590            } else {
1591                cmd_pair_host(&relay, yes, timeout)
1592            }
1593        }
1594        Command::PairJoin {
1595            code_phrase,
1596            relay,
1597            yes,
1598            timeout,
1599            detach,
1600            json,
1601        } => {
1602            if detach {
1603                cmd_pair_join_detach(&code_phrase, &relay, json)
1604            } else {
1605                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1606            }
1607        }
1608        Command::PairConfirm {
1609            code_phrase,
1610            digits,
1611            json,
1612        } => cmd_pair_confirm(&code_phrase, &digits, json),
1613        Command::PairList {
1614            json,
1615            watch,
1616            watch_interval,
1617        } => cmd_pair_list(json, watch, watch_interval),
1618        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1619        Command::PairWatch {
1620            code_phrase,
1621            status,
1622            timeout,
1623            json,
1624        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1625        Command::Pair {
1626            handle,
1627            code,
1628            relay,
1629            yes,
1630            timeout,
1631            no_setup,
1632            detach,
1633        } => {
1634            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1635            // the zero-paste megacommand path — `wire pair slancha-spark@
1636            // wireup.net` does add + poll-for-ack + verify in one shot. The
1637            // SAS / code-based pair flow stays available for handles without
1638            // `@` (bootstrap pairing between two boxes that don't yet share a
1639            // relay directory).
1640            if handle.contains('@') && code.is_none() {
1641                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1642            } else if detach {
1643                cmd_pair_detach(&handle, code.as_deref(), &relay)
1644            } else {
1645                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1646            }
1647        }
1648        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1649        Command::PairAccept { peer, json } => {
1650            let j = json_default(json);
1651            deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1652            cmd_pair_accept(&peer, j)
1653        }
1654        Command::PairReject { peer, json } => {
1655            let j = json_default(json);
1656            deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1657            cmd_pair_reject(&peer, j)
1658        }
1659        Command::PairListInbound { json } => {
1660            let j = json_default(json);
1661            deprecation_warn("pair-list-inbound", "pending", j);
1662            cmd_pair_list_inbound(j)
1663        }
1664        Command::Session(cmd) => cmd_session(cmd),
1665        Command::Identity { cmd } => cmd_identity(cmd),
1666        Command::Mesh(cmd) => cmd_mesh(cmd),
1667        Command::Invite {
1668            relay,
1669            ttl,
1670            uses,
1671            share,
1672            json,
1673        } => cmd_invite(&relay, ttl, uses, share, json),
1674        Command::Accept { target, json } => {
1675            // v0.9.4: smart-dispatch retired. `wire accept` always means
1676            // pair-accept by name. URL-shaped input gets a deprecation
1677            // banner pointing at `wire accept-invite <URL>` and then
1678            // (for back-compat with v0.9 scripts) routes to the invite
1679            // accept path one last time. v1.0 will reject URLs here.
1680            let j = json_default(json);
1681            if target.starts_with("wire://pair?") {
1682                deprecation_warn("accept-url", "accept-invite <url>", j);
1683                cmd_accept(&target, j)
1684            } else {
1685                cmd_pair_accept(&target, j)
1686            }
1687        }
1688        Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1689        Command::Whois {
1690            handle,
1691            json,
1692            relay,
1693        } => {
1694            // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1695            // resolves through the local identity layer (pinned peers
1696            // + local sister sessions). `wire whois <nick>@<relay>`
1697            // keeps the existing federation `.well-known/wire/agent`
1698            // path. `wire whois` (no arg) prints self via the original
1699            // path. The character nickname is the canonical operator-
1700            // facing name as of v0.8 — most callers should hit the
1701            // local route.
1702            match handle.as_deref() {
1703                Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1704                other => cmd_whois(other, json, relay.as_deref()),
1705            }
1706        }
1707        Command::Add {
1708            handle,
1709            relay,
1710            local_sister,
1711            json,
1712        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1713        Command::Up {
1714            relay,
1715            name,
1716            with_local,
1717            no_local,
1718            json,
1719        } => cmd_up(
1720            relay.as_deref(),
1721            name.as_deref(),
1722            with_local.as_deref(),
1723            no_local,
1724            json,
1725        ),
1726        Command::Doctor {
1727            json,
1728            recent_rejections,
1729        } => cmd_doctor(json, recent_rejections),
1730        Command::Upgrade { check, json } => cmd_upgrade(check, json),
1731        Command::Service { action } => cmd_service(action),
1732        Command::Diag { action } => cmd_diag(action),
1733        Command::Claim {
1734            nick,
1735            relay,
1736            public_url,
1737            hidden,
1738            json,
1739        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1740        Command::Profile { action } => cmd_profile(action),
1741        Command::Setup { apply } => cmd_setup(apply),
1742        Command::Reactor {
1743            on_event,
1744            peer,
1745            kind,
1746            verified_only,
1747            interval,
1748            once,
1749            dry_run,
1750            max_per_minute,
1751            max_chain_depth,
1752        } => cmd_reactor(
1753            &on_event,
1754            peer.as_deref(),
1755            kind.as_deref(),
1756            verified_only,
1757            interval,
1758            once,
1759            dry_run,
1760            max_per_minute,
1761            max_chain_depth,
1762        ),
1763        Command::Notify {
1764            interval,
1765            peer,
1766            once,
1767            json,
1768        } => cmd_notify(interval, peer.as_deref(), once, json),
1769    }
1770}
1771
1772// ---------- init ----------
1773
1774fn cmd_init(
1775    handle: Option<&str>,
1776    name: Option<&str>,
1777    relay: Option<&str>,
1778    offline: bool,
1779    as_json: bool,
1780) -> Result<()> {
1781    // One-name rule: a typed handle (if any) is only a vanity seed — the
1782    // persona is derived from the keypair fingerprint, so it has no effect
1783    // on the resulting identity. `wire up` passes None (there is no name to
1784    // type); an explicit `wire init <handle>` passes Some and we surface the
1785    // "ignored in favor of persona" notice for transparency.
1786    if let Some(h) = handle
1787        && !h
1788            .chars()
1789            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1790    {
1791        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
1792    }
1793    if config::is_initialized()? {
1794        bail!(
1795            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1796            config::config_dir()?
1797        );
1798    }
1799    // v0.9.1 smart-default reachability. If the operator passed neither
1800    // --relay nor --offline, probe the conventional local relay at
1801    // http://127.0.0.1:8771 and auto-attach if healthy. Closes the
1802    // silent-slotless footgun WITHOUT the v0.9 rejection wall, which
1803    // forced operators through a three-flag decision tree on first
1804    // invocation. Bare `wire init <handle>` is now ergonomic again
1805    // whenever a local relay is running (the common dev setup).
1806    //
1807    // Probe order:
1808    //   1. --relay <url>          → use it
1809    //   2. --offline               → skip slot allocation (rare power-user)
1810    //   3. local relay reachable  → auto-attach + log to stderr
1811    //   4. otherwise               → bail with actionable options
1812    let mut resolved_relay: Option<String> = relay.map(str::to_string);
1813    if resolved_relay.is_none() && !offline {
1814        let default_local = "http://127.0.0.1:8771";
1815        let client = crate::relay_client::RelayClient::new(default_local);
1816        if client.check_healthz().is_ok() {
1817            eprintln!(
1818                "wire init: local relay at {default_local} reachable — auto-attaching. \
1819                 Use --relay <url> to pick a different relay, --offline to skip."
1820            );
1821            resolved_relay = Some(default_local.to_string());
1822        } else {
1823            // v0.9.5: interactive prompt for first-time operators
1824            // when the smart-default can't auto-attach. Detect TTY on
1825            // stdin AND stderr — only prompt for humans. CI / agents
1826            // / non-interactive shells fall through to the explicit
1827            // error wall (unchanged behavior since v0.9.1).
1828            use std::io::{BufRead, IsTerminal, Write};
1829            let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1830            if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1831                eprintln!("wire init: no local relay reachable at {default_local}.");
1832                eprint!(
1833                    "  Bind to public federation relay https://wireup.net instead? \
1834                     [Y/n/offline/url]: "
1835                );
1836                let _ = std::io::stderr().flush();
1837                let mut input = String::new();
1838                let _ = std::io::stdin().lock().read_line(&mut input);
1839                let answer = input.trim();
1840                match answer {
1841                    "" | "y" | "Y" | "yes" | "YES" => {
1842                        eprintln!("wire init: binding to https://wireup.net");
1843                        resolved_relay = Some("https://wireup.net".to_string());
1844                    }
1845                    "n" | "N" | "no" | "NO" => {
1846                        bail!(
1847                            "wire init: declined federation default; re-run with --relay <url> or --offline."
1848                        );
1849                    }
1850                    "offline" | "OFFLINE" => {
1851                        eprintln!(
1852                            "wire init: proceeding offline. \
1853                             Run `wire bind-relay <url>` before pairing."
1854                        );
1855                        // Fall through with resolved_relay still None;
1856                        // the `offline` flag is conceptually set but
1857                        // the caller's local doesn't need updating —
1858                        // resolved_relay = None + offline behavior
1859                        // is identical for the rest of cmd_init.
1860                    }
1861                    url if url.starts_with("http://") || url.starts_with("https://") => {
1862                        eprintln!("wire init: binding to {url}");
1863                        resolved_relay = Some(url.to_string());
1864                    }
1865                    other => {
1866                        bail!(
1867                            "wire init: unrecognized answer `{other}` — \
1868                             expected Y/n/offline/<url>. Re-run with --relay or --offline."
1869                        );
1870                    }
1871                }
1872            } else {
1873                bail!(
1874                    "wire init: no relay specified and no local relay reachable at \
1875                     http://127.0.0.1:8771.\n\
1876                     Pick one (or just run `wire up`):\n\
1877                     • `wire service install --local-relay` — start the local relay, then re-run\n\
1878                     • `wire up @wireup.net` — bind to public federation in one command\n\
1879                     • `wire init --offline` — generate keypair only \
1880                     (peers cannot reach you until you `wire bind-relay <url>` later)"
1881                );
1882            }
1883        }
1884    }
1885    let relay = resolved_relay.as_deref();
1886
1887    config::ensure_dirs()?;
1888    let (sk_seed, pk_bytes) = generate_keypair();
1889    config::write_private_key(&sk_seed)?;
1890
1891    // v0.11 ONE-NAME: derive the character nickname from a synthetic DID
1892    // using the freshly-generated pubkey, then USE THE CHARACTER as the
1893    // canonical handle. The operator-typed `handle` arg becomes either:
1894    //   - identical to character (already-canonical input — no-op), OR
1895    //   - overridden in favor of character (operator-typed name was a
1896    //     vanity layer that would never have been federation-reachable).
1897    // Either way, agent-card.handle ends up == character, and every
1898    // downstream surface (relay phonebook, .well-known, dial/send) keys
1899    // on the same name an operator sees in their statusline.
1900    //
1901    // Per the v0.11 directive: "If you can't call someone via a name,
1902    // don't let them have it as a name." Operator-typed handles violated
1903    // that rule because the character was the displayed name but the
1904    // handle was the addressable one. Now they're the same string.
1905    // The seed string only fills the (immediately-discarded) handle portion
1906    // of a synthetic DID; the persona derives from the fp suffix regardless,
1907    // so any seed yields the same identity.
1908    let seed = handle.unwrap_or("agent");
1909    let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
1910    let character = crate::character::Character::from_did(&synth_did);
1911    let canonical_handle: &str = &character.nickname;
1912    if let Some(typed) = handle
1913        && typed != canonical_handle
1914    {
1915        eprintln!(
1916            "wire init: one-name rule — typed `{typed}` ignored in favor of \
1917             DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
1918        );
1919    }
1920
1921    let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
1922    let signed = sign_agent_card(&card, &sk_seed);
1923    config::write_agent_card(&signed)?;
1924
1925    let mut trust = empty_trust();
1926    add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
1927    config::write_trust(&trust)?;
1928
1929    let fp = fingerprint(&pk_bytes);
1930    let key_id = make_key_id(canonical_handle, &pk_bytes);
1931    // Rebind `handle` for the rest of cmd_init so downstream prints,
1932    // relay-state writes, etc. all reference the canonical name.
1933    let handle = canonical_handle;
1934
1935    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
1936    let mut relay_info: Option<(String, String)> = None;
1937    if let Some(url) = relay {
1938        let normalized = url.trim_end_matches('/');
1939        let client = crate::relay_client::RelayClient::new(normalized);
1940        client.check_healthz()?;
1941        let alloc = client.allocate_slot(Some(handle))?;
1942        let mut state = config::read_relay_state()?;
1943        state["self"] = json!({
1944            "relay_url": normalized,
1945            "slot_id": alloc.slot_id.clone(),
1946            "slot_token": alloc.slot_token,
1947        });
1948        config::write_relay_state(&state)?;
1949        relay_info = Some((normalized.to_string(), alloc.slot_id));
1950    }
1951
1952    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1953    if as_json {
1954        let mut out = json!({
1955            "did": did_str.clone(),
1956            "fingerprint": fp,
1957            "key_id": key_id,
1958            "config_dir": config::config_dir()?.to_string_lossy(),
1959        });
1960        if let Some((url, slot_id)) = &relay_info {
1961            out["relay_url"] = json!(url);
1962            out["slot_id"] = json!(slot_id);
1963        }
1964        println!("{}", serde_json::to_string(&out)?);
1965    } else {
1966        println!("generated {did_str} (ed25519:{key_id})");
1967        println!(
1968            "config written to {}",
1969            config::config_dir()?.to_string_lossy()
1970        );
1971        if let Some((url, slot_id)) = &relay_info {
1972            println!("bound to relay {url} (slot {slot_id})");
1973            println!();
1974            println!(
1975                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1976            );
1977        } else {
1978            println!();
1979            println!(
1980                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1981            );
1982        }
1983    }
1984    Ok(())
1985}
1986
1987// ---------- status ----------
1988
1989fn cmd_status(as_json: bool) -> Result<()> {
1990    let initialized = config::is_initialized()?;
1991
1992    let mut summary = json!({
1993        "initialized": initialized,
1994    });
1995
1996    if initialized {
1997        let card = config::read_agent_card()?;
1998        let did = card
1999            .get("did")
2000            .and_then(Value::as_str)
2001            .unwrap_or("")
2002            .to_string();
2003        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
2004        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
2005        // legacy cards.
2006        let handle = card
2007            .get("handle")
2008            .and_then(Value::as_str)
2009            .map(str::to_string)
2010            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2011        let pk_b64 = card
2012            .get("verify_keys")
2013            .and_then(Value::as_object)
2014            .and_then(|m| m.values().next())
2015            .and_then(|v| v.get("key"))
2016            .and_then(Value::as_str)
2017            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2018        let pk_bytes = crate::signing::b64decode(pk_b64)?;
2019        summary["did"] = json!(did);
2020        summary["handle"] = json!(handle);
2021        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2022        summary["capabilities"] = card
2023            .get("capabilities")
2024            .cloned()
2025            .unwrap_or_else(|| json!([]));
2026
2027        let trust = config::read_trust()?;
2028        let relay_state_for_tier =
2029            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2030        let mut peers = Vec::new();
2031        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2032            for (peer_handle, _agent) in agents {
2033                if peer_handle == &handle {
2034                    continue; // self
2035                }
2036                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
2037                // for peers we've pinned but never received a pair_drop_ack
2038                // from, so the operator sees the "we can't send to them yet"
2039                // state instead of seeing a misleading VERIFIED.
2040                peers.push(json!({
2041                    "handle": peer_handle,
2042                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2043                }));
2044            }
2045        }
2046        summary["peers"] = json!(peers);
2047
2048        let relay_state = config::read_relay_state()?;
2049        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2050        if !summary["self_relay"].is_null() {
2051            // Hide slot_token from default view.
2052            if let Some(obj) = summary["self_relay"].as_object_mut() {
2053                obj.remove("slot_token");
2054            }
2055        }
2056        summary["peer_slots_count"] = json!(
2057            relay_state
2058                .get("peers")
2059                .and_then(Value::as_object)
2060                .map(|m| m.len())
2061                .unwrap_or(0)
2062        );
2063
2064        // Outbox / inbox queue depth (file count + total events)
2065        let outbox = config::outbox_dir()?;
2066        let inbox = config::inbox_dir()?;
2067        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2068        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2069
2070        // v0.5.19: liveness snapshot through a single helper so this
2071        // surface and `wire doctor` agree by construction. Issue #2:
2072        // doctor PASSed while status said DOWN for 25 min because each
2073        // computed liveness independently. ensure_up::daemon_liveness
2074        // is the only path now.
2075        let snap = crate::ensure_up::daemon_liveness();
2076        let mut daemon = json!({
2077            "running": snap.pidfile_alive,
2078            "pid": snap.pidfile_pid,
2079            "all_running_pids": snap.pgrep_pids,
2080            "orphans": snap.orphan_pids,
2081        });
2082        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2083            daemon["version"] = json!(d.version);
2084            daemon["bin_path"] = json!(d.bin_path);
2085            daemon["did"] = json!(d.did);
2086            daemon["relay_url"] = json!(d.relay_url);
2087            daemon["started_at"] = json!(d.started_at);
2088            daemon["schema"] = json!(d.schema);
2089            if d.version != env!("CARGO_PKG_VERSION") {
2090                daemon["version_mismatch"] = json!({
2091                    "daemon": d.version.clone(),
2092                    "cli": env!("CARGO_PKG_VERSION"),
2093                });
2094            }
2095        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2096            daemon["pidfile_form"] = json!("legacy-int");
2097            daemon["version_mismatch"] = json!({
2098                "daemon": "<pre-0.5.11>",
2099                "cli": env!("CARGO_PKG_VERSION"),
2100            });
2101        }
2102        summary["daemon"] = daemon;
2103
2104        // Pending pair sessions — counts by status.
2105        let pending = crate::pending_pair::list_pending().unwrap_or_default();
2106        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2107        for p in &pending {
2108            *counts.entry(p.status.clone()).or_default() += 1;
2109        }
2110        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
2111        let pending_inbound =
2112            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2113        let inbound_handles: Vec<&str> = pending_inbound
2114            .iter()
2115            .map(|p| p.peer_handle.as_str())
2116            .collect();
2117        summary["pending_pairs"] = json!({
2118            "total": pending.len(),
2119            "by_status": counts,
2120            "inbound_count": pending_inbound.len(),
2121            "inbound_handles": inbound_handles,
2122        });
2123    }
2124
2125    if as_json {
2126        println!("{}", serde_json::to_string(&summary)?);
2127    } else if !initialized {
2128        println!("not initialized — run `wire init <handle>` first");
2129    } else {
2130        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
2131        println!(
2132            "fingerprint:   {}",
2133            summary["fingerprint"].as_str().unwrap_or("?")
2134        );
2135        println!("capabilities:  {}", summary["capabilities"]);
2136        if !summary["self_relay"].is_null() {
2137            println!(
2138                "self relay:    {} (slot {})",
2139                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2140                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2141            );
2142        } else {
2143            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
2144        }
2145        println!(
2146            "peers:         {}",
2147            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2148        );
2149        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2150            println!(
2151                "  - {:<20} tier={}",
2152                p["handle"].as_str().unwrap_or(""),
2153                p["tier"].as_str().unwrap_or("?")
2154            );
2155        }
2156        println!(
2157            "outbox:        {} file(s), {} event(s) queued",
2158            summary["outbox"]["files"].as_u64().unwrap_or(0),
2159            summary["outbox"]["events"].as_u64().unwrap_or(0)
2160        );
2161        println!(
2162            "inbox:         {} file(s), {} event(s) received",
2163            summary["inbox"]["files"].as_u64().unwrap_or(0),
2164            summary["inbox"]["events"].as_u64().unwrap_or(0)
2165        );
2166        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2167        let daemon_pid = summary["daemon"]["pid"]
2168            .as_u64()
2169            .map(|p| p.to_string())
2170            .unwrap_or_else(|| "—".to_string());
2171        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2172        let version_suffix = if !daemon_version.is_empty() {
2173            format!(" v{daemon_version}")
2174        } else {
2175            String::new()
2176        };
2177        println!(
2178            "daemon:        {} (pid {}{})",
2179            if daemon_running { "running" } else { "DOWN" },
2180            daemon_pid,
2181            version_suffix,
2182        );
2183        // P1.7: surface version mismatch + orphan procs loudly.
2184        if let Some(mm) = summary["daemon"].get("version_mismatch") {
2185            println!(
2186                "               !! version mismatch: daemon={} CLI={}. \
2187                 run `wire upgrade` to swap atomically.",
2188                mm["daemon"].as_str().unwrap_or("?"),
2189                mm["cli"].as_str().unwrap_or("?"),
2190            );
2191        }
2192        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2193            && !orphans.is_empty()
2194        {
2195            let pids: Vec<String> = orphans
2196                .iter()
2197                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2198                .collect();
2199            println!(
2200                "               !! orphan daemon process(es): pids {}. \
2201                 pgrep saw them but pidfile didn't — likely stale process from \
2202                 prior install. Multiple daemons race the relay cursor.",
2203                pids.join(", ")
2204            );
2205        }
2206        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2207        let inbound_count = summary["pending_pairs"]["inbound_count"]
2208            .as_u64()
2209            .unwrap_or(0);
2210        if pending_total > 0 {
2211            print!("pending pairs: {pending_total}");
2212            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2213                let parts: Vec<String> = obj
2214                    .iter()
2215                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2216                    .collect();
2217                if !parts.is_empty() {
2218                    print!(" ({})", parts.join(", "));
2219                }
2220            }
2221            println!();
2222        } else if inbound_count == 0 {
2223            println!("pending pairs: none");
2224        }
2225        // v0.5.14: separate line for pending-inbound zero-paste requests.
2226        // Loud because each one is awaiting an operator gesture and the
2227        // capability hasn't flowed yet.
2228        if inbound_count > 0 {
2229            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2230                .as_array()
2231                .map(|a| {
2232                    a.iter()
2233                        .filter_map(|v| v.as_str().map(str::to_string))
2234                        .collect()
2235                })
2236                .unwrap_or_default();
2237            println!(
2238                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2239                handles.join(", "),
2240            );
2241        }
2242    }
2243    Ok(())
2244}
2245
2246fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2247    if !dir.exists() {
2248        return Ok(json!({"files": 0, "events": 0}));
2249    }
2250    let mut files = 0usize;
2251    let mut events = 0usize;
2252    for entry in std::fs::read_dir(dir)? {
2253        let path = entry?.path();
2254        if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2255            files += 1;
2256            if let Ok(body) = std::fs::read_to_string(&path) {
2257                events += body.lines().filter(|l| !l.trim().is_empty()).count();
2258            }
2259        }
2260    }
2261    Ok(json!({"files": files, "events": events}))
2262}
2263
2264// ---------- responder health ----------
2265
2266fn responder_status_allowed(status: &str) -> bool {
2267    matches!(
2268        status,
2269        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2270    )
2271}
2272
2273fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2274    let state = config::read_relay_state()?;
2275    let (label, slot_info) = match peer {
2276        Some(peer) => (
2277            peer.to_string(),
2278            state
2279                .get("peers")
2280                .and_then(|p| p.get(peer))
2281                .ok_or_else(|| {
2282                    anyhow!(
2283                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
2284                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
2285                         (`wire peers` lists who you've already paired with.)"
2286                    )
2287                })?,
2288        ),
2289        None => (
2290            "self".to_string(),
2291            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2292                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2293            })?,
2294        ),
2295    };
2296    let relay_url = slot_info["relay_url"]
2297        .as_str()
2298        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2299        .to_string();
2300    let slot_id = slot_info["slot_id"]
2301        .as_str()
2302        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2303        .to_string();
2304    let slot_token = slot_info["slot_token"]
2305        .as_str()
2306        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2307        .to_string();
2308    Ok((label, relay_url, slot_id, slot_token))
2309}
2310
2311fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2312    if !responder_status_allowed(status) {
2313        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2314    }
2315    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2316    let now = time::OffsetDateTime::now_utc()
2317        .format(&time::format_description::well_known::Rfc3339)
2318        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2319    let mut record = json!({
2320        "status": status,
2321        "set_at": now,
2322    });
2323    if let Some(reason) = reason {
2324        record["reason"] = json!(reason);
2325    }
2326    if status == "online" {
2327        record["last_success_at"] = json!(now);
2328    }
2329    let client = crate::relay_client::RelayClient::new(&relay_url);
2330    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2331    if as_json {
2332        println!("{}", serde_json::to_string(&saved)?);
2333    } else {
2334        let reason = saved
2335            .get("reason")
2336            .and_then(Value::as_str)
2337            .map(|r| format!(" — {r}"))
2338            .unwrap_or_default();
2339        println!(
2340            "responder {}{}",
2341            saved
2342                .get("status")
2343                .and_then(Value::as_str)
2344                .unwrap_or(status),
2345            reason
2346        );
2347    }
2348    Ok(())
2349}
2350
2351fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2352    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2353    let client = crate::relay_client::RelayClient::new(&relay_url);
2354    let health = client.responder_health_get(&slot_id, &slot_token)?;
2355    if as_json {
2356        println!(
2357            "{}",
2358            serde_json::to_string(&json!({
2359                "target": label,
2360                "responder_health": health,
2361            }))?
2362        );
2363    } else if health.is_null() {
2364        println!("{label}: responder health not reported");
2365    } else {
2366        let status = health
2367            .get("status")
2368            .and_then(Value::as_str)
2369            .unwrap_or("unknown");
2370        let reason = health
2371            .get("reason")
2372            .and_then(Value::as_str)
2373            .map(|r| format!(" — {r}"))
2374            .unwrap_or_default();
2375        let last_success = health
2376            .get("last_success_at")
2377            .and_then(Value::as_str)
2378            .map(|t| format!(" (last_success: {t})"))
2379            .unwrap_or_default();
2380        println!("{label}: {status}{reason}{last_success}");
2381    }
2382    Ok(())
2383}
2384
2385fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2386    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2387    let client = crate::relay_client::RelayClient::new(&relay_url);
2388
2389    let started = std::time::Instant::now();
2390    let transport_ok = client.healthz().unwrap_or(false);
2391    let latency_ms = started.elapsed().as_millis() as u64;
2392
2393    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2394    let now = std::time::SystemTime::now()
2395        .duration_since(std::time::UNIX_EPOCH)
2396        .map(|d| d.as_secs())
2397        .unwrap_or(0);
2398    let attention = match last_pull_at_unix {
2399        Some(last) if now.saturating_sub(last) <= 300 => json!({
2400            "status": "ok",
2401            "last_pull_at_unix": last,
2402            "age_seconds": now.saturating_sub(last),
2403            "event_count": event_count,
2404        }),
2405        Some(last) => json!({
2406            "status": "stale",
2407            "last_pull_at_unix": last,
2408            "age_seconds": now.saturating_sub(last),
2409            "event_count": event_count,
2410        }),
2411        None => json!({
2412            "status": "never_pulled",
2413            "last_pull_at_unix": Value::Null,
2414            "event_count": event_count,
2415        }),
2416    };
2417
2418    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2419    let responder = if responder_health.is_null() {
2420        json!({"status": "not_reported", "record": Value::Null})
2421    } else {
2422        json!({
2423            "status": responder_health
2424                .get("status")
2425                .and_then(Value::as_str)
2426                .unwrap_or("unknown"),
2427            "record": responder_health,
2428        })
2429    };
2430
2431    let report = json!({
2432        "peer": peer,
2433        "transport": {
2434            "status": if transport_ok { "ok" } else { "error" },
2435            "relay_url": relay_url,
2436            "latency_ms": latency_ms,
2437        },
2438        "attention": attention,
2439        "responder": responder,
2440    });
2441
2442    if as_json {
2443        println!("{}", serde_json::to_string(&report)?);
2444    } else {
2445        let transport_line = if transport_ok {
2446            format!("ok relay reachable ({latency_ms}ms)")
2447        } else {
2448            "error relay unreachable".to_string()
2449        };
2450        println!("transport      {transport_line}");
2451        match report["attention"]["status"].as_str().unwrap_or("unknown") {
2452            "ok" => println!(
2453                "attention      ok last pull {}s ago",
2454                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2455            ),
2456            "stale" => println!(
2457                "attention      stale last pull {}m ago",
2458                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2459            ),
2460            "never_pulled" => println!("attention      never pulled since relay reset"),
2461            other => println!("attention      {other}"),
2462        }
2463        if report["responder"]["status"] == "not_reported" {
2464            println!("auto-responder not reported");
2465        } else {
2466            let record = &report["responder"]["record"];
2467            let status = record
2468                .get("status")
2469                .and_then(Value::as_str)
2470                .unwrap_or("unknown");
2471            let reason = record
2472                .get("reason")
2473                .and_then(Value::as_str)
2474                .map(|r| format!(" — {r}"))
2475                .unwrap_or_default();
2476            println!("auto-responder {status}{reason}");
2477        }
2478    }
2479    Ok(())
2480}
2481
2482// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
2483
2484// ---------- whoami ----------
2485
2486/// Return the current cwd with the user's home dir abbreviated to `~/`.
2487/// Used in whoami `--short` / `--colored` output so multi-window operators
2488/// see *what project* each Claude is working in alongside the character.
2489fn current_cwd_display() -> String {
2490    let cwd = match std::env::current_dir() {
2491        Ok(c) => c,
2492        Err(_) => return String::from("?"),
2493    };
2494    if let Some(home) = dirs::home_dir()
2495        && let Ok(rel) = cwd.strip_prefix(&home)
2496    {
2497        // strip_prefix returns "" for cwd == home itself; show "~" then.
2498        let rel_str = rel.to_string_lossy();
2499        if rel_str.is_empty() {
2500            return String::from("~");
2501        }
2502        return format!("~/{}", rel_str);
2503    }
2504    cwd.to_string_lossy().into_owned()
2505}
2506
2507fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2508    if !config::is_initialized()? {
2509        bail!("not initialized — run `wire init <handle>` first");
2510    }
2511    let card = config::read_agent_card()?;
2512    let did = card
2513        .get("did")
2514        .and_then(Value::as_str)
2515        .unwrap_or("")
2516        .to_string();
2517    let handle = card
2518        .get("handle")
2519        .and_then(Value::as_str)
2520        .map(str::to_string)
2521        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2522    // v0.11: character is purely DID-derived. No overrides — the
2523    // operator-rename verb is gone and display.json reads are stripped
2524    // because they introduced a second name that peers couldn't find.
2525    let character = crate::character::Character::from_did(&did);
2526
2527    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
2528    // so operators tab-flipping between multiple Claude windows see both
2529    // *who* this session is (character) and *what* it's working on (cwd).
2530    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
2531    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
2532    let cwd_display = current_cwd_display();
2533
2534    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
2535    // beyond did — these calls are hot (statusline polls ~300ms).
2536    if short {
2537        println!("{} · {}", character.short(), cwd_display);
2538        return Ok(());
2539    }
2540    if colored {
2541        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2542        return Ok(());
2543    }
2544
2545    let pk_b64 = card
2546        .get("verify_keys")
2547        .and_then(Value::as_object)
2548        .and_then(|m| m.values().next())
2549        .and_then(|v| v.get("key"))
2550        .and_then(Value::as_str)
2551        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2552    let pk_bytes = crate::signing::b64decode(pk_b64)?;
2553    let fp = fingerprint(&pk_bytes);
2554    let key_id = make_key_id(&handle, &pk_bytes);
2555    let capabilities = card
2556        .get("capabilities")
2557        .cloned()
2558        .unwrap_or_else(|| json!(["wire/v3.1"]));
2559
2560    if as_json {
2561        // v0.11: character_override is always false now (no rename verb,
2562        // no display.json reads). Field stays for back-compat with v0.10
2563        // JSON consumers that key off it.
2564        let has_override = false;
2565        println!(
2566            "{}",
2567            serde_json::to_string(&json!({
2568                "did": did,
2569                "handle": handle,
2570                "fingerprint": fp,
2571                "key_id": key_id,
2572                "public_key_b64": pk_b64,
2573                "capabilities": capabilities,
2574                "config_dir": config::config_dir()?.to_string_lossy(),
2575                "persona": character,
2576                "persona_override": has_override,
2577            }))?
2578        );
2579    } else {
2580        println!("{}", character.colored());
2581        println!("{did} (ed25519:{key_id})");
2582        println!("fingerprint: {fp}");
2583        println!("capabilities: {capabilities}");
2584    }
2585    Ok(())
2586}
2587
2588// ---------- identity (v0.7.0-alpha.3) ----------
2589
2590fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2591    match cmd {
2592        // v0.11: IdentityCommand::Rename deleted. The character is the
2593        // one canonical name (DID-derived); a local-display rename
2594        // would create a second name peers can't find, violating the
2595        // "names must be findable" invariant. Aliases (if needed
2596        // later) become relay-claimed entries that ARE findable —
2597        // a different architectural shape from rename.
2598        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2599        IdentityCommand::List { json } => cmd_session_list(json),
2600        IdentityCommand::Publish {
2601            nick,
2602            relay,
2603            public_url,
2604            hidden,
2605            json,
2606        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2607        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2608        IdentityCommand::Create {
2609            name,
2610            anonymous,
2611            local: _,
2612            json,
2613        } => cmd_identity_create(name.as_deref(), anonymous, json),
2614        IdentityCommand::Persist {
2615            name,
2616            as_name,
2617            json,
2618        } => cmd_identity_persist(&name, as_name.as_deref(), json),
2619        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2620    }
2621}
2622
2623/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
2624/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
2625/// paste into their shell; the identity lives there until reboot
2626/// clears /tmp. Persist promotes it to the real sessions root.
2627fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2628    if anonymous {
2629        // Generate a unique tmpdir for this anonymous identity.
2630        let rand_suffix = format!("{:08x}", rand::random::<u32>());
2631        let anon_name = name
2632            .map(crate::session::sanitize_name)
2633            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2634        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2635        std::fs::create_dir_all(&anon_root)
2636            .with_context(|| format!("creating anon root {anon_root:?}"))?;
2637        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
2638        let session_home = anon_root.join("sessions").join(&anon_name);
2639        std::fs::create_dir_all(&session_home)?;
2640        let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2641        if !status.success() {
2642            bail!("anonymous identity init failed: {status}");
2643        }
2644        // Register the anonymous name in a SIDE registry so persist
2645        // can find it later. Stored at <anon_root>/anon-marker.json.
2646        let marker = anon_root.join("anon-marker.json");
2647        std::fs::write(
2648            &marker,
2649            serde_json::to_vec_pretty(&serde_json::json!({
2650                "name": anon_name,
2651                "session_home": session_home.to_string_lossy(),
2652                "created_at": time::OffsetDateTime::now_utc()
2653                    .format(&time::format_description::well_known::Rfc3339)
2654                    .unwrap_or_default(),
2655                "kind": "anonymous",
2656            }))?,
2657        )?;
2658        let card = serde_json::from_slice::<Value>(&std::fs::read(
2659            session_home
2660                .join("config")
2661                .join("wire")
2662                .join("agent-card.json"),
2663        )?)?;
2664        let did = card
2665            .get("did")
2666            .and_then(Value::as_str)
2667            .unwrap_or("")
2668            .to_string();
2669        if as_json {
2670            println!(
2671                "{}",
2672                serde_json::to_string(&json!({
2673                    "kind": "anonymous",
2674                    "name": anon_name,
2675                    "did": did,
2676                    "session_home": session_home.to_string_lossy(),
2677                    "anon_root": anon_root.to_string_lossy(),
2678                }))?
2679            );
2680        } else {
2681            println!("created anonymous identity `{anon_name}` ({did})");
2682            println!(
2683                "  session_home: {} (dies on reboot — /tmp)",
2684                session_home.display()
2685            );
2686            println!();
2687            println!("activate in this shell:");
2688            println!("  export WIRE_HOME={}", session_home.display());
2689            println!();
2690            println!("promote to persistent later with:");
2691            println!("  wire identity persist {anon_name}");
2692        }
2693        return Ok(());
2694    }
2695    // --local (or default): delegate to existing session new flow.
2696    let name_arg = name.map(|s| s.to_string());
2697    cmd_session_new(
2698        name_arg.as_deref(),
2699        "https://wireup.net",
2700        false,
2701        "http://127.0.0.1:8771",
2702        false,
2703        None,
2704        false,
2705        None,
2706        true, // no_daemon: identity create just allocates the identity, no daemon
2707        true, // local_only: explicit lifecycle
2708        as_json,
2709    )
2710}
2711
2712/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
2713/// tmpdir to the persistent sessions root + registers in the cwd map.
2714fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2715    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
2716    let temp = std::env::temp_dir();
2717    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2718    for entry in std::fs::read_dir(&temp)?.flatten() {
2719        let path = entry.path();
2720        if !path
2721            .file_name()
2722            .and_then(|s| s.to_str())
2723            .map(|s| s.starts_with("wire-anon-"))
2724            .unwrap_or(false)
2725        {
2726            continue;
2727        }
2728        let marker = path.join("anon-marker.json");
2729        if let Ok(bytes) = std::fs::read(&marker)
2730            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2731            && json.get("name").and_then(Value::as_str) == Some(name)
2732        {
2733            let session_home = json
2734                .get("session_home")
2735                .and_then(Value::as_str)
2736                .map(std::path::PathBuf::from)
2737                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2738            found = Some((path, session_home));
2739            break;
2740        }
2741    }
2742    let (anon_root, anon_session_home) = found.ok_or_else(|| {
2743        anyhow!(
2744            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2745             run `wire identity list` to see available identities"
2746        )
2747    })?;
2748
2749    let new_name = as_name.unwrap_or(name);
2750    let new_session_home = crate::session::session_dir(new_name)?;
2751    if new_session_home.exists() {
2752        bail!(
2753            "target session `{new_name}` already exists at {new_session_home:?} — \
2754             pick a different name with --as <new-name>"
2755        );
2756    }
2757
2758    // Move the session dir from tmpdir to persistent root.
2759    if let Some(parent) = new_session_home.parent() {
2760        std::fs::create_dir_all(parent)?;
2761    }
2762    std::fs::rename(&anon_session_home, &new_session_home)
2763        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2764
2765    // Clean up the (now-empty) anon root + marker.
2766    let _ = std::fs::remove_dir_all(&anon_root);
2767
2768    // Register cwd → new_name (operator may have cd'd elsewhere; use the
2769    // session_home's grandparent as the conceptual "cwd" if no other).
2770    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2771    let cwd_key = cwd.to_string_lossy().into_owned();
2772    let new_name_for_reg = new_name.to_string();
2773    if let Err(e) = crate::session::update_registry(|reg| {
2774        reg.by_cwd.insert(cwd_key, new_name_for_reg);
2775        Ok(())
2776    }) {
2777        eprintln!("wire identity persist: failed to update registry: {e:#}");
2778    }
2779
2780    if as_json {
2781        println!(
2782            "{}",
2783            serde_json::to_string(&json!({
2784                "kind": "persisted",
2785                "from_name": name,
2786                "to_name": new_name,
2787                "session_home": new_session_home.to_string_lossy(),
2788            }))?
2789        );
2790    } else {
2791        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2792        println!(
2793            "  session_home: {} (survives reboot)",
2794            new_session_home.display()
2795        );
2796        println!("  registered cwd: {}", cwd.display());
2797    }
2798    Ok(())
2799}
2800
2801/// v0.7.0-alpha.20: demote federation → local. Removes the federation
2802/// slot binding from relay.json (and the legacy top-level fields). Keeps
2803/// the keypair + agent-card so re-publish later just calls `wire identity
2804/// publish` again. local → anonymous is NOT supported; destroy + recreate
2805/// is the safer path for that step-down.
2806fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2807    let sessions = crate::session::list_sessions()?;
2808    let session = sessions
2809        .iter()
2810        .find(|s| s.name == name)
2811        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2812    let relay_state_path = session
2813        .home_dir
2814        .join("config")
2815        .join("wire")
2816        .join("relay.json");
2817    if !relay_state_path.exists() {
2818        bail!("session `{name}` has no relay state — already demoted?");
2819    }
2820    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2821    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2822    let had_fed = self_obj
2823        .get("relay_url")
2824        .and_then(Value::as_str)
2825        .map(|u| {
2826            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2827        })
2828        .unwrap_or(false);
2829    if !had_fed {
2830        if as_json {
2831            println!(
2832                "{}",
2833                serde_json::to_string(
2834                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2835                )?
2836            );
2837        } else {
2838            println!("session `{name}` has no federation slot — nothing to demote");
2839        }
2840        return Ok(());
2841    }
2842    // Strip federation: remove top-level relay_url/slot_id/slot_token,
2843    // remove federation-scope entries from endpoints[].
2844    if let Some(self_mut) = state
2845        .as_object_mut()
2846        .and_then(|m| m.get_mut("self"))
2847        .and_then(|s| s.as_object_mut())
2848    {
2849        self_mut.remove("relay_url");
2850        self_mut.remove("slot_id");
2851        self_mut.remove("slot_token");
2852        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2853            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2854        }
2855    }
2856    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2857
2858    if as_json {
2859        println!(
2860            "{}",
2861            serde_json::to_string(
2862                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2863            )?
2864        );
2865    } else {
2866        println!("demoted `{name}` from federation → local");
2867        println!("  relay slot binding removed; keypair + agent-card retained");
2868        println!("  re-publish with `wire identity publish <nick>`");
2869    }
2870    Ok(())
2871}
2872
2873fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2874    let raw = crate::trust::get_tier(trust, handle);
2875    if raw != "VERIFIED" {
2876        return raw.to_string();
2877    }
2878    let token = relay_state
2879        .get("peers")
2880        .and_then(|p| p.get(handle))
2881        .and_then(|p| p.get("slot_token"))
2882        .and_then(Value::as_str)
2883        .unwrap_or("");
2884    if token.is_empty() {
2885        "PENDING_ACK".to_string()
2886    } else {
2887        raw.to_string()
2888    }
2889}
2890
2891fn cmd_peers(as_json: bool) -> Result<()> {
2892    let trust = config::read_trust()?;
2893    let agents = trust
2894        .get("agents")
2895        .and_then(Value::as_object)
2896        .cloned()
2897        .unwrap_or_default();
2898    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2899
2900    let mut self_did: Option<String> = None;
2901    if let Ok(card) = config::read_agent_card() {
2902        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2903    }
2904
2905    let mut peers = Vec::new();
2906    for (handle, agent) in agents.iter() {
2907        let did = agent
2908            .get("did")
2909            .and_then(Value::as_str)
2910            .unwrap_or("")
2911            .to_string();
2912        if Some(did.as_str()) == self_did.as_deref() {
2913            continue; // skip self-attestation
2914        }
2915        let tier = effective_peer_tier(&trust, &relay_state, handle);
2916        let capabilities = agent
2917            .get("card")
2918            .and_then(|c| c.get("capabilities"))
2919            .cloned()
2920            .unwrap_or_else(|| json!([]));
2921        // v0.7.0-alpha.6: prefer peer's published character override
2922        // (display.nickname / display.emoji on their pinned agent-card).
2923        // Falls back to auto-derived if peer hasn't renamed themselves
2924        // OR runs an older wire that doesn't publish the field.
2925        let character = if did.is_empty() {
2926            None
2927        } else {
2928            let card_obj = agent.get("card");
2929            Some(match card_obj {
2930                Some(card) => crate::character::Character::from_card(card),
2931                None => crate::character::Character::from_did(&did),
2932            })
2933        };
2934        peers.push(json!({
2935            "handle": handle,
2936            "did": did,
2937            "tier": tier,
2938            "capabilities": capabilities,
2939            "persona": character,
2940        }));
2941    }
2942
2943    if as_json {
2944        println!("{}", serde_json::to_string(&peers)?);
2945    } else if peers.is_empty() {
2946        println!("no peers pinned (run `wire join <code>` to pair)");
2947    } else {
2948        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
2949        // computed above (from peer's agent-card, honoring override) so
2950        // text and JSON output never diverge. Pre-alpha.8 the text loop
2951        // recomputed via Character::from_did (no override) — operators
2952        // saw different identities depending on --json flag.
2953        for p in &peers {
2954            let char_json = &p["persona"];
2955            let (colored_char, plain_len): (String, usize) = match char_json {
2956                serde_json::Value::Null => ("?".to_string(), 1),
2957                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2958                    Ok(c) => {
2959                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
2960                        (c.colored(), plain)
2961                    }
2962                    Err(_) => ("?".to_string(), 1),
2963                },
2964            };
2965            let pad = 22usize.saturating_sub(plain_len);
2966            println!(
2967                "{}{}  {:<20} {:<10} {}",
2968                colored_char,
2969                " ".repeat(pad),
2970                p["handle"].as_str().unwrap_or(""),
2971                p["tier"].as_str().unwrap_or(""),
2972                p["did"].as_str().unwrap_or(""),
2973            );
2974        }
2975    }
2976    Ok(())
2977}
2978
2979// ---------- send ----------
2980
2981/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
2982///
2983/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
2984/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
2985/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
2986/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
2987/// hasn't crossed two heartbeats means probably degraded.
2988fn maybe_warn_peer_attentiveness(peer: &str) {
2989    let state = match config::read_relay_state() {
2990        Ok(s) => s,
2991        Err(_) => return,
2992    };
2993    let p = state.get("peers").and_then(|p| p.get(peer));
2994    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2995        Some(s) if !s.is_empty() => s,
2996        _ => return,
2997    };
2998    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2999        Some(s) if !s.is_empty() => s,
3000        _ => return,
3001    };
3002    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3003        Some(s) if !s.is_empty() => s.to_string(),
3004        _ => match state
3005            .get("self")
3006            .and_then(|s| s.get("relay_url"))
3007            .and_then(Value::as_str)
3008        {
3009            Some(s) if !s.is_empty() => s.to_string(),
3010            _ => return,
3011        },
3012    };
3013    let client = crate::relay_client::RelayClient::new(&relay_url);
3014    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3015        Ok(t) => t,
3016        Err(_) => return,
3017    };
3018    let now = std::time::SystemTime::now()
3019        .duration_since(std::time::UNIX_EPOCH)
3020        .map(|d| d.as_secs())
3021        .unwrap_or(0);
3022    match last_pull {
3023        None => {
3024            eprintln!(
3025                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3026            );
3027        }
3028        Some(t) if now.saturating_sub(t) > 300 => {
3029            let mins = now.saturating_sub(t) / 60;
3030            eprintln!(
3031                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3032            );
3033        }
3034        _ => {}
3035    }
3036}
3037
3038pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3039    let trimmed = input.trim();
3040    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3041    {
3042        return Ok(trimmed.to_string());
3043    }
3044    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3045    let n: i64 = amount
3046        .parse()
3047        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3048    if n <= 0 {
3049        bail!("deadline duration must be positive: {input:?}");
3050    }
3051    let duration = match unit {
3052        "m" => time::Duration::minutes(n),
3053        "h" => time::Duration::hours(n),
3054        "d" => time::Duration::days(n),
3055        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3056    };
3057    Ok((time::OffsetDateTime::now_utc() + duration)
3058        .format(&time::format_description::well_known::Rfc3339)
3059        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3060}
3061
3062fn cmd_send(
3063    peer: &str,
3064    kind: &str,
3065    body_arg: &str,
3066    deadline: Option<&str>,
3067    // v0.10: when true, refuse to auto-pair on miss; fail loudly so
3068    // scripts can branch on the error instead of accepting an implicit
3069    // side effect.
3070    no_auto_pair: bool,
3071    as_json: bool,
3072) -> Result<()> {
3073    if !config::is_initialized()? {
3074        bail!("not initialized — run `wire init <handle>` first");
3075    }
3076    let peer_in = crate::agent_card::bare_handle(peer).to_string();
3077    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
3078    // match wins; nickname (DID-hash auto-derived) is the fallback.
3079    // Ambiguous nicknames (two pinned peers DID-hash to the same
3080    // adj-noun pair) fail loudly with disambiguation; unknown handles
3081    // pass through (matches existing `wire send` semantics — queue
3082    // first, deliver best-effort).
3083    let peer = match resolve_peer_handle(&peer_in) {
3084        Ok(Some(resolved)) if resolved != peer_in => {
3085            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3086            resolved
3087        }
3088        Ok(Some(canonical)) => canonical, // exact handle match
3089        Ok(None) => peer_in,              // unknown — pass through, downstream errors
3090        Err(ResolveError::Ambiguous(candidates)) => bail!(
3091            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3092             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3093            candidates.len(),
3094            candidates.join(", ")
3095        ),
3096        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
3097    };
3098
3099    // v0.9 auto-pair-on-miss: if the resolved peer isn't pinned yet but
3100    // matches a local sister session, pair first (disk-read --local-sister
3101    // path) then continue. Closes the "wire send returns queued but
3102    // peer never receives because we were never paired" silent-fail
3103    // class. Equivalent to `wire dial <name>` followed by `wire send
3104    // <name> ...` in one step.
3105    let peer_is_pinned = config::read_relay_state()
3106        .ok()
3107        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3108        .map(|peers| peers.contains_key(&peer))
3109        .unwrap_or(false);
3110    if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3111        if no_auto_pair {
3112            bail!(
3113                "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3114                 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3115                 then re-run send."
3116            );
3117        }
3118        eprintln!(
3119            "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3120             Pass --no-auto-pair to refuse implicit dialing."
3121        );
3122        cmd_add_local_sister(&sister_name, true).map_err(|e| {
3123            anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3124        })?;
3125    }
3126
3127    let peer = peer.as_str();
3128    let sk_seed = config::read_private_key()?;
3129    let card = config::read_agent_card()?;
3130    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3131    let handle = crate::agent_card::display_handle_from_did(did).to_string();
3132    let pk_b64 = card
3133        .get("verify_keys")
3134        .and_then(Value::as_object)
3135        .and_then(|m| m.values().next())
3136        .and_then(|v| v.get("key"))
3137        .and_then(Value::as_str)
3138        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3139    let pk_bytes = crate::signing::b64decode(pk_b64)?;
3140
3141    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
3142    // P0.S (0.5.11): stdin support lets shells pipe in long content
3143    // without quoting/escaping ceremony, and supports heredocs naturally:
3144    //   wire send peer - <<EOF ... EOF
3145    let body_value: Value = if body_arg == "-" {
3146        use std::io::Read;
3147        let mut raw = String::new();
3148        std::io::stdin()
3149            .read_to_string(&mut raw)
3150            .with_context(|| "reading body from stdin")?;
3151        // Try parsing as JSON first; fall back to string literal for
3152        // plain-text bodies.
3153        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3154    } else if let Some(path) = body_arg.strip_prefix('@') {
3155        let raw =
3156            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3157        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3158    } else {
3159        Value::String(body_arg.to_string())
3160    };
3161
3162    let kind_id = parse_kind(kind)?;
3163
3164    let now = time::OffsetDateTime::now_utc()
3165        .format(&time::format_description::well_known::Rfc3339)
3166        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3167
3168    let mut event = json!({
3169        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3170        "timestamp": now,
3171        "from": did,
3172        "to": format!("did:wire:{peer}"),
3173        "type": kind,
3174        "kind": kind_id,
3175        "body": body_value,
3176    });
3177    if let Some(deadline) = deadline {
3178        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3179    }
3180    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3181    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3182
3183    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
3184    // coords in relay-state and ask the relay how recently the peer pulled.
3185    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
3186    // Never blocks the send — the event still queues to outbox.
3187    maybe_warn_peer_attentiveness(peer);
3188
3189    // For now we append to outbox JSONL and rely on a future daemon to push
3190    // to the relay. That's the file-system contract from AGENT_INTEGRATION.md.
3191    // Append goes through `config::append_outbox_record` which holds a per-
3192    // path mutex so concurrent senders cannot interleave bytes mid-line.
3193    let line = serde_json::to_vec(&signed)?;
3194    let outbox = config::append_outbox_record(peer, &line)?;
3195
3196    if as_json {
3197        println!(
3198            "{}",
3199            serde_json::to_string(&json!({
3200                "event_id": event_id,
3201                "status": "queued",
3202                "peer": peer,
3203                "outbox": outbox.to_string_lossy(),
3204            }))?
3205        );
3206    } else {
3207        println!(
3208            "queued event {event_id} → {peer} (outbox: {})",
3209            outbox.display()
3210        );
3211    }
3212    Ok(())
3213}
3214
3215fn parse_kind(s: &str) -> Result<u32> {
3216    if let Ok(n) = s.parse::<u32>() {
3217        return Ok(n);
3218    }
3219    for (id, name) in crate::signing::kinds() {
3220        if *name == s {
3221            return Ok(*id);
3222        }
3223    }
3224    // Unknown name — default to kind 1 (decision) for v0.1.
3225    Ok(1)
3226}
3227
3228// ---------- here (v0.9.3 you-are-here view) ----------
3229
3230/// `wire here` — one-screen "you are this session, your neighbors are
3231/// these." Combines what `wire whoami`, `wire peers`, and `wire session
3232/// list-local` would otherwise force the operator to call separately.
3233fn cmd_here(as_json: bool) -> Result<()> {
3234    let initialized = config::is_initialized().unwrap_or(false);
3235
3236    // Self identity.
3237    let (self_did, self_handle, self_character) = if initialized {
3238        let card = config::read_agent_card().ok();
3239        let did = card
3240            .as_ref()
3241            .and_then(|c| c.get("did").and_then(Value::as_str))
3242            .unwrap_or("")
3243            .to_string();
3244        let handle = if did.is_empty() {
3245            String::new()
3246        } else {
3247            crate::agent_card::display_handle_from_did(&did).to_string()
3248        };
3249        let character = if did.is_empty() {
3250            None
3251        } else {
3252            // v0.11: DID-derived only. No display.json overrides.
3253            Some(crate::character::Character::from_did(&did))
3254        };
3255        (did, handle, character)
3256    } else {
3257        (String::new(), String::new(), None)
3258    };
3259
3260    let cwd = std::env::current_dir()
3261        .map(|p| p.to_string_lossy().into_owned())
3262        .unwrap_or_default();
3263    let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3264
3265    // Sister sessions (same-machine).
3266    let mut sisters: Vec<Value> = Vec::new();
3267    if let Ok(listing) = crate::session::list_local_sessions() {
3268        for group in listing.local.values() {
3269            for s in group {
3270                if s.handle.as_deref() == Some(self_handle.as_str()) {
3271                    continue; // skip self
3272                }
3273                let ch = s.did.as_deref().map(crate::character::Character::from_did);
3274                sisters.push(json!({
3275                    "session": s.name,
3276                    "handle": s.handle,
3277                    "persona": ch,
3278                }));
3279            }
3280        }
3281    }
3282
3283    // Pinned peers (trust ring agents).
3284    let mut peers: Vec<Value> = Vec::new();
3285    if initialized
3286        && let Ok(trust) = config::read_trust()
3287        && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3288    {
3289        for (handle, agent) in agents {
3290            if handle == &self_handle {
3291                continue; // skip self
3292            }
3293            let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3294            let ch = if did.is_empty() {
3295                None
3296            } else {
3297                Some(crate::character::Character::from_did(did))
3298            };
3299            peers.push(json!({
3300                "handle": handle,
3301                "did": did,
3302                "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3303                "persona": ch,
3304            }));
3305        }
3306    }
3307
3308    if as_json {
3309        println!(
3310            "{}",
3311            serde_json::to_string(&json!({
3312                "self": {
3313                    "handle": self_handle,
3314                    "did": self_did,
3315                    "persona": self_character,
3316                    "cwd": cwd,
3317                    "wire_home": wire_home,
3318                },
3319                "sister_sessions": sisters,
3320                "pinned_peers": peers,
3321            }))?
3322        );
3323        return Ok(());
3324    }
3325
3326    // Human format.
3327    if !initialized {
3328        println!("not initialized — run `wire init <handle>` to bootstrap.");
3329        return Ok(());
3330    }
3331    let glyph = self_character
3332        .as_ref()
3333        .map(crate::character::emoji_with_fallback)
3334        .unwrap_or_else(|| "?".to_string());
3335    let nick = self_character
3336        .as_ref()
3337        .map(|c| c.nickname.clone())
3338        .unwrap_or_default();
3339    println!("you are {glyph} {nick}  ({self_handle})");
3340    if !cwd.is_empty() {
3341        println!("  cwd:    {cwd}");
3342    }
3343    // Helper closure that mirrors emoji_with_fallback over a JSON-encoded
3344    // character object (because we already collected sisters/peers into
3345    // Value rows above). Looks up the canonical emoji-name and falls
3346    // back to that — never repeats the nickname inside the brackets.
3347    let render_glyph = |character: &Value| -> String {
3348        let emoji = character
3349            .get("emoji")
3350            .and_then(Value::as_str)
3351            .unwrap_or("?");
3352        let nickname = character
3353            .get("nickname")
3354            .and_then(Value::as_str)
3355            .unwrap_or("?");
3356        if crate::character::terminal_supports_emoji() {
3357            return emoji.to_string();
3358        }
3359        // Synthesize a minimal Character so emoji_with_fallback's
3360        // lookup table picks the right ASCII tag.
3361        let synth = crate::character::Character {
3362            nickname: nickname.to_string(),
3363            emoji: emoji.to_string(),
3364            palette: crate::character::Palette {
3365                primary_hex: String::new(),
3366                accent_hex: String::new(),
3367                ansi256_primary: 0,
3368                ansi256_accent: 0,
3369            },
3370        };
3371        crate::character::emoji_with_fallback(&synth)
3372    };
3373    if !sisters.is_empty() {
3374        println!();
3375        println!("sister sessions on this machine:");
3376        for s in &sisters {
3377            let session = s["session"].as_str().unwrap_or("?");
3378            let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3379            let glyph = render_glyph(&s["persona"]);
3380            println!("  {glyph} {ch_nick}  ({session})");
3381        }
3382    }
3383    if !peers.is_empty() {
3384        println!();
3385        println!("pinned peers:");
3386        for p in &peers {
3387            let handle = p["handle"].as_str().unwrap_or("?");
3388            let tier = p["tier"].as_str().unwrap_or("");
3389            let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3390            let glyph = render_glyph(&p["persona"]);
3391            println!("  {glyph} {ch_nick}  ({handle})  [{tier}]");
3392        }
3393    }
3394    if sisters.is_empty() && peers.is_empty() {
3395        println!();
3396        println!(
3397            "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3398        );
3399    }
3400    Ok(())
3401}
3402
3403// ---------- dial / whois (v0.8 canonical addressing) ----------
3404
3405/// `wire dial <name> [message]` — the one verb operators reach for.
3406/// Resolves any name (nickname/handle/session/DID) to a peer and
3407/// drives the right pair flow + optional first message. See the
3408/// `Command::Dial` doc for the resolution ladder.
3409///
3410/// v0.9: when `name` contains `@<relay>`, route through the federation
3411/// `wire add <handle>@<relay>` path (`.well-known/wire/agent` resolution
3412/// plus cross-machine pair_drop). No more bail with "federation isn't
3413/// implemented yet" — one verb across both orbits.
3414fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3415    if name.contains('@') {
3416        // Federation path. cmd_add already auto-detects (per v0.7.4)
3417        // when input has `@` and routes through the .well-known
3418        // resolver + pair_drop deposit. After it returns, the peer
3419        // is in pending-outbound; bilateral completes when the peer
3420        // accepts. Optionally send the first message after the add.
3421        cmd_add(name, None, false, true)
3422            .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3423        if let Some(msg) = message {
3424            // Peer handle for send = the nick part before the `@`.
3425            let bare = name.split('@').next().unwrap_or(name);
3426            cmd_send(bare, "claim", msg, None, false, as_json)?;
3427        }
3428        return Ok(());
3429    }
3430
3431    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3432    // success with `{found: false, candidates: [...]}` instead of
3433    // erroring. Agents can branch on `found` without wrapping in a
3434    // try/catch.
3435    let resolution = match resolve_name_to_target(name) {
3436        Ok(r) => r,
3437        Err(e) if as_json => {
3438            let pool = known_local_names();
3439            let suggestions = closest_candidates(name, &pool, 3, 3);
3440            println!(
3441                "{}",
3442                serde_json::to_string(&json!({
3443                    "name_input": name,
3444                    "found": false,
3445                    "candidates": suggestions,
3446                    "error": format!("{e:#}"),
3447                }))?
3448            );
3449            return Ok(());
3450        }
3451        Err(e) => return Err(e),
3452    };
3453    let mut steps: Vec<Value> = Vec::new();
3454
3455    match &resolution {
3456        DialTarget::PinnedPeer { handle, .. } => {
3457            steps.push(json!({
3458                "step": "resolved",
3459                "kind": "already_pinned",
3460                "handle": handle,
3461            }));
3462        }
3463        DialTarget::LocalSister { session_name, .. } => {
3464            steps.push(json!({
3465                "step": "resolved",
3466                "kind": "local_sister",
3467                "session": session_name,
3468            }));
3469            // Drive the bilateral pair via the disk-read sister path.
3470            // cmd_add_local_sister already handles "already paired"
3471            // gracefully (its internal state.peers check returns the
3472            // existing pin instead of re-issuing a pair_drop), so
3473            // re-dialling is idempotent.
3474            cmd_add_local_sister(session_name, true).map_err(|e| {
3475                anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3476            })?;
3477            steps.push(json!({
3478                "step": "paired",
3479                "via": "local_sister",
3480            }));
3481        }
3482    }
3483
3484    let send_handle = match &resolution {
3485        DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3486        DialTarget::LocalSister { handle, .. } => handle.clone(),
3487    };
3488
3489    let send_result = if let Some(msg) = message {
3490        let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3491        match &r {
3492            Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3493            Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3494        }
3495        Some(r)
3496    } else {
3497        None
3498    };
3499
3500    if as_json {
3501        println!(
3502            "{}",
3503            serde_json::to_string(&json!({
3504                "name_input": name,
3505                "resolved_handle": send_handle,
3506                "steps": steps,
3507            }))?
3508        );
3509    } else {
3510        println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3511        for s in &steps {
3512            let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3513            println!("  - {step}");
3514        }
3515        if message.is_some() {
3516            println!("  (use `wire tail {send_handle}` to read replies)");
3517        }
3518    }
3519    if let Some(Err(e)) = send_result {
3520        return Err(e);
3521    }
3522    Ok(())
3523}
3524
3525/// `wire whois <name>` — resolve any local name (nickname/session/
3526/// handle/DID) to the full identity row. The inspector for the
3527/// canonical addressing layer. For federation `handle@relay-domain`
3528/// resolution see `cmd_whois` (line 5536+) — the dispatcher chooses
3529/// based on whether the input contains `@`.
3530fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3531    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
3532    // success (exit 0) with `{found: false, candidates: [...]}` so
3533    // agents don't need try/catch around `wire whois <name>`. In
3534    // human mode, the bail's did-you-mean line points at the
3535    // closest candidate.
3536    let resolution = match resolve_name_to_target(name) {
3537        Ok(r) => r,
3538        Err(e) if as_json => {
3539            let pool = known_local_names();
3540            let suggestions = closest_candidates(name, &pool, 3, 3);
3541            println!(
3542                "{}",
3543                serde_json::to_string(&json!({
3544                    "name_input": name,
3545                    "found": false,
3546                    "candidates": suggestions,
3547                    "error": format!("{e:#}"),
3548                }))?
3549            );
3550            return Ok(());
3551        }
3552        Err(e) => return Err(e),
3553    };
3554    match resolution {
3555        DialTarget::PinnedPeer {
3556            handle,
3557            did,
3558            nickname,
3559            emoji,
3560            tier,
3561        } => {
3562            if as_json {
3563                println!(
3564                    "{}",
3565                    serde_json::to_string(&json!({
3566                        "kind": "pinned_peer",
3567                        "handle": handle,
3568                        "did": did,
3569                        "nickname": nickname,
3570                        "emoji": emoji,
3571                        "tier": tier,
3572                    }))?
3573                );
3574            } else {
3575                let n = nickname.as_deref().unwrap_or("(no character)");
3576                let e = emoji.as_deref().unwrap_or("?");
3577                println!("{e} {n}");
3578                println!("  handle:   {handle}");
3579                println!("  did:      {did}");
3580                println!("  tier:     {tier}");
3581                println!("  reach:    pinned peer (already in trust ring + slot pinned)");
3582            }
3583        }
3584        DialTarget::LocalSister {
3585            session_name,
3586            handle,
3587            did,
3588            nickname,
3589            emoji,
3590        } => {
3591            if as_json {
3592                println!(
3593                    "{}",
3594                    serde_json::to_string(&json!({
3595                        "kind": "local_sister",
3596                        "session_name": session_name,
3597                        "handle": handle,
3598                        "did": did,
3599                        "nickname": nickname,
3600                        "emoji": emoji,
3601                    }))?
3602                );
3603            } else {
3604                let n = nickname.as_deref().unwrap_or("(no character)");
3605                let e = emoji.as_deref().unwrap_or("?");
3606                println!("{e} {n}");
3607                println!("  session:  {session_name}");
3608                println!("  handle:   {handle}");
3609                println!(
3610                    "  did:      {}",
3611                    did.as_deref().unwrap_or("(card unreadable)")
3612                );
3613                println!("  reach:    local sister on this machine — `wire dial {n}` pairs us");
3614            }
3615        }
3616    }
3617    Ok(())
3618}
3619
3620enum DialTarget {
3621    PinnedPeer {
3622        handle: String,
3623        did: String,
3624        nickname: Option<String>,
3625        emoji: Option<String>,
3626        tier: String,
3627    },
3628    LocalSister {
3629        session_name: String,
3630        handle: String,
3631        did: Option<String>,
3632        nickname: Option<String>,
3633        emoji: Option<String>,
3634    },
3635}
3636
3637/// Resolution order: pinned peers first (already in our trust ring),
3638/// then local sister sessions (on-disk discovery). Case-insensitive
3639/// match against handle, character nickname, session name, or DID.
3640fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3641    let needle = name.trim();
3642    if needle.is_empty() {
3643        bail!("empty name");
3644    }
3645
3646    // 1. Pinned peers — `wire peers` data. trust.agents is an object
3647    // keyed by handle (not an array); iterate as a map.
3648    if config::is_initialized().unwrap_or(false) {
3649        let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3650        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3651            for (handle_key, agent) in agents {
3652                let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3653                if did.is_empty() {
3654                    continue;
3655                }
3656                let handle = handle_key.clone();
3657                let character = crate::character::Character::from_did(did);
3658                let tier = agent
3659                    .get("tier")
3660                    .and_then(Value::as_str)
3661                    .unwrap_or("UNKNOWN")
3662                    .to_string();
3663                let matches = handle.eq_ignore_ascii_case(needle)
3664                    || did.eq_ignore_ascii_case(needle)
3665                    || character.nickname.eq_ignore_ascii_case(needle);
3666                if matches {
3667                    return Ok(DialTarget::PinnedPeer {
3668                        handle,
3669                        did: did.to_string(),
3670                        nickname: Some(character.nickname),
3671                        emoji: Some(character.emoji.to_string()),
3672                        tier,
3673                    });
3674                }
3675            }
3676        }
3677    }
3678
3679    // 2. Local sister sessions.
3680    if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3681        let sessions = crate::session::list_sessions().unwrap_or_default();
3682        let s = sessions.iter().find(|s| s.name == session_name);
3683        if let Some(s) = s {
3684            return Ok(DialTarget::LocalSister {
3685                session_name: s.name.clone(),
3686                handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3687                did: s.did.clone(),
3688                nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3689                emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3690            });
3691        }
3692    }
3693
3694    // v0.9.2: fuzzy did-you-mean suggestion on resolution miss. Walks
3695    // the union of pinned-peer handles + character nicknames + sister
3696    // session names + sister character nicknames, returns up to 3 names
3697    // within Levenshtein distance 3 of the operator's typed name.
3698    let pool = known_local_names();
3699    let suggestions = closest_candidates(name, &pool, 3, 3);
3700    if suggestions.is_empty() {
3701        bail!(
3702            "no peer matched `{name}`.\n\
3703             Tried: pinned peers (`wire peers`) + local sister sessions \
3704             (`wire session list-local`).\n\
3705             For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3706        );
3707    }
3708    bail!(
3709        "no peer matched `{name}`.\n\
3710         Did you mean: {}?\n\
3711         List all: `wire peers`, `wire session list-local`.",
3712        suggestions
3713            .iter()
3714            .map(|s| format!("`{s}`"))
3715            .collect::<Vec<_>>()
3716            .join(", ")
3717    );
3718}
3719
3720// ---------- tail ----------
3721
3722fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3723    let inbox = config::inbox_dir()?;
3724    if !inbox.exists() {
3725        if !as_json {
3726            eprintln!("no inbox yet — daemon hasn't run, or no events received");
3727        }
3728        return Ok(());
3729    }
3730    let trust = config::read_trust()?;
3731    let mut count = 0usize;
3732
3733    let entries: Vec<_> = std::fs::read_dir(&inbox)?
3734        .filter_map(|e| e.ok())
3735        .map(|e| e.path())
3736        .filter(|p| {
3737            p.extension().map(|x| x == "jsonl").unwrap_or(false)
3738                && match peer {
3739                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3740                    None => true,
3741                }
3742        })
3743        .collect();
3744
3745    for path in entries {
3746        let body = std::fs::read_to_string(&path)?;
3747        for line in body.lines() {
3748            let event: Value = match serde_json::from_str(line) {
3749                Ok(v) => v,
3750                Err(_) => continue,
3751            };
3752            let verified = verify_message_v31(&event, &trust).is_ok();
3753            if as_json {
3754                let mut event_with_meta = event.clone();
3755                if let Some(obj) = event_with_meta.as_object_mut() {
3756                    obj.insert("verified".into(), json!(verified));
3757                }
3758                println!("{}", serde_json::to_string(&event_with_meta)?);
3759            } else {
3760                let ts = event
3761                    .get("timestamp")
3762                    .and_then(Value::as_str)
3763                    .unwrap_or("?");
3764                let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3765                let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3766                let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3767                let summary = event
3768                    .get("body")
3769                    .map(|b| match b {
3770                        Value::String(s) => s.clone(),
3771                        _ => b.to_string(),
3772                    })
3773                    .unwrap_or_default();
3774                let mark = if verified { "✓" } else { "✗" };
3775                let deadline = event
3776                    .get("time_sensitive_until")
3777                    .and_then(Value::as_str)
3778                    .map(|d| format!(" deadline: {d}"))
3779                    .unwrap_or_default();
3780                println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3781            }
3782            count += 1;
3783            if limit > 0 && count >= limit {
3784                return Ok(());
3785            }
3786        }
3787    }
3788    Ok(())
3789}
3790
3791// ---------- monitor (live-tail across all peers, harness-friendly) ----------
3792
3793/// Events filtered out of `wire monitor` by default — pair handshake +
3794/// liveness pings. Operators almost never want these surfaced; an explicit
3795/// `--include-handshake` brings them back.
3796fn monitor_is_noise_kind(kind: &str) -> bool {
3797    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3798}
3799
3800/// Resolve a pinned peer's persona (the DID-derived nickname + emoji,
3801/// respecting an advertised override on their card). `None` if the peer
3802/// isn't in trust or can't be resolved — callers fall back to the handle.
3803fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
3804    let trust = config::read_trust().ok()?;
3805    let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
3806    if let Some(card) = agent.get("card") {
3807        Some(crate::character::Character::from_card(card))
3808    } else {
3809        let did = agent.get("did").and_then(Value::as_str)?;
3810        Some(crate::character::Character::from_did(did))
3811    }
3812}
3813
3814/// "emoji nickname" label for a peer, falling back to the raw handle.
3815fn persona_label(peer_handle: &str) -> String {
3816    match resolve_persona(peer_handle) {
3817        Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
3818        None => peer_handle.to_string(),
3819    }
3820}
3821
3822/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
3823/// full structured event for tooling consumption; the plain form is a tight
3824/// one-line summary suitable as a harness stream-watcher notification.
3825///
3826/// Kept PURE (no trust I/O) so it stays deterministic and cheap per event.
3827/// Persona enrichment for `--json` belongs at InboxEvent construction in
3828/// `inbox_watch` (a follow-up), not here.
3829fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3830    if as_json {
3831        Ok(serde_json::to_string(e)?)
3832    } else {
3833        let eid_short: String = e.event_id.chars().take(12).collect();
3834        let body = e.body_preview.replace('\n', " ");
3835        let ts: String = e.timestamp.chars().take(19).collect();
3836        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3837    }
3838}
3839
3840/// `wire monitor` — long-running line-per-event stream of new inbox events.
3841///
3842/// Built for agent harnesses that have an "every stdout line is a chat
3843/// notification" stream watcher (Claude Code Monitor tool, etc.). One
3844/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
3845/// | python parse | grep -v pair_drop` pipeline operators improvise on day
3846/// one of every wire session.
3847///
3848/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
3849/// pure handshake / liveness noise that operators almost never want
3850/// surfaced. Pass `--include-handshake` if you do.
3851///
3852/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
3853/// doesn't drown the operator in replay), with optional `--replay N` to
3854/// emit the last N events first.
3855fn cmd_monitor(
3856    peer_filter: Option<&str>,
3857    as_json: bool,
3858    include_handshake: bool,
3859    interval_ms: u64,
3860    replay: usize,
3861) -> Result<()> {
3862    let inbox_dir = config::inbox_dir()?;
3863    if !inbox_dir.exists() && !as_json {
3864        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3865    }
3866    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
3867
3868    // Optional replay — read existing files and emit the last `replay` events
3869    // (post-filter) before going live. Useful when the harness restarts and
3870    // wants recent context.
3871    if replay > 0 && inbox_dir.exists() {
3872        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3873        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3874            let path = entry.path();
3875            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3876                continue;
3877            }
3878            let peer = match path.file_stem().and_then(|s| s.to_str()) {
3879                Some(s) => s.to_string(),
3880                None => continue,
3881            };
3882            if let Some(filter) = peer_filter
3883                && peer != filter
3884            {
3885                continue;
3886            }
3887            let body = std::fs::read_to_string(&path).unwrap_or_default();
3888            for line in body.lines() {
3889                let line = line.trim();
3890                if line.is_empty() {
3891                    continue;
3892                }
3893                let signed: Value = match serde_json::from_str(line) {
3894                    Ok(v) => v,
3895                    Err(_) => continue,
3896                };
3897                let ev = crate::inbox_watch::InboxEvent::from_signed(
3898                    &peer, signed, /* verified */ true,
3899                );
3900                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3901                    continue;
3902                }
3903                all.push(ev);
3904            }
3905        }
3906        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
3907        // chronological for same-zoned timestamps).
3908        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3909        let start = all.len().saturating_sub(replay);
3910        for ev in &all[start..] {
3911            println!("{}", monitor_render(ev, as_json)?);
3912        }
3913        use std::io::Write;
3914        std::io::stdout().flush().ok();
3915    }
3916
3917    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
3918    // the first poll only returns events that arrived AFTER startup.
3919    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3920    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3921
3922    loop {
3923        let events = w.poll()?;
3924        let mut wrote = false;
3925        for ev in events {
3926            if let Some(filter) = peer_filter
3927                && ev.peer != filter
3928            {
3929                continue;
3930            }
3931            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3932                continue;
3933            }
3934            println!("{}", monitor_render(&ev, as_json)?);
3935            wrote = true;
3936        }
3937        if wrote {
3938            use std::io::Write;
3939            std::io::stdout().flush().ok();
3940        }
3941        std::thread::sleep(sleep_dur);
3942    }
3943}
3944
3945#[cfg(test)]
3946mod tier_tests {
3947    use super::*;
3948    use serde_json::json;
3949
3950    fn trust_with(handle: &str, tier: &str) -> Value {
3951        json!({
3952            "version": 1,
3953            "agents": {
3954                handle: {
3955                    "tier": tier,
3956                    "did": format!("did:wire:{handle}"),
3957                    "card": {"capabilities": ["wire/v3.1"]}
3958                }
3959            }
3960        })
3961    }
3962
3963    #[test]
3964    fn pending_ack_when_verified_but_no_slot_token() {
3965        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
3966        // slot_token hasn't arrived yet. Display PENDING_ACK so the
3967        // operator knows wire send won't work yet.
3968        let trust = trust_with("willard", "VERIFIED");
3969        let relay_state = json!({
3970            "peers": {
3971                "willard": {
3972                    "relay_url": "https://relay",
3973                    "slot_id": "abc",
3974                    "slot_token": "",
3975                }
3976            }
3977        });
3978        assert_eq!(
3979            effective_peer_tier(&trust, &relay_state, "willard"),
3980            "PENDING_ACK"
3981        );
3982    }
3983
3984    #[test]
3985    fn verified_when_slot_token_present() {
3986        let trust = trust_with("willard", "VERIFIED");
3987        let relay_state = json!({
3988            "peers": {
3989                "willard": {
3990                    "relay_url": "https://relay",
3991                    "slot_id": "abc",
3992                    "slot_token": "tok123",
3993                }
3994            }
3995        });
3996        assert_eq!(
3997            effective_peer_tier(&trust, &relay_state, "willard"),
3998            "VERIFIED"
3999        );
4000    }
4001
4002    #[test]
4003    fn raw_tier_passes_through_for_non_verified() {
4004        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
4005        // UNTRUSTED regardless of slot_token state.
4006        let trust = trust_with("willard", "UNTRUSTED");
4007        let relay_state = json!({
4008            "peers": {"willard": {"slot_token": ""}}
4009        });
4010        assert_eq!(
4011            effective_peer_tier(&trust, &relay_state, "willard"),
4012            "UNTRUSTED"
4013        );
4014    }
4015
4016    #[test]
4017    fn pending_ack_when_relay_state_missing_peer() {
4018        // After wire add, trust gets updated BEFORE relay_state.peers does.
4019        // If relay_state has no entry for the peer at all, the operator
4020        // still hasn't completed the bilateral pin — show PENDING_ACK.
4021        let trust = trust_with("willard", "VERIFIED");
4022        let relay_state = json!({"peers": {}});
4023        assert_eq!(
4024            effective_peer_tier(&trust, &relay_state, "willard"),
4025            "PENDING_ACK"
4026        );
4027    }
4028}
4029
4030#[cfg(test)]
4031mod monitor_tests {
4032    use super::*;
4033    use crate::inbox_watch::InboxEvent;
4034    use serde_json::Value;
4035
4036    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4037        InboxEvent {
4038            peer: peer.to_string(),
4039            event_id: "abcd1234567890ef".to_string(),
4040            kind: kind.to_string(),
4041            body_preview: body.to_string(),
4042            verified: true,
4043            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4044            raw: Value::Null,
4045        }
4046    }
4047
4048    #[test]
4049    fn monitor_filter_drops_handshake_kinds_by_default() {
4050        // The whole point: pair_drop / pair_drop_ack / heartbeat are
4051        // protocol noise. If they leak into the operator's chat stream by
4052        // default, the recipe is useless ("wire monitor talks too much,
4053        // disabled it"). Burn this rule in.
4054        assert!(monitor_is_noise_kind("pair_drop"));
4055        assert!(monitor_is_noise_kind("pair_drop_ack"));
4056        assert!(monitor_is_noise_kind("heartbeat"));
4057
4058        // Real-payload kinds — operator wants every one.
4059        assert!(!monitor_is_noise_kind("claim"));
4060        assert!(!monitor_is_noise_kind("decision"));
4061        assert!(!monitor_is_noise_kind("ack"));
4062        assert!(!monitor_is_noise_kind("request"));
4063        assert!(!monitor_is_noise_kind("note"));
4064        // Unknown future kinds shouldn't be filtered as noise either —
4065        // operator probably wants to see something they don't recognise,
4066        // not have it silently dropped (the P0.1 lesson at the UX layer).
4067        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4068    }
4069
4070    #[test]
4071    fn monitor_render_plain_is_one_short_line() {
4072        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4073        let line = monitor_render(&e, false).unwrap();
4074        // Must be single-line.
4075        assert!(!line.contains('\n'), "render must be one line: {line}");
4076        // Must include peer, kind, body fragment, short event_id.
4077        assert!(line.contains("willard"));
4078        assert!(line.contains("claim"));
4079        assert!(line.contains("real v8 train"));
4080        // Short event id (first 12 chars).
4081        assert!(line.contains("abcd12345678"));
4082        assert!(
4083            !line.contains("abcd1234567890ef"),
4084            "should truncate full id"
4085        );
4086        // RFC3339-ish second precision.
4087        assert!(line.contains("2026-05-15T23:14:07"));
4088    }
4089
4090    #[test]
4091    fn monitor_render_strips_newlines_from_body() {
4092        // Multi-line bodies (markdown lists, code, etc.) must collapse to
4093        // one line — otherwise a single message produces multiple
4094        // notifications in the harness, ruining the "one event = one line"
4095        // contract the Monitor tool relies on.
4096        let e = ev("spark", "claim", "line one\nline two\nline three");
4097        let line = monitor_render(&e, false).unwrap();
4098        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4099        assert!(line.contains("line one line two line three"));
4100    }
4101
4102    #[test]
4103    fn monitor_render_json_is_valid_jsonl() {
4104        let e = ev("spark", "claim", "hi");
4105        let line = monitor_render(&e, true).unwrap();
4106        assert!(!line.contains('\n'));
4107        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4108        assert_eq!(parsed["peer"], "spark");
4109        assert_eq!(parsed["kind"], "claim");
4110        assert_eq!(parsed["body_preview"], "hi");
4111    }
4112
4113    #[test]
4114    fn monitor_does_not_drop_on_verified_null() {
4115        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
4116        // `select(.verified == true)` against inbox JSONL. Daemon writes
4117        // events with verified=null (verification happens at tail-time, not
4118        // write-time), so the filter silently rejected everything — same
4119        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
4120        // never surfaced for ~30min.
4121        //
4122        // wire monitor's render path must NOT consult `.verified` for any
4123        // filter decision. Lock that in here so a future "be conservative,
4124        // only emit verified" patch can't quietly land.
4125        let mut e = ev("spark", "claim", "from disk with verified=null");
4126        e.verified = false; // worst case — even if disk says unverified, emit
4127        let line = monitor_render(&e, false).unwrap();
4128        assert!(line.contains("from disk with verified=null"));
4129        // Noise filter operates purely on kind, never on verified.
4130        assert!(!monitor_is_noise_kind("claim"));
4131    }
4132}
4133
4134// ---------- verify ----------
4135
4136fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4137    let body = if path == "-" {
4138        let mut buf = String::new();
4139        use std::io::Read;
4140        std::io::stdin().read_to_string(&mut buf)?;
4141        buf
4142    } else {
4143        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4144    };
4145    let event: Value = serde_json::from_str(&body)?;
4146    let trust = config::read_trust()?;
4147    match verify_message_v31(&event, &trust) {
4148        Ok(()) => {
4149            if as_json {
4150                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4151            } else {
4152                println!("verified ✓");
4153            }
4154            Ok(())
4155        }
4156        Err(e) => {
4157            let reason = e.to_string();
4158            if as_json {
4159                println!(
4160                    "{}",
4161                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4162                );
4163            } else {
4164                eprintln!("FAILED: {reason}");
4165            }
4166            std::process::exit(1);
4167        }
4168    }
4169}
4170
4171// ---------- mcp / relay-server stubs ----------
4172
4173fn cmd_mcp() -> Result<()> {
4174    crate::mcp::run()
4175}
4176
4177fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4178    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
4179    // overriding --bind. Implies --local-only semantics. Routed to a
4180    // separate serve_uds entry point with a manual hyper accept loop
4181    // (axum 0.7's `serve` is TcpListener-only).
4182    if let Some(socket_path) = uds {
4183        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4184            std::path::PathBuf::from(home)
4185                .join("state")
4186                .join("wire-relay")
4187                .join("uds")
4188        } else {
4189            dirs::state_dir()
4190                .or_else(dirs::data_local_dir)
4191                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4192                .join("wire-relay")
4193                .join("uds")
4194        };
4195        let runtime = tokio::runtime::Builder::new_multi_thread()
4196            .enable_all()
4197            .build()?;
4198        return runtime.block_on(crate::relay_server::serve_uds(
4199            socket_path.to_path_buf(),
4200            base,
4201        ));
4202    }
4203    // v0.5.17: --local-only refuses non-loopback binds. Catches the
4204    // "wait did I just bind a publicly-reachable local-only relay" mistake
4205    // at startup rather than discovering it via an empty phonebook later.
4206    if local_only {
4207        validate_loopback_bind(bind)?;
4208    }
4209    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
4210    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
4211    // so a single user can run both client and server on one machine.
4212    // For --local-only, suffix with /local so a single operator can run
4213    // both a federation relay and a local-only relay without state collision.
4214    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4215        std::path::PathBuf::from(home)
4216            .join("state")
4217            .join("wire-relay")
4218    } else {
4219        dirs::state_dir()
4220            .or_else(dirs::data_local_dir)
4221            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4222            .join("wire-relay")
4223    };
4224    let state_dir = if local_only { base.join("local") } else { base };
4225    let runtime = tokio::runtime::Builder::new_multi_thread()
4226        .enable_all()
4227        .build()?;
4228    runtime.block_on(crate::relay_server::serve_with_mode(
4229        bind,
4230        state_dir,
4231        crate::relay_server::ServerMode { local_only },
4232    ))
4233}
4234
4235/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
4236/// resolves to something outside `127.0.0.0/8` or `::1`.
4237///
4238/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
4239/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
4240/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
4241///
4242/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
4243/// is the IP range Tailscale uses for tailnet addresses. Lets operators
4244/// pair wire across machines using their tailnet IPs (e.g. Mac at
4245/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
4246/// auth + encryption + NAT traversal, wire handles protocol + identity.
4247/// Sidesteps host firewall config entirely (utun interface bypass).
4248///
4249/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
4250/// multicast, broadcast. Those would publish a "local-only" relay to
4251/// the global internet — the v0.5.17 security gate's whole point.
4252fn validate_loopback_bind(bind: &str) -> Result<()> {
4253    // Split host:port. IPv6 literals use `[::]:port` form.
4254    let host = if let Some(stripped) = bind.strip_prefix('[') {
4255        let close = stripped
4256            .find(']')
4257            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4258        stripped[..close].to_string()
4259    } else {
4260        bind.rsplit_once(':')
4261            .map(|(h, _)| h.to_string())
4262            .unwrap_or_else(|| bind.to_string())
4263    };
4264    use std::net::{IpAddr, ToSocketAddrs};
4265    let probe = format!("{host}:0");
4266    let resolved: Vec<_> = probe
4267        .to_socket_addrs()
4268        .with_context(|| format!("resolving bind host {host:?}"))?
4269        .collect();
4270    if resolved.is_empty() {
4271        bail!("--local-only: bind host {host:?} resolved to no addresses");
4272    }
4273    for addr in &resolved {
4274        let ip = addr.ip();
4275        let is_acceptable = match ip {
4276            IpAddr::V4(v4) => {
4277                v4.is_loopback() || v4.is_private() || {
4278                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
4279                    let octets = v4.octets();
4280                    octets[0] == 100 && (64..=127).contains(&octets[1])
4281                }
4282            }
4283            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
4284        };
4285        if !is_acceptable {
4286            bail!(
4287                "--local-only refuses non-private bind: {host:?} resolves to {} \
4288                 which is not loopback (127/8, ::1), RFC 1918 private \
4289                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4290                 (100.64.0.0/10). Remove --local-only to bind publicly.",
4291                ip
4292            );
4293        }
4294    }
4295    Ok(())
4296}
4297
4298// ---------- bind-relay ----------
4299
4300fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4301    use crate::endpoints::EndpointScope;
4302    match s.to_lowercase().as_str() {
4303        "federation" | "fed" => Ok(EndpointScope::Federation),
4304        "local" => Ok(EndpointScope::Local),
4305        "lan" => Ok(EndpointScope::Lan),
4306        "uds" => Ok(EndpointScope::Uds),
4307        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4308    }
4309}
4310
4311/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
4312/// appended to `self.endpoints[]`, keeping any existing slots so an agent
4313/// can hold a local relay AND a federation relay simultaneously without
4314/// black-holing pinned peers. `--replace` restores the pre-v0.12
4315/// destructive single-slot behavior (guarded by issue #7).
4316fn cmd_bind_relay(
4317    url: &str,
4318    scope: Option<&str>,
4319    replace: bool,
4320    migrate_pinned: bool,
4321    as_json: bool,
4322) -> Result<()> {
4323    use crate::endpoints::{Endpoint, self_endpoints};
4324
4325    if !config::is_initialized()? {
4326        bail!("not initialized — run `wire init <handle>` first");
4327    }
4328    let card = config::read_agent_card()?;
4329    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4330    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4331
4332    let normalized = url.trim_end_matches('/');
4333    let new_scope = match scope {
4334        Some(s) => parse_scope(s)?,
4335        None => crate::endpoints::infer_scope_from_url(normalized),
4336    };
4337
4338    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4339    let pinned: Vec<String> = existing
4340        .get("peers")
4341        .and_then(|p| p.as_object())
4342        .map(|o| o.keys().cloned().collect())
4343        .unwrap_or_default();
4344
4345    let existing_eps = self_endpoints(&existing);
4346    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4347
4348    // Destructive paths that black-hole pinned peers (issue #7):
4349    //   • `--replace` drops every other slot.
4350    //   • re-binding the SAME relay rotates that slot in place.
4351    // An additive bind of a NEW relay keeps existing slots, so peers stay
4352    // reachable — no acknowledgement required. This is the v0.12 default
4353    // that unblocks simultaneous local + remote.
4354    let destructive = replace || is_rebind_same;
4355    if destructive && !pinned.is_empty() && !migrate_pinned {
4356        let list = pinned.join(", ");
4357        let why = if replace {
4358            "`--replace` drops your other slot(s)"
4359        } else {
4360            "re-binding the same relay rotates its slot"
4361        };
4362        bail!(
4363            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4364             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4365             read.\n\n\
4366             SAFE PATHS:\n\
4367             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4368             slots — no black-hole.\n\
4369             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4370             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4371             peer out-of-band.\n\n\
4372             Issue #7 (silent black-hole on relay change) caught this.",
4373            n = pinned.len(),
4374        );
4375    }
4376
4377    let client = crate::relay_client::RelayClient::new(normalized);
4378    client.check_healthz()?;
4379    let alloc = client.allocate_slot(Some(&handle))?;
4380
4381    if destructive && !pinned.is_empty() {
4382        eprintln!(
4383            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4384             until they re-pin: {peers}",
4385            mode = if replace { "replacing" } else { "rotating" },
4386            n = pinned.len(),
4387            peers = pinned.join(", "),
4388        );
4389    }
4390
4391    // Write the new slot via the single source of truth for the self-slot
4392    // shape. Additive by default; --replace starts from an empty self so
4393    // only this slot remains.
4394    let mut state = existing;
4395    if replace {
4396        state["self"] = Value::Null;
4397    }
4398    crate::endpoints::upsert_self_endpoint(
4399        &mut state,
4400        Endpoint {
4401            relay_url: normalized.to_string(),
4402            slot_id: alloc.slot_id.clone(),
4403            slot_token: alloc.slot_token.clone(),
4404            scope: new_scope,
4405        },
4406    );
4407    config::write_relay_state(&state)?;
4408    let eps = self_endpoints(&state);
4409
4410    let scope_str = format!("{new_scope:?}").to_lowercase();
4411    if as_json {
4412        println!(
4413            "{}",
4414            serde_json::to_string(&json!({
4415                "relay_url": normalized,
4416                "slot_id": alloc.slot_id,
4417                "scope": scope_str,
4418                "endpoints": eps.len(),
4419                "additive": !replace,
4420                "slot_token_present": true,
4421            }))?
4422        );
4423    } else {
4424        println!(
4425            "bound {scope_str} slot on {normalized} (slot {})",
4426            alloc.slot_id
4427        );
4428        println!(
4429            "self now has {n} endpoint(s): {list}",
4430            n = eps.len(),
4431            list = eps
4432                .iter()
4433                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4434                .collect::<Vec<_>>()
4435                .join(", "),
4436        );
4437    }
4438    Ok(())
4439}
4440
4441// ---------- add-peer-slot ----------
4442
4443fn cmd_add_peer_slot(
4444    handle: &str,
4445    url: &str,
4446    slot_id: &str,
4447    slot_token: &str,
4448    as_json: bool,
4449) -> Result<()> {
4450    let mut state = config::read_relay_state()?;
4451    let peers = state["peers"]
4452        .as_object_mut()
4453        .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
4454    peers.insert(
4455        handle.to_string(),
4456        json!({
4457            "relay_url": url,
4458            "slot_id": slot_id,
4459            "slot_token": slot_token,
4460        }),
4461    );
4462    config::write_relay_state(&state)?;
4463    if as_json {
4464        println!(
4465            "{}",
4466            serde_json::to_string(&json!({
4467                "handle": handle,
4468                "relay_url": url,
4469                "slot_id": slot_id,
4470                "added": true,
4471            }))?
4472        );
4473    } else {
4474        println!("pinned peer slot for {handle} at {url} ({slot_id})");
4475    }
4476    Ok(())
4477}
4478
4479// ---------- push ----------
4480
4481fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4482    let state = config::read_relay_state()?;
4483    let peers = state["peers"].as_object().cloned().unwrap_or_default();
4484    if peers.is_empty() {
4485        bail!(
4486            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4487        );
4488    }
4489    let outbox_dir = config::outbox_dir()?;
4490    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
4491    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
4492    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
4493    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
4494    if outbox_dir.exists() {
4495        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4496        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4497            let path = entry.path();
4498            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4499                continue;
4500            }
4501            let stem = match path.file_stem().and_then(|s| s.to_str()) {
4502                Some(s) => s.to_string(),
4503                None => continue,
4504            };
4505            if pinned.contains(&stem) {
4506                continue;
4507            }
4508            // Try the bare-handle of the orphaned stem — if THAT matches a
4509            // pinned peer, the stem is a stale FQDN-suffixed file.
4510            let bare = crate::agent_card::bare_handle(&stem);
4511            if pinned.contains(bare) {
4512                eprintln!(
4513                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4514                     Merge with: `cat {} >> {}` then delete the FQDN file.",
4515                    stem,
4516                    path.display(),
4517                    outbox_dir.join(format!("{bare}.jsonl")).display(),
4518                );
4519            }
4520        }
4521    }
4522    if !outbox_dir.exists() {
4523        if as_json {
4524            println!(
4525                "{}",
4526                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4527            );
4528        } else {
4529            println!("phyllis: nothing to dial out — write a message first with `wire send`");
4530        }
4531        return Ok(());
4532    }
4533
4534    let mut pushed = Vec::new();
4535    let mut skipped = Vec::new();
4536
4537    // v0.5.17: walk each peer's pinned endpoints in priority order (local
4538    // first if we share a local relay, federation second). Try POST on the
4539    // first endpoint; on transport failure, fall through to the next.
4540    // Falls back to the v0.5.16 legacy single-endpoint code path when the
4541    // peer record carries no `endpoints[]` array (back-compat).
4542    for (peer_handle, _) in peers.iter() {
4543        if let Some(want) = peer_filter
4544            && peer_handle != want
4545        {
4546            continue;
4547        }
4548        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4549        if !outbox.exists() {
4550            continue;
4551        }
4552        let ordered_endpoints =
4553            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4554        if ordered_endpoints.is_empty() {
4555            // Unreachable peer (no federation endpoint AND our local
4556            // relay doesn't match the peer's). Skip with a loud reason
4557            // rather than silently dropping events.
4558            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4559                let event: Value = match serde_json::from_str(line) {
4560                    Ok(v) => v,
4561                    Err(_) => continue,
4562                };
4563                let event_id = event
4564                    .get("event_id")
4565                    .and_then(Value::as_str)
4566                    .unwrap_or("")
4567                    .to_string();
4568                skipped.push(json!({
4569                    "peer": peer_handle,
4570                    "event_id": event_id,
4571                    "reason": "no reachable endpoint pinned for peer",
4572                }));
4573            }
4574            continue;
4575        }
4576        let body = std::fs::read_to_string(&outbox)?;
4577        for line in body.lines() {
4578            let event: Value = match serde_json::from_str(line) {
4579                Ok(v) => v,
4580                Err(_) => continue,
4581            };
4582            let event_id = event
4583                .get("event_id")
4584                .and_then(Value::as_str)
4585                .unwrap_or("")
4586                .to_string();
4587
4588            let mut delivered = false;
4589            let mut last_err_reason: Option<String> = None;
4590            for endpoint in &ordered_endpoints {
4591                let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4592                match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
4593                    Ok(resp) => {
4594                        if resp.status == "duplicate" {
4595                            skipped.push(json!({
4596                                "peer": peer_handle,
4597                                "event_id": event_id,
4598                                "reason": "duplicate",
4599                                "endpoint": endpoint.relay_url,
4600                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4601                            }));
4602                        } else {
4603                            pushed.push(json!({
4604                                "peer": peer_handle,
4605                                "event_id": event_id,
4606                                "endpoint": endpoint.relay_url,
4607                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4608                            }));
4609                        }
4610                        delivered = true;
4611                        break;
4612                    }
4613                    Err(e) => {
4614                        // Local-first endpoint failed; record reason and
4615                        // try the next endpoint silently (operator sees
4616                        // the federation success). If every endpoint
4617                        // fails, the last reason is what gets reported.
4618                        last_err_reason = Some(crate::relay_client::format_transport_error(&e));
4619                    }
4620                }
4621            }
4622            if !delivered {
4623                skipped.push(json!({
4624                    "peer": peer_handle,
4625                    "event_id": event_id,
4626                    "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
4627                }));
4628            }
4629        }
4630    }
4631
4632    if as_json {
4633        println!(
4634            "{}",
4635            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
4636        );
4637    } else {
4638        println!(
4639            "pushed {} event(s); skipped {} ({})",
4640            pushed.len(),
4641            skipped.len(),
4642            if skipped.is_empty() {
4643                "none"
4644            } else {
4645                "see --json for detail"
4646            }
4647        );
4648    }
4649    Ok(())
4650}
4651
4652// ---------- pull ----------
4653
4654fn cmd_pull(as_json: bool) -> Result<()> {
4655    let state = config::read_relay_state()?;
4656    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4657    if self_state.is_null() {
4658        bail!("self slot not bound — run `wire bind-relay <url>` first");
4659    }
4660
4661    // v0.5.17: pull from every endpoint in self.endpoints (federation +
4662    // optional local). Each endpoint has its own per-scope cursor so we
4663    // don't re-pull events we've already seen on that path. Events from
4664    // all endpoints feed into the same inbox JSONL via process_events;
4665    // dedup by event_id is the last line of defense.
4666    // Falls back to a single federation endpoint synthesized from the
4667    // top-level legacy fields when self.endpoints is absent (v0.5.16
4668    // back-compat).
4669    let endpoints = crate::endpoints::self_endpoints(&state);
4670    if endpoints.is_empty() {
4671        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
4672    }
4673
4674    let inbox_dir = config::inbox_dir()?;
4675    config::ensure_dirs()?;
4676
4677    let mut total_seen = 0usize;
4678    let mut all_written: Vec<Value> = Vec::new();
4679    let mut all_rejected: Vec<Value> = Vec::new();
4680    let mut all_blocked = false;
4681    let mut all_advance_cursor_to: Option<String> = None;
4682
4683    for endpoint in &endpoints {
4684        let cursor_key = endpoint_cursor_key(endpoint.scope);
4685        let last_event_id = self_state
4686            .get(&cursor_key)
4687            .and_then(Value::as_str)
4688            .map(str::to_string);
4689        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4690        let events = match client.list_events(
4691            &endpoint.slot_id,
4692            &endpoint.slot_token,
4693            last_event_id.as_deref(),
4694            Some(1000),
4695        ) {
4696            Ok(ev) => ev,
4697            Err(e) => {
4698                // One endpoint's failure shouldn't kill the whole pull.
4699                // The local-relay-down case in particular needs to
4700                // gracefully continue against federation.
4701                eprintln!(
4702                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
4703                    endpoint.relay_url,
4704                    endpoint.scope,
4705                    crate::relay_client::format_transport_error(&e),
4706                );
4707                continue;
4708            }
4709        };
4710        total_seen += events.len();
4711        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
4712        all_written.extend(result.written.iter().cloned());
4713        all_rejected.extend(result.rejected.iter().cloned());
4714        if result.blocked {
4715            all_blocked = true;
4716        }
4717        // Advance per-endpoint cursor. The cursor key is scope-specific
4718        // so federation and local don't trample each other.
4719        if let Some(eid) = result.advance_cursor_to.clone() {
4720            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
4721                all_advance_cursor_to = Some(eid.clone());
4722            }
4723            let key = cursor_key.clone();
4724            config::update_relay_state(|state| {
4725                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4726                    self_obj.insert(key, Value::String(eid));
4727                }
4728                Ok(())
4729            })?;
4730        }
4731    }
4732
4733    // Compatibility shim for the legacy single-cursor code paths below:
4734    // `result` used to come from one process_events call; we now have
4735    // per-endpoint results aggregated into the all_* accumulators.
4736    // Reconstruct a synthetic result for the remaining display logic.
4737    let result = crate::pull::PullResult {
4738        written: all_written,
4739        rejected: all_rejected,
4740        blocked: all_blocked,
4741        advance_cursor_to: all_advance_cursor_to,
4742    };
4743    let events_len = total_seen;
4744
4745    // Cursor advance happened per-endpoint above; no aggregate cursor
4746    // write needed here.
4747
4748    if as_json {
4749        println!(
4750            "{}",
4751            serde_json::to_string(&json!({
4752                "written": result.written,
4753                "rejected": result.rejected,
4754                "total_seen": events_len,
4755                "cursor_blocked": result.blocked,
4756                "cursor_advanced_to": result.advance_cursor_to,
4757            }))?
4758        );
4759    } else {
4760        let blocking = result
4761            .rejected
4762            .iter()
4763            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
4764            .count();
4765        if blocking > 0 {
4766            println!(
4767                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
4768                events_len,
4769                result.written.len(),
4770                result.rejected.len(),
4771                blocking,
4772            );
4773        } else {
4774            println!(
4775                "pulled {} event(s); wrote {}; rejected {}",
4776                events_len,
4777                result.written.len(),
4778                result.rejected.len(),
4779            );
4780        }
4781    }
4782    Ok(())
4783}
4784
4785/// v0.5.17: cursor key for an endpoint's per-scope read position.
4786/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
4787/// back-compat with on-disk relay_state files; local uses a
4788/// `_local` suffix.
4789fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4790    match scope {
4791        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4792        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4793        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4794        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4795    }
4796}
4797
4798// ---------- rotate-slot ----------
4799
4800fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4801    if !config::is_initialized()? {
4802        bail!("not initialized — run `wire init <handle>` first");
4803    }
4804    let mut state = config::read_relay_state()?;
4805    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4806    if self_state.is_null() {
4807        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4808    }
4809    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
4810    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
4811    // top-level legacy fields directly and bailed for those sessions.
4812    let primary = crate::endpoints::self_primary_endpoint(&state)
4813        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
4814    let url = primary.relay_url.clone();
4815    let old_slot_id = primary.slot_id.clone();
4816    let old_slot_token = primary.slot_token.clone();
4817
4818    // Read identity to sign the announcement.
4819    let card = config::read_agent_card()?;
4820    let did = card
4821        .get("did")
4822        .and_then(Value::as_str)
4823        .unwrap_or("")
4824        .to_string();
4825    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4826    let pk_b64 = card
4827        .get("verify_keys")
4828        .and_then(Value::as_object)
4829        .and_then(|m| m.values().next())
4830        .and_then(|v| v.get("key"))
4831        .and_then(Value::as_str)
4832        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4833        .to_string();
4834    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4835    let sk_seed = config::read_private_key()?;
4836
4837    // Allocate new slot on the same relay.
4838    let normalized = url.trim_end_matches('/').to_string();
4839    let client = crate::relay_client::RelayClient::new(&normalized);
4840    client
4841        .check_healthz()
4842        .context("aborting rotation; old slot still valid")?;
4843    let alloc = client.allocate_slot(Some(&handle))?;
4844    let new_slot_id = alloc.slot_id.clone();
4845    let new_slot_token = alloc.slot_token.clone();
4846
4847    // Optionally announce the rotation to every paired peer via the OLD slot.
4848    // Each peer's recipient-side `wire pull` will pick up this event before
4849    // their daemon next polls the new slot — but auto-update of peer's
4850    // relay.json from a wire_close event is a v0.2 daemon feature; for now
4851    // peers see the event and an operator must manually `add-peer-slot` the
4852    // new coords, OR re-pair via SAS.
4853    let mut announced: Vec<String> = Vec::new();
4854    if !no_announce {
4855        let now = time::OffsetDateTime::now_utc()
4856            .format(&time::format_description::well_known::Rfc3339)
4857            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4858        let body = json!({
4859            "reason": "operator-initiated slot rotation",
4860            "new_relay_url": url,
4861            "new_slot_id": new_slot_id,
4862            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
4863            // In v0.1 slot tokens are bilateral-shared, so peer can post via
4864            // existing add-peer-slot flow if operator chooses to re-issue.
4865        });
4866        let peers = state["peers"].as_object().cloned().unwrap_or_default();
4867        for (peer_handle, _peer_info) in peers.iter() {
4868            let event = json!({
4869                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4870                "timestamp": now.clone(),
4871                "from": did,
4872                "to": format!("did:wire:{peer_handle}"),
4873                "type": "wire_close",
4874                "kind": 1201,
4875                "body": body.clone(),
4876            });
4877            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4878                Ok(s) => s,
4879                Err(e) => {
4880                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4881                    continue;
4882                }
4883            };
4884            // Post to OUR old slot (we're announcing on our own slot, NOT
4885            // peer's slot — peer reads from us). Wait, this is wrong: peers
4886            // read from THEIR OWN slot via wire pull. To reach peer A, we
4887            // post to peer A's slot. Use the existing per-peer slot mapping.
4888            let peer_info = match state["peers"].get(peer_handle) {
4889                Some(p) => p.clone(),
4890                None => continue,
4891            };
4892            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4893            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4894            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4895            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4896                continue;
4897            }
4898            let peer_client = if peer_url == url {
4899                client.clone()
4900            } else {
4901                crate::relay_client::RelayClient::new(peer_url)
4902            };
4903            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4904                Ok(_) => announced.push(peer_handle.clone()),
4905                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4906            }
4907        }
4908    }
4909
4910    // Swap the self-slot to the new one.
4911    state["self"] = json!({
4912        "relay_url": url,
4913        "slot_id": new_slot_id,
4914        "slot_token": new_slot_token,
4915    });
4916    config::write_relay_state(&state)?;
4917
4918    if as_json {
4919        println!(
4920            "{}",
4921            serde_json::to_string(&json!({
4922                "rotated": true,
4923                "old_slot_id": old_slot_id,
4924                "new_slot_id": new_slot_id,
4925                "relay_url": url,
4926                "announced_to": announced,
4927            }))?
4928        );
4929    } else {
4930        println!("rotated slot on {url}");
4931        println!(
4932            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4933        );
4934        println!("  new slot_id: {new_slot_id}");
4935        if !announced.is_empty() {
4936            println!(
4937                "  announced wire_close (kind=1201) to: {}",
4938                announced.join(", ")
4939            );
4940        }
4941        println!();
4942        println!("next steps:");
4943        println!("  - peers see the wire_close event in their next `wire pull`");
4944        println!(
4945            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4946        );
4947        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
4948        println!("  - until they do, you'll receive but they won't be able to reach you");
4949        // Suppress unused warning
4950        let _ = old_slot_token;
4951    }
4952    Ok(())
4953}
4954
4955// ---------- forget-peer ----------
4956
4957fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4958    let mut trust = config::read_trust()?;
4959    let mut removed_from_trust = false;
4960    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4961        && agents.remove(handle).is_some()
4962    {
4963        removed_from_trust = true;
4964    }
4965    config::write_trust(&trust)?;
4966
4967    let mut state = config::read_relay_state()?;
4968    let mut removed_from_relay = false;
4969    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4970        && peers.remove(handle).is_some()
4971    {
4972        removed_from_relay = true;
4973    }
4974    config::write_relay_state(&state)?;
4975
4976    let mut purged: Vec<String> = Vec::new();
4977    if purge {
4978        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4979            let path = dir.join(format!("{handle}.jsonl"));
4980            if path.exists() {
4981                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4982                purged.push(path.to_string_lossy().into());
4983            }
4984        }
4985    }
4986
4987    if !removed_from_trust && !removed_from_relay {
4988        if as_json {
4989            println!(
4990                "{}",
4991                serde_json::to_string(&json!({
4992                    "removed": false,
4993                    "reason": format!("peer {handle:?} not pinned"),
4994                }))?
4995            );
4996        } else {
4997            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
4998        }
4999        return Ok(());
5000    }
5001
5002    if as_json {
5003        println!(
5004            "{}",
5005            serde_json::to_string(&json!({
5006                "handle": handle,
5007                "removed_from_trust": removed_from_trust,
5008                "removed_from_relay_state": removed_from_relay,
5009                "purged_files": purged,
5010            }))?
5011        );
5012    } else {
5013        println!("forgot peer {handle:?}");
5014        if removed_from_trust {
5015            println!("  - removed from trust.json");
5016        }
5017        if removed_from_relay {
5018            println!("  - removed from relay.json");
5019        }
5020        if !purged.is_empty() {
5021            for p in &purged {
5022                println!("  - deleted {p}");
5023            }
5024        } else if !purge {
5025            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
5026        }
5027    }
5028    Ok(())
5029}
5030
5031// ---------- daemon (long-lived push+pull sync) ----------
5032
5033fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5034    if !config::is_initialized()? {
5035        bail!("not initialized — run `wire init <handle>` first");
5036    }
5037    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5038
5039    if !as_json {
5040        if once {
5041            eprintln!("wire daemon: single sync cycle, then exit");
5042        } else {
5043            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5044        }
5045    }
5046
5047    // Recover from prior crash: any pending pair in transient state had its
5048    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
5049    // the relay slots and mark the files so the operator can re-issue.
5050    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5051        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5052    }
5053
5054    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
5055    // to our slot, the subscriber signals `wake_rx`; we use it as the
5056    // sleep-or-wake gate of the polling loop. Polling stays as the
5057    // safety net — stream errors fall back transparently to the existing
5058    // interval-based cadence.
5059    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5060    if !once {
5061        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5062    }
5063
5064    loop {
5065        let pushed = run_sync_push().unwrap_or_else(|e| {
5066            eprintln!("daemon: push error: {e:#}");
5067            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5068        });
5069        let pulled = run_sync_pull().unwrap_or_else(|e| {
5070            eprintln!("daemon: pull error: {e:#}");
5071            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5072        });
5073        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5074            eprintln!("daemon: pending-pair tick error: {e:#}");
5075            json!({"transitions": []})
5076        });
5077
5078        if as_json {
5079            println!(
5080                "{}",
5081                serde_json::to_string(&json!({
5082                    "ts": time::OffsetDateTime::now_utc()
5083                        .format(&time::format_description::well_known::Rfc3339)
5084                        .unwrap_or_default(),
5085                    "push": pushed,
5086                    "pull": pulled,
5087                    "pairs": pairs,
5088                }))?
5089            );
5090        } else {
5091            let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5092            let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5093            let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5094            let pair_transitions = pairs["transitions"]
5095                .as_array()
5096                .map(|a| a.len())
5097                .unwrap_or(0);
5098            if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5099                eprintln!(
5100                    "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5101                );
5102            }
5103            // Loud per-transition logging so operator sees pair progress live.
5104            if let Some(arr) = pairs["transitions"].as_array() {
5105                for t in arr {
5106                    eprintln!(
5107                        "  pair {} : {} → {}",
5108                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
5109                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
5110                        t.get("to").and_then(Value::as_str).unwrap_or("?")
5111                    );
5112                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
5113                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5114                    {
5115                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
5116                        eprintln!(
5117                            "    Run: wire pair-confirm {} {}",
5118                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
5119                            sas
5120                        );
5121                    }
5122                }
5123            }
5124        }
5125
5126        if once {
5127            return Ok(());
5128        }
5129        // Wait either for the next poll-interval tick OR for a stream
5130        // wake signal — whichever comes first. Drain any additional
5131        // wake-ups that accumulated during the previous cycle since one
5132        // pull catches up everything.
5133        let _ = wake_rx.recv_timeout(interval);
5134        while wake_rx.try_recv().is_ok() {}
5135    }
5136}
5137
5138/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
5139/// shape `wire push --json` emits.
5140fn run_sync_push() -> Result<Value> {
5141    let state = config::read_relay_state()?;
5142    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5143    if peers.is_empty() {
5144        return Ok(json!({"pushed": [], "skipped": []}));
5145    }
5146    let outbox_dir = config::outbox_dir()?;
5147    if !outbox_dir.exists() {
5148        return Ok(json!({"pushed": [], "skipped": []}));
5149    }
5150    let mut pushed = Vec::new();
5151    let mut skipped = Vec::new();
5152    for (peer_handle, slot_info) in peers.iter() {
5153        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5154        if !outbox.exists() {
5155            continue;
5156        }
5157        let url = slot_info["relay_url"].as_str().unwrap_or("");
5158        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5159        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5160        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5161            continue;
5162        }
5163        let client = crate::relay_client::RelayClient::new(url);
5164        let body = std::fs::read_to_string(&outbox)?;
5165        for line in body.lines() {
5166            let event: Value = match serde_json::from_str(line) {
5167                Ok(v) => v,
5168                Err(_) => continue,
5169            };
5170            let event_id = event
5171                .get("event_id")
5172                .and_then(Value::as_str)
5173                .unwrap_or("")
5174                .to_string();
5175            match client.post_event(slot_id, slot_token, &event) {
5176                Ok(resp) => {
5177                    if resp.status == "duplicate" {
5178                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5179                    } else {
5180                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5181                    }
5182                }
5183                Err(e) => {
5184                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
5185                    // errors aren't hidden behind the topmost-context URL string.
5186                    // Issue #6 highest-impact silent-fail fix.
5187                    let reason = crate::relay_client::format_transport_error(&e);
5188                    skipped
5189                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5190                }
5191            }
5192        }
5193    }
5194    Ok(json!({"pushed": pushed, "skipped": skipped}))
5195}
5196
5197/// Programmatic pull. Same shape as `wire pull --json`.
5198///
5199/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
5200/// created via `wire session new --with-local` (which only writes
5201/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
5202/// Pre-v0.9 this function read only the top-level fields and silently
5203/// returned `{}` for any v0.5.17+ session.
5204fn run_sync_pull() -> Result<Value> {
5205    let state = config::read_relay_state()?;
5206    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5207    if self_state.is_null() {
5208        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5209    }
5210    let ep = match crate::endpoints::self_primary_endpoint(&state) {
5211        Some(e) => e,
5212        None => return Ok(json!({"written": [], "rejected": [], "total_seen": 0})),
5213    };
5214    let url = ep.relay_url.as_str();
5215    let slot_id = ep.slot_id.as_str();
5216    let slot_token = ep.slot_token.as_str();
5217    let last_event_id = self_state
5218        .get("last_pulled_event_id")
5219        .and_then(Value::as_str)
5220        .map(str::to_string);
5221    if url.is_empty() {
5222        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5223    }
5224    let client = crate::relay_client::RelayClient::new(url);
5225    let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
5226    let inbox_dir = config::inbox_dir()?;
5227    config::ensure_dirs()?;
5228
5229    // P0.1 (0.5.11): shared cursor-blocking logic. Daemon's --once path
5230    // must match the CLI's `wire pull` semantics or version-skew bugs
5231    // re-emerge by another route.
5232    let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
5233
5234    // P0.3 (0.5.11): same flock-protected RMW as cmd_pull.
5235    if let Some(eid) = &result.advance_cursor_to {
5236        let eid = eid.clone();
5237        config::update_relay_state(|state| {
5238            if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5239                self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
5240            }
5241            Ok(())
5242        })?;
5243    }
5244
5245    Ok(json!({
5246        "written": result.written,
5247        "rejected": result.rejected,
5248        "total_seen": events.len(),
5249        "cursor_blocked": result.blocked,
5250        "cursor_advanced_to": result.advance_cursor_to,
5251    }))
5252}
5253
5254// ---------- pin (manual out-of-band peer pairing) ----------
5255
5256fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5257    let body =
5258        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5259    let card: Value =
5260        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5261    crate::agent_card::verify_agent_card(&card)
5262        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5263
5264    let mut trust = config::read_trust()?;
5265    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5266
5267    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5268    let handle = crate::agent_card::display_handle_from_did(did).to_string();
5269    config::write_trust(&trust)?;
5270
5271    if as_json {
5272        println!(
5273            "{}",
5274            serde_json::to_string(&json!({
5275                "handle": handle,
5276                "did": did,
5277                "tier": "VERIFIED",
5278                "pinned": true,
5279            }))?
5280        );
5281    } else {
5282        println!("pinned {handle} ({did}) at tier VERIFIED");
5283    }
5284    Ok(())
5285}
5286
5287// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
5288
5289fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5290    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5291}
5292
5293fn cmd_pair_join(
5294    code_phrase: &str,
5295    relay_url: &str,
5296    auto_yes: bool,
5297    timeout_secs: u64,
5298) -> Result<()> {
5299    pair_orchestrate(
5300        relay_url,
5301        Some(code_phrase),
5302        "guest",
5303        auto_yes,
5304        timeout_secs,
5305    )
5306}
5307
5308/// Shared orchestration for both sides of the SAS pairing.
5309///
5310/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
5311/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
5312/// `pair_session_confirm_sas` instead.
5313fn pair_orchestrate(
5314    relay_url: &str,
5315    code_in: Option<&str>,
5316    role: &str,
5317    auto_yes: bool,
5318    timeout_secs: u64,
5319) -> Result<()> {
5320    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5321
5322    let mut s = pair_session_open(role, relay_url, code_in)?;
5323
5324    if role == "host" {
5325        eprintln!();
5326        eprintln!("share this code phrase with your peer:");
5327        eprintln!();
5328        eprintln!("    {}", s.code);
5329        eprintln!();
5330        eprintln!(
5331            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5332            s.code
5333        );
5334    } else {
5335        eprintln!();
5336        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5337    }
5338
5339    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
5340    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
5341    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
5342    // see the process is alive while the other side connects.
5343    const HEARTBEAT_SECS: u64 = 10;
5344    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5345    let started = std::time::Instant::now();
5346    let mut last_heartbeat = started;
5347    let formatted = loop {
5348        if let Some(sas) = pair_session_try_sas(&mut s)? {
5349            break sas;
5350        }
5351        let now = std::time::Instant::now();
5352        if now >= deadline {
5353            return Err(anyhow!(
5354                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5355            ));
5356        }
5357        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5358            let elapsed = now.duration_since(started).as_secs();
5359            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
5360            last_heartbeat = now;
5361        }
5362        std::thread::sleep(std::time::Duration::from_millis(250));
5363    };
5364
5365    eprintln!();
5366    eprintln!("SAS digits (must match peer's terminal):");
5367    eprintln!();
5368    eprintln!("    {formatted}");
5369    eprintln!();
5370
5371    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
5372    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
5373    if !auto_yes {
5374        eprint!("does this match your peer's terminal? [y/N]: ");
5375        use std::io::Write;
5376        std::io::stderr().flush().ok();
5377        let mut input = String::new();
5378        std::io::stdin().read_line(&mut input)?;
5379        let trimmed = input.trim().to_lowercase();
5380        if trimmed != "y" && trimmed != "yes" {
5381            bail!("SAS confirmation declined — aborting pairing");
5382        }
5383    }
5384    s.sas_confirmed = true;
5385
5386    // Stage 4 — seal+exchange bootstrap, pin peer.
5387    let result = pair_session_finalize(&mut s, timeout_secs)?;
5388
5389    let peer_did = result["paired_with"].as_str().unwrap_or("");
5390    let peer_role = if role == "host" { "guest" } else { "host" };
5391    eprintln!("paired with {peer_did} (peer role: {peer_role})");
5392    eprintln!("peer card pinned at tier VERIFIED");
5393    eprintln!(
5394        "peer relay slot saved to {}",
5395        config::relay_state_path()?.display()
5396    );
5397
5398    println!("{}", serde_json::to_string(&result)?);
5399    Ok(())
5400}
5401
5402// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
5403// and pair_session_finalize, both of which inline their own deadline loops.)
5404
5405// ---------- pair — single-shot init + pair-* + setup ----------
5406
5407fn cmd_pair(
5408    handle: &str,
5409    code: Option<&str>,
5410    relay: &str,
5411    auto_yes: bool,
5412    timeout_secs: u64,
5413    no_setup: bool,
5414) -> Result<()> {
5415    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
5416    // bails loudly if a different handle is already set (operator must explicitly delete).
5417    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5418    let did = init_result
5419        .get("did")
5420        .and_then(|v| v.as_str())
5421        .unwrap_or("(unknown)")
5422        .to_string();
5423    let already = init_result
5424        .get("already_initialized")
5425        .and_then(|v| v.as_bool())
5426        .unwrap_or(false);
5427    if already {
5428        println!("(identity {did} already initialized — reusing)");
5429    } else {
5430        println!("initialized {did}");
5431    }
5432    println!();
5433
5434    // Step 2 — pair-host or pair-join based on code presence.
5435    match code {
5436        None => {
5437            println!("hosting pair on {relay} (no code = host) ...");
5438            cmd_pair_host(relay, auto_yes, timeout_secs)?;
5439        }
5440        Some(c) => {
5441            println!("joining pair with code {c} on {relay} ...");
5442            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5443        }
5444    }
5445
5446    // Step 3 — register wire as MCP server in detected client configs (idempotent).
5447    if !no_setup {
5448        println!();
5449        println!("registering wire as MCP server in detected client configs ...");
5450        if let Err(e) = cmd_setup(true) {
5451            // Non-fatal — pair succeeded, just print the warning.
5452            eprintln!("warn: setup --apply failed: {e}");
5453            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
5454        }
5455    }
5456
5457    println!();
5458    println!("pair complete. Next steps:");
5459    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
5460    println!("  wire send <peer> claim <msg>   # send your peer something");
5461    println!("  wire tail                      # watch incoming events");
5462    Ok(())
5463}
5464
5465// ---------- detached pair (daemon-orchestrated) ----------
5466
5467/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
5468/// pair-host/-join into a single command. The non-detached variant lives in
5469/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
5470fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5471    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5472    let did = init_result
5473        .get("did")
5474        .and_then(|v| v.as_str())
5475        .unwrap_or("(unknown)")
5476        .to_string();
5477    let already = init_result
5478        .get("already_initialized")
5479        .and_then(|v| v.as_bool())
5480        .unwrap_or(false);
5481    if already {
5482        println!("(identity {did} already initialized — reusing)");
5483    } else {
5484        println!("initialized {did}");
5485    }
5486    println!();
5487    match code {
5488        None => cmd_pair_host_detach(relay, false),
5489        Some(c) => cmd_pair_join_detach(c, relay, false),
5490    }
5491}
5492
5493fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5494    if !config::is_initialized()? {
5495        bail!("not initialized — run `wire init <handle>` first");
5496    }
5497    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5498        Ok(b) => b,
5499        Err(e) => {
5500            if !as_json {
5501                eprintln!(
5502                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5503                );
5504            }
5505            false
5506        }
5507    };
5508    let code = crate::sas::generate_code_phrase();
5509    let code_hash = crate::pair_session::derive_code_hash(&code);
5510    let now = time::OffsetDateTime::now_utc()
5511        .format(&time::format_description::well_known::Rfc3339)
5512        .unwrap_or_default();
5513    let p = crate::pending_pair::PendingPair {
5514        code: code.clone(),
5515        code_hash,
5516        role: "host".to_string(),
5517        relay_url: relay_url.to_string(),
5518        status: "request_host".to_string(),
5519        sas: None,
5520        peer_did: None,
5521        created_at: now,
5522        last_error: None,
5523        pair_id: None,
5524        our_slot_id: None,
5525        our_slot_token: None,
5526        spake2_seed_b64: None,
5527    };
5528    crate::pending_pair::write_pending(&p)?;
5529    if as_json {
5530        println!(
5531            "{}",
5532            serde_json::to_string(&json!({
5533                "state": "queued",
5534                "code_phrase": code,
5535                "relay_url": relay_url,
5536                "role": "host",
5537                "daemon_spawned": daemon_spawned,
5538            }))?
5539        );
5540    } else {
5541        if daemon_spawned {
5542            println!("(started wire daemon in background)");
5543        }
5544        println!("detached pair-host queued. Share this code with your peer:\n");
5545        println!("    {code}\n");
5546        println!("Next steps:");
5547        println!("  wire pair-list                                # check status");
5548        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
5549        println!("  wire pair-cancel  {code}            # to abort");
5550    }
5551    Ok(())
5552}
5553
5554fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
5555    if !config::is_initialized()? {
5556        bail!("not initialized — run `wire init <handle>` first");
5557    }
5558    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5559        Ok(b) => b,
5560        Err(e) => {
5561            if !as_json {
5562                eprintln!(
5563                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5564                );
5565            }
5566            false
5567        }
5568    };
5569    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5570    let code_hash = crate::pair_session::derive_code_hash(&code);
5571    let now = time::OffsetDateTime::now_utc()
5572        .format(&time::format_description::well_known::Rfc3339)
5573        .unwrap_or_default();
5574    let p = crate::pending_pair::PendingPair {
5575        code: code.clone(),
5576        code_hash,
5577        role: "guest".to_string(),
5578        relay_url: relay_url.to_string(),
5579        status: "request_guest".to_string(),
5580        sas: None,
5581        peer_did: None,
5582        created_at: now,
5583        last_error: None,
5584        pair_id: None,
5585        our_slot_id: None,
5586        our_slot_token: None,
5587        spake2_seed_b64: None,
5588    };
5589    crate::pending_pair::write_pending(&p)?;
5590    if as_json {
5591        println!(
5592            "{}",
5593            serde_json::to_string(&json!({
5594                "state": "queued",
5595                "code_phrase": code,
5596                "relay_url": relay_url,
5597                "role": "guest",
5598                "daemon_spawned": daemon_spawned,
5599            }))?
5600        );
5601    } else {
5602        if daemon_spawned {
5603            println!("(started wire daemon in background)");
5604        }
5605        println!("detached pair-join queued for code {code}.");
5606        println!(
5607            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
5608        );
5609    }
5610    Ok(())
5611}
5612
5613fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
5614    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5615    let typed: String = typed_digits
5616        .chars()
5617        .filter(|c| c.is_ascii_digit())
5618        .collect();
5619    if typed.len() != 6 {
5620        bail!(
5621            "expected 6 digits (got {} after stripping non-digits)",
5622            typed.len()
5623        );
5624    }
5625    let mut p = crate::pending_pair::read_pending(&code)?
5626        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
5627    if p.status != "sas_ready" {
5628        bail!(
5629            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
5630            p.status
5631        );
5632    }
5633    let stored = p
5634        .sas
5635        .as_ref()
5636        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
5637        .clone();
5638    if stored == typed {
5639        p.status = "confirmed".to_string();
5640        crate::pending_pair::write_pending(&p)?;
5641        if as_json {
5642            println!(
5643                "{}",
5644                serde_json::to_string(&json!({
5645                    "state": "confirmed",
5646                    "code_phrase": code,
5647                }))?
5648            );
5649        } else {
5650            println!("digits match. Daemon will finalize the handshake on its next tick.");
5651            println!("Run `wire peers` after a few seconds to confirm.");
5652        }
5653    } else {
5654        p.status = "aborted".to_string();
5655        p.last_error = Some(format!(
5656            "SAS digit mismatch (typed {typed}, expected {stored})"
5657        ));
5658        let client = crate::relay_client::RelayClient::new(&p.relay_url);
5659        let _ = client.pair_abandon(&p.code_hash);
5660        crate::pending_pair::write_pending(&p)?;
5661        crate::os_notify::toast(
5662            &format!("wire — pair aborted ({})", p.code),
5663            p.last_error.as_deref().unwrap_or("digits mismatch"),
5664        );
5665        if as_json {
5666            println!(
5667                "{}",
5668                serde_json::to_string(&json!({
5669                    "state": "aborted",
5670                    "code_phrase": code,
5671                    "error": "digits mismatch",
5672                }))?
5673            );
5674        }
5675        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
5676    }
5677    Ok(())
5678}
5679
5680fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
5681    if watch {
5682        return cmd_pair_list_watch(watch_interval_secs);
5683    }
5684    let spake2_items = crate::pending_pair::list_pending()?;
5685    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
5686    if as_json {
5687        // Backwards-compat: flat SPAKE2 array (the shape every existing
5688        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
5689        // surface programmatically via `wire pair-list-inbound --json`
5690        // and via `wire status --json` `pending_pairs.inbound_*` fields.
5691        println!("{}", serde_json::to_string(&spake2_items)?);
5692        return Ok(());
5693    }
5694    if spake2_items.is_empty() && inbound_items.is_empty() {
5695        println!("no pending pair sessions.");
5696        return Ok(());
5697    }
5698    // v0.5.14: inbound section first — these need operator action right now.
5699    // SPAKE2 sessions are typically already mid-flow.
5700    if !inbound_items.is_empty() {
5701        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
5702        println!(
5703            "{:<20} {:<35} {:<25} NEXT STEP",
5704            "PEER", "RELAY", "RECEIVED"
5705        );
5706        for p in &inbound_items {
5707            println!(
5708                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
5709                p.peer_handle,
5710                p.peer_relay_url,
5711                p.received_at,
5712                peer = p.peer_handle,
5713            );
5714        }
5715        println!();
5716    }
5717    if !spake2_items.is_empty() {
5718        println!("SPAKE2 SESSIONS");
5719        println!(
5720            "{:<15} {:<8} {:<18} {:<10} NOTE",
5721            "CODE", "ROLE", "STATUS", "SAS"
5722        );
5723        for p in spake2_items {
5724            let sas = p
5725                .sas
5726                .as_ref()
5727                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
5728                .unwrap_or_else(|| "—".to_string());
5729            let note = p
5730                .last_error
5731                .as_deref()
5732                .or(p.peer_did.as_deref())
5733                .unwrap_or("");
5734            println!(
5735                "{:<15} {:<8} {:<18} {:<10} {}",
5736                p.code, p.role, p.status, sas, note
5737            );
5738        }
5739    }
5740    Ok(())
5741}
5742
5743/// Stream-mode pair-list: never exits. Diffs per-code state every
5744/// `interval_secs` and prints one JSON line per transition (creation,
5745/// status flip, deletion). Useful for shell pipelines:
5746///
5747/// ```text
5748/// wire pair-list --watch | while read line; do
5749///     CODE=$(echo "$line" | jq -r .code)
5750///     STATUS=$(echo "$line" | jq -r .status)
5751///     ...
5752/// done
5753/// ```
5754fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
5755    use std::collections::HashMap;
5756    use std::io::Write;
5757    let interval = std::time::Duration::from_secs(interval_secs.max(1));
5758    // Emit a snapshot synthetic event for every currently-pending pair on
5759    // startup so a consumer that arrives mid-flight sees the current state.
5760    let mut prev: HashMap<String, String> = HashMap::new();
5761    {
5762        let items = crate::pending_pair::list_pending()?;
5763        for p in &items {
5764            println!("{}", serde_json::to_string(&p)?);
5765            prev.insert(p.code.clone(), p.status.clone());
5766        }
5767        // Flush so the consumer's `while read` gets the snapshot promptly.
5768        let _ = std::io::stdout().flush();
5769    }
5770    loop {
5771        std::thread::sleep(interval);
5772        let items = match crate::pending_pair::list_pending() {
5773            Ok(v) => v,
5774            Err(_) => continue,
5775        };
5776        let mut cur: HashMap<String, String> = HashMap::new();
5777        for p in &items {
5778            cur.insert(p.code.clone(), p.status.clone());
5779            match prev.get(&p.code) {
5780                None => {
5781                    // New code appeared.
5782                    println!("{}", serde_json::to_string(&p)?);
5783                }
5784                Some(prev_status) if prev_status != &p.status => {
5785                    // Status flipped.
5786                    println!("{}", serde_json::to_string(&p)?);
5787                }
5788                _ => {}
5789            }
5790        }
5791        for code in prev.keys() {
5792            if !cur.contains_key(code) {
5793                // File disappeared → finalized or cancelled. Emit a synthetic
5794                // "removed" marker so the consumer sees the terminal event.
5795                println!(
5796                    "{}",
5797                    serde_json::to_string(&json!({
5798                        "code": code,
5799                        "status": "removed",
5800                        "_synthetic": true,
5801                    }))?
5802                );
5803            }
5804        }
5805        let _ = std::io::stdout().flush();
5806        prev = cur;
5807    }
5808}
5809
5810/// Block until a pending pair reaches `target_status` or terminates. Process
5811/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
5812/// timeout) so shell scripts can branch directly.
5813fn cmd_pair_watch(
5814    code_phrase: &str,
5815    target_status: &str,
5816    timeout_secs: u64,
5817    as_json: bool,
5818) -> Result<()> {
5819    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5820    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5821    let mut last_seen_status: Option<String> = None;
5822    loop {
5823        let p_opt = crate::pending_pair::read_pending(&code)?;
5824        let now = std::time::Instant::now();
5825        match p_opt {
5826            None => {
5827                // File gone — either finalized (success if target=sas_ready
5828                // since finalization implies it passed sas_ready) or never
5829                // existed. Distinguish by whether we ever saw it.
5830                if last_seen_status.is_some() {
5831                    if as_json {
5832                        println!(
5833                            "{}",
5834                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5835                        );
5836                    } else {
5837                        println!("pair {code} finalized (file removed)");
5838                    }
5839                    return Ok(());
5840                } else {
5841                    if as_json {
5842                        println!(
5843                            "{}",
5844                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5845                        );
5846                    }
5847                    std::process::exit(1);
5848                }
5849            }
5850            Some(p) => {
5851                let cur = p.status.clone();
5852                if Some(cur.clone()) != last_seen_status {
5853                    if as_json {
5854                        // Emit per-transition line so scripts can stream.
5855                        println!("{}", serde_json::to_string(&p)?);
5856                    }
5857                    last_seen_status = Some(cur.clone());
5858                }
5859                if cur == target_status {
5860                    if !as_json {
5861                        let sas_str = p
5862                            .sas
5863                            .as_ref()
5864                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5865                            .unwrap_or_else(|| "—".to_string());
5866                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
5867                    }
5868                    return Ok(());
5869                }
5870                if cur == "aborted" || cur == "aborted_restart" {
5871                    if !as_json {
5872                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
5873                        eprintln!("pair {code} {cur}: {err}");
5874                    }
5875                    std::process::exit(1);
5876                }
5877            }
5878        }
5879        if now >= deadline {
5880            if !as_json {
5881                eprintln!(
5882                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5883                );
5884            }
5885            std::process::exit(2);
5886        }
5887        std::thread::sleep(std::time::Duration::from_millis(250));
5888    }
5889}
5890
5891fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5892    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5893    let p = crate::pending_pair::read_pending(&code)?
5894        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5895    let client = crate::relay_client::RelayClient::new(&p.relay_url);
5896    let _ = client.pair_abandon(&p.code_hash);
5897    crate::pending_pair::delete_pending(&code)?;
5898    if as_json {
5899        println!(
5900            "{}",
5901            serde_json::to_string(&json!({
5902                "state": "cancelled",
5903                "code_phrase": code,
5904            }))?
5905        );
5906    } else {
5907        println!("cancelled pending pair {code} (relay slot released, file removed).");
5908    }
5909    Ok(())
5910}
5911
5912// ---------- pair-abandon — release stuck pair-slot ----------
5913
5914fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5915    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
5916    // typed — normalize via the existing parser.
5917    let code = crate::sas::parse_code_phrase(code_phrase)?;
5918    let code_hash = crate::pair_session::derive_code_hash(code);
5919    let client = crate::relay_client::RelayClient::new(relay_url);
5920    client.pair_abandon(&code_hash)?;
5921    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
5922    println!("host can now issue a fresh code; guest can re-join.");
5923    Ok(())
5924}
5925
5926// ---------- invite / accept — one-paste pair (v0.4.0) ----------
5927
5928fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
5929    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
5930
5931    // If --share, register the invite at the relay's short-URL endpoint and
5932    // build the one-curl onboarding line for the peer to paste.
5933    let share_payload: Option<Value> = if share {
5934        let client = reqwest::blocking::Client::new();
5935        let single_use = if uses == 1 { Some(1u32) } else { None };
5936        let body = json!({
5937            "invite_url": url,
5938            "ttl_seconds": ttl,
5939            "uses": single_use,
5940        });
5941        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
5942        let resp = client.post(&endpoint).json(&body).send()?;
5943        if !resp.status().is_success() {
5944            let code = resp.status();
5945            let txt = resp.text().unwrap_or_default();
5946            bail!("relay {code} on /v1/invite/register: {txt}");
5947        }
5948        let parsed: Value = resp.json()?;
5949        let token = parsed
5950            .get("token")
5951            .and_then(Value::as_str)
5952            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
5953            .to_string();
5954        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
5955        let curl_line = format!("curl -fsSL {share_url} | sh");
5956        Some(json!({
5957            "token": token,
5958            "share_url": share_url,
5959            "curl": curl_line,
5960            "expires_unix": parsed.get("expires_unix"),
5961        }))
5962    } else {
5963        None
5964    };
5965
5966    if as_json {
5967        let mut out = json!({
5968            "invite_url": url,
5969            "ttl_secs": ttl,
5970            "uses": uses,
5971            "relay": relay,
5972        });
5973        if let Some(s) = &share_payload {
5974            out["share"] = s.clone();
5975        }
5976        println!("{}", serde_json::to_string(&out)?);
5977    } else if let Some(s) = share_payload {
5978        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
5979        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
5980        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
5981        println!("{curl}");
5982    } else {
5983        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
5984        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
5985        println!("{url}");
5986    }
5987    Ok(())
5988}
5989
5990fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
5991    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
5992    // resolve it to the underlying wire://pair?... URL via ?format=url before
5993    // accepting. Saves them from having to know which URL shape goes where.
5994    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
5995        let sep = if url.contains('?') { '&' } else { '?' };
5996        let resolve_url = format!("{url}{sep}format=url");
5997        let client = reqwest::blocking::Client::new();
5998        let resp = client
5999            .get(&resolve_url)
6000            .send()
6001            .with_context(|| format!("GET {resolve_url}"))?;
6002        if !resp.status().is_success() {
6003            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6004        }
6005        let body = resp.text().unwrap_or_default().trim().to_string();
6006        if !body.starts_with("wire://pair?") {
6007            bail!(
6008                "short URL {url} did not resolve to a wire:// invite. \
6009                 (got: {}{})",
6010                body.chars().take(80).collect::<String>(),
6011                if body.chars().count() > 80 { "…" } else { "" }
6012            );
6013        }
6014        body
6015    } else {
6016        url.to_string()
6017    };
6018
6019    let result = crate::pair_invite::accept_invite(&resolved)?;
6020    if as_json {
6021        println!("{}", serde_json::to_string(&result)?);
6022    } else {
6023        let did = result
6024            .get("paired_with")
6025            .and_then(Value::as_str)
6026            .unwrap_or("?");
6027        println!("paired with {did}");
6028        println!(
6029            "you can now: wire send {} <kind> <body>",
6030            crate::agent_card::display_handle_from_did(did)
6031        );
6032    }
6033    Ok(())
6034}
6035
6036// ---------- whois / profile (v0.5) ----------
6037
6038fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6039    if let Some(h) = handle {
6040        let parsed = crate::pair_profile::parse_handle(h)?;
6041        // Special-case: if the supplied handle matches our own, skip the
6042        // network round-trip and print local.
6043        if config::is_initialized()? {
6044            let card = config::read_agent_card()?;
6045            let local_handle = card
6046                .get("profile")
6047                .and_then(|p| p.get("handle"))
6048                .and_then(Value::as_str)
6049                .map(str::to_string);
6050            if local_handle.as_deref() == Some(h) {
6051                return cmd_whois(None, as_json, None);
6052            }
6053        }
6054        // Remote resolution via .well-known/wire/agent on the handle's domain.
6055        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6056        if as_json {
6057            println!("{}", serde_json::to_string(&resolved)?);
6058        } else {
6059            print_resolved_profile(&resolved);
6060        }
6061        return Ok(());
6062    }
6063    let card = config::read_agent_card()?;
6064    if as_json {
6065        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6066        println!(
6067            "{}",
6068            serde_json::to_string(&json!({
6069                "did": card.get("did").cloned().unwrap_or(Value::Null),
6070                "profile": profile,
6071            }))?
6072        );
6073    } else {
6074        print!("{}", crate::pair_profile::render_self_summary()?);
6075    }
6076    Ok(())
6077}
6078
6079fn print_resolved_profile(resolved: &Value) {
6080    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6081    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6082    let relay = resolved
6083        .get("relay_url")
6084        .and_then(Value::as_str)
6085        .unwrap_or("");
6086    let slot = resolved
6087        .get("slot_id")
6088        .and_then(Value::as_str)
6089        .unwrap_or("");
6090    let profile = resolved
6091        .get("card")
6092        .and_then(|c| c.get("profile"))
6093        .cloned()
6094        .unwrap_or(Value::Null);
6095    println!("{did}");
6096    println!("  nick:         {nick}");
6097    if !relay.is_empty() {
6098        println!("  relay_url:    {relay}");
6099    }
6100    if !slot.is_empty() {
6101        println!("  slot_id:      {slot}");
6102    }
6103    let pick =
6104        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6105    if let Some(s) = pick("display_name") {
6106        println!("  display_name: {s}");
6107    }
6108    if let Some(s) = pick("emoji") {
6109        println!("  emoji:        {s}");
6110    }
6111    if let Some(s) = pick("motto") {
6112        println!("  motto:        {s}");
6113    }
6114    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6115        let joined: Vec<String> = arr
6116            .iter()
6117            .filter_map(|v| v.as_str().map(str::to_string))
6118            .collect();
6119        println!("  vibe:         {}", joined.join(", "));
6120    }
6121    if let Some(s) = pick("pronouns") {
6122        println!("  pronouns:     {s}");
6123    }
6124}
6125
6126/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
6127/// signed pair_drop event with our card + slot coords, deliver via the
6128/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
6129/// Peer's daemon completes the bilateral pin on its next pull and emits a
6130/// pair_drop_ack carrying their slot_token so we can send back.
6131/// Extract just the host portion from `https://host:port/path` → `host`.
6132/// Returns empty string if the URL is malformed.
6133fn host_of_url(url: &str) -> String {
6134    let no_scheme = url
6135        .trim_start_matches("https://")
6136        .trim_start_matches("http://");
6137    no_scheme
6138        .split('/')
6139        .next()
6140        .unwrap_or("")
6141        .split(':')
6142        .next()
6143        .unwrap_or("")
6144        .to_string()
6145}
6146
6147/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
6148/// operator's own relay? Used to suppress the cross-relay phishing
6149/// warning in `wire add` for the happy path.
6150fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6151    // Hard-coded known-good list. wireup.net is the default relay.
6152    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6153    let peer_domain = peer_domain.trim().to_ascii_lowercase();
6154    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6155        return true;
6156    }
6157    // Operator's OWN relay is implicitly trusted — they're already
6158    // bound to it; pairing same-relay peers is the common case.
6159    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6160    if !our_host.is_empty() && our_host == peer_domain {
6161        return true;
6162    }
6163    false
6164}
6165
6166/// v0.6.6: pair with a sister session on this machine without federation.
6167/// Reads the sister's agent-card + endpoints from disk, pins them into our
6168/// trust + relay_state, builds the same `pair_drop` event the federation
6169/// path would emit, then POSTs it directly to the sister's local-relay slot.
6170/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
6171/// the cwd-derived `wire`) are addressable because the local relay never
6172/// needed a public claim for sister coordination.
6173/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
6174/// to a local sister session.
6175///
6176/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
6177/// either form. Exact session-name matches always win; nickname matches
6178/// are a fallback so operators can type "winter-bay" instead of "wire".
6179/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
6180/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
6181/// with the candidate list so the caller can surface a disambiguation
6182/// hint instead of silently picking one.
6183fn resolve_local_session<'a>(
6184    sessions: &'a [crate::session::SessionInfo],
6185    input: &str,
6186) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6187    // Exact session-name match always wins, even if a nickname elsewhere
6188    // also matches. Predictable for scripts and operator muscle memory.
6189    if let Some(s) = sessions.iter().find(|s| s.name == input) {
6190        return Ok(s);
6191    }
6192    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6193        .iter()
6194        .filter(|s| {
6195            s.character
6196                .as_ref()
6197                .map(|c| c.nickname == input)
6198                .unwrap_or(false)
6199        })
6200        .collect();
6201    match nick_matches.len() {
6202        0 => Err(ResolveError::NotFound),
6203        1 => Ok(nick_matches[0]),
6204        _ => Err(ResolveError::Ambiguous(
6205            nick_matches.iter().map(|s| s.name.clone()).collect(),
6206        )),
6207    }
6208}
6209
6210#[derive(Debug)]
6211enum ResolveError {
6212    NotFound,
6213    Ambiguous(Vec<String>),
6214}
6215
6216/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
6217/// to a pinned peer's canonical handle.
6218///
6219/// `wire send <peer>` accepts either the handle the peer registered with
6220/// or their character nickname (DID-hash-derived). Exact handle match
6221/// always wins. When a nickname matches multiple peers (theoretically
6222/// possible via DID-hash collision in the (adj, noun) space), returns
6223/// `Ambiguous` so the caller can surface a disambiguation hint instead
6224/// of silently picking one.
6225///
6226/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
6227/// overrides on the peer's side live in their local `display.json` and
6228/// aren't yet published via agent-card. (That's the v0.7+ federation
6229/// lifecycle work — peers publishing overrides so we resolve by what
6230/// they call themselves, not just what their DID hashes to.)
6231fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6232    let trust = match config::read_trust() {
6233        Ok(t) => t,
6234        Err(_) => return Ok(None),
6235    };
6236    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6237        Some(a) => a,
6238        None => return Ok(None),
6239    };
6240    if agents.contains_key(input) {
6241        return Ok(Some(input.to_string()));
6242    }
6243    let mut nick_matches: Vec<String> = Vec::new();
6244    for (handle, agent) in agents.iter() {
6245        // v0.7.0-alpha.6: prefer peer's published display nickname over
6246        // auto-derived. Allows `wire send <their-chosen-name>` not just
6247        // `wire send <their-did-hash-derived-name>`.
6248        let character = match agent.get("card") {
6249            Some(card) => crate::character::Character::from_card(card),
6250            None => match agent.get("did").and_then(Value::as_str) {
6251                Some(did) => crate::character::Character::from_did(did),
6252                None => continue,
6253            },
6254        };
6255        if character.nickname == input {
6256            nick_matches.push(handle.clone());
6257        }
6258    }
6259    match nick_matches.len() {
6260        0 => Ok(None),
6261        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6262        _ => Err(ResolveError::Ambiguous(nick_matches)),
6263    }
6264}
6265
6266fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6267    // 1. Locate sister session by name OR character nickname.
6268    let sessions = crate::session::list_sessions()?;
6269    let sister = match resolve_local_session(&sessions, sister_name) {
6270        Ok(s) => s,
6271        Err(ResolveError::NotFound) => bail!(
6272            "no sister session named `{sister_name}` (matched by session name or character nickname). \
6273             Run `wire session list` to see what's available."
6274        ),
6275        Err(ResolveError::Ambiguous(candidates)) => bail!(
6276            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6277             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6278            candidates.len(),
6279            candidates.join(", ")
6280        ),
6281    };
6282    // If we matched via nickname (not exact name), surface that so the
6283    // operator sees what we resolved to. Quiet when names match exactly.
6284    if sister.name != sister_name {
6285        eprintln!(
6286            "wire add: resolved nickname `{sister_name}` → session `{}`",
6287            sister.name
6288        );
6289    }
6290
6291    // 2. Refuse self-pair — operator owns both sides, but a self-loop
6292    // breaks the bilateral state machine.
6293    let our_card = config::read_agent_card()
6294        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6295    let our_did = our_card
6296        .get("did")
6297        .and_then(Value::as_str)
6298        .ok_or_else(|| anyhow!("agent-card missing did"))?
6299        .to_string();
6300    if let Some(sister_did) = sister.did.as_deref()
6301        && sister_did == our_did
6302    {
6303        bail!("refusing to add self (`{sister_name}` is this very session)");
6304    }
6305
6306    // 3. Read sister's agent-card + relay state from disk.
6307    let sister_card_path = sister
6308        .home_dir
6309        .join("config")
6310        .join("wire")
6311        .join("agent-card.json");
6312    let sister_card: Value = serde_json::from_slice(
6313        &std::fs::read(&sister_card_path)
6314            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6315    )
6316    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6317    let sister_relay_state: Value = std::fs::read(
6318        sister
6319            .home_dir
6320            .join("config")
6321            .join("wire")
6322            .join("relay.json"),
6323    )
6324    .ok()
6325    .and_then(|b| serde_json::from_slice(&b).ok())
6326    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6327
6328    let sister_did = sister_card
6329        .get("did")
6330        .and_then(Value::as_str)
6331        .ok_or_else(|| anyhow!("sister card missing did"))?
6332        .to_string();
6333    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6334
6335    // Pull sister's full endpoint set; we want the local one for delivery
6336    // and we'll pin all of them so OUR pushes prefer local-first per the
6337    // existing routing logic.
6338    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6339    if sister_endpoints.is_empty() {
6340        bail!(
6341            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6342        );
6343    }
6344    let sister_local = sister_endpoints
6345        .iter()
6346        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6347    let delivery_endpoint = match sister_local {
6348        Some(e) => e.clone(),
6349        None => sister_endpoints[0].clone(),
6350    };
6351
6352    // 4. Ensure WE have a slot to advertise back. For local-only sessions
6353    // this is the local slot; for dual-slot sessions, federation is fine.
6354    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
6355    // for pure local-only — instead, pick our own existing federation
6356    // endpoint if present, else fall back to whatever's first.
6357    let our_relay_state = config::read_relay_state()?;
6358    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6359    if our_endpoints.is_empty() {
6360        bail!(
6361            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6362        );
6363    }
6364    let our_advertised = our_endpoints
6365        .iter()
6366        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6367        .cloned()
6368        .unwrap_or_else(|| our_endpoints[0].clone());
6369
6370    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
6371    // relay_state.peers with their full endpoint set. slot_token lands
6372    // via pair_drop_ack as usual.
6373    let mut trust = config::read_trust()?;
6374    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6375    config::write_trust(&trust)?;
6376    let mut relay_state = config::read_relay_state()?;
6377    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6378    config::write_relay_state(&relay_state)?;
6379
6380    // 6. Build the same pair_drop event the federation path emits, with
6381    // our card + endpoints in the body so the sister can pin us back.
6382    let sk_seed = config::read_private_key()?;
6383    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6384    let pk_b64 = our_card
6385        .get("verify_keys")
6386        .and_then(Value::as_object)
6387        .and_then(|m| m.values().next())
6388        .and_then(|v| v.get("key"))
6389        .and_then(Value::as_str)
6390        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6391    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6392    let now = time::OffsetDateTime::now_utc()
6393        .format(&time::format_description::well_known::Rfc3339)
6394        .unwrap_or_default();
6395    let mut body = json!({
6396        "card": our_card,
6397        "relay_url": our_advertised.relay_url,
6398        "slot_id": our_advertised.slot_id,
6399        "slot_token": our_advertised.slot_token,
6400    });
6401    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6402    let event = json!({
6403        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6404        "timestamp": now,
6405        "from": our_did,
6406        "to": sister_did,
6407        "type": "pair_drop",
6408        "kind": 1100u32,
6409        "body": body,
6410    });
6411    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6412    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6413
6414    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
6415    // (the federation handle indexer) — we already know the slot coords
6416    // from disk, so post_event is sufficient.
6417    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6418    client
6419        .post_event(
6420            &delivery_endpoint.slot_id,
6421            &delivery_endpoint.slot_token,
6422            &signed,
6423        )
6424        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6425
6426    if as_json {
6427        println!(
6428            "{}",
6429            serde_json::to_string(&json!({
6430                "handle": sister_name,
6431                "paired_with": sister_did,
6432                "peer_handle": sister_handle,
6433                "event_id": event_id,
6434                "delivered_via": match delivery_endpoint.scope {
6435                    crate::endpoints::EndpointScope::Local => "local",
6436                    crate::endpoints::EndpointScope::Lan => "lan",
6437                    crate::endpoints::EndpointScope::Uds => "uds",
6438                    crate::endpoints::EndpointScope::Federation => "federation",
6439                },
6440                "status": "drop_sent",
6441            }))?
6442        );
6443    } else {
6444        let scope = match delivery_endpoint.scope {
6445            crate::endpoints::EndpointScope::Local => "local",
6446            crate::endpoints::EndpointScope::Lan => "lan",
6447            crate::endpoints::EndpointScope::Uds => "uds",
6448            crate::endpoints::EndpointScope::Federation => "federation",
6449        };
6450        println!(
6451            "→ 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.",
6452            delivery_endpoint.relay_url
6453        );
6454    }
6455    Ok(())
6456}
6457
6458fn cmd_add(
6459    handle_arg: &str,
6460    relay_override: Option<&str>,
6461    local_sister: bool,
6462    as_json: bool,
6463) -> Result<()> {
6464    // v0.7.4: nickname-friendly local-sister resolution. Whether the
6465    // operator passed `--local-sister` explicitly OR just typed a bare
6466    // name (no `@<relay>`), try to resolve through the local sessions
6467    // registry so character nicknames AND session names AND card
6468    // handles all work as input. Closes the "I only know this peer by
6469    // its character name" ergonomic gap that forced operators into
6470    // `wire session list-local | grep <nick> | awk` dances.
6471    if local_sister {
6472        let resolved = crate::session::resolve_local_sister(handle_arg)
6473            .unwrap_or_else(|| handle_arg.to_string());
6474        return cmd_add_local_sister(&resolved, as_json);
6475    }
6476    if !handle_arg.contains('@')
6477        && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6478    {
6479        eprintln!(
6480            "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6481             — routing via --local-sister (disk-read card, no relay lookup)."
6482        );
6483        return cmd_add_local_sister(&resolved, as_json);
6484    }
6485    if !handle_arg.contains('@') {
6486        bail!(
6487            "`{handle_arg}` doesn't match any local sister session and has no \
6488             @<relay> suffix for federation.\n\
6489             — Local sisters: `wire session list-local` (operator types name OR \
6490             character nickname)\n\
6491             — Federation:    `wire add <handle>@<relay-domain>` (e.g. \
6492             `wire add alice@wireup.net`)"
6493        );
6494    }
6495    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6496
6497    // 1. Auto-init self if needed + ensure a relay slot.
6498    let (our_did, our_relay, our_slot_id, our_slot_token) =
6499        crate::pair_invite::ensure_self_with_relay(relay_override)?;
6500    if our_did == format!("did:wire:{}", parsed.nick) {
6501        // Lazy guard — actual self-add would also be caught by FCFS later.
6502        bail!("refusing to add self (handle matches own DID)");
6503    }
6504
6505    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
6506    // already sitting in pending-inbound, the operator is now accepting it.
6507    // Pin trust, save relay coords + slot_token from the stored drop, ship
6508    // our own slot_token back via pair_drop_ack, delete the pending record.
6509    //
6510    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
6511    // receiver-side auto-promote was removed there; operator consent flows
6512    // through here. After this branch returns, both sides are bilaterally
6513    // pinned and capability flows in both directions.
6514    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6515        return cmd_add_accept_pending(
6516            handle_arg,
6517            &parsed.nick,
6518            &pending,
6519            &our_relay,
6520            &our_slot_id,
6521            &our_slot_token,
6522            as_json,
6523        );
6524    }
6525
6526    // v0.5.19 (#9.4): cross-relay phishing guardrail.
6527    //
6528    // Threat: operator wants to add `boss@wireup.net` but types
6529    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
6530    // The .well-known resolution returns whoever claimed the nick on the
6531    // *typo* relay, the bilateral gate still completes (the attacker
6532    // accepts the pair on their side), and the operator pins the
6533    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
6534    // there's no asymmetry to detect when the attacker WANTS to be
6535    // paired.
6536    //
6537    // Mitigation: warn loudly when the peer's relay domain is novel
6538    // (not the operator's own relay, not in a small known-good set).
6539    // Doesn't block — operators have legitimate reasons to pair across
6540    // relays. The signal lands in shell history so a phished operator
6541    // can find it in retrospect.
6542    if !is_known_relay_domain(&parsed.domain, &our_relay) {
6543        eprintln!(
6544            "wire add: WARN unfamiliar relay domain `{}`.",
6545            parsed.domain
6546        );
6547        eprintln!(
6548            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
6549            host_of_url(&our_relay)
6550        );
6551        eprintln!(
6552            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
6553            parsed.nick
6554        );
6555        eprintln!(
6556            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
6557            parsed.nick
6558        );
6559        eprintln!("  peer out-of-band that they actually run a relay at this domain");
6560        eprintln!("  before relying on the pair. (See issue #9.4.)");
6561    }
6562
6563    // 2. Resolve peer via .well-known on their relay.
6564    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6565    let peer_card = resolved
6566        .get("card")
6567        .cloned()
6568        .ok_or_else(|| anyhow!("resolved missing card"))?;
6569    let peer_did = resolved
6570        .get("did")
6571        .and_then(Value::as_str)
6572        .ok_or_else(|| anyhow!("resolved missing did"))?
6573        .to_string();
6574    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
6575    let peer_slot_id = resolved
6576        .get("slot_id")
6577        .and_then(Value::as_str)
6578        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
6579        .to_string();
6580    let peer_relay = resolved
6581        .get("relay_url")
6582        .and_then(Value::as_str)
6583        .map(str::to_string)
6584        .or_else(|| relay_override.map(str::to_string))
6585        .unwrap_or_else(|| format!("https://{}", parsed.domain));
6586
6587    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
6588    let mut trust = config::read_trust()?;
6589    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
6590    config::write_trust(&trust)?;
6591    let mut relay_state = config::read_relay_state()?;
6592    let existing_token = relay_state
6593        .get("peers")
6594        .and_then(|p| p.get(&peer_handle))
6595        .and_then(|p| p.get("slot_token"))
6596        .and_then(Value::as_str)
6597        .map(str::to_string)
6598        .unwrap_or_default();
6599    relay_state["peers"][&peer_handle] = json!({
6600        "relay_url": peer_relay,
6601        "slot_id": peer_slot_id,
6602        "slot_token": existing_token, // empty until pair_drop_ack lands
6603    });
6604    config::write_relay_state(&relay_state)?;
6605
6606    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
6607    // is the v0.5 zero-paste open-mode path).
6608    let our_card = config::read_agent_card()?;
6609    let sk_seed = config::read_private_key()?;
6610    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6611    let pk_b64 = our_card
6612        .get("verify_keys")
6613        .and_then(Value::as_object)
6614        .and_then(|m| m.values().next())
6615        .and_then(|v| v.get("key"))
6616        .and_then(Value::as_str)
6617        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6618    let pk_bytes = crate::signing::b64decode(pk_b64)?;
6619    let now = time::OffsetDateTime::now_utc()
6620        .format(&time::format_description::well_known::Rfc3339)
6621        .unwrap_or_default();
6622    // v0.5.17: advertise all our endpoints (federation + optional local)
6623    // to the peer in the pair_drop body. Back-compat: top-level
6624    // relay_url/slot_id/slot_token still point at the federation
6625    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
6626    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
6627    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6628    let mut body = json!({
6629        "card": our_card,
6630        "relay_url": our_relay,
6631        "slot_id": our_slot_id,
6632        "slot_token": our_slot_token,
6633    });
6634    if !our_endpoints.is_empty() {
6635        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6636    }
6637    let event = json!({
6638        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6639        "timestamp": now,
6640        "from": our_did,
6641        "to": peer_did,
6642        "type": "pair_drop",
6643        "kind": 1100u32,
6644        "body": body,
6645    });
6646    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6647
6648    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
6649    let client = crate::relay_client::RelayClient::new(&peer_relay);
6650    let resp = client.handle_intro(&parsed.nick, &signed)?;
6651    let event_id = signed
6652        .get("event_id")
6653        .and_then(Value::as_str)
6654        .unwrap_or("")
6655        .to_string();
6656
6657    if as_json {
6658        println!(
6659            "{}",
6660            serde_json::to_string(&json!({
6661                "handle": handle_arg,
6662                "paired_with": peer_did,
6663                "peer_handle": peer_handle,
6664                "event_id": event_id,
6665                "drop_response": resp,
6666                "status": "drop_sent",
6667            }))?
6668        );
6669    } else {
6670        println!(
6671            "→ 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."
6672        );
6673    }
6674    Ok(())
6675}
6676
6677/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
6678/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
6679/// coords + slot_token from the stored drop, ship our slot_token back via
6680/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
6681/// invite-URL path (which is already bilateral by virtue of the pre-shared
6682/// nonce).
6683fn cmd_add_accept_pending(
6684    handle_arg: &str,
6685    peer_nick: &str,
6686    pending: &crate::pending_inbound_pair::PendingInboundPair,
6687    _our_relay: &str,
6688    _our_slot_id: &str,
6689    _our_slot_token: &str,
6690    as_json: bool,
6691) -> Result<()> {
6692    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
6693    //    `wire add` against this handle while a drop was waiting.
6694    let mut trust = config::read_trust()?;
6695    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
6696    config::write_trust(&trust)?;
6697
6698    // 2. Record peer's relay coords + slot_token (already shipped to us in
6699    //    the original drop body; held back until now).
6700    // v0.5.17: pin all advertised endpoints (federation + optional local).
6701    // Falls back to a single federation entry when the record was written
6702    // by v0.5.16-era code that didn't carry endpoints[].
6703    let mut relay_state = config::read_relay_state()?;
6704    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
6705        vec![crate::endpoints::Endpoint::federation(
6706            pending.peer_relay_url.clone(),
6707            pending.peer_slot_id.clone(),
6708            pending.peer_slot_token.clone(),
6709        )]
6710    } else {
6711        pending.peer_endpoints.clone()
6712    };
6713    crate::endpoints::pin_peer_endpoints(
6714        &mut relay_state,
6715        &pending.peer_handle,
6716        &endpoints_to_pin,
6717    )?;
6718    config::write_relay_state(&relay_state)?;
6719
6720    // 3. Ship our slot_token to peer via pair_drop_ack so they can write back.
6721    crate::pair_invite::send_pair_drop_ack(
6722        &pending.peer_handle,
6723        &pending.peer_relay_url,
6724        &pending.peer_slot_id,
6725        &pending.peer_slot_token,
6726    )
6727    .with_context(|| {
6728        format!(
6729            "pair_drop_ack send to {} @ {} slot {} failed",
6730            pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
6731        )
6732    })?;
6733
6734    // 4. Delete the pending-inbound record now that bilateral is complete.
6735    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
6736
6737    if as_json {
6738        println!(
6739            "{}",
6740            serde_json::to_string(&json!({
6741                "handle": handle_arg,
6742                "paired_with": pending.peer_did,
6743                "peer_handle": pending.peer_handle,
6744                "status": "bilateral_accepted",
6745                "via": "pending_inbound",
6746            }))?
6747        );
6748    } else {
6749        println!(
6750            "→ 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} \"...\"`.",
6751            peer = pending.peer_handle,
6752        );
6753    }
6754    Ok(())
6755}
6756
6757/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
6758/// for a pending-inbound pair request. Pin trust, write relay_state from the
6759/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
6760/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
6761/// when a pending-inbound record exists, but without needing to remember
6762/// the peer's relay domain.
6763fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
6764    let nick = crate::agent_card::bare_handle(peer_nick);
6765    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
6766        anyhow!(
6767            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
6768             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
6769        )
6770    })?;
6771    let (_our_did, our_relay, our_slot_id, our_slot_token) =
6772        crate::pair_invite::ensure_self_with_relay(None)?;
6773    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
6774    cmd_add_accept_pending(
6775        &handle_arg,
6776        nick,
6777        &pending,
6778        &our_relay,
6779        &our_slot_id,
6780        &our_slot_token,
6781        as_json,
6782    )
6783}
6784
6785/// v0.5.14: programmatic access to pending-inbound for scripts.
6786/// `wire pair-list-inbound --json` returns a flat array of records.
6787fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
6788    let items = crate::pending_inbound_pair::list_pending_inbound()?;
6789    if as_json {
6790        println!("{}", serde_json::to_string(&items)?);
6791        return Ok(());
6792    }
6793    if items.is_empty() {
6794        println!("no pending pair requests — your inbox is clear.");
6795        return Ok(());
6796    }
6797    // v0.9.3: conversational output. Tabular data is for --json. Humans
6798    // get one short sentence per pending peer, each rendered with the
6799    // peer's character (DID-derived emoji + nickname) so they can match
6800    // the speaker against their statusline / mesh-status view at a
6801    // glance. The "next step" sentence at the bottom names the exact
6802    // verbs to run.
6803    let plural = if items.len() == 1 { "" } else { "s" };
6804    println!("{} pending pair request{plural}:\n", items.len());
6805    for p in &items {
6806        let ch = crate::character::Character::from_did(&p.peer_did);
6807        let glyph = crate::character::emoji_with_fallback(&ch);
6808        // ASCII-friendly arrow if the operator's terminal can't render
6809        // emoji (the same routine drives the fallback).
6810        println!(
6811            "  {glyph} {nick}  ({handle})  wants to pair with you",
6812            nick = ch.nickname,
6813            handle = p.peer_handle,
6814        );
6815    }
6816    println!();
6817    println!(
6818        "→ to accept any: `wire accept <name>`  (e.g. `wire accept {first}`)",
6819        first = items
6820            .first()
6821            .map(|p| {
6822                let ch = crate::character::Character::from_did(&p.peer_did);
6823                ch.nickname
6824            })
6825            .unwrap_or_else(|| "<name>".to_string())
6826    );
6827    println!("→ to refuse:    `wire reject <name>`");
6828    Ok(())
6829}
6830
6831/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
6832/// without pairing. No event is sent back to the peer; their side stays
6833/// pending until they time out or the operator-side data ages out.
6834fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
6835    let nick = crate::agent_card::bare_handle(peer_nick);
6836    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
6837    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
6838
6839    if as_json {
6840        println!(
6841            "{}",
6842            serde_json::to_string(&json!({
6843                "peer": nick,
6844                "rejected": existed.is_some(),
6845                "had_pending": existed.is_some(),
6846            }))?
6847        );
6848    } else if existed.is_some() {
6849        println!(
6850            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6851        );
6852    } else {
6853        println!("no pending pair from {nick} — nothing to reject");
6854    }
6855    Ok(())
6856}
6857
6858// ---------- session (v0.5.16) ----------
6859//
6860// Multi-session wire on one machine. See src/session.rs for the storage
6861// layout + naming rules. The CLI dispatcher here orchestrates child
6862// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
6863// each session-local `init` / `claim` / `daemon` runs in its own world
6864// without cross-contamination via env vars in this process.
6865
6866/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
6867/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
6868fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6869    match cmd {
6870        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6871        MeshCommand::Broadcast {
6872            kind,
6873            scope,
6874            exclude,
6875            noreply,
6876            body,
6877            json,
6878        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6879        MeshCommand::Role { action } => cmd_mesh_role(action),
6880        MeshCommand::Route {
6881            role,
6882            strategy,
6883            exclude,
6884            kind,
6885            body,
6886            json,
6887        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
6888    }
6889}
6890
6891/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
6892/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
6893/// picks ONE via the requested strategy, then signs + pushes the event
6894/// to that peer. Pinned-peers-only by construction (same as broadcast).
6895fn cmd_mesh_route(
6896    role: &str,
6897    strategy: &str,
6898    exclude: &[String],
6899    kind: &str,
6900    body_arg: &str,
6901    as_json: bool,
6902) -> Result<()> {
6903    use std::time::Instant;
6904
6905    if !config::is_initialized()? {
6906        bail!("not initialized — run `wire init <handle>` first");
6907    }
6908    let strategy = strategy.to_ascii_lowercase();
6909    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
6910        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
6911    }
6912
6913    // Our pinned-peer set: only these handles are addressable. mesh-route
6914    // refuses to invent a recipient, same posture as broadcast.
6915    let state = config::read_relay_state()?;
6916    let pinned: std::collections::BTreeSet<String> = state["peers"]
6917        .as_object()
6918        .map(|m| m.keys().cloned().collect())
6919        .unwrap_or_default();
6920
6921    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6922
6923    // Enumerate every sister on the box, read each one's role from its
6924    // signed agent-card. Filter: matching role AND pinned AND not
6925    // excluded. `list_sessions` returns the cross-session view (using the
6926    // v0.6.4 inside-session sessions_root fallback).
6927    let sessions = crate::session::list_sessions()?;
6928    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
6929    for s in &sessions {
6930        let handle = match s.handle.as_ref() {
6931            Some(h) => h.clone(),
6932            None => continue,
6933        };
6934        if exclude_set.contains(handle.as_str()) {
6935            continue;
6936        }
6937        if !pinned.contains(&handle) {
6938            continue;
6939        }
6940        let card_path = s
6941            .home_dir
6942            .join("config")
6943            .join("wire")
6944            .join("agent-card.json");
6945        let card_role = std::fs::read(&card_path)
6946            .ok()
6947            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6948            .and_then(|c| {
6949                c.get("profile")
6950                    .and_then(|p| p.get("role"))
6951                    .and_then(Value::as_str)
6952                    .map(str::to_string)
6953            });
6954        if card_role.as_deref() == Some(role) {
6955            candidates.push((handle, s.did.clone()));
6956        }
6957    }
6958
6959    candidates.sort_by(|a, b| a.0.cmp(&b.0));
6960    candidates.dedup_by(|a, b| a.0 == b.0);
6961
6962    if candidates.is_empty() {
6963        bail!(
6964            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
6965        );
6966    }
6967
6968    let chosen = match strategy.as_str() {
6969        "first" => candidates[0].clone(),
6970        "random" => {
6971            use rand::Rng;
6972            let idx = rand::thread_rng().gen_range(0..candidates.len());
6973            candidates[idx].clone()
6974        }
6975        "round-robin" => {
6976            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
6977            // `{role: last_picked_handle}`. Next pick = first candidate
6978            // alphabetically AFTER last_picked, wrapping around when no
6979            // candidate is greater.
6980            let cursor_path = mesh_route_cursor_path()?;
6981            let mut cursors: std::collections::BTreeMap<String, String> =
6982                read_mesh_route_cursors(&cursor_path);
6983            let last = cursors.get(role).cloned();
6984            let pick = match last {
6985                None => candidates[0].clone(),
6986                Some(last_h) => candidates
6987                    .iter()
6988                    .find(|(h, _)| h.as_str() > last_h.as_str())
6989                    .cloned()
6990                    .unwrap_or_else(|| candidates[0].clone()),
6991            };
6992            cursors.insert(role.to_string(), pick.0.clone());
6993            write_mesh_route_cursors(&cursor_path, &cursors)?;
6994            pick
6995        }
6996        _ => unreachable!(),
6997    };
6998
6999    let (chosen_handle, _chosen_did) = chosen;
7000
7001    // Body parsing follows wire send / mesh broadcast.
7002    let body_value: Value = if body_arg == "-" {
7003        use std::io::Read;
7004        let mut raw = String::new();
7005        std::io::stdin()
7006            .read_to_string(&mut raw)
7007            .with_context(|| "reading body from stdin")?;
7008        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7009    } else if let Some(path) = body_arg.strip_prefix('@') {
7010        let raw =
7011            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7012        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7013    } else {
7014        Value::String(body_arg.to_string())
7015    };
7016
7017    let sk_seed = config::read_private_key()?;
7018    let card = config::read_agent_card()?;
7019    let did = card
7020        .get("did")
7021        .and_then(Value::as_str)
7022        .ok_or_else(|| anyhow!("agent-card missing did"))?
7023        .to_string();
7024    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7025    let pk_b64 = card
7026        .get("verify_keys")
7027        .and_then(Value::as_object)
7028        .and_then(|m| m.values().next())
7029        .and_then(|v| v.get("key"))
7030        .and_then(Value::as_str)
7031        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7032    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7033
7034    let kind_id = parse_kind(kind)?;
7035    let now_iso = time::OffsetDateTime::now_utc()
7036        .format(&time::format_description::well_known::Rfc3339)
7037        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7038
7039    let event = json!({
7040        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7041        "timestamp": now_iso,
7042        "from": did,
7043        "to": format!("did:wire:{chosen_handle}"),
7044        "type": kind,
7045        "kind": kind_id,
7046        "body": json!({
7047            "content": body_value,
7048            "routed_via": {
7049                "role": role,
7050                "strategy": strategy,
7051            },
7052        }),
7053    });
7054    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7055        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
7056    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7057
7058    let line = serde_json::to_vec(&signed)?;
7059    config::append_outbox_record(&chosen_handle, &line)?;
7060
7061    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
7062    if endpoints.is_empty() {
7063        bail!(
7064            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
7065        );
7066    }
7067    let start = Instant::now();
7068    let mut delivered = false;
7069    let mut last_err: Option<String> = None;
7070    let mut via_scope: Option<String> = None;
7071    for ep in &endpoints {
7072        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
7073        // route via uds_request, others via reqwest. Allows peers with
7074        // UDS-tagged endpoints in their agent-card to receive events
7075        // over the local socket instead of loopback HTTP.
7076        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7077            Ok(_) => {
7078                delivered = true;
7079                via_scope = Some(
7080                    match ep.scope {
7081                        crate::endpoints::EndpointScope::Local => "local",
7082                        crate::endpoints::EndpointScope::Lan => "lan",
7083                        crate::endpoints::EndpointScope::Uds => "uds",
7084                        crate::endpoints::EndpointScope::Federation => "federation",
7085                    }
7086                    .to_string(),
7087                );
7088                break;
7089            }
7090            Err(e) => last_err = Some(format!("{e:#}")),
7091        }
7092    }
7093    let rtt_ms = start.elapsed().as_millis() as u64;
7094
7095    let summary = json!({
7096        "role": role,
7097        "strategy": strategy,
7098        "routed_to": chosen_handle,
7099        "event_id": event_id,
7100        "delivered": delivered,
7101        "delivered_via": via_scope,
7102        "rtt_ms": rtt_ms,
7103        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
7104        "error": last_err,
7105    });
7106
7107    if as_json {
7108        println!("{}", serde_json::to_string(&summary)?);
7109    } else if delivered {
7110        let via = via_scope.as_deref().unwrap_or("?");
7111        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
7112    } else {
7113        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
7114        bail!("delivery to `{chosen_handle}` failed: {err}");
7115    }
7116    Ok(())
7117}
7118
7119fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
7120    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
7121}
7122
7123fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
7124    std::fs::read(path)
7125        .ok()
7126        .and_then(|b| serde_json::from_slice(&b).ok())
7127        .unwrap_or_default()
7128}
7129
7130fn write_mesh_route_cursors(
7131    path: &std::path::Path,
7132    cursors: &std::collections::BTreeMap<String, String>,
7133) -> Result<()> {
7134    if let Some(parent) = path.parent() {
7135        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
7136    }
7137    let body = serde_json::to_vec_pretty(cursors)?;
7138    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
7139    Ok(())
7140}
7141
7142/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
7143/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
7144/// behind a discoverability-friendlier surface, plus cross-session
7145/// enumeration for the list path.
7146fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
7147    match action {
7148        MeshRoleAction::Set { role, json } => {
7149            validate_role_tag(&role)?;
7150            let new_profile =
7151                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
7152            if json {
7153                println!(
7154                    "{}",
7155                    serde_json::to_string(&json!({
7156                        "role": role,
7157                        "profile": new_profile,
7158                    }))?
7159                );
7160            } else {
7161                println!("self role = {role} (signed into agent-card)");
7162            }
7163        }
7164        MeshRoleAction::Get { peer, json } => {
7165            let (who, role) = match peer.as_deref() {
7166                None => {
7167                    let card = config::read_agent_card()?;
7168                    let role = card
7169                        .get("profile")
7170                        .and_then(|p| p.get("role"))
7171                        .and_then(Value::as_str)
7172                        .map(str::to_string);
7173                    let who = card
7174                        .get("did")
7175                        .and_then(Value::as_str)
7176                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
7177                        .unwrap_or_else(|| "self".to_string());
7178                    (who, role)
7179                }
7180                Some(handle) => {
7181                    let bare = crate::agent_card::bare_handle(handle).to_string();
7182                    let trust = config::read_trust()?;
7183                    let role = trust
7184                        .get("agents")
7185                        .and_then(|a| a.get(&bare))
7186                        .and_then(|a| a.get("card"))
7187                        .and_then(|c| c.get("profile"))
7188                        .and_then(|p| p.get("role"))
7189                        .and_then(Value::as_str)
7190                        .map(str::to_string);
7191                    (bare, role)
7192                }
7193            };
7194            if json {
7195                println!(
7196                    "{}",
7197                    serde_json::to_string(&json!({
7198                        "handle": who,
7199                        "role": role,
7200                    }))?
7201                );
7202            } else {
7203                match role {
7204                    Some(r) => println!("{who}: {r}"),
7205                    None => println!("{who}: (unset)"),
7206                }
7207            }
7208        }
7209        MeshRoleAction::List { json } => {
7210            let mut self_did: Option<String> = None;
7211            if let Ok(card) = config::read_agent_card() {
7212                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
7213            }
7214            let sessions = crate::session::list_sessions()?;
7215            let mut rows: Vec<Value> = Vec::new();
7216            for s in &sessions {
7217                let card_path = s
7218                    .home_dir
7219                    .join("config")
7220                    .join("wire")
7221                    .join("agent-card.json");
7222                let role = std::fs::read(&card_path)
7223                    .ok()
7224                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7225                    .and_then(|c| {
7226                        c.get("profile")
7227                            .and_then(|p| p.get("role"))
7228                            .and_then(Value::as_str)
7229                            .map(str::to_string)
7230                    });
7231                let is_self = match (&self_did, &s.did) {
7232                    (Some(a), Some(b)) => a == b,
7233                    _ => false,
7234                };
7235                rows.push(json!({
7236                    "name": s.name,
7237                    "handle": s.handle,
7238                    "role": role,
7239                    "self": is_self,
7240                }));
7241            }
7242            rows.sort_by(|a, b| {
7243                a["name"]
7244                    .as_str()
7245                    .unwrap_or("")
7246                    .cmp(b["name"].as_str().unwrap_or(""))
7247            });
7248            if json {
7249                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
7250            } else if rows.is_empty() {
7251                println!("no sister sessions on this machine.");
7252            } else {
7253                println!("SISTER ROLES (this machine):");
7254                for r in &rows {
7255                    let name = r["name"].as_str().unwrap_or("?");
7256                    let role = r["role"].as_str().unwrap_or("(unset)");
7257                    let marker = if r["self"].as_bool().unwrap_or(false) {
7258                        "    ← you"
7259                    } else {
7260                        ""
7261                    };
7262                    println!("  {name:<24} {role}{marker}");
7263                }
7264            }
7265        }
7266        MeshRoleAction::Clear { json } => {
7267            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
7268            if json {
7269                println!(
7270                    "{}",
7271                    serde_json::to_string(&json!({
7272                        "cleared": true,
7273                        "profile": new_profile,
7274                    }))?
7275                );
7276            } else {
7277                println!("self role cleared");
7278            }
7279        }
7280    }
7281    Ok(())
7282}
7283
7284/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
7285/// No vocabulary check — operators choose the taxonomy (planner /
7286/// reviewer / dispatcher / your-custom-tag). The constraint is purely
7287/// to keep the tag safe for filenames / URLs / shell args.
7288fn validate_role_tag(role: &str) -> Result<()> {
7289    if role.is_empty() {
7290        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
7291    }
7292    if role.len() > 32 {
7293        bail!("role too long ({} chars; max 32)", role.len());
7294    }
7295    for c in role.chars() {
7296        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
7297            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
7298        }
7299    }
7300    Ok(())
7301}
7302
7303/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
7304///
7305/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
7306/// canonical event including `to:`, so per-recipient signing is required;
7307/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
7308/// Per-recipient pushes happen in parallel via `std::thread::scope` so
7309/// broadcast-to-5 takes ~1× RTT, not 5×.
7310///
7311/// **Scope filter.** Default `local` — only peers reachable via a same-
7312/// machine local relay (priority-1 endpoint has `scope=local`). This is
7313/// the lowest-blast-radius default: local-only broadcasts cannot escape
7314/// the operator's machine. `federation` flips to public-relay peers
7315/// only; `both` removes the filter.
7316///
7317/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
7318/// resolution, never trust["agents"] expansion. Closes #8-class
7319/// phonebook-scrape vectors by construction: an attacker pinning a
7320/// hostile handle has to first be pinned bidirectionally by the
7321/// operator, and even then `--exclude` is the loud opt-out.
7322fn cmd_mesh_broadcast(
7323    kind: &str,
7324    scope_str: &str,
7325    exclude: &[String],
7326    _noreply: bool,
7327    body_arg: &str,
7328    as_json: bool,
7329) -> Result<()> {
7330    use std::time::Instant;
7331
7332    if !config::is_initialized()? {
7333        bail!("not initialized — run `wire init <handle>` first");
7334    }
7335
7336    let scope = match scope_str {
7337        "local" => crate::endpoints::EndpointScope::Local,
7338        "federation" => crate::endpoints::EndpointScope::Federation,
7339        "both" => {
7340            // Sentinel: we don't actually have a `Both` variant on the
7341            // scope enum; use a tri-state below. Treat as Local for the
7342            // typed match and special-case it via the bool below.
7343            crate::endpoints::EndpointScope::Local
7344        }
7345        other => bail!("unknown scope `{other}` — use local | federation | both"),
7346    };
7347    let any_scope = scope_str == "both";
7348
7349    let state = config::read_relay_state()?;
7350    let peers = state["peers"].as_object().cloned().unwrap_or_default();
7351    if peers.is_empty() {
7352        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
7353    }
7354
7355    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7356
7357    // Walk the pinned-peer set, filter by scope + exclude. Keep the
7358    // priority-ordered endpoint list for each match so the push can
7359    // try local first then fall through to federation (when scope=both).
7360    struct Target {
7361        handle: String,
7362        endpoints: Vec<crate::endpoints::Endpoint>,
7363    }
7364    let mut targets: Vec<Target> = Vec::new();
7365    let mut skipped_wrong_scope: Vec<String> = Vec::new();
7366    let mut skipped_excluded: Vec<String> = Vec::new();
7367    for handle in peers.keys() {
7368        if exclude_set.contains(handle.as_str()) {
7369            skipped_excluded.push(handle.clone());
7370            continue;
7371        }
7372        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
7373        let filtered: Vec<crate::endpoints::Endpoint> = ordered
7374            .into_iter()
7375            .filter(|ep| any_scope || ep.scope == scope)
7376            .collect();
7377        if filtered.is_empty() {
7378            skipped_wrong_scope.push(handle.clone());
7379            continue;
7380        }
7381        targets.push(Target {
7382            handle: handle.clone(),
7383            endpoints: filtered,
7384        });
7385    }
7386
7387    if targets.is_empty() {
7388        bail!(
7389            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
7390            skipped_excluded.len(),
7391            skipped_wrong_scope.len()
7392        );
7393    }
7394
7395    // Load signing material once; share across per-peer signatures.
7396    let sk_seed = config::read_private_key()?;
7397    let card = config::read_agent_card()?;
7398    let did = card
7399        .get("did")
7400        .and_then(Value::as_str)
7401        .ok_or_else(|| anyhow!("agent-card missing did"))?
7402        .to_string();
7403    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7404    let pk_b64 = card
7405        .get("verify_keys")
7406        .and_then(Value::as_object)
7407        .and_then(|m| m.values().next())
7408        .and_then(|v| v.get("key"))
7409        .and_then(Value::as_str)
7410        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7411    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7412
7413    let body_value: Value = if body_arg == "-" {
7414        use std::io::Read;
7415        let mut raw = String::new();
7416        std::io::stdin()
7417            .read_to_string(&mut raw)
7418            .with_context(|| "reading body from stdin")?;
7419        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7420    } else if let Some(path) = body_arg.strip_prefix('@') {
7421        let raw =
7422            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7423        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7424    } else {
7425        Value::String(body_arg.to_string())
7426    };
7427
7428    let kind_id = parse_kind(kind)?;
7429    let now_iso = time::OffsetDateTime::now_utc()
7430        .format(&time::format_description::well_known::Rfc3339)
7431        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7432
7433    let broadcast_id = generate_broadcast_id();
7434    let target_count = targets.len();
7435
7436    // Build + sign every event up front (sequential, ~50µs/sig). Then
7437    // queue to outbox + push to relay in parallel per-peer. Returns
7438    // a per-peer outcome we then sort by handle for deterministic output.
7439    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
7440        Vec::with_capacity(targets.len());
7441    for t in &targets {
7442        let body = json!({
7443            "content": body_value,
7444            "broadcast_id": broadcast_id,
7445            "broadcast_target_count": target_count,
7446        });
7447        let event = json!({
7448            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7449            "timestamp": now_iso,
7450            "from": did,
7451            "to": format!("did:wire:{}", t.handle),
7452            "type": kind,
7453            "kind": kind_id,
7454            "body": body,
7455        });
7456        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7457            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
7458        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7459        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
7460    }
7461
7462    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
7463    // holds a per-path mutex; writes are independent across handles but
7464    // we want the side-effect ordering deterministic).
7465    for (peer, _, signed, _) in &signed_per_peer {
7466        let line = serde_json::to_vec(signed)?;
7467        config::append_outbox_record(peer, &line)?;
7468    }
7469
7470    // Per-peer parallel push. Each thread tries the priority-ordered
7471    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
7472    // error_opt) over a channel.
7473    use std::sync::mpsc;
7474    let (tx, rx) = mpsc::channel::<Value>();
7475    std::thread::scope(|s| {
7476        for (peer, endpoints, signed, event_id) in &signed_per_peer {
7477            let tx = tx.clone();
7478            let peer = peer.clone();
7479            let event_id = event_id.clone();
7480            let endpoints = endpoints.clone();
7481            let signed = signed.clone();
7482            s.spawn(move || {
7483                let start = Instant::now();
7484                let mut delivered = false;
7485                let mut last_err: Option<String> = None;
7486                let mut delivered_via: Option<String> = None;
7487                for ep in &endpoints {
7488                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
7489                    // uds_request, else reqwest). Same as cmd_send's
7490                    // single-peer path above; this is the parallel
7491                    // multi-peer broadcast loop.
7492                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7493                        Ok(_) => {
7494                            delivered = true;
7495                            delivered_via = Some(
7496                                match ep.scope {
7497                                    crate::endpoints::EndpointScope::Local => "local",
7498                                    crate::endpoints::EndpointScope::Lan => "lan",
7499                                    crate::endpoints::EndpointScope::Uds => "uds",
7500                                    crate::endpoints::EndpointScope::Federation => "federation",
7501                                }
7502                                .to_string(),
7503                            );
7504                            break;
7505                        }
7506                        Err(e) => last_err = Some(format!("{e:#}")),
7507                    }
7508                }
7509                let rtt_ms = start.elapsed().as_millis() as u64;
7510                let _ = tx.send(json!({
7511                    "peer": peer,
7512                    "event_id": event_id,
7513                    "delivered": delivered,
7514                    "delivered_via": delivered_via,
7515                    "rtt_ms": rtt_ms,
7516                    "error": last_err,
7517                }));
7518            });
7519        }
7520    });
7521    drop(tx);
7522
7523    let mut results: Vec<Value> = rx.iter().collect();
7524    results.sort_by(|a, b| {
7525        a["peer"]
7526            .as_str()
7527            .unwrap_or("")
7528            .cmp(b["peer"].as_str().unwrap_or(""))
7529    });
7530
7531    let delivered = results
7532        .iter()
7533        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
7534        .count();
7535    let failed = results.len() - delivered;
7536
7537    let summary = json!({
7538        "broadcast_id": broadcast_id,
7539        "kind": kind,
7540        "scope": scope_str,
7541        "target_count": target_count,
7542        "delivered": delivered,
7543        "failed": failed,
7544        "skipped_excluded": skipped_excluded,
7545        "skipped_wrong_scope": skipped_wrong_scope,
7546        "results": results,
7547    });
7548
7549    if as_json {
7550        println!("{}", serde_json::to_string(&summary)?);
7551        return Ok(());
7552    }
7553
7554    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
7555    for r in &results {
7556        let peer = r["peer"].as_str().unwrap_or("?");
7557        let delivered = r["delivered"].as_bool().unwrap_or(false);
7558        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
7559        let via = r["delivered_via"].as_str().unwrap_or("");
7560        if delivered {
7561            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
7562        } else {
7563            let err = r["error"].as_str().unwrap_or("?");
7564            println!("  {peer:<24} ✗ failed — {err}");
7565        }
7566    }
7567    if !skipped_excluded.is_empty() {
7568        println!("  excluded: {}", skipped_excluded.join(", "));
7569    }
7570    if !skipped_wrong_scope.is_empty() {
7571        println!(
7572            "  skipped (wrong scope): {}",
7573            skipped_wrong_scope.join(", ")
7574        );
7575    }
7576    println!("broadcast_id: {broadcast_id}");
7577    Ok(())
7578}
7579
7580/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
7581/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
7582/// correlate by string equality, the shape is for human readability.
7583fn generate_broadcast_id() -> String {
7584    use rand::RngCore;
7585    let mut buf = [0u8; 16];
7586    rand::thread_rng().fill_bytes(&mut buf);
7587    let h = hex::encode(buf);
7588    format!(
7589        "{}-{}-{}-{}-{}",
7590        &h[0..8],
7591        &h[8..12],
7592        &h[12..16],
7593        &h[16..20],
7594        &h[20..32],
7595    )
7596}
7597
7598fn cmd_session(cmd: SessionCommand) -> Result<()> {
7599    match cmd {
7600        SessionCommand::New {
7601            name,
7602            relay,
7603            with_local,
7604            local_relay,
7605            with_lan,
7606            lan_relay,
7607            with_uds,
7608            uds_socket,
7609            no_daemon,
7610            local_only,
7611            json,
7612        } => cmd_session_new(
7613            name.as_deref(),
7614            &relay,
7615            with_local,
7616            &local_relay,
7617            with_lan,
7618            lan_relay.as_deref(),
7619            with_uds,
7620            uds_socket.as_deref(),
7621            no_daemon,
7622            local_only,
7623            json,
7624        ),
7625        SessionCommand::List { json } => cmd_session_list(json),
7626        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
7627        SessionCommand::PairAllLocal {
7628            settle_secs,
7629            federation_relay,
7630            json,
7631        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
7632        SessionCommand::MeshStatus { stale_secs, json } => {
7633            cmd_session_mesh_status(stale_secs, json)
7634        }
7635        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
7636        SessionCommand::Current { json } => cmd_session_current(json),
7637        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
7638        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
7639    }
7640}
7641
7642fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
7643    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7644    let cwd_str = cwd.to_string_lossy().into_owned();
7645
7646    let resolved_name = match name_arg {
7647        Some(n) => crate::session::sanitize_name(n),
7648        None => crate::session::sanitize_name(
7649            cwd.file_name()
7650                .and_then(|s| s.to_str())
7651                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
7652        ),
7653    };
7654
7655    let session_home = crate::session::session_dir(&resolved_name)?;
7656    if !session_home.exists() {
7657        bail!(
7658            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
7659            session_home.display()
7660        );
7661    }
7662
7663    let prior = crate::session::read_registry()
7664        .ok()
7665        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
7666    if prior.as_deref() == Some(resolved_name.as_str()) {
7667        if json {
7668            println!(
7669                "{}",
7670                serde_json::to_string(&json!({
7671                    "cwd": cwd_str,
7672                    "session": resolved_name,
7673                    "changed": false,
7674                }))?
7675            );
7676        } else {
7677            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
7678        }
7679        return Ok(());
7680    }
7681    if let Some(prior_name) = &prior {
7682        eprintln!(
7683            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
7684        );
7685    }
7686
7687    crate::session::update_registry(|reg| {
7688        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
7689        Ok(())
7690    })?;
7691
7692    if json {
7693        println!(
7694            "{}",
7695            serde_json::to_string(&json!({
7696                "cwd": cwd_str,
7697                "session": resolved_name,
7698                "changed": true,
7699                "previous": prior,
7700            }))?
7701        );
7702    } else {
7703        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
7704        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
7705    }
7706    Ok(())
7707}
7708
7709fn resolve_session_name(name: Option<&str>) -> Result<String> {
7710    if let Some(n) = name {
7711        return Ok(crate::session::sanitize_name(n));
7712    }
7713    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7714    let registry = crate::session::read_registry().unwrap_or_default();
7715    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
7716}
7717
7718#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
7719// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
7720// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
7721fn cmd_session_new(
7722    name_arg: Option<&str>,
7723    relay: &str,
7724    with_local: bool,
7725    local_relay: &str,
7726    with_lan: bool,
7727    lan_relay: Option<&str>,
7728    with_uds: bool,
7729    uds_socket: Option<&std::path::Path>,
7730    no_daemon: bool,
7731    local_only: bool,
7732    as_json: bool,
7733) -> Result<()> {
7734    // v0.6.6: --local-only implies --with-local (a federation-free
7735    // session with no endpoints at all would be unaddressable).
7736    let with_local = with_local || local_only;
7737    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
7738    if with_lan && lan_relay.is_none() {
7739        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
7740    }
7741    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
7742    if with_uds && uds_socket.is_none() {
7743        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
7744    }
7745    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7746    let mut registry = crate::session::read_registry().unwrap_or_default();
7747    let name = match name_arg {
7748        Some(n) => crate::session::sanitize_name(n),
7749        None => crate::session::derive_name_from_cwd(&cwd, &registry),
7750    };
7751    let session_home = crate::session::session_dir(&name)?;
7752
7753    let already_exists = session_home.exists()
7754        && session_home
7755            .join("config")
7756            .join("wire")
7757            .join("agent-card.json")
7758            .exists();
7759    if already_exists {
7760        // Idempotent: re-register the cwd (if not already), refresh the
7761        // daemon if requested, surface the env-var line. Do not re-init
7762        // identity — that would clobber the keypair.
7763        registry
7764            .by_cwd
7765            .insert(cwd.to_string_lossy().into_owned(), name.clone());
7766        crate::session::write_registry(&registry)?;
7767        let info = render_session_info(&name, &session_home, &cwd)?;
7768        emit_session_new_result(&info, "already_exists", as_json)?;
7769        if !no_daemon {
7770            ensure_session_daemon(&session_home)?;
7771        }
7772        return Ok(());
7773    }
7774
7775    std::fs::create_dir_all(&session_home)
7776        .with_context(|| format!("creating session dir {session_home:?}"))?;
7777
7778    // Phase 1: init identity in the new session's WIRE_HOME. For
7779    // federation-bound sessions we pass `--relay` so init also
7780    // allocates a federation slot in the same step; for `--local-only`
7781    // we run init with `--offline` (v0.9 requires explicit reachability
7782    // acknowledgement at init time) because cmd_session_new allocates
7783    // the local-relay slot itself via try_allocate_local_slot below.
7784    // The session is not actually slotless — init is just deferred to
7785    // the subsequent allocation pass.
7786    let init_args: Vec<&str> = if local_only {
7787        vec!["init", &name, "--offline"]
7788    } else {
7789        vec!["init", &name, "--relay", relay]
7790    };
7791    let init_status = run_wire_with_home(&session_home, &init_args)?;
7792    if !init_status.success() {
7793        let how = if local_only {
7794            format!("`wire init {name}` (local-only)")
7795        } else {
7796            format!("`wire init {name} --relay {relay}`")
7797        };
7798        bail!("{how} failed inside session dir {session_home:?}");
7799    }
7800
7801    // Phase 2: claim the handle on the federation relay — SKIPPED when
7802    // `--local-only`. Local-only sessions have no public address and
7803    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
7804    // tries to publish them.
7805    let effective_handle = if local_only {
7806        name.clone()
7807    } else {
7808        let mut claim_attempt = 0u32;
7809        let mut effective = name.clone();
7810        loop {
7811            claim_attempt += 1;
7812            let status =
7813                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
7814            if status.success() {
7815                break;
7816            }
7817            if claim_attempt >= 5 {
7818                bail!(
7819                    "5 failed attempts to claim a handle on {relay} for session {name}. \
7820                     Try `wire session destroy {name} --force` and re-run with a different name, \
7821                     or use `--local-only` if you don't need a federation address."
7822                );
7823            }
7824            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
7825            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
7826            let token = suffix
7827                .rsplit('-')
7828                .next()
7829                .filter(|t| t.len() == 4)
7830                .map(str::to_string)
7831                .unwrap_or_else(|| format!("{claim_attempt}"));
7832            effective = format!("{name}-{token}");
7833        }
7834        effective
7835    };
7836
7837    // Persist the cwd → name mapping NOW so subsequent invocations from
7838    // this directory short-circuit to the "already_exists" branch.
7839    registry
7840        .by_cwd
7841        .insert(cwd.to_string_lossy().into_owned(), name.clone());
7842    crate::session::write_registry(&registry)?;
7843
7844    // v0.5.17: --with-local probes the local relay and, if it's
7845    // reachable, allocates a second slot there. The session's
7846    // relay_state.json grows a `self.endpoints[]` array carrying both
7847    // endpoints; routing layer (cmd_push) prefers local for sister-
7848    // session peers that also have a local slot.
7849    //
7850    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
7851    // allocation; a failed probe leaves the session with no endpoints,
7852    // which we surface as a hard error (the operator asked for local-
7853    // only but the local relay isn't running — fix that first).
7854    if with_local {
7855        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
7856        if local_only {
7857            // Verify the local slot landed. If the local relay was
7858            // unreachable, the session would be unreachable from
7859            // anywhere — surface that loudly instead of leaving an
7860            // orphaned session dir.
7861            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
7862            let state: Value = std::fs::read(&relay_state_path)
7863                .ok()
7864                .and_then(|b| serde_json::from_slice(&b).ok())
7865                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7866            let endpoints = crate::endpoints::self_endpoints(&state);
7867            let has_local = endpoints
7868                .iter()
7869                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
7870            if !has_local {
7871                bail!(
7872                    "--local-only requested but local-relay probe at {local_relay} failed — \
7873                     ensure the local relay is running (`wire service install --local-relay`), \
7874                     then re-run `wire session new {name} --local-only`."
7875                );
7876            }
7877        }
7878    }
7879
7880    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
7881    // Sits AFTER local because cmd_session_new's flow is "add endpoints
7882    // alongside existing self.endpoints[]" — order independent post-init.
7883    if with_lan && let Some(lan_url) = lan_relay {
7884        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
7885    }
7886    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
7887    if with_uds && let Some(socket_path) = uds_socket {
7888        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
7889    }
7890
7891    if !no_daemon {
7892        ensure_session_daemon(&session_home)?;
7893    }
7894
7895    let info = render_session_info(&name, &session_home, &cwd)?;
7896    emit_session_new_result(&info, "created", as_json)
7897}
7898
7899/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
7900/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
7901/// sister sessions can route over the local socket instead of loopback
7902/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
7903/// alpha.17 — reqwest has no UDS support.
7904///
7905/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
7906/// and try_allocate_lan_slot semantics): session stays at existing
7907/// endpoint mix, operator can retry once the UDS relay is up.
7908#[cfg(unix)]
7909fn try_allocate_uds_slot(
7910    session_home: &std::path::Path,
7911    handle: &str,
7912    uds_socket: &std::path::Path,
7913) {
7914    // Probe healthz first so we fail fast with a clear stderr if the
7915    // socket doesn't exist OR isn't a wire relay.
7916    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
7917        Ok((200, _)) => true,
7918        Ok((status, body)) => {
7919            eprintln!(
7920                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
7921                String::from_utf8_lossy(&body)
7922            );
7923            return;
7924        }
7925        Err(e) => {
7926            eprintln!(
7927                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
7928                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
7929            );
7930            return;
7931        }
7932    };
7933    if !healthz {
7934        return;
7935    }
7936
7937    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
7938    let alloc_body = serde_json::json!({"handle": handle}).to_string();
7939    let (status, body) = match crate::relay_client::uds_request(
7940        uds_socket,
7941        "POST",
7942        "/v1/slot/allocate",
7943        &[("Content-Type", "application/json")],
7944        alloc_body.as_bytes(),
7945    ) {
7946        Ok(r) => r,
7947        Err(e) => {
7948            eprintln!(
7949                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
7950            );
7951            return;
7952        }
7953    };
7954    if status >= 300 {
7955        eprintln!(
7956            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
7957            String::from_utf8_lossy(&body)
7958        );
7959        return;
7960    }
7961    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
7962        Ok(a) => a,
7963        Err(e) => {
7964            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
7965            return;
7966        }
7967    };
7968
7969    let state_path = session_home.join("config").join("wire").join("relay.json");
7970    let mut state: serde_json::Value = std::fs::read(&state_path)
7971        .ok()
7972        .and_then(|b| serde_json::from_slice(&b).ok())
7973        .unwrap_or_else(|| serde_json::json!({}));
7974
7975    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7976        .get("self")
7977        .and_then(|s| s.get("endpoints"))
7978        .and_then(|e| e.as_array())
7979        .map(|arr| {
7980            arr.iter()
7981                .filter_map(|v| {
7982                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7983                })
7984                .collect()
7985        })
7986        .unwrap_or_default();
7987    endpoints.push(crate::endpoints::Endpoint::uds(
7988        format!("unix://{}", uds_socket.display()),
7989        alloc.slot_id.clone(),
7990        alloc.slot_token.clone(),
7991    ));
7992
7993    let self_obj = state
7994        .as_object_mut()
7995        .expect("relay_state root is an object")
7996        .entry("self")
7997        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7998    if !self_obj.is_object() {
7999        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8000    }
8001    if let Some(obj) = self_obj.as_object_mut() {
8002        obj.insert(
8003            "endpoints".into(),
8004            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8005        );
8006    }
8007    if let Err(e) = std::fs::write(
8008        &state_path,
8009        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8010    ) {
8011        eprintln!("wire session new: failed to write {state_path:?}: {e}");
8012        return;
8013    }
8014    eprintln!(
8015        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
8016        uds_socket.display(),
8017        alloc.slot_id
8018    );
8019}
8020
8021#[cfg(not(unix))]
8022fn try_allocate_uds_slot(
8023    _session_home: &std::path::Path,
8024    _handle: &str,
8025    _uds_socket: &std::path::Path,
8026) {
8027    eprintln!(
8028        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
8029    );
8030}
8031
8032/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
8033/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
8034/// pulling the agent-card see a third reachable address.
8035///
8036/// Mirrors `try_allocate_local_slot` but tags the endpoint
8037/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
8038/// session stays at whatever endpoint mix it already had — operators
8039/// can retry with `wire session new --with-lan --lan-relay <url>` once
8040/// the LAN relay is up.
8041fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
8042    let probe = match crate::relay_client::build_blocking_client(Some(
8043        std::time::Duration::from_millis(500),
8044    )) {
8045        Ok(c) => c,
8046        Err(e) => {
8047            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
8048            return;
8049        }
8050    };
8051    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
8052    match probe.get(&healthz_url).send() {
8053        Ok(resp) if resp.status().is_success() => {}
8054        Ok(resp) => {
8055            eprintln!(
8056                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
8057                resp.status()
8058            );
8059            return;
8060        }
8061        Err(e) => {
8062            eprintln!(
8063                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
8064                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
8065                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8066            );
8067            return;
8068        }
8069    };
8070
8071    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
8072    let alloc = match lan_client.allocate_slot(Some(handle)) {
8073        Ok(a) => a,
8074        Err(e) => {
8075            eprintln!(
8076                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
8077            );
8078            return;
8079        }
8080    };
8081
8082    let state_path = session_home.join("config").join("wire").join("relay.json");
8083    let mut state: serde_json::Value = std::fs::read(&state_path)
8084        .ok()
8085        .and_then(|b| serde_json::from_slice(&b).ok())
8086        .unwrap_or_else(|| serde_json::json!({}));
8087
8088    // Read existing endpoints array and add the LAN one. Preserve
8089    // federation / local entries already there.
8090    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8091        .get("self")
8092        .and_then(|s| s.get("endpoints"))
8093        .and_then(|e| e.as_array())
8094        .map(|arr| {
8095            arr.iter()
8096                .filter_map(|v| {
8097                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8098                })
8099                .collect()
8100        })
8101        .unwrap_or_default();
8102    endpoints.push(crate::endpoints::Endpoint::lan(
8103        lan_relay.trim_end_matches('/').to_string(),
8104        alloc.slot_id.clone(),
8105        alloc.slot_token.clone(),
8106    ));
8107
8108    let self_obj = state
8109        .as_object_mut()
8110        .expect("relay_state root is an object")
8111        .entry("self")
8112        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8113    if !self_obj.is_object() {
8114        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8115    }
8116    if let Some(obj) = self_obj.as_object_mut() {
8117        obj.insert(
8118            "endpoints".into(),
8119            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8120        );
8121    }
8122    if let Err(e) = std::fs::write(
8123        &state_path,
8124        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8125    ) {
8126        eprintln!("wire session new: failed to write {state_path:?}: {e}");
8127        return;
8128    }
8129    eprintln!(
8130        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
8131        alloc.slot_id
8132    );
8133}
8134
8135/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
8136/// a short timeout, allocate a slot there and update the session's
8137/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
8138///
8139/// Failure to reach the local relay is NOT fatal — the session stays
8140/// federation-only. Logs to stderr on failure so operators can tell
8141/// the local relay isn't running, but doesn't abort the bootstrap.
8142fn try_allocate_local_slot(
8143    session_home: &std::path::Path,
8144    handle: &str,
8145    _federation_relay: &str,
8146    local_relay: &str,
8147) {
8148    // Probe healthz with a tight timeout. Use a fresh client (don't
8149    // share the daemon-wide one) so the timeout is local to this call.
8150    let probe = match crate::relay_client::build_blocking_client(Some(
8151        std::time::Duration::from_millis(500),
8152    )) {
8153        Ok(c) => c,
8154        Err(e) => {
8155            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
8156            return;
8157        }
8158    };
8159    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
8160    match probe.get(&healthz_url).send() {
8161        Ok(resp) if resp.status().is_success() => {}
8162        Ok(resp) => {
8163            eprintln!(
8164                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
8165                resp.status()
8166            );
8167            return;
8168        }
8169        Err(e) => {
8170            eprintln!(
8171                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
8172                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
8173                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8174            );
8175            return;
8176        }
8177    };
8178
8179    // Allocate a slot on the local relay.
8180    let local_client = crate::relay_client::RelayClient::new(local_relay);
8181    let alloc = match local_client.allocate_slot(Some(handle)) {
8182        Ok(a) => a,
8183        Err(e) => {
8184            eprintln!(
8185                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
8186            );
8187            return;
8188        }
8189    };
8190
8191    // Merge into the session's relay.json. We invoke wire via
8192    // run_wire_with_home for federation calls (subprocess isolation),
8193    // but relay.json is a simple file we can edit directly
8194    // — and need to, because there's no `wire bind-relay --add-local`
8195    // command yet (could add later; out of scope for v0.5.17 MVP).
8196    //
8197    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
8198    // does not exist (canonical filename is `relay.json` per
8199    // `config::relay_state_path`). The mis-named file write succeeded
8200    // but landed in a sibling path nothing else reads. Every
8201    // `wire session new --with-local` invocation silently degraded to
8202    // federation-only despite the "local slot allocated" stderr line.
8203    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
8204    // session's relay.json — it had only the federation endpoint.
8205    let state_path = session_home.join("config").join("wire").join("relay.json");
8206    let mut state: serde_json::Value = std::fs::read(&state_path)
8207        .ok()
8208        .and_then(|b| serde_json::from_slice(&b).ok())
8209        .unwrap_or_else(|| serde_json::json!({}));
8210    // Read the existing federation self info (already written by
8211    // `wire init` + `wire bind-relay` path during session bootstrap).
8212    let fed_endpoint = state.get("self").and_then(|s| {
8213        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
8214        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
8215        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
8216        Some(crate::endpoints::Endpoint::federation(
8217            url.to_string(),
8218            slot_id.to_string(),
8219            slot_token.to_string(),
8220        ))
8221    });
8222
8223    let local_endpoint = crate::endpoints::Endpoint::local(
8224        local_relay.trim_end_matches('/').to_string(),
8225        alloc.slot_id.clone(),
8226        alloc.slot_token.clone(),
8227    );
8228
8229    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
8230    if let Some(f) = fed_endpoint.clone() {
8231        endpoints.push(f);
8232    }
8233    endpoints.push(local_endpoint);
8234
8235    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
8236    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
8237    // `slot_token` fields must point at the LOCAL endpoint so callers
8238    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
8239    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
8240    // still find a valid slot. Pre-v0.6.6 this branch wrote
8241    // `relay_url: federation_relay` with no slot_id, which produced
8242    // half-populated self state that broke pair-accept on local-only
8243    // sessions.
8244    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
8245        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
8246        None => (
8247            local_relay.trim_end_matches('/').to_string(),
8248            alloc.slot_id.clone(),
8249            alloc.slot_token.clone(),
8250        ),
8251    };
8252    let self_obj = state
8253        .as_object_mut()
8254        .expect("relay_state root is an object")
8255        .entry("self")
8256        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8257    // The entry might be Value::Null (left by read_relay_state's default
8258    // template) — replace with an object before mutating.
8259    if !self_obj.is_object() {
8260        *self_obj = serde_json::Value::Object(serde_json::Map::new());
8261    }
8262    if let Some(obj) = self_obj.as_object_mut() {
8263        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
8264        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
8265        obj.insert(
8266            "slot_token".into(),
8267            serde_json::Value::String(legacy_slot_token),
8268        );
8269        obj.insert(
8270            "endpoints".into(),
8271            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8272        );
8273    }
8274
8275    if let Err(e) = std::fs::write(
8276        &state_path,
8277        serde_json::to_vec_pretty(&state).unwrap_or_default(),
8278    ) {
8279        eprintln!(
8280            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
8281        );
8282        return;
8283    }
8284    eprintln!(
8285        "wire session new: local slot allocated on {local_relay} (slot_id={})",
8286        alloc.slot_id
8287    );
8288}
8289
8290fn render_session_info(
8291    name: &str,
8292    session_home: &std::path::Path,
8293    cwd: &std::path::Path,
8294) -> Result<serde_json::Value> {
8295    let card_path = session_home
8296        .join("config")
8297        .join("wire")
8298        .join("agent-card.json");
8299    let (did, handle) = if card_path.exists() {
8300        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
8301        let did = card
8302            .get("did")
8303            .and_then(Value::as_str)
8304            .unwrap_or("")
8305            .to_string();
8306        let handle = card
8307            .get("handle")
8308            .and_then(Value::as_str)
8309            .map(str::to_string)
8310            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8311        (did, handle)
8312    } else {
8313        (String::new(), String::new())
8314    };
8315    Ok(json!({
8316        "name": name,
8317        "home_dir": session_home.to_string_lossy(),
8318        "cwd": cwd.to_string_lossy(),
8319        "did": did,
8320        "handle": handle,
8321        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8322    }))
8323}
8324
8325fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
8326    if as_json {
8327        let mut obj = info.clone();
8328        obj["status"] = json!(status);
8329        println!("{}", serde_json::to_string(&obj)?);
8330    } else {
8331        let name = info["name"].as_str().unwrap_or("?");
8332        let handle = info["handle"].as_str().unwrap_or("?");
8333        let home = info["home_dir"].as_str().unwrap_or("?");
8334        let did = info["did"].as_str().unwrap_or("?");
8335        let export = info["export"].as_str().unwrap_or("?");
8336        let prefix = if status == "already_exists" {
8337            "session already exists (re-registered cwd)"
8338        } else {
8339            "session created"
8340        };
8341        println!(
8342            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
8343        );
8344    }
8345    Ok(())
8346}
8347
8348fn run_wire_with_home(
8349    session_home: &std::path::Path,
8350    args: &[&str],
8351) -> Result<std::process::ExitStatus> {
8352    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8353    let status = std::process::Command::new(&bin)
8354        .env("WIRE_HOME", session_home)
8355        .env_remove("RUST_LOG")
8356        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
8357        // We already own the session; nested init would clobber state.
8358        .env("WIRE_AUTO_INIT", "0")
8359        .args(args)
8360        .status()
8361        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8362    Ok(status)
8363}
8364
8365/// v0.7.0-alpha.2: idempotent per-cwd session creation.
8366///
8367/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
8368/// registered session for the current cwd — including via parent-walk —
8369/// this creates one inline so every Claude tab in a fresh project gets
8370/// its own wire identity rather than collapsing onto the machine-wide
8371/// default. Without this, multiple Claudes in unwired cwds all render
8372/// the same character (the default identity's character), defeating the
8373/// "every session looks different" promise.
8374///
8375/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
8376/// `run_wire_with_home` subprocess context).
8377///
8378/// Best-effort: any failure (no home dir, name collision pathology,
8379/// `wire init` subprocess crash) is logged to stderr and we fall back
8380/// to default identity. Must not block MCP startup.
8381///
8382/// MUST be called BEFORE worker thread spawn (env::set_var safety).
8383pub fn maybe_auto_init_cwd_session(label: &str) {
8384    if std::env::var("WIRE_HOME").is_ok() {
8385        return; // explicit override OR auto-detect already won
8386    }
8387    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
8388        return; // operator opt-out
8389    }
8390    let cwd = match std::env::current_dir() {
8391        Ok(c) => c,
8392        Err(_) => return,
8393    };
8394    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
8395    // already runs but we want to be robust to ordering).
8396    if crate::session::detect_session_wire_home(&cwd).is_some() {
8397        return;
8398    }
8399
8400    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
8401    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
8402    // Two different cwds with the same basename (e.g. /a/projx +
8403    // /b/projx) used to race outside the lock: both read empty
8404    // registry, both derived name="projx", per-name lock didn't help
8405    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
8406    //
8407    // Single lock serializes ALL auto-init across the sessions_root.
8408    // Inside the lock: re-read registry, derive_name_from_cwd which
8409    // adds path-hash suffix when basename is occupied by another cwd
8410    // already committed to the registry. Different cwds get DIFFERENT
8411    // names guaranteed.
8412    //
8413    // Cost: parallel auto-inits in different cwds now serialize
8414    // (~hundreds of ms each when local relay is up). Acceptable —
8415    // auto-init runs once per cwd per machine; not a hot path.
8416    use fs2::FileExt;
8417    let sessions_root = match crate::session::sessions_root() {
8418        Ok(r) => r,
8419        Err(_) => return,
8420    };
8421    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
8422        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
8423        return;
8424    }
8425    let lock_path = sessions_root.join(".auto-init.lock");
8426    let lock_file = match std::fs::OpenOptions::new()
8427        .create(true)
8428        .truncate(false)
8429        .read(true)
8430        .write(true)
8431        .open(&lock_path)
8432    {
8433        Ok(f) => f,
8434        Err(e) => {
8435            eprintln!(
8436                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
8437            );
8438            return;
8439        }
8440    };
8441    if let Err(e) = lock_file.lock_exclusive() {
8442        eprintln!(
8443            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
8444        );
8445        return;
8446    }
8447    // Lock acquired. Read registry + derive name now that all parallel
8448    // racers serialize through us — derive_name_from_cwd adds a
8449    // path-hash suffix if the basename is already claimed by another
8450    // cwd in the (now-stable) registry.
8451    let registry = crate::session::read_registry().unwrap_or_default();
8452    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
8453    let session_home = match crate::session::session_dir(&name) {
8454        Ok(h) => h,
8455        Err(_) => {
8456            let _ = fs2::FileExt::unlock(&lock_file);
8457            return;
8458        }
8459    };
8460    let agent_card_path = session_home
8461        .join("config")
8462        .join("wire")
8463        .join("agent-card.json");
8464    let needs_init = !agent_card_path.exists();
8465
8466    if needs_init {
8467        if let Err(e) = std::fs::create_dir_all(&session_home) {
8468            eprintln!(
8469                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
8470            );
8471            let _ = fs2::FileExt::unlock(&lock_file);
8472            return;
8473        }
8474        // v0.9: --offline; the surrounding session-spawn path runs
8475        // try_allocate_local_slot afterward to attach an inbound slot
8476        // when a local relay is available. Init itself stays slotless
8477        // because it's a precursor step, not the final state.
8478        match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
8479            Ok(status) if status.success() => {}
8480            Ok(status) => {
8481                eprintln!(
8482                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
8483                );
8484                let _ = fs2::FileExt::unlock(&lock_file);
8485                return;
8486            }
8487            Err(e) => {
8488                eprintln!(
8489                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
8490                );
8491                let _ = fs2::FileExt::unlock(&lock_file);
8492                return;
8493            }
8494        }
8495        // Best-effort: allocate a local-relay slot so this auto-init'd
8496        // session is addressable by sister sessions. Skipped silently when
8497        // the local relay isn't running (the function itself reports to
8498        // stderr). Auto-init'd sessions without endpoints can still
8499        // surface their character but cannot receive pair_drops until the
8500        // operator runs `wire bind-relay` or restarts the local relay.
8501        try_allocate_local_slot(
8502            &session_home,
8503            &name,
8504            "https://wireup.net",
8505            "http://127.0.0.1:8771",
8506        );
8507    } else {
8508        // Race loser path: peer already created the session. Surface
8509        // this honestly so the operator can see we adopted rather than
8510        // double-initialized.
8511        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8512            eprintln!(
8513                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
8514            );
8515        }
8516    }
8517    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
8518    // BEFORE releasing the auto-init lock. Pre-fix released the lock
8519    // here and committed the registry update afterward — racers in
8520    // OTHER cwds with the same basename would acquire the lock,
8521    // read the registry (still without our entry), and derive the
8522    // SAME name we just claimed. Live regression test caught it:
8523    // two cwds /a/projx + /b/projx both got name "projx", both
8524    // mapped to the same identity. Update the registry WHILE STILL
8525    // holding the auto-init lock so the next racer sees our claim.
8526    let cwd_key = cwd.to_string_lossy().into_owned();
8527    let name_for_reg = name.clone();
8528    if let Err(e) = crate::session::update_registry(|reg| {
8529        reg.by_cwd.insert(cwd_key, name_for_reg);
8530        Ok(())
8531    }) {
8532        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
8533        // proceed — env var still gets set below
8534    }
8535    // NOW release the lock — racers waiting will see our registry
8536    // entry on their re-read.
8537    let _ = fs2::FileExt::unlock(&lock_file);
8538
8539    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8540        eprintln!(
8541            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
8542            cwd.display(),
8543            session_home.display()
8544        );
8545    }
8546    // SAFETY: caller contract is "before any thread spawn." MCP::run
8547    // calls this immediately after `maybe_adopt_session_wire_home`.
8548    unsafe {
8549        std::env::set_var("WIRE_HOME", &session_home);
8550    }
8551}
8552
8553fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
8554    // Check if a daemon is already alive in this session's WIRE_HOME.
8555    // If so, no-op (let the existing process keep running).
8556    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8557    if pidfile.exists() {
8558        let bytes = std::fs::read(&pidfile).unwrap_or_default();
8559        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8560            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8561        } else {
8562            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8563        };
8564        if let Some(p) = pid {
8565            let alive = {
8566                #[cfg(target_os = "linux")]
8567                {
8568                    std::path::Path::new(&format!("/proc/{p}")).exists()
8569                }
8570                #[cfg(not(target_os = "linux"))]
8571                {
8572                    std::process::Command::new("kill")
8573                        .args(["-0", &p.to_string()])
8574                        .output()
8575                        .map(|o| o.status.success())
8576                        .unwrap_or(false)
8577                }
8578            };
8579            if alive {
8580                return Ok(());
8581            }
8582        }
8583    }
8584
8585    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
8586    // versioned pidfile; we just kick it off and return.
8587    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8588    let log_path = session_home.join("state").join("wire").join("daemon.log");
8589    if let Some(parent) = log_path.parent() {
8590        std::fs::create_dir_all(parent).ok();
8591    }
8592    let log_file = std::fs::OpenOptions::new()
8593        .create(true)
8594        .append(true)
8595        .open(&log_path)
8596        .with_context(|| format!("opening daemon log {log_path:?}"))?;
8597    let log_err = log_file.try_clone()?;
8598    std::process::Command::new(&bin)
8599        .env("WIRE_HOME", session_home)
8600        .env_remove("RUST_LOG")
8601        .args(["daemon", "--interval", "5"])
8602        .stdout(log_file)
8603        .stderr(log_err)
8604        .stdin(std::process::Stdio::null())
8605        .spawn()
8606        .with_context(|| "spawning session-local `wire daemon`")?;
8607    Ok(())
8608}
8609
8610fn cmd_session_list(as_json: bool) -> Result<()> {
8611    let items = crate::session::list_sessions()?;
8612    if as_json {
8613        println!("{}", serde_json::to_string(&items)?);
8614        return Ok(());
8615    }
8616    if items.is_empty() {
8617        println!("no sessions on this machine. `wire session new` to create one.");
8618        return Ok(());
8619    }
8620    println!(
8621        "{:<22} {:<24} {:<24} {:<10} CWD",
8622        "PERSONA", "NAME", "HANDLE", "DAEMON"
8623    );
8624    for s in items {
8625        // ANSI-escape-wrapped character takes more visual width than its
8626        // displayed glyph count; pad based on the plain-text form, then
8627        // wrap in escapes so the column lines up across rows.
8628        let plain = s
8629            .character
8630            .as_ref()
8631            .map(|c| c.short())
8632            .unwrap_or_else(|| "?".to_string());
8633        let colored = s
8634            .character
8635            .as_ref()
8636            .map(|c| c.colored())
8637            .unwrap_or_else(|| "?".to_string());
8638        // Approximate display width: emoji renders as ~2 cells in most
8639        // terminals; the rest are 1 cell each. We pad to 18 displayed
8640        // chars (≈22 byte slots when counting emoji).
8641        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
8642        let pad = 22usize.saturating_sub(displayed_width);
8643        println!(
8644            "{}{}  {:<24} {:<24} {:<10} {}",
8645            colored,
8646            " ".repeat(pad),
8647            s.name,
8648            s.handle.as_deref().unwrap_or("?"),
8649            if s.daemon_running { "running" } else { "down" },
8650            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8651        );
8652    }
8653    Ok(())
8654}
8655
8656/// v0.5.19: `wire session list-local` — sister-session discovery.
8657///
8658/// For each on-disk session, read its `relay-state.json` and surface
8659/// the ones that have a Local-scope endpoint (allocated via
8660/// `wire session new --with-local`). Group by the local-relay URL so
8661/// the operator can see at a glance which sessions are mutually
8662/// reachable over the same loopback relay.
8663///
8664/// Read-only, no daemon contact. Useful as the prelude to teaming /
8665/// pairing same-box sister claudes (see also `wire session
8666/// pair-all-local` once implemented).
8667fn cmd_session_list_local(as_json: bool) -> Result<()> {
8668    let listing = crate::session::list_local_sessions()?;
8669    if as_json {
8670        println!("{}", serde_json::to_string(&listing)?);
8671        return Ok(());
8672    }
8673
8674    if listing.local.is_empty() && listing.federation_only.is_empty() {
8675        println!(
8676            "no sessions on this machine. `wire session new --with-local` to create one \
8677             with a local-relay endpoint (start the relay first: \
8678             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
8679        );
8680        return Ok(());
8681    }
8682
8683    if listing.local.is_empty() {
8684        println!(
8685            "no sister sessions reachable via a local relay. \
8686             Re-run `wire session new --with-local` to add a Local endpoint, or \
8687             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
8688        );
8689    } else {
8690        // Stable iteration order: sort the relay URLs.
8691        let mut keys: Vec<&String> = listing.local.keys().collect();
8692        keys.sort();
8693        for relay_url in keys {
8694            let group = &listing.local[relay_url];
8695            println!("LOCAL RELAY: {relay_url}");
8696            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
8697            for s in group {
8698                println!(
8699                    "  {:<24} {:<32} {:<10} {}",
8700                    s.name,
8701                    s.handle.as_deref().unwrap_or("?"),
8702                    if s.daemon_running { "running" } else { "down" },
8703                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8704                );
8705            }
8706            println!();
8707        }
8708    }
8709
8710    if !listing.federation_only.is_empty() {
8711        println!("federation-only (no local endpoint):");
8712        for s in &listing.federation_only {
8713            println!(
8714                "  {:<24} {:<32} {}",
8715                s.name,
8716                s.handle.as_deref().unwrap_or("?"),
8717                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8718            );
8719        }
8720    }
8721    Ok(())
8722}
8723
8724/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
8725/// session that has a Local-scope endpoint. Skips already-paired
8726/// pairs; reports a per-pair outcome JSON suitable for scripting.
8727///
8728/// Same-uid trust anchor: the caller owns every session enumerated by
8729/// `list_local_sessions`, so the operator running this command IS the
8730/// consent for both sides. The bilateral SAS / network-level handshake
8731/// assumes strangers; same-uid sister sessions are not strangers.
8732///
8733/// Per-pair flow (sequential to keep relay-side load + log clarity):
8734///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
8735///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
8736///   3. sleep settle_secs                       (pair_drop reaches B)
8737///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
8738///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
8739///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
8740///   7. sleep settle_secs                       (ack reaches A)
8741///   8. WIRE_HOME=A wire pull --json            (A pins B)
8742fn cmd_session_pair_all_local(
8743    settle_secs: u64,
8744    federation_relay: &str,
8745    as_json: bool,
8746) -> Result<()> {
8747    use std::collections::BTreeSet;
8748    use std::time::Duration;
8749
8750    let listing = crate::session::list_local_sessions()?;
8751    // Flatten + dedup by session NAME (same session can appear under
8752    // multiple local-relay URLs if it advertises two local endpoints;
8753    // rare, but pair each pair exactly once).
8754    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
8755        Default::default();
8756    for group in listing.local.into_values() {
8757        for s in group {
8758            by_name.entry(s.name.clone()).or_insert(s);
8759        }
8760    }
8761    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8762
8763    if sessions.len() < 2 {
8764        let msg = format!(
8765            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
8766            sessions.len()
8767        );
8768        if as_json {
8769            println!(
8770                "{}",
8771                serde_json::to_string(&json!({
8772                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
8773                    "pairs_attempted": 0,
8774                    "pairs_succeeded": 0,
8775                    "pairs_skipped_already_paired": 0,
8776                    "pairs_failed": 0,
8777                    "note": msg,
8778                }))?
8779            );
8780        } else {
8781            println!("{msg}");
8782            if let Some(s) = sessions.first() {
8783                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
8784            }
8785            println!("Use `wire session new --with-local` to add more.");
8786        }
8787        return Ok(());
8788    }
8789
8790    let fed_host = host_of_url(federation_relay);
8791    if fed_host.is_empty() {
8792        bail!(
8793            "federation_relay `{federation_relay}` has no parseable host — \
8794             pass a full URL like `https://wireup.net`."
8795        );
8796    }
8797
8798    // Enumerate unordered pairs deterministically by session name.
8799    let mut attempted = 0u32;
8800    let mut succeeded = 0u32;
8801    let mut skipped_already = 0u32;
8802    let mut failed = 0u32;
8803    let mut per_pair: Vec<Value> = Vec::new();
8804
8805    for i in 0..sessions.len() {
8806        for j in (i + 1)..sessions.len() {
8807            let a = &sessions[i];
8808            let b = &sessions[j];
8809            attempted += 1;
8810
8811            // Already-paired check: if A's relay-state has B's CARD
8812            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
8813            // are character handles (not session names), so we use
8814            // each side's handle field (already on the LocalSessionView)
8815            // for the lookup rather than the session name.
8816            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
8817            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
8818            let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
8819            let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
8820            if a_pinned_b && b_pinned_a {
8821                skipped_already += 1;
8822                per_pair.push(json!({
8823                    "from": a.name,
8824                    "to": b.name,
8825                    "status": "already_paired",
8826                }));
8827                continue;
8828            }
8829
8830            let pair_result = drive_bilateral_pair(
8831                &a.home_dir,
8832                &a.name,
8833                &b.home_dir,
8834                &b.name,
8835                &fed_host,
8836                federation_relay,
8837                settle_secs,
8838            );
8839
8840            match pair_result {
8841                Ok(()) => {
8842                    succeeded += 1;
8843                    per_pair.push(json!({
8844                        "from": a.name,
8845                        "to": b.name,
8846                        "status": "paired",
8847                    }));
8848                }
8849                Err(e) => {
8850                    failed += 1;
8851                    let detail = format!("{e:#}");
8852                    per_pair.push(json!({
8853                        "from": a.name,
8854                        "to": b.name,
8855                        "status": "failed",
8856                        "error": detail,
8857                    }));
8858                }
8859            }
8860
8861            // Brief settle between pairs so we don't slam the relay
8862            // with N(N-1) parallel requests.
8863            std::thread::sleep(Duration::from_millis(200));
8864        }
8865    }
8866
8867    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
8868    let summary = json!({
8869        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
8870        "pairs_attempted": attempted,
8871        "pairs_succeeded": succeeded,
8872        "pairs_skipped_already_paired": skipped_already,
8873        "pairs_failed": failed,
8874        "results": per_pair,
8875    });
8876    if as_json {
8877        println!("{}", serde_json::to_string(&summary)?);
8878    } else {
8879        println!(
8880            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
8881            sessions.len(),
8882            attempted
8883        );
8884        println!("  paired:                 {succeeded}");
8885        println!("  skipped (already pinned): {skipped_already}");
8886        println!("  failed:                 {failed}");
8887        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
8888            let from = entry["from"].as_str().unwrap_or("?");
8889            let to = entry["to"].as_str().unwrap_or("?");
8890            let status = entry["status"].as_str().unwrap_or("?");
8891            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
8892            if err.is_empty() {
8893                println!("  {from:<24} ↔ {to:<24} {status}");
8894            } else {
8895                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
8896            }
8897        }
8898    }
8899    Ok(())
8900}
8901
8902/// Check whether `session_home`'s `relay.json` already lists `peer_name`
8903/// under `state.peers`. Best-effort — any read/parse error → false.
8904fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
8905    val_session_relay_state(session_home)
8906        .and_then(|v| v.get("peers").cloned())
8907        .and_then(|p| p.get(peer_name).cloned())
8908        .is_some()
8909}
8910
8911/// Read a session's `relay.json` directly without mutating the process'
8912/// WIRE_HOME env (which would race other threads / processes). Returns
8913/// `None` on any read or parse error — callers treat missing state as
8914/// "no peers / no endpoints" rather than aborting.
8915fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
8916    let path = session_home.join("config").join("wire").join("relay.json");
8917    let bytes = std::fs::read(&path).ok()?;
8918    serde_json::from_slice(&bytes).ok()
8919}
8920
8921/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
8922/// One probe per directed edge against the relay backing that edge's
8923/// priority-1 endpoint; output groups by undirected pair.
8924fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
8925    use std::collections::BTreeMap;
8926
8927    // Flatten by session NAME — same dedup logic as pair-all-local so a
8928    // session advertising two local endpoints doesn't get double-counted.
8929    let listing = crate::session::list_local_sessions()?;
8930    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
8931    for group in listing.local.into_values() {
8932        for s in group {
8933            by_name.entry(s.name.clone()).or_insert(s);
8934        }
8935    }
8936    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8937    let federation_only = listing.federation_only;
8938
8939    if sessions.is_empty() {
8940        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
8941        if as_json {
8942            println!(
8943                "{}",
8944                serde_json::to_string(&json!({
8945                    "sessions": [],
8946                    "edges": [],
8947                    "local_relay": null,
8948                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8949                    "summary": {
8950                        "session_count": 0,
8951                        "edge_count": 0,
8952                        "healthy": 0,
8953                        "stale": 0,
8954                        "asymmetric": 0,
8955                    },
8956                    "note": msg,
8957                }))?
8958            );
8959        } else {
8960            println!("{msg}");
8961            println!("Use `wire session new --with-local` to create one.");
8962        }
8963        return Ok(());
8964    }
8965
8966    // Build a name → session-state map: relay_state + reachable handle set.
8967    struct SessionState {
8968        view: crate::session::LocalSessionView,
8969        relay_state: Value,
8970        local_relay_url: Option<String>,
8971    }
8972    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
8973    for s in sessions {
8974        let relay_state = val_session_relay_state(&s.home_dir)
8975            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8976        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
8977        sstates.push(SessionState {
8978            view: s,
8979            relay_state,
8980            local_relay_url,
8981        });
8982    }
8983
8984    // Probe each unique local-relay URL once for healthz so the operator
8985    // sees one liveness line per local relay, not one per edge.
8986    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
8987    for s in &sstates {
8988        if let Some(url) = &s.local_relay_url
8989            && !local_relays.contains_key(url)
8990        {
8991            let healthy = probe_relay_healthz(url);
8992            local_relays.insert(url.clone(), healthy);
8993        }
8994    }
8995
8996    let now = std::time::SystemTime::now()
8997        .duration_since(std::time::UNIX_EPOCH)
8998        .map(|d| d.as_secs())
8999        .unwrap_or(0);
9000
9001    // Edges: walk every unordered pair, surface bilateral state + each
9002    // direction's last_pull. Probe priority-1 endpoint (local preferred
9003    // by `peer_endpoints_in_priority_order`).
9004    let mut edges: Vec<Value> = Vec::new();
9005    let mut healthy_count = 0u32;
9006    let mut stale_count = 0u32;
9007    let mut asymmetric_count = 0u32;
9008
9009    for i in 0..sstates.len() {
9010        for j in (i + 1)..sstates.len() {
9011            let a = &sstates[i];
9012            let b = &sstates[j];
9013            // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
9014            // (DID-derived character), not the session name. Look the
9015            // peer up by its handle (with a session-name fallback for
9016            // pre-v0.11 sessions that haven't re-init'd yet).
9017            let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
9018            let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
9019            let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
9020            let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
9021
9022            let bilateral = a_to_b.pinned && b_to_a.pinned;
9023            // Scope = the most-local scope available in either direction.
9024            // (If a→b is local and b→a is federation, the asymmetric
9025            // detail surfaces below; the headline scope is the better.)
9026            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
9027                (Some("local"), _) | (_, Some("local")) => "local",
9028                (Some("federation"), _) | (_, Some("federation")) => "federation",
9029                _ => "unknown",
9030            };
9031
9032            // Health: stale if either direction's last_pull is older than
9033            // `stale_secs`, or never observed when both sides are pinned.
9034            let mut status = if bilateral { "healthy" } else { "asymmetric" };
9035            if bilateral {
9036                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
9037                    Some(s) => s > stale_secs,
9038                    None => d.probed,
9039                });
9040                if either_stale {
9041                    status = "stale";
9042                }
9043            }
9044
9045            match status {
9046                "healthy" => healthy_count += 1,
9047                "stale" => stale_count += 1,
9048                "asymmetric" => asymmetric_count += 1,
9049                _ => {}
9050            }
9051
9052            edges.push(json!({
9053                "from": a.view.name,
9054                "to": b.view.name,
9055                "bilateral": bilateral,
9056                "scope": scope,
9057                "status": status,
9058                "directions": {
9059                    a.view.name.clone(): direction_summary(&a_to_b),
9060                    b.view.name.clone(): direction_summary(&b_to_a),
9061                },
9062            }));
9063        }
9064    }
9065
9066    let summary = json!({
9067        "sessions": sstates.iter().map(|s| json!({
9068            "name": s.view.name,
9069            "handle": s.view.handle,
9070            "cwd": s.view.cwd,
9071            "daemon_running": s.view.daemon_running,
9072            "local_relay": s.local_relay_url,
9073        })).collect::<Vec<_>>(),
9074        "edges": edges,
9075        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
9076            "url": url,
9077            "healthy": healthy,
9078        })).collect::<Vec<_>>(),
9079        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9080        "summary": {
9081            "session_count": sstates.len(),
9082            "edge_count": edges.len(),
9083            "healthy": healthy_count,
9084            "stale": stale_count,
9085            "asymmetric": asymmetric_count,
9086            "stale_threshold_secs": stale_secs,
9087        },
9088    });
9089
9090    if as_json {
9091        println!("{}", serde_json::to_string(&summary)?);
9092        return Ok(());
9093    }
9094
9095    println!(
9096        "wire mesh: {} session(s), {} edge(s)",
9097        sstates.len(),
9098        edges.len()
9099    );
9100    for (url, healthy) in &local_relays {
9101        let tick = if *healthy { "✓" } else { "✗" };
9102        println!("  local-relay {url} {tick}");
9103    }
9104    if !federation_only.is_empty() {
9105        print!("  federation-only sessions:");
9106        for f in &federation_only {
9107            print!(" {}", f.name);
9108        }
9109        println!();
9110    }
9111
9112    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
9113    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
9114    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
9115    print!("\n{:>col_w$}", "", col_w = col_w);
9116    for n in &names {
9117        print!("{:>col_w$}", n, col_w = col_w);
9118    }
9119    println!();
9120    for (i, row) in names.iter().enumerate() {
9121        print!("{:>col_w$}", row, col_w = col_w);
9122        for (j, col) in names.iter().enumerate() {
9123            let cell = if i == j {
9124                "self".to_string()
9125            } else {
9126                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
9127                match d.scope.as_deref() {
9128                    Some("local") => "local".to_string(),
9129                    Some("federation") => "fed".to_string(),
9130                    _ => "—".to_string(),
9131                }
9132            };
9133            print!("{:>col_w$}", cell, col_w = col_w);
9134        }
9135        println!();
9136    }
9137
9138    println!("\nHealth (stale threshold: {stale_secs}s):");
9139    for e in &edges {
9140        let from = e["from"].as_str().unwrap_or("?");
9141        let to = e["to"].as_str().unwrap_or("?");
9142        let scope = e["scope"].as_str().unwrap_or("?");
9143        let status = e["status"].as_str().unwrap_or("?");
9144        let mark = match status {
9145            "healthy" => "✓",
9146            "stale" => "⚠",
9147            "asymmetric" => "!",
9148            _ => "?",
9149        };
9150        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
9151        let mut details: Vec<String> = Vec::new();
9152        for (who, d) in &dirs {
9153            let silent = d.get("silent_secs").and_then(Value::as_u64);
9154            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
9155            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
9156            let label = match (pinned, probed, silent) {
9157                (false, _, _) => format!("{who} has not pinned"),
9158                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
9159                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
9160                (true, true, Some(s)) => format!("{who} silent {s}s"),
9161                (true, true, None) => format!("{who} never pulled"),
9162            };
9163            details.push(label);
9164        }
9165        println!(
9166            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
9167            details.join(" | ")
9168        );
9169    }
9170    Ok(())
9171}
9172
9173#[derive(Default)]
9174struct DirectedEdge {
9175    pinned: bool,
9176    scope: Option<String>,
9177    last_pull_at_unix: Option<u64>,
9178    silent_secs: Option<u64>,
9179    probed: bool,
9180    event_count: usize,
9181}
9182
9183/// Probe a single directed edge from `from_state`'s view of `to_name`.
9184/// Picks the priority-1 endpoint (local preferred when reachable) and
9185/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
9186/// failure (the function records `probed = true`, `last_pull = None`,
9187/// which the caller treats as "never pulled, route exists" = stale).
9188fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
9189    let pinned = from_state
9190        .get("peers")
9191        .and_then(|p| p.get(to_name))
9192        .is_some();
9193    if !pinned {
9194        return DirectedEdge::default();
9195    }
9196    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
9197    let ep = match endpoints.into_iter().next() {
9198        Some(e) => e,
9199        None => {
9200            return DirectedEdge {
9201                pinned: true,
9202                ..Default::default()
9203            };
9204        }
9205    };
9206    let scope = Some(
9207        match ep.scope {
9208            crate::endpoints::EndpointScope::Local => "local",
9209            crate::endpoints::EndpointScope::Lan => "lan",
9210            crate::endpoints::EndpointScope::Uds => "uds",
9211            crate::endpoints::EndpointScope::Federation => "federation",
9212        }
9213        .to_string(),
9214    );
9215    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
9216    let (count, last) = client
9217        .slot_state(&ep.slot_id, &ep.slot_token)
9218        .unwrap_or((0, None));
9219    let silent = last.map(|t| now.saturating_sub(t));
9220    DirectedEdge {
9221        pinned: true,
9222        scope,
9223        last_pull_at_unix: last,
9224        silent_secs: silent,
9225        probed: true,
9226        event_count: count,
9227    }
9228}
9229
9230fn direction_summary(d: &DirectedEdge) -> Value {
9231    json!({
9232        "pinned": d.pinned,
9233        "scope": d.scope,
9234        "probed": d.probed,
9235        "last_pull_at_unix": d.last_pull_at_unix,
9236        "silent_secs": d.silent_secs,
9237        "event_count": d.event_count,
9238    })
9239}
9240
9241/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
9242fn probe_relay_healthz(url: &str) -> bool {
9243    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
9244    let client = match reqwest::blocking::Client::builder()
9245        .timeout(std::time::Duration::from_millis(500))
9246        .build()
9247    {
9248        Ok(c) => c,
9249        Err(_) => return false,
9250    };
9251    match client.get(&probe_url).send() {
9252        Ok(r) => r.status().is_success(),
9253        Err(_) => false,
9254    }
9255}
9256
9257/// Drive one bilateral pair handshake between two sister sessions
9258/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
9259/// flow so failures bubble up at the offending step, not buried in
9260/// a parallel race. See `cmd_session_pair_all_local` docstring.
9261///
9262/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
9263/// federation `.well-known/wire/agent` resolution. Reads B's card +
9264/// endpoints directly off disk under `b_home` and pins them. This
9265/// makes pair-all-local work for sister sessions whose federation
9266/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
9267/// for sessions created with `wire session new --local-only`
9268/// (no federation slot at all). The `_federation_relay` / `_fed_host`
9269/// parameters are retained for callers that want to log them but
9270/// the handshake itself no longer touches federation.
9271fn drive_bilateral_pair(
9272    a_home: &std::path::Path,
9273    a_name: &str,
9274    b_home: &std::path::Path,
9275    b_name: &str,
9276    _fed_host: &str,
9277    _federation_relay: &str,
9278    settle_secs: u64,
9279) -> Result<()> {
9280    use std::time::Duration;
9281    let bin = std::env::current_exe().context("locating self exe")?;
9282
9283    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
9284        let out = std::process::Command::new(&bin)
9285            .env("WIRE_HOME", home)
9286            .env_remove("RUST_LOG")
9287            .args(args)
9288            .output()
9289            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9290        if !out.status.success() {
9291            bail!(
9292                "`wire {}` failed: stderr={}",
9293                args.join(" "),
9294                String::from_utf8_lossy(&out.stderr).trim()
9295            );
9296        }
9297        Ok(())
9298    };
9299
9300    // v0.11: each session's agent-card.handle is the DID-derived
9301    // character, not the session name. pair-accept lookups key on the
9302    // CARD HANDLE, so we discover each side's canonical handle from
9303    // its agent-card on disk before driving the pair flow.
9304    let read_card_handle = |home: &std::path::Path| -> Result<String> {
9305        let card_path = home.join("config").join("wire").join("agent-card.json");
9306        let bytes = std::fs::read(&card_path)
9307            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
9308        let card: Value = serde_json::from_slice(&bytes)?;
9309        card.get("handle")
9310            .and_then(Value::as_str)
9311            .map(str::to_string)
9312            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
9313    };
9314    let a_handle = read_card_handle(a_home)
9315        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
9316    let b_handle = read_card_handle(b_home)
9317        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
9318
9319    // 1. A initiates via --local-sister (uses the session NAME for
9320    // the registry lookup; cmd_add_local_sister auto-resolves
9321    // session→handle internally).
9322    run(a_home, &["add", b_name, "--local-sister", "--json"])
9323        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
9324
9325    // 3. settle so pair_drop reaches B's slot
9326    std::thread::sleep(Duration::from_secs(settle_secs));
9327
9328    // 4. B pulls pair_drop → 5. B pair-accept (pins A by CARD HANDLE,
9329    // not by session name — under v0.11 these differ) → 6. B push ack
9330    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
9331    run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
9332        format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
9333    })?;
9334    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
9335
9336    // 7. settle so ack reaches A's slot
9337    std::thread::sleep(Duration::from_secs(settle_secs));
9338
9339    // 8. A pulls ack (pins B by CARD HANDLE)
9340    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
9341    // suppress unused warning when both handles are consumed
9342    let _ = &b_handle;
9343
9344    Ok(())
9345}
9346
9347fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
9348    let name = resolve_session_name(name_arg)?;
9349    let session_home = crate::session::session_dir(&name)?;
9350    if !session_home.exists() {
9351        bail!(
9352            "no session named {name:?} on this machine. `wire session list` to enumerate, \
9353             `wire session new {name}` to create."
9354        );
9355    }
9356    if as_json {
9357        println!(
9358            "{}",
9359            serde_json::to_string(&json!({
9360                "name": name,
9361                "home_dir": session_home.to_string_lossy(),
9362                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9363            }))?
9364        );
9365    } else {
9366        println!("export WIRE_HOME={}", session_home.to_string_lossy());
9367    }
9368    Ok(())
9369}
9370
9371fn cmd_session_current(as_json: bool) -> Result<()> {
9372    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9373    let registry = crate::session::read_registry().unwrap_or_default();
9374    let cwd_key = cwd.to_string_lossy().into_owned();
9375    let name = registry.by_cwd.get(&cwd_key).cloned();
9376    if as_json {
9377        println!(
9378            "{}",
9379            serde_json::to_string(&json!({
9380                "cwd": cwd_key,
9381                "session": name,
9382            }))?
9383        );
9384    } else if let Some(n) = name {
9385        println!("{n}");
9386    } else {
9387        println!("(no session registered for this cwd)");
9388    }
9389    Ok(())
9390}
9391
9392fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
9393    let name = crate::session::sanitize_name(name_arg);
9394    let session_home = crate::session::session_dir(&name)?;
9395    if !session_home.exists() {
9396        if as_json {
9397            println!(
9398                "{}",
9399                serde_json::to_string(&json!({
9400                    "name": name,
9401                    "destroyed": false,
9402                    "reason": "no such session",
9403                }))?
9404            );
9405        } else {
9406            println!("no session named {name:?} — nothing to destroy.");
9407        }
9408        return Ok(());
9409    }
9410    if !force {
9411        bail!(
9412            "destroying session {name:?} would delete its keypair + state irrecoverably. \
9413             Pass --force to confirm."
9414        );
9415    }
9416
9417    // Kill the session-local daemon if alive.
9418    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9419    if let Ok(bytes) = std::fs::read(&pidfile) {
9420        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9421            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9422        } else {
9423            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9424        };
9425        if let Some(p) = pid {
9426            let _ = std::process::Command::new("kill")
9427                .args(["-TERM", &p.to_string()])
9428                .output();
9429        }
9430    }
9431
9432    std::fs::remove_dir_all(&session_home)
9433        .with_context(|| format!("removing session dir {session_home:?}"))?;
9434
9435    // Strip from registry.
9436    let mut registry = crate::session::read_registry().unwrap_or_default();
9437    registry.by_cwd.retain(|_, v| v != &name);
9438    crate::session::write_registry(&registry)?;
9439
9440    if as_json {
9441        println!(
9442            "{}",
9443            serde_json::to_string(&json!({
9444                "name": name,
9445                "destroyed": true,
9446            }))?
9447        );
9448    } else {
9449        println!("destroyed session {name:?}.");
9450    }
9451    Ok(())
9452}
9453
9454// ---------- diag (structured trace) ----------
9455
9456fn cmd_diag(action: DiagAction) -> Result<()> {
9457    let state = config::state_dir()?;
9458    let knob = state.join("diag.enabled");
9459    let log_path = state.join("diag.jsonl");
9460    match action {
9461        DiagAction::Tail { limit, json } => {
9462            let entries = crate::diag::tail(limit);
9463            if json {
9464                for e in entries {
9465                    println!("{}", serde_json::to_string(&e)?);
9466                }
9467            } else if entries.is_empty() {
9468                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
9469            } else {
9470                for e in entries {
9471                    let ts = e["ts"].as_u64().unwrap_or(0);
9472                    let ty = e["type"].as_str().unwrap_or("?");
9473                    let pid = e["pid"].as_u64().unwrap_or(0);
9474                    let payload = e["payload"].to_string();
9475                    println!("[{ts}] pid={pid} {ty} {payload}");
9476                }
9477            }
9478        }
9479        DiagAction::Enable => {
9480            config::ensure_dirs()?;
9481            std::fs::write(&knob, "1")?;
9482            println!("wire diag: enabled at {knob:?}");
9483        }
9484        DiagAction::Disable => {
9485            if knob.exists() {
9486                std::fs::remove_file(&knob)?;
9487            }
9488            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
9489        }
9490        DiagAction::Status { json } => {
9491            let enabled = crate::diag::is_enabled();
9492            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
9493            if json {
9494                println!(
9495                    "{}",
9496                    serde_json::to_string(&serde_json::json!({
9497                        "enabled": enabled,
9498                        "log_path": log_path,
9499                        "log_size_bytes": size,
9500                    }))?
9501                );
9502            } else {
9503                println!("wire diag status");
9504                println!("  enabled:    {enabled}");
9505                println!("  log:        {log_path:?}");
9506                println!("  log size:   {size} bytes");
9507            }
9508        }
9509    }
9510    Ok(())
9511}
9512
9513// ---------- service (install / uninstall / status) ----------
9514
9515fn cmd_service(action: ServiceAction) -> Result<()> {
9516    let kind = |local_relay: bool| {
9517        if local_relay {
9518            crate::service::ServiceKind::LocalRelay
9519        } else {
9520            crate::service::ServiceKind::Daemon
9521        }
9522    };
9523    let (report, as_json) = match action {
9524        ServiceAction::Install { local_relay, json } => {
9525            (crate::service::install_kind(kind(local_relay))?, json)
9526        }
9527        ServiceAction::Uninstall { local_relay, json } => {
9528            (crate::service::uninstall_kind(kind(local_relay))?, json)
9529        }
9530        ServiceAction::Status { local_relay, json } => {
9531            (crate::service::status_kind(kind(local_relay))?, json)
9532        }
9533    };
9534    if as_json {
9535        println!("{}", serde_json::to_string(&report)?);
9536    } else {
9537        println!("wire service {}", report.action);
9538        println!("  platform:  {}", report.platform);
9539        println!("  unit:      {}", report.unit_path);
9540        println!("  status:    {}", report.status);
9541        println!("  detail:    {}", report.detail);
9542    }
9543    Ok(())
9544}
9545
9546// ---------- upgrade (atomic daemon swap) ----------
9547
9548/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
9549/// fresh one from the currently-installed binary, write a new versioned
9550/// pidfile. The fix for today's exact failure mode: a daemon process that
9551/// kept running OLD binary text in memory under a symlink that had since
9552/// been repointed at a NEW binary on disk.
9553///
9554/// Idempotent. If no stale daemon is running, just starts a fresh one
9555/// (same as `wire daemon &` but with the wait-until-alive guard from
9556/// ensure_up::ensure_daemon_running).
9557///
9558/// `--check` mode reports drift without acting — lists the processes
9559/// that WOULD be killed and the binary version of each.
9560fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
9561    // 1. Identify all running wire processes. v0.7.3: walks `pgrep -f`
9562    // on unix / `Get-CimInstance Win32_Process` on Windows via the
9563    // shared `platform::find_processes_by_cmdline`. Covers both the
9564    // long-lived sync `wire daemon` *and* the `wire relay-server`
9565    // local-only loopback — the pre-v0.7.3 upgrade only swept daemons
9566    // and left stale relay-server children pinned on the old binary,
9567    // forcing operators to `pkill -f relay-server` manually after
9568    // every version bump.
9569    let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
9570    let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
9571    let running_pids: Vec<u32> = daemon_pids
9572        .iter()
9573        .chain(relay_pids.iter())
9574        .copied()
9575        .collect();
9576
9577    // 2. Read pidfile to surface what the daemon THINKS it is.
9578    let record = crate::ensure_up::read_pid_record("daemon");
9579    let recorded_version: Option<String> = match &record {
9580        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
9581        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
9582        _ => None,
9583    };
9584    let cli_version = env!("CARGO_PKG_VERSION").to_string();
9585
9586    // 2b. v0.6.9: snapshot which sessions HAD a running daemon BEFORE
9587    // we kill anything. Step 3's pgrep+SIGTERM also kills session-owned
9588    // daemons (they share the `wire daemon` command line), so by the
9589    // time the respawn loop runs, `daemon_running` would always be
9590    // false and zero sessions would respawn. Capture state up front
9591    // and respawn whatever was alive at the start.
9592    let sessions_to_respawn_after_kill: Vec<std::path::PathBuf> = crate::session::list_sessions()
9593        .unwrap_or_default()
9594        .into_iter()
9595        .filter(|s| s.daemon_running)
9596        .map(|s| s.home_dir)
9597        .collect();
9598
9599    if check_only {
9600        // v0.6.8: also surface session-level state + PATH dupes in --check.
9601        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
9602            .unwrap_or_default()
9603            .iter()
9604            .filter(|s| s.daemon_running)
9605            .map(|s| s.name.clone())
9606            .collect();
9607        let mut path_dupes: Vec<String> = Vec::new();
9608        if let Ok(path) = std::env::var("PATH") {
9609            let mut seen: std::collections::HashSet<std::path::PathBuf> =
9610                std::collections::HashSet::new();
9611            for dir in path.split(':') {
9612                let candidate = std::path::PathBuf::from(dir).join("wire");
9613                if candidate.exists() {
9614                    let canon = candidate.canonicalize().unwrap_or(candidate);
9615                    if seen.insert(canon.clone()) {
9616                        path_dupes.push(canon.to_string_lossy().into_owned());
9617                    }
9618                }
9619            }
9620        }
9621        // v0.7.3: enumerate which service units WOULD be refreshed.
9622        // Read-only — `status_kind` doesn't touch anything.
9623        let installed_service_kinds: Vec<&'static str> = [
9624            (crate::service::ServiceKind::Daemon, "daemon"),
9625            (crate::service::ServiceKind::LocalRelay, "local-relay"),
9626        ]
9627        .into_iter()
9628        .filter_map(|(k, label)| {
9629            crate::service::status_kind(k)
9630                .ok()
9631                .filter(|r| r.status != "absent")
9632                .map(|_| label)
9633        })
9634        .collect();
9635        let report = json!({
9636            "running_pids": running_pids,
9637            "running_daemons": daemon_pids,
9638            "running_relay_servers": relay_pids,
9639            "pidfile_version": recorded_version,
9640            "cli_version": cli_version,
9641            "would_kill": running_pids,
9642            "would_refresh_services": installed_service_kinds,
9643            "session_daemons_running": sessions_with_daemons,
9644            "path_binaries": path_dupes,
9645            "path_duplicate_warning": path_dupes.len() > 1,
9646        });
9647        if as_json {
9648            println!("{}", serde_json::to_string(&report)?);
9649        } else {
9650            println!("wire upgrade --check");
9651            println!("  cli version:      {cli_version}");
9652            println!(
9653                "  pidfile version:  {}",
9654                recorded_version.as_deref().unwrap_or("(missing)")
9655            );
9656            if running_pids.is_empty() {
9657                println!("  running daemons:  none");
9658                println!("  running relays:   none");
9659            } else {
9660                if daemon_pids.is_empty() {
9661                    println!("  running daemons:  none");
9662                } else {
9663                    let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
9664                    println!("  running daemons:  pids {}", p.join(", "));
9665                }
9666                if relay_pids.is_empty() {
9667                    println!("  running relays:   none");
9668                } else {
9669                    let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
9670                    println!("  running relays:   pids {}", p.join(", "));
9671                }
9672                println!("  would kill all + spawn fresh");
9673            }
9674            if !installed_service_kinds.is_empty() {
9675                println!(
9676                    "  would refresh:    {} installed service unit(s) → new binary path",
9677                    installed_service_kinds.join(", ")
9678                );
9679            }
9680            if !sessions_with_daemons.is_empty() {
9681                println!(
9682                    "  session daemons:  {} (would respawn under new binary)",
9683                    sessions_with_daemons.join(", ")
9684                );
9685            }
9686            if path_dupes.len() > 1 {
9687                println!(
9688                    "  PATH warning:     {} distinct `wire` binaries on PATH:",
9689                    path_dupes.len()
9690                );
9691                for b in &path_dupes {
9692                    println!("                      {b}");
9693                }
9694                println!("                    operators should remove the stale ones");
9695            }
9696        }
9697        return Ok(());
9698    }
9699
9700    // 3. Kill every running wire process (daemons + relay-servers).
9701    // Graceful first (SIGTERM / taskkill), then forceful (SIGKILL /
9702    // taskkill /F) after a brief grace period. v0.7.3: uses
9703    // `platform::kill_process` so the Windows path goes through
9704    // `taskkill /T /PID` (kills the process tree, important for
9705    // relay-server's hyper worker threads).
9706    let mut killed: Vec<u32> = Vec::new();
9707    for pid in &running_pids {
9708        if crate::platform::kill_process(*pid, false) {
9709            killed.push(*pid);
9710        }
9711    }
9712    // Wait up to ~2s for graceful exit.
9713    if !killed.is_empty() {
9714        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
9715        loop {
9716            let still_alive: Vec<u32> = killed
9717                .iter()
9718                .copied()
9719                .filter(|p| process_alive_pid(*p))
9720                .collect();
9721            if still_alive.is_empty() {
9722                break;
9723            }
9724            if std::time::Instant::now() >= deadline {
9725                // Force-kill hold-outs.
9726                for pid in still_alive {
9727                    let _ = crate::platform::kill_process(pid, true);
9728                }
9729                break;
9730            }
9731            std::thread::sleep(std::time::Duration::from_millis(50));
9732        }
9733    }
9734
9735    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
9736    //    old daemon is still owning it.
9737    let pidfile = config::state_dir()?.join("daemon.pid");
9738    if pidfile.exists() {
9739        let _ = std::fs::remove_file(&pidfile);
9740    }
9741
9742    // 4b. v0.6.8/9 stale-cleanup: wipe every session's pidfile (step 3's
9743    // pgrep+SIGTERM has already killed the processes; pidfile tombstones
9744    // would otherwise block ensure_session_daemon's "already running"
9745    // short-circuit). The respawn list comes from the v0.6.9 pre-kill
9746    // snapshot above — checking `daemon_running` here would always
9747    // return false because we just killed them.
9748    if let Ok(sessions) = crate::session::list_sessions() {
9749        for s in &sessions {
9750            let session_pidfile = s.home_dir.join("state").join("wire").join("daemon.pid");
9751            if session_pidfile.exists() {
9752                let _ = std::fs::remove_file(&session_pidfile);
9753            }
9754        }
9755    }
9756    let session_daemons_to_respawn = sessions_to_respawn_after_kill;
9757
9758    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
9759    // multiple distinct files on $PATH, surface the conflict — operators
9760    // get bitten when an old binary at /usr/local/bin shadows a fresh
9761    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
9762    let mut path_dupes: Vec<String> = Vec::new();
9763    if let Ok(path) = std::env::var("PATH") {
9764        let mut seen: std::collections::HashSet<std::path::PathBuf> =
9765            std::collections::HashSet::new();
9766        for dir in path.split(':') {
9767            let candidate = std::path::PathBuf::from(dir).join("wire");
9768            if candidate.exists() {
9769                let canon = candidate.canonicalize().unwrap_or(candidate);
9770                if seen.insert(canon.clone()) {
9771                    path_dupes.push(canon.to_string_lossy().into_owned());
9772                }
9773            }
9774        }
9775    }
9776    let path_warning = if path_dupes.len() > 1 {
9777        Some(format!(
9778            "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n  {}",
9779            path_dupes.len(),
9780            path_dupes.join("\n  ")
9781        ))
9782    } else {
9783        None
9784    };
9785
9786    // 4d. v0.7.3 NEW: refresh installed service units so they point at
9787    // the freshly-installed binary path. Without this step, an upgrade
9788    // would: kill the old daemon, leave the launchd plist /
9789    // systemd unit / Windows scheduled task pointing at the OLD
9790    // binary path (or, worse, an old binary location that's been
9791    // unlinked), and then the OS's auto-respawn would either fail or
9792    // bring the OLD binary back from the dead. Reinstalling rewrites
9793    // the unit with `std::env::current_exe()` (the freshly-resolved
9794    // path of the running upgrade-driver process) and re-bootstraps /
9795    // re-enables / re-registers so the next OS-driven start uses it.
9796    //
9797    // Only refreshes units that are already installed — does NOT
9798    // install services the operator never opted into.
9799    let mut service_refreshes: Vec<Value> = Vec::new();
9800    for kind in [
9801        crate::service::ServiceKind::Daemon,
9802        crate::service::ServiceKind::LocalRelay,
9803    ] {
9804        let already_installed = crate::service::status_kind(kind)
9805            .map(|r| r.status != "absent")
9806            .unwrap_or(false);
9807        if !already_installed {
9808            continue;
9809        }
9810        match crate::service::install_kind(kind) {
9811            Ok(rep) => service_refreshes.push(json!({
9812                "kind": rep.kind,
9813                "platform": rep.platform,
9814                "status": rep.status,
9815                "unit_path": rep.unit_path,
9816                "action": "refreshed",
9817            })),
9818            Err(e) => service_refreshes.push(json!({
9819                "kind": format!("{kind:?}"),
9820                "action": "refresh_failed",
9821                "error": format!("{e:#}"),
9822            })),
9823        }
9824    }
9825
9826    // 5. Spawn fresh daemon via ensure_up — atomically waits for
9827    //    process_alive + writes the versioned pidfile. (If the Daemon
9828    //    service was refreshed above, it has already started a fresh
9829    //    process under the new binary; ensure_daemon_running notices
9830    //    and short-circuits to "already running".)
9831    let spawned = crate::ensure_up::ensure_daemon_running()?;
9832
9833    // 5b. v0.6.8: respawn each session daemon under the new binary.
9834    // Reuses `ensure_session_daemon` — same code path `wire session new`
9835    // takes for the initial spawn (writes versioned pidfile, opens log,
9836    // detaches). Best effort: failure of one session's respawn doesn't
9837    // abort the upgrade for the others.
9838    let mut session_respawns: Vec<Value> = Vec::new();
9839    for home in &session_daemons_to_respawn {
9840        match ensure_session_daemon(home) {
9841            Ok(()) => session_respawns.push(json!({
9842                "session_home": home.to_string_lossy(),
9843                "status": "respawned",
9844            })),
9845            Err(e) => session_respawns.push(json!({
9846                "session_home": home.to_string_lossy(),
9847                "status": "failed",
9848                "error": format!("{e:#}"),
9849            })),
9850        }
9851    }
9852
9853    let new_record = crate::ensure_up::read_pid_record("daemon");
9854    let new_pid = new_record.pid();
9855    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
9856        Some(d.version.clone())
9857    } else {
9858        None
9859    };
9860
9861    if as_json {
9862        println!(
9863            "{}",
9864            serde_json::to_string(&json!({
9865                "killed": killed,
9866                "killed_daemons": daemon_pids,
9867                "killed_relay_servers": relay_pids,
9868                "service_refreshes": service_refreshes,
9869                "spawned_fresh_daemon": spawned,
9870                "new_pid": new_pid,
9871                "new_version": new_version,
9872                "cli_version": cli_version,
9873                "session_respawns": session_respawns,
9874                "path_binaries": path_dupes,
9875                "path_warning": path_warning,
9876            }))?
9877        );
9878    } else {
9879        if killed.is_empty() {
9880            println!("wire upgrade: no stale wire processes running");
9881        } else {
9882            println!(
9883                "wire upgrade: killed {} process(es) — {} daemon(s) + {} relay-server(s) (pids {})",
9884                killed.len(),
9885                daemon_pids.len(),
9886                relay_pids.len(),
9887                killed
9888                    .iter()
9889                    .map(|p| p.to_string())
9890                    .collect::<Vec<_>>()
9891                    .join(", ")
9892            );
9893        }
9894        if !service_refreshes.is_empty() {
9895            println!(
9896                "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
9897                service_refreshes.len()
9898            );
9899            for r in &service_refreshes {
9900                let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
9901                let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
9902                let status = r.get("status").and_then(Value::as_str).unwrap_or("");
9903                let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
9904                if action == "refreshed" {
9905                    println!("                    - {kind}: {action} ({status}, {platform})");
9906                } else {
9907                    let err = r.get("error").and_then(Value::as_str).unwrap_or("");
9908                    println!("                    - {kind}: {action} ({err})");
9909                }
9910            }
9911        }
9912        if spawned {
9913            println!(
9914                "wire upgrade: spawned fresh daemon (pid {} v{})",
9915                new_pid
9916                    .map(|p| p.to_string())
9917                    .unwrap_or_else(|| "?".to_string()),
9918                new_version.as_deref().unwrap_or(&cli_version),
9919            );
9920        } else {
9921            println!("wire upgrade: daemon was already running on current binary");
9922        }
9923        if !session_respawns.is_empty() {
9924            println!(
9925                "wire upgrade: refreshed {} session daemon(s):",
9926                session_respawns.len()
9927            );
9928            for r in &session_respawns {
9929                let h = r["session_home"].as_str().unwrap_or("?");
9930                let s = r["status"].as_str().unwrap_or("?");
9931                let label = std::path::Path::new(h)
9932                    .file_name()
9933                    .map(|f| f.to_string_lossy().into_owned())
9934                    .unwrap_or_else(|| h.to_string());
9935                println!("  {label:<24} {s}");
9936            }
9937        }
9938        if let Some(msg) = &path_warning {
9939            eprintln!("wire upgrade: {msg}");
9940        }
9941    }
9942    Ok(())
9943}
9944
9945/// v0.9.1: should this command emit JSON by default?
9946///
9947/// - `explicit=true` → operator passed `--json`, always JSON.
9948/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
9949///   captured output parses cleanly without operators remembering to
9950///   append `--json`. Mirrors `gh`, `kubectl`, etc.
9951/// - interactive TTY → human format (false).
9952/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
9953///   that parsed the human text by accident).
9954fn json_default(explicit: bool) -> bool {
9955    if explicit {
9956        return true;
9957    }
9958    if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
9959        return false;
9960    }
9961    use std::io::IsTerminal;
9962    !std::io::stdout().is_terminal()
9963}
9964
9965fn process_alive_pid(pid: u32) -> bool {
9966    // v0.7.3: delegate to the cross-platform helper. See
9967    // `platform::process_alive` for the per-OS dispatch — Windows now
9968    // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
9969    // gave a hard-coded false on Windows pre-v0.7.3.
9970    crate::platform::process_alive(pid)
9971}
9972
9973// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
9974
9975/// Iterative Levenshtein distance between two strings, case-insensitive.
9976/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
9977/// resolves against (typically <30 chars).
9978fn levenshtein_ci(a: &str, b: &str) -> usize {
9979    let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
9980    let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
9981    let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
9982    let (m, n) = (a.len(), b.len());
9983    if m == 0 {
9984        return n;
9985    }
9986    let mut prev: Vec<usize> = (0..=m).collect();
9987    let mut curr = vec![0usize; m + 1];
9988    for j in 1..=n {
9989        curr[0] = j;
9990        for i in 1..=m {
9991            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
9992            curr[i] = std::cmp::min(
9993                std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
9994                prev[i - 1] + cost,
9995            );
9996        }
9997        std::mem::swap(&mut prev, &mut curr);
9998    }
9999    prev[m]
10000}
10001
10002/// Return up to `max_results` names from `pool` whose edit distance to
10003/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
10004/// "did you mean" suggestions on resolution miss.
10005pub fn closest_candidates(
10006    needle: &str,
10007    pool: &[String],
10008    max_distance: usize,
10009    max_results: usize,
10010) -> Vec<String> {
10011    let mut scored: Vec<(usize, &String)> = pool
10012        .iter()
10013        .map(|c| (levenshtein_ci(needle, c), c))
10014        .filter(|(d, _)| *d <= max_distance)
10015        .collect();
10016    scored.sort_by_key(|(d, _)| *d);
10017    scored
10018        .into_iter()
10019        .take(max_results)
10020        .map(|(_, c)| c.clone())
10021        .collect()
10022}
10023
10024/// Collect every name that `resolve_name_to_target` would currently
10025/// match: pinned-peer handles, pinned-peer character nicknames, sister
10026/// session names, sister character nicknames, sister handles. Used for
10027/// the `did_you_mean` pool on resolution miss.
10028fn known_local_names() -> Vec<String> {
10029    let mut names: Vec<String> = Vec::new();
10030    if let Ok(trust) = config::read_trust() {
10031        // (debug eprintln removed; left bug-trail in commit message)
10032        // trust.agents is an object keyed by handle, NOT an array —
10033        // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
10034        // the object's keys (which ARE the handles) plus each entry's
10035        // did for the DID-derived character nickname.
10036        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
10037            for (handle, agent) in agents {
10038                names.push(handle.clone());
10039                if let Some(did) = agent.get("did").and_then(Value::as_str) {
10040                    let ch = crate::character::Character::from_did(did);
10041                    names.push(ch.nickname);
10042                }
10043            }
10044        }
10045    }
10046    if let Ok(sessions) = crate::session::list_sessions() {
10047        for s in sessions {
10048            names.push(s.name.clone());
10049            if let Some(h) = &s.handle {
10050                names.push(h.clone());
10051            }
10052            if let Some(ch) = &s.character {
10053                names.push(ch.nickname.clone());
10054            }
10055        }
10056    }
10057    names.sort();
10058    names.dedup();
10059    names
10060}
10061
10062/// v0.9.2 deprecation banner with two ergonomic guards:
10063/// 1. Suppress in JSON mode (the caller is expected to fold the
10064///    deprecation note into its JSON output instead).
10065/// 2. Cache once-per-shell-session via a marker env var; subsequent
10066///    invocations in the same shell stay silent.
10067///
10068/// `verb` is the legacy verb name, `replacement` is the canonical one.
10069fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
10070    if json_mode {
10071        return;
10072    }
10073    // Pull a marker from environment of THIS process. Persistent across
10074    // multiple wire invocations only when the shell sets and exports
10075    // WIRE_DEPRECATION_NAGGED — operators rarely do, so practically
10076    // this nags once per `wire foo` invocation. The single-process
10077    // dedup matters most for scripts that call multiple deprecated
10078    // verbs in one wire run, which is currently impossible (one verb
10079    // per process) but documented for future loop-style wire shells.
10080    let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
10081    if std::env::var(&key).is_ok() {
10082        return;
10083    }
10084    // SAFETY: deprecation_warn is called from sync dispatcher code paths
10085    // before any worker thread spawns; env::set_var in Rust 2024 is
10086    // safe at that point. Pattern matches maybe_adopt_session_wire_home.
10087    unsafe {
10088        std::env::set_var(&key, "1");
10089    }
10090    eprintln!(
10091        "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
10092         Will be removed in v1.0 (target 2026-Q3). \
10093         Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
10094        verb.replace('-', "_")
10095    );
10096}
10097
10098// ---------- doctor (single-command diagnostic) ----------
10099
10100/// One DoctorCheck = one verdict on one health dimension.
10101#[derive(Clone, Debug, serde::Serialize)]
10102pub struct DoctorCheck {
10103    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
10104    /// Stable across versions for tooling consumption.
10105    pub id: String,
10106    /// PASS / WARN / FAIL.
10107    pub status: String,
10108    /// One-line human summary.
10109    pub detail: String,
10110    /// Optional remediation hint shown after the failing line.
10111    #[serde(skip_serializing_if = "Option::is_none")]
10112    pub fix: Option<String>,
10113}
10114
10115impl DoctorCheck {
10116    fn pass(id: &str, detail: impl Into<String>) -> Self {
10117        Self {
10118            id: id.into(),
10119            status: "PASS".into(),
10120            detail: detail.into(),
10121            fix: None,
10122        }
10123    }
10124    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10125        Self {
10126            id: id.into(),
10127            status: "WARN".into(),
10128            detail: detail.into(),
10129            fix: Some(fix.into()),
10130        }
10131    }
10132    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10133        Self {
10134            id: id.into(),
10135            status: "FAIL".into(),
10136            detail: detail.into(),
10137            fix: Some(fix.into()),
10138        }
10139    }
10140}
10141
10142/// `wire doctor` — single-command diagnostic for the silent-fail classes
10143/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
10144/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
10145/// so operators don't have to know where each lives.
10146fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
10147    let checks: Vec<DoctorCheck> = vec![
10148        check_daemon_health(),
10149        check_daemon_pid_consistency(),
10150        check_relay_reachable(),
10151        check_pair_rejections(recent_rejections),
10152        check_cursor_progress(),
10153    ];
10154
10155    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
10156    let warns = checks.iter().filter(|c| c.status == "WARN").count();
10157
10158    if as_json {
10159        println!(
10160            "{}",
10161            serde_json::to_string(&json!({
10162                "checks": checks,
10163                "fail_count": fails,
10164                "warn_count": warns,
10165                "ok": fails == 0,
10166            }))?
10167        );
10168    } else {
10169        println!("wire doctor — {} checks", checks.len());
10170        for c in &checks {
10171            let bullet = match c.status.as_str() {
10172                "PASS" => "✓",
10173                "WARN" => "!",
10174                "FAIL" => "✗",
10175                _ => "?",
10176            };
10177            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
10178            if let Some(fix) = &c.fix {
10179                println!("      fix: {fix}");
10180            }
10181        }
10182        println!();
10183        if fails == 0 && warns == 0 {
10184            println!("ALL GREEN");
10185        } else {
10186            println!("{fails} FAIL, {warns} WARN");
10187        }
10188    }
10189
10190    if fails > 0 {
10191        std::process::exit(1);
10192    }
10193    Ok(())
10194}
10195
10196/// Check: daemon running, exactly one instance, no orphans.
10197///
10198/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
10199/// days, advancing cursor without pinning). `wire status` lied about it.
10200/// `wire doctor` must catch THIS class: multiple daemons running, OR
10201/// pid-file claims daemon down while a process is actually up.
10202fn check_daemon_health() -> DoctorCheck {
10203    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
10204    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
10205    // hardening): every surface routes through ensure_up::daemon_liveness
10206    // so they share one view of the world. No more parallel liveness
10207    // logic to drift out of sync.
10208    let snap = crate::ensure_up::daemon_liveness();
10209    let pgrep_pids = &snap.pgrep_pids;
10210    let pidfile_pid = snap.pidfile_pid;
10211    let pidfile_alive = snap.pidfile_alive;
10212    let orphan_pids = &snap.orphan_pids;
10213
10214    let fmt_pids = |xs: &[u32]| -> String {
10215        xs.iter()
10216            .map(|p| p.to_string())
10217            .collect::<Vec<_>>()
10218            .join(", ")
10219    };
10220
10221    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
10222        (0, _, _) => DoctorCheck::fail(
10223            "daemon",
10224            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
10225            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
10226        ),
10227        // Single daemon AND it matches the pidfile → healthy.
10228        (1, true, true) => DoctorCheck::pass(
10229            "daemon",
10230            format!(
10231                "one daemon running (pid {}, matches pidfile)",
10232                pgrep_pids[0]
10233            ),
10234        ),
10235        // Pidfile is alive but pgrep ALSO sees orphan processes.
10236        (n, true, false) => DoctorCheck::fail(
10237            "daemon",
10238            format!(
10239                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
10240                 The orphans race the relay cursor — they advance past events your current binary can't process. \
10241                 (Issue #2 exact class.)",
10242                fmt_pids(pgrep_pids),
10243                pidfile_pid.unwrap(),
10244                fmt_pids(orphan_pids),
10245            ),
10246            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
10247        ),
10248        // Pidfile is dead but processes ARE running → all are orphans.
10249        (n, false, _) => DoctorCheck::fail(
10250            "daemon",
10251            format!(
10252                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
10253                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
10254                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
10255                fmt_pids(pgrep_pids),
10256                match pidfile_pid {
10257                    Some(p) => format!("claims pid {p} which is dead"),
10258                    None => "is missing".to_string(),
10259                },
10260            ),
10261            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
10262        ),
10263        // Multiple daemons all matching … impossible by construction; fall back to warn.
10264        (n, true, true) => DoctorCheck::warn(
10265            "daemon",
10266            format!(
10267                "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
10268                fmt_pids(pgrep_pids)
10269            ),
10270            "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
10271        ),
10272    }
10273}
10274
10275/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
10276/// check. Surfaces version mismatch (daemon running old binary text in
10277/// memory under a current symlink — today's exact bug class), schema
10278/// drift (future format bumps), and identity contamination (daemon's
10279/// recorded DID doesn't match this box's configured DID).
10280///
10281/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
10282/// JSON pid record whose recorded `pid` is no longer a live OS process.
10283/// Pre-hardening this check PASSed in that state (it only validated
10284/// content, not liveness), letting `wire status: DOWN` and
10285/// `wire doctor: PASS` disagree for 25 min in incident #2.
10286fn check_daemon_pid_consistency() -> DoctorCheck {
10287    let snap = crate::ensure_up::daemon_liveness();
10288    match &snap.record {
10289        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
10290            "daemon_pid_consistency",
10291            "no daemon.pid yet — fresh box or daemon never started",
10292        ),
10293        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
10294            "daemon_pid_consistency",
10295            format!("daemon.pid is corrupt: {reason}"),
10296            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
10297        ),
10298        crate::ensure_up::PidRecord::LegacyInt(pid) => {
10299            // Legacy pidfile: still surface liveness so a dead legacy pid
10300            // doesn't quietly PASS this check while status says DOWN.
10301            let pid = *pid;
10302            if !crate::ensure_up::pid_is_alive(pid) {
10303                return DoctorCheck::warn(
10304                    "daemon_pid_consistency",
10305                    format!(
10306                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
10307                         Stale pidfile from a crashed pre-0.5.11 daemon. \
10308                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
10309                    ),
10310                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
10311                );
10312            }
10313            DoctorCheck::warn(
10314                "daemon_pid_consistency",
10315                format!(
10316                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
10317                     Daemon was started by a pre-0.5.11 binary."
10318                ),
10319                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
10320            )
10321        }
10322        crate::ensure_up::PidRecord::Json(d) => {
10323            // v0.5.19 liveness gate: if the recorded pid is dead, the
10324            // pidfile is stale and the rest of the content drift checks
10325            // are moot — `wire upgrade` is the answer regardless.
10326            if !snap.pidfile_alive {
10327                return DoctorCheck::warn(
10328                    "daemon_pid_consistency",
10329                    format!(
10330                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
10331                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
10332                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
10333                        pid = d.pid,
10334                        version = d.version,
10335                    ),
10336                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
10337                     (kills any orphan daemon advancing the cursor without coordination)",
10338                );
10339            }
10340            let mut issues: Vec<String> = Vec::new();
10341            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
10342                issues.push(format!(
10343                    "schema={} (expected {})",
10344                    d.schema,
10345                    crate::ensure_up::DAEMON_PID_SCHEMA
10346                ));
10347            }
10348            let cli_version = env!("CARGO_PKG_VERSION");
10349            if d.version != cli_version {
10350                issues.push(format!("version daemon={} cli={cli_version}", d.version));
10351            }
10352            if !std::path::Path::new(&d.bin_path).exists() {
10353                issues.push(format!("bin_path {} missing on disk", d.bin_path));
10354            }
10355            // Cross-check DID + relay against current config (best-effort).
10356            if let Ok(card) = config::read_agent_card()
10357                && let Some(current_did) = card.get("did").and_then(Value::as_str)
10358                && let Some(recorded_did) = &d.did
10359                && recorded_did != current_did
10360            {
10361                issues.push(format!(
10362                    "did daemon={recorded_did} config={current_did} — identity drift"
10363                ));
10364            }
10365            if let Ok(state) = config::read_relay_state()
10366                && let Some(current_relay) = state
10367                    .get("self")
10368                    .and_then(|s| s.get("relay_url"))
10369                    .and_then(Value::as_str)
10370                && let Some(recorded_relay) = &d.relay_url
10371                && recorded_relay != current_relay
10372            {
10373                issues.push(format!(
10374                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
10375                ));
10376            }
10377            if issues.is_empty() {
10378                DoctorCheck::pass(
10379                    "daemon_pid_consistency",
10380                    format!(
10381                        "daemon v{} bound to {} as {}",
10382                        d.version,
10383                        d.relay_url.as_deref().unwrap_or("?"),
10384                        d.did.as_deref().unwrap_or("?")
10385                    ),
10386                )
10387            } else {
10388                DoctorCheck::warn(
10389                    "daemon_pid_consistency",
10390                    format!("daemon pidfile drift: {}", issues.join("; ")),
10391                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
10392                )
10393            }
10394        }
10395    }
10396}
10397
10398/// Check: bound relay's /healthz returns 200.
10399fn check_relay_reachable() -> DoctorCheck {
10400    let state = match config::read_relay_state() {
10401        Ok(s) => s,
10402        Err(e) => {
10403            return DoctorCheck::fail(
10404                "relay",
10405                format!("could not read relay state: {e}"),
10406                "run `wire up <handle>@<relay>` to bootstrap",
10407            );
10408        }
10409    };
10410    let url = state
10411        .get("self")
10412        .and_then(|s| s.get("relay_url"))
10413        .and_then(Value::as_str)
10414        .unwrap_or("");
10415    if url.is_empty() {
10416        return DoctorCheck::warn(
10417            "relay",
10418            "no relay bound — wire send/pull will not work",
10419            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
10420        );
10421    }
10422    let client = crate::relay_client::RelayClient::new(url);
10423    match client.check_healthz() {
10424        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
10425        Err(e) => DoctorCheck::fail(
10426            "relay",
10427            format!("{url} unreachable: {e}"),
10428            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
10429        ),
10430    }
10431}
10432
10433/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
10434/// entry there is a silent failure that, pre-0.5.11, would have left the
10435/// operator wondering why pairing didn't complete.
10436fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
10437    let path = match config::state_dir() {
10438        Ok(d) => d.join("pair-rejected.jsonl"),
10439        Err(e) => {
10440            return DoctorCheck::warn(
10441                "pair_rejections",
10442                format!("could not resolve state dir: {e}"),
10443                "set WIRE_HOME or fix XDG_STATE_HOME",
10444            );
10445        }
10446    };
10447    if !path.exists() {
10448        return DoctorCheck::pass(
10449            "pair_rejections",
10450            "no pair-rejected.jsonl — no recorded pair failures",
10451        );
10452    }
10453    let body = match std::fs::read_to_string(&path) {
10454        Ok(b) => b,
10455        Err(e) => {
10456            return DoctorCheck::warn(
10457                "pair_rejections",
10458                format!("could not read {path:?}: {e}"),
10459                "check file permissions",
10460            );
10461        }
10462    };
10463    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
10464    if lines.is_empty() {
10465        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
10466    }
10467    let total = lines.len();
10468    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
10469    let mut summary: Vec<String> = Vec::new();
10470    for line in &recent {
10471        if let Ok(rec) = serde_json::from_str::<Value>(line) {
10472            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
10473            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
10474            summary.push(format!("{peer}/{code}"));
10475        }
10476    }
10477    DoctorCheck::warn(
10478        "pair_rejections",
10479        format!(
10480            "{total} pair failures recorded. recent: [{}]",
10481            summary.join(", ")
10482        ),
10483        format!(
10484            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
10485        ),
10486    )
10487}
10488
10489/// Check: cursor isn't stuck. We can't tell without polling — but we can
10490/// report the current cursor position so operators see if it changes.
10491/// Real "stuck" detection needs two pulls separated in time; defer that
10492/// behaviour to a `wire doctor --watch` mode.
10493fn check_cursor_progress() -> DoctorCheck {
10494    let state = match config::read_relay_state() {
10495        Ok(s) => s,
10496        Err(e) => {
10497            return DoctorCheck::warn(
10498                "cursor",
10499                format!("could not read relay state: {e}"),
10500                "check ~/Library/Application Support/wire/relay.json",
10501            );
10502        }
10503    };
10504    let cursor = state
10505        .get("self")
10506        .and_then(|s| s.get("last_pulled_event_id"))
10507        .and_then(Value::as_str)
10508        .map(|s| s.chars().take(16).collect::<String>())
10509        .unwrap_or_else(|| "<none>".to_string());
10510    DoctorCheck::pass(
10511        "cursor",
10512        format!(
10513            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
10514        ),
10515    )
10516}
10517
10518#[cfg(test)]
10519mod doctor_tests {
10520    use super::*;
10521
10522    #[test]
10523    fn doctor_check_constructors_set_status_correctly() {
10524        // Silent-fail-prevention rule: pass/warn/fail must be visibly
10525        // distinguishable to operators. If any constructor lets the wrong
10526        // status through, `wire doctor` lies and we're back to today's
10527        // 30-minute debug.
10528        let p = DoctorCheck::pass("x", "ok");
10529        assert_eq!(p.status, "PASS");
10530        assert_eq!(p.fix, None);
10531
10532        let w = DoctorCheck::warn("x", "watch out", "do this");
10533        assert_eq!(w.status, "WARN");
10534        assert_eq!(w.fix, Some("do this".to_string()));
10535
10536        let f = DoctorCheck::fail("x", "broken", "fix it");
10537        assert_eq!(f.status, "FAIL");
10538        assert_eq!(f.fix, Some("fix it".to_string()));
10539    }
10540
10541    #[test]
10542    fn check_pair_rejections_no_file_is_pass() {
10543        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
10544        // as a problem.
10545        config::test_support::with_temp_home(|| {
10546            config::ensure_dirs().unwrap();
10547            let c = check_pair_rejections(5);
10548            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
10549        });
10550    }
10551
10552    #[test]
10553    fn check_pair_rejections_with_entries_warns() {
10554        // Existence of rejections is itself a signal — even if each entry
10555        // is a "known good failure," the operator wants to know they
10556        // happened.
10557        config::test_support::with_temp_home(|| {
10558            config::ensure_dirs().unwrap();
10559            crate::pair_invite::record_pair_rejection(
10560                "willard",
10561                "pair_drop_ack_send_failed",
10562                "POST 502",
10563            );
10564            let c = check_pair_rejections(5);
10565            assert_eq!(c.status, "WARN");
10566            assert!(c.detail.contains("1 pair failures"));
10567            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
10568        });
10569    }
10570}
10571
10572// ---------- up megacommand (full bootstrap) ----------
10573
10574/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
10575/// pair. Composes the steps that today's onboarding walks operators through
10576/// one by one (init / bind-relay / claim / background daemon / arm monitor
10577/// recipe). Idempotent: every step checks current state and skips if done.
10578///
10579/// Argument parsing accepts:
10580///   - `<nick>@<relay-host>` — explicit relay
10581///   - `<nick>`              — defaults to wireup.net (the configured
10582///     public relay)
10583fn cmd_up(
10584    relay_arg: Option<&str>,
10585    name: Option<&str>,
10586    with_local: Option<&str>,
10587    no_local: bool,
10588    as_json: bool,
10589) -> Result<()> {
10590    // No nick to parse — your handle is your DID-derived persona (one-name
10591    // rule). The optional arg is only which relay to bind/claim on. Accepts
10592    // `@host`, bare `host`, or a full URL; defaults to the public relay.
10593    let relay_url = match relay_arg {
10594        Some(r) => {
10595            let r = r.trim_start_matches('@');
10596            if r.starts_with("http://") || r.starts_with("https://") {
10597                r.to_string()
10598            } else {
10599                format!("https://{r}")
10600            }
10601        }
10602        None => crate::pair_invite::DEFAULT_RELAY.to_string(),
10603    };
10604
10605    let mut report: Vec<(String, String)> = Vec::new();
10606    let mut step = |stage: &str, detail: String| {
10607        report.push((stage.to_string(), detail.clone()));
10608        if !as_json {
10609            eprintln!("wire up: {stage} — {detail}");
10610        }
10611    };
10612
10613    // 1. init (or note existing identity). No typed name — cmd_init(None)
10614    // generates the persona from the freshly-minted keypair (one-name rule).
10615    if config::is_initialized()? {
10616        step("init", "already initialized".to_string());
10617    } else {
10618        cmd_init(
10619            None,
10620            name,
10621            Some(&relay_url),
10622            false,
10623            /* as_json */ false,
10624        )?;
10625        step("init", format!("created identity bound to {relay_url}"));
10626    }
10627
10628    // Canonical persona handle — the one name we claim and are addressed by.
10629    let canonical = {
10630        let card = config::read_agent_card()?;
10631        let did = card.get("did").and_then(Value::as_str).unwrap_or("");
10632        crate::agent_card::display_handle_from_did(did).to_string()
10633    };
10634    step("identity", format!("persona is `{canonical}`"));
10635
10636    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
10637    // already initialized we may need to bind to the requested relay
10638    // separately (operator switched relays).
10639    let relay_state = config::read_relay_state()?;
10640    let bound_relay = relay_state
10641        .get("self")
10642        .and_then(|s| s.get("relay_url"))
10643        .and_then(Value::as_str)
10644        .unwrap_or("")
10645        .to_string();
10646    if bound_relay.is_empty() {
10647        // Identity exists but never bound to a relay — bind now.
10648        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
10649        // Pass `false` so the safety check kicks in if state was non-empty.
10650        cmd_bind_relay(
10651            &relay_url, /* scope */ None, // infer from URL (federation for wireup.net)
10652            /* replace */ false, /* migrate_pinned */ false, /* as_json */ false,
10653        )?;
10654        step("bind-relay", format!("bound to {relay_url}"));
10655    } else if bound_relay != relay_url {
10656        step(
10657            "bind-relay",
10658            format!(
10659                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
10660                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
10661            ),
10662        );
10663    } else {
10664        step("bind-relay", format!("already bound to {bound_relay}"));
10665    }
10666
10667    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
10668    // re-claims are accepted by the relay.
10669    match cmd_claim(
10670        &canonical,
10671        Some(&relay_url),
10672        None,
10673        /* hidden */ false,
10674        /* as_json */ false,
10675    ) {
10676        Ok(()) => step(
10677            "claim",
10678            format!("{canonical}@{} claimed", strip_proto(&relay_url)),
10679        ),
10680        Err(e) => step(
10681            "claim",
10682            format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
10683        ),
10684    }
10685
10686    // 3b. Opportunistic local dual-slot (additive). Gives same-box sister
10687    // sessions sub-millisecond loopback routing alongside the federation
10688    // slot. Local relays carry no handle directory — nothing to claim
10689    // there; sister discovery is via `wire session list-local`.
10690    if no_local {
10691        step("local-slot", "skipped (--no-local)".to_string());
10692    } else {
10693        let local_url = with_local
10694            .unwrap_or("http://127.0.0.1:8771")
10695            .trim_end_matches('/');
10696        let already_local = crate::endpoints::self_endpoints(
10697            &config::read_relay_state().unwrap_or_else(|_| json!({})),
10698        )
10699        .iter()
10700        .any(|e| e.relay_url == local_url);
10701        if relay_url.trim_end_matches('/') == local_url || already_local {
10702            step("local-slot", "already covered".to_string());
10703        } else if crate::relay_client::RelayClient::new(local_url)
10704            .check_healthz()
10705            .is_ok()
10706        {
10707            match cmd_bind_relay(
10708                local_url,
10709                Some("local"),
10710                /* replace */ false,
10711                /* migrate_pinned */ false,
10712                /* as_json */ false,
10713            ) {
10714                Ok(()) => step(
10715                    "local-slot",
10716                    format!("dual-bound local relay {local_url} for sister routing"),
10717                ),
10718                Err(e) => step("local-slot", format!("skipped local relay: {e}")),
10719            }
10720        } else {
10721            step(
10722                "local-slot",
10723                format!(
10724                    "no local relay reachable at {local_url} — federation only \
10725                     (sisters resolve via session-list)"
10726                ),
10727            );
10728        }
10729    }
10730
10731    // 4. Background daemon — must be running for pull/push/ack to flow.
10732    match crate::ensure_up::ensure_daemon_running() {
10733        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
10734        Ok(false) => step("daemon", "already running".to_string()),
10735        Err(e) => step(
10736            "daemon",
10737            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
10738        ),
10739    }
10740
10741    // 5. Final summary — point operator at the next commands.
10742    let summary =
10743        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
10744         `wire monitor` to watch incoming events."
10745            .to_string();
10746    step("ready", summary.clone());
10747
10748    if as_json {
10749        let steps_json: Vec<_> = report
10750            .iter()
10751            .map(|(k, v)| json!({"stage": k, "detail": v}))
10752            .collect();
10753        println!(
10754            "{}",
10755            serde_json::to_string(&json!({
10756                "nick": canonical,
10757                "relay": relay_url,
10758                "steps": steps_json,
10759            }))?
10760        );
10761    }
10762    Ok(())
10763}
10764
10765/// Strip http:// or https:// prefix for display in `wire up` step output.
10766fn strip_proto(url: &str) -> String {
10767    url.trim_start_matches("https://")
10768        .trim_start_matches("http://")
10769        .to_string()
10770}
10771
10772// ---------- pair megacommand (zero-paste handle-based) ----------
10773
10774/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
10775/// the handle is in `nick@domain` form. Wraps:
10776///
10777///   1. cmd_add — resolve, pin, drop intro
10778///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
10779///      (signalled by `peers.<handle>.slot_token` populating in relay state)
10780///   3. Verify bilateral pin: trust contains peer + relay state has token
10781///   4. Print final state — both sides VERIFIED + can `wire send`
10782///
10783/// On timeout: hard-errors with the specific stuck step so the operator
10784/// knows which side to chase. No silent partial success.
10785fn cmd_pair_megacommand(
10786    handle_arg: &str,
10787    relay_override: Option<&str>,
10788    timeout_secs: u64,
10789    _as_json: bool,
10790) -> Result<()> {
10791    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
10792    let peer_handle = parsed.nick.clone();
10793
10794    eprintln!("wire pair: resolving {handle_arg}...");
10795    cmd_add(
10796        handle_arg,
10797        relay_override,
10798        /* local_sister */ false,
10799        /* as_json */ false,
10800    )?;
10801
10802    eprintln!(
10803        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
10804         to ack (their daemon must be running + pulling)..."
10805    );
10806
10807    // Trigger an immediate daemon-style pull so we don't wait the full daemon
10808    // interval. Best-effort — if it fails, we still fall through to the
10809    // polling loop.
10810    let _ = run_sync_pull();
10811
10812    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
10813    let poll_interval = std::time::Duration::from_millis(500);
10814
10815    loop {
10816        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
10817        let _ = run_sync_pull();
10818        let relay_state = config::read_relay_state()?;
10819        let peer_entry = relay_state
10820            .get("peers")
10821            .and_then(|p| p.get(&peer_handle))
10822            .cloned();
10823        let token = peer_entry
10824            .as_ref()
10825            .and_then(|e| e.get("slot_token"))
10826            .and_then(Value::as_str)
10827            .unwrap_or("");
10828
10829        if !token.is_empty() {
10830            // Bilateral pin complete — we have their slot_token, we can send.
10831            let trust = config::read_trust()?;
10832            let pinned_in_trust = trust
10833                .get("agents")
10834                .and_then(|a| a.get(&peer_handle))
10835                .is_some();
10836            println!(
10837                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
10838                if pinned_in_trust {
10839                    "VERIFIED"
10840                } else {
10841                    "MISSING (bug)"
10842                }
10843            );
10844            return Ok(());
10845        }
10846
10847        if std::time::Instant::now() >= deadline {
10848            // Timeout — surface the EXACT stuck step. Likely culprits:
10849            //   - peer daemon not running on their box
10850            //   - peer's relay slot is offline
10851            //   - their daemon is on an older binary that doesn't know
10852            //     pair_drop kind=1100 (the P0.1 class — now visible via
10853            //     wire pull --json on their side as a blocking rejection)
10854            bail!(
10855                "wire pair: timed out after {timeout_secs}s. \
10856                 peer {peer_handle} never sent pair_drop_ack. \
10857                 likely causes: (a) their daemon is down — ask them to run \
10858                 `wire status` and `wire daemon &`; (b) their binary is older \
10859                 than 0.5.x and doesn't understand pair_drop events — ask \
10860                 them to `wire upgrade`; (c) network / relay blip — re-run \
10861                 `wire pair {handle_arg}` to retry."
10862            );
10863        }
10864
10865        std::thread::sleep(poll_interval);
10866    }
10867}
10868
10869fn cmd_claim(
10870    nick: &str,
10871    relay_override: Option<&str>,
10872    public_url: Option<&str>,
10873    hidden: bool,
10874    as_json: bool,
10875) -> Result<()> {
10876    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
10877    // + claim handle. Operator should never have to run init/bind-relay first.
10878    let (_did, relay_url, slot_id, slot_token) =
10879        crate::pair_invite::ensure_self_with_relay(relay_override)?;
10880    let card = config::read_agent_card()?;
10881
10882    // v0.13.1 one-name enforcement: the handle you claim in the phonebook
10883    // MUST equal your DID-derived persona, so the directory entry can never
10884    // drift from your agent-card handle. A typed nick that differs is ignored
10885    // (mirrors how `wire init` coerces the typed name). This closes the
10886    // claim-path reopening of the v0.11 "two names" footgun — before this,
10887    // `wire claim coffee-ghost` published coffee-ghost@relay -> your DID while
10888    // your card said e.g. outback-sandpiper. The typed `nick` arg is now
10889    // vestigial, exactly like the one `wire init` / `wire up` already accept.
10890    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
10891    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
10892    if !canonical.is_empty() && nick != canonical && !as_json {
10893        eprintln!(
10894            "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
10895        );
10896    }
10897    let nick = if canonical.is_empty() {
10898        nick
10899    } else {
10900        canonical.as_str()
10901    };
10902    if !crate::pair_profile::is_valid_nick(nick) {
10903        bail!(
10904            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
10905        );
10906    }
10907
10908    let client = crate::relay_client::RelayClient::new(&relay_url);
10909    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
10910    // (back-compat); Some(false) for `--hidden`. Relays older than
10911    // v0.5.19 ignore the field, so this is safe to always send.
10912    let discoverable = if hidden { Some(false) } else { None };
10913    let resp =
10914        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
10915
10916    if as_json {
10917        println!(
10918            "{}",
10919            serde_json::to_string(&json!({
10920                "nick": nick,
10921                "relay": relay_url,
10922                "response": resp,
10923            }))?
10924        );
10925    } else {
10926        // Best-effort: derive the public domain from the relay URL. If
10927        // operator passed --public-url that's the canonical address; else
10928        // the relay URL itself. Falls back to a placeholder if both miss.
10929        let domain = public_url
10930            .unwrap_or(&relay_url)
10931            .trim_start_matches("https://")
10932            .trim_start_matches("http://")
10933            .trim_end_matches('/')
10934            .split('/')
10935            .next()
10936            .unwrap_or("<this-relay-domain>")
10937            .to_string();
10938        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
10939        println!("verify with: wire whois {nick}@{domain}");
10940    }
10941    Ok(())
10942}
10943
10944fn cmd_profile(action: ProfileAction) -> Result<()> {
10945    match action {
10946        ProfileAction::Set { field, value, json } => {
10947            // Try parsing the value as JSON; if that fails, treat it as a
10948            // bare string. Lets operators pass either `42` or `"hello"` or
10949            // `["rust","late-night"]` without quoting hell.
10950            let parsed: Value =
10951                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
10952            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
10953            if json {
10954                println!(
10955                    "{}",
10956                    serde_json::to_string(&json!({
10957                        "field": field,
10958                        "profile": new_profile,
10959                    }))?
10960                );
10961            } else {
10962                println!("profile.{field} set");
10963            }
10964        }
10965        ProfileAction::Get { json } => return cmd_whois(None, json, None),
10966        ProfileAction::Clear { field, json } => {
10967            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
10968            if json {
10969                println!(
10970                    "{}",
10971                    serde_json::to_string(&json!({
10972                        "field": field,
10973                        "cleared": true,
10974                        "profile": new_profile,
10975                    }))?
10976                );
10977            } else {
10978                println!("profile.{field} cleared");
10979            }
10980        }
10981    }
10982    Ok(())
10983}
10984
10985// ---------- setup — one-shot MCP host registration ----------
10986
10987fn cmd_setup(apply: bool) -> Result<()> {
10988    use std::path::PathBuf;
10989
10990    let entry = json!({"command": "wire", "args": ["mcp"]});
10991    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
10992
10993    // Detect probable MCP host config locations. Cross-platform — we only
10994    // touch the file if it already exists OR --apply was passed.
10995    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
10996    if let Some(home) = dirs::home_dir() {
10997        // Claude Code (CLI) — real config path is ~/.claude.json on all platforms (Linux/macOS/Windows).
10998        // The mcpServers map lives at the top level of that file.
10999        targets.push(("Claude Code", home.join(".claude.json")));
11000        // Legacy / alternate Claude Code XDG path — still try, harmless if absent.
11001        targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
11002        // Claude Desktop macOS
11003        #[cfg(target_os = "macos")]
11004        targets.push((
11005            "Claude Desktop (macOS)",
11006            home.join("Library/Application Support/Claude/claude_desktop_config.json"),
11007        ));
11008        // Claude Desktop Windows
11009        #[cfg(target_os = "windows")]
11010        if let Ok(appdata) = std::env::var("APPDATA") {
11011            targets.push((
11012                "Claude Desktop (Windows)",
11013                PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
11014            ));
11015        }
11016        // Cursor
11017        targets.push(("Cursor", home.join(".cursor/mcp.json")));
11018    }
11019    // Project-local — works for several MCP-aware tools
11020    targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
11021
11022    println!("wire setup\n");
11023    println!("MCP server snippet (add this to your client's mcpServers):");
11024    println!();
11025    println!("{entry_pretty}");
11026    println!();
11027
11028    if !apply {
11029        println!("Probable MCP host config locations on this machine:");
11030        for (name, path) in &targets {
11031            let marker = if path.exists() {
11032                "✓ found"
11033            } else {
11034                "  (would create)"
11035            };
11036            println!("  {marker:14}  {name}: {}", path.display());
11037        }
11038        println!();
11039        println!("Run `wire setup --apply` to merge wire into each config above.");
11040        println!(
11041            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
11042        );
11043        return Ok(());
11044    }
11045
11046    let mut modified: Vec<String> = Vec::new();
11047    let mut skipped: Vec<String> = Vec::new();
11048    for (name, path) in &targets {
11049        match upsert_mcp_entry(path, "wire", &entry) {
11050            Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
11051            Ok(false) => skipped.push(format!("  {name} ({}): already configured", path.display())),
11052            Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
11053        }
11054    }
11055    if !modified.is_empty() {
11056        println!("Modified:");
11057        for line in &modified {
11058            println!("  {line}");
11059        }
11060        println!();
11061        println!("Restart the app(s) above to load wire MCP.");
11062    }
11063    if !skipped.is_empty() {
11064        println!();
11065        println!("Skipped:");
11066        for line in &skipped {
11067            println!("  {line}");
11068        }
11069    }
11070    Ok(())
11071}
11072
11073/// Idempotent merge of an `mcpServers.<name>` entry into a JSON config file.
11074/// Returns Ok(true) if file was changed, Ok(false) if entry already matched.
11075fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
11076    let mut cfg: Value = if path.exists() {
11077        let body = std::fs::read_to_string(path).context("reading config")?;
11078        serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
11079    } else {
11080        json!({})
11081    };
11082    if !cfg.is_object() {
11083        cfg = json!({});
11084    }
11085    let root = cfg.as_object_mut().unwrap();
11086    let servers = root
11087        .entry("mcpServers".to_string())
11088        .or_insert_with(|| json!({}));
11089    if !servers.is_object() {
11090        *servers = json!({});
11091    }
11092    let map = servers.as_object_mut().unwrap();
11093    if map.get(server_name) == Some(entry) {
11094        return Ok(false);
11095    }
11096    map.insert(server_name.to_string(), entry.clone());
11097    if let Some(parent) = path.parent()
11098        && !parent.as_os_str().is_empty()
11099    {
11100        std::fs::create_dir_all(parent).context("creating parent dir")?;
11101    }
11102    let out = serde_json::to_string_pretty(&cfg)? + "\n";
11103    std::fs::write(path, out).context("writing config")?;
11104    Ok(true)
11105}
11106
11107// ---------- reactor — event-handler dispatch loop ----------
11108
11109#[allow(clippy::too_many_arguments)]
11110fn cmd_reactor(
11111    on_event: &str,
11112    peer_filter: Option<&str>,
11113    kind_filter: Option<&str>,
11114    verified_only: bool,
11115    interval_secs: u64,
11116    once: bool,
11117    dry_run: bool,
11118    max_per_minute: u32,
11119    max_chain_depth: u32,
11120) -> Result<()> {
11121    use crate::inbox_watch::{InboxEvent, InboxWatcher};
11122    use std::collections::{HashMap, HashSet, VecDeque};
11123    use std::io::Write;
11124    use std::process::{Command, Stdio};
11125    use std::time::{Duration, Instant};
11126
11127    let cursor_path = config::state_dir()?.join("reactor.cursor");
11128    // event_ids THIS reactor's handler has caused to be sent (via wire send).
11129    // Used by chain-depth check — an incoming `(re:X)` where X is in this set
11130    // means peer is replying to something we just said → don't reply back.
11131    //
11132    // Persisted across restarts so a reactor that crashes mid-conversation
11133    // doesn't re-enter the loop. Reads on startup, writes after each
11134    // outbox-grow detection. Capped at 500 entries (LRU-ish — old entries
11135    // dropped from front of file).
11136    let emitted_path = config::state_dir()?.join("reactor-emitted.log");
11137    let mut emitted_ids: HashSet<String> = HashSet::new();
11138    if emitted_path.exists()
11139        && let Ok(body) = std::fs::read_to_string(&emitted_path)
11140    {
11141        for line in body.lines() {
11142            let t = line.trim();
11143            if !t.is_empty() {
11144                emitted_ids.insert(t.to_string());
11145            }
11146        }
11147    }
11148    // Outbox file paths the reactor watches for new sent-event_ids.
11149    let outbox_dir = config::outbox_dir()?;
11150    // (peer → file size we've already scanned). Lets us notice new outbox
11151    // appends without re-reading the whole file each sweep.
11152    let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
11153
11154    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11155
11156    let kind_num: Option<u32> = match kind_filter {
11157        Some(k) => Some(parse_kind(k)?),
11158        None => None,
11159    };
11160
11161    // Per-peer sliding window of dispatch instants for rate-limit check.
11162    let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
11163
11164    let dispatch = |ev: &InboxEvent,
11165                    peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
11166                    emitted_ids: &HashSet<String>|
11167     -> Result<bool> {
11168        if let Some(p) = peer_filter
11169            && ev.peer != p
11170        {
11171            return Ok(false);
11172        }
11173        if verified_only && !ev.verified {
11174            return Ok(false);
11175        }
11176        if let Some(want) = kind_num {
11177            let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
11178            if ev_kind != Some(want) {
11179                return Ok(false);
11180            }
11181        }
11182
11183        // Chain-depth check: if the body contains `(re:<event_id>)` and that
11184        // event_id is in our emitted set, this is a reply to one of our
11185        // replies → loop suspected, skip.
11186        if max_chain_depth > 0 {
11187            let body_str = match &ev.raw["body"] {
11188                Value::String(s) => s.clone(),
11189                other => serde_json::to_string(other).unwrap_or_default(),
11190            };
11191            if let Some(referenced) = parse_re_marker(&body_str) {
11192                // Handler scripts usually truncate event_id (e.g. ${ID:0:12}).
11193                // Match emitted set by prefix to catch both full + truncated.
11194                let matched = emitted_ids.contains(&referenced)
11195                    || emitted_ids.iter().any(|full| full.starts_with(&referenced));
11196                if matched {
11197                    eprintln!(
11198                        "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
11199                        ev.event_id, ev.peer, referenced
11200                    );
11201                    return Ok(false);
11202                }
11203            }
11204        }
11205
11206        // Per-peer rate-limit check (sliding 60s window).
11207        if max_per_minute > 0 {
11208            let now = Instant::now();
11209            let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
11210            while let Some(&front) = win.front() {
11211                if now.duration_since(front) > Duration::from_secs(60) {
11212                    win.pop_front();
11213                } else {
11214                    break;
11215                }
11216            }
11217            if win.len() as u32 >= max_per_minute {
11218                eprintln!(
11219                    "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
11220                    ev.event_id, ev.peer, max_per_minute
11221                );
11222                return Ok(false);
11223            }
11224            win.push_back(now);
11225        }
11226
11227        if dry_run {
11228            println!("{}", serde_json::to_string(&ev.raw)?);
11229            return Ok(true);
11230        }
11231
11232        let mut child = Command::new("sh")
11233            .arg("-c")
11234            .arg(on_event)
11235            .stdin(Stdio::piped())
11236            .stdout(Stdio::inherit())
11237            .stderr(Stdio::inherit())
11238            .env("WIRE_EVENT_PEER", &ev.peer)
11239            .env("WIRE_EVENT_ID", &ev.event_id)
11240            .env("WIRE_EVENT_KIND", &ev.kind)
11241            .spawn()
11242            .with_context(|| format!("spawning reactor handler: {on_event}"))?;
11243        if let Some(mut stdin) = child.stdin.take() {
11244            let body = serde_json::to_vec(&ev.raw)?;
11245            let _ = stdin.write_all(&body);
11246            let _ = stdin.write_all(b"\n");
11247        }
11248        std::mem::drop(child);
11249        Ok(true)
11250    };
11251
11252    // Scan outbox files for newly-appended event_ids and add to emitted set.
11253    let scan_outbox = |emitted_ids: &mut HashSet<String>,
11254                       outbox_cursors: &mut HashMap<String, u64>|
11255     -> Result<usize> {
11256        if !outbox_dir.exists() {
11257            return Ok(0);
11258        }
11259        let mut added = 0;
11260        let mut new_ids: Vec<String> = Vec::new();
11261        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
11262            let path = entry.path();
11263            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
11264                continue;
11265            }
11266            let peer = match path.file_stem().and_then(|s| s.to_str()) {
11267                Some(s) => s.to_string(),
11268                None => continue,
11269            };
11270            let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
11271            let start = *outbox_cursors.get(&peer).unwrap_or(&0);
11272            if cur_len <= start {
11273                outbox_cursors.insert(peer, start);
11274                continue;
11275            }
11276            let body = std::fs::read_to_string(&path).unwrap_or_default();
11277            let tail = &body[start as usize..];
11278            for line in tail.lines() {
11279                if let Ok(v) = serde_json::from_str::<Value>(line)
11280                    && let Some(eid) = v.get("event_id").and_then(Value::as_str)
11281                    && emitted_ids.insert(eid.to_string())
11282                {
11283                    new_ids.push(eid.to_string());
11284                    added += 1;
11285                }
11286            }
11287            outbox_cursors.insert(peer, cur_len);
11288        }
11289        if !new_ids.is_empty() {
11290            // Append new ids to disk, cap on-disk file at 500 entries.
11291            let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
11292            if all.len() > 500 {
11293                all.sort();
11294                let drop_n = all.len() - 500;
11295                let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
11296                emitted_ids.retain(|x| !dropped.contains(x));
11297                all = emitted_ids.iter().cloned().collect();
11298            }
11299            let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
11300        }
11301        Ok(added)
11302    };
11303
11304    let sweep = |watcher: &mut InboxWatcher,
11305                 emitted_ids: &mut HashSet<String>,
11306                 outbox_cursors: &mut HashMap<String, u64>,
11307                 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
11308     -> Result<usize> {
11309        // Pick up any event_ids we sent since last sweep.
11310        let _ = scan_outbox(emitted_ids, outbox_cursors);
11311
11312        let events = watcher.poll()?;
11313        let mut fired = 0usize;
11314        for ev in &events {
11315            match dispatch(ev, peer_dispatch_log, emitted_ids) {
11316                Ok(true) => fired += 1,
11317                Ok(false) => {}
11318                Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
11319            }
11320        }
11321        watcher.save_cursors(&cursor_path)?;
11322        Ok(fired)
11323    };
11324
11325    if once {
11326        sweep(
11327            &mut watcher,
11328            &mut emitted_ids,
11329            &mut outbox_cursors,
11330            &mut peer_dispatch_log,
11331        )?;
11332        return Ok(());
11333    }
11334    let interval = std::time::Duration::from_secs(interval_secs.max(1));
11335    loop {
11336        if let Err(e) = sweep(
11337            &mut watcher,
11338            &mut emitted_ids,
11339            &mut outbox_cursors,
11340            &mut peer_dispatch_log,
11341        ) {
11342            eprintln!("wire reactor: sweep error: {e}");
11343        }
11344        std::thread::sleep(interval);
11345    }
11346}
11347
11348/// Parse `(re:<event_id>)` marker out of an event body. Returns the
11349/// referenced event_id (full or prefix) if present. Tolerates spaces.
11350fn parse_re_marker(body: &str) -> Option<String> {
11351    let needle = "(re:";
11352    let i = body.find(needle)?;
11353    let rest = &body[i + needle.len()..];
11354    let end = rest.find(')')?;
11355    let id = rest[..end].trim().to_string();
11356    if id.is_empty() {
11357        return None;
11358    }
11359    Some(id)
11360}
11361
11362// ---------- notify (Goal 2) ----------
11363
11364fn cmd_notify(
11365    interval_secs: u64,
11366    peer_filter: Option<&str>,
11367    once: bool,
11368    as_json: bool,
11369) -> Result<()> {
11370    use crate::inbox_watch::InboxWatcher;
11371    let cursor_path = config::state_dir()?.join("notify.cursor");
11372    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11373
11374    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
11375        let events = watcher.poll()?;
11376        for ev in events {
11377            if let Some(p) = peer_filter
11378                && ev.peer != p
11379            {
11380                continue;
11381            }
11382            if as_json {
11383                println!("{}", serde_json::to_string(&ev)?);
11384            } else {
11385                os_notify_inbox_event(&ev);
11386            }
11387        }
11388        watcher.save_cursors(&cursor_path)?;
11389        Ok(())
11390    };
11391
11392    if once {
11393        return sweep(&mut watcher);
11394    }
11395
11396    let interval = std::time::Duration::from_secs(interval_secs.max(1));
11397    loop {
11398        if let Err(e) = sweep(&mut watcher) {
11399            eprintln!("wire notify: sweep error: {e}");
11400        }
11401        std::thread::sleep(interval);
11402    }
11403}
11404
11405fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
11406    let who = persona_label(&ev.peer);
11407    let title = if ev.verified {
11408        format!("wire ← {who}")
11409    } else {
11410        format!("wire ← {who} (UNVERIFIED)")
11411    };
11412    let body = format!("{}: {}", ev.kind, ev.body_preview);
11413    crate::os_notify::toast(&title, &body);
11414}
11415
11416#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
11417fn os_toast(title: &str, body: &str) {
11418    eprintln!("[wire notify] {title}\n  {body}");
11419}
11420
11421// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).