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        /// v0.14.2: opt back into the legacy outbox→daemon-push pipeline.
158        /// By default `wire send` POSTs to the peer's relay slot
159        /// synchronously and returns a real `delivered` / `duplicate` /
160        /// `failed` verdict. With `--queue` the event is appended to
161        /// `<outbox_dir>/<peer>.jsonl` and the daemon's push loop
162        /// drains it later (pre-v0.14.2 behavior). Use for offline
163        /// buffering, batch sends, or pre-pair queueing.
164        #[arg(long)]
165        queue: bool,
166        /// Emit JSON.
167        #[arg(long)]
168        json: bool,
169    },
170    /// v0.8 — "go talk to this name." The one verb operators reach for.
171    ///
172    /// `wire dial <name>` accepts a character nickname (`noble-slate`),
173    /// a session name (`slancha-api`), a card handle, or a DID — whichever
174    /// face you happen to know the peer by. Resolution order:
175    ///
176    /// 1. Already-pinned peer? → no-op (or send if a message was passed).
177    /// 2. Local sister session? → bilateral pair via the disk-read
178    ///    `--local-sister` path (no relay round-trip, no .well-known
179    ///    lookup, no SAS digits).
180    /// 3. Otherwise → bail with a clear hint pointing at federation
181    ///    syntax (`wire dial <handle>@<relay>` for cross-machine peers).
182    ///
183    /// With an optional message, `wire dial <name> "<msg>"` also sends
184    /// the message synchronously after the pair lands (#187 collapsed
185    /// the legacy queue→push step into a single direct relay POST;
186    /// the response carries the actual delivered/duplicate/etc.
187    /// verdict). Idempotent: re-dialling a known peer just sends.
188    Dial {
189        /// Peer name. Character nickname (preferred), session name,
190        /// card handle, or DID — anything that identifies the peer to
191        /// you.
192        name: String,
193        /// Optional first message to send after the pair lands. Same
194        /// semantics as the body argument to `wire send`. Defaults to
195        /// kind=claim.
196        message: Option<String>,
197        /// Emit JSON.
198        #[arg(long)]
199        json: bool,
200    },
201    /// Stream signed events from peers.
202    ///
203    /// Defaults to NEWEST-N orientation: with `--limit N`, prints the most
204    /// recent N events across all matched peers, sorted chronologically
205    /// (oldest of the window first, newest last — same orientation as Unix
206    /// `tail`). Pass `--oldest` to flip back to first-N (FIFO) behaviour.
207    /// `--limit 0` returns the full inbox in chronological order.
208    Tail {
209        /// Optional peer filter; if omitted, tails all peers.
210        peer: Option<String>,
211        /// Emit JSONL (one event per line).
212        #[arg(long)]
213        json: bool,
214        /// Maximum events to print. 0 = print everything (oldest → newest).
215        #[arg(long, default_value_t = 0)]
216        limit: usize,
217        /// Return the FIRST `--limit` events (oldest-N) instead of the
218        /// default last-N (newest-N). No effect when `--limit` is 0.
219        #[arg(long)]
220        oldest: bool,
221    },
222    /// Live tail of new inbox events across all pinned peers — one line per
223    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
224    /// by default.
225    ///
226    /// Designed to be left running in an agent harness's stream-watcher
227    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
228    /// session as they arrive, not on next manual `wire pull`.
229    ///
230    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
231    /// template.
232    Monitor {
233        /// Only show events from this peer.
234        #[arg(long)]
235        peer: Option<String>,
236        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
237        #[arg(long)]
238        json: bool,
239        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
240        /// Default filters them out as noise.
241        #[arg(long)]
242        include_handshake: bool,
243        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
244        #[arg(long, default_value_t = 500)]
245        interval_ms: u64,
246        /// Replay last N events from history before going live (0 = none).
247        #[arg(long, default_value_t = 0)]
248        replay: usize,
249    },
250    /// Verify a signed event from a JSON file or stdin (`-`).
251    Verify {
252        /// Path to event JSON, or `-` for stdin.
253        path: String,
254        /// Emit JSON.
255        #[arg(long)]
256        json: bool,
257    },
258    /// Run the MCP (Model Context Protocol) server over stdio.
259    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
260    /// `wire_send`, `wire_tail`, etc. as native tools.
261    Mcp,
262    /// Run a relay server on this host.
263    RelayServer {
264        /// Bind address (e.g. `127.0.0.1:8770`).
265        #[arg(long, default_value = "127.0.0.1:8770")]
266        bind: String,
267        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
268        /// skip `.well-known/wire/agent` serving. The relay becomes
269        /// invisible from outside the box — only same-machine processes
270        /// can pair through it. Right call for within-machine agent
271        /// coordination where you don't want metadata leaking to a
272        /// public relay. Pair this with `wire session new` which probes
273        /// `127.0.0.1:8771` and allocates a local slot automatically.
274        #[arg(long)]
275        local_only: bool,
276        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
277        /// When set, --bind is ignored. Implies --local-only semantics
278        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
279        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
280        /// sister sessions. Unix only (Windows refuses).
281        #[arg(long)]
282        uds: Option<std::path::PathBuf>,
283    },
284    /// Allocate a slot on a relay; bind it to this agent's identity.
285    ///
286    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
287    /// current slot, this command refuses by default — silent migration
288    /// silently black-holes their inbound messages. Pass
289    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
290    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
291    /// for safe rotation.
292    BindRelay {
293        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
294        url: String,
295        /// Endpoint scope: `federation` | `local` | `lan` | `uds`.
296        /// Default inferred from the URL (loopback host -> local,
297        /// `unix://` -> uds, otherwise federation). Pass explicitly when
298        /// the inference is ambiguous (e.g. a federation relay on a
299        /// loopback address in tests).
300        #[arg(long)]
301        scope: Option<String>,
302        /// DESTRUCTIVE: drop all existing self slots and bind only this
303        /// relay (the pre-v0.12 single-slot behavior). Default is
304        /// ADDITIVE — the new slot is appended to `self.endpoints[]`,
305        /// keeping any existing slots so pinned peers are not
306        /// black-holed.
307        #[arg(long)]
308        replace: bool,
309        /// Acknowledge that pinned peers will black-hole until they
310        /// re-pin manually. Required for `--replace` (and same-relay
311        /// rotation) when `state.peers` is non-empty; ignored on fresh
312        /// boxes. Use `wire rotate-slot` instead for the supported
313        /// same-relay rotation path.
314        #[arg(long)]
315        migrate_pinned: bool,
316        #[arg(long)]
317        json: bool,
318    },
319    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
320    /// real `wire join` lands in the SPAKE2 iter.)
321    AddPeerSlot {
322        /// Peer handle (becomes did:wire:<handle>).
323        handle: String,
324        /// Peer's relay base URL.
325        url: String,
326        /// Peer's slot id.
327        slot_id: String,
328        /// Slot bearer token (shared between paired peers in v0.1).
329        slot_token: String,
330        #[arg(long)]
331        json: bool,
332    },
333    /// Drain outbox JSONL files to peers' relay slots.
334    Push {
335        /// Optional peer filter; default = all peers with outbox entries.
336        peer: Option<String>,
337        #[arg(long)]
338        json: bool,
339    },
340    /// Pull events from our relay slot, verify, write to inbox.
341    Pull {
342        #[arg(long)]
343        json: bool,
344    },
345    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
346    /// Useful as a single "where am I" check.
347    Status {
348        /// Inspect a paired peer's transport / attention / responder health.
349        #[arg(long)]
350        peer: Option<String>,
351        #[arg(long)]
352        json: bool,
353    },
354    /// Publish or inspect auto-responder health for this slot.
355    Responder {
356        #[command(subcommand)]
357        command: ResponderCommand,
358    },
359    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
360    /// — fallback path; the magic-wormhole flow is `pair-host` / `pair-join`.)
361    Pin {
362        /// Path to peer's signed agent-card JSON.
363        card_file: String,
364        #[arg(long)]
365        json: bool,
366    },
367    /// Allocate a NEW slot on the same relay and abandon the old one.
368    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
369    /// slot announcing the new mailbox before swapping. After rotation,
370    /// peers must re-pair (or operator runs `add-peer-slot` with the new
371    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
372    ///
373    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
374    /// abusive bearer-holder spamming your slot). Rotate → old slot is
375    /// orphaned → attacker's leverage gone. Operator pairs again with
376    /// peers they still want.
377    RotateSlot {
378        /// Skip the wire_close announcement to peers (faster but they won't know
379        /// where you went).
380        #[arg(long)]
381        no_announce: bool,
382        #[arg(long)]
383        json: bool,
384    },
385    /// Remove a peer from trust + relay state. Inbox/outbox files for that
386    /// peer are NOT deleted (operator can grep history); pass --purge to
387    /// also wipe the JSONL files.
388    ForgetPeer {
389        /// Peer handle to forget.
390        handle: String,
391        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
392        #[arg(long)]
393        purge: bool,
394        #[arg(long)]
395        json: bool,
396    },
397    /// v0.14.2 (#170): multi-session topology view — supervisor
398    /// liveness + per-session daemon liveness + unmanaged `wire daemon`
399    /// pids. `wire status` answers "is THIS session syncing?";
400    /// `wire supervisor` answers "what is the supervisor (and every
401    /// session's daemon) doing across the box?". Replaces the manual
402    /// `pgrep -fl 'wire daemon' | cross-ref each per-session pidfile`
403    /// dance honey-pine ran during her launchd diagnosis.
404    Supervisor {
405        /// Emit JSON instead of human-readable text. The shape matches
406        /// the `SupervisorState` struct in `daemon_supervisor.rs`.
407        #[arg(long)]
408        json: bool,
409    },
410    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
411    /// peers' relay slots and pull inbox from our own slot. Foreground process;
412    /// background it with systemd / `&` / tmux as you prefer.
413    Daemon {
414        /// Sync interval in seconds. Default 5.
415        #[arg(long, default_value_t = 5)]
416        interval: u64,
417        /// Run a single sync cycle and exit (useful for cron-driven setups).
418        #[arg(long)]
419        once: bool,
420        /// v0.14.2 (#162): supervisor mode — read the session registry +
421        /// fork-exec one child `wire daemon` per initialized session,
422        /// each with its own WIRE_HOME pinned. Closes the launchd-blind
423        /// session-isolation gap honey-pine reported: with no cwd
424        /// context, a single launchd-spawned daemon resolves the
425        /// default WIRE_HOME and silently skips every other session.
426        /// Operator-facing: install this mode via `wire service install`
427        /// — the plist now uses `--all-sessions` so every session syncs
428        /// at login without the operator running N tmux panes.
429        #[arg(long)]
430        all_sessions: bool,
431        /// v0.14.2 (#162): run the daemon loop pinned to a specific
432        /// named session by setting WIRE_HOME for the process. The
433        /// supervisor (`--all-sessions`) spawns children with this
434        /// flag; operators can also use it directly for a one-session
435        /// foreground daemon outside the supervisor.
436        #[arg(long)]
437        session: Option<String>,
438        #[arg(long)]
439        json: bool,
440    },
441    /// Host a SAS-confirmed pairing. Generates a code phrase, prints it, waits
442    /// for a peer to `pair-join`, exchanges signed agent-cards via SPAKE2 +
443    /// ChaCha20-Poly1305. Auto-pins on success. (HUMAN-ONLY — operator must
444    /// read the SAS digits aloud and confirm.)
445    #[command(hide = true)] // v0.9 deprecated
446    PairHost {
447        /// Relay base URL.
448        #[arg(long)]
449        relay: String,
450        /// Skip the SAS confirmation prompt. ONLY use when piping under
451        /// automated tests or when the SAS has already been verified by
452        /// another channel. Documented as test-only.
453        #[arg(long)]
454        yes: bool,
455        /// How long (seconds) to wait for the peer to join before timing out.
456        #[arg(long, default_value_t = 300)]
457        timeout: u64,
458        /// Detach: write a pending-pair file, print the code phrase, and exit
459        /// immediately. The running `wire daemon` does the handshake in the
460        /// background; confirm SAS later via `wire pair-confirm <code> <digits>`.
461        /// `wire pair-list` shows pending sessions. Default is foreground
462        /// blocking behavior for backward compat.
463        #[arg(long)]
464        detach: bool,
465        /// Emit JSON instead of text. Currently only meaningful with --detach.
466        #[arg(long)]
467        json: bool,
468    },
469    /// Join a pair-slot using a code phrase from the host. (HUMAN-ONLY.)
470    ///
471    /// Aliased as `wire join <code>` for magic-wormhole muscle-memory.
472    #[command(alias = "join")]
473    #[command(hide = true)] // v0.9 deprecated
474    PairJoin {
475        /// Code phrase from the host's `pair-host` output (e.g. `73-2QXC4P`).
476        code_phrase: String,
477        /// Relay base URL (must match the host's relay).
478        #[arg(long)]
479        relay: String,
480        #[arg(long)]
481        yes: bool,
482        #[arg(long, default_value_t = 300)]
483        timeout: u64,
484        /// Detach: see `pair-host --detach`.
485        #[arg(long)]
486        detach: bool,
487        /// Emit JSON instead of text. Currently only meaningful with --detach.
488        #[arg(long)]
489        json: bool,
490    },
491    /// Confirm SAS digits for a detached pending pair. The daemon must be
492    /// running for this to do anything — it picks up the confirmation on its
493    /// next tick. Mismatch aborts the pair.
494    #[command(hide = true)] // v0.9 deprecated
495    PairConfirm {
496        /// The code phrase the original `wire pair-host --detach` printed.
497        code_phrase: String,
498        /// 6 digits as displayed by `wire pair-list` (dashes/spaces stripped).
499        digits: String,
500        /// Emit JSON instead of human-readable text.
501        #[arg(long)]
502        json: bool,
503    },
504    /// List all pending detached pair sessions and their state.
505    #[command(hide = true)] // v0.9 deprecated
506    PairList {
507        /// Emit JSON instead of the table.
508        #[arg(long)]
509        json: bool,
510        /// Stream mode: never exit; print one JSON line per status transition
511        /// (creation, status change, deletion) across all pending pairs.
512        /// Compose with bash `while read` to react in shell. Implies --json.
513        #[arg(long)]
514        watch: bool,
515        /// Poll interval in seconds for --watch.
516        #[arg(long, default_value_t = 1)]
517        watch_interval: u64,
518    },
519    /// Cancel a pending pair. Releases the relay slot and removes the pending file.
520    #[command(hide = true)] // v0.9 deprecated
521    PairCancel {
522        code_phrase: String,
523        #[arg(long)]
524        json: bool,
525    },
526    /// Block until a pending pair reaches a target status (default sas_ready),
527    /// or terminates (finalized = file removed, aborted, aborted_restart), or
528    /// the timeout expires. Useful for shell scripts that want to drive the
529    /// detached flow without polling pair-list themselves.
530    ///
531    /// Exit codes:
532    ///   0 — reached target status (or finalized, if target was sas_ready)
533    ///   1 — terminated abnormally (aborted, aborted_restart, no such code)
534    ///   2 — timeout
535    #[command(hide = true)] // v0.9 deprecated
536    PairWatch {
537        code_phrase: String,
538        /// Target status to wait for. Default: sas_ready.
539        #[arg(long, default_value = "sas_ready")]
540        status: String,
541        /// Max seconds to wait.
542        #[arg(long, default_value_t = 300)]
543        timeout: u64,
544        /// Emit JSON on each status change (one per line) instead of just on exit.
545        #[arg(long)]
546        json: bool,
547    },
548    /// One-shot bootstrap. Inits identity (idempotent), opens pair-host or
549    /// pair-join, then registers wire as an MCP server. Single command from
550    /// nothing to paired and ready — no separate init/pair-host/setup steps.
551    /// Operator still must confirm SAS digits.
552    ///
553    /// Examples:
554    ///   wire pair paul                          # host a new pair on default relay
555    ///   wire pair willard --code 58-NMTY7A      # join paul's pair
556    ///
557    /// v0.10: hidden from --help. Federation pair flow is now
558    /// `wire dial <handle>@<relay>` + `wire accept-invite <URL>`.
559    /// `wire pair` stays callable for back-compat scripts; v1.0 removes.
560    #[command(hide = true)] // v0.10 deprecated — use `wire dial <h>@<relay>`
561    Pair {
562        /// Short handle for this agent (becomes did:wire:<handle>). Used by init
563        /// step if no identity exists; ignored if already initialized.
564        handle: String,
565        /// Code phrase from peer's pair-host output. Omit to be the host
566        /// (this command will print one for you to share).
567        #[arg(long)]
568        code: Option<String>,
569        /// Relay base URL. Defaults to the laulpogan public-good relay.
570        #[arg(long, default_value = "https://wireup.net")]
571        relay: String,
572        /// Skip SAS prompt. Test-only.
573        #[arg(long)]
574        yes: bool,
575        /// Pair-step timeout in seconds.
576        #[arg(long, default_value_t = 300)]
577        timeout: u64,
578        /// Skip the post-pair `setup --apply` step (don't register wire as
579        /// an MCP server in detected client configs).
580        #[arg(long)]
581        no_setup: bool,
582        /// Run via the daemon-orchestrated detached path (auto-starts daemon,
583        /// exits immediately, daemon does the handshake). Confirm via
584        /// `wire pair-confirm <code> <digits>` from any terminal. See
585        /// `pair-host --detach` for details.
586        #[arg(long)]
587        detach: bool,
588    },
589    /// Forget a half-finished pair-slot on the relay. Use this if `pair-host`
590    /// or `pair-join` crashed (process killed, network blip, OOM) before SAS
591    /// confirmation, leaving the relay-side slot stuck with "guest already
592    /// registered" or "host already registered" until the 5-minute TTL expires.
593    /// Either side can call. Idempotent.
594    #[command(hide = true)] // v0.9 deprecated
595    PairAbandon {
596        /// The code phrase from the original pair-host (e.g. `58-NMTY7A`).
597        code_phrase: String,
598        /// Relay base URL.
599        #[arg(long, default_value = "https://wireup.net")]
600        relay: String,
601    },
602    /// Accept a pending-inbound pair request (v0.5.14). Explicit alias for
603    /// the bilateral-completion path that `wire add <peer>@<relay>` also
604    /// drives — but doesn't require remembering the peer's relay domain
605    /// (the relay coords come from the stored pair_drop). Errors if no
606    /// pending-inbound record exists for that peer.
607    #[command(hide = true)] // v0.9 deprecated
608    PairAccept {
609        /// Bare peer handle (without `@<relay>`).
610        peer: String,
611        /// Emit JSON.
612        #[arg(long)]
613        json: bool,
614    },
615    /// Reject a pending pair request (v0.5.14). When someone runs `wire add
616    /// you@<your-relay>` against your handle, their signed pair_drop lands
617    /// in pending-inbound — visible via `wire pair-list`. Run `wire pair-reject
618    /// <peer>` to delete the record without pairing. The peer never receives
619    /// our slot_token; from their side the pair stays pending until they
620    /// time out.
621    #[command(hide = true)] // v0.9 deprecated
622    PairReject {
623        /// Bare peer handle (without `@<relay>`).
624        peer: String,
625        /// Emit JSON.
626        #[arg(long)]
627        json: bool,
628    },
629    /// Programmatic-shape list of pending-inbound pair requests (v0.5.14).
630    /// `--json` returns a flat array (matching the v0.5.13-and-earlier
631    /// `pair-list --json` shape but for inbound). Use this in scripts that
632    /// need to enumerate inbound pair requests without parsing the SPAKE2
633    /// table format from `wire pair-list`.
634    #[command(hide = true)] // v0.9 deprecated
635    PairListInbound {
636        /// Emit JSON.
637        #[arg(long)]
638        json: bool,
639    },
640    /// Manage isolated wire sessions on this machine (v0.5.16).
641    ///
642    /// Each session = its own DID + handle + relay slot + daemon + inbox/
643    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
644    /// in different projects) run on the same machine — without sessions
645    /// they all share one identity and race the inbox cursor.
646    ///
647    /// Names are derived from `basename(cwd)` and cached in a registry,
648    /// so re-entering the same project reuses the same identity.
649    #[command(subcommand)]
650    Session(SessionCommand),
651    /// Manage this session's identity display layer (character override).
652    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
653    /// itself picks a custom nickname + emoji that overrides the
654    /// auto-derived hash-based defaults.
655    Identity {
656        #[command(subcommand)]
657        cmd: IdentityCommand,
658    },
659    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
660    /// sister-session mesh. `wire mesh status` is the live view of every
661    /// paired sister (alias for `wire session mesh-status`); `wire mesh
662    /// broadcast` fans one signed event to every pinned peer.
663    #[command(subcommand)]
664    Mesh(MeshCommand),
665    /// Group chat (v0.13.3): create a named group, add VERIFIED peers, and
666    /// send/tail messages across the whole member set. Membership is a signed
667    /// roster (group-scoped tiers, separate from bilateral peer trust).
668    #[command(subcommand)]
669    Group(GroupCommand),
670    /// Mint operator / organization identities for the offline org-membership
671    /// layer (RFC-001): `wire enroll op` / `org-create` / `org-add-member`.
672    #[command(subcommand)]
673    Enroll(EnrollCommand),
674    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
675    /// Cursor, project-local) and either print or auto-merge the wire MCP
676    /// server entry. Default prints; pass `--apply` to actually modify config
677    /// files. Idempotent — re-running is safe.
678    Setup {
679        /// Actually write the changes (default = print only).
680        #[arg(long)]
681        apply: bool,
682        /// Install a Claude Code statusLine showing your wire persona
683        /// (liveness dot + emoji + nickname in the persona's accent color +
684        /// cwd) instead of merging the MCP server. Writes a renderer script
685        /// and merges a `statusLine` block into Claude Code's settings.json
686        /// (honors $CLAUDE_CONFIG_DIR). Combine with --apply to write.
687        #[arg(long)]
688        statusline: bool,
689        /// With --statusline: uninstall it (drop the statusLine key + remove
690        /// the renderer script) instead of installing.
691        #[arg(long)]
692        remove: bool,
693    },
694    /// Show an agent's profile. With no arg, prints local self. With a
695    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
696    /// endpoint and verifies the returned signed card before display.
697    Whois {
698        /// Optional handle (`nick@domain`). Omit to show self.
699        handle: Option<String>,
700        #[arg(long)]
701        json: bool,
702        /// Override the relay base URL used for resolution (default:
703        /// `https://<domain>` from the handle).
704        #[arg(long)]
705        relay: Option<String>,
706    },
707    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
708    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
709    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
710    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
711    /// their slot_token so we can `wire send` to them).
712    Add {
713        /// Peer handle (`nick@domain`), OR a bare sister-session name
714        /// when `--local-sister` is set.
715        handle: String,
716        /// Override the relay base URL used for resolution.
717        #[arg(long)]
718        relay: Option<String>,
719        /// v0.6.6: pair with a sister session on this machine without
720        /// touching federation. Looks up `handle` as a session name in
721        /// `wire session list`, reads that session's agent-card +
722        /// endpoints from disk, pins directly, then delivers the
723        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
724        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
725        /// addressable because they don't need a federation claim.
726        #[arg(long)]
727        local_sister: bool,
728        #[arg(long)]
729        json: bool,
730    },
731    /// Come online in one command — `wire up` does what used to take five
732    /// (init + bind-relay + claim your persona + background daemon +
733    /// restart-on-login). Idempotent: re-run on an already-set-up box prints
734    /// state without churn.
735    ///
736    /// There is no name to choose: your handle IS your DID-derived persona
737    /// (one-name rule). The optional argument is just which relay to use.
738    ///
739    /// Examples:
740    ///   wire up                        # default public relay (wireup.net)
741    ///   wire up @wireup.net            # explicit federation relay
742    ///   wire up http://127.0.0.1:8771  # a local / self-hosted relay
743    Up {
744        /// Relay to bind + claim your persona on: `@wireup.net`, `wireup.net`,
745        /// or a full URL. Omit for the default public relay. No nick — your
746        /// handle is your DID-derived persona.
747        relay: Option<String>,
748        /// Optional display name for your profile card (cosmetic; distinct
749        /// from your addressable handle/persona).
750        #[arg(long)]
751        name: Option<String>,
752        /// Also additively dual-bind a LOCAL relay slot for fast same-box
753        /// sister-session routing. Defaults to probing
754        /// `http://127.0.0.1:8771`; pass a URL to override. Local relays
755        /// carry no handle directory, so nothing is claimed there.
756        #[arg(long)]
757        with_local: Option<String>,
758        /// Skip the opportunistic local dual-bind entirely.
759        #[arg(long)]
760        no_local: bool,
761        #[arg(long)]
762        json: bool,
763    },
764    /// Diagnose wire setup health. Single command that surfaces every
765    /// silent-fail class — daemon down or duplicated, relay unreachable,
766    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
767    /// Replaces today's 30-minute manual debug.
768    ///
769    /// Exit code non-zero if any FAIL findings.
770    Doctor {
771        /// Emit JSON.
772        #[arg(long)]
773        json: bool,
774        /// Show last N entries from pair-rejected.jsonl in the report.
775        #[arg(long, default_value_t = 5)]
776        recent_rejections: usize,
777    },
778    /// Update + restart in one step (alias: `wire update`). ALWAYS checks
779    /// crates.io for a newer published wire; if one exists it installs it
780    /// (via `cargo install slancha-wire` when a Rust toolchain is on PATH,
781    /// else by downloading + SHA-256-verifying the prebuilt release binary
782    /// and replacing this one in place), then does the atomic daemon swap —
783    /// kill every `wire daemon`, respawn from the (now-current) binary, write
784    /// a fresh pidfile. No newer version → it skips the install and just
785    /// restarts the daemon. `--check` reports what would happen (available
786    /// update + processes that would be restarted) without doing it;
787    /// `--local` skips the crates.io check and only restarts the daemon
788    /// (offline, or running a local dev build).
789    #[command(visible_alias = "update")]
790    Upgrade {
791        /// Report current vs latest + drift without taking action.
792        #[arg(long)]
793        check: bool,
794        /// Skip the crates.io update check; just restart the daemon from the
795        /// current binary (offline / local dev build).
796        #[arg(long)]
797        local: bool,
798        /// Also kill `wire mcp` server subprocesses after the daemon swap so
799        /// their MCP host (Claude Code / Claude.app / Copilot CLI) respawns
800        /// them on the new binary. Without this, sister sessions keep
801        /// running pre-upgrade MCP code until each one explicitly `/mcp`
802        /// reconnects. Cross-session impact: kills every `wire mcp` found.
803        #[arg(long = "restart-mcp")]
804        restart_mcp: bool,
805        /// v0.14.3 (closes the #198 follow-up): kill the daemons reported in
806        /// `wire supervisor`'s `stale_binary_sessions` set — sister-session
807        /// children alive on an old binary that the supervisor's
808        /// existing-pidfile check intentionally protected from respawn. Once
809        /// each is killed, the `--all-sessions` supervisor respawns it on
810        /// the new binary on its next 10s registry poll. Cross-session
811        /// impact: only sessions flagged stale are touched; in-sync siblings
812        /// are spared. No-op (silent) when no supervisor is running OR no
813        /// stale daemons exist.
814        #[arg(long = "refresh-stale-children")]
815        refresh_stale_children: bool,
816        #[arg(long)]
817        json: bool,
818    },
819    /// Install / inspect / remove a launchd plist (macOS) or systemd
820    /// user unit (linux) that runs `wire daemon` on login + restarts
821    /// on crash. Replaces today's "background it with tmux/&/systemd
822    /// as you prefer" footgun.
823    Service {
824        #[command(subcommand)]
825        action: ServiceAction,
826    },
827    /// Inspect or toggle the structured diagnostic trace
828    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
829    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
830    /// (writes the file knob a running daemon picks up automatically).
831    Diag {
832        #[command(subcommand)]
833        action: DiagAction,
834    },
835    /// Claim your persona on a relay's handle directory. Anyone can then
836    /// reach this agent by `<persona>@<relay-domain>` via the relay's
837    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
838    ///
839    /// ONE-NAME RULE (v0.13.1): the claimed handle is always your DID-derived
840    /// persona. The `nick` arg is vestigial — if it differs it is ignored
841    /// (like the typed name `wire init` / `wire up` already ignore), so your
842    /// phonebook entry can never drift from your agent-card handle.
843    ///
844    /// v0.13.1: hidden — `wire up` claims your persona for you. Kept callable
845    /// (idempotent re-claim) but not a user verb; there is no nick to choose.
846    #[command(hide = true)]
847    Claim {
848        /// Vestigial: ignored if it differs from your DID-derived persona.
849        nick: String,
850        /// Relay to claim the nick on. Default = relay our slot is on.
851        #[arg(long)]
852        relay: Option<String>,
853        /// Public URL the relay should advertise to resolvers (default = relay).
854        #[arg(long)]
855        public_url: Option<String>,
856        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
857        /// directory listing. The handle stays claimed (FCFS still
858        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
859        /// still resolves, so peers you share the handle with out-of-band
860        /// can still pair. Bulk scrapers / phonebook crawlers will not
861        /// see the nick. Use this for handles meant for known-peer
862        /// pairing only — see issue #9.
863        #[arg(long)]
864        hidden: bool,
865        #[arg(long)]
866        json: bool,
867    },
868    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
869    /// avatar_url, handle, now). Re-signs the agent-card atomically.
870    ///
871    /// Examples:
872    ///   wire profile set motto "compiles or dies trying"
873    ///   wire profile set emoji "🦀"
874    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
875    ///   wire profile set handle "coffee-ghost@anthropic.dev"
876    ///   wire profile get
877    Profile {
878        #[command(subcommand)]
879        action: ProfileAction,
880    },
881    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
882    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
883    /// a relay slot on first use. Default TTL 24h, single-use.
884    #[command(hide = true)] // v0.9 deprecated
885    Invite {
886        /// Override the relay URL for first-time auto-allocation.
887        #[arg(long, default_value = "https://wireup.net")]
888        relay: String,
889        /// Invite lifetime in seconds (default 86400 = 24h).
890        #[arg(long, default_value_t = 86_400)]
891        ttl: u64,
892        /// Number of distinct peers that can accept this invite before it's
893        /// consumed (default 1).
894        #[arg(long, default_value_t = 1)]
895        uses: u32,
896        /// Register the invite at the relay's short-URL endpoint and print
897        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
898        /// Installs wire if missing, then accepts the invite, then pairs.
899        #[arg(long)]
900        share: bool,
901        /// Emit JSON.
902        #[arg(long)]
903        json: bool,
904    },
905    /// v0.9: accept a pending-inbound pair request by character
906    /// nickname or card handle. Replaces the verbose `wire pair-accept
907    /// <peer>`.
908    ///
909    /// v0.9.4: the URL-vs-name smart-dispatch from v0.9 is gone. To
910    /// accept a federation invite URL use `wire accept-invite <URL>`
911    /// (split out as an explicit verb to eliminate the input-shape
912    /// ambiguity). `wire accept <URL>` still works for back-compat
913    /// but emits a deprecation banner pointing at `accept-invite`.
914    Accept {
915        /// Pending peer name (character nickname or card handle).
916        target: String,
917        /// Emit JSON.
918        #[arg(long)]
919        json: bool,
920    },
921    /// v0.9.4: accept a federation invite URL minted by `wire invite`.
922    /// Pins issuer, sends signed card to issuer's slot. Auto-inits +
923    /// auto-allocates as needed.
924    ///
925    /// Split out from `wire accept` to eliminate the URL-vs-name
926    /// smart-dispatch ambiguity (peer handles can legitimately collide
927    /// with URL-shaped strings; the explicit verb removes the inference).
928    #[command(alias = "invite-accept")]
929    AcceptInvite {
930        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
931        url: String,
932        /// Emit JSON.
933        #[arg(long)]
934        json: bool,
935    },
936    /// v0.9: refuse a pending-inbound pair request without pairing. Aliases
937    /// the legacy `wire pair-reject <peer>`.
938    Reject {
939        /// Peer name (character nickname or handle) from `wire pending`.
940        peer: String,
941        /// Emit JSON.
942        #[arg(long)]
943        json: bool,
944    },
945    /// Watch the inbox for new verified events and fire an OS notification per
946    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
947    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
948    /// re-emit history.
949    Notify {
950        /// Poll interval in seconds.
951        #[arg(long, default_value_t = 2)]
952        interval: u64,
953        /// Only notify for events from this peer (handle, no did: prefix).
954        #[arg(long)]
955        peer: Option<String>,
956        /// Run a single sweep and exit (useful for cron / tests).
957        #[arg(long)]
958        once: bool,
959        /// Suppress the OS notification call; print one JSON line per event to
960        /// stdout instead (for piping into other tooling or smoke-testing
961        /// without a desktop session).
962        #[arg(long)]
963        json: bool,
964    },
965    /// Silence (or re-enable) all wire desktop toasts. Persistent across
966    /// daemon restarts via a file at `<config_dir>/quiet`. `wire quiet on`
967    /// = silence; `wire quiet off` = restore; `wire quiet status` = report.
968    /// Same effect as exporting `WIRE_NO_TOASTS=1` (the env-var override
969    /// is for launchd contexts where the daemon's env isn't writable from
970    /// the operator's shell).
971    Quiet {
972        #[command(subcommand)]
973        action: QuietAction,
974    },
975}
976
977#[derive(Subcommand, Debug)]
978pub enum QuietAction {
979    /// Touch `<config_dir>/quiet` — silences every wire desktop toast
980    /// (pair_drop, pending_pair, monitor, inbox). Idempotent.
981    On,
982    /// Remove `<config_dir>/quiet` — re-enables toasts. Idempotent (no
983    /// error if already off / file absent).
984    Off,
985    /// Report current state: `on` (file present) / `off` (file absent) /
986    /// `forced-on-by-env` (`WIRE_NO_TOASTS=1` in env, overrides file).
987    Status {
988        /// Emit `{"state": "...", "via": "file"|"env"|"none"}` JSON
989        /// instead of the human one-liner.
990        #[arg(long)]
991        json: bool,
992    },
993}
994
995#[derive(Subcommand, Debug)]
996pub enum DiagAction {
997    /// Tail the last N entries from diag.jsonl.
998    Tail {
999        #[arg(long, default_value_t = 20)]
1000        limit: usize,
1001        #[arg(long)]
1002        json: bool,
1003    },
1004    /// Flip the file-based knob ON. Running daemons pick this up on
1005    /// the next emit call without restart.
1006    Enable,
1007    /// Flip the file-based knob OFF.
1008    Disable,
1009    /// Report whether diag is currently enabled + the file's size.
1010    Status {
1011        #[arg(long)]
1012        json: bool,
1013    },
1014}
1015
1016/// `wire enroll …` — mint the operator/org identities + certs the offline
1017/// org-membership layer (RFC-001) consumes. Keys are stored 0600 alongside
1018/// `private.key`. (Publishing these claims on the agent's own card — the
1019/// card-emit integration — is a separate follow-up.)
1020#[derive(Subcommand, Debug)]
1021pub enum EnrollCommand {
1022    /// Mint this machine's operator root key (`op.key`) and print its `op_did`.
1023    Op {
1024        /// Operator handle (display only; the op_did commits to the key).
1025        #[arg(long, default_value = "operator")]
1026        handle: String,
1027        #[arg(long)]
1028        json: bool,
1029    },
1030    /// Mint an organization root key and print its `org_did` + `org_pubkey`.
1031    OrgCreate {
1032        /// Org handle (display only; the org_did commits to the key).
1033        #[arg(long)]
1034        handle: String,
1035        #[arg(long)]
1036        json: bool,
1037    },
1038    /// Issue a membership cert: the named org signs an operator's `op_did`.
1039    /// Prints the `{org_did, org_pubkey, member_cert}` bundle for the operator
1040    /// to add to their card's `org_memberships[]`.
1041    OrgAddMember {
1042        /// The operator DID to vouch for (`did:wire:op:…`).
1043        op_did: String,
1044        /// Which org signs (its `org_did`).
1045        #[arg(long)]
1046        org: String,
1047        #[arg(long)]
1048        json: bool,
1049    },
1050    /// Rebuild the agent card with the **current** enrollment state and
1051    /// republish to the phonebook. Closes the enroll-after-`init` DX gap:
1052    /// claims are normally attached at card-build time, but an operator who
1053    /// enrolls AFTER `init` has a stored card that pre-dates the claims. Run
1054    /// this once after `wire enroll op` / `org-add-member` to surface them.
1055    /// Idempotent: not-enrolled rebuilds a claims-free card; not-bound prints
1056    /// "local only".
1057    Republish {
1058        #[arg(long)]
1059        json: bool,
1060    },
1061    /// Ingest a membership cert handed to this operator by an org owner.
1062    ///
1063    /// Closes the DX gap surfaced in #127 (slate-lotus 2026-05-30 audit):
1064    /// `wire enroll org-add-member` printed an `{org_did, org_pubkey,
1065    /// member_cert}` bundle but the receiver had no verb to store it —
1066    /// joining an org required hand-editing
1067    /// `<config>/wire/memberships.json`. This verb wraps the existing
1068    /// `config::add_membership` helper + verifies the cert against
1069    /// `org_pubkey` and this operator's `op_did` before storing, so a
1070    /// malformed / wrong-key bundle fails loudly instead of corrupting
1071    /// the next `wire enroll republish`.
1072    ///
1073    /// Accepts either a single `--bundle '<json>'` (the verbatim
1074    /// org-add-member output) or the three fields separately. Idempotent:
1075    /// re-running with the same `org_did` replaces the prior entry.
1076    AddMembership {
1077        /// Verbatim `org-add-member` output (overrides individual flags
1078        /// when set). Shape: `{"org_did":"…","org_pubkey":"…","member_cert":"…"}`.
1079        #[arg(long)]
1080        bundle: Option<String>,
1081        /// Required when `--bundle` is not set.
1082        #[arg(long)]
1083        org: Option<String>,
1084        /// Required when `--bundle` is not set. Base64.
1085        #[arg(long = "org-pubkey")]
1086        org_pubkey: Option<String>,
1087        /// Required when `--bundle` is not set. Base64-encoded Ed25519
1088        /// signature by `org_pubkey` over this operator's `op_did`.
1089        #[arg(long = "member-cert")]
1090        member_cert: Option<String>,
1091        #[arg(long)]
1092        json: bool,
1093    },
1094}
1095
1096#[derive(Subcommand, Debug)]
1097pub enum IdentityCommand {
1098    /// Print the current character (DID-derived, the only name).
1099    /// Equivalent to `wire whoami --short` but scoped here for grouping.
1100    Show {
1101        #[arg(long)]
1102        json: bool,
1103    },
1104    /// List all identities on this machine — one row per session, with
1105    /// each session's character, DID, federation handle, and cwd. Same
1106    /// shape as `wire session list`, scoped here for the v0.7+ noun-
1107    /// CLI surface.
1108    List {
1109        #[arg(long)]
1110        json: bool,
1111    },
1112    /// Promote this identity to FEDERATION lifecycle: claim your persona on
1113    /// the relay so peers can `wire dial <persona>@<relay-domain>` you.
1114    /// Re-claims with current display fields so the relay always serves the
1115    /// latest signed card. Equivalent to `wire claim`.
1116    ///
1117    /// v0.13.1: hidden — `wire up` publishes your persona for you, and the
1118    /// nick is vestigial (one-name rule). Kept callable for re-publish.
1119    #[command(hide = true)]
1120    Publish {
1121        /// Vestigial: ignored; your handle is your DID-derived persona.
1122        nick: String,
1123        /// Override the relay URL. Defaults to the session's bound relay
1124        /// from `wire init --relay <url>`. Public relay if unset.
1125        #[arg(long)]
1126        relay: Option<String>,
1127        /// Public-facing URL for the agent-card location (when the relay
1128        /// is behind a CDN with a different public domain).
1129        #[arg(long, alias = "public")]
1130        public_url: Option<String>,
1131        /// Skip listing in the relay's public phonebook. The card is
1132        /// still claimable + reachable; just doesn't appear in
1133        /// `wireup.net/phonebook` for stranger-discovery.
1134        #[arg(long)]
1135        hidden: bool,
1136        #[arg(long)]
1137        json: bool,
1138    },
1139    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
1140    /// Equivalent to `wire session destroy <name>`, scoped here for the
1141    /// noun-CLI surface. Requires `--force` (the underlying command does).
1142    Destroy {
1143        /// Session name to destroy (use `wire identity list` to see).
1144        name: String,
1145        /// Bypass the confirmation prompt.
1146        #[arg(long)]
1147        force: bool,
1148        #[arg(long)]
1149        json: bool,
1150    },
1151    /// Create an identity in an EXPLICIT lifecycle state (vs. the
1152    /// implicit `wire init` + `wire claim` flow).
1153    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
1154    ///
1155    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
1156    /// next reboot). In-memory semantics not yet supported — the
1157    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
1158    /// For pure-RAM identities, see v1.0 vision.
1159    ///
1160    /// `--local` is the explicit form of today's default; identity
1161    /// persists to the machine-wide sessions root.
1162    Create {
1163        /// Session name. Defaults to derived from cwd (anonymous mode
1164        /// uses a random name).
1165        #[arg(long)]
1166        name: Option<String>,
1167        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
1168        /// reboot, no federation). Mutually exclusive with --local.
1169        #[arg(long, conflicts_with = "local")]
1170        anonymous: bool,
1171        /// Create a LOCAL identity (machine-persistent, no federation).
1172        /// Default — explicit flag for clarity.
1173        #[arg(long)]
1174        local: bool,
1175        #[arg(long)]
1176        json: bool,
1177    },
1178    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
1179    /// the machine-wide sessions root + register in the cwd map.
1180    /// After persist, the identity survives reboot.
1181    /// v0.7.0-alpha.20.
1182    Persist {
1183        /// The anonymous identity's name (from `wire identity list`).
1184        name: String,
1185        /// Optional rename during persist. Default: keep the anon name.
1186        #[arg(long = "as", value_name = "NEW_NAME")]
1187        as_name: Option<String>,
1188        #[arg(long)]
1189        json: bool,
1190    },
1191    /// Demote an identity ONE level in the lifecycle:
1192    ///   federation → local: removes the relay slot binding but keeps
1193    ///   the keypair + agent-card. Operator can later re-publish with
1194    ///   `wire identity publish`. v0.7.0-alpha.20.
1195    ///
1196    /// (local → anonymous is not exposed; the safer flow is destroy +
1197    /// recreate, since "demoting" a persistent identity to ephemeral
1198    /// has surprising semantics — what about the keypair? what about
1199    /// pinned peers? Better to be explicit with destroy.)
1200    Demote {
1201        /// Session name to demote.
1202        name: String,
1203        #[arg(long)]
1204        json: bool,
1205    },
1206}
1207
1208#[derive(Subcommand, Debug)]
1209pub enum SessionCommand {
1210    /// Bootstrap a new isolated session in this machine's sessions root.
1211    /// With no name, derives one from `basename(cwd)` and caches it in
1212    /// the registry so re-running from the same project reuses it.
1213    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1214    /// the new session's WIRE_HOME. Output includes the `export
1215    /// WIRE_HOME=...` line operators paste into their shell to activate
1216    /// it.
1217    New {
1218        /// Optional session name. Default = derived from `basename(cwd)`.
1219        name: Option<String>,
1220        /// Relay URL for the session's slot allocation + handle claim.
1221        #[arg(long, default_value = "https://wireup.net")]
1222        relay: String,
1223        /// v0.5.17: also allocate a second slot on a same-machine local
1224        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1225        /// sister-session traffic prefers this path: zero round-trip
1226        /// latency, zero metadata exposure to the public relay. Probes
1227        /// `<local-relay>/healthz` first; silently skips if the local
1228        /// relay isn't running.
1229        #[arg(long)]
1230        with_local: bool,
1231        /// v0.5.17: override the local relay URL probed by `--with-local`.
1232        /// Default is `http://127.0.0.1:8771` to match
1233        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1234        #[arg(long, default_value = "http://127.0.0.1:8771")]
1235        local_relay: String,
1236        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1237        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1238        /// Lets other machines on the same network reach this session
1239        /// directly without round-tripping the public federation relay
1240        /// at https://wireup.net. LAN endpoint is published in the
1241        /// agent-card; opt-in per session (default off).
1242        #[arg(long)]
1243        with_lan: bool,
1244        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1245        /// LAN IP — operator must type the address). Example:
1246        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1247        #[arg(long)]
1248        lan_relay: Option<String>,
1249        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1250        /// relay (must be running e.g. via `wire relay-server --uds
1251        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1252        /// bypasses the macOS firewall + Tailscale userspace-netstack
1253        /// class of issues entirely for sister-session traffic. UDS
1254        /// endpoint is published in the agent-card.
1255        #[arg(long)]
1256        with_uds: bool,
1257        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1258        /// is set. Example: `/tmp/wire.sock` or
1259        /// `~/.wire/local.sock`.
1260        #[arg(long)]
1261        uds_socket: Option<std::path::PathBuf>,
1262        /// Skip spawning the session-local daemon. Use when you want
1263        /// to drive sync explicitly from the agent or test rig.
1264        #[arg(long)]
1265        no_daemon: bool,
1266        /// v0.6.6: create a federation-free session — no nick claim on
1267        /// `--relay`, no federation slot allocation. Implies
1268        /// `--with-local`. The session exists only to coordinate with
1269        /// other sister sessions on this machine; it has no public
1270        /// address and cannot be reached from outside. Reserved nicks
1271        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1272        /// to publish them.
1273        #[arg(long)]
1274        local_only: bool,
1275        /// Emit JSON.
1276        #[arg(long)]
1277        json: bool,
1278    },
1279    /// List all sessions on this machine with their handle, DID,
1280    /// daemon liveness, and the cwd they're associated with.
1281    List {
1282        #[arg(long)]
1283        json: bool,
1284    },
1285    /// List sister sessions reachable via a same-machine local relay
1286    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1287    /// share. Sessions without a Local-scope endpoint are listed
1288    /// separately so the operator can tell which are federation-only.
1289    /// Read-only — does not probe any relay or touch daemons.
1290    ListLocal {
1291        #[arg(long)]
1292        json: bool,
1293    },
1294    /// v0.6.0 (issue #12): mesh-pair every sister session against every
1295    /// other in O(N²) handshakes. For each unordered pair (A, B) that
1296    /// is not already paired, drives the bilateral flow end-to-end:
1297    /// `wire add` from A → B (queued + pushed), `wire pair-accept` on
1298    /// B's side, then a final pull on A so the ack lands. Idempotent —
1299    /// re-running skips pairs already in `state.peers`.
1300    ///
1301    /// **Trust anchor:** the operator running this command owns every
1302    /// session listed in `wire session list-local` (they all live under
1303    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1304    /// That filesystem-permission boundary IS the consent for both
1305    /// sides — the bilateral SAS / network-level handshake assumes
1306    /// strangers; same-uid sister sessions are by definition not
1307    /// strangers. Cross-uid sister sessions are out of scope; today
1308    /// `wire session list-local` only enumerates this user's sessions.
1309    PairAllLocal {
1310        /// Seconds to wait between handshake stages for pair_drop /
1311        /// pair_drop_ack to propagate over the relay. Default 1s
1312        /// (local-relay is typically <100ms RTT). Bump if you see
1313        /// "pending-inbound never arrived" errors on a slow relay.
1314        #[arg(long, default_value_t = 1)]
1315        settle_secs: u64,
1316        /// Federation relay to bind each `wire add` against. Default
1317        /// `https://wireup.net`. Sister sessions should be bound to
1318        /// the same federation relay; the pair handshake routes through
1319        /// it for the .well-known resolution + pair_drop deposit.
1320        #[arg(long, default_value = "https://wireup.net")]
1321        federation_relay: String,
1322        #[arg(long)]
1323        json: bool,
1324    },
1325    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1326    /// machine. Enumerates every session in `wire session list-local`,
1327    /// walks each session's `relay.json#peers` to find which other sister
1328    /// sessions it has pinned, and probes the local relay for each edge's
1329    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1330    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1331    /// local_relay, summary}` so scripts can scrape.
1332    ///
1333    /// Read-only — does NOT touch peers or daemons, only the relay's
1334    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1335    /// already hold. Silent on any probe failure (degrades to "no
1336    /// signal" rather than abort) so a half-broken mesh is still
1337    /// inspectable.
1338    MeshStatus {
1339        /// Threshold in seconds for "stale" classification on an edge.
1340        /// An edge whose receiver hasn't polled their slot in this long
1341        /// is flagged. Default 300s (5 min) — same as the per-send
1342        /// `phyllis` attentiveness nag.
1343        #[arg(long, default_value_t = 300)]
1344        stale_secs: u64,
1345        #[arg(long)]
1346        json: bool,
1347    },
1348    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1349    /// can `eval $(wire session env <name>)` to activate it. With no
1350    /// name, resolves the cwd through the registry.
1351    Env {
1352        /// Session name. Default = derived from cwd via the registry.
1353        name: Option<String>,
1354        #[arg(long)]
1355        json: bool,
1356    },
1357    /// Identify which session the current cwd maps to in the registry.
1358    /// Prints `(none)` if cwd isn't registered — `wire session new`
1359    /// would create one.
1360    Current {
1361        #[arg(long)]
1362        json: bool,
1363    },
1364    /// Attach an existing session to the current cwd in the registry,
1365    /// so subsequent auto-detect from this cwd resolves to that session
1366    /// instead of walking up to an ancestor's binding. Use when an
1367    /// ancestor dir (e.g. `~/Source`) is already registered and is
1368    /// shadowing per-project identities for cwds beneath it. Idempotent;
1369    /// re-binding to the same name is a no-op. Re-binding to a different
1370    /// name overwrites the prior entry with a stderr warning.
1371    Bind {
1372        /// Session name to bind. Must already exist (run `wire session
1373        /// new <name>` first if not). With no name, auto-derives from
1374        /// `basename(cwd)` and errors if no session of that name exists.
1375        name: Option<String>,
1376        #[arg(long)]
1377        json: bool,
1378    },
1379    /// Tear down a session: kills its daemon (if running), deletes its
1380    /// state directory, and removes it from the registry. Requires
1381    /// `--force` because state loss is unrecoverable (keypair gone).
1382    Destroy {
1383        name: String,
1384        /// Confirm state-deleting operation.
1385        #[arg(long)]
1386        force: bool,
1387        #[arg(long)]
1388        json: bool,
1389    },
1390}
1391
1392/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1393/// session's view of the pinned peer set. `status` is the read-only
1394/// observability primitive (alias for `wire session mesh-status`);
1395/// Group-chat verbs (v0.13.3). Membership is a creator-signed roster
1396/// (`src/group.rs`); send fans a signed message over the member set.
1397#[derive(Subcommand, Debug)]
1398pub enum GroupCommand {
1399    /// Create a new group — you become the creator + sole member, roster signed.
1400    Create {
1401        /// Group name (human label).
1402        name: String,
1403        #[arg(long)]
1404        json: bool,
1405    },
1406    /// Add a bilaterally-VERIFIED pinned peer to a group you created (Member tier).
1407    Add {
1408        /// Group id or name.
1409        group: String,
1410        /// Peer handle (must be a VERIFIED pinned peer).
1411        peer: String,
1412        #[arg(long)]
1413        json: bool,
1414    },
1415    /// Send a message to every other member of a group (signed fan-out).
1416    Send {
1417        /// Group id or name.
1418        group: String,
1419        /// Message text.
1420        message: String,
1421        #[arg(long)]
1422        json: bool,
1423    },
1424    /// Show recent messages received for a group.
1425    Tail {
1426        /// Group id or name.
1427        group: String,
1428        /// Max messages to show.
1429        #[arg(long, default_value_t = 20)]
1430        limit: usize,
1431        #[arg(long)]
1432        json: bool,
1433    },
1434    /// List your groups + their members and tiers.
1435    List {
1436        #[arg(long)]
1437        json: bool,
1438    },
1439    /// Mint a shareable join code for a group (a self-contained token carrying
1440    /// the room coords + signed roster). Anyone you give it to can `wire group
1441    /// join <code>` to enter the room at Introduced tier. The code IS the room
1442    /// key — share it only with people you want in the room.
1443    Invite {
1444        /// Group id or name.
1445        group: String,
1446        #[arg(long)]
1447        json: bool,
1448    },
1449    /// Join a group from a code minted by `wire group invite`. Materializes the
1450    /// room locally, pins the existing members on the creator's vouch, and
1451    /// announces you to the room so members can verify your messages.
1452    Join {
1453        /// The `wire-group:` code (or bare base64 payload).
1454        code: String,
1455        #[arg(long)]
1456        json: bool,
1457    },
1458}
1459
1460/// `broadcast` fans a signed event to every pinned peer in one call.
1461#[derive(Subcommand, Debug)]
1462pub enum MeshCommand {
1463    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1464    /// per-edge health roll-up across every sister session on this machine.
1465    Status {
1466        /// Threshold in seconds for "stale" classification on an edge.
1467        #[arg(long, default_value_t = 300)]
1468        stale_secs: u64,
1469        #[arg(long)]
1470        json: bool,
1471    },
1472    /// Fan one signed event to every pinned peer. Each peer receives a
1473    /// distinct `event_id` but every copy shares the same `broadcast_id`
1474    /// UUID so receivers can correlate them as a single broadcast.
1475    ///
1476    /// `--scope local` (default) only fans to peers reachable via a same-
1477    /// machine local relay. `--scope federation` only to public-relay
1478    /// peers. `--scope both` to every pinned peer.
1479    ///
1480    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1481    /// for "ack-loop" prevention: a peer responding to a broadcast can
1482    /// exclude its own broadcaster when re-broadcasting.
1483    ///
1484    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1485    /// file, `-` reads stdin (JSON if parseable, else literal).
1486    ///
1487    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1488    /// peers — that would re-introduce the phonebook-scrape risk closed
1489    /// in v0.5.14 (T8).
1490    Broadcast {
1491        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1492        /// `heartbeat`. Same vocabulary as `wire send`.
1493        #[arg(long, default_value = "claim")]
1494        kind: String,
1495        /// `local`, `federation`, or `both`. Default `local`.
1496        #[arg(long, default_value = "local")]
1497        scope: String,
1498        /// Skip a specific peer handle. Repeatable.
1499        #[arg(long)]
1500        exclude: Vec<String>,
1501        /// Drop the broadcast event ID from the relay-side attentiveness
1502        /// nag (`phyllis`) — useful when broadcasting to many peers and
1503        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1504        #[arg(long)]
1505        noreply: bool,
1506        /// Body — string, `@/path` for a file, or `-` for stdin.
1507        body: String,
1508        #[arg(long)]
1509        json: bool,
1510    },
1511    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1512    /// capability-aware addressing. Stored as `profile.role` on the
1513    /// signed agent-card — propagates over the existing pair / .well-
1514    /// known plumbing, no new persistence.
1515    ///
1516    /// First slice of the Layer-2 capability metadata umbrella (#13).
1517    /// `wire mesh route` (issue #21) will consume these tags to pick
1518    /// the right sister for a task.
1519    Role {
1520        #[command(subcommand)]
1521        action: MeshRoleAction,
1522    },
1523    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1524    /// to one sister session and deliver an event to that one peer.
1525    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1526    /// can now address "the reviewer" instead of hard-coding a handle.
1527    ///
1528    /// Strategies:
1529    ///   - `round-robin` (default): per-role cursor, persisted at
1530    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1531    ///   - `first`: alphabetically-first matching sister. Deterministic.
1532    ///   - `random`: uniform random among matches. Stateless.
1533    ///
1534    /// Pinned-peers-only by construction (same posture as `broadcast`).
1535    /// Caller must already have the target sister pinned in
1536    /// `state.peers` — otherwise we can't sign + push. Run
1537    /// `wire session pair-all-local` first if the mesh isn't wired.
1538    Route {
1539        /// Role to match (operator-defined tag from `wire mesh role set`).
1540        role: String,
1541        /// `round-robin` (default), `first`, or `random`.
1542        #[arg(long, default_value = "round-robin")]
1543        strategy: String,
1544        /// Skip a specific sister handle. Repeatable.
1545        #[arg(long)]
1546        exclude: Vec<String>,
1547        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1548        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1549        #[arg(long, default_value = "claim")]
1550        kind: String,
1551        /// Body — string, `@/path` for a file, or `-` for stdin.
1552        body: String,
1553        #[arg(long)]
1554        json: bool,
1555    },
1556}
1557
1558/// v0.6.4: subcommands of `wire mesh role`.
1559#[derive(Subcommand, Debug)]
1560pub enum MeshRoleAction {
1561    /// Assign self to a role. Role is a free-form ASCII string
1562    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1563    /// the vocabulary out-of-band — common starters: `planner`,
1564    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1565    Set {
1566        role: String,
1567        #[arg(long)]
1568        json: bool,
1569    },
1570    /// Read self or a peer's role. With no arg, prints self. With a
1571    /// handle, reads from the peer's pinned agent-card.
1572    Get {
1573        peer: Option<String>,
1574        #[arg(long)]
1575        json: bool,
1576    },
1577    /// List roles across every sister session on this machine. Reads
1578    /// each session's agent-card by path — no network, no env mutation.
1579    List {
1580        #[arg(long)]
1581        json: bool,
1582    },
1583    /// Remove self from any assigned role. Re-signs the card with
1584    /// `profile.role: null`.
1585    Clear {
1586        #[arg(long)]
1587        json: bool,
1588    },
1589}
1590
1591#[derive(Subcommand, Debug)]
1592pub enum ServiceAction {
1593    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1594    /// load it. Idempotent — re-running re-bootstraps an existing service.
1595    ///
1596    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1597    /// process). Pass `--local-relay` to install the loopback relay
1598    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1599    /// transport sister-Claudes use to coordinate on the same machine
1600    /// (v0.5.17 dual-slot). The two services have distinct labels +
1601    /// log files, so you can install both.
1602    Install {
1603        /// Install the local-relay service instead of the daemon.
1604        #[arg(long)]
1605        local_relay: bool,
1606        #[arg(long)]
1607        json: bool,
1608    },
1609    /// Unload + delete the service unit. Daemon keeps running until the
1610    /// next reboot or `wire upgrade`; this only changes the boot-time
1611    /// behaviour.
1612    Uninstall {
1613        /// Uninstall the local-relay service instead of the daemon.
1614        #[arg(long)]
1615        local_relay: bool,
1616        #[arg(long)]
1617        json: bool,
1618    },
1619    /// Report whether the unit is installed + active.
1620    Status {
1621        /// Show status of the local-relay service instead of the daemon.
1622        #[arg(long)]
1623        local_relay: bool,
1624        #[arg(long)]
1625        json: bool,
1626    },
1627}
1628
1629#[derive(Subcommand, Debug)]
1630pub enum ResponderCommand {
1631    /// Publish this agent's auto-responder health.
1632    Set {
1633        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1634        status: String,
1635        /// Optional operator-facing reason.
1636        #[arg(long)]
1637        reason: Option<String>,
1638        /// Emit JSON.
1639        #[arg(long)]
1640        json: bool,
1641    },
1642    /// Read responder health for self, or for a paired peer.
1643    Get {
1644        /// Optional peer handle; omitted means this agent's own slot.
1645        peer: Option<String>,
1646        /// Emit JSON.
1647        #[arg(long)]
1648        json: bool,
1649    },
1650}
1651
1652#[derive(Subcommand, Debug)]
1653pub enum ProfileAction {
1654    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1655    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1656    /// (JSON array) and `now` (JSON object).
1657    Set {
1658        field: String,
1659        value: String,
1660        #[arg(long)]
1661        json: bool,
1662    },
1663    /// Show all profile fields. Equivalent to `wire whois`.
1664    Get {
1665        #[arg(long)]
1666        json: bool,
1667    },
1668    /// Clear a profile field.
1669    Clear {
1670        field: String,
1671        #[arg(long)]
1672        json: bool,
1673    },
1674}
1675
1676/// Entry point — parse and dispatch.
1677pub fn run() -> Result<()> {
1678    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1679    // the session registry and adopt that session's home for this
1680    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1681    // detect — `wire whoami` / `wire monitor` from a project cwd now
1682    // resolve to that project's session identity, not the machine
1683    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1684    //
1685    // MUST run before any thread spawn — call it FIRST, before
1686    // `Cli::parse` (which uses clap internals only) and before any
1687    // command dispatch (which may spawn workers).
1688    crate::session::maybe_adopt_session_wire_home("cli");
1689    let cli = Cli::parse();
1690    match cli.command {
1691        Command::Init {
1692            handle,
1693            name,
1694            relay,
1695            offline,
1696            json,
1697        } => cmd_init(
1698            Some(&handle),
1699            name.as_deref(),
1700            relay.as_deref(),
1701            offline,
1702            json,
1703        ),
1704        Command::Status { peer, json } => {
1705            if let Some(peer) = peer {
1706                cmd_status_peer(&peer, json)
1707            } else {
1708                cmd_status(json)
1709            }
1710        }
1711        Command::Whoami {
1712            json,
1713            short,
1714            colored,
1715        } => cmd_whoami(json_default(json), short, colored),
1716        Command::Peers { json } => cmd_peers(json_default(json)),
1717        Command::Here { json } => cmd_here(json_default(json)),
1718        Command::Completions { shell } => {
1719            // v0.9.5: print shell completion script to stdout. Operator
1720            // pipes into their shell's completion dir; tab completion
1721            // covers verbs (dial, send, pending, accept, etc.) AND
1722            // their flags. Peer-name dynamic completion is a future
1723            // shell-side enhancement; clap_complete only ships the
1724            // static grammar.
1725            use clap::CommandFactory;
1726            let mut cmd = Cli::command();
1727            clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1728            Ok(())
1729        }
1730        Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1731        Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1732        Command::Send {
1733            peer,
1734            kind_or_body,
1735            body,
1736            deadline,
1737            no_auto_pair,
1738            queue,
1739            json,
1740        } => {
1741            // P0.S: smart-positional API. `wire send peer body` =
1742            // kind=claim. `wire send peer kind body` = explicit kind.
1743            let (kind, body) = match body {
1744                Some(real_body) => (kind_or_body, real_body),
1745                None => ("claim".to_string(), kind_or_body),
1746            };
1747            cmd_send(
1748                &peer,
1749                &kind,
1750                &body,
1751                deadline.as_deref(),
1752                no_auto_pair,
1753                queue,
1754                json_default(json),
1755            )
1756        }
1757        Command::Dial {
1758            name,
1759            message,
1760            json,
1761        } => cmd_dial(&name, message.as_deref(), json_default(json)),
1762        Command::Tail {
1763            peer,
1764            json,
1765            limit,
1766            oldest,
1767        } => cmd_tail(peer.as_deref(), json, limit, oldest),
1768        Command::Monitor {
1769            peer,
1770            json,
1771            include_handshake,
1772            interval_ms,
1773            replay,
1774        } => cmd_monitor(
1775            peer.as_deref(),
1776            json,
1777            include_handshake,
1778            interval_ms,
1779            replay,
1780        ),
1781        Command::Verify { path, json } => cmd_verify(&path, json),
1782        Command::Responder { command } => match command {
1783            ResponderCommand::Set {
1784                status,
1785                reason,
1786                json,
1787            } => cmd_responder_set(&status, reason.as_deref(), json),
1788            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1789        },
1790        Command::Mcp => cmd_mcp(),
1791        Command::RelayServer {
1792            bind,
1793            local_only,
1794            uds,
1795        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1796        Command::BindRelay {
1797            url,
1798            scope,
1799            replace,
1800            migrate_pinned,
1801            json,
1802        } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1803        Command::AddPeerSlot {
1804            handle,
1805            url,
1806            slot_id,
1807            slot_token,
1808            json,
1809        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1810        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1811        Command::Pull { json } => cmd_pull(json),
1812        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1813        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1814        Command::ForgetPeer {
1815            handle,
1816            purge,
1817            json,
1818        } => cmd_forget_peer(&handle, purge, json),
1819        Command::Supervisor { json } => cmd_supervisor(json),
1820        Command::Daemon {
1821            interval,
1822            once,
1823            all_sessions,
1824            session,
1825            json,
1826        } => cmd_daemon(interval, once, all_sessions, session, json),
1827        Command::PairHost {
1828            relay,
1829            yes,
1830            timeout,
1831            detach,
1832            json,
1833        } => {
1834            if detach {
1835                cmd_pair_host_detach(&relay, json)
1836            } else {
1837                cmd_pair_host(&relay, yes, timeout)
1838            }
1839        }
1840        Command::PairJoin {
1841            code_phrase,
1842            relay,
1843            yes,
1844            timeout,
1845            detach,
1846            json,
1847        } => {
1848            if detach {
1849                cmd_pair_join_detach(&code_phrase, &relay, json)
1850            } else {
1851                cmd_pair_join(&code_phrase, &relay, yes, timeout)
1852            }
1853        }
1854        Command::PairConfirm {
1855            code_phrase,
1856            digits,
1857            json,
1858        } => cmd_pair_confirm(&code_phrase, &digits, json),
1859        Command::PairList {
1860            json,
1861            watch,
1862            watch_interval,
1863        } => cmd_pair_list(json, watch, watch_interval),
1864        Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1865        Command::PairWatch {
1866            code_phrase,
1867            status,
1868            timeout,
1869            json,
1870        } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1871        Command::Pair {
1872            handle,
1873            code,
1874            relay,
1875            yes,
1876            timeout,
1877            no_setup,
1878            detach,
1879        } => {
1880            // P0.P (0.5.11): if the handle is in `nick@domain` form, route to
1881            // the zero-paste megacommand path — `wire pair slancha-spark@
1882            // wireup.net` does add + poll-for-ack + verify in one shot. The
1883            // SAS / code-based pair flow stays available for handles without
1884            // `@` (bootstrap pairing between two boxes that don't yet share a
1885            // relay directory).
1886            if handle.contains('@') && code.is_none() {
1887                cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1888            } else if detach {
1889                cmd_pair_detach(&handle, code.as_deref(), &relay)
1890            } else {
1891                cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1892            }
1893        }
1894        Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1895        Command::PairAccept { peer, json } => {
1896            let j = json_default(json);
1897            deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1898            cmd_pair_accept(&peer, j)
1899        }
1900        Command::PairReject { peer, json } => {
1901            let j = json_default(json);
1902            deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1903            cmd_pair_reject(&peer, j)
1904        }
1905        Command::PairListInbound { json } => {
1906            let j = json_default(json);
1907            deprecation_warn("pair-list-inbound", "pending", j);
1908            cmd_pair_list_inbound(j)
1909        }
1910        Command::Session(cmd) => cmd_session(cmd),
1911        Command::Identity { cmd } => cmd_identity(cmd),
1912        Command::Mesh(cmd) => cmd_mesh(cmd),
1913        Command::Group(cmd) => cmd_group(cmd),
1914        Command::Enroll(cmd) => cmd_enroll(cmd),
1915        Command::Invite {
1916            relay,
1917            ttl,
1918            uses,
1919            share,
1920            json,
1921        } => cmd_invite(&relay, ttl, uses, share, json),
1922        Command::Accept { target, json } => {
1923            // v0.9.4: smart-dispatch retired. `wire accept` always means
1924            // pair-accept by name. URL-shaped input gets a deprecation
1925            // banner pointing at `wire accept-invite <URL>` and then
1926            // (for back-compat with v0.9 scripts) routes to the invite
1927            // accept path one last time. v1.0 will reject URLs here.
1928            let j = json_default(json);
1929            if target.starts_with("wire://pair?") {
1930                deprecation_warn("accept-url", "accept-invite <url>", j);
1931                cmd_accept(&target, j)
1932            } else {
1933                cmd_pair_accept(&target, j)
1934            }
1935        }
1936        Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1937        Command::Whois {
1938            handle,
1939            json,
1940            relay,
1941        } => {
1942            // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1943            // resolves through the local identity layer (pinned peers
1944            // + local sister sessions). `wire whois <nick>@<relay>`
1945            // keeps the existing federation `.well-known/wire/agent`
1946            // path. `wire whois` (no arg) prints self via the original
1947            // path. The character nickname is the canonical operator-
1948            // facing name as of v0.8 — most callers should hit the
1949            // local route.
1950            match handle.as_deref() {
1951                Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1952                other => cmd_whois(other, json, relay.as_deref()),
1953            }
1954        }
1955        Command::Add {
1956            handle,
1957            relay,
1958            local_sister,
1959            json,
1960        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1961        Command::Up {
1962            relay,
1963            name,
1964            with_local,
1965            no_local,
1966            json,
1967        } => cmd_up(
1968            relay.as_deref(),
1969            name.as_deref(),
1970            with_local.as_deref(),
1971            no_local,
1972            json,
1973        ),
1974        Command::Doctor {
1975            json,
1976            recent_rejections,
1977        } => cmd_doctor(json, recent_rejections),
1978        Command::Upgrade {
1979            check,
1980            local,
1981            restart_mcp,
1982            refresh_stale_children,
1983            json,
1984        } => cmd_upgrade(check, local, restart_mcp, refresh_stale_children, json),
1985        Command::Service { action } => cmd_service(action),
1986        Command::Diag { action } => cmd_diag(action),
1987        Command::Claim {
1988            nick,
1989            relay,
1990            public_url,
1991            hidden,
1992            json,
1993        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1994        Command::Profile { action } => cmd_profile(action),
1995        Command::Setup {
1996            apply,
1997            statusline,
1998            remove,
1999        } => {
2000            if statusline {
2001                cmd_setup_statusline(apply, remove)
2002            } else {
2003                cmd_setup(apply)
2004            }
2005        }
2006        Command::Notify {
2007            interval,
2008            peer,
2009            once,
2010            json,
2011        } => cmd_notify(interval, peer.as_deref(), once, json),
2012        Command::Quiet { action } => cmd_quiet(action),
2013    }
2014}
2015
2016// ---------- quiet (v0.14.x toast kill switch) ----------
2017
2018/// Path to the file that, when present, silences every wire desktop
2019/// toast. Created by `wire quiet on`, removed by `wire quiet off`. Read
2020/// per-toast-call by `crate::os_notify::toasts_disabled` — no daemon
2021/// restart needed for the toggle to take effect, just for binary swap.
2022fn quiet_flag_path() -> Result<std::path::PathBuf> {
2023    Ok(config::config_dir()?.join("quiet"))
2024}
2025
2026fn cmd_quiet(action: QuietAction) -> Result<()> {
2027    match action {
2028        QuietAction::On => {
2029            let path = quiet_flag_path()?;
2030            if let Some(parent) = path.parent() {
2031                std::fs::create_dir_all(parent).with_context(|| {
2032                    format!("creating config dir for quiet flag: {}", parent.display())
2033                })?;
2034            }
2035            // Idempotent: open with create-if-missing, write nothing.
2036            std::fs::OpenOptions::new()
2037                .create(true)
2038                .truncate(true)
2039                .write(true)
2040                .open(&path)
2041                .with_context(|| format!("writing {}", path.display()))?;
2042            println!(
2043                "wire quiet: ON (toasts silenced — file at {})",
2044                path.display()
2045            );
2046            Ok(())
2047        }
2048        QuietAction::Off => {
2049            let path = quiet_flag_path()?;
2050            match std::fs::remove_file(&path) {
2051                Ok(()) => println!("wire quiet: OFF (toasts re-enabled)"),
2052                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2053                    println!("wire quiet: OFF (was already off)")
2054                }
2055                Err(e) => return Err(anyhow!("removing {}: {e}", path.display())),
2056            }
2057            // Re-check env: a user can override file-off with WIRE_NO_TOASTS=1.
2058            if std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0") {
2059                println!(
2060                    "  note: WIRE_NO_TOASTS={} is still set in env — toasts stay silenced for this process / daemon until `launchctl unsetenv WIRE_NO_TOASTS` (or unset in your shell).",
2061                    std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
2062                );
2063            }
2064            Ok(())
2065        }
2066        QuietAction::Status { json } => {
2067            let env_set = std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0");
2068            let file_present = quiet_flag_path()?.exists();
2069            let (state, via) = match (env_set, file_present) {
2070                (true, _) => ("on", "env"),
2071                (false, true) => ("on", "file"),
2072                (false, false) => ("off", "none"),
2073            };
2074            if json {
2075                println!(
2076                    "{}",
2077                    serde_json::to_string(&json!({
2078                        "state": state,
2079                        "via": via,
2080                        "file": quiet_flag_path()?.display().to_string(),
2081                        "env_WIRE_NO_TOASTS": std::env::var("WIRE_NO_TOASTS").ok(),
2082                    }))?
2083                );
2084            } else {
2085                match (env_set, file_present) {
2086                    (true, _) => println!(
2087                        "wire quiet: ON (via WIRE_NO_TOASTS={} in env)",
2088                        std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
2089                    ),
2090                    (false, true) => println!(
2091                        "wire quiet: ON (via file at {})",
2092                        quiet_flag_path()?.display()
2093                    ),
2094                    (false, false) => println!("wire quiet: OFF"),
2095                }
2096            }
2097            Ok(())
2098        }
2099    }
2100}
2101
2102// ---------- init ----------
2103
2104fn cmd_init(
2105    handle: Option<&str>,
2106    name: Option<&str>,
2107    relay: Option<&str>,
2108    offline: bool,
2109    as_json: bool,
2110) -> Result<()> {
2111    // One-name rule: a typed handle (if any) is only a vanity seed — the
2112    // persona is derived from the keypair fingerprint, so it has no effect
2113    // on the resulting identity. `wire up` passes None (there is no name to
2114    // type); an explicit `wire init <handle>` passes Some and we surface the
2115    // "ignored in favor of persona" notice for transparency.
2116    if let Some(h) = handle
2117        && !h
2118            .chars()
2119            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2120    {
2121        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
2122    }
2123    if config::is_initialized()? {
2124        bail!(
2125            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
2126            config::config_dir()?
2127        );
2128    }
2129    // v0.9.1 smart-default reachability. If the operator passed neither
2130    // --relay nor --offline, probe the conventional local relay at
2131    // http://127.0.0.1:8771 and auto-attach if healthy. Closes the
2132    // silent-slotless footgun WITHOUT the v0.9 rejection wall, which
2133    // forced operators through a three-flag decision tree on first
2134    // invocation. Bare `wire init <handle>` is now ergonomic again
2135    // whenever a local relay is running (the common dev setup).
2136    //
2137    // Probe order:
2138    //   1. --relay <url>          → use it
2139    //   2. --offline               → skip slot allocation (rare power-user)
2140    //   3. local relay reachable  → auto-attach + log to stderr
2141    //   4. otherwise               → bail with actionable options
2142    let mut resolved_relay: Option<String> = relay.map(str::to_string);
2143    if resolved_relay.is_none() && !offline {
2144        let default_local = "http://127.0.0.1:8771";
2145        let client = crate::relay_client::RelayClient::new(default_local);
2146        if client.check_healthz().is_ok() {
2147            eprintln!(
2148                "wire init: local relay at {default_local} reachable — auto-attaching. \
2149                 Use --relay <url> to pick a different relay, --offline to skip."
2150            );
2151            resolved_relay = Some(default_local.to_string());
2152        } else {
2153            // v0.9.5: interactive prompt for first-time operators
2154            // when the smart-default can't auto-attach. Detect TTY on
2155            // stdin AND stderr — only prompt for humans. CI / agents
2156            // / non-interactive shells fall through to the explicit
2157            // error wall (unchanged behavior since v0.9.1).
2158            use std::io::{BufRead, IsTerminal, Write};
2159            let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
2160            if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
2161                eprintln!("wire init: no local relay reachable at {default_local}.");
2162                eprint!(
2163                    "  Bind to public federation relay https://wireup.net instead? \
2164                     [Y/n/offline/url]: "
2165                );
2166                let _ = std::io::stderr().flush();
2167                let mut input = String::new();
2168                let _ = std::io::stdin().lock().read_line(&mut input);
2169                let answer = input.trim();
2170                match answer {
2171                    "" | "y" | "Y" | "yes" | "YES" => {
2172                        eprintln!("wire init: binding to https://wireup.net");
2173                        resolved_relay = Some("https://wireup.net".to_string());
2174                    }
2175                    "n" | "N" | "no" | "NO" => {
2176                        bail!(
2177                            "wire init: declined federation default; re-run with --relay <url> or --offline."
2178                        );
2179                    }
2180                    "offline" | "OFFLINE" => {
2181                        eprintln!(
2182                            "wire init: proceeding offline. \
2183                             Run `wire bind-relay <url>` before pairing."
2184                        );
2185                        // Fall through with resolved_relay still None;
2186                        // the `offline` flag is conceptually set but
2187                        // the caller's local doesn't need updating —
2188                        // resolved_relay = None + offline behavior
2189                        // is identical for the rest of cmd_init.
2190                    }
2191                    url if url.starts_with("http://") || url.starts_with("https://") => {
2192                        eprintln!("wire init: binding to {url}");
2193                        resolved_relay = Some(url.to_string());
2194                    }
2195                    other => {
2196                        bail!(
2197                            "wire init: unrecognized answer `{other}` — \
2198                             expected Y/n/offline/<url>. Re-run with --relay or --offline."
2199                        );
2200                    }
2201                }
2202            } else {
2203                bail!(
2204                    "wire init: no relay specified and no local relay reachable at \
2205                     http://127.0.0.1:8771.\n\
2206                     Pick one (or just run `wire up`):\n\
2207                     • `wire service install --local-relay` — start the local relay, then re-run\n\
2208                     • `wire up @wireup.net` — bind to public federation in one command\n\
2209                     • `wire init --offline` — generate keypair only \
2210                     (peers cannot reach you until you `wire bind-relay <url>` later)"
2211                );
2212            }
2213        }
2214    }
2215    let relay = resolved_relay.as_deref();
2216
2217    config::ensure_dirs()?;
2218    let (sk_seed, pk_bytes) = generate_keypair();
2219    config::write_private_key(&sk_seed)?;
2220
2221    // v0.11 ONE-NAME: derive the character nickname from a synthetic DID
2222    // using the freshly-generated pubkey, then USE THE CHARACTER as the
2223    // canonical handle. The operator-typed `handle` arg becomes either:
2224    //   - identical to character (already-canonical input — no-op), OR
2225    //   - overridden in favor of character (operator-typed name was a
2226    //     vanity layer that would never have been federation-reachable).
2227    // Either way, agent-card.handle ends up == character, and every
2228    // downstream surface (relay phonebook, .well-known, dial/send) keys
2229    // on the same name an operator sees in their statusline.
2230    //
2231    // Per the v0.11 directive: "If you can't call someone via a name,
2232    // don't let them have it as a name." Operator-typed handles violated
2233    // that rule because the character was the displayed name but the
2234    // handle was the addressable one. Now they're the same string.
2235    // The seed string only fills the (immediately-discarded) handle portion
2236    // of a synthetic DID; the persona derives from the fp suffix regardless,
2237    // so any seed yields the same identity.
2238    let seed = handle.unwrap_or("agent");
2239    let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
2240    let character = crate::character::Character::from_did(&synth_did);
2241    let canonical_handle: &str = &character.nickname;
2242    if let Some(typed) = handle
2243        && typed != canonical_handle
2244    {
2245        eprintln!(
2246            "wire init: one-name rule — typed `{typed}` ignored in favor of \
2247             DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
2248        );
2249    }
2250
2251    let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
2252    // Card-emit (RFC-001 Phase 1b): attach operator/org claims if enrolled
2253    // (fail-soft no-op otherwise; signed below so the sig covers the claims).
2254    let card = crate::enroll::with_op_claims_if_enrolled(card)?;
2255    let signed = sign_agent_card(&card, &sk_seed);
2256    config::write_agent_card(&signed)?;
2257
2258    let mut trust = empty_trust();
2259    add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
2260    config::write_trust(&trust)?;
2261
2262    let fp = fingerprint(&pk_bytes);
2263    let key_id = make_key_id(canonical_handle, &pk_bytes);
2264    // Rebind `handle` for the rest of cmd_init so downstream prints,
2265    // relay-state writes, etc. all reference the canonical name.
2266    let handle = canonical_handle;
2267
2268    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
2269    let mut relay_info: Option<(String, String)> = None;
2270    if let Some(url) = relay {
2271        let normalized = url.trim_end_matches('/');
2272        let client = crate::relay_client::RelayClient::new(normalized);
2273        client.check_healthz()?;
2274        let alloc = client.allocate_slot(Some(handle))?;
2275        let mut state = config::read_relay_state()?;
2276        state["self"] = json!({
2277            "relay_url": normalized,
2278            "slot_id": alloc.slot_id.clone(),
2279            "slot_token": alloc.slot_token,
2280        });
2281        config::write_relay_state(&state)?;
2282        relay_info = Some((normalized.to_string(), alloc.slot_id));
2283    }
2284
2285    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
2286    if as_json {
2287        let mut out = json!({
2288            "did": did_str.clone(),
2289            "fingerprint": fp,
2290            "key_id": key_id,
2291            "config_dir": config::config_dir()?.to_string_lossy(),
2292        });
2293        if let Some((url, slot_id)) = &relay_info {
2294            out["relay_url"] = json!(url);
2295            out["slot_id"] = json!(slot_id);
2296        }
2297        println!("{}", serde_json::to_string(&out)?);
2298    } else {
2299        println!("generated {did_str} (ed25519:{key_id})");
2300        println!(
2301            "config written to {}",
2302            config::config_dir()?.to_string_lossy()
2303        );
2304        if let Some((url, slot_id)) = &relay_info {
2305            println!("bound to relay {url} (slot {slot_id})");
2306            println!();
2307            println!(
2308                "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
2309            );
2310        } else {
2311            println!();
2312            println!(
2313                "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
2314            );
2315        }
2316    }
2317    Ok(())
2318}
2319
2320// ---------- status ----------
2321
2322fn cmd_status(as_json: bool) -> Result<()> {
2323    let initialized = config::is_initialized()?;
2324
2325    let mut summary = json!({
2326        "initialized": initialized,
2327    });
2328
2329    if initialized {
2330        let card = config::read_agent_card()?;
2331        let did = card
2332            .get("did")
2333            .and_then(Value::as_str)
2334            .unwrap_or("")
2335            .to_string();
2336        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
2337        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
2338        // legacy cards.
2339        let handle = card
2340            .get("handle")
2341            .and_then(Value::as_str)
2342            .map(str::to_string)
2343            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2344        let pk_b64 = card
2345            .get("verify_keys")
2346            .and_then(Value::as_object)
2347            .and_then(|m| m.values().next())
2348            .and_then(|v| v.get("key"))
2349            .and_then(Value::as_str)
2350            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2351        let pk_bytes = crate::signing::b64decode(pk_b64)?;
2352        summary["did"] = json!(did);
2353        summary["handle"] = json!(handle);
2354        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2355        summary["capabilities"] = card
2356            .get("capabilities")
2357            .cloned()
2358            .unwrap_or_else(|| json!([]));
2359
2360        let trust = config::read_trust()?;
2361        let relay_state_for_tier =
2362            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2363        let mut peers = Vec::new();
2364        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2365            for (peer_handle, _agent) in agents {
2366                if peer_handle == &handle {
2367                    continue; // self
2368                }
2369                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
2370                // for peers we've pinned but never received a pair_drop_ack
2371                // from, so the operator sees the "we can't send to them yet"
2372                // state instead of seeing a misleading VERIFIED.
2373                peers.push(json!({
2374                    "handle": peer_handle,
2375                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2376                }));
2377            }
2378        }
2379        summary["peers"] = json!(peers);
2380
2381        let relay_state = config::read_relay_state()?;
2382        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2383        if !summary["self_relay"].is_null() {
2384            // Hide slot_token from default view.
2385            if let Some(obj) = summary["self_relay"].as_object_mut() {
2386                obj.remove("slot_token");
2387            }
2388        }
2389        summary["peer_slots_count"] = json!(
2390            relay_state
2391                .get("peers")
2392                .and_then(Value::as_object)
2393                .map(|m| m.len())
2394                .unwrap_or(0)
2395        );
2396
2397        // Outbox / inbox queue depth (file count + total events)
2398        let outbox = config::outbox_dir()?;
2399        let inbox = config::inbox_dir()?;
2400        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2401        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2402
2403        // v0.5.19: liveness snapshot through a single helper so this
2404        // surface and `wire doctor` agree by construction. Issue #2:
2405        // doctor PASSed while status said DOWN for 25 min because each
2406        // computed liveness independently. ensure_up::daemon_liveness
2407        // is the only path now.
2408        let snap = crate::ensure_up::daemon_liveness();
2409        let mut daemon = json!({
2410            "running": snap.pidfile_alive,
2411            "pid": snap.pidfile_pid,
2412            "all_running_pids": snap.pgrep_pids,
2413            "orphans": snap.orphan_pids,
2414        });
2415        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2416            daemon["version"] = json!(d.version);
2417            daemon["bin_path"] = json!(d.bin_path);
2418            daemon["did"] = json!(d.did);
2419            daemon["relay_url"] = json!(d.relay_url);
2420            daemon["started_at"] = json!(d.started_at);
2421            daemon["schema"] = json!(d.schema);
2422            if d.version != env!("CARGO_PKG_VERSION") {
2423                daemon["version_mismatch"] = json!({
2424                    "daemon": d.version.clone(),
2425                    "cli": env!("CARGO_PKG_VERSION"),
2426                });
2427            }
2428        } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2429            daemon["pidfile_form"] = json!("legacy-int");
2430            daemon["version_mismatch"] = json!({
2431                "daemon": "<pre-0.5.11>",
2432                "cli": env!("CARGO_PKG_VERSION"),
2433            });
2434        }
2435        // v0.14.2 (#162): surface "is the sync loop actually running RIGHT NOW?"
2436        // distinct from "is there a process named `wire daemon` somewhere?".
2437        // pidfile_alive + a fresh last_sync are both required for "healthy
2438        // sync"; pidfile_alive + no recent last_sync = the daemon is up but
2439        // wedged. last_sync_age_seconds = null = no record (never ran here).
2440        let last_sync_age = crate::ensure_up::last_sync_age_seconds();
2441        if let Some(rec) = crate::ensure_up::read_last_sync_record() {
2442            daemon["last_sync_at"] = json!(rec.ts);
2443            daemon["last_sync_age_seconds"] = json!(last_sync_age);
2444            daemon["last_sync_push_n"] = json!(rec.push_n);
2445            daemon["last_sync_pull_n"] = json!(rec.pull_n);
2446            daemon["last_sync_rejected_n"] = json!(rec.rejected_n);
2447        } else {
2448            daemon["last_sync_at"] = Value::Null;
2449            daemon["last_sync_age_seconds"] = Value::Null;
2450        }
2451        // v0.14.2 (#162 fix #2 + #7 surface gap, post-merge of #167/#168):
2452        // honey-pine round-trip dogfood (2026-06-01) confirmed pending_push_count
2453        // + stale_sync + stream_state surface in MCP wire_status but not in CLI
2454        // `wire status`. Shared helpers in config.rs keep both surfaces in lock
2455        // so future doctor/web checks pick up the same numbers.
2456        // Per-peer breakdown introduced 2026-06-01 after coral
2457        // dogfood found 3 events stuck on `orchid-savanna`
2458        // (PENDING_ACK pair). Aggregate count was already
2459        // surfaced; the missing piece was attribution — operator
2460        // had to manually walk per-peer outbox files to learn
2461        // which pair was wedged. Compute both from a single
2462        // breakdown so total + per-peer detail can't diverge.
2463        let pending_breakdown = config::compute_pending_push_breakdown();
2464        let pending_total: u64 = pending_breakdown.iter().map(|p| p.count).sum();
2465        daemon["pending_push_count"] = json!(pending_total);
2466        daemon["pending_push_breakdown"] = json!(pending_breakdown);
2467        daemon["stale_sync"] = json!(config::stale_sync(last_sync_age));
2468        daemon["stream_state"] = config::read_stream_state();
2469        // v0.14.2 (#162 diagnostic, post-#170): annotate orphan pids
2470        // with their cmdline + the `--session <name>` arg the
2471        // supervisor (or operator) tagged them with. honey-pine spent
2472        // multiple sessions diagnosing "wire status reports DOWN
2473        // while comms work" — turned out the orphan was a launchd-
2474        // spawned daemon serving a different WIRE_HOME. Surfacing
2475        // "(serving session 'X')" on each orphan collapses the
2476        // diagnostic time. Best-effort: cmdline read can race exit
2477        // → fields just stay absent rather than failing the status
2478        // call.
2479        // v0.14.2 #173 follow-up (post-#174 hotfix): the supervisor's
2480        // children no longer carry `--session <name>` in their cmdline
2481        // (WIRE_HOME env is the sole contract), so the pid → session
2482        // mapping has to walk per-session pidfiles instead. The
2483        // cmdline `parse_session_arg` path is kept as a fallback for
2484        // operator-spawned `wire daemon --session foo` runs.
2485        let pid_session_map = crate::session::pid_to_session_map();
2486        let orphans_detail: Vec<Value> = snap
2487            .orphan_pids
2488            .iter()
2489            .map(|pid| {
2490                let cmdline = crate::platform::pid_cmdline(*pid);
2491                let session = pid_session_map.get(pid).cloned().or_else(|| {
2492                    cmdline
2493                        .as_deref()
2494                        .and_then(crate::platform::parse_session_arg)
2495                        .map(str::to_string)
2496                });
2497                json!({
2498                    "pid": pid,
2499                    "cmdline": cmdline,
2500                    "session": session,
2501                })
2502            })
2503            .collect();
2504        daemon["orphans_detail"] = json!(orphans_detail);
2505        summary["daemon"] = daemon;
2506
2507        // Pending pair sessions — counts by status.
2508        let pending = crate::pending_pair::list_pending().unwrap_or_default();
2509        let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2510        for p in &pending {
2511            *counts.entry(p.status.clone()).or_default() += 1;
2512        }
2513        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
2514        // v0.14.2: filter out records whose peer is already pinned at
2515        // VERIFIED+ tier (i.e., bilateral completed via some other
2516        // path). Pre-#171 `maybe_consume_pair_drop_ack` didn't clear
2517        // pending_inbound on receipt of the peer's ack; operators
2518        // with pre-#171 data on disk see their VERIFIED peers show
2519        // up in `inbound pair requests`, prompting a misleading
2520        // `wire pair-accept` suggestion. The stale records still
2521        // exist on disk (operator can clear via `wire reject` if
2522        // they care); the status surface just stops showing them.
2523        // Records for genuinely-not-pinned peers — or peers at
2524        // UNTRUSTED/PENDING_ACK — surface normally.
2525        let pinned_verified_handles: std::collections::HashSet<String> =
2526            crate::config::read_trust()
2527                .ok()
2528                .and_then(|t| t.get("agents").and_then(Value::as_object).cloned())
2529                .map(|agents| {
2530                    agents
2531                        .into_iter()
2532                        .filter_map(|(handle, agent)| {
2533                            let tier = agent.get("tier").and_then(Value::as_str).unwrap_or("");
2534                            if matches!(tier, "VERIFIED" | "ORG_VERIFIED") {
2535                                Some(handle)
2536                            } else {
2537                                None
2538                            }
2539                        })
2540                        .collect()
2541                })
2542                .unwrap_or_default();
2543        let raw_pending_inbound =
2544            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2545        let stale_inbound_handles: Vec<&str> = raw_pending_inbound
2546            .iter()
2547            .filter(|p| pinned_verified_handles.contains(&p.peer_handle))
2548            .map(|p| p.peer_handle.as_str())
2549            .collect();
2550        let pending_inbound: Vec<_> = raw_pending_inbound
2551            .iter()
2552            .filter(|p| !pinned_verified_handles.contains(&p.peer_handle))
2553            .collect();
2554        let inbound_handles: Vec<&str> = pending_inbound
2555            .iter()
2556            .map(|p| p.peer_handle.as_str())
2557            .collect();
2558        summary["pending_pairs"] = json!({
2559            "total": pending.len(),
2560            "by_status": counts,
2561            "inbound_count": pending_inbound.len(),
2562            "inbound_handles": inbound_handles,
2563            // Surface the filtered-as-stale set so operators with
2564            // pre-#171 leftover records can find + clean them via
2565            // `wire reject <handle>` if they care.
2566            "stale_inbound_count": stale_inbound_handles.len(),
2567            "stale_inbound_handles": stale_inbound_handles,
2568        });
2569    }
2570
2571    if as_json {
2572        println!("{}", serde_json::to_string(&summary)?);
2573    } else if !initialized {
2574        println!("not initialized — run `wire init <handle>` first");
2575    } else {
2576        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
2577        println!(
2578            "fingerprint:   {}",
2579            summary["fingerprint"].as_str().unwrap_or("?")
2580        );
2581        println!("capabilities:  {}", summary["capabilities"]);
2582        if !summary["self_relay"].is_null() {
2583            println!(
2584                "self relay:    {} (slot {})",
2585                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2586                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2587            );
2588        } else {
2589            println!("self relay:    (not bound — run `wire pair-host --relay <url>` to bind)");
2590        }
2591        println!(
2592            "peers:         {}",
2593            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2594        );
2595        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2596            println!(
2597                "  - {:<20} tier={}",
2598                p["handle"].as_str().unwrap_or(""),
2599                p["tier"].as_str().unwrap_or("?")
2600            );
2601        }
2602        println!(
2603            "outbox:        {} file(s), {} event(s) queued",
2604            summary["outbox"]["files"].as_u64().unwrap_or(0),
2605            summary["outbox"]["events"].as_u64().unwrap_or(0)
2606        );
2607        println!(
2608            "inbox:         {} file(s), {} event(s) received",
2609            summary["inbox"]["files"].as_u64().unwrap_or(0),
2610            summary["inbox"]["events"].as_u64().unwrap_or(0)
2611        );
2612        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2613        let daemon_pid = summary["daemon"]["pid"]
2614            .as_u64()
2615            .map(|p| p.to_string())
2616            .unwrap_or_else(|| "—".to_string());
2617        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2618        let version_suffix = if !daemon_version.is_empty() {
2619            format!(" v{daemon_version}")
2620        } else {
2621            String::new()
2622        };
2623        println!(
2624            "daemon:        {} (pid {}{})",
2625            if daemon_running { "running" } else { "DOWN" },
2626            daemon_pid,
2627            version_suffix,
2628        );
2629        // P1.7: surface version mismatch + orphan procs loudly.
2630        if let Some(mm) = summary["daemon"].get("version_mismatch") {
2631            println!(
2632                "               !! version mismatch: daemon={} CLI={}. \
2633                 run `wire upgrade` to swap atomically.",
2634                mm["daemon"].as_str().unwrap_or("?"),
2635                mm["cli"].as_str().unwrap_or("?"),
2636            );
2637        }
2638        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2639            && !orphans.is_empty()
2640        {
2641            let pids: Vec<String> = orphans
2642                .iter()
2643                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2644                .collect();
2645            println!(
2646                "               !! orphan daemon process(es): pids {}. \
2647                 pgrep saw them but pidfile didn't — likely stale process from \
2648                 prior install. Multiple daemons race the relay cursor.",
2649                pids.join(", ")
2650            );
2651            // v0.14.2 (#162 diagnostic): per-orphan annotation so
2652            // operators don't have to grep ps themselves. Each orphan
2653            // shows its --session arg (or "(no --session)" for legacy
2654            // launchd daemons + operator-spawned `wire daemon` without
2655            // the flag — those default to dirs::state_dir() WIRE_HOME,
2656            // which often diverges from the shell's cwd-mapped session).
2657            if let Some(details) = summary["daemon"]["orphans_detail"].as_array() {
2658                for d in details {
2659                    let pid = d["pid"].as_u64().unwrap_or(0);
2660                    let session = d["session"].as_str();
2661                    let cmdline = d["cmdline"].as_str();
2662                    // v0.14.2: distinguish the supervisor (orchestrator —
2663                    // doesn't sync any single WIRE_HOME) from a legacy
2664                    // single-session daemon (DOES sync a WIRE_HOME, just
2665                    // not via --session). Pre-fix both were labelled "no
2666                    // --session — serving default WIRE_HOME" which was
2667                    // misleading for the supervisor case: it doesn't
2668                    // serve any home, it spawns child daemons that do.
2669                    let is_supervisor = cmdline
2670                        .map(|c| c.contains("--all-sessions"))
2671                        .unwrap_or(false);
2672                    match (session, cmdline, is_supervisor) {
2673                        (Some(s), _, _) => {
2674                            println!("                  pid {pid}: serving session '{s}'")
2675                        }
2676                        (None, Some(c), true) if !c.is_empty() => println!(
2677                            "                  pid {pid}: supervisor — orchestrates one daemon per session, doesn't sync directly (cmdline={c})"
2678                        ),
2679                        (None, Some(c), false) if !c.is_empty() => println!(
2680                            "                  pid {pid}: (no --session — serving default WIRE_HOME) cmdline={c}"
2681                        ),
2682                        _ => println!(
2683                            "                  pid {pid}: (cmdline unavailable — pid may have just exited)"
2684                        ),
2685                    }
2686                }
2687            }
2688        }
2689        // v0.14.2 (#162 #2/#7 surface): three lines that catch the
2690        // silent-send class operators kept missing on 0.14.1. Order matters
2691        // — last_sync first (is the loop running?), then pending_push_count
2692        // (am I leaking sends?), then stream_state (will live-monitor see
2693        // anything?).
2694        let last_sync_age = summary["daemon"]["last_sync_age_seconds"].as_u64();
2695        let last_sync_at = summary["daemon"]["last_sync_at"].as_str();
2696        match (last_sync_at, last_sync_age) {
2697            (Some(ts), Some(age)) => {
2698                let stale = summary["daemon"]["stale_sync"].as_bool().unwrap_or(false);
2699                let stale_tag = if stale { "  !! STALE (>60s)" } else { "" };
2700                let p = summary["daemon"]["last_sync_push_n"].as_u64().unwrap_or(0);
2701                let pl = summary["daemon"]["last_sync_pull_n"].as_u64().unwrap_or(0);
2702                let r = summary["daemon"]["last_sync_rejected_n"]
2703                    .as_u64()
2704                    .unwrap_or(0);
2705                println!(
2706                    "last sync:     {ts} ({age}s ago) push={p} pull={pl} rejected={r}{stale_tag}"
2707                );
2708            }
2709            _ => {
2710                println!(
2711                    "last sync:     (none recorded) — daemon hasn't completed a cycle in this WIRE_HOME"
2712                );
2713            }
2714        }
2715        let pending_push = summary["daemon"]["pending_push_count"]
2716            .as_u64()
2717            .unwrap_or(0);
2718        if pending_push > 0 {
2719            println!(
2720                "pending push:  {pending_push} event(s) queued but not yet pushed to relay — \
2721                 if stale_sync, this is the silent-send class (#162 fix #2)"
2722            );
2723            // v0.14.3: per-peer attribution. coral dogfood
2724            // (2026-06-01) found 3 events stuck on a PENDING_ACK
2725            // pair; the aggregate count gave no hint which pair.
2726            // Expand into one line per peer with tier + a hint
2727            // about the action the tier implies.
2728            if let Some(breakdown) = summary["daemon"]["pending_push_breakdown"].as_array() {
2729                for entry in breakdown {
2730                    let peer = entry.get("peer").and_then(Value::as_str).unwrap_or("?");
2731                    let tier = entry
2732                        .get("tier")
2733                        .and_then(Value::as_str)
2734                        .unwrap_or("UNKNOWN");
2735                    let count = entry.get("count").and_then(Value::as_u64).unwrap_or(0);
2736                    // Tier-specific hint. PENDING_ACK = wedged
2737                    // pair (operator action: `wire pair-accept`
2738                    // or `wire reject`). UNTRUSTED = peer not yet
2739                    // pinned (rare but possible if trust file
2740                    // was hand-edited). VERIFIED + queued =
2741                    // #162 silent-send class; daemon should push
2742                    // imminently or `stale_sync` will flip.
2743                    let hint = match tier {
2744                        "PENDING_ACK" => {
2745                            " — pair never completed; daemon won't push until accept/reject"
2746                        }
2747                        "UNTRUSTED" => " — peer not pinned; daemon won't push to UNTRUSTED",
2748                        _ => "",
2749                    };
2750                    println!("  {count:>4} → {peer} ({tier}){hint}");
2751                }
2752            }
2753        } else {
2754            println!("pending push:  0");
2755        }
2756        match summary["daemon"]["stream_state"]
2757            .get("state")
2758            .and_then(Value::as_str)
2759        {
2760            Some(s) => {
2761                let last_evt = summary["daemon"]["stream_state"]
2762                    .get("last_event_at")
2763                    .and_then(Value::as_str)
2764                    .unwrap_or("never");
2765                let reconnects = summary["daemon"]["stream_state"]
2766                    .get("reconnect_count")
2767                    .and_then(Value::as_u64)
2768                    .unwrap_or(0);
2769                println!("stream:        {s} (last event {last_evt}, reconnects {reconnects})");
2770            }
2771            None => {
2772                println!(
2773                    "stream:        (no stream_state.json) — daemon predates #168 or hasn't \
2774                     subscribed yet; live monitor will fall back to polling cadence"
2775                );
2776            }
2777        }
2778        let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2779        let inbound_count = summary["pending_pairs"]["inbound_count"]
2780            .as_u64()
2781            .unwrap_or(0);
2782        if pending_total > 0 {
2783            print!("pending pairs: {pending_total}");
2784            if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2785                let parts: Vec<String> = obj
2786                    .iter()
2787                    .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2788                    .collect();
2789                if !parts.is_empty() {
2790                    print!(" ({})", parts.join(", "));
2791                }
2792            }
2793            println!();
2794        } else if inbound_count == 0 {
2795            println!("pending pairs: none");
2796        }
2797        // v0.5.14: separate line for pending-inbound zero-paste requests.
2798        // Loud because each one is awaiting an operator gesture and the
2799        // capability hasn't flowed yet.
2800        if inbound_count > 0 {
2801            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2802                .as_array()
2803                .map(|a| {
2804                    a.iter()
2805                        .filter_map(|v| v.as_str().map(str::to_string))
2806                        .collect()
2807                })
2808                .unwrap_or_default();
2809            println!(
2810                "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2811                handles.join(", "),
2812            );
2813        }
2814    }
2815    Ok(())
2816}
2817
2818pub(crate) fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2819    if !dir.exists() {
2820        return Ok(json!({"files": 0, "events": 0}));
2821    }
2822    let mut files = 0usize;
2823    let mut events = 0usize;
2824    for entry in std::fs::read_dir(dir)? {
2825        let path = entry?.path();
2826        // v0.14.2: skip pushed-log audit files (`<peer>.pushed.jsonl`)
2827        // when scanning the outbox dir. Those are append-only audit
2828        // logs of "queued → pushed" lifecycle events (#162 fix #2);
2829        // counting them as outbox events inflates `outbox.events` in
2830        // `wire status` by orders of magnitude. Pre-fix, an operator
2831        // with 8328 events delivered across a peer's lifetime saw
2832        // "outbox: 71811 events queued" when actual unpushed work was
2833        // 11 events. Inbox scans are unaffected because the inbox dir
2834        // contains only `<peer>.jsonl`, never `.pushed.jsonl`.
2835        if path.extension().map(|x| x == "jsonl").unwrap_or(false)
2836            && !path
2837                .file_name()
2838                .and_then(|s| s.to_str())
2839                .map(|n| n.ends_with(".pushed.jsonl"))
2840                .unwrap_or(false)
2841        {
2842            files += 1;
2843            if let Ok(body) = std::fs::read_to_string(&path) {
2844                events += body.lines().filter(|l| !l.trim().is_empty()).count();
2845            }
2846        }
2847    }
2848    Ok(json!({"files": files, "events": events}))
2849}
2850
2851// ---------- responder health ----------
2852
2853fn responder_status_allowed(status: &str) -> bool {
2854    matches!(
2855        status,
2856        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2857    )
2858}
2859
2860fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2861    let state = config::read_relay_state()?;
2862    let (label, slot_info) = match peer {
2863        Some(peer) => (
2864            peer.to_string(),
2865            state
2866                .get("peers")
2867                .and_then(|p| p.get(peer))
2868                .ok_or_else(|| {
2869                    anyhow!(
2870                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
2871                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
2872                         (`wire peers` lists who you've already paired with.)"
2873                    )
2874                })?,
2875        ),
2876        None => (
2877            "self".to_string(),
2878            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2879                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2880            })?,
2881        ),
2882    };
2883    let relay_url = slot_info["relay_url"]
2884        .as_str()
2885        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2886        .to_string();
2887    let slot_id = slot_info["slot_id"]
2888        .as_str()
2889        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2890        .to_string();
2891    let slot_token = slot_info["slot_token"]
2892        .as_str()
2893        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2894        .to_string();
2895    Ok((label, relay_url, slot_id, slot_token))
2896}
2897
2898/// v0.14.2 (#170 / honey-pine BUG 3): `wire supervisor` — operator-
2899/// facing multi-session topology view. Reads `SupervisorState` and
2900/// renders it as JSON or pretty text. `wire status` covers the
2901/// "is THIS session syncing?" question; `wire supervisor` covers
2902/// "what is the supervisor and every session's daemon doing across
2903/// the box?". No mutation.
2904fn cmd_supervisor(as_json: bool) -> Result<()> {
2905    let state = crate::daemon_supervisor::read_supervisor_state()?;
2906    if as_json {
2907        println!("{}", serde_json::to_string(&state)?);
2908        return Ok(());
2909    }
2910    let pid_label = state
2911        .supervisor_pid
2912        .map(|p| p.to_string())
2913        .unwrap_or_else(|| "—".to_string());
2914    println!(
2915        "supervisor:    {} (pid {pid_label})",
2916        if state.supervisor_alive {
2917            "running"
2918        } else {
2919            "DOWN"
2920        },
2921    );
2922    let sessions_total = state.sessions.len();
2923    let sessions_with_daemon = state.sessions.iter().filter(|s| s.daemon_alive).count();
2924    println!(
2925        "sessions:      {sessions_total} initialized, {sessions_with_daemon} with live daemon"
2926    );
2927    // Per-session table — only show sessions whose daemon state is
2928    // "interesting" (alive OR has a stale pidfile pointing at a dead
2929    // process) to keep the output bounded on a 100+-session box. Pure
2930    // healthy sessions get a single summary line above.
2931    let mut shown = 0usize;
2932    for s in &state.sessions {
2933        // Skip sessions with no pidfile at all — they've never had a
2934        // daemon, nothing to report.
2935        if s.daemon_pid.is_none() {
2936            continue;
2937        }
2938        // Skip a "boringly healthy" session: alive daemon + recent
2939        // sync. Only worth showing when something's off.
2940        let recent = matches!(s.last_sync_age_seconds, Some(age) if age <= 60);
2941        if s.daemon_alive && recent {
2942            continue;
2943        }
2944        shown += 1;
2945        let age = s
2946            .last_sync_age_seconds
2947            .map(|a| format!("{a}s"))
2948            .unwrap_or_else(|| "?".to_string());
2949        let pid = s
2950            .daemon_pid
2951            .map(|p| p.to_string())
2952            .unwrap_or_else(|| "—".to_string());
2953        let liveness = if s.daemon_alive { "running" } else { "DOWN" };
2954        println!(
2955            "  {:<24} pid {:<7} {} last_sync {}",
2956            s.name, pid, liveness, age
2957        );
2958    }
2959    if shown == 0 && sessions_with_daemon > 0 {
2960        println!(
2961            "  (every session with a daemon is alive + synced within 60s — pass --json for full per-session detail)"
2962        );
2963    }
2964    if !state.unmanaged_pids.is_empty() {
2965        let pids: Vec<String> = state.unmanaged_pids.iter().map(u32::to_string).collect();
2966        println!(
2967            "unmanaged:     {} pid(s) — {} — `wire daemon` processes not mapped to any session's pidfile.",
2968            state.unmanaged_pids.len(),
2969            pids.join(", ")
2970        );
2971        // Annotate each unmanaged pid the same way `wire status` does
2972        // for orphans: cmdline + parsed --session arg.
2973        for pid in &state.unmanaged_pids {
2974            let cmdline = crate::platform::pid_cmdline(*pid);
2975            let session = cmdline
2976                .as_deref()
2977                .and_then(crate::platform::parse_session_arg);
2978            match (session, cmdline.as_deref()) {
2979                (Some(s), _) => println!("  pid {pid}: --session '{s}'"),
2980                (None, Some(c)) if !c.is_empty() => println!("  pid {pid}: cmdline={c}"),
2981                _ => println!("  pid {pid}: cmdline unavailable"),
2982            }
2983        }
2984    }
2985    // v0.14.2: surface sessions whose live daemon is on a stale
2986    // binary version. Supervisor's existing-pidfile check protects
2987    // alive daemons from respawn regardless of binary age, so
2988    // mid-upgrade fleets accumulate version-drifted children.
2989    // Operators see the list here + can act (manual kill, or a
2990    // future `wire upgrade --refresh-stale-children`).
2991    if !state.stale_binary_sessions.is_empty() {
2992        let our_version = env!("CARGO_PKG_VERSION");
2993        println!(
2994            "stale binary:  {} session(s) running daemons older than this CLI (v{our_version}). Supervisor won't respawn them until they exit.",
2995            state.stale_binary_sessions.len()
2996        );
2997        for name in &state.stale_binary_sessions {
2998            // Look up the recorded version + pid so the diagnostic
2999            // line is actionable: operator can `kill <pid>` to let
3000            // the supervisor respawn on the fresh binary.
3001            let session = state.sessions.iter().find(|s| &s.name == name);
3002            let ver = session
3003                .and_then(|s| s.daemon_version.clone())
3004                .unwrap_or_else(|| "?".to_string());
3005            let pid = session
3006                .and_then(|s| s.daemon_pid)
3007                .map(|p| p.to_string())
3008                .unwrap_or_else(|| "?".to_string());
3009            println!("  {name:<24} running v{ver} (pid {pid})");
3010        }
3011    }
3012    Ok(())
3013}
3014
3015fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
3016    if !responder_status_allowed(status) {
3017        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
3018    }
3019    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
3020    let now = time::OffsetDateTime::now_utc()
3021        .format(&time::format_description::well_known::Rfc3339)
3022        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3023    let mut record = json!({
3024        "status": status,
3025        "set_at": now,
3026    });
3027    if let Some(reason) = reason {
3028        record["reason"] = json!(reason);
3029    }
3030    if status == "online" {
3031        record["last_success_at"] = json!(now);
3032    }
3033    let client = crate::relay_client::RelayClient::new(&relay_url);
3034    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
3035    if as_json {
3036        println!("{}", serde_json::to_string(&saved)?);
3037    } else {
3038        let reason = saved
3039            .get("reason")
3040            .and_then(Value::as_str)
3041            .map(|r| format!(" — {r}"))
3042            .unwrap_or_default();
3043        println!(
3044            "responder {}{}",
3045            saved
3046                .get("status")
3047                .and_then(Value::as_str)
3048                .unwrap_or(status),
3049            reason
3050        );
3051    }
3052    Ok(())
3053}
3054
3055fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
3056    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
3057    let client = crate::relay_client::RelayClient::new(&relay_url);
3058    let health = client.responder_health_get(&slot_id, &slot_token)?;
3059    if as_json {
3060        println!(
3061            "{}",
3062            serde_json::to_string(&json!({
3063                "target": label,
3064                "responder_health": health,
3065            }))?
3066        );
3067    } else if health.is_null() {
3068        println!("{label}: responder health not reported");
3069    } else {
3070        let status = health
3071            .get("status")
3072            .and_then(Value::as_str)
3073            .unwrap_or("unknown");
3074        let reason = health
3075            .get("reason")
3076            .and_then(Value::as_str)
3077            .map(|r| format!(" — {r}"))
3078            .unwrap_or_default();
3079        let last_success = health
3080            .get("last_success_at")
3081            .and_then(Value::as_str)
3082            .map(|t| format!(" (last_success: {t})"))
3083            .unwrap_or_default();
3084        println!("{label}: {status}{reason}{last_success}");
3085    }
3086    Ok(())
3087}
3088
3089fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
3090    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
3091    let client = crate::relay_client::RelayClient::new(&relay_url);
3092
3093    let started = std::time::Instant::now();
3094    let transport_ok = client.healthz().unwrap_or(false);
3095    let latency_ms = started.elapsed().as_millis() as u64;
3096
3097    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
3098    let now = std::time::SystemTime::now()
3099        .duration_since(std::time::UNIX_EPOCH)
3100        .map(|d| d.as_secs())
3101        .unwrap_or(0);
3102    let attention = match last_pull_at_unix {
3103        Some(last) if now.saturating_sub(last) <= 300 => json!({
3104            "status": "ok",
3105            "last_pull_at_unix": last,
3106            "age_seconds": now.saturating_sub(last),
3107            "event_count": event_count,
3108        }),
3109        Some(last) => json!({
3110            "status": "stale",
3111            "last_pull_at_unix": last,
3112            "age_seconds": now.saturating_sub(last),
3113            "event_count": event_count,
3114        }),
3115        None => json!({
3116            "status": "never_pulled",
3117            "last_pull_at_unix": Value::Null,
3118            "event_count": event_count,
3119        }),
3120    };
3121
3122    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
3123    let responder = if responder_health.is_null() {
3124        json!({"status": "not_reported", "record": Value::Null})
3125    } else {
3126        json!({
3127            "status": responder_health
3128                .get("status")
3129                .and_then(Value::as_str)
3130                .unwrap_or("unknown"),
3131            "record": responder_health,
3132        })
3133    };
3134
3135    let report = json!({
3136        "peer": peer,
3137        "transport": {
3138            "status": if transport_ok { "ok" } else { "error" },
3139            "relay_url": relay_url,
3140            "latency_ms": latency_ms,
3141        },
3142        "attention": attention,
3143        "responder": responder,
3144    });
3145
3146    if as_json {
3147        println!("{}", serde_json::to_string(&report)?);
3148    } else {
3149        let transport_line = if transport_ok {
3150            format!("ok relay reachable ({latency_ms}ms)")
3151        } else {
3152            "error relay unreachable".to_string()
3153        };
3154        println!("transport      {transport_line}");
3155        match report["attention"]["status"].as_str().unwrap_or("unknown") {
3156            "ok" => println!(
3157                "attention      ok last pull {}s ago",
3158                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
3159            ),
3160            "stale" => println!(
3161                "attention      stale last pull {}m ago",
3162                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
3163            ),
3164            "never_pulled" => println!("attention      never pulled since relay reset"),
3165            other => println!("attention      {other}"),
3166        }
3167        if report["responder"]["status"] == "not_reported" {
3168            println!("auto-responder not reported");
3169        } else {
3170            let record = &report["responder"]["record"];
3171            let status = record
3172                .get("status")
3173                .and_then(Value::as_str)
3174                .unwrap_or("unknown");
3175            let reason = record
3176                .get("reason")
3177                .and_then(Value::as_str)
3178                .map(|r| format!(" — {r}"))
3179                .unwrap_or_default();
3180            println!("auto-responder {status}{reason}");
3181        }
3182    }
3183    Ok(())
3184}
3185
3186// (Old cmd_join stub removed — superseded by cmd_pair_join below.)
3187
3188// ---------- whoami ----------
3189
3190/// Return the current cwd with the user's home dir abbreviated to `~/`.
3191/// Used in whoami `--short` / `--colored` output so multi-window operators
3192/// see *what project* each Claude is working in alongside the character.
3193fn current_cwd_display() -> String {
3194    let cwd = match std::env::current_dir() {
3195        Ok(c) => c,
3196        Err(_) => return String::from("?"),
3197    };
3198    if let Some(home) = dirs::home_dir()
3199        && let Ok(rel) = cwd.strip_prefix(&home)
3200    {
3201        // strip_prefix returns "" for cwd == home itself; show "~" then.
3202        let rel_str = rel.to_string_lossy();
3203        if rel_str.is_empty() {
3204            return String::from("~");
3205        }
3206        return format!("~/{rel_str}");
3207    }
3208    cwd.to_string_lossy().into_owned()
3209}
3210
3211/// v0.14: extract the inline op claims from an agent card (or pinned
3212/// trust row) for surfacing on operator-facing read paths. Returns the
3213/// subset of fields actually present and non-null — operators read the
3214/// absence to mean "not enrolled / older peer".
3215///
3216/// Surfaced fields: `op_did`, `op_pubkey`, `op_cert`, `org_memberships`,
3217/// `schema_version`. All RFC-001-defined; all public commits, safe to
3218/// surface on every read verb. Centralized here so whoami / peers / whois
3219/// stay in lock-step as the inline set grows (e.g. `sso_attest` in v0.15).
3220///
3221/// `pub(crate)` so the MCP surface (`src/mcp.rs`) wires the same helper
3222/// into `tool_whoami` / `tool_peers` — agents reading MCP responses must
3223/// see the same op claims that operators see via CLI.
3224pub(crate) fn op_claims_from_card(card: &Value) -> serde_json::Map<String, Value> {
3225    let mut out = serde_json::Map::new();
3226    for key in [
3227        "op_did",
3228        "op_pubkey",
3229        "op_cert",
3230        "org_memberships",
3231        "schema_version",
3232    ] {
3233        if let Some(v) = card.get(key)
3234            && !v.is_null()
3235        {
3236            out.insert(key.to_string(), v.clone());
3237        }
3238    }
3239    out
3240}
3241
3242fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
3243    if !config::is_initialized()? {
3244        // v0.14.x: with per-session WIRE_HOME (`sessions/by-key/<hash>`), a
3245        // freshly-spawned session's home starts EMPTY until `wire up`. The
3246        // machine-readable consumers that poll whoami every render — statusline
3247        // scripts, the `.wire-name` cache refreshers — hit that uninitialized
3248        // state constantly. Bailing (exit 1, no stdout) made them crash on
3249        // empty stdin or freeze on a stale name. Degrade gracefully here,
3250        // matching `wire here --json`, so a missing identity is a parseable
3251        // signal rather than a hard failure. The bare interactive (tty, no
3252        // JSON) path keeps its actionable hint + exit 1.
3253        // Precedence mirrors the initialized path below: an explicit --short
3254        // / --colored beats the piped-stdout JSON default (`json_default`),
3255        // and bare interactive `wire whoami` still gets the actionable hint.
3256        if short {
3257            println!("(uninitialized) · {}", current_cwd_display());
3258            return Ok(());
3259        }
3260        if colored {
3261            println!(
3262                "\x1b[2m(uninitialized)\x1b[0m \x1b[2m·\x1b[0m {}",
3263                current_cwd_display()
3264            );
3265            return Ok(());
3266        }
3267        if as_json {
3268            println!(
3269                "{}",
3270                serde_json::to_string(&json!({
3271                    "initialized": false,
3272                    "cwd": current_cwd_display(),
3273                }))?
3274            );
3275            return Ok(());
3276        }
3277        bail!("not initialized — run `wire init <handle>` first");
3278    }
3279    let card = config::read_agent_card()?;
3280    let did = card
3281        .get("did")
3282        .and_then(Value::as_str)
3283        .unwrap_or("")
3284        .to_string();
3285    let handle = card
3286        .get("handle")
3287        .and_then(Value::as_str)
3288        .map(str::to_string)
3289        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
3290    // v0.11: character is purely DID-derived. No overrides — the
3291    // operator-rename verb is gone and display.json reads are stripped
3292    // because they introduced a second name that peers couldn't find.
3293    let character = crate::character::Character::from_did(&did);
3294
3295    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
3296    // so operators tab-flipping between multiple Claude windows see both
3297    // *who* this session is (character) and *what* it's working on (cwd).
3298    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
3299    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
3300    let cwd_display = current_cwd_display();
3301
3302    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
3303    // beyond did — these calls are hot (statusline polls ~300ms).
3304    if short {
3305        println!("{} · {}", character.short(), cwd_display);
3306        return Ok(());
3307    }
3308    if colored {
3309        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
3310        return Ok(());
3311    }
3312
3313    let pk_b64 = card
3314        .get("verify_keys")
3315        .and_then(Value::as_object)
3316        .and_then(|m| m.values().next())
3317        .and_then(|v| v.get("key"))
3318        .and_then(Value::as_str)
3319        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3320    let pk_bytes = crate::signing::b64decode(pk_b64)?;
3321    let fp = fingerprint(&pk_bytes);
3322    let key_id = make_key_id(&handle, &pk_bytes);
3323    let capabilities = card
3324        .get("capabilities")
3325        .cloned()
3326        .unwrap_or_else(|| json!(["wire/v3.1"]));
3327
3328    if as_json {
3329        // v0.11: character_override is always false now (no rename verb,
3330        // no display.json reads). Field stays for back-compat with v0.10
3331        // JSON consumers that key off it.
3332        let has_override = false;
3333        let mut payload = serde_json::Map::new();
3334        // Symmetric with the uninitialized branch above so consumers can
3335        // branch on a single key instead of probing for `did`.
3336        payload.insert("initialized".into(), json!(true));
3337        payload.insert("did".into(), json!(did));
3338        payload.insert("handle".into(), json!(handle));
3339        payload.insert("fingerprint".into(), json!(fp));
3340        payload.insert("key_id".into(), json!(key_id));
3341        payload.insert("public_key_b64".into(), json!(pk_b64));
3342        payload.insert("capabilities".into(), capabilities);
3343        payload.insert(
3344            "config_dir".into(),
3345            json!(config::config_dir()?.to_string_lossy()),
3346        );
3347        payload.insert("persona".into(), serde_json::to_value(&character)?);
3348        payload.insert("persona_override".into(), json!(has_override));
3349        // v0.14: surface the RFC-001 op claims (when enrolled) on the
3350        // canonical operator read verb. Absent ⇒ pre-v0.14 card or not
3351        // yet enrolled. See `op_claims_from_card` rationale.
3352        for (k, v) in op_claims_from_card(&card) {
3353            payload.insert(k, v);
3354        }
3355        println!("{}", serde_json::to_string(&payload)?);
3356    } else {
3357        println!("{}", character.colored());
3358        println!("{did} (ed25519:{key_id})");
3359        println!("fingerprint: {fp}");
3360        println!("capabilities: {capabilities}");
3361        // v0.14: when enrolled, surface op_did + membership count so
3362        // the operator can spot at a glance whether the marquee identity
3363        // layer is active. Silent when not enrolled (no clutter for
3364        // pre-v0.14 cards).
3365        if let Some(op_did) = card.get("op_did").and_then(Value::as_str) {
3366            let memberships = card
3367                .get("org_memberships")
3368                .and_then(Value::as_array)
3369                .map(|a| a.len())
3370                .unwrap_or(0);
3371            let plural = if memberships == 1 { "" } else { "s" };
3372            println!("enrolled: {op_did} ({memberships} org membership{plural})");
3373        }
3374    }
3375    Ok(())
3376}
3377
3378// ---------- identity (v0.7.0-alpha.3) ----------
3379
3380fn cmd_enroll(cmd: EnrollCommand) -> Result<()> {
3381    match cmd {
3382        EnrollCommand::Op { handle, json } => {
3383            let (sk, pk) = crate::signing::generate_keypair();
3384            crate::config::write_op_key(&sk)?;
3385            crate::config::write_op_handle(&handle)?;
3386            let op_did = crate::agent_card::did_for_op(&handle, &pk);
3387            let op_pubkey = crate::signing::b64encode(&pk);
3388            if json {
3389                println!(
3390                    "{}",
3391                    serde_json::to_string(&json!({"op_did": op_did, "op_pubkey": op_pubkey}))?
3392                );
3393            } else {
3394                println!(
3395                    "→ operator enrolled\n  op_did:    {op_did}\n  op_pubkey: {op_pubkey}\n  key saved 0600 at {:?}",
3396                    crate::config::op_key_path()?
3397                );
3398            }
3399            Ok(())
3400        }
3401        EnrollCommand::OrgCreate { handle, json } => {
3402            let (sk, pk) = crate::signing::generate_keypair();
3403            let org_did = crate::agent_card::did_for_org(&handle, &pk);
3404            crate::config::write_org_key(&org_did, &sk)?;
3405            let org_pubkey = crate::signing::b64encode(&pk);
3406            if json {
3407                println!(
3408                    "{}",
3409                    serde_json::to_string(&json!({"org_did": org_did, "org_pubkey": org_pubkey}))?
3410                );
3411            } else {
3412                println!(
3413                    "→ organization created\n  org_did:    {org_did}\n  org_pubkey: {org_pubkey}\n  key saved 0600 at {:?}",
3414                    crate::config::org_key_path(&org_did)?
3415                );
3416            }
3417            Ok(())
3418        }
3419        EnrollCommand::OrgAddMember { op_did, org, json } => {
3420            if !crate::agent_card::is_op_did(&op_did) {
3421                bail!("not a valid operator DID (did:wire:op:<handle>-<32hex>): {op_did}");
3422            }
3423            let org_sk = crate::config::read_org_key(&org).with_context(|| {
3424                format!("no stored key for org {org} — run `wire enroll org-create` first")
3425            })?;
3426            let org_pk = ed25519_dalek::SigningKey::from_bytes(&org_sk)
3427                .verifying_key()
3428                .to_bytes();
3429            let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did)?;
3430            let org_pubkey = crate::signing::b64encode(&org_pk);
3431            // Store locally so card-emit can attach it (same-machine operator);
3432            // also printed below for the cross-machine share case.
3433            crate::config::add_membership(&org, &org_pubkey, &member_cert)?;
3434            if json {
3435                println!(
3436                    "{}",
3437                    serde_json::to_string(&json!({
3438                        "org_did": org, "org_pubkey": org_pubkey, "member_cert": member_cert
3439                    }))?
3440                );
3441            } else {
3442                println!(
3443                    "→ membership issued for {op_did}\n  add to the operator's card org_memberships[]:\n  {{\"org_did\": \"{org}\", \"org_pubkey\": \"{org_pubkey}\", \"member_cert\": \"{member_cert}\"}}"
3444                );
3445            }
3446            Ok(())
3447        }
3448        EnrollCommand::AddMembership {
3449            bundle,
3450            org,
3451            org_pubkey,
3452            member_cert,
3453            json,
3454        } => cmd_enroll_add_membership(bundle, org, org_pubkey, member_cert, json),
3455        EnrollCommand::Republish { json } => {
3456            // Rebuild the on-disk card with current enrollment, then republish
3457            // via the same path `profile set` uses. Closes the enroll-after-init
3458            // DX gap (see `enroll::rebuild_card_with_current_claims`).
3459            let card = crate::enroll::rebuild_card_with_current_claims()?;
3460            let published = republish_card_to_phonebook();
3461            let op_did = card
3462                .get("op_did")
3463                .and_then(Value::as_str)
3464                .map(str::to_string);
3465            let n_memberships = card
3466                .get("org_memberships")
3467                .and_then(Value::as_array)
3468                .map(Vec::len)
3469                .unwrap_or(0);
3470            if json {
3471                println!(
3472                    "{}",
3473                    serde_json::to_string(&json!({
3474                        "op_did": op_did,
3475                        "org_memberships": n_memberships,
3476                        "published": published,
3477                    }))?
3478                );
3479            } else {
3480                match op_did {
3481                    Some(did) => println!(
3482                        "→ card rebuilt with current enrollment\n  op_did:    {did}\n  memberships: {n_memberships}"
3483                    ),
3484                    None => println!(
3485                        "→ card rebuilt — no operator enrolled (claims stripped if previously present)"
3486                    ),
3487                }
3488                print_profile_publish_result(&published);
3489            }
3490            Ok(())
3491        }
3492    }
3493}
3494
3495/// Implementation of `wire enroll add-membership` (closes #127).
3496///
3497/// Validates the bundle before storing — a malformed / wrong-key cert
3498/// would corrupt the next `wire enroll republish` (the bundle is
3499/// attached verbatim to the agent card; a bad bundle propagates to
3500/// peers and gets rejected on `evaluate_card_membership`). Verifying
3501/// up-front means the failure is at ingest time, not at publish time.
3502fn cmd_enroll_add_membership(
3503    bundle: Option<String>,
3504    org: Option<String>,
3505    org_pubkey: Option<String>,
3506    member_cert: Option<String>,
3507    as_json: bool,
3508) -> Result<()> {
3509    // Resolve the three fields from either --bundle or the individual flags.
3510    let (org_did, org_pk_b64, cert_b64) = if let Some(b) = bundle {
3511        let v: Value = serde_json::from_str(&b).with_context(|| "parsing --bundle as JSON")?;
3512        let o = v
3513            .get("org_did")
3514            .and_then(Value::as_str)
3515            .ok_or_else(|| anyhow!("--bundle missing 'org_did'"))?
3516            .to_string();
3517        let p = v
3518            .get("org_pubkey")
3519            .and_then(Value::as_str)
3520            .ok_or_else(|| anyhow!("--bundle missing 'org_pubkey'"))?
3521            .to_string();
3522        let c = v
3523            .get("member_cert")
3524            .and_then(Value::as_str)
3525            .ok_or_else(|| anyhow!("--bundle missing 'member_cert'"))?
3526            .to_string();
3527        (o, p, c)
3528    } else {
3529        let o = org.ok_or_else(|| anyhow!("--org is required when --bundle is not set"))?;
3530        let p = org_pubkey
3531            .ok_or_else(|| anyhow!("--org-pubkey is required when --bundle is not set"))?;
3532        let c = member_cert
3533            .ok_or_else(|| anyhow!("--member-cert is required when --bundle is not set"))?;
3534        (o, p, c)
3535    };
3536
3537    // Validate org_did shape — refuse before touching disk.
3538    if !crate::agent_card::is_org_did(&org_did) {
3539        bail!("not a valid organization DID (did:wire:org:<handle>-<32hex>): {org_did}");
3540    }
3541
3542    // This operator must be enrolled — we need op_did to verify the cert
3543    // is FOR US, not for a different operator. A cert valid against some
3544    // other op_did would still verify on the org_pubkey but storing it
3545    // here would be a misattribution.
3546    let op_sk = crate::config::read_op_key().with_context(
3547        || "this operator is not enrolled — run `wire enroll op` first to mint op_did",
3548    )?;
3549    let op_handle = crate::config::read_op_handle()
3550        .ok()
3551        .flatten()
3552        .unwrap_or_else(|| "operator".to_string());
3553    let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
3554        .verifying_key()
3555        .to_bytes();
3556    let op_did = crate::agent_card::did_for_op(&op_handle, &op_pk);
3557
3558    // Decode + verify the cert against org_pubkey + this op_did. Failure
3559    // here is the load-bearing guard against the "stored bundle corrupts
3560    // republish" footgun.
3561    let org_pk_bytes =
3562        crate::signing::b64decode(&org_pk_b64).with_context(|| "decoding --org-pubkey (base64)")?;
3563    crate::identity::verify_member_cert(&org_pk_bytes, &cert_b64, &op_did)
3564        .map_err(|e| anyhow!("member_cert verification failed: {e:?} — bundle is not valid for this operator (op_did={op_did})"))?;
3565
3566    // Idempotent store. add_membership retains-then-pushes so re-running
3567    // with the same org_did replaces the prior entry; multiple distinct
3568    // orgs accumulate.
3569    crate::config::add_membership(&org_did, &org_pk_b64, &cert_b64)?;
3570
3571    if as_json {
3572        println!(
3573            "{}",
3574            serde_json::to_string(&json!({
3575                "stored": true,
3576                "org_did": org_did,
3577                "op_did": op_did,
3578                "note": "run `wire enroll republish` to attach the claim to your agent card and republish",
3579            }))?
3580        );
3581    } else {
3582        println!(
3583            "→ membership stored\n  org_did:  {org_did}\n  op_did:   {op_did}\n  next: `wire enroll republish` to attach + publish"
3584        );
3585    }
3586    Ok(())
3587}
3588
3589fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
3590    match cmd {
3591        // v0.11: IdentityCommand::Rename deleted. The character is the
3592        // one canonical name (DID-derived); a local-display rename
3593        // would create a second name peers can't find, violating the
3594        // "names must be findable" invariant. Aliases (if needed
3595        // later) become relay-claimed entries that ARE findable —
3596        // a different architectural shape from rename.
3597        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
3598        IdentityCommand::List { json } => cmd_session_list(json),
3599        IdentityCommand::Publish {
3600            nick,
3601            relay,
3602            public_url,
3603            hidden,
3604            json,
3605        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
3606        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
3607        IdentityCommand::Create {
3608            name,
3609            anonymous,
3610            local: _,
3611            json,
3612        } => cmd_identity_create(name.as_deref(), anonymous, json),
3613        IdentityCommand::Persist {
3614            name,
3615            as_name,
3616            json,
3617        } => cmd_identity_persist(&name, as_name.as_deref(), json),
3618        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
3619    }
3620}
3621
3622/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
3623/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
3624/// paste into their shell; the identity lives there until reboot
3625/// clears /tmp. Persist promotes it to the real sessions root.
3626fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
3627    if anonymous {
3628        // Generate a unique tmpdir for this anonymous identity.
3629        let rand_suffix = format!("{:08x}", rand::random::<u32>());
3630        let anon_name = name
3631            .map(crate::session::sanitize_name)
3632            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
3633        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
3634        std::fs::create_dir_all(&anon_root)
3635            .with_context(|| format!("creating anon root {anon_root:?}"))?;
3636        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
3637        let session_home = anon_root.join("sessions").join(&anon_name);
3638        std::fs::create_dir_all(&session_home)?;
3639        let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
3640        if !status.success() {
3641            bail!("anonymous identity init failed: {status}");
3642        }
3643        // Register the anonymous name in a SIDE registry so persist
3644        // can find it later. Stored at <anon_root>/anon-marker.json.
3645        let marker = anon_root.join("anon-marker.json");
3646        std::fs::write(
3647            &marker,
3648            serde_json::to_vec_pretty(&serde_json::json!({
3649                "name": anon_name,
3650                "session_home": session_home.to_string_lossy(),
3651                "created_at": time::OffsetDateTime::now_utc()
3652                    .format(&time::format_description::well_known::Rfc3339)
3653                    .unwrap_or_default(),
3654                "kind": "anonymous",
3655            }))?,
3656        )?;
3657        let card = serde_json::from_slice::<Value>(&std::fs::read(
3658            session_home
3659                .join("config")
3660                .join("wire")
3661                .join("agent-card.json"),
3662        )?)?;
3663        let did = card
3664            .get("did")
3665            .and_then(Value::as_str)
3666            .unwrap_or("")
3667            .to_string();
3668        if as_json {
3669            println!(
3670                "{}",
3671                serde_json::to_string(&json!({
3672                    "kind": "anonymous",
3673                    "name": anon_name,
3674                    "did": did,
3675                    "session_home": session_home.to_string_lossy(),
3676                    "anon_root": anon_root.to_string_lossy(),
3677                }))?
3678            );
3679        } else {
3680            println!("created anonymous identity `{anon_name}` ({did})");
3681            println!(
3682                "  session_home: {} (dies on reboot — /tmp)",
3683                session_home.display()
3684            );
3685            println!();
3686            println!("activate in this shell:");
3687            println!("  export WIRE_HOME={}", session_home.display());
3688            println!();
3689            println!("promote to persistent later with:");
3690            println!("  wire identity persist {anon_name}");
3691        }
3692        return Ok(());
3693    }
3694    // --local (or default): delegate to existing session new flow.
3695    let name_arg = name.map(|s| s.to_string());
3696    cmd_session_new(
3697        name_arg.as_deref(),
3698        "https://wireup.net",
3699        false,
3700        "http://127.0.0.1:8771",
3701        false,
3702        None,
3703        false,
3704        None,
3705        true, // no_daemon: identity create just allocates the identity, no daemon
3706        true, // local_only: explicit lifecycle
3707        as_json,
3708    )
3709}
3710
3711/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
3712/// tmpdir to the persistent sessions root + registers in the cwd map.
3713fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
3714    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
3715    let temp = std::env::temp_dir();
3716    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3717    for entry in std::fs::read_dir(&temp)?.flatten() {
3718        let path = entry.path();
3719        if !path
3720            .file_name()
3721            .and_then(|s| s.to_str())
3722            .map(|s| s.starts_with("wire-anon-"))
3723            .unwrap_or(false)
3724        {
3725            continue;
3726        }
3727        let marker = path.join("anon-marker.json");
3728        if let Ok(bytes) = std::fs::read(&marker)
3729            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
3730            && json.get("name").and_then(Value::as_str) == Some(name)
3731        {
3732            let session_home = json
3733                .get("session_home")
3734                .and_then(Value::as_str)
3735                .map(std::path::PathBuf::from)
3736                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
3737            found = Some((path, session_home));
3738            break;
3739        }
3740    }
3741    let (anon_root, anon_session_home) = found.ok_or_else(|| {
3742        anyhow!(
3743            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
3744             run `wire identity list` to see available identities"
3745        )
3746    })?;
3747
3748    let new_name = as_name.unwrap_or(name);
3749    let new_session_home = crate::session::session_dir(new_name)?;
3750    if new_session_home.exists() {
3751        bail!(
3752            "target session `{new_name}` already exists at {new_session_home:?} — \
3753             pick a different name with --as <new-name>"
3754        );
3755    }
3756
3757    // Move the session dir from tmpdir to persistent root.
3758    if let Some(parent) = new_session_home.parent() {
3759        std::fs::create_dir_all(parent)?;
3760    }
3761    std::fs::rename(&anon_session_home, &new_session_home)
3762        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
3763
3764    // Clean up the (now-empty) anon root + marker.
3765    let _ = std::fs::remove_dir_all(&anon_root);
3766
3767    // Register cwd → new_name (operator may have cd'd elsewhere; use the
3768    // session_home's grandparent as the conceptual "cwd" if no other).
3769    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
3770    let cwd_key = crate::session::normalize_cwd_key(&cwd);
3771    let new_name_for_reg = new_name.to_string();
3772    if let Err(e) = crate::session::update_registry(|reg| {
3773        reg.by_cwd.insert(cwd_key, new_name_for_reg);
3774        Ok(())
3775    }) {
3776        eprintln!("wire identity persist: failed to update registry: {e:#}");
3777    }
3778
3779    if as_json {
3780        println!(
3781            "{}",
3782            serde_json::to_string(&json!({
3783                "kind": "persisted",
3784                "from_name": name,
3785                "to_name": new_name,
3786                "session_home": new_session_home.to_string_lossy(),
3787            }))?
3788        );
3789    } else {
3790        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
3791        println!(
3792            "  session_home: {} (survives reboot)",
3793            new_session_home.display()
3794        );
3795        println!("  registered cwd: {}", cwd.display());
3796    }
3797    Ok(())
3798}
3799
3800/// v0.7.0-alpha.20: demote federation → local. Removes the federation
3801/// slot binding from relay.json (and the legacy top-level fields). Keeps
3802/// the keypair + agent-card so re-publish later just calls `wire identity
3803/// publish` again. local → anonymous is NOT supported; destroy + recreate
3804/// is the safer path for that step-down.
3805fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
3806    let sessions = crate::session::list_sessions()?;
3807    let session = sessions
3808        .iter()
3809        .find(|s| s.name == name)
3810        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
3811    let relay_state_path = session
3812        .home_dir
3813        .join("config")
3814        .join("wire")
3815        .join("relay.json");
3816    if !relay_state_path.exists() {
3817        bail!("session `{name}` has no relay state — already demoted?");
3818    }
3819    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
3820    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
3821    let had_fed = self_obj
3822        .get("relay_url")
3823        .and_then(Value::as_str)
3824        .map(|u| {
3825            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
3826        })
3827        .unwrap_or(false);
3828    if !had_fed {
3829        if as_json {
3830            println!(
3831                "{}",
3832                serde_json::to_string(
3833                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
3834                )?
3835            );
3836        } else {
3837            println!("session `{name}` has no federation slot — nothing to demote");
3838        }
3839        return Ok(());
3840    }
3841    // Strip federation: remove top-level relay_url/slot_id/slot_token,
3842    // remove federation-scope entries from endpoints[].
3843    if let Some(self_mut) = state
3844        .as_object_mut()
3845        .and_then(|m| m.get_mut("self"))
3846        .and_then(|s| s.as_object_mut())
3847    {
3848        self_mut.remove("relay_url");
3849        self_mut.remove("slot_id");
3850        self_mut.remove("slot_token");
3851        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
3852            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
3853        }
3854    }
3855    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
3856
3857    if as_json {
3858        println!(
3859            "{}",
3860            serde_json::to_string(
3861                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
3862            )?
3863        );
3864    } else {
3865        println!("demoted `{name}` from federation → local");
3866        println!("  relay slot binding removed; keypair + agent-card retained");
3867        println!("  re-publish with `wire identity publish <nick>`");
3868    }
3869    Ok(())
3870}
3871
3872/// Thin wrapper — kept as a function for tests + back-compat with
3873/// the small handful of callsites that already use this name.
3874/// Implementation moved to `crate::trust::effective_tier` so the
3875/// canonical derivation is shared with `compute_pending_push_breakdown`.
3876fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
3877    crate::trust::effective_tier(trust, relay_state, handle)
3878}
3879
3880fn cmd_peers(as_json: bool) -> Result<()> {
3881    let trust = config::read_trust()?;
3882    let agents = trust
3883        .get("agents")
3884        .and_then(Value::as_object)
3885        .cloned()
3886        .unwrap_or_default();
3887    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
3888
3889    let mut self_did: Option<String> = None;
3890    if let Ok(card) = config::read_agent_card() {
3891        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
3892    }
3893
3894    let mut peers = Vec::new();
3895    for (handle, agent) in agents.iter() {
3896        let did = agent
3897            .get("did")
3898            .and_then(Value::as_str)
3899            .unwrap_or("")
3900            .to_string();
3901        if Some(did.as_str()) == self_did.as_deref() {
3902            continue; // skip self-attestation
3903        }
3904        let tier = effective_peer_tier(&trust, &relay_state, handle);
3905        let capabilities = agent
3906            .get("card")
3907            .and_then(|c| c.get("capabilities"))
3908            .cloned()
3909            .unwrap_or_else(|| json!([]));
3910        // v0.7.0-alpha.6: prefer peer's published character override
3911        // (display.nickname / display.emoji on their pinned agent-card).
3912        // Falls back to auto-derived if peer hasn't renamed themselves
3913        // OR runs an older wire that doesn't publish the field.
3914        let character = if did.is_empty() {
3915            None
3916        } else {
3917            let card_obj = agent.get("card");
3918            Some(match card_obj {
3919                Some(card) => crate::character::Character::from_card(card),
3920                None => crate::character::Character::from_did(&did),
3921            })
3922        };
3923        // v0.14: surface peer's op claims when their pinned card carries
3924        // them (post-v0.14 peers). Older peers ⇒ absent keys; same shape
3925        // as `wire whoami --json` so operators have one mental model.
3926        let peer_op_claims = agent
3927            .get("card")
3928            .map(op_claims_from_card)
3929            .unwrap_or_default();
3930        let mut row = serde_json::Map::new();
3931        row.insert("handle".into(), json!(handle));
3932        row.insert("did".into(), json!(did));
3933        row.insert("tier".into(), json!(tier));
3934        row.insert("capabilities".into(), capabilities);
3935        row.insert("persona".into(), serde_json::to_value(&character)?);
3936        for (k, v) in peer_op_claims {
3937            row.insert(k, v);
3938        }
3939        peers.push(Value::Object(row));
3940    }
3941
3942    if as_json {
3943        println!("{}", serde_json::to_string(&peers)?);
3944    } else if peers.is_empty() {
3945        println!("no peers pinned (run `wire join <code>` to pair)");
3946    } else {
3947        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
3948        // computed above (from peer's agent-card, honoring override) so
3949        // text and JSON output never diverge. Pre-alpha.8 the text loop
3950        // recomputed via Character::from_did (no override) — operators
3951        // saw different identities depending on --json flag.
3952        for p in &peers {
3953            let char_json = &p["persona"];
3954            let (colored_char, plain_len): (String, usize) = match char_json {
3955                serde_json::Value::Null => ("?".to_string(), 1),
3956                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3957                    Ok(c) => {
3958                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
3959                        (c.colored(), plain)
3960                    }
3961                    Err(_) => ("?".to_string(), 1),
3962                },
3963            };
3964            let pad = 22usize.saturating_sub(plain_len);
3965            println!(
3966                "{}{}  {:<20} {:<10} {}",
3967                colored_char,
3968                " ".repeat(pad),
3969                p["handle"].as_str().unwrap_or(""),
3970                p["tier"].as_str().unwrap_or(""),
3971                p["did"].as_str().unwrap_or(""),
3972            );
3973        }
3974    }
3975    Ok(())
3976}
3977
3978// ---------- send ----------
3979
3980/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
3981///
3982/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
3983/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
3984/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
3985/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
3986/// hasn't crossed two heartbeats means probably degraded.
3987fn maybe_warn_peer_attentiveness(peer: &str) {
3988    let state = match config::read_relay_state() {
3989        Ok(s) => s,
3990        Err(_) => return,
3991    };
3992    let p = state.get("peers").and_then(|p| p.get(peer));
3993    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3994        Some(s) if !s.is_empty() => s,
3995        _ => return,
3996    };
3997    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3998        Some(s) if !s.is_empty() => s,
3999        _ => return,
4000    };
4001    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
4002        Some(s) if !s.is_empty() => s.to_string(),
4003        _ => match state
4004            .get("self")
4005            .and_then(|s| s.get("relay_url"))
4006            .and_then(Value::as_str)
4007        {
4008            Some(s) if !s.is_empty() => s.to_string(),
4009            _ => return,
4010        },
4011    };
4012    let client = crate::relay_client::RelayClient::new(&relay_url);
4013    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
4014        Ok(t) => t,
4015        Err(_) => return,
4016    };
4017    let now = std::time::SystemTime::now()
4018        .duration_since(std::time::UNIX_EPOCH)
4019        .map(|d| d.as_secs())
4020        .unwrap_or(0);
4021    match last_pull {
4022        None => {
4023            eprintln!(
4024                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
4025            );
4026        }
4027        Some(t) if now.saturating_sub(t) > 300 => {
4028            let mins = now.saturating_sub(t) / 60;
4029            eprintln!(
4030                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
4031            );
4032        }
4033        _ => {}
4034    }
4035}
4036
4037pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
4038    let trimmed = input.trim();
4039    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
4040    {
4041        return Ok(trimmed.to_string());
4042    }
4043    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
4044    let n: i64 = amount
4045        .parse()
4046        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
4047    if n <= 0 {
4048        bail!("deadline duration must be positive: {input:?}");
4049    }
4050    let duration = match unit {
4051        "m" => time::Duration::minutes(n),
4052        "h" => time::Duration::hours(n),
4053        "d" => time::Duration::days(n),
4054        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
4055    };
4056    Ok((time::OffsetDateTime::now_utc() + duration)
4057        .format(&time::format_description::well_known::Rfc3339)
4058        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
4059}
4060
4061fn cmd_send(
4062    peer: &str,
4063    kind: &str,
4064    body_arg: &str,
4065    deadline: Option<&str>,
4066    // v0.10: when true, refuse to auto-pair on miss; fail loudly so
4067    // scripts can branch on the error instead of accepting an implicit
4068    // side effect.
4069    no_auto_pair: bool,
4070    // v0.14.2: opt back into the legacy outbox→daemon-push path. When
4071    // false (default), we POST synchronously and return a real
4072    // `delivered` / `duplicate` / `failed` verdict.
4073    queue: bool,
4074    as_json: bool,
4075) -> Result<()> {
4076    if !config::is_initialized()? {
4077        bail!("not initialized — run `wire init <handle>` first");
4078    }
4079    let peer_in = crate::agent_card::bare_handle(peer).to_string();
4080    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
4081    // match wins; nickname (DID-hash auto-derived) is the fallback.
4082    // Ambiguous nicknames (two pinned peers DID-hash to the same
4083    // adj-noun pair) fail loudly with disambiguation; unknown handles
4084    // pass through and surface as `peer_unknown` from the sync
4085    // delivery layer (post-#187 `wire send` is sync by default;
4086    // `--queue` opts back into the legacy outbox-write path).
4087    let peer = match resolve_peer_handle(&peer_in) {
4088        Ok(Some(resolved)) if resolved != peer_in => {
4089            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
4090            resolved
4091        }
4092        Ok(Some(canonical)) => canonical, // exact handle match
4093        Ok(None) => peer_in,              // unknown — pass through, downstream errors
4094        Err(ResolveError::Ambiguous(candidates)) => bail!(
4095            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
4096             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
4097            candidates.len(),
4098            candidates.join(", ")
4099        ),
4100        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
4101    };
4102
4103    // v0.9 auto-pair-on-miss: if the resolved peer isn't pinned yet but
4104    // matches a local sister session, pair first (disk-read --local-sister
4105    // path) then continue. Pre-v0.14.2 closed the "wire send returns queued but
4106    // peer never receives because we were never paired" silent-fail
4107    // class. Equivalent to `wire dial <name>` followed by `wire send
4108    // <name> ...` in one step.
4109    let peer_is_pinned = config::read_relay_state()
4110        .ok()
4111        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
4112        .map(|peers| peers.contains_key(&peer))
4113        .unwrap_or(false);
4114    if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
4115        if no_auto_pair {
4116            bail!(
4117                "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
4118                 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
4119                 then re-run send."
4120            );
4121        }
4122        eprintln!(
4123            "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
4124             Pass --no-auto-pair to refuse implicit dialing."
4125        );
4126        cmd_add_local_sister(&sister_name, true).map_err(|e| {
4127            anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
4128        })?;
4129    }
4130
4131    let peer = peer.as_str();
4132    let sk_seed = config::read_private_key()?;
4133    let card = config::read_agent_card()?;
4134    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4135    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4136    let pk_b64 = card
4137        .get("verify_keys")
4138        .and_then(Value::as_object)
4139        .and_then(|m| m.values().next())
4140        .and_then(|v| v.get("key"))
4141        .and_then(Value::as_str)
4142        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
4143    let pk_bytes = crate::signing::b64decode(pk_b64)?;
4144
4145    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
4146    // P0.S (0.5.11): stdin support lets shells pipe in long content
4147    // without quoting/escaping ceremony, and supports heredocs naturally:
4148    //   wire send peer - <<EOF ... EOF
4149    let body_value: Value = if body_arg == "-" {
4150        use std::io::Read;
4151        let mut raw = String::new();
4152        std::io::stdin()
4153            .read_to_string(&mut raw)
4154            .with_context(|| "reading body from stdin")?;
4155        // Try parsing as JSON first; fall back to string literal for
4156        // plain-text bodies.
4157        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
4158    } else if let Some(path) = body_arg.strip_prefix('@') {
4159        let raw =
4160            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
4161        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
4162    } else {
4163        Value::String(body_arg.to_string())
4164    };
4165
4166    let kind_id = parse_kind(kind)?;
4167
4168    let now = time::OffsetDateTime::now_utc()
4169        .format(&time::format_description::well_known::Rfc3339)
4170        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4171
4172    // v0.14.2 (#162 fix #4): canonicalize `to:` against the pinned
4173    // peer's full DID. Bare-handle `to:did:wire:<handle>` misses the
4174    // long-fingerprint suffix (`did:wire:sunlit-aurora-ec6f890d`) that
4175    // pinned peers actually publish; mismatch risks receiver rejection
4176    // at canonical/cursor verification. resolve_peer_did falls back to
4177    // the bare form for unknown peers (pre-pair queue best-effort).
4178    let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
4179    let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
4180    let mut event = json!({
4181        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4182        "timestamp": now,
4183        "from": did,
4184        "to": to_did,
4185        "type": kind,
4186        "kind": kind_id,
4187        "body": body_value,
4188    });
4189    if let Some(deadline) = deadline {
4190        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
4191    }
4192    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
4193    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
4194
4195    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
4196    // coords in relay-state and ask the relay how recently the peer pulled.
4197    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
4198    // Never blocks the send — the sync POST or `--queue` outbox-write
4199    // happens below regardless.
4200    maybe_warn_peer_attentiveness(peer);
4201
4202    // v0.14.2 (paul, 2026-06-01): collapse the legacy 3-step
4203    // (outbox-write → daemon push → relay) into a single synchronous
4204    // POST when `--queue` is NOT set. The old path silently dropped
4205    // events in three distinct classes (daemon-down,
4206    // wrong-WIRE_HOME, stale-slot); the new path returns the real
4207    // verdict inline.
4208    if !queue {
4209        let outcome = crate::send::attempt_deliver(peer, &signed)?;
4210        if as_json {
4211            println!(
4212                "{}",
4213                serde_json::to_string(&crate::send::delivery_json(&outcome, peer))?
4214            );
4215        } else {
4216            use crate::send::SyncDelivery;
4217            match &outcome {
4218                SyncDelivery::Delivered {
4219                    event_id,
4220                    relay_url,
4221                    slot_id,
4222                } => println!("delivered {event_id} → {peer} (relay {relay_url} slot {slot_id})"),
4223                SyncDelivery::Duplicate {
4224                    event_id,
4225                    relay_url,
4226                    slot_id,
4227                } => println!(
4228                    "duplicate {event_id} → {peer} (already on relay {relay_url} slot {slot_id} — change the body to send a distinct event)"
4229                ),
4230                SyncDelivery::PeerUnknown { event_id } => println!(
4231                    "FAILED {event_id} → {peer}: peer not pinned. Run `wire dial {peer}` to pair, or `wire send --queue {peer} ...` to write to outbox for the daemon to retry later."
4232                ),
4233                SyncDelivery::SlotStale {
4234                    event_id, detail, ..
4235                } => println!(
4236                    "FAILED {event_id} → {peer}: relay says slot is stale ({detail}). Run `wire dial {peer}` to re-pair."
4237                ),
4238                SyncDelivery::TransportError {
4239                    event_id, detail, ..
4240                } => println!(
4241                    "FAILED {event_id} → {peer}: transport error ({detail}). Retry, or pass --queue to outbox the event for daemon retry."
4242                ),
4243            }
4244        }
4245        // Non-zero exit for non-delivered states so scripts can
4246        // branch. Delivered + Duplicate both count as success (both
4247        // mean the peer can pull).
4248        if !outcome.reached_relay() {
4249            std::process::exit(2);
4250        }
4251        return Ok(());
4252    }
4253
4254    // Legacy --queue path: append to per-peer outbox JSONL, daemon
4255    // push loop drains. Same code shape as pre-v0.14.2.
4256    //
4257    // Honesty check: if the peer is BOTH not pinned in trust AND has
4258    // no pending pair, the daemon has no relay endpoint to push to
4259    // and never will until the operator pairs. The CLI shouldn't
4260    // silently accept this — coral dogfood today (2026-06-01) found
4261    // a year-old `no-such-peer.jsonl` outbox file from a typo'd send,
4262    // still on disk because the daemon has nowhere to send it. Emit
4263    // a one-line stderr warning so the operator knows what's going
4264    // to happen (the write proceeds — `--queue` is the documented
4265    // pre-pair best-effort path and we don't want to break the
4266    // "queue → then dial → then push" workflow).
4267    let peer_pinned_in_trust = trust_for_did
4268        .get("agents")
4269        .and_then(Value::as_object)
4270        .map(|a| a.contains_key(peer))
4271        .unwrap_or(false);
4272    if !peer_pinned_in_trust && !peer_is_pinned {
4273        // Check both directions: outbound (we dialed, daemon
4274        // hasn't completed the pair yet — peer_did may be set if
4275        // the relay returned it) and inbound (we received an
4276        // invite drop awaiting accept — explicit peer_handle).
4277        let pending_outbound = crate::pending_pair::list_pending()
4278            .ok()
4279            .map(|v| {
4280                v.iter().any(|p| {
4281                    p.peer_did
4282                        .as_deref()
4283                        .map(|d| {
4284                            crate::agent_card::display_handle_from_did(d)
4285                                .to_string()
4286                                .eq(peer)
4287                        })
4288                        .unwrap_or(false)
4289                })
4290            })
4291            .unwrap_or(false);
4292        let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
4293            .ok()
4294            .map(|v| v.iter().any(|p| p.peer_handle == peer))
4295            .unwrap_or(false);
4296        if !pending_outbound && !pending_inbound {
4297            eprintln!(
4298                "wire send: WARN — `{peer}` is not pinned and has no pending pair. \
4299                 The event will sit in outbox forever unless you pair first \
4300                 (`wire dial {peer}` or accept an inbound invite)."
4301            );
4302        }
4303    }
4304    let line = serde_json::to_vec(&signed)?;
4305    let outbox = config::append_outbox_record(peer, &line)?;
4306    if as_json {
4307        println!(
4308            "{}",
4309            serde_json::to_string(&json!({
4310                "event_id": event_id,
4311                "status": "queued",
4312                "peer": peer,
4313                "outbox": outbox.to_string_lossy(),
4314            }))?
4315        );
4316    } else {
4317        println!(
4318            "queued event {event_id} → {peer} (outbox: {}; daemon will push)",
4319            outbox.display()
4320        );
4321    }
4322    Ok(())
4323}
4324
4325fn parse_kind(s: &str) -> Result<u32> {
4326    if let Ok(n) = s.parse::<u32>() {
4327        return Ok(n);
4328    }
4329    for (id, name) in crate::signing::kinds() {
4330        if *name == s {
4331            return Ok(*id);
4332        }
4333    }
4334    // Unknown name — default to kind 1 (decision) for v0.1.
4335    Ok(1)
4336}
4337
4338// ---------- here (v0.9.3 you-are-here view) ----------
4339
4340/// `wire here` — one-screen "you are this session, your neighbors are
4341/// these." Combines what `wire whoami`, `wire peers`, and `wire session
4342/// list-local` would otherwise force the operator to call separately.
4343fn cmd_here(as_json: bool) -> Result<()> {
4344    let initialized = config::is_initialized().unwrap_or(false);
4345
4346    // Self identity.
4347    let (self_did, self_handle, self_character) = if initialized {
4348        let card = config::read_agent_card().ok();
4349        let did = card
4350            .as_ref()
4351            .and_then(|c| c.get("did").and_then(Value::as_str))
4352            .unwrap_or("")
4353            .to_string();
4354        let handle = if did.is_empty() {
4355            String::new()
4356        } else {
4357            crate::agent_card::display_handle_from_did(&did).to_string()
4358        };
4359        let character = if did.is_empty() {
4360            None
4361        } else {
4362            // v0.11: DID-derived only. No display.json overrides.
4363            Some(crate::character::Character::from_did(&did))
4364        };
4365        (did, handle, character)
4366    } else {
4367        (String::new(), String::new(), None)
4368    };
4369
4370    let cwd = std::env::current_dir()
4371        .map(|p| p.to_string_lossy().into_owned())
4372        .unwrap_or_default();
4373    let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
4374
4375    // Sister sessions (same-machine).
4376    let mut sisters: Vec<Value> = Vec::new();
4377    if let Ok(listing) = crate::session::list_local_sessions() {
4378        for group in listing.local.values() {
4379            for s in group {
4380                if s.handle.as_deref() == Some(self_handle.as_str()) {
4381                    continue; // skip self
4382                }
4383                let ch = s.did.as_deref().map(crate::character::Character::from_did);
4384                sisters.push(json!({
4385                    "session": s.name,
4386                    "handle": s.handle,
4387                    "persona": ch,
4388                }));
4389            }
4390        }
4391    }
4392
4393    // Pinned peers (trust ring agents).
4394    let mut peers: Vec<Value> = Vec::new();
4395    if initialized
4396        && let Ok(trust) = config::read_trust()
4397        && let Some(agents) = trust.get("agents").and_then(Value::as_object)
4398    {
4399        // Read relay_state once so the effective-tier lookup
4400        // doesn't hammer disk per peer. Missing file is fine —
4401        // effective_tier handles it.
4402        let relay_state =
4403            config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
4404        for (handle, agent) in agents {
4405            if handle == &self_handle {
4406                continue; // skip self
4407            }
4408            let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4409            let ch = if did.is_empty() {
4410                None
4411            } else {
4412                Some(crate::character::Character::from_did(did))
4413            };
4414            // v0.14.3: use effective tier so `wire here`, `wire
4415            // peers`, and `wire status` agree on what the daemon
4416            // can actually do. Raw trust tier alone was lying when
4417            // a VERIFIED peer's relay credentials were never
4418            // delivered (slot_token empty, bilateral_completed_at
4419            // missing). coral dogfood 2026-06-01 saw
4420            // orchid-savanna as VERIFIED here but PENDING_ACK in
4421            // the other two — same screen, two answers.
4422            peers.push(json!({
4423                "handle": handle,
4424                "did": did,
4425                "tier": crate::trust::effective_tier(&trust, &relay_state, handle),
4426                "persona": ch,
4427            }));
4428        }
4429    }
4430
4431    if as_json {
4432        println!(
4433            "{}",
4434            serde_json::to_string(&json!({
4435                "self": {
4436                    "handle": self_handle,
4437                    "did": self_did,
4438                    "persona": self_character,
4439                    "cwd": cwd,
4440                    "wire_home": wire_home,
4441                },
4442                "sister_sessions": sisters,
4443                "pinned_peers": peers,
4444            }))?
4445        );
4446        return Ok(());
4447    }
4448
4449    // Human format.
4450    if !initialized {
4451        println!("not initialized — run `wire init <handle>` to bootstrap.");
4452        return Ok(());
4453    }
4454    let glyph = self_character
4455        .as_ref()
4456        .map(crate::character::emoji_with_fallback)
4457        .unwrap_or_else(|| "?".to_string());
4458    let nick = self_character
4459        .as_ref()
4460        .map(|c| c.nickname.clone())
4461        .unwrap_or_default();
4462    println!("you are {glyph} {nick}  ({self_handle})");
4463    if !cwd.is_empty() {
4464        println!("  cwd:    {cwd}");
4465    }
4466    // Helper closure that mirrors emoji_with_fallback over a JSON-encoded
4467    // character object (because we already collected sisters/peers into
4468    // Value rows above). Looks up the canonical emoji-name and falls
4469    // back to that — never repeats the nickname inside the brackets.
4470    let render_glyph = |character: &Value| -> String {
4471        let emoji = character
4472            .get("emoji")
4473            .and_then(Value::as_str)
4474            .unwrap_or("?");
4475        let nickname = character
4476            .get("nickname")
4477            .and_then(Value::as_str)
4478            .unwrap_or("?");
4479        if crate::character::terminal_supports_emoji() {
4480            return emoji.to_string();
4481        }
4482        // Synthesize a minimal Character so emoji_with_fallback's
4483        // lookup table picks the right ASCII tag.
4484        let synth = crate::character::Character {
4485            nickname: nickname.to_string(),
4486            emoji: emoji.to_string(),
4487            palette: crate::character::Palette {
4488                primary_hex: String::new(),
4489                accent_hex: String::new(),
4490                ansi256_primary: 0,
4491                ansi256_accent: 0,
4492            },
4493        };
4494        crate::character::emoji_with_fallback(&synth)
4495    };
4496    if !sisters.is_empty() {
4497        println!();
4498        println!("sister sessions on this machine:");
4499        for s in &sisters {
4500            let session = s["session"].as_str().unwrap_or("?");
4501            let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
4502            let glyph = render_glyph(&s["persona"]);
4503            println!("  {glyph} {ch_nick}  ({session})");
4504        }
4505    }
4506    if !peers.is_empty() {
4507        println!();
4508        println!("pinned peers:");
4509        for p in &peers {
4510            let handle = p["handle"].as_str().unwrap_or("?");
4511            let tier = p["tier"].as_str().unwrap_or("");
4512            let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
4513            let glyph = render_glyph(&p["persona"]);
4514            println!("  {glyph} {ch_nick}  ({handle})  [{tier}]");
4515        }
4516    }
4517    if sisters.is_empty() && peers.is_empty() {
4518        println!();
4519        println!(
4520            "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
4521        );
4522    }
4523    Ok(())
4524}
4525
4526// ---------- dial / whois (v0.8 canonical addressing) ----------
4527
4528/// `wire dial <name> [message]` — the one verb operators reach for.
4529/// Resolves any name (nickname/handle/session/DID) to a peer and
4530/// drives the right pair flow + optional first message. See the
4531/// `Command::Dial` doc for the resolution ladder.
4532///
4533/// v0.9: when `name` contains `@<relay>`, route through the federation
4534/// `wire add <handle>@<relay>` path (`.well-known/wire/agent` resolution
4535/// plus cross-machine pair_drop). No more bail with "federation isn't
4536/// implemented yet" — one verb across both orbits.
4537fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
4538    if name.contains('@') {
4539        // Federation path. cmd_add already auto-detects (per v0.7.4)
4540        // when input has `@` and routes through the .well-known
4541        // resolver + pair_drop deposit. After it returns, the peer
4542        // is in pending-outbound; bilateral completes when the peer
4543        // accepts. Optionally send the first message after the add.
4544        cmd_add(name, None, false, true)
4545            .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
4546        if let Some(msg) = message {
4547            // Peer handle for send = the nick part before the `@`.
4548            let bare = name.split('@').next().unwrap_or(name);
4549            cmd_send(bare, "claim", msg, None, false, false, as_json)?;
4550        }
4551        return Ok(());
4552    }
4553
4554    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
4555    // success with `{found: false, candidates: [...]}` instead of
4556    // erroring. Agents can branch on `found` without wrapping in a
4557    // try/catch.
4558    let resolution = match resolve_name_to_target(name) {
4559        Ok(r) => r,
4560        Err(e) if as_json => {
4561            let pool = known_local_names();
4562            let suggestions = closest_candidates(name, &pool, 3, 3);
4563            println!(
4564                "{}",
4565                serde_json::to_string(&json!({
4566                    "name_input": name,
4567                    "found": false,
4568                    "candidates": suggestions,
4569                    "error": format!("{e:#}"),
4570                }))?
4571            );
4572            return Ok(());
4573        }
4574        Err(e) => return Err(e),
4575    };
4576    let mut steps: Vec<Value> = Vec::new();
4577
4578    match &resolution {
4579        DialTarget::PinnedPeer { handle, .. } => {
4580            steps.push(json!({
4581                "step": "resolved",
4582                "kind": "already_pinned",
4583                "handle": handle,
4584            }));
4585        }
4586        DialTarget::LocalSister { session_name, .. } => {
4587            steps.push(json!({
4588                "step": "resolved",
4589                "kind": "local_sister",
4590                "session": session_name,
4591            }));
4592            // Drive the bilateral pair via the disk-read sister path.
4593            // cmd_add_local_sister already handles "already paired"
4594            // gracefully (its internal state.peers check returns the
4595            // existing pin instead of re-issuing a pair_drop), so
4596            // re-dialling is idempotent.
4597            cmd_add_local_sister(session_name, true).map_err(|e| {
4598                anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
4599            })?;
4600            steps.push(json!({
4601                "step": "paired",
4602                "via": "local_sister",
4603            }));
4604        }
4605    }
4606
4607    let send_handle = match &resolution {
4608        DialTarget::PinnedPeer { handle, .. } => handle.clone(),
4609        DialTarget::LocalSister { handle, .. } => handle.clone(),
4610    };
4611
4612    let send_result = if let Some(msg) = message {
4613        let r = cmd_send(&send_handle, "claim", msg, None, false, false, true);
4614        match &r {
4615            Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
4616            Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
4617        }
4618        Some(r)
4619    } else {
4620        None
4621    };
4622
4623    if as_json {
4624        println!(
4625            "{}",
4626            serde_json::to_string(&json!({
4627                "name_input": name,
4628                "resolved_handle": send_handle,
4629                "steps": steps,
4630            }))?
4631        );
4632    } else {
4633        println!("wire dial: resolved `{name}` → handle `{send_handle}`");
4634        for s in &steps {
4635            let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
4636            println!("  - {step}");
4637        }
4638        if message.is_some() {
4639            println!("  (use `wire tail {send_handle}` to read replies)");
4640        }
4641    }
4642    if let Some(Err(e)) = send_result {
4643        return Err(e);
4644    }
4645    Ok(())
4646}
4647
4648/// `wire whois <name>` — resolve any local name (nickname/session/
4649/// handle/DID) to the full identity row. The inspector for the
4650/// canonical addressing layer. For federation `handle@relay-domain`
4651/// resolution see `cmd_whois` (line 5536+) — the dispatcher chooses
4652/// based on whether the input contains `@`.
4653fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
4654    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
4655    // success (exit 0) with `{found: false, candidates: [...]}` so
4656    // agents don't need try/catch around `wire whois <name>`. In
4657    // human mode, the bail's did-you-mean line points at the
4658    // closest candidate.
4659    let resolution = match resolve_name_to_target(name) {
4660        Ok(r) => r,
4661        Err(e) if as_json => {
4662            let pool = known_local_names();
4663            let suggestions = closest_candidates(name, &pool, 3, 3);
4664            println!(
4665                "{}",
4666                serde_json::to_string(&json!({
4667                    "name_input": name,
4668                    "found": false,
4669                    "candidates": suggestions,
4670                    "error": format!("{e:#}"),
4671                }))?
4672            );
4673            return Ok(());
4674        }
4675        Err(e) => return Err(e),
4676    };
4677    match resolution {
4678        DialTarget::PinnedPeer {
4679            handle,
4680            did,
4681            nickname,
4682            emoji,
4683            tier,
4684        } => {
4685            // v0.14: re-read trust to pull the pinned peer's card for op
4686            // claims surfacing. Pinned ⇒ card lives in trust.json (no
4687            // network round-trip). Older peers ⇒ no op_* fields ⇒ empty.
4688            let op_claims = config::read_trust()
4689                .ok()
4690                .and_then(|t| {
4691                    t.get("agents")
4692                        .and_then(Value::as_object)
4693                        .and_then(|m| m.get(&handle))
4694                        .and_then(|a| a.get("card").cloned())
4695                })
4696                .map(|c| op_claims_from_card(&c))
4697                .unwrap_or_default();
4698
4699            if as_json {
4700                let mut payload = serde_json::Map::new();
4701                payload.insert("kind".into(), json!("pinned_peer"));
4702                payload.insert("handle".into(), json!(handle));
4703                payload.insert("did".into(), json!(did));
4704                payload.insert("nickname".into(), json!(nickname));
4705                payload.insert("emoji".into(), json!(emoji));
4706                payload.insert("tier".into(), json!(tier));
4707                for (k, v) in &op_claims {
4708                    payload.insert(k.clone(), v.clone());
4709                }
4710                println!("{}", serde_json::to_string(&payload)?);
4711            } else {
4712                let n = nickname.as_deref().unwrap_or("(no character)");
4713                let e = emoji.as_deref().unwrap_or("?");
4714                println!("{e} {n}");
4715                println!("  handle:   {handle}");
4716                println!("  did:      {did}");
4717                println!("  tier:     {tier}");
4718                // v0.14: surface peer's op_did when the pinned card
4719                // carries one. Silent for pre-v0.14 peers.
4720                if let Some(op_did) = op_claims.get("op_did").and_then(Value::as_str) {
4721                    println!("  op_did:   {op_did}");
4722                }
4723                println!("  reach:    pinned peer (already in trust ring + slot pinned)");
4724            }
4725        }
4726        DialTarget::LocalSister {
4727            session_name,
4728            handle,
4729            did,
4730            nickname,
4731            emoji,
4732        } => {
4733            if as_json {
4734                println!(
4735                    "{}",
4736                    serde_json::to_string(&json!({
4737                        "kind": "local_sister",
4738                        "session_name": session_name,
4739                        "handle": handle,
4740                        "did": did,
4741                        "nickname": nickname,
4742                        "emoji": emoji,
4743                    }))?
4744                );
4745            } else {
4746                let n = nickname.as_deref().unwrap_or("(no character)");
4747                let e = emoji.as_deref().unwrap_or("?");
4748                println!("{e} {n}");
4749                println!("  session:  {session_name}");
4750                println!("  handle:   {handle}");
4751                println!(
4752                    "  did:      {}",
4753                    did.as_deref().unwrap_or("(card unreadable)")
4754                );
4755                println!("  reach:    local sister on this machine — `wire dial {n}` pairs us");
4756            }
4757        }
4758    }
4759    Ok(())
4760}
4761
4762pub(crate) enum DialTarget {
4763    PinnedPeer {
4764        handle: String,
4765        did: String,
4766        nickname: Option<String>,
4767        emoji: Option<String>,
4768        tier: String,
4769    },
4770    LocalSister {
4771        session_name: String,
4772        handle: String,
4773        did: Option<String>,
4774        nickname: Option<String>,
4775        emoji: Option<String>,
4776    },
4777}
4778
4779/// Resolution order: pinned peers first (already in our trust ring),
4780/// then local sister sessions (on-disk discovery). Case-insensitive
4781/// match against handle, character nickname, session name, or DID.
4782///
4783/// `pub(crate)` so the MCP `tool_whois` surface mirrors the CLI's
4784/// bare-nick resolution (closes the known `missing '@' separator`
4785/// rejection on bare nicks — agents reading via MCP now resolve
4786/// pinned peers + local sisters identically to operators reading via
4787/// CLI).
4788pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
4789    let needle = name.trim();
4790    if needle.is_empty() {
4791        bail!("empty name");
4792    }
4793
4794    // 1. Pinned peers — `wire peers` data. trust.agents is an object
4795    // keyed by handle (not an array); iterate as a map.
4796    if config::is_initialized().unwrap_or(false) {
4797        let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
4798        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
4799            for (handle_key, agent) in agents {
4800                let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4801                if did.is_empty() {
4802                    continue;
4803                }
4804                let handle = handle_key.clone();
4805                let character = crate::character::Character::from_did(did);
4806                let tier = agent
4807                    .get("tier")
4808                    .and_then(Value::as_str)
4809                    .unwrap_or("UNKNOWN")
4810                    .to_string();
4811                let matches = handle.eq_ignore_ascii_case(needle)
4812                    || did.eq_ignore_ascii_case(needle)
4813                    || character.nickname.eq_ignore_ascii_case(needle);
4814                if matches {
4815                    return Ok(DialTarget::PinnedPeer {
4816                        handle,
4817                        did: did.to_string(),
4818                        nickname: Some(character.nickname),
4819                        emoji: Some(character.emoji.to_string()),
4820                        tier,
4821                    });
4822                }
4823            }
4824        }
4825    }
4826
4827    // 2. Local sister sessions.
4828    if let Some(session_name) = crate::session::resolve_local_sister(needle) {
4829        let sessions = crate::session::list_sessions().unwrap_or_default();
4830        let s = sessions.iter().find(|s| s.name == session_name);
4831        if let Some(s) = s {
4832            return Ok(DialTarget::LocalSister {
4833                session_name: s.name.clone(),
4834                handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
4835                did: s.did.clone(),
4836                nickname: s.character.as_ref().map(|c| c.nickname.clone()),
4837                emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
4838            });
4839        }
4840    }
4841
4842    // v0.9.2: fuzzy did-you-mean suggestion on resolution miss. Walks
4843    // the union of pinned-peer handles + character nicknames + sister
4844    // session names + sister character nicknames, returns up to 3 names
4845    // within Levenshtein distance 3 of the operator's typed name.
4846    let pool = known_local_names();
4847    let suggestions = closest_candidates(name, &pool, 3, 3);
4848    if suggestions.is_empty() {
4849        bail!(
4850            "no peer matched `{name}`.\n\
4851             Tried: pinned peers (`wire peers`) + local sister sessions \
4852             (`wire session list-local`).\n\
4853             For cross-machine federation: `wire dial <handle>@<relay-domain>`."
4854        );
4855    }
4856    bail!(
4857        "no peer matched `{name}`.\n\
4858         Did you mean: {}?\n\
4859         List all: `wire peers`, `wire session list-local`.",
4860        suggestions
4861            .iter()
4862            .map(|s| format!("`{s}`"))
4863            .collect::<Vec<_>>()
4864            .join(", ")
4865    );
4866}
4867
4868// ---------- tail ----------
4869
4870/// Print recent events from this agent's inbox.
4871///
4872/// **Orientation (wire #79):** defaults to NEWEST-N — with `limit > 0`, the
4873/// last `limit` events across all matched peer jsonl files are returned,
4874/// sorted chronologically (by `timestamp`, then by per-file append order as
4875/// tiebreaker) and printed oldest-of-window first / newest last. This matches
4876/// `tail -n` semantics on log files; previously `wire tail --limit N` returned
4877/// the OLDEST N which silently hid live-context for any agent harness that
4878/// re-tailed an established inbox.
4879///
4880/// `oldest=true` flips back to FIFO (first-N) for operators who need the
4881/// original orientation (e.g. replaying an inbox from the start). `limit=0`
4882/// prints every event in chronological order.
4883fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize, oldest: bool) -> Result<()> {
4884    let inbox = config::inbox_dir()?;
4885    if !inbox.exists() {
4886        if !as_json {
4887            eprintln!("no inbox yet — daemon hasn't run, or no events received");
4888        }
4889        return Ok(());
4890    }
4891    let trust = config::read_trust()?;
4892
4893    let entries: Vec<_> = std::fs::read_dir(&inbox)?
4894        .filter_map(|e| e.ok())
4895        .map(|e| e.path())
4896        .filter(|p| {
4897            p.extension().map(|x| x == "jsonl").unwrap_or(false)
4898                && match peer {
4899                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
4900                    None => true,
4901                }
4902        })
4903        .collect();
4904
4905    // Collect every parseable event across all matched peer files. Each entry
4906    // carries a sort key `(timestamp, line_idx)` so multi-peer interleaving
4907    // sorts deterministically by event time, with append-order as the
4908    // tiebreaker for events that share a timestamp (or for events with no
4909    // timestamp string at all).
4910    let mut events: Vec<(String, usize, Value)> = Vec::new();
4911    for path in &entries {
4912        let body = std::fs::read_to_string(path)?;
4913        for (idx, line) in body.lines().enumerate() {
4914            let event: Value = match serde_json::from_str(line) {
4915                Ok(v) => v,
4916                Err(_) => continue,
4917            };
4918            let ts = event
4919                .get("timestamp")
4920                .and_then(Value::as_str)
4921                .unwrap_or("")
4922                .to_string();
4923            events.push((ts, idx, event));
4924        }
4925    }
4926    events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
4927
4928    // Pick the window. limit=0 → all events; oldest → first N; default → last N.
4929    let total = events.len();
4930    let window: &[(String, usize, Value)] = if limit == 0 {
4931        &events[..]
4932    } else if oldest {
4933        &events[..limit.min(total)]
4934    } else {
4935        let start = total.saturating_sub(limit);
4936        &events[start..]
4937    };
4938
4939    for (_, _, event) in window {
4940        let verified = verify_message_v31(event, &trust).is_ok();
4941        if as_json {
4942            let mut event_with_meta = event.clone();
4943            if let Some(obj) = event_with_meta.as_object_mut() {
4944                obj.insert("verified".into(), json!(verified));
4945            }
4946            println!("{}", serde_json::to_string(&event_with_meta)?);
4947        } else {
4948            let ts = event
4949                .get("timestamp")
4950                .and_then(Value::as_str)
4951                .unwrap_or("?");
4952            let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
4953            let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
4954            let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
4955            let summary = event
4956                .get("body")
4957                .map(|b| match b {
4958                    Value::String(s) => s.clone(),
4959                    _ => b.to_string(),
4960                })
4961                .unwrap_or_default();
4962            let mark = if verified { "✓" } else { "✗" };
4963            let deadline = event
4964                .get("time_sensitive_until")
4965                .and_then(Value::as_str)
4966                .map(|d| format!(" deadline: {d}"))
4967                .unwrap_or_default();
4968            println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
4969        }
4970    }
4971    Ok(())
4972}
4973
4974// ---------- monitor (live-tail across all peers, harness-friendly) ----------
4975
4976/// Events filtered out of `wire monitor` by default — pair handshake +
4977/// liveness pings. Operators almost never want these surfaced; an explicit
4978/// `--include-handshake` brings them back.
4979fn monitor_is_noise_kind(kind: &str) -> bool {
4980    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
4981}
4982
4983/// Resolve a pinned peer's persona (the DID-derived nickname + emoji,
4984/// respecting an advertised override on their card). `None` if the peer
4985/// isn't in trust or can't be resolved — callers fall back to the handle.
4986fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
4987    let trust = config::read_trust().ok()?;
4988    let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
4989    if let Some(card) = agent.get("card") {
4990        Some(crate::character::Character::from_card(card))
4991    } else {
4992        let did = agent.get("did").and_then(Value::as_str)?;
4993        Some(crate::character::Character::from_did(did))
4994    }
4995}
4996
4997/// "emoji nickname" label for a peer, falling back to the raw handle.
4998fn persona_label(peer_handle: &str) -> String {
4999    match resolve_persona(peer_handle) {
5000        Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
5001        None => peer_handle.to_string(),
5002    }
5003}
5004
5005/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
5006/// full structured event for tooling consumption; the plain form is a tight
5007/// one-line summary suitable as a harness stream-watcher notification.
5008///
5009/// Kept PURE (no trust I/O) so it stays deterministic and cheap per event.
5010/// Persona enrichment for `--json` belongs at InboxEvent construction in
5011/// `inbox_watch` (a follow-up), not here.
5012fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
5013    if as_json {
5014        Ok(serde_json::to_string(e)?)
5015    } else {
5016        let eid_short: String = e.event_id.chars().take(12).collect();
5017        let body = e.body_preview.replace('\n', " ");
5018        let ts: String = e.timestamp.chars().take(19).collect();
5019        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
5020    }
5021}
5022
5023/// `wire monitor` — long-running line-per-event stream of new inbox events.
5024///
5025/// Built for agent harnesses that have an "every stdout line is a chat
5026/// notification" stream watcher (Claude Code Monitor tool, etc.). One
5027/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
5028/// | python parse | grep -v pair_drop` pipeline operators improvise on day
5029/// one of every wire session.
5030///
5031/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
5032/// pure handshake / liveness noise that operators almost never want
5033/// surfaced. Pass `--include-handshake` if you do.
5034///
5035/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
5036/// doesn't drown the operator in replay), with optional `--replay N` to
5037/// emit the last N events first.
5038fn cmd_monitor(
5039    peer_filter: Option<&str>,
5040    as_json: bool,
5041    include_handshake: bool,
5042    interval_ms: u64,
5043    replay: usize,
5044) -> Result<()> {
5045    let inbox_dir = config::inbox_dir()?;
5046    if !inbox_dir.exists() && !as_json {
5047        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
5048    }
5049    // v0.13.x identity work: monitor owns the inbox cursor across the
5050    // long-running poll loop; collision with another wire process under
5051    // the same WIRE_HOME causes "I'm not seeing X's events" debugging
5052    // rabbit holes. Warn at startup so the operator catches it fast.
5053    crate::session::warn_on_identity_collision(std::process::id(), "monitor");
5054    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
5055
5056    // Optional replay — read existing files and emit the last `replay` events
5057    // (post-filter) before going live. Useful when the harness restarts and
5058    // wants recent context.
5059    if replay > 0 && inbox_dir.exists() {
5060        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
5061        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
5062            let path = entry.path();
5063            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5064                continue;
5065            }
5066            let peer = match path.file_stem().and_then(|s| s.to_str()) {
5067                Some(s) => s.to_string(),
5068                None => continue,
5069            };
5070            if let Some(filter) = peer_filter
5071                && peer != filter
5072            {
5073                continue;
5074            }
5075            let body = std::fs::read_to_string(&path).unwrap_or_default();
5076            for line in body.lines() {
5077                let line = line.trim();
5078                if line.is_empty() {
5079                    continue;
5080                }
5081                let signed: Value = match serde_json::from_str(line) {
5082                    Ok(v) => v,
5083                    Err(_) => continue,
5084                };
5085                let ev = crate::inbox_watch::InboxEvent::from_signed(
5086                    &peer, signed, /* verified */ true,
5087                );
5088                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
5089                    continue;
5090                }
5091                all.push(ev);
5092            }
5093        }
5094        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
5095        // chronological for same-zoned timestamps).
5096        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
5097        let start = all.len().saturating_sub(replay);
5098        for ev in &all[start..] {
5099            println!("{}", monitor_render(ev, as_json)?);
5100        }
5101        use std::io::Write;
5102        std::io::stdout().flush().ok();
5103    }
5104
5105    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
5106    // the first poll only returns events that arrived AFTER startup.
5107    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
5108    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
5109
5110    loop {
5111        // Never die silently. wisp-blossom (Win10) saw `wire monitor` exit 1
5112        // with ZERO bytes on stdout+stderr when a cursor-block (untrusted
5113        // signer's pair event) tripped the watcher — a silent death looks
5114        // identical to "still watching" and breaks the sister-collab model.
5115        // Surface the reason and KEEP watching instead of propagating a fatal
5116        // `?` that some callers swallow.
5117        let events = match w.poll() {
5118            Ok(evs) => evs,
5119            Err(e) => {
5120                eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
5121                std::thread::sleep(sleep_dur);
5122                continue;
5123            }
5124        };
5125        let mut wrote = false;
5126        for ev in events {
5127            if let Some(filter) = peer_filter
5128                && ev.peer != filter
5129            {
5130                continue;
5131            }
5132            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
5133                continue;
5134            }
5135            println!("{}", monitor_render(&ev, as_json)?);
5136            wrote = true;
5137        }
5138        if wrote {
5139            use std::io::Write;
5140            std::io::stdout().flush().ok();
5141        }
5142        std::thread::sleep(sleep_dur);
5143    }
5144}
5145
5146#[cfg(test)]
5147mod tier_tests {
5148    use super::*;
5149    use serde_json::json;
5150
5151    fn trust_with(handle: &str, tier: &str) -> Value {
5152        json!({
5153            "version": 1,
5154            "agents": {
5155                handle: {
5156                    "tier": tier,
5157                    "did": format!("did:wire:{handle}"),
5158                    "card": {"capabilities": ["wire/v3.1"]}
5159                }
5160            }
5161        })
5162    }
5163
5164    #[test]
5165    fn pending_ack_when_verified_but_no_slot_token() {
5166        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
5167        // slot_token hasn't arrived yet. Display PENDING_ACK so the
5168        // operator knows wire send won't work yet.
5169        let trust = trust_with("willard", "VERIFIED");
5170        let relay_state = json!({
5171            "peers": {
5172                "willard": {
5173                    "relay_url": "https://relay",
5174                    "slot_id": "abc",
5175                    "slot_token": "",
5176                }
5177            }
5178        });
5179        assert_eq!(
5180            effective_peer_tier(&trust, &relay_state, "willard"),
5181            "PENDING_ACK"
5182        );
5183    }
5184
5185    #[test]
5186    fn verified_when_slot_token_present() {
5187        let trust = trust_with("willard", "VERIFIED");
5188        let relay_state = json!({
5189            "peers": {
5190                "willard": {
5191                    "relay_url": "https://relay",
5192                    "slot_id": "abc",
5193                    "slot_token": "tok123",
5194                }
5195            }
5196        });
5197        assert_eq!(
5198            effective_peer_tier(&trust, &relay_state, "willard"),
5199            "VERIFIED"
5200        );
5201    }
5202
5203    #[test]
5204    fn raw_tier_passes_through_for_non_verified() {
5205        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
5206        // UNTRUSTED regardless of slot_token state.
5207        let trust = trust_with("willard", "UNTRUSTED");
5208        let relay_state = json!({
5209            "peers": {"willard": {"slot_token": ""}}
5210        });
5211        assert_eq!(
5212            effective_peer_tier(&trust, &relay_state, "willard"),
5213            "UNTRUSTED"
5214        );
5215    }
5216
5217    #[test]
5218    fn pending_ack_when_relay_state_missing_peer() {
5219        // After wire add, trust gets updated BEFORE relay_state.peers does.
5220        // If relay_state has no entry for the peer at all, the operator
5221        // still hasn't completed the bilateral pin — show PENDING_ACK.
5222        let trust = trust_with("willard", "VERIFIED");
5223        let relay_state = json!({"peers": {}});
5224        assert_eq!(
5225            effective_peer_tier(&trust, &relay_state, "willard"),
5226            "PENDING_ACK"
5227        );
5228    }
5229}
5230
5231#[cfg(test)]
5232mod monitor_tests {
5233    use super::*;
5234    use crate::inbox_watch::InboxEvent;
5235    use serde_json::Value;
5236
5237    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
5238        InboxEvent {
5239            peer: peer.to_string(),
5240            event_id: "abcd1234567890ef".to_string(),
5241            kind: kind.to_string(),
5242            body_preview: body.to_string(),
5243            verified: true,
5244            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
5245            raw: Value::Null,
5246        }
5247    }
5248
5249    #[test]
5250    fn monitor_filter_drops_handshake_kinds_by_default() {
5251        // The whole point: pair_drop / pair_drop_ack / heartbeat are
5252        // protocol noise. If they leak into the operator's chat stream by
5253        // default, the recipe is useless ("wire monitor talks too much,
5254        // disabled it"). Burn this rule in.
5255        assert!(monitor_is_noise_kind("pair_drop"));
5256        assert!(monitor_is_noise_kind("pair_drop_ack"));
5257        assert!(monitor_is_noise_kind("heartbeat"));
5258
5259        // Real-payload kinds — operator wants every one.
5260        assert!(!monitor_is_noise_kind("claim"));
5261        assert!(!monitor_is_noise_kind("decision"));
5262        assert!(!monitor_is_noise_kind("ack"));
5263        assert!(!monitor_is_noise_kind("request"));
5264        assert!(!monitor_is_noise_kind("note"));
5265        // Unknown future kinds shouldn't be filtered as noise either —
5266        // operator probably wants to see something they don't recognise,
5267        // not have it silently dropped (the P0.1 lesson at the UX layer).
5268        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
5269    }
5270
5271    #[test]
5272    fn monitor_render_plain_is_one_short_line() {
5273        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
5274        let line = monitor_render(&e, false).unwrap();
5275        // Must be single-line.
5276        assert!(!line.contains('\n'), "render must be one line: {line}");
5277        // Must include peer, kind, body fragment, short event_id.
5278        assert!(line.contains("willard"));
5279        assert!(line.contains("claim"));
5280        assert!(line.contains("real v8 train"));
5281        // Short event id (first 12 chars).
5282        assert!(line.contains("abcd12345678"));
5283        assert!(
5284            !line.contains("abcd1234567890ef"),
5285            "should truncate full id"
5286        );
5287        // RFC3339-ish second precision.
5288        assert!(line.contains("2026-05-15T23:14:07"));
5289    }
5290
5291    #[test]
5292    fn monitor_render_strips_newlines_from_body() {
5293        // Multi-line bodies (markdown lists, code, etc.) must collapse to
5294        // one line — otherwise a single message produces multiple
5295        // notifications in the harness, ruining the "one event = one line"
5296        // contract the Monitor tool relies on.
5297        let e = ev("spark", "claim", "line one\nline two\nline three");
5298        let line = monitor_render(&e, false).unwrap();
5299        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
5300        assert!(line.contains("line one line two line three"));
5301    }
5302
5303    #[test]
5304    fn monitor_render_json_is_valid_jsonl() {
5305        let e = ev("spark", "claim", "hi");
5306        let line = monitor_render(&e, true).unwrap();
5307        assert!(!line.contains('\n'));
5308        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
5309        assert_eq!(parsed["peer"], "spark");
5310        assert_eq!(parsed["kind"], "claim");
5311        assert_eq!(parsed["body_preview"], "hi");
5312    }
5313
5314    #[test]
5315    fn monitor_does_not_drop_on_verified_null() {
5316        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
5317        // `select(.verified == true)` against inbox JSONL. Daemon writes
5318        // events with verified=null (verification happens at tail-time, not
5319        // write-time), so the filter silently rejected everything — same
5320        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
5321        // never surfaced for ~30min.
5322        //
5323        // wire monitor's render path must NOT consult `.verified` for any
5324        // filter decision. Lock that in here so a future "be conservative,
5325        // only emit verified" patch can't quietly land.
5326        let mut e = ev("spark", "claim", "from disk with verified=null");
5327        e.verified = false; // worst case — even if disk says unverified, emit
5328        let line = monitor_render(&e, false).unwrap();
5329        assert!(line.contains("from disk with verified=null"));
5330        // Noise filter operates purely on kind, never on verified.
5331        assert!(!monitor_is_noise_kind("claim"));
5332    }
5333}
5334
5335// ---------- verify ----------
5336
5337fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
5338    let body = if path == "-" {
5339        let mut buf = String::new();
5340        use std::io::Read;
5341        std::io::stdin().read_to_string(&mut buf)?;
5342        buf
5343    } else {
5344        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
5345    };
5346    let event: Value = serde_json::from_str(&body)?;
5347    let trust = config::read_trust()?;
5348    match verify_message_v31(&event, &trust) {
5349        Ok(()) => {
5350            if as_json {
5351                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
5352            } else {
5353                println!("verified ✓");
5354            }
5355            Ok(())
5356        }
5357        Err(e) => {
5358            let reason = e.to_string();
5359            if as_json {
5360                println!(
5361                    "{}",
5362                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
5363                );
5364            } else {
5365                eprintln!("FAILED: {reason}");
5366            }
5367            std::process::exit(1);
5368        }
5369    }
5370}
5371
5372// ---------- mcp / relay-server stubs ----------
5373
5374fn cmd_mcp() -> Result<()> {
5375    crate::mcp::run()
5376}
5377
5378fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
5379    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
5380    // overriding --bind. Implies --local-only semantics. Routed to a
5381    // separate serve_uds entry point with a manual hyper accept loop
5382    // (axum 0.7's `serve` is TcpListener-only).
5383    if let Some(socket_path) = uds {
5384        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
5385            std::path::PathBuf::from(home)
5386                .join("state")
5387                .join("wire-relay")
5388                .join("uds")
5389        } else {
5390            dirs::state_dir()
5391                .or_else(dirs::data_local_dir)
5392                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
5393                .join("wire-relay")
5394                .join("uds")
5395        };
5396        let runtime = tokio::runtime::Builder::new_multi_thread()
5397            .enable_all()
5398            .build()?;
5399        return runtime.block_on(crate::relay_server::serve_uds(
5400            socket_path.to_path_buf(),
5401            base,
5402        ));
5403    }
5404    // v0.5.17: --local-only refuses non-loopback binds. Catches the
5405    // "wait did I just bind a publicly-reachable local-only relay" mistake
5406    // at startup rather than discovering it via an empty phonebook later.
5407    if local_only {
5408        validate_loopback_bind(bind)?;
5409    }
5410    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
5411    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
5412    // so a single user can run both client and server on one machine.
5413    // For --local-only, suffix with /local so a single operator can run
5414    // both a federation relay and a local-only relay without state collision.
5415    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
5416        std::path::PathBuf::from(home)
5417            .join("state")
5418            .join("wire-relay")
5419    } else {
5420        dirs::state_dir()
5421            .or_else(dirs::data_local_dir)
5422            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
5423            .join("wire-relay")
5424    };
5425    let state_dir = if local_only { base.join("local") } else { base };
5426    let runtime = tokio::runtime::Builder::new_multi_thread()
5427        .enable_all()
5428        .build()?;
5429    runtime.block_on(crate::relay_server::serve_with_mode(
5430        bind,
5431        state_dir,
5432        crate::relay_server::ServerMode { local_only },
5433    ))
5434}
5435
5436/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
5437/// resolves to something outside `127.0.0.0/8` or `::1`.
5438///
5439/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
5440/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
5441/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
5442///
5443/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
5444/// is the IP range Tailscale uses for tailnet addresses. Lets operators
5445/// pair wire across machines using their tailnet IPs (e.g. Mac at
5446/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
5447/// auth + encryption + NAT traversal, wire handles protocol + identity.
5448/// Sidesteps host firewall config entirely (utun interface bypass).
5449///
5450/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
5451/// multicast, broadcast. Those would publish a "local-only" relay to
5452/// the global internet — the v0.5.17 security gate's whole point.
5453fn validate_loopback_bind(bind: &str) -> Result<()> {
5454    // Split host:port. IPv6 literals use `[::]:port` form.
5455    let host = if let Some(stripped) = bind.strip_prefix('[') {
5456        let close = stripped
5457            .find(']')
5458            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
5459        stripped[..close].to_string()
5460    } else {
5461        bind.rsplit_once(':')
5462            .map(|(h, _)| h.to_string())
5463            .unwrap_or_else(|| bind.to_string())
5464    };
5465    use std::net::{IpAddr, ToSocketAddrs};
5466    let probe = format!("{host}:0");
5467    let resolved: Vec<_> = probe
5468        .to_socket_addrs()
5469        .with_context(|| format!("resolving bind host {host:?}"))?
5470        .collect();
5471    if resolved.is_empty() {
5472        bail!("--local-only: bind host {host:?} resolved to no addresses");
5473    }
5474    for addr in &resolved {
5475        let ip = addr.ip();
5476        let is_acceptable = match ip {
5477            IpAddr::V4(v4) => {
5478                v4.is_loopback() || v4.is_private() || {
5479                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
5480                    let octets = v4.octets();
5481                    octets[0] == 100 && (64..=127).contains(&octets[1])
5482                }
5483            }
5484            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
5485        };
5486        if !is_acceptable {
5487            bail!(
5488                "--local-only refuses non-private bind: {host:?} resolves to {ip} \
5489                 which is not loopback (127/8, ::1), RFC 1918 private \
5490                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
5491                 (100.64.0.0/10). Remove --local-only to bind publicly."
5492            );
5493        }
5494    }
5495    Ok(())
5496}
5497
5498// ---------- bind-relay ----------
5499
5500fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
5501    use crate::endpoints::EndpointScope;
5502    match s.to_lowercase().as_str() {
5503        "federation" | "fed" => Ok(EndpointScope::Federation),
5504        "local" => Ok(EndpointScope::Local),
5505        "lan" => Ok(EndpointScope::Lan),
5506        "uds" => Ok(EndpointScope::Uds),
5507        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
5508    }
5509}
5510
5511/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
5512/// appended to `self.endpoints[]`, keeping any existing slots so an agent
5513/// can hold a local relay AND a federation relay simultaneously without
5514/// black-holing pinned peers. `--replace` restores the pre-v0.12
5515/// destructive single-slot behavior (guarded by issue #7).
5516fn cmd_bind_relay(
5517    url: &str,
5518    scope: Option<&str>,
5519    replace: bool,
5520    migrate_pinned: bool,
5521    as_json: bool,
5522) -> Result<()> {
5523    use crate::endpoints::{Endpoint, self_endpoints};
5524
5525    if !config::is_initialized()? {
5526        bail!("not initialized — run `wire init <handle>` first");
5527    }
5528    let card = config::read_agent_card()?;
5529    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5530    let handle = crate::agent_card::display_handle_from_did(did).to_string();
5531
5532    let normalized_raw = url.trim_end_matches('/');
5533    // Refuse to record/publish a relay endpoint that embeds userinfo —
5534    // `https://<handle>@<host>` 4xxes every inbound event POST. Strip and
5535    // warn so operators learn the right shape without losing the call.
5536    let normalized_owned = strip_relay_url_userinfo(normalized_raw);
5537    let normalized = normalized_owned.as_str();
5538    // Belt-and-suspenders: confirm the post-strip URL is clean before any
5539    // persist / publish. A future code path that bypasses the strip filter
5540    // MUST NOT be able to leak userinfo into the signed agent-card.
5541    assert_relay_url_clean_for_publish(normalized)?;
5542    let new_scope = match scope {
5543        Some(s) => parse_scope(s)?,
5544        None => crate::endpoints::infer_scope_from_url(normalized),
5545    };
5546
5547    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
5548    let pinned: Vec<String> = existing
5549        .get("peers")
5550        .and_then(|p| p.as_object())
5551        .map(|o| o.keys().cloned().collect())
5552        .unwrap_or_default();
5553
5554    let existing_eps = self_endpoints(&existing);
5555    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
5556
5557    // Destructive paths that black-hole pinned peers (issue #7):
5558    //   • `--replace` drops every other slot.
5559    //   • re-binding the SAME relay rotates that slot in place.
5560    // An additive bind of a NEW relay keeps existing slots, so peers stay
5561    // reachable — no acknowledgement required. This is the v0.12 default
5562    // that unblocks simultaneous local + remote.
5563    let destructive = replace || is_rebind_same;
5564    if destructive && !pinned.is_empty() && !migrate_pinned {
5565        let list = pinned.join(", ");
5566        let why = if replace {
5567            "`--replace` drops your other slot(s)"
5568        } else {
5569            "re-binding the same relay rotates its slot"
5570        };
5571        bail!(
5572            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
5573             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
5574             read.\n\n\
5575             SAFE PATHS:\n\
5576             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
5577             slots — no black-hole.\n\
5578             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
5579             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
5580             peer out-of-band.\n\n\
5581             Issue #7 (silent black-hole on relay change) caught this.",
5582            n = pinned.len(),
5583        );
5584    }
5585
5586    let client = crate::relay_client::RelayClient::new(normalized);
5587    client.check_healthz()?;
5588    let alloc = client.allocate_slot(Some(&handle))?;
5589
5590    if destructive && !pinned.is_empty() {
5591        eprintln!(
5592            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
5593             until they re-pin: {peers}",
5594            mode = if replace { "replacing" } else { "rotating" },
5595            n = pinned.len(),
5596            peers = pinned.join(", "),
5597        );
5598    }
5599
5600    // Write the new slot via the single source of truth for the self-slot
5601    // shape. Additive by default; --replace starts from an empty self so
5602    // only this slot remains.
5603    let mut state = existing;
5604    if replace {
5605        state["self"] = Value::Null;
5606    }
5607    crate::endpoints::upsert_self_endpoint(
5608        &mut state,
5609        Endpoint {
5610            relay_url: normalized.to_string(),
5611            slot_id: alloc.slot_id.clone(),
5612            slot_token: alloc.slot_token.clone(),
5613            scope: new_scope,
5614        },
5615    );
5616    config::write_relay_state(&state)?;
5617    let eps = self_endpoints(&state);
5618
5619    let scope_str = format!("{new_scope:?}").to_lowercase();
5620    if as_json {
5621        println!(
5622            "{}",
5623            serde_json::to_string(&json!({
5624                "relay_url": normalized,
5625                "slot_id": alloc.slot_id,
5626                "scope": scope_str,
5627                "endpoints": eps.len(),
5628                "additive": !replace,
5629                "slot_token_present": true,
5630            }))?
5631        );
5632    } else {
5633        println!(
5634            "bound {scope_str} slot on {normalized} (slot {})",
5635            alloc.slot_id
5636        );
5637        println!(
5638            "self now has {n} endpoint(s): {list}",
5639            n = eps.len(),
5640            list = eps
5641                .iter()
5642                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
5643                .collect::<Vec<_>>()
5644                .join(", "),
5645        );
5646    }
5647    Ok(())
5648}
5649
5650// ---------- add-peer-slot ----------
5651
5652fn cmd_add_peer_slot(
5653    handle: &str,
5654    url: &str,
5655    slot_id: &str,
5656    slot_token: &str,
5657    as_json: bool,
5658) -> Result<()> {
5659    use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
5660    let mut state = config::read_relay_state()?;
5661
5662    // E3 (v0.13.2): ADD this slot to the peer's endpoint set — don't REPLACE
5663    // the whole entry. The old flat `peers.insert` clobbered an existing
5664    // peer's federation endpoint when pinning a local slot, silently dropping
5665    // the federation route (glossy-magnolia + wisp-blossom repro: pinning a
5666    // loopback slot made the peer flat loopback-only). Mirror bind-relay's
5667    // additive semantics: upsert by relay_url into the peer's endpoints[].
5668    let new_ep = Endpoint {
5669        relay_url: url.to_string(),
5670        slot_id: slot_id.to_string(),
5671        slot_token: slot_token.to_string(),
5672        scope: infer_scope_from_url(url),
5673    };
5674    let mut endpoints: Vec<Endpoint> = state
5675        .get("peers")
5676        .and_then(|p| p.get(handle))
5677        .and_then(|e| e.get("endpoints"))
5678        .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
5679        .unwrap_or_default();
5680    // Back-compat: seed from legacy flat fields when the peer predates endpoints[].
5681    if endpoints.is_empty()
5682        && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
5683        && let (Some(ru), Some(si), Some(st)) = (
5684            peer.get("relay_url").and_then(Value::as_str),
5685            peer.get("slot_id").and_then(Value::as_str),
5686            peer.get("slot_token").and_then(Value::as_str),
5687        )
5688    {
5689        endpoints.push(Endpoint {
5690            relay_url: ru.to_string(),
5691            slot_id: si.to_string(),
5692            slot_token: st.to_string(),
5693            scope: infer_scope_from_url(ru),
5694        });
5695    }
5696    // Upsert by relay_url: refresh in place if already pinned, else append.
5697    if let Some(existing) = endpoints
5698        .iter_mut()
5699        .find(|e| e.relay_url == new_ep.relay_url)
5700    {
5701        *existing = new_ep;
5702    } else {
5703        endpoints.push(new_ep);
5704    }
5705    let n = endpoints.len();
5706    pin_peer_endpoints(&mut state, handle, &endpoints)?;
5707    config::write_relay_state(&state)?;
5708    if as_json {
5709        println!(
5710            "{}",
5711            serde_json::to_string(&json!({
5712                "handle": handle,
5713                "relay_url": url,
5714                "slot_id": slot_id,
5715                "added": true,
5716                "endpoint_count": n,
5717            }))?
5718        );
5719    } else {
5720        println!(
5721            "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
5722        );
5723    }
5724    Ok(())
5725}
5726
5727// ---------- push ----------
5728
5729fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
5730    let mut state = config::read_relay_state()?;
5731    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5732    if peers.is_empty() {
5733        bail!(
5734            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
5735        );
5736    }
5737    let outbox_dir = config::outbox_dir()?;
5738    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
5739    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
5740    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
5741    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
5742    if outbox_dir.exists() {
5743        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
5744        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
5745            let path = entry.path();
5746            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5747                continue;
5748            }
5749            let stem = match path.file_stem().and_then(|s| s.to_str()) {
5750                Some(s) => s.to_string(),
5751                None => continue,
5752            };
5753            if pinned.contains(&stem) {
5754                continue;
5755            }
5756            // Try the bare-handle of the orphaned stem — if THAT matches a
5757            // pinned peer, the stem is a stale FQDN-suffixed file.
5758            let bare = crate::agent_card::bare_handle(&stem);
5759            if pinned.contains(bare) {
5760                eprintln!(
5761                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
5762                     Merge with: `cat {} >> {}` then delete the FQDN file.",
5763                    stem,
5764                    path.display(),
5765                    outbox_dir.join(format!("{bare}.jsonl")).display(),
5766                );
5767            }
5768        }
5769    }
5770    if !outbox_dir.exists() {
5771        if as_json {
5772            println!(
5773                "{}",
5774                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
5775            );
5776        } else {
5777            println!("phyllis: nothing to dial out — write a message first with `wire send`");
5778        }
5779        return Ok(());
5780    }
5781
5782    let mut pushed = Vec::new();
5783    let mut skipped = Vec::new();
5784
5785    // Issue #15: track which peers we've already re-resolved this push call
5786    // so we don't whois more than once per peer per push (the rate limit the
5787    // issue specifies). Lifetime is the whole `cmd_push` invocation; clears
5788    // every time the operator (or daemon) runs `wire push` again.
5789    let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
5790    // Track whether we mutated `state` so we can write it back exactly
5791    // once at the end (avoids a write per peer).
5792    let mut state_dirty = false;
5793
5794    // v0.5.17: walk each peer's pinned endpoints in priority order (local
5795    // first if we share a local relay, federation second). Try POST on the
5796    // first endpoint; on transport failure, fall through to the next.
5797    // Falls back to the v0.5.16 legacy single-endpoint code path when the
5798    // peer record carries no `endpoints[]` array (back-compat).
5799    for (peer_handle, _) in peers.iter() {
5800        if let Some(want) = peer_filter
5801            && peer_handle != want
5802        {
5803            continue;
5804        }
5805        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5806        if !outbox.exists() {
5807            continue;
5808        }
5809        let mut ordered_endpoints =
5810            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
5811        if ordered_endpoints.is_empty() {
5812            // Unreachable peer (no federation endpoint AND our local
5813            // relay doesn't match the peer's). Skip with a loud reason
5814            // rather than silently dropping events.
5815            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
5816                let event: Value = match serde_json::from_str(line) {
5817                    Ok(v) => v,
5818                    Err(_) => continue,
5819                };
5820                let event_id = event
5821                    .get("event_id")
5822                    .and_then(Value::as_str)
5823                    .unwrap_or("")
5824                    .to_string();
5825                skipped.push(json!({
5826                    "peer": peer_handle,
5827                    "event_id": event_id,
5828                    "reason": "no reachable endpoint pinned for peer",
5829                }));
5830            }
5831            continue;
5832        }
5833        let body = std::fs::read_to_string(&outbox)?;
5834        for line in body.lines() {
5835            let event: Value = match serde_json::from_str(line) {
5836                Ok(v) => v,
5837                Err(_) => continue,
5838            };
5839            let event_id = event
5840                .get("event_id")
5841                .and_then(Value::as_str)
5842                .unwrap_or("")
5843                .to_string();
5844
5845            // Capture the most recent per-endpoint error reason via a RefCell
5846            // so we can preserve cmd_push's pre-existing "last-error wins"
5847            // semantics for the skipped-with-reason path. The shared
5848            // try_post_event_with_failover helper (from #62) handles iteration,
5849            // priority order, and early-return on first success; the closure
5850            // applies the existing `format_transport_error` formatting on
5851            // each individual error so the operator sees the same diagnostic
5852            // text as before the dedup.
5853            let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
5854            match crate::relay_client::try_post_event_with_failover(
5855                &ordered_endpoints,
5856                &event,
5857                |endpoint, ev| {
5858                    let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5859                    match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
5860                        Ok(resp) => Ok(resp),
5861                        Err(e) => {
5862                            *last_err.borrow_mut() =
5863                                Some(crate::relay_client::format_transport_error(&e));
5864                            Err(e)
5865                        }
5866                    }
5867                },
5868            ) {
5869                Ok((endpoint, resp)) => {
5870                    if resp.status == "duplicate" {
5871                        skipped.push(json!({
5872                            "peer": peer_handle,
5873                            "event_id": event_id,
5874                            "reason": "duplicate",
5875                            "endpoint": endpoint.relay_url,
5876                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5877                        }));
5878                    } else {
5879                        pushed.push(json!({
5880                            "peer": peer_handle,
5881                            "event_id": event_id,
5882                            "endpoint": endpoint.relay_url,
5883                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5884                        }));
5885                    }
5886                }
5887                Err(_) => {
5888                    // Issue #15: before reporting the event as skipped, see
5889                    // if the failure smelled like a slot-rotation (4xx 404 /
5890                    // 410). If yes AND we haven't already re-resolved this
5891                    // peer in this push call, attempt one whois lookup. On
5892                    // a real rotation, the helper updates `state.peers[peer]`
5893                    // in place; we refresh `ordered_endpoints` from the
5894                    // mutated state and retry the same event once. Composes
5895                    // with the doctor #14 staleness check from PR #68: #14
5896                    // surfaces the symptom, #15 closes the loop.
5897                    let last_err_text = last_err.borrow().clone().unwrap_or_default();
5898                    let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
5899                    match try_reresolve_peer_on_slot_4xx(
5900                        &mut state,
5901                        peer_handle,
5902                        &last_err_text,
5903                        &rotated_this_push,
5904                    ) {
5905                        Ok(true) => {
5906                            // Mark this peer as already re-resolved this push.
5907                            rotated_this_push.insert(peer_handle.clone());
5908                            state_dirty = true;
5909                            // Refresh endpoints from the updated state and
5910                            // retry exactly once. last_err is also reset so
5911                            // the retry's error (if any) replaces the prior
5912                            // one in the eventual skipped reason.
5913                            ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
5914                                &state,
5915                                peer_handle,
5916                            );
5917                            *last_err.borrow_mut() = None;
5918                            if let Ok((endpoint, resp)) =
5919                                crate::relay_client::try_post_event_with_failover(
5920                                    &ordered_endpoints,
5921                                    &event,
5922                                    |endpoint, ev| {
5923                                        let client = crate::relay_client::RelayClient::new(
5924                                            &endpoint.relay_url,
5925                                        );
5926                                        match client.post_event(
5927                                            &endpoint.slot_id,
5928                                            &endpoint.slot_token,
5929                                            ev,
5930                                        ) {
5931                                            Ok(resp) => Ok(resp),
5932                                            Err(e) => {
5933                                                *last_err.borrow_mut() = Some(
5934                                                    crate::relay_client::format_transport_error(&e),
5935                                                );
5936                                                Err(e)
5937                                            }
5938                                        }
5939                                    },
5940                                )
5941                            {
5942                                delivered_via_retry = Some((endpoint, resp));
5943                            }
5944                        }
5945                        Ok(false) => {
5946                            // Either not a slot-rotation shape, or already
5947                            // re-resolved this push, or slot id unchanged —
5948                            // fall through to the original skipped path.
5949                        }
5950                        Err(e) => {
5951                            // Re-resolve itself failed (DNS down, relay 5xx,
5952                            // handle unclaimed, etc.). Don't fail the push —
5953                            // fall through to skipped with the resolve error
5954                            // appended for diagnostic context.
5955                            *last_err.borrow_mut() = Some(format!(
5956                                "{}; re-resolve also failed: {e:#}",
5957                                last_err.borrow().clone().unwrap_or_default()
5958                            ));
5959                            // Mark as tried so we don't loop on the next event.
5960                            rotated_this_push.insert(peer_handle.clone());
5961                        }
5962                    }
5963                    if let Some((endpoint, resp)) = delivered_via_retry {
5964                        if resp.status == "duplicate" {
5965                            skipped.push(json!({
5966                                "peer": peer_handle,
5967                                "event_id": event_id,
5968                                "reason": "duplicate",
5969                                "endpoint": endpoint.relay_url,
5970                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5971                                "via": "slot_reresolve_retry",
5972                            }));
5973                        } else {
5974                            pushed.push(json!({
5975                                "peer": peer_handle,
5976                                "event_id": event_id,
5977                                "endpoint": endpoint.relay_url,
5978                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5979                                "via": "slot_reresolve_retry",
5980                            }));
5981                        }
5982                    } else {
5983                        // Every endpoint failed even after (any) retry.
5984                        // Preserve the prior "last reason is what gets
5985                        // reported" UX (the closure captured the last per-
5986                        // endpoint error via `last_err`).
5987                        skipped.push(json!({
5988                            "peer": peer_handle,
5989                            "event_id": event_id,
5990                            "reason": last_err
5991                                .borrow()
5992                                .clone()
5993                                .unwrap_or_else(|| "all endpoints failed".to_string()),
5994                        }));
5995                    }
5996                }
5997            }
5998        }
5999    }
6000
6001    // Issue #15: persist any in-place slot rotations from the per-peer loop
6002    // exactly once at the end. Best-effort: if the write fails the operator
6003    // still gets a valid push report, and the next push will re-attempt the
6004    // resolve (cheap) before retrying delivery.
6005    if state_dirty && let Err(e) = config::write_relay_state(&state) {
6006        eprintln!(
6007            "wire push: WARN failed to persist rotated peer slots: {e:#}. \
6008             Slot rotation will be re-attempted on next push."
6009        );
6010    }
6011
6012    if as_json {
6013        println!(
6014            "{}",
6015            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
6016        );
6017    } else {
6018        println!(
6019            "pushed {} event(s); skipped {} ({})",
6020            pushed.len(),
6021            skipped.len(),
6022            if skipped.is_empty() {
6023                "none"
6024            } else {
6025                "see --json for detail"
6026            }
6027        );
6028    }
6029    Ok(())
6030}
6031
6032// ---------- pull ----------
6033
6034fn cmd_pull(as_json: bool) -> Result<()> {
6035    let state = config::read_relay_state()?;
6036    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
6037    if self_state.is_null() {
6038        bail!("self slot not bound — run `wire bind-relay <url>` first");
6039    }
6040
6041    // v0.5.17: pull from every endpoint in self.endpoints (federation +
6042    // optional local). Each endpoint has its own per-scope cursor so we
6043    // don't re-pull events we've already seen on that path. Events from
6044    // all endpoints feed into the same inbox JSONL via process_events;
6045    // dedup by event_id is the last line of defense.
6046    // Falls back to a single federation endpoint synthesized from the
6047    // top-level legacy fields when self.endpoints is absent (v0.5.16
6048    // back-compat).
6049    let endpoints = crate::endpoints::self_endpoints(&state);
6050    if endpoints.is_empty() {
6051        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
6052    }
6053
6054    let inbox_dir = config::inbox_dir()?;
6055    config::ensure_dirs()?;
6056
6057    let mut total_seen = 0usize;
6058    let mut all_written: Vec<Value> = Vec::new();
6059    let mut all_rejected: Vec<Value> = Vec::new();
6060    let mut all_blocked = false;
6061    let mut all_advance_cursor_to: Option<String> = None;
6062
6063    for endpoint in &endpoints {
6064        let cursor_key = endpoint_cursor_key(endpoint.scope);
6065        let last_event_id = self_state
6066            .get(&cursor_key)
6067            .and_then(Value::as_str)
6068            .map(str::to_string);
6069        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
6070        let events = match client.list_events(
6071            &endpoint.slot_id,
6072            &endpoint.slot_token,
6073            last_event_id.as_deref(),
6074            Some(1000),
6075        ) {
6076            Ok(ev) => ev,
6077            Err(e) => {
6078                // One endpoint's failure shouldn't kill the whole pull.
6079                // The local-relay-down case in particular needs to
6080                // gracefully continue against federation.
6081                eprintln!(
6082                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
6083                    endpoint.relay_url,
6084                    endpoint.scope,
6085                    crate::relay_client::format_transport_error(&e),
6086                );
6087                continue;
6088            }
6089        };
6090        total_seen += events.len();
6091        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
6092        all_written.extend(result.written.iter().cloned());
6093        all_rejected.extend(result.rejected.iter().cloned());
6094        if result.blocked {
6095            all_blocked = true;
6096        }
6097        // Advance per-endpoint cursor. The cursor key is scope-specific
6098        // so federation and local don't trample each other.
6099        if let Some(eid) = result.advance_cursor_to.clone() {
6100            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
6101                all_advance_cursor_to = Some(eid.clone());
6102            }
6103            let key = cursor_key.clone();
6104            config::update_relay_state(|state| {
6105                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
6106                    self_obj.insert(key, Value::String(eid));
6107                }
6108                Ok(())
6109            })?;
6110        }
6111    }
6112
6113    // Compatibility shim for the legacy single-cursor code paths below:
6114    // `result` used to come from one process_events call; we now have
6115    // per-endpoint results aggregated into the all_* accumulators.
6116    // Reconstruct a synthetic result for the remaining display logic.
6117    let result = crate::pull::PullResult {
6118        written: all_written,
6119        rejected: all_rejected,
6120        blocked: all_blocked,
6121        advance_cursor_to: all_advance_cursor_to,
6122    };
6123    let events_len = total_seen;
6124
6125    // Cursor advance happened per-endpoint above; no aggregate cursor
6126    // write needed here.
6127
6128    if as_json {
6129        println!(
6130            "{}",
6131            serde_json::to_string(&json!({
6132                "written": result.written,
6133                "rejected": result.rejected,
6134                "total_seen": events_len,
6135                "cursor_blocked": result.blocked,
6136                "cursor_advanced_to": result.advance_cursor_to,
6137            }))?
6138        );
6139    } else {
6140        let blocking = result
6141            .rejected
6142            .iter()
6143            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
6144            .count();
6145        if blocking > 0 {
6146            println!(
6147                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
6148                events_len,
6149                result.written.len(),
6150                result.rejected.len(),
6151                blocking,
6152            );
6153        } else {
6154            println!(
6155                "pulled {} event(s); wrote {}; rejected {}",
6156                events_len,
6157                result.written.len(),
6158                result.rejected.len(),
6159            );
6160        }
6161    }
6162    Ok(())
6163}
6164
6165/// v0.5.17: cursor key for an endpoint's per-scope read position.
6166/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
6167/// back-compat with on-disk relay_state files; local uses a
6168/// `_local` suffix.
6169fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
6170    match scope {
6171        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
6172        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
6173        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
6174        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
6175    }
6176}
6177
6178// ---------- rotate-slot ----------
6179
6180fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
6181    if !config::is_initialized()? {
6182        bail!("not initialized — run `wire init <handle>` first");
6183    }
6184    let mut state = config::read_relay_state()?;
6185    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
6186    if self_state.is_null() {
6187        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
6188    }
6189    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
6190    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
6191    // top-level legacy fields directly and bailed for those sessions.
6192    let primary = crate::endpoints::self_primary_endpoint(&state)
6193        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
6194    let url = primary.relay_url.clone();
6195    let old_slot_id = primary.slot_id.clone();
6196    let old_slot_token = primary.slot_token.clone();
6197
6198    // Read identity to sign the announcement.
6199    let card = config::read_agent_card()?;
6200    let did = card
6201        .get("did")
6202        .and_then(Value::as_str)
6203        .unwrap_or("")
6204        .to_string();
6205    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6206    let pk_b64 = card
6207        .get("verify_keys")
6208        .and_then(Value::as_object)
6209        .and_then(|m| m.values().next())
6210        .and_then(|v| v.get("key"))
6211        .and_then(Value::as_str)
6212        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
6213        .to_string();
6214    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
6215    let sk_seed = config::read_private_key()?;
6216
6217    // Allocate new slot on the same relay.
6218    let normalized = url.trim_end_matches('/').to_string();
6219    let client = crate::relay_client::RelayClient::new(&normalized);
6220    client
6221        .check_healthz()
6222        .context("aborting rotation; old slot still valid")?;
6223    let alloc = client.allocate_slot(Some(&handle))?;
6224    let new_slot_id = alloc.slot_id.clone();
6225    let new_slot_token = alloc.slot_token.clone();
6226
6227    // Optionally announce the rotation to every paired peer via the OLD slot.
6228    // Each peer's recipient-side `wire pull` will pick up this event before
6229    // their daemon next polls the new slot — but auto-update of peer's
6230    // relay.json from a wire_close event is a v0.2 daemon feature; for now
6231    // peers see the event and an operator must manually `add-peer-slot` the
6232    // new coords, OR re-pair via SAS.
6233    let mut announced: Vec<String> = Vec::new();
6234    if !no_announce {
6235        let now = time::OffsetDateTime::now_utc()
6236            .format(&time::format_description::well_known::Rfc3339)
6237            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6238        let body = json!({
6239            "reason": "operator-initiated slot rotation",
6240            "new_relay_url": url,
6241            "new_slot_id": new_slot_id,
6242            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
6243            // In v0.1 slot tokens are bilateral-shared, so peer can post via
6244            // existing add-peer-slot flow if operator chooses to re-issue.
6245        });
6246        let peers = state["peers"].as_object().cloned().unwrap_or_default();
6247        for (peer_handle, _peer_info) in peers.iter() {
6248            let event = json!({
6249                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6250                "timestamp": now.clone(),
6251                "from": did,
6252                "to": format!("did:wire:{peer_handle}"),
6253                "type": "wire_close",
6254                "kind": 1201,
6255                "body": body.clone(),
6256            });
6257            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
6258                Ok(s) => s,
6259                Err(e) => {
6260                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
6261                    continue;
6262                }
6263            };
6264            // Post to OUR old slot (we're announcing on our own slot, NOT
6265            // peer's slot — peer reads from us). Wait, this is wrong: peers
6266            // read from THEIR OWN slot via wire pull. To reach peer A, we
6267            // post to peer A's slot. Use the existing per-peer slot mapping.
6268            let peer_info = match state["peers"].get(peer_handle) {
6269                Some(p) => p.clone(),
6270                None => continue,
6271            };
6272            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
6273            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
6274            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
6275            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
6276                continue;
6277            }
6278            let peer_client = if peer_url == url {
6279                client.clone()
6280            } else {
6281                crate::relay_client::RelayClient::new(peer_url)
6282            };
6283            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
6284                Ok(_) => announced.push(peer_handle.clone()),
6285                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
6286            }
6287        }
6288    }
6289
6290    // Swap the self-slot to the new one.
6291    state["self"] = json!({
6292        "relay_url": url,
6293        "slot_id": new_slot_id,
6294        "slot_token": new_slot_token,
6295    });
6296    config::write_relay_state(&state)?;
6297
6298    if as_json {
6299        println!(
6300            "{}",
6301            serde_json::to_string(&json!({
6302                "rotated": true,
6303                "old_slot_id": old_slot_id,
6304                "new_slot_id": new_slot_id,
6305                "relay_url": url,
6306                "announced_to": announced,
6307            }))?
6308        );
6309    } else {
6310        println!("rotated slot on {url}");
6311        println!(
6312            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
6313        );
6314        println!("  new slot_id: {new_slot_id}");
6315        if !announced.is_empty() {
6316            println!(
6317                "  announced wire_close (kind=1201) to: {}",
6318                announced.join(", ")
6319            );
6320        }
6321        println!();
6322        println!("next steps:");
6323        println!("  - peers see the wire_close event in their next `wire pull`");
6324        println!(
6325            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
6326        );
6327        println!("    (or full re-pair via `wire pair-host`/`wire join`)");
6328        println!("  - until they do, you'll receive but they won't be able to reach you");
6329        // Suppress unused warning
6330        let _ = old_slot_token;
6331    }
6332    Ok(())
6333}
6334
6335// ---------- forget-peer ----------
6336
6337fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
6338    let mut trust = config::read_trust()?;
6339    let mut removed_from_trust = false;
6340    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
6341        && agents.remove(handle).is_some()
6342    {
6343        removed_from_trust = true;
6344    }
6345    config::write_trust(&trust)?;
6346
6347    let mut state = config::read_relay_state()?;
6348    let mut removed_from_relay = false;
6349    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
6350        && peers.remove(handle).is_some()
6351    {
6352        removed_from_relay = true;
6353    }
6354    config::write_relay_state(&state)?;
6355
6356    let mut purged: Vec<String> = Vec::new();
6357    if purge {
6358        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
6359            let path = dir.join(format!("{handle}.jsonl"));
6360            if path.exists() {
6361                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
6362                purged.push(path.to_string_lossy().into());
6363            }
6364        }
6365    }
6366
6367    if !removed_from_trust && !removed_from_relay {
6368        if as_json {
6369            println!(
6370                "{}",
6371                serde_json::to_string(&json!({
6372                    "removed": false,
6373                    "reason": format!("peer {handle:?} not pinned"),
6374                }))?
6375            );
6376        } else {
6377            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
6378        }
6379        return Ok(());
6380    }
6381
6382    if as_json {
6383        println!(
6384            "{}",
6385            serde_json::to_string(&json!({
6386                "handle": handle,
6387                "removed_from_trust": removed_from_trust,
6388                "removed_from_relay_state": removed_from_relay,
6389                "purged_files": purged,
6390            }))?
6391        );
6392    } else {
6393        println!("forgot peer {handle:?}");
6394        if removed_from_trust {
6395            println!("  - removed from trust.json");
6396        }
6397        if removed_from_relay {
6398            println!("  - removed from relay.json");
6399        }
6400        if !purged.is_empty() {
6401            for p in &purged {
6402                println!("  - deleted {p}");
6403            }
6404        } else if !purge {
6405            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
6406        }
6407    }
6408    Ok(())
6409}
6410
6411// ---------- daemon (long-lived push+pull sync) ----------
6412
6413fn cmd_daemon(
6414    interval_secs: u64,
6415    once: bool,
6416    all_sessions: bool,
6417    session: Option<String>,
6418    as_json: bool,
6419) -> Result<()> {
6420    // v0.14.2 (#162): supervisor mode is mutually exclusive with --once and
6421    // --session — the supervisor IS the multi-session orchestrator, and
6422    // --once is a single-cycle exit (no supervision). Surface loudly
6423    // rather than silently picking one branch.
6424    if all_sessions {
6425        if once {
6426            bail!("--all-sessions and --once are mutually exclusive (supervisor runs forever)");
6427        }
6428        if session.is_some() {
6429            bail!(
6430                "--all-sessions and --session are mutually exclusive (supervisor manages every session, not a single named one)"
6431            );
6432        }
6433        return crate::daemon_supervisor::run_supervisor(interval_secs, as_json);
6434    }
6435    // v0.14.2 (#162): pin this process's WIRE_HOME to the named session's
6436    // home dir BEFORE any config read. Used by the supervisor when it
6437    // fork-execs children, and operator-facing when running a one-session
6438    // foreground daemon outside launchd.
6439    if let Some(ref name) = session {
6440        // v0.14.2 #44: resolve via the layout-aware helper so v0.13
6441        // by-key sessions (where the on-disk dir is a hash and the
6442        // operator-typed name is the persona handle, e.g.
6443        // "coral-weasel") work as well as legacy v0.6 top-level
6444        // sessions. Pre-fix: `session_dir(name)` only resolved the
6445        // legacy form → operator running `wire daemon --session
6446        // coral-weasel` in a tmux pane saw "session not found" even
6447        // though `wire session list` clearly enumerated it.
6448        let home = crate::session::find_session_home_by_name(name)
6449            .with_context(|| format!("resolving session home for --session {name}"))?
6450            .ok_or_else(|| {
6451                anyhow!(
6452                    "session '{name}' not found — run `wire session list` to see initialized sessions"
6453                )
6454            })?;
6455        // SAFETY: cmd_daemon is the one process-lifetime entrypoint that
6456        // chooses a session. No other thread reads WIRE_HOME yet.
6457        unsafe {
6458            std::env::set_var("WIRE_HOME", &home);
6459        }
6460        if !as_json {
6461            eprintln!(
6462                "wire daemon: pinned to session '{name}' (WIRE_HOME={})",
6463                home.display()
6464            );
6465        }
6466    }
6467    if !config::is_initialized()? {
6468        bail!("not initialized — run `wire init <handle>` first");
6469    }
6470    // v0.14.2 (#162): pidfile singleton on the persistent daemon. If
6471    // another live `wire daemon` already owns the pidfile, exit 0 with a
6472    // human/JSON message instead of starting a second polling loop —
6473    // honey-pine's report observed 3 concurrent daemons polling the same
6474    // slot, wasteful and a possible source of duplicate-pull races.
6475    // `--once` is a single sync cycle and doesn't own the cursor; the
6476    // singleton check is skipped for it (matches the existing collision
6477    // warning's `--once` carve-out). Test escape hatch:
6478    // `WIRE_DAEMON_NO_SINGLETON=1`.
6479    let _pid_guard = if !once && std::env::var("WIRE_DAEMON_NO_SINGLETON").is_err() {
6480        if let Some(holder_pid) = crate::ensure_up::daemon_singleton_holder() {
6481            if as_json {
6482                println!(
6483                    "{}",
6484                    serde_json::to_string(&json!({
6485                        "status": "skipped",
6486                        "reason": "daemon already running",
6487                        "holder_pid": holder_pid,
6488                    }))?
6489                );
6490            } else {
6491                eprintln!(
6492                    "wire daemon: another daemon is already running (pid {holder_pid}); not starting a second polling loop. Set WIRE_DAEMON_NO_SINGLETON=1 to override."
6493                );
6494            }
6495            return Ok(());
6496        }
6497        Some(crate::ensure_up::claim_daemon_singleton()?)
6498    } else {
6499        None
6500    };
6501    // v0.13.x identity work: a long-running daemon racing another wire
6502    // process for the same inbox cursor silently loses messages. Surface
6503    // the collision the same way `wire mcp` does. Skipped under `--once`:
6504    // a single sync cycle is atomic and doesn't own the cursor.
6505    if !once {
6506        crate::session::warn_on_identity_collision(std::process::id(), "daemon");
6507    }
6508    let interval = std::time::Duration::from_secs(interval_secs.max(1));
6509
6510    if !as_json {
6511        if once {
6512            eprintln!("wire daemon: single sync cycle, then exit");
6513        } else {
6514            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
6515        }
6516    }
6517
6518    // Recover from prior crash: any pending pair in transient state had its
6519    // in-memory SPAKE2 secret lost when the previous daemon exited. Release
6520    // the relay slots and mark the files so the operator can re-issue.
6521    if let Err(e) = crate::pending_pair::cleanup_on_startup() {
6522        eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
6523    }
6524
6525    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
6526    // to our slot, the subscriber signals `wake_rx`; we use it as the
6527    // sleep-or-wake gate of the polling loop. Polling stays as the
6528    // safety net — stream errors fall back transparently to the existing
6529    // interval-based cadence.
6530    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
6531    if !once {
6532        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
6533    }
6534
6535    loop {
6536        let pushed = run_sync_push().unwrap_or_else(|e| {
6537            eprintln!("daemon: push error: {e:#}");
6538            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
6539        });
6540        let pulled = run_sync_pull().unwrap_or_else(|e| {
6541            eprintln!("daemon: pull error: {e:#}");
6542            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
6543        });
6544        let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
6545            eprintln!("daemon: pending-pair tick error: {e:#}");
6546            json!({"transitions": []})
6547        });
6548
6549        // v0.14.2 (#162): persist a `last_sync.json` record after every
6550        // cycle (including --once + cycles that pushed/pulled zero events
6551        // — the "idle daemon is alive" signal is exactly what the
6552        // detection layers need). Readers: `wire status`,
6553        // `mcp__wire__wire_status`, `mcp__wire__wire_send` annotations.
6554        // Best-effort: errors log + don't abort the loop.
6555        let cycle_push_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
6556        let cycle_pull_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
6557        let cycle_rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
6558        crate::ensure_up::write_last_sync_record(cycle_push_n, cycle_pull_n, cycle_rejected_n);
6559
6560        if as_json {
6561            println!(
6562                "{}",
6563                serde_json::to_string(&json!({
6564                    "ts": time::OffsetDateTime::now_utc()
6565                        .format(&time::format_description::well_known::Rfc3339)
6566                        .unwrap_or_default(),
6567                    "push": pushed,
6568                    "pull": pulled,
6569                    "pairs": pairs,
6570                }))?
6571            );
6572        } else {
6573            let pair_transitions = pairs["transitions"]
6574                .as_array()
6575                .map(|a| a.len())
6576                .unwrap_or(0);
6577            if cycle_push_n > 0 || cycle_pull_n > 0 || cycle_rejected_n > 0 || pair_transitions > 0
6578            {
6579                eprintln!(
6580                    "daemon: pushed={cycle_push_n} pulled={cycle_pull_n} rejected={cycle_rejected_n} pair-transitions={pair_transitions}"
6581                );
6582            }
6583            // Loud per-transition logging so operator sees pair progress live.
6584            if let Some(arr) = pairs["transitions"].as_array() {
6585                for t in arr {
6586                    eprintln!(
6587                        "  pair {} : {} → {}",
6588                        t.get("code").and_then(Value::as_str).unwrap_or("?"),
6589                        t.get("from").and_then(Value::as_str).unwrap_or("?"),
6590                        t.get("to").and_then(Value::as_str).unwrap_or("?")
6591                    );
6592                    if let Some(sas) = t.get("sas").and_then(Value::as_str)
6593                        && t.get("to").and_then(Value::as_str) == Some("sas_ready")
6594                    {
6595                        eprintln!("    SAS digits: {}-{}", &sas[..3], &sas[3..]);
6596                        eprintln!(
6597                            "    Run: wire pair-confirm {} {}",
6598                            t.get("code").and_then(Value::as_str).unwrap_or("?"),
6599                            sas
6600                        );
6601                    }
6602                }
6603            }
6604        }
6605
6606        if once {
6607            return Ok(());
6608        }
6609        // Wait either for the next poll-interval tick OR for a stream
6610        // wake signal — whichever comes first. Drain any additional
6611        // wake-ups that accumulated during the previous cycle since one
6612        // pull catches up everything.
6613        //
6614        // v0.13.2 (wisp-blossom): if the stream subscriber thread has gone
6615        // away, `wake_rx` is Disconnected and `recv_timeout` returns
6616        // INSTANTLY — which would busy-spin the sync loop (hammering push/pull
6617        // + the relay with zero delay). Fall back to a plain sleep so a dead
6618        // stream degrades to normal polling and never kills or pegs the
6619        // daemon. (Realizes the "decouple stream from sync" hardening — a
6620        // stream failure must never affect the push/pull loop.)
6621        match wake_rx.recv_timeout(interval) {
6622            Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
6623            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
6624                std::thread::sleep(interval);
6625            }
6626        }
6627        while wake_rx.try_recv().is_ok() {}
6628    }
6629}
6630
6631/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
6632/// shape `wire push --json` emits.
6633fn run_sync_push() -> Result<Value> {
6634    let state = config::read_relay_state()?;
6635    let peers = state["peers"].as_object().cloned().unwrap_or_default();
6636    if peers.is_empty() {
6637        return Ok(json!({"pushed": [], "skipped": []}));
6638    }
6639    let outbox_dir = config::outbox_dir()?;
6640    if !outbox_dir.exists() {
6641        return Ok(json!({"pushed": [], "skipped": []}));
6642    }
6643    let mut pushed = Vec::new();
6644    let mut skipped = Vec::new();
6645    for (peer_handle, slot_info) in peers.iter() {
6646        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
6647        if !outbox.exists() {
6648            continue;
6649        }
6650        let url = slot_info["relay_url"].as_str().unwrap_or("");
6651        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
6652        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
6653        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
6654            continue;
6655        }
6656        let client = crate::relay_client::RelayClient::new(url);
6657        let body = std::fs::read_to_string(&outbox)?;
6658        for line in body.lines() {
6659            let event: Value = match serde_json::from_str(line) {
6660                Ok(v) => v,
6661                Err(_) => continue,
6662            };
6663            let event_id = event
6664                .get("event_id")
6665                .and_then(Value::as_str)
6666                .unwrap_or("")
6667                .to_string();
6668            match client.post_event(slot_id, slot_token, &event) {
6669                Ok(resp) => {
6670                    // v0.14.2 (#162 fix #2): record the queued → pushed
6671                    // transition in the per-peer lifecycle log. Both
6672                    // `ok` and `duplicate` count as pushed — the relay
6673                    // has the event either way, and an operator who
6674                    // hits the dedup path didn't lose the event. Failure
6675                    // here is non-fatal: the sync loop must keep
6676                    // running even if the lifecycle log can't be
6677                    // appended.
6678                    let now = time::OffsetDateTime::now_utc()
6679                        .format(&time::format_description::well_known::Rfc3339)
6680                        .unwrap_or_default();
6681                    if let Err(e) = config::append_pushed_log(peer_handle, &event_id, &now) {
6682                        eprintln!(
6683                            "daemon: pushed-log append for {peer_handle}/{event_id} failed (non-fatal): {e:#}"
6684                        );
6685                    }
6686                    if resp.status == "duplicate" {
6687                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
6688                    } else {
6689                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
6690                    }
6691                }
6692                Err(e) => {
6693                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
6694                    // errors aren't hidden behind the topmost-context URL string.
6695                    // Issue #6 highest-impact silent-fail fix.
6696                    let reason = crate::relay_client::format_transport_error(&e);
6697                    skipped
6698                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
6699                }
6700            }
6701        }
6702    }
6703    Ok(json!({"pushed": pushed, "skipped": skipped}))
6704}
6705
6706/// Programmatic pull. Same shape as `wire pull --json`.
6707///
6708/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
6709/// created via `wire session new --with-local` (which only writes
6710/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
6711/// Pre-v0.9 this function read only the top-level fields and silently
6712/// returned `{}` for any v0.5.17+ session.
6713pub fn run_sync_pull() -> Result<Value> {
6714    let state = config::read_relay_state()?;
6715    if state.get("self").map(Value::is_null).unwrap_or(true) {
6716        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
6717    }
6718    // E2 (v0.13.2): pull EVERY self endpoint, not just the primary. A session
6719    // that bound a local slot (additive) alongside its federation slot used to
6720    // have the daemon pull ONLY the primary (federation) endpoint — the local
6721    // slot was never serviced, so same-box loopback delivery silently never
6722    // happened until a manual restart re-seeded the (startup-only) stream
6723    // subscriber. Now each endpoint is pulled with its OWN cursor.
6724    let endpoints = crate::endpoints::self_endpoints(&state);
6725    if endpoints.is_empty() {
6726        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
6727    }
6728    let inbox_dir = config::inbox_dir()?;
6729    config::ensure_dirs()?;
6730
6731    // Per-slot cursors live at `self.cursors.<slot_id>`. The legacy global
6732    // `self.last_pulled_event_id` is migrated as the cursor for the PRIMARY
6733    // slot only (a federation event id won't match a local slot's log); other
6734    // slots start from None and `process_events` dedups against the inbox.
6735    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
6736    let legacy_cursor = self_obj
6737        .get("last_pulled_event_id")
6738        .and_then(Value::as_str)
6739        .map(str::to_string);
6740    let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
6741    let mut cursors: serde_json::Map<String, Value> = self_obj
6742        .get("cursors")
6743        .and_then(Value::as_object)
6744        .cloned()
6745        .unwrap_or_default();
6746
6747    let mut all_written: Vec<Value> = Vec::new();
6748    let mut all_rejected: Vec<Value> = Vec::new();
6749    let mut total_seen = 0usize;
6750    let mut blocked_any = false;
6751
6752    for ep in &endpoints {
6753        if ep.relay_url.is_empty() {
6754            continue;
6755        }
6756        let cursor = cursors
6757            .get(&ep.slot_id)
6758            .and_then(Value::as_str)
6759            .map(str::to_string)
6760            .or_else(|| {
6761                if Some(&ep.slot_id) == primary_slot.as_ref() {
6762                    legacy_cursor.clone()
6763                } else {
6764                    None
6765                }
6766            });
6767        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
6768        // One endpoint erroring (relay down, slot gone) must NOT stop the
6769        // others — a dead local relay shouldn't black-hole federation pulls.
6770        let events =
6771            match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
6772                Ok(e) => e,
6773                Err(e) => {
6774                    eprintln!(
6775                        "daemon: pull error on {} slot {} (continuing): {e:#}",
6776                        ep.relay_url, ep.slot_id
6777                    );
6778                    continue;
6779                }
6780            };
6781        total_seen += events.len();
6782        // P0.1 shared cursor-blocking logic (matches `wire pull`). A block on
6783        // one slot only stalls THAT slot's cursor; other slots keep flowing.
6784        let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
6785        if let Some(eid) = &result.advance_cursor_to {
6786            cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
6787        }
6788        blocked_any |= result.blocked;
6789        all_written.extend(result.written);
6790        all_rejected.extend(result.rejected);
6791    }
6792
6793    // P0.3 flock-protected RMW: persist per-slot cursors + keep the legacy
6794    // global cursor in sync with the primary slot for back-compat with older
6795    // binaries that only read `last_pulled_event_id`.
6796    let primary_cursor = primary_slot
6797        .as_ref()
6798        .and_then(|s| cursors.get(s))
6799        .and_then(Value::as_str)
6800        .map(str::to_string);
6801    // v0.14.3 (#14): group `written` by sender handle, take max
6802    // timestamp, write to `peers[<handle>].last_inbound_event_at`.
6803    // RFC3339-comparable as lex sort (same offset, ISO 8601). This
6804    // is the daemon-written signal `check_peer_staleness` needs —
6805    // robust against backup/restore/`touch` that breaks inbox-mtime
6806    // detection. Additive field: pre-v0.14.3 readers ignore it,
6807    // older daemons just don't write it.
6808    let mut latest_inbound: std::collections::HashMap<String, String> =
6809        std::collections::HashMap::new();
6810    for w in &all_written {
6811        let from = match w.get("from").and_then(Value::as_str) {
6812            Some(s) => s.to_string(),
6813            None => continue,
6814        };
6815        let ts = match w.get("timestamp").and_then(Value::as_str) {
6816            Some(s) if !s.is_empty() => s.to_string(),
6817            _ => continue,
6818        };
6819        latest_inbound
6820            .entry(from)
6821            .and_modify(|existing| {
6822                if ts > *existing {
6823                    *existing = ts.clone();
6824                }
6825            })
6826            .or_insert(ts);
6827    }
6828    config::update_relay_state(|state| {
6829        if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
6830            self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
6831            if let Some(pc) = &primary_cursor {
6832                self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
6833            }
6834        }
6835        if !latest_inbound.is_empty()
6836            && let Some(peers_obj) = state.get_mut("peers").and_then(Value::as_object_mut)
6837        {
6838            for (handle, ts) in &latest_inbound {
6839                let entry = peers_obj.entry(handle.clone()).or_insert_with(|| json!({}));
6840                if let Some(obj) = entry.as_object_mut() {
6841                    obj.insert("last_inbound_event_at".into(), Value::String(ts.clone()));
6842                }
6843            }
6844        }
6845        Ok(())
6846    })?;
6847
6848    Ok(json!({
6849        "written": all_written,
6850        "rejected": all_rejected,
6851        "total_seen": total_seen,
6852        "cursor_blocked": blocked_any,
6853        "endpoints_pulled": endpoints.len(),
6854    }))
6855}
6856
6857// ---------- pin (manual out-of-band peer pairing) ----------
6858
6859fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
6860    let body =
6861        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
6862    let card: Value =
6863        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
6864    crate::agent_card::verify_agent_card(&card)
6865        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
6866
6867    let mut trust = config::read_trust()?;
6868    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
6869
6870    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
6871    let handle = crate::agent_card::display_handle_from_did(did).to_string();
6872    config::write_trust(&trust)?;
6873
6874    if as_json {
6875        println!(
6876            "{}",
6877            serde_json::to_string(&json!({
6878                "handle": handle,
6879                "did": did,
6880                "tier": "VERIFIED",
6881                "pinned": true,
6882            }))?
6883        );
6884    } else {
6885        println!("pinned {handle} ({did}) at tier VERIFIED");
6886    }
6887    Ok(())
6888}
6889
6890// ---------- pair-host / pair-join (the magic-wormhole flow) ----------
6891
6892fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
6893    pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
6894}
6895
6896fn cmd_pair_join(
6897    code_phrase: &str,
6898    relay_url: &str,
6899    auto_yes: bool,
6900    timeout_secs: u64,
6901) -> Result<()> {
6902    pair_orchestrate(
6903        relay_url,
6904        Some(code_phrase),
6905        "guest",
6906        auto_yes,
6907        timeout_secs,
6908    )
6909}
6910
6911/// Shared orchestration for both sides of the SAS pairing.
6912///
6913/// Now thin: delegates to `pair_session::pair_session_open` / `_try_sas` /
6914/// `_finalize`. CLI keeps its interactive y/N prompt; MCP uses
6915/// `pair_session_confirm_sas` instead.
6916fn pair_orchestrate(
6917    relay_url: &str,
6918    code_in: Option<&str>,
6919    role: &str,
6920    auto_yes: bool,
6921    timeout_secs: u64,
6922) -> Result<()> {
6923    use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
6924
6925    let mut s = pair_session_open(role, relay_url, code_in)?;
6926
6927    if role == "host" {
6928        eprintln!();
6929        eprintln!("share this code phrase with your peer:");
6930        eprintln!();
6931        eprintln!("    {}", s.code);
6932        eprintln!();
6933        eprintln!(
6934            "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
6935            s.code
6936        );
6937    } else {
6938        eprintln!();
6939        eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
6940    }
6941
6942    // Stage 2 — poll for SAS-ready with periodic progress heartbeat. The bare
6943    // pair_session_wait_for_sas helper is silent; the CLI wraps it in a loop
6944    // that emits a "waiting (Ns / Ts)" line every HEARTBEAT_SECS so operators
6945    // see the process is alive while the other side connects.
6946    const HEARTBEAT_SECS: u64 = 10;
6947    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
6948    let started = std::time::Instant::now();
6949    let mut last_heartbeat = started;
6950    let formatted = loop {
6951        if let Some(sas) = pair_session_try_sas(&mut s)? {
6952            break sas;
6953        }
6954        let now = std::time::Instant::now();
6955        if now >= deadline {
6956            return Err(anyhow!(
6957                "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
6958            ));
6959        }
6960        if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
6961            let elapsed = now.duration_since(started).as_secs();
6962            eprintln!("  ... still waiting ({elapsed}s / {timeout_secs}s)");
6963            last_heartbeat = now;
6964        }
6965        std::thread::sleep(std::time::Duration::from_millis(250));
6966    };
6967
6968    eprintln!();
6969    eprintln!("SAS digits (must match peer's terminal):");
6970    eprintln!();
6971    eprintln!("    {formatted}");
6972    eprintln!();
6973
6974    // Stage 3 — operator confirmation. CLI uses interactive y/N for backward
6975    // compatibility; MCP uses pair_session_confirm_sas with the typed digits.
6976    if !auto_yes {
6977        eprint!("does this match your peer's terminal? [y/N]: ");
6978        use std::io::Write;
6979        std::io::stderr().flush().ok();
6980        let mut input = String::new();
6981        std::io::stdin().read_line(&mut input)?;
6982        let trimmed = input.trim().to_lowercase();
6983        if trimmed != "y" && trimmed != "yes" {
6984            bail!("SAS confirmation declined — aborting pairing");
6985        }
6986    }
6987    s.sas_confirmed = true;
6988
6989    // Stage 4 — seal+exchange bootstrap, pin peer.
6990    let result = pair_session_finalize(&mut s, timeout_secs)?;
6991
6992    let peer_did = result["paired_with"].as_str().unwrap_or("");
6993    let peer_role = if role == "host" { "guest" } else { "host" };
6994    eprintln!("paired with {peer_did} (peer role: {peer_role})");
6995    eprintln!("peer card pinned at tier VERIFIED");
6996    eprintln!(
6997        "peer relay slot saved to {}",
6998        config::relay_state_path()?.display()
6999    );
7000
7001    println!("{}", serde_json::to_string(&result)?);
7002    Ok(())
7003}
7004
7005// (poll_until helper removed — pair flow now uses pair_session::pair_session_wait_for_sas
7006// and pair_session_finalize, both of which inline their own deadline loops.)
7007
7008// ---------- pair — single-shot init + pair-* + setup ----------
7009
7010fn cmd_pair(
7011    handle: &str,
7012    code: Option<&str>,
7013    relay: &str,
7014    auto_yes: bool,
7015    timeout_secs: u64,
7016    no_setup: bool,
7017) -> Result<()> {
7018    // Step 1 — idempotent identity. Safe if already initialized with the SAME handle;
7019    // bails loudly if a different handle is already set (operator must explicitly delete).
7020    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
7021    let did = init_result
7022        .get("did")
7023        .and_then(|v| v.as_str())
7024        .unwrap_or("(unknown)")
7025        .to_string();
7026    let already = init_result
7027        .get("already_initialized")
7028        .and_then(|v| v.as_bool())
7029        .unwrap_or(false);
7030    if already {
7031        println!("(identity {did} already initialized — reusing)");
7032    } else {
7033        println!("initialized {did}");
7034    }
7035    println!();
7036
7037    // Step 2 — pair-host or pair-join based on code presence.
7038    match code {
7039        None => {
7040            println!("hosting pair on {relay} (no code = host) ...");
7041            cmd_pair_host(relay, auto_yes, timeout_secs)?;
7042        }
7043        Some(c) => {
7044            println!("joining pair with code {c} on {relay} ...");
7045            cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
7046        }
7047    }
7048
7049    // Step 3 — register wire as MCP server in detected client configs (idempotent).
7050    if !no_setup {
7051        println!();
7052        println!("registering wire as MCP server in detected client configs ...");
7053        if let Err(e) = cmd_setup(true) {
7054            // Non-fatal — pair succeeded, just print the warning.
7055            eprintln!("warn: setup --apply failed: {e}");
7056            eprintln!("      pair succeeded; you can re-run `wire setup --apply` manually.");
7057        }
7058    }
7059
7060    println!();
7061    println!("pair complete. Next steps:");
7062    println!("  wire daemon start              # background sync of inbox/outbox vs relay");
7063    println!("  wire send <peer> claim <msg>   # send your peer something");
7064    println!("  wire tail                      # watch incoming events");
7065    Ok(())
7066}
7067
7068// ---------- detached pair (daemon-orchestrated) ----------
7069
7070/// `wire pair <handle> [--code <phrase>] --detach` — wraps init + detach
7071/// pair-host/-join into a single command. The non-detached variant lives in
7072/// `cmd_pair`; this one short-circuits to the daemon-orchestrated path.
7073fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
7074    let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
7075    let did = init_result
7076        .get("did")
7077        .and_then(|v| v.as_str())
7078        .unwrap_or("(unknown)")
7079        .to_string();
7080    let already = init_result
7081        .get("already_initialized")
7082        .and_then(|v| v.as_bool())
7083        .unwrap_or(false);
7084    if already {
7085        println!("(identity {did} already initialized — reusing)");
7086    } else {
7087        println!("initialized {did}");
7088    }
7089    println!();
7090    match code {
7091        None => cmd_pair_host_detach(relay, false),
7092        Some(c) => cmd_pair_join_detach(c, relay, false),
7093    }
7094}
7095
7096fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
7097    if !config::is_initialized()? {
7098        bail!("not initialized — run `wire init <handle>` first");
7099    }
7100    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
7101        Ok(b) => b,
7102        Err(e) => {
7103            if !as_json {
7104                eprintln!(
7105                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
7106                );
7107            }
7108            false
7109        }
7110    };
7111    let code = crate::sas::generate_code_phrase();
7112    let code_hash = crate::pair_session::derive_code_hash(&code);
7113    let now = time::OffsetDateTime::now_utc()
7114        .format(&time::format_description::well_known::Rfc3339)
7115        .unwrap_or_default();
7116    let p = crate::pending_pair::PendingPair {
7117        code: code.clone(),
7118        code_hash,
7119        role: "host".to_string(),
7120        relay_url: relay_url.to_string(),
7121        status: "request_host".to_string(),
7122        sas: None,
7123        peer_did: None,
7124        created_at: now,
7125        last_error: None,
7126        pair_id: None,
7127        our_slot_id: None,
7128        our_slot_token: None,
7129        spake2_seed_b64: None,
7130    };
7131    crate::pending_pair::write_pending(&p)?;
7132    if as_json {
7133        println!(
7134            "{}",
7135            serde_json::to_string(&json!({
7136                "state": "queued",
7137                "code_phrase": code,
7138                "relay_url": relay_url,
7139                "role": "host",
7140                "daemon_spawned": daemon_spawned,
7141            }))?
7142        );
7143    } else {
7144        if daemon_spawned {
7145            println!("(started wire daemon in background)");
7146        }
7147        println!("detached pair-host queued. Share this code with your peer:\n");
7148        println!("    {code}\n");
7149        println!("Next steps:");
7150        println!("  wire pair-list                                # check status");
7151        println!("  wire pair-confirm {code} <digits>   # when SAS shows up");
7152        println!("  wire pair-cancel  {code}            # to abort");
7153    }
7154    Ok(())
7155}
7156
7157fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
7158    if !config::is_initialized()? {
7159        bail!("not initialized — run `wire init <handle>` first");
7160    }
7161    let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
7162        Ok(b) => b,
7163        Err(e) => {
7164            if !as_json {
7165                eprintln!(
7166                    "warn: could not auto-start daemon: {e}; pair will queue but not advance"
7167                );
7168            }
7169            false
7170        }
7171    };
7172    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
7173    let code_hash = crate::pair_session::derive_code_hash(&code);
7174    let now = time::OffsetDateTime::now_utc()
7175        .format(&time::format_description::well_known::Rfc3339)
7176        .unwrap_or_default();
7177    let p = crate::pending_pair::PendingPair {
7178        code: code.clone(),
7179        code_hash,
7180        role: "guest".to_string(),
7181        relay_url: relay_url.to_string(),
7182        status: "request_guest".to_string(),
7183        sas: None,
7184        peer_did: None,
7185        created_at: now,
7186        last_error: None,
7187        pair_id: None,
7188        our_slot_id: None,
7189        our_slot_token: None,
7190        spake2_seed_b64: None,
7191    };
7192    crate::pending_pair::write_pending(&p)?;
7193    if as_json {
7194        println!(
7195            "{}",
7196            serde_json::to_string(&json!({
7197                "state": "queued",
7198                "code_phrase": code,
7199                "relay_url": relay_url,
7200                "role": "guest",
7201                "daemon_spawned": daemon_spawned,
7202            }))?
7203        );
7204    } else {
7205        if daemon_spawned {
7206            println!("(started wire daemon in background)");
7207        }
7208        println!("detached pair-join queued for code {code}.");
7209        println!(
7210            "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
7211        );
7212    }
7213    Ok(())
7214}
7215
7216fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
7217    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
7218    let typed: String = typed_digits
7219        .chars()
7220        .filter(|c| c.is_ascii_digit())
7221        .collect();
7222    if typed.len() != 6 {
7223        bail!(
7224            "expected 6 digits (got {} after stripping non-digits)",
7225            typed.len()
7226        );
7227    }
7228    let mut p = crate::pending_pair::read_pending(&code)?
7229        .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
7230    if p.status != "sas_ready" {
7231        bail!(
7232            "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
7233            p.status
7234        );
7235    }
7236    let stored = p
7237        .sas
7238        .as_ref()
7239        .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
7240        .clone();
7241    if stored == typed {
7242        p.status = "confirmed".to_string();
7243        crate::pending_pair::write_pending(&p)?;
7244        if as_json {
7245            println!(
7246                "{}",
7247                serde_json::to_string(&json!({
7248                    "state": "confirmed",
7249                    "code_phrase": code,
7250                }))?
7251            );
7252        } else {
7253            println!("digits match. Daemon will finalize the handshake on its next tick.");
7254            println!("Run `wire peers` after a few seconds to confirm.");
7255        }
7256    } else {
7257        p.status = "aborted".to_string();
7258        p.last_error = Some(format!(
7259            "SAS digit mismatch (typed {typed}, expected {stored})"
7260        ));
7261        let client = crate::relay_client::RelayClient::new(&p.relay_url);
7262        let _ = client.pair_abandon(&p.code_hash);
7263        crate::pending_pair::write_pending(&p)?;
7264        crate::os_notify::toast(
7265            &format!("wire — pair aborted ({})", p.code),
7266            p.last_error.as_deref().unwrap_or("digits mismatch"),
7267        );
7268        if as_json {
7269            println!(
7270                "{}",
7271                serde_json::to_string(&json!({
7272                    "state": "aborted",
7273                    "code_phrase": code,
7274                    "error": "digits mismatch",
7275                }))?
7276            );
7277        }
7278        bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
7279    }
7280    Ok(())
7281}
7282
7283fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
7284    if watch {
7285        return cmd_pair_list_watch(watch_interval_secs);
7286    }
7287    let spake2_items = crate::pending_pair::list_pending()?;
7288    let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
7289    if as_json {
7290        // Backwards-compat: flat SPAKE2 array (the shape every existing
7291        // script + e2e test parses since v0.5.x). v0.5.14 inbound items
7292        // surface programmatically via `wire pair-list-inbound --json`
7293        // and via `wire status --json` `pending_pairs.inbound_*` fields.
7294        println!("{}", serde_json::to_string(&spake2_items)?);
7295        return Ok(());
7296    }
7297    if spake2_items.is_empty() && inbound_items.is_empty() {
7298        println!("no pending pair sessions.");
7299        return Ok(());
7300    }
7301    // v0.5.14: inbound section first — these need operator action right now.
7302    // SPAKE2 sessions are typically already mid-flow.
7303    if !inbound_items.is_empty() {
7304        println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
7305        println!(
7306            "{:<20} {:<35} {:<25} NEXT STEP",
7307            "PEER", "RELAY", "RECEIVED"
7308        );
7309        for p in &inbound_items {
7310            println!(
7311                "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
7312                p.peer_handle,
7313                p.peer_relay_url,
7314                p.received_at,
7315                peer = p.peer_handle,
7316            );
7317        }
7318        println!();
7319    }
7320    if !spake2_items.is_empty() {
7321        println!("SPAKE2 SESSIONS");
7322        println!(
7323            "{:<15} {:<8} {:<18} {:<10} NOTE",
7324            "CODE", "ROLE", "STATUS", "SAS"
7325        );
7326        for p in spake2_items {
7327            let sas = p
7328                .sas
7329                .as_ref()
7330                .map(|d| format!("{}-{}", &d[..3], &d[3..]))
7331                .unwrap_or_else(|| "—".to_string());
7332            let note = p
7333                .last_error
7334                .as_deref()
7335                .or(p.peer_did.as_deref())
7336                .unwrap_or("");
7337            println!(
7338                "{:<15} {:<8} {:<18} {:<10} {}",
7339                p.code, p.role, p.status, sas, note
7340            );
7341        }
7342    }
7343    Ok(())
7344}
7345
7346/// Stream-mode pair-list: never exits. Diffs per-code state every
7347/// `interval_secs` and prints one JSON line per transition (creation,
7348/// status flip, deletion). Useful for shell pipelines:
7349///
7350/// ```text
7351/// wire pair-list --watch | while read line; do
7352///     CODE=$(echo "$line" | jq -r .code)
7353///     STATUS=$(echo "$line" | jq -r .status)
7354///     ...
7355/// done
7356/// ```
7357fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
7358    use std::collections::HashMap;
7359    use std::io::Write;
7360    let interval = std::time::Duration::from_secs(interval_secs.max(1));
7361    // Emit a snapshot synthetic event for every currently-pending pair on
7362    // startup so a consumer that arrives mid-flight sees the current state.
7363    let mut prev: HashMap<String, String> = HashMap::new();
7364    {
7365        let items = crate::pending_pair::list_pending()?;
7366        for p in &items {
7367            println!("{}", serde_json::to_string(&p)?);
7368            prev.insert(p.code.clone(), p.status.clone());
7369        }
7370        // Flush so the consumer's `while read` gets the snapshot promptly.
7371        let _ = std::io::stdout().flush();
7372    }
7373    loop {
7374        std::thread::sleep(interval);
7375        let items = match crate::pending_pair::list_pending() {
7376            Ok(v) => v,
7377            Err(_) => continue,
7378        };
7379        let mut cur: HashMap<String, String> = HashMap::new();
7380        for p in &items {
7381            cur.insert(p.code.clone(), p.status.clone());
7382            match prev.get(&p.code) {
7383                None => {
7384                    // New code appeared.
7385                    println!("{}", serde_json::to_string(&p)?);
7386                }
7387                Some(prev_status) if prev_status != &p.status => {
7388                    // Status flipped.
7389                    println!("{}", serde_json::to_string(&p)?);
7390                }
7391                _ => {}
7392            }
7393        }
7394        for code in prev.keys() {
7395            if !cur.contains_key(code) {
7396                // File disappeared → finalized or cancelled. Emit a synthetic
7397                // "removed" marker so the consumer sees the terminal event.
7398                println!(
7399                    "{}",
7400                    serde_json::to_string(&json!({
7401                        "code": code,
7402                        "status": "removed",
7403                        "_synthetic": true,
7404                    }))?
7405                );
7406            }
7407        }
7408        let _ = std::io::stdout().flush();
7409        prev = cur;
7410    }
7411}
7412
7413/// Block until a pending pair reaches `target_status` or terminates. Process
7414/// exit code carries the outcome (0 success, 1 terminated abnormally, 2
7415/// timeout) so shell scripts can branch directly.
7416fn cmd_pair_watch(
7417    code_phrase: &str,
7418    target_status: &str,
7419    timeout_secs: u64,
7420    as_json: bool,
7421) -> Result<()> {
7422    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
7423    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
7424    let mut last_seen_status: Option<String> = None;
7425    loop {
7426        let p_opt = crate::pending_pair::read_pending(&code)?;
7427        let now = std::time::Instant::now();
7428        match p_opt {
7429            None => {
7430                // File gone — either finalized (success if target=sas_ready
7431                // since finalization implies it passed sas_ready) or never
7432                // existed. Distinguish by whether we ever saw it.
7433                if last_seen_status.is_some() {
7434                    if as_json {
7435                        println!(
7436                            "{}",
7437                            serde_json::to_string(&json!({"state": "finalized", "code": code}))?
7438                        );
7439                    } else {
7440                        println!("pair {code} finalized (file removed)");
7441                    }
7442                    return Ok(());
7443                } else {
7444                    if as_json {
7445                        println!(
7446                            "{}",
7447                            serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
7448                        );
7449                    }
7450                    std::process::exit(1);
7451                }
7452            }
7453            Some(p) => {
7454                let cur = p.status.clone();
7455                if Some(cur.clone()) != last_seen_status {
7456                    if as_json {
7457                        // Emit per-transition line so scripts can stream.
7458                        println!("{}", serde_json::to_string(&p)?);
7459                    }
7460                    last_seen_status = Some(cur.clone());
7461                }
7462                if cur == target_status {
7463                    if !as_json {
7464                        let sas_str = p
7465                            .sas
7466                            .as_ref()
7467                            .map(|s| format!("{}-{}", &s[..3], &s[3..]))
7468                            .unwrap_or_else(|| "—".to_string());
7469                        println!("pair {code} reached {target_status} (SAS: {sas_str})");
7470                    }
7471                    return Ok(());
7472                }
7473                if cur == "aborted" || cur == "aborted_restart" {
7474                    if !as_json {
7475                        let err = p.last_error.as_deref().unwrap_or("(no detail)");
7476                        eprintln!("pair {code} {cur}: {err}");
7477                    }
7478                    std::process::exit(1);
7479                }
7480            }
7481        }
7482        if now >= deadline {
7483            if !as_json {
7484                eprintln!(
7485                    "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
7486                );
7487            }
7488            std::process::exit(2);
7489        }
7490        std::thread::sleep(std::time::Duration::from_millis(250));
7491    }
7492}
7493
7494fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
7495    let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
7496    let p = crate::pending_pair::read_pending(&code)?
7497        .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
7498    let client = crate::relay_client::RelayClient::new(&p.relay_url);
7499    let _ = client.pair_abandon(&p.code_hash);
7500    crate::pending_pair::delete_pending(&code)?;
7501    if as_json {
7502        println!(
7503            "{}",
7504            serde_json::to_string(&json!({
7505                "state": "cancelled",
7506                "code_phrase": code,
7507            }))?
7508        );
7509    } else {
7510        println!("cancelled pending pair {code} (relay slot released, file removed).");
7511    }
7512    Ok(())
7513}
7514
7515// ---------- pair-abandon — release stuck pair-slot ----------
7516
7517fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
7518    // Accept either the raw phrase (e.g. "53-CKWIA5") or whatever the user
7519    // typed — normalize via the existing parser.
7520    let code = crate::sas::parse_code_phrase(code_phrase)?;
7521    let code_hash = crate::pair_session::derive_code_hash(code);
7522    let client = crate::relay_client::RelayClient::new(relay_url);
7523    client.pair_abandon(&code_hash)?;
7524    println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
7525    println!("host can now issue a fresh code; guest can re-join.");
7526    Ok(())
7527}
7528
7529// ---------- invite / accept — one-paste pair (v0.4.0) ----------
7530
7531fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
7532    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
7533
7534    // If --share, register the invite at the relay's short-URL endpoint and
7535    // build the one-curl onboarding line for the peer to paste.
7536    let share_payload: Option<Value> = if share {
7537        let client = reqwest::blocking::Client::new();
7538        let single_use = if uses == 1 { Some(1u32) } else { None };
7539        let body = json!({
7540            "invite_url": url,
7541            "ttl_seconds": ttl,
7542            "uses": single_use,
7543        });
7544        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
7545        let resp = client.post(&endpoint).json(&body).send()?;
7546        if !resp.status().is_success() {
7547            let code = resp.status();
7548            let txt = resp.text().unwrap_or_default();
7549            bail!("relay {code} on /v1/invite/register: {txt}");
7550        }
7551        let parsed: Value = resp.json()?;
7552        let token = parsed
7553            .get("token")
7554            .and_then(Value::as_str)
7555            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
7556            .to_string();
7557        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
7558        let curl_line = format!("curl -fsSL {share_url} | sh");
7559        Some(json!({
7560            "token": token,
7561            "share_url": share_url,
7562            "curl": curl_line,
7563            "expires_unix": parsed.get("expires_unix"),
7564        }))
7565    } else {
7566        None
7567    };
7568
7569    if as_json {
7570        let mut out = json!({
7571            "invite_url": url,
7572            "ttl_secs": ttl,
7573            "uses": uses,
7574            "relay": relay,
7575        });
7576        if let Some(s) = &share_payload {
7577            out["share"] = s.clone();
7578        }
7579        println!("{}", serde_json::to_string(&out)?);
7580    } else if let Some(s) = share_payload {
7581        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
7582        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
7583        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
7584        println!("{curl}");
7585    } else {
7586        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
7587        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
7588        println!("{url}");
7589    }
7590    Ok(())
7591}
7592
7593fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
7594    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
7595    // resolve it to the underlying wire://pair?... URL via ?format=url before
7596    // accepting. Saves them from having to know which URL shape goes where.
7597    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
7598        let sep = if url.contains('?') { '&' } else { '?' };
7599        let resolve_url = format!("{url}{sep}format=url");
7600        let client = reqwest::blocking::Client::new();
7601        let resp = client
7602            .get(&resolve_url)
7603            .send()
7604            .with_context(|| format!("GET {resolve_url}"))?;
7605        if !resp.status().is_success() {
7606            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
7607        }
7608        let body = resp.text().unwrap_or_default().trim().to_string();
7609        if !body.starts_with("wire://pair?") {
7610            bail!(
7611                "short URL {url} did not resolve to a wire:// invite. \
7612                 (got: {}{})",
7613                body.chars().take(80).collect::<String>(),
7614                if body.chars().count() > 80 { "…" } else { "" }
7615            );
7616        }
7617        body
7618    } else {
7619        url.to_string()
7620    };
7621
7622    let result = crate::pair_invite::accept_invite(&resolved)?;
7623    if as_json {
7624        println!("{}", serde_json::to_string(&result)?);
7625    } else {
7626        let did = result
7627            .get("paired_with")
7628            .and_then(Value::as_str)
7629            .unwrap_or("?");
7630        println!("paired with {did}");
7631        println!(
7632            "you can now: wire send {} <kind> <body>",
7633            crate::agent_card::display_handle_from_did(did)
7634        );
7635    }
7636    Ok(())
7637}
7638
7639// ---------- whois / profile (v0.5) ----------
7640
7641fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
7642    if let Some(h) = handle {
7643        let parsed = crate::pair_profile::parse_handle(h)?;
7644        // Special-case: if the supplied handle matches our own, skip the
7645        // network round-trip and print local.
7646        if config::is_initialized()? {
7647            let card = config::read_agent_card()?;
7648            let local_handle = card
7649                .get("profile")
7650                .and_then(|p| p.get("handle"))
7651                .and_then(Value::as_str)
7652                .map(str::to_string);
7653            if local_handle.as_deref() == Some(h) {
7654                return cmd_whois(None, as_json, None);
7655            }
7656        }
7657        // Remote resolution via .well-known/wire/agent on the handle's domain.
7658        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
7659        if as_json {
7660            println!("{}", serde_json::to_string(&resolved)?);
7661        } else {
7662            print_resolved_profile(&resolved);
7663        }
7664        return Ok(());
7665    }
7666    let card = config::read_agent_card()?;
7667    if as_json {
7668        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
7669        let mut payload = serde_json::Map::new();
7670        payload.insert(
7671            "did".into(),
7672            card.get("did").cloned().unwrap_or(Value::Null),
7673        );
7674        payload.insert("profile".into(), profile);
7675        // v0.14: surface inline op claims on self-whois too, for parity
7676        // with `wire whoami --json`. Single mental model across read
7677        // verbs; absent ⇒ not enrolled.
7678        for (k, v) in op_claims_from_card(&card) {
7679            payload.insert(k, v);
7680        }
7681        println!("{}", serde_json::to_string(&payload)?);
7682    } else {
7683        print!("{}", crate::pair_profile::render_self_summary()?);
7684    }
7685    Ok(())
7686}
7687
7688fn print_resolved_profile(resolved: &Value) {
7689    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
7690    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
7691    let relay = resolved
7692        .get("relay_url")
7693        .and_then(Value::as_str)
7694        .unwrap_or("");
7695    let slot = resolved
7696        .get("slot_id")
7697        .and_then(Value::as_str)
7698        .unwrap_or("");
7699    let profile = resolved
7700        .get("card")
7701        .and_then(|c| c.get("profile"))
7702        .cloned()
7703        .unwrap_or(Value::Null);
7704    println!("{did}");
7705    println!("  nick:         {nick}");
7706    if !relay.is_empty() {
7707        println!("  relay_url:    {relay}");
7708    }
7709    if !slot.is_empty() {
7710        println!("  slot_id:      {slot}");
7711    }
7712    let pick =
7713        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
7714    if let Some(s) = pick("display_name") {
7715        println!("  display_name: {s}");
7716    }
7717    if let Some(s) = pick("emoji") {
7718        println!("  emoji:        {s}");
7719    }
7720    if let Some(s) = pick("motto") {
7721        println!("  motto:        {s}");
7722    }
7723    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
7724        let joined: Vec<String> = arr
7725            .iter()
7726            .filter_map(|v| v.as_str().map(str::to_string))
7727            .collect();
7728        println!("  vibe:         {}", joined.join(", "));
7729    }
7730    if let Some(s) = pick("pronouns") {
7731        println!("  pronouns:     {s}");
7732    }
7733}
7734
7735/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
7736/// signed pair_drop event with our card + slot coords, deliver via the
7737/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
7738/// Peer's daemon completes the bilateral pin on its next pull and emits a
7739/// pair_drop_ack carrying their slot_token so we can send back.
7740/// Extract just the host portion from `https://host:port/path` → `host`.
7741/// Returns empty string if the URL is malformed.
7742fn host_of_url(url: &str) -> String {
7743    let no_scheme = url
7744        .trim_start_matches("https://")
7745        .trim_start_matches("http://");
7746    no_scheme
7747        .split('/')
7748        .next()
7749        .unwrap_or("")
7750        .split(':')
7751        .next()
7752        .unwrap_or("")
7753        .to_string()
7754}
7755
7756/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
7757/// operator's own relay? Used to suppress the cross-relay phishing
7758/// warning in `wire add` for the happy path.
7759fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
7760    // Hard-coded known-good list. wireup.net is the default relay.
7761    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
7762    let peer_domain = peer_domain.trim().to_ascii_lowercase();
7763    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
7764        return true;
7765    }
7766    // Operator's OWN relay is implicitly trusted — they're already
7767    // bound to it; pairing same-relay peers is the common case.
7768    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
7769    if !our_host.is_empty() && our_host == peer_domain {
7770        return true;
7771    }
7772    false
7773}
7774
7775/// v0.6.6: pair with a sister session on this machine without federation.
7776/// Reads the sister's agent-card + endpoints from disk, pins them into our
7777/// trust + relay_state, builds the same `pair_drop` event the federation
7778/// path would emit, then POSTs it directly to the sister's local-relay slot.
7779/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
7780/// the cwd-derived `wire`) are addressable because the local relay never
7781/// needed a public claim for sister coordination.
7782/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
7783/// to a local sister session.
7784///
7785/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
7786/// either form. Exact session-name matches always win; nickname matches
7787/// are a fallback so operators can type "winter-bay" instead of "wire".
7788/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
7789/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
7790/// with the candidate list so the caller can surface a disambiguation
7791/// hint instead of silently picking one.
7792fn resolve_local_session<'a>(
7793    sessions: &'a [crate::session::SessionInfo],
7794    input: &str,
7795) -> Result<&'a crate::session::SessionInfo, ResolveError> {
7796    // Exact session-name match always wins, even if a nickname elsewhere
7797    // also matches. Predictable for scripts and operator muscle memory.
7798    if let Some(s) = sessions.iter().find(|s| s.name == input) {
7799        return Ok(s);
7800    }
7801    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
7802        .iter()
7803        .filter(|s| {
7804            s.character
7805                .as_ref()
7806                .map(|c| c.nickname == input)
7807                .unwrap_or(false)
7808        })
7809        .collect();
7810    match nick_matches.len() {
7811        0 => Err(ResolveError::NotFound),
7812        1 => Ok(nick_matches[0]),
7813        _ => Err(ResolveError::Ambiguous(
7814            nick_matches.iter().map(|s| s.name.clone()).collect(),
7815        )),
7816    }
7817}
7818
7819#[derive(Debug)]
7820enum ResolveError {
7821    NotFound,
7822    Ambiguous(Vec<String>),
7823}
7824
7825/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
7826/// to a pinned peer's canonical handle.
7827///
7828/// `wire send <peer>` accepts either the handle the peer registered with
7829/// or their character nickname (DID-hash-derived). Exact handle match
7830/// always wins. When a nickname matches multiple peers (theoretically
7831/// possible via DID-hash collision in the (adj, noun) space), returns
7832/// `Ambiguous` so the caller can surface a disambiguation hint instead
7833/// of silently picking one.
7834///
7835/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
7836/// overrides on the peer's side live in their local `display.json` and
7837/// aren't yet published via agent-card. (That's the v0.7+ federation
7838/// lifecycle work — peers publishing overrides so we resolve by what
7839/// they call themselves, not just what their DID hashes to.)
7840fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
7841    let trust = match config::read_trust() {
7842        Ok(t) => t,
7843        Err(_) => return Ok(None),
7844    };
7845    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
7846        Some(a) => a,
7847        None => return Ok(None),
7848    };
7849    if agents.contains_key(input) {
7850        return Ok(Some(input.to_string()));
7851    }
7852    let mut nick_matches: Vec<String> = Vec::new();
7853    for (handle, agent) in agents.iter() {
7854        // v0.7.0-alpha.6: prefer peer's published display nickname over
7855        // auto-derived. Allows `wire send <their-chosen-name>` not just
7856        // `wire send <their-did-hash-derived-name>`.
7857        let character = match agent.get("card") {
7858            Some(card) => crate::character::Character::from_card(card),
7859            None => match agent.get("did").and_then(Value::as_str) {
7860                Some(did) => crate::character::Character::from_did(did),
7861                None => continue,
7862            },
7863        };
7864        if character.nickname == input {
7865            nick_matches.push(handle.clone());
7866        }
7867    }
7868    match nick_matches.len() {
7869        0 => Ok(None),
7870        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
7871        _ => Err(ResolveError::Ambiguous(nick_matches)),
7872    }
7873}
7874
7875fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
7876    // 1. Locate sister session by name OR character nickname.
7877    let sessions = crate::session::list_sessions()?;
7878    let sister = match resolve_local_session(&sessions, sister_name) {
7879        Ok(s) => s,
7880        Err(ResolveError::NotFound) => bail!(
7881            "no sister session named `{sister_name}` (matched by session name or character nickname). \
7882             Run `wire session list` to see what's available."
7883        ),
7884        Err(ResolveError::Ambiguous(candidates)) => bail!(
7885            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
7886             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
7887            candidates.len(),
7888            candidates.join(", ")
7889        ),
7890    };
7891    // If we matched via nickname (not exact name), surface that so the
7892    // operator sees what we resolved to. Quiet when names match exactly.
7893    if sister.name != sister_name {
7894        eprintln!(
7895            "wire add: resolved nickname `{sister_name}` → session `{}`",
7896            sister.name
7897        );
7898    }
7899
7900    // 2. Refuse self-pair — operator owns both sides, but a self-loop
7901    // breaks the bilateral state machine.
7902    let our_card = config::read_agent_card()
7903        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
7904    let our_did = our_card
7905        .get("did")
7906        .and_then(Value::as_str)
7907        .ok_or_else(|| anyhow!("agent-card missing did"))?
7908        .to_string();
7909    if let Some(sister_did) = sister.did.as_deref()
7910        && sister_did == our_did
7911    {
7912        bail!("refusing to add self (`{sister_name}` is this very session)");
7913    }
7914
7915    // 3. Read sister's agent-card + relay state from disk.
7916    let sister_card_path = sister
7917        .home_dir
7918        .join("config")
7919        .join("wire")
7920        .join("agent-card.json");
7921    let sister_card: Value = serde_json::from_slice(
7922        &std::fs::read(&sister_card_path)
7923            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
7924    )
7925    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
7926    let sister_relay_state: Value = std::fs::read(
7927        sister
7928            .home_dir
7929            .join("config")
7930            .join("wire")
7931            .join("relay.json"),
7932    )
7933    .ok()
7934    .and_then(|b| serde_json::from_slice(&b).ok())
7935    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7936
7937    let sister_did = sister_card
7938        .get("did")
7939        .and_then(Value::as_str)
7940        .ok_or_else(|| anyhow!("sister card missing did"))?
7941        .to_string();
7942    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
7943
7944    // Pull sister's full endpoint set; we want the local one for delivery
7945    // and we'll pin all of them so OUR pushes prefer local-first per the
7946    // existing routing logic.
7947    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
7948    if sister_endpoints.is_empty() {
7949        bail!(
7950            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
7951        );
7952    }
7953    let sister_local = sister_endpoints
7954        .iter()
7955        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
7956    let delivery_endpoint = match sister_local {
7957        Some(e) => e.clone(),
7958        None => sister_endpoints[0].clone(),
7959    };
7960
7961    // 4. Ensure WE have a slot to advertise back. For local-only sessions
7962    // this is the local slot; for dual-slot sessions, federation is fine.
7963    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
7964    // for pure local-only — instead, pick our own existing federation
7965    // endpoint if present, else fall back to whatever's first.
7966    let our_relay_state = config::read_relay_state()?;
7967    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7968    if our_endpoints.is_empty() {
7969        bail!(
7970            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
7971        );
7972    }
7973    let our_advertised = our_endpoints
7974        .iter()
7975        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
7976        .cloned()
7977        .unwrap_or_else(|| our_endpoints[0].clone());
7978
7979    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
7980    // relay_state.peers with their full endpoint set. slot_token lands
7981    // via pair_drop_ack as usual.
7982    let mut trust = config::read_trust()?;
7983    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
7984    config::write_trust(&trust)?;
7985    let mut relay_state = config::read_relay_state()?;
7986    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
7987    config::write_relay_state(&relay_state)?;
7988
7989    // 6. Build the same pair_drop event the federation path emits, with
7990    // our card + endpoints in the body so the sister can pin us back.
7991    let sk_seed = config::read_private_key()?;
7992    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7993    let pk_b64 = our_card
7994        .get("verify_keys")
7995        .and_then(Value::as_object)
7996        .and_then(|m| m.values().next())
7997        .and_then(|v| v.get("key"))
7998        .and_then(Value::as_str)
7999        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
8000    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8001    let now = time::OffsetDateTime::now_utc()
8002        .format(&time::format_description::well_known::Rfc3339)
8003        .unwrap_or_default();
8004    let mut body = json!({
8005        "card": our_card,
8006        "relay_url": our_advertised.relay_url,
8007        "slot_id": our_advertised.slot_id,
8008        "slot_token": our_advertised.slot_token,
8009    });
8010    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
8011    let event = json!({
8012        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8013        "timestamp": now,
8014        "from": our_did,
8015        "to": sister_did,
8016        "type": "pair_drop",
8017        "kind": 1100u32,
8018        "body": body,
8019    });
8020    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
8021    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8022
8023    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
8024    // (the federation handle indexer) — we already know the slot coords
8025    // from disk, so post_event is sufficient.
8026    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
8027    client
8028        .post_event(
8029            &delivery_endpoint.slot_id,
8030            &delivery_endpoint.slot_token,
8031            &signed,
8032        )
8033        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
8034
8035    if as_json {
8036        println!(
8037            "{}",
8038            serde_json::to_string(&json!({
8039                "handle": sister_name,
8040                "paired_with": sister_did,
8041                "peer_handle": sister_handle,
8042                "event_id": event_id,
8043                "delivered_via": match delivery_endpoint.scope {
8044                    crate::endpoints::EndpointScope::Local => "local",
8045                    crate::endpoints::EndpointScope::Lan => "lan",
8046                    crate::endpoints::EndpointScope::Uds => "uds",
8047                    crate::endpoints::EndpointScope::Federation => "federation",
8048                },
8049                "status": "drop_sent",
8050            }))?
8051        );
8052    } else {
8053        let scope = match delivery_endpoint.scope {
8054            crate::endpoints::EndpointScope::Local => "local",
8055            crate::endpoints::EndpointScope::Lan => "lan",
8056            crate::endpoints::EndpointScope::Uds => "uds",
8057            crate::endpoints::EndpointScope::Federation => "federation",
8058        };
8059        println!(
8060            "→ 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.",
8061            delivery_endpoint.relay_url
8062        );
8063    }
8064    Ok(())
8065}
8066
8067fn cmd_add(
8068    handle_arg: &str,
8069    relay_override: Option<&str>,
8070    local_sister: bool,
8071    as_json: bool,
8072) -> Result<()> {
8073    // v0.7.4: nickname-friendly local-sister resolution. Whether the
8074    // operator passed `--local-sister` explicitly OR just typed a bare
8075    // name (no `@<relay>`), try to resolve through the local sessions
8076    // registry so character nicknames AND session names AND card
8077    // handles all work as input. Closes the "I only know this peer by
8078    // its character name" ergonomic gap that forced operators into
8079    // `wire session list-local | grep <nick> | awk` dances.
8080    if local_sister {
8081        let resolved = crate::session::resolve_local_sister(handle_arg)
8082            .unwrap_or_else(|| handle_arg.to_string());
8083        return cmd_add_local_sister(&resolved, as_json);
8084    }
8085    if !handle_arg.contains('@')
8086        && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
8087    {
8088        eprintln!(
8089            "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
8090             — routing via --local-sister (disk-read card, no relay lookup)."
8091        );
8092        return cmd_add_local_sister(&resolved, as_json);
8093    }
8094    if !handle_arg.contains('@') {
8095        bail!(
8096            "`{handle_arg}` doesn't match any local sister session and has no \
8097             @<relay> suffix for federation.\n\
8098             — Local sisters: `wire session list-local` (operator types name OR \
8099             character nickname)\n\
8100             — Federation:    `wire add <handle>@<relay-domain>` (e.g. \
8101             `wire add alice@wireup.net`)"
8102        );
8103    }
8104    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
8105
8106    // 1. Auto-init self if needed + ensure a relay slot.
8107    let (our_did, our_relay, our_slot_id, our_slot_token) =
8108        crate::pair_invite::ensure_self_with_relay(relay_override)?;
8109    if our_did == format!("did:wire:{}", parsed.nick) {
8110        // Lazy guard — actual self-add would also be caught by FCFS later.
8111        bail!("refusing to add self (handle matches own DID)");
8112    }
8113
8114    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
8115    // already sitting in pending-inbound, the operator is now accepting it.
8116    // Pin trust, save relay coords + slot_token from the stored drop, ship
8117    // our own slot_token back via pair_drop_ack, delete the pending record.
8118    //
8119    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
8120    // receiver-side auto-promote was removed there; operator consent flows
8121    // through here. After this branch returns, both sides are bilaterally
8122    // pinned and capability flows in both directions.
8123    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
8124        return cmd_add_accept_pending(
8125            handle_arg,
8126            &parsed.nick,
8127            &pending,
8128            &our_relay,
8129            &our_slot_id,
8130            &our_slot_token,
8131            as_json,
8132        );
8133    }
8134
8135    // v0.5.19 (#9.4): cross-relay phishing guardrail.
8136    //
8137    // Threat: operator wants to add `boss@wireup.net` but types
8138    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
8139    // The .well-known resolution returns whoever claimed the nick on the
8140    // *typo* relay, the bilateral gate still completes (the attacker
8141    // accepts the pair on their side), and the operator pins the
8142    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
8143    // there's no asymmetry to detect when the attacker WANTS to be
8144    // paired.
8145    //
8146    // Mitigation: warn loudly when the peer's relay domain is novel
8147    // (not the operator's own relay, not in a small known-good set).
8148    // Doesn't block — operators have legitimate reasons to pair across
8149    // relays. The signal lands in shell history so a phished operator
8150    // can find it in retrospect.
8151    if !is_known_relay_domain(&parsed.domain, &our_relay) {
8152        eprintln!(
8153            "wire add: WARN unfamiliar relay domain `{}`.",
8154            parsed.domain
8155        );
8156        eprintln!(
8157            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
8158            host_of_url(&our_relay)
8159        );
8160        eprintln!(
8161            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
8162            parsed.nick
8163        );
8164        eprintln!(
8165            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
8166            parsed.nick
8167        );
8168        eprintln!("  peer out-of-band that they actually run a relay at this domain");
8169        eprintln!("  before relying on the pair. (See issue #9.4.)");
8170    }
8171
8172    // 2. Resolve peer via .well-known on their relay.
8173    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
8174    let peer_card = resolved
8175        .get("card")
8176        .cloned()
8177        .ok_or_else(|| anyhow!("resolved missing card"))?;
8178    let peer_did = resolved
8179        .get("did")
8180        .and_then(Value::as_str)
8181        .ok_or_else(|| anyhow!("resolved missing did"))?
8182        .to_string();
8183    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
8184
8185    // Self-pair guard (issue #30, explicit "Optional" ask). Refuses loudly
8186    // when the resolved peer DID matches our own. See
8187    // `reject_self_pair_after_resolution` for the full failure-mode and
8188    // remediation rationale.
8189    reject_self_pair_after_resolution(&our_did, &peer_did)?;
8190
8191    let peer_slot_id = resolved
8192        .get("slot_id")
8193        .and_then(Value::as_str)
8194        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
8195        .to_string();
8196    let peer_relay = resolved
8197        .get("relay_url")
8198        .and_then(Value::as_str)
8199        .map(str::to_string)
8200        .or_else(|| relay_override.map(str::to_string))
8201        .unwrap_or_else(|| format!("https://{}", parsed.domain));
8202
8203    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
8204    let mut trust = config::read_trust()?;
8205    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
8206    config::write_trust(&trust)?;
8207    let mut relay_state = config::read_relay_state()?;
8208    // Additive re-pin (v0.13.2, E3 token-bleed fix). The old code REPLACED the
8209    // whole peer entry with a flat federation-only one, seeding the token from
8210    // the entry's TOP-LEVEL `slot_token`. Two bugs (glossy-magnolia repro):
8211    //   1. re-dialing a peer that had a local endpoint (from add-peer-slot)
8212    //      CLOBBERED that local endpoint.
8213    //   2. after a local add-peer-slot the top-level token was the LOCAL token,
8214    //      so the federation endpoint inherited a stale LOCAL bearer →
8215    //      federation delivery would 401.
8216    // Fix: merge the federation endpoint into the peer's endpoints[] (preserve
8217    // the local one), and seed its token ONLY from a prior FEDERATION endpoint
8218    // on the same relay (re-dialing an already-acked peer), never a local one —
8219    // empty until the pair_drop_ack lands otherwise.
8220    let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
8221        .get("peers")
8222        .and_then(|p| p.get(&peer_handle))
8223        .and_then(|e| e.get("endpoints"))
8224        .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
8225        .unwrap_or_default();
8226    let fed_token = endpoints
8227        .iter()
8228        .find(|e| {
8229            e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
8230        })
8231        .map(|e| e.slot_token.clone())
8232        .unwrap_or_default();
8233    let fed_ep = crate::endpoints::Endpoint {
8234        relay_url: peer_relay.clone(),
8235        slot_id: peer_slot_id.clone(),
8236        slot_token: fed_token, // empty until pair_drop_ack lands
8237        scope: crate::endpoints::EndpointScope::Federation,
8238    };
8239    if let Some(existing) = endpoints
8240        .iter_mut()
8241        .find(|e| e.relay_url == fed_ep.relay_url)
8242    {
8243        *existing = fed_ep;
8244    } else {
8245        endpoints.push(fed_ep);
8246    }
8247    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
8248    config::write_relay_state(&relay_state)?;
8249
8250    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
8251    // is the v0.5 zero-paste open-mode path).
8252    let our_card = config::read_agent_card()?;
8253    let sk_seed = config::read_private_key()?;
8254    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
8255    let pk_b64 = our_card
8256        .get("verify_keys")
8257        .and_then(Value::as_object)
8258        .and_then(|m| m.values().next())
8259        .and_then(|v| v.get("key"))
8260        .and_then(Value::as_str)
8261        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
8262    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8263    let now = time::OffsetDateTime::now_utc()
8264        .format(&time::format_description::well_known::Rfc3339)
8265        .unwrap_or_default();
8266    // v0.5.17: advertise all our endpoints (federation + optional local)
8267    // to the peer in the pair_drop body. Back-compat: top-level
8268    // relay_url/slot_id/slot_token still point at the federation
8269    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
8270    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
8271    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
8272    let mut body = json!({
8273        "card": our_card,
8274        "relay_url": our_relay,
8275        "slot_id": our_slot_id,
8276        "slot_token": our_slot_token,
8277    });
8278    if !our_endpoints.is_empty() {
8279        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
8280    }
8281    let event = json!({
8282        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8283        "timestamp": now,
8284        "from": our_did,
8285        "to": peer_did,
8286        "type": "pair_drop",
8287        "kind": 1100u32,
8288        "body": body,
8289    });
8290    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
8291
8292    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
8293    let client = crate::relay_client::RelayClient::new(&peer_relay);
8294    let resp = client.handle_intro(&parsed.nick, &signed)?;
8295    let event_id = signed
8296        .get("event_id")
8297        .and_then(Value::as_str)
8298        .unwrap_or("")
8299        .to_string();
8300
8301    if as_json {
8302        println!(
8303            "{}",
8304            serde_json::to_string(&json!({
8305                "handle": handle_arg,
8306                "paired_with": peer_did,
8307                "peer_handle": peer_handle,
8308                "event_id": event_id,
8309                "drop_response": resp,
8310                "status": "drop_sent",
8311            }))?
8312        );
8313    } else {
8314        println!(
8315            "→ 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."
8316        );
8317    }
8318    Ok(())
8319}
8320
8321/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
8322/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
8323/// coords + slot_token from the stored drop, ship our slot_token back via
8324/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
8325/// invite-URL path (which is already bilateral by virtue of the pre-shared
8326/// nonce).
8327fn cmd_add_accept_pending(
8328    handle_arg: &str,
8329    peer_nick: &str,
8330    pending: &crate::pending_inbound_pair::PendingInboundPair,
8331    _our_relay: &str,
8332    _our_slot_id: &str,
8333    _our_slot_token: &str,
8334    as_json: bool,
8335) -> Result<()> {
8336    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
8337    //    `wire add` against this handle while a drop was waiting.
8338    let mut trust = config::read_trust()?;
8339    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
8340    config::write_trust(&trust)?;
8341
8342    // 2. Record peer's relay coords + slot_token (already shipped to us in
8343    //    the original drop body; held back until now).
8344    // v0.5.17: pin all advertised endpoints (federation + optional local).
8345    // Falls back to a single federation entry when the record was written
8346    // by v0.5.16-era code that didn't carry endpoints[].
8347    let mut relay_state = config::read_relay_state()?;
8348    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
8349        vec![crate::endpoints::Endpoint::federation(
8350            pending.peer_relay_url.clone(),
8351            pending.peer_slot_id.clone(),
8352            pending.peer_slot_token.clone(),
8353        )]
8354    } else {
8355        pending.peer_endpoints.clone()
8356    };
8357    crate::endpoints::pin_peer_endpoints(
8358        &mut relay_state,
8359        &pending.peer_handle,
8360        &endpoints_to_pin,
8361    )?;
8362    config::write_relay_state(&relay_state)?;
8363
8364    // 3. Ship our slot_token to peer via pair_drop_ack — try every advertised
8365    //    peer endpoint in priority order (Bug 2). `endpoints_to_pin` was
8366    //    already built from `pending.peer_endpoints` (with legacy-triple
8367    //    fallback) just above, so we reuse it rather than rebuilding.
8368    crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
8369        || {
8370            format!(
8371                "pair_drop_ack send to {} (across {} endpoint(s)) failed",
8372                pending.peer_handle,
8373                endpoints_to_pin.len()
8374            )
8375        },
8376    )?;
8377
8378    // 4. Delete the pending-inbound record now that bilateral is complete.
8379    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
8380
8381    if as_json {
8382        println!(
8383            "{}",
8384            serde_json::to_string(&json!({
8385                "handle": handle_arg,
8386                "paired_with": pending.peer_did,
8387                "peer_handle": pending.peer_handle,
8388                "status": "bilateral_accepted",
8389                "via": "pending_inbound",
8390            }))?
8391        );
8392    } else {
8393        println!(
8394            "→ 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} \"...\"`.",
8395            peer = pending.peer_handle,
8396        );
8397    }
8398    Ok(())
8399}
8400
8401/// v0.5.14: explicit `wire pair-accept <peer>` — bilateral-completion path
8402/// for a pending-inbound pair request. Pin trust, write relay_state from the
8403/// stored pair_drop, send `pair_drop_ack` with our slot_token, delete the
8404/// pending record. Equivalent to running `wire add <peer>@<their-relay>`
8405/// when a pending-inbound record exists, but without needing to remember
8406/// the peer's relay domain.
8407fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
8408    let nick = crate::agent_card::bare_handle(peer_nick);
8409    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
8410        anyhow!(
8411            "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
8412             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
8413        )
8414    })?;
8415    let (_our_did, our_relay, our_slot_id, our_slot_token) =
8416        crate::pair_invite::ensure_self_with_relay(None)?;
8417    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
8418    cmd_add_accept_pending(
8419        &handle_arg,
8420        nick,
8421        &pending,
8422        &our_relay,
8423        &our_slot_id,
8424        &our_slot_token,
8425        as_json,
8426    )
8427}
8428
8429/// v0.5.14: programmatic access to pending-inbound for scripts.
8430/// `wire pair-list-inbound --json` returns a flat array of records.
8431fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
8432    let items = crate::pending_inbound_pair::list_pending_inbound()?;
8433    if as_json {
8434        println!("{}", serde_json::to_string(&items)?);
8435        return Ok(());
8436    }
8437    if items.is_empty() {
8438        println!("no pending pair requests — your inbox is clear.");
8439        return Ok(());
8440    }
8441    // v0.9.3: conversational output. Tabular data is for --json. Humans
8442    // get one short sentence per pending peer, each rendered with the
8443    // peer's character (DID-derived emoji + nickname) so they can match
8444    // the speaker against their statusline / mesh-status view at a
8445    // glance. The "next step" sentence at the bottom names the exact
8446    // verbs to run.
8447    let plural = if items.len() == 1 { "" } else { "s" };
8448    println!("{} pending pair request{plural}:\n", items.len());
8449    for p in &items {
8450        let ch = crate::character::Character::from_did(&p.peer_did);
8451        let glyph = crate::character::emoji_with_fallback(&ch);
8452        // ASCII-friendly arrow if the operator's terminal can't render
8453        // emoji (the same routine drives the fallback).
8454        println!(
8455            "  {glyph} {nick}  ({handle})  wants to pair with you",
8456            nick = ch.nickname,
8457            handle = p.peer_handle,
8458        );
8459    }
8460    println!();
8461    println!(
8462        "→ to accept any: `wire accept <name>`  (e.g. `wire accept {first}`)",
8463        first = items
8464            .first()
8465            .map(|p| {
8466                let ch = crate::character::Character::from_did(&p.peer_did);
8467                ch.nickname
8468            })
8469            .unwrap_or_else(|| "<name>".to_string())
8470    );
8471    println!("→ to refuse:    `wire reject <name>`");
8472    Ok(())
8473}
8474
8475/// v0.5.14: `wire pair-reject <peer>` — drop a pending-inbound record
8476/// without pairing. No event is sent back to the peer; their side stays
8477/// pending until they time out or the operator-side data ages out.
8478fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
8479    let nick = crate::agent_card::bare_handle(peer_nick);
8480    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
8481    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
8482
8483    if as_json {
8484        println!(
8485            "{}",
8486            serde_json::to_string(&json!({
8487                "peer": nick,
8488                "rejected": existed.is_some(),
8489                "had_pending": existed.is_some(),
8490            }))?
8491        );
8492    } else if existed.is_some() {
8493        println!(
8494            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
8495        );
8496    } else {
8497        println!("no pending pair from {nick} — nothing to reject");
8498    }
8499    Ok(())
8500}
8501
8502// ---------- session (v0.5.16) ----------
8503//
8504// Multi-session wire on one machine. See src/session.rs for the storage
8505// layout + naming rules. The CLI dispatcher here orchestrates child
8506// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
8507// each session-local `init` / `claim` / `daemon` runs in its own world
8508// without cross-contamination via env vars in this process.
8509
8510// ---------- group chat (v0.13.3) ----------
8511
8512fn cmd_group(cmd: GroupCommand) -> Result<()> {
8513    match cmd {
8514        GroupCommand::Create { name, json } => cmd_group_create(&name, json),
8515        GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
8516        GroupCommand::Send {
8517            group,
8518            message,
8519            json,
8520        } => cmd_group_send(&group, &message, json),
8521        GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
8522        GroupCommand::List { json } => cmd_group_list(json),
8523        GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
8524        GroupCommand::Join { code, json } => cmd_group_join(&code, json),
8525    }
8526}
8527
8528/// This agent's (did, handle) from its signed card.
8529/// This agent's signing identity for group ops: (did, handle, key_id, pk_b64).
8530fn group_self() -> Result<(String, String, String, String)> {
8531    let card = config::read_agent_card()?;
8532    let did = card
8533        .get("did")
8534        .and_then(Value::as_str)
8535        .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
8536        .to_string();
8537    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8538    let pk_b64 = card
8539        .get("verify_keys")
8540        .and_then(Value::as_object)
8541        .and_then(|m| m.values().next())
8542        .and_then(|v| v.get("key"))
8543        .and_then(Value::as_str)
8544        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
8545        .to_string();
8546    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8547    let key_id = make_key_id(&handle, &pk_bytes);
8548    Ok((did, handle, key_id, pk_b64))
8549}
8550
8551/// Relay to host a group room on — prefer the federation endpoint (remote
8552/// members can reach it), fall back to LAN, then local, then any.
8553fn group_room_relay_url() -> Result<String> {
8554    use crate::endpoints::EndpointScope;
8555    let state = config::read_relay_state()?;
8556    let eps = crate::endpoints::self_endpoints(&state);
8557    let pick = eps
8558        .iter()
8559        .find(|e| e.scope == EndpointScope::Federation)
8560        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
8561        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
8562        .or_else(|| eps.first());
8563    match pick {
8564        Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
8565        _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
8566    }
8567}
8568
8569/// Sign a `group_invite` (carrying the full creator-signed Group) and queue it
8570/// to every other member's outbox. The daemon/push delivers; the recipient's
8571/// `ingest_group_invites` materializes the room + introduce-pins members.
8572fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
8573    let (_, self_handle, _, pk_b64) = group_self()?;
8574    let sk_seed = config::read_private_key()?;
8575    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8576    let now_iso = time::OffsetDateTime::now_utc()
8577        .format(&time::format_description::well_known::Rfc3339)
8578        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8579    let group_json = serde_json::to_value(group)?;
8580    let mut delivered = 0usize;
8581    for handle in group.other_member_handles(self_did) {
8582        let event = json!({
8583            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8584            "timestamp": now_iso,
8585            "from": self_did,
8586            "to": format!("did:wire:{handle}"),
8587            "type": "group_invite",
8588            "kind": parse_kind("group_invite")?,
8589            "body": group_json,
8590        });
8591        let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8592            .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
8593        let line = serde_json::to_vec(&signed)?;
8594        if config::append_outbox_record(&handle, &line).is_ok() {
8595            delivered += 1;
8596        }
8597    }
8598    Ok(delivered)
8599}
8600
8601/// Introduce-pin a member's key on the creator's vouch: ensure
8602/// `trust.agents[handle]` carries this key so the member's group messages
8603/// verify, WITHOUT granting bilateral trust. Never lowers an existing tier
8604/// (a directly-VERIFIED peer stays VERIFIED); only adds the key if missing.
8605/// Returns `true` iff it actually changed `trust` (new entry or added key) —
8606/// callers use this to decide whether to persist.
8607fn introduce_pin(
8608    trust: &mut Value,
8609    handle: &str,
8610    did: &str,
8611    key_id: &str,
8612    key: &str,
8613    group_id: &str,
8614) -> bool {
8615    let now = time::OffsetDateTime::now_utc()
8616        .format(&time::format_description::well_known::Rfc3339)
8617        .unwrap_or_default();
8618    let agents = trust
8619        .as_object_mut()
8620        .expect("trust is an object")
8621        .entry("agents")
8622        .or_insert_with(|| json!({}));
8623    let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
8624    match agents.get_mut(handle) {
8625        Some(existing) => {
8626            // Already pinned (maybe at a higher bilateral tier) — just ensure
8627            // the key is present. Do NOT touch the tier.
8628            let keys = existing
8629                .as_object_mut()
8630                .and_then(|o| o.get_mut("public_keys"))
8631                .and_then(Value::as_array_mut);
8632            if let Some(keys) = keys {
8633                let have = keys
8634                    .iter()
8635                    .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
8636                if !have {
8637                    keys.push(key_rec);
8638                    return true;
8639                }
8640            }
8641            false
8642        }
8643        None => {
8644            // First sight — pin at bilateral UNTRUSTED (disjoint from GroupTier).
8645            agents[handle] = json!({
8646                "tier": "UNTRUSTED",
8647                "did": did,
8648                "public_keys": [key_rec],
8649                "introduced_via": group_id,
8650                "pinned_at": now,
8651            });
8652            true
8653        }
8654    }
8655}
8656
8657/// Scan the inbox for `group_invite` events from pinned creators, verify them
8658/// (event signature + roster `creator_sig`), materialize/refresh the local
8659/// group at its highest epoch, and introduce-pin every other member. Lazy:
8660/// runs at the top of group send/tail/list so a member just-pulled an invite
8661/// is immediately usable. Skips groups this agent created.
8662fn ingest_group_invites() -> Result<()> {
8663    let inbox = config::inbox_dir()?;
8664    if !inbox.exists() {
8665        return Ok(());
8666    }
8667    let (self_did, ..) = group_self()?;
8668    let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
8669    // group_id -> highest-epoch verified roster seen in the inbox.
8670    let mut best: std::collections::HashMap<String, crate::group::Group> =
8671        std::collections::HashMap::new();
8672
8673    for entry in std::fs::read_dir(&inbox)?.flatten() {
8674        let path = entry.path();
8675        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
8676            continue;
8677        }
8678        for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
8679            let event: Value = match serde_json::from_str(line) {
8680                Ok(v) => v,
8681                Err(_) => continue,
8682            };
8683            if event.get("type").and_then(Value::as_str) != Some("group_invite") {
8684                continue;
8685            }
8686            // Event-level: the invite must be from a pinned peer (the creator)
8687            // with a valid signature.
8688            if verify_message_v31(&event, &trust_now).is_err() {
8689                continue;
8690            }
8691            let Some(body) = event.get("body") else {
8692                continue;
8693            };
8694            let group: crate::group::Group = match serde_json::from_value(body.clone()) {
8695                Ok(g) => g,
8696                Err(_) => continue,
8697            };
8698            if group.creator_did == self_did {
8699                continue; // never overwrite a group I created
8700            }
8701            // The invite's sender must be the group's creator.
8702            let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
8703            if from_did != group.creator_did {
8704                continue;
8705            }
8706            // Roster integrity: creator_sig must verify against the creator's
8707            // independently-pinned key (we paired with the creator → have it).
8708            let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
8709            let creator_key = trust_now
8710                .get("agents")
8711                .and_then(|a| a.get(creator_handle))
8712                .and_then(|a| a.get("public_keys"))
8713                .and_then(Value::as_array)
8714                .and_then(|ks| ks.first())
8715                .and_then(|k| k.get("key"))
8716                .and_then(Value::as_str)
8717                .and_then(|b| crate::signing::b64decode(b).ok());
8718            let Some(creator_key) = creator_key else {
8719                continue;
8720            };
8721            if !group.verify(&creator_key) {
8722                continue;
8723            }
8724            match best.get(&group.id) {
8725                Some(prev) if prev.epoch >= group.epoch => {}
8726                _ => {
8727                    best.insert(group.id.clone(), group);
8728                }
8729            }
8730        }
8731    }
8732
8733    if best.is_empty() {
8734        return Ok(());
8735    }
8736    let mut trust = config::read_trust()?;
8737    for group in best.values() {
8738        // Don't regress a locally-known group to a stale epoch.
8739        if let Ok(local) = crate::group::load_group(&group.id)
8740            && local.epoch >= group.epoch
8741        {
8742            continue;
8743        }
8744        crate::group::save_group(group)?;
8745        for m in &group.members {
8746            if m.did == self_did || m.key.is_empty() {
8747                continue;
8748            }
8749            introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
8750        }
8751    }
8752    config::write_trust(&trust)?;
8753    Ok(())
8754}
8755
8756fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
8757    if !config::is_initialized()? {
8758        bail!("not initialized — run `wire up` first");
8759    }
8760    let (did, handle, key_id, pk_b64) = group_self()?;
8761    let relay_url = group_room_relay_url()?;
8762    // Allocate the shared group-room slot on the relay.
8763    let client = crate::relay_client::RelayClient::new(&relay_url);
8764    let room = client
8765        .allocate_slot(Some(&format!("group:{name}")))
8766        .with_context(|| format!("allocating group room on {relay_url}"))?;
8767    let id = format!("g{:016x}", rand::random::<u64>());
8768    let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
8769    group.set_room(relay_url, room.slot_id, room.slot_token);
8770    group.set_member_keys(&did, key_id, pk_b64)?;
8771    let sk = config::read_private_key()?;
8772    group.sign(&sk)?;
8773    crate::group::save_group(&group)?;
8774    if as_json {
8775        println!(
8776            "{}",
8777            serde_json::to_string(&json!({
8778                "id": id, "name": name, "members": 1, "relay_url": group.relay_url
8779            }))?
8780        );
8781    } else {
8782        println!(
8783            "created group `{name}` (id {id}) — room on {}. You are the creator.",
8784            group.relay_url
8785        );
8786        println!("  add peers: `wire group add {id} <peer>`   talk: `wire group send {id} \"hi\"`");
8787    }
8788    Ok(())
8789}
8790
8791fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
8792    let (self_did, ..) = group_self()?;
8793    let mut group = crate::group::resolve_group(group_ref)?;
8794    if group.creator_did != self_did {
8795        bail!("only the group creator can add members (the creator signs the roster)");
8796    }
8797    // T22 consent: a Member must be a peer you bilaterally VERIFIED.
8798    let bare = crate::agent_card::bare_handle(peer).to_string();
8799    let trust = config::read_trust()?;
8800    let agent = trust
8801        .get("agents")
8802        .and_then(|a| a.get(&bare))
8803        .ok_or_else(|| {
8804            anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
8805        })?;
8806    let tier = agent
8807        .get("tier")
8808        .and_then(Value::as_str)
8809        .unwrap_or("UNTRUSTED");
8810    if tier != "VERIFIED" {
8811        bail!(
8812            "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
8813        );
8814    }
8815    let peer_did = agent
8816        .get("did")
8817        .and_then(Value::as_str)
8818        .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
8819        .to_string();
8820    // Capture the peer's signing key from trust so the creator can vouch for it
8821    // in the signed roster (members introduce-pin it to verify this peer).
8822    let key = agent
8823        .get("public_keys")
8824        .and_then(Value::as_array)
8825        .and_then(|ks| {
8826            ks.iter()
8827                .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
8828        })
8829        .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
8830    let peer_key_id = key
8831        .get("key_id")
8832        .and_then(Value::as_str)
8833        .unwrap_or_default()
8834        .to_string();
8835    let peer_pk = key
8836        .get("key")
8837        .and_then(Value::as_str)
8838        .unwrap_or_default()
8839        .to_string();
8840
8841    group.add_member(
8842        bare.clone(),
8843        peer_did.clone(),
8844        crate::group::GroupTier::Member,
8845    )?;
8846    group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
8847    let sk = config::read_private_key()?;
8848    group.sign(&sk)?;
8849    crate::group::save_group(&group)?;
8850    // Distribute the refreshed signed roster (room coords + everyone's keys) to
8851    // ALL members so each can post + verify the others.
8852    let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
8853    if as_json {
8854        println!(
8855            "{}",
8856            serde_json::to_string(&json!({
8857                "group": group.id, "added": bare, "epoch": group.epoch,
8858                "members": group.members.len(), "invites_queued": delivered
8859            }))?
8860        );
8861    } else {
8862        println!(
8863            "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
8864            group.name,
8865            group.members.len(),
8866            group.epoch
8867        );
8868    }
8869    Ok(())
8870}
8871
8872fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
8873    if !config::is_initialized()? {
8874        bail!("not initialized — run `wire up` first");
8875    }
8876    ingest_group_invites()?;
8877    let (self_did, self_handle, _, pk_b64) = group_self()?;
8878    let group = crate::group::resolve_group(group_ref)?;
8879    // Membership for SEND is room-token possession: having the group locally
8880    // (with its slot_token) is the capability. The signed roster gates who you
8881    // can VERIFY, not whether you may post — a code-redeemed joiner isn't in the
8882    // creator-signed roster but legitimately holds the room key.
8883    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8884        bail!(
8885            "group `{}` has no room slot (legacy/partial group)",
8886            group.name
8887        );
8888    }
8889    let sk_seed = config::read_private_key()?;
8890    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8891    let now_iso = time::OffsetDateTime::now_utc()
8892        .format(&time::format_description::well_known::Rfc3339)
8893        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8894    let event = json!({
8895        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8896        "timestamp": now_iso,
8897        "from": self_did,
8898        "to": format!("did:wire:group:{}", group.id),
8899        "type": "group_msg",
8900        "kind": parse_kind("group_msg")?,
8901        "body": {
8902            "group_id": group.id,
8903            "group_name": group.name,
8904            "epoch": group.epoch,
8905            "text": message,
8906        },
8907    });
8908    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8909        .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
8910    // Post the one message to the shared group slot.
8911    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8912    client
8913        .post_event(&group.slot_id, &group.slot_token, &signed)
8914        .with_context(|| {
8915            format!(
8916                "posting to group room {} on {}",
8917                group.slot_id, group.relay_url
8918            )
8919        })?;
8920    if as_json {
8921        println!(
8922            "{}",
8923            serde_json::to_string(&json!({
8924                "group": group.id, "epoch": group.epoch, "status": "posted",
8925                "members": group.members.len()
8926            }))?
8927        );
8928    } else {
8929        println!(
8930            "group `{}`: posted to the room ({} member(s))",
8931            group.name,
8932            group.members.len()
8933        );
8934    }
8935    Ok(())
8936}
8937
8938fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
8939    ingest_group_invites()?;
8940    let group = crate::group::resolve_group(group_ref)?;
8941    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8942        bail!(
8943            "group `{}` has no room slot (legacy/partial group)",
8944            group.name
8945        );
8946    }
8947    let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
8948    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8949    // Pull the shared room; cap generously then show the last `limit`.
8950    let fetch = if limit == 0 {
8951        1000
8952    } else {
8953        (limit * 4).min(1000)
8954    };
8955    let events = client
8956        .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
8957        .with_context(|| {
8958            format!(
8959                "pulling group room {} on {}",
8960                group.slot_id, group.relay_url
8961            )
8962        })?;
8963
8964    // Pass 1: introduce-pin anyone who announced a join. A `group_join` carries
8965    // the joiner's card and must self-consistently sign under it; posting to the
8966    // room requires the room token, so possession is the authorization (pinned
8967    // at bilateral UNTRUSTED, group tier Introduced). This lets their later
8968    // group messages verify even though they're not in the creator-signed roster.
8969    let mut trust_changed = false;
8970    for event in &events {
8971        if event.get("type").and_then(Value::as_str) != Some("group_join") {
8972            continue;
8973        }
8974        if let Some((h, did, kid, key)) = group_join_pin_material(event)
8975            && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
8976        {
8977            trust_changed = true;
8978        }
8979    }
8980    if trust_changed {
8981        let _ = config::write_trust(&trust);
8982    }
8983
8984    // Pass 2: build the timeline — group messages (verified against the
8985    // now-augmented trust) interleaved with join notices.
8986    enum Line {
8987        Msg {
8988            from: String,
8989            text: String,
8990            verified: bool,
8991        },
8992        Join {
8993            who: String,
8994        },
8995    }
8996    let mut timeline: Vec<(String, Line)> = Vec::new();
8997    for event in &events {
8998        let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
8999        let body = match event.get("body") {
9000            Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
9001            Some(v) => Some(v.clone()),
9002            None => None,
9003        };
9004        let Some(body) = body else { continue };
9005        if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
9006            continue;
9007        }
9008        let ts = event
9009            .get("timestamp")
9010            .and_then(Value::as_str)
9011            .unwrap_or("")
9012            .to_string();
9013        let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
9014        let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
9015        match ty {
9016            "group_msg" => {
9017                let text = body
9018                    .get("text")
9019                    .and_then(Value::as_str)
9020                    .unwrap_or("")
9021                    .to_string();
9022                let verified = verify_message_v31(event, &trust).is_ok();
9023                timeline.push((
9024                    ts,
9025                    Line::Msg {
9026                        from: from_handle,
9027                        text,
9028                        verified,
9029                    },
9030                ));
9031            }
9032            "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
9033            _ => {}
9034        }
9035    }
9036    timeline.sort_by(|a, b| a.0.cmp(&b.0));
9037    let start = if limit > 0 {
9038        timeline.len().saturating_sub(limit)
9039    } else {
9040        0
9041    };
9042    let recent = &timeline[start..];
9043    if as_json {
9044        let arr: Vec<Value> = recent
9045            .iter()
9046            .map(|(ts, l)| match l {
9047                Line::Msg {
9048                    from,
9049                    text,
9050                    verified,
9051                } => {
9052                    json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
9053                }
9054                Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
9055            })
9056            .collect();
9057        println!(
9058            "{}",
9059            serde_json::to_string(
9060                &json!({"group": group.id, "name": group.name, "messages": arr})
9061            )?
9062        );
9063    } else if recent.is_empty() {
9064        println!("group `{}`: no messages yet", group.name);
9065    } else {
9066        for (ts, l) in recent {
9067            let short_ts: String = ts.chars().take(19).collect();
9068            match l {
9069                Line::Msg {
9070                    from,
9071                    text,
9072                    verified,
9073                } => {
9074                    let mark = if *verified { "✓" } else { "✗" };
9075                    println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
9076                }
9077                Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
9078            }
9079        }
9080    }
9081    Ok(())
9082}
9083
9084/// Validate a `group_join` room event and extract the joiner's pin material:
9085/// (handle, did, key_id, key_b64). The event MUST self-consistently sign under
9086/// the key in the card it carries — so a forged join (card A, signed by key B)
9087/// is rejected. Authorization to be in the room is proven by the post itself
9088/// (it required the room token).
9089fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
9090    let body = match event.get("body") {
9091        Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
9092        Some(v) => v.clone(),
9093        None => return None,
9094    };
9095    let card = body.get("joiner_card")?;
9096    // Verify the event signs under the card it carries (one-entry trust).
9097    let mut tmp = json!({"agents": {}});
9098    crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
9099    if verify_message_v31(event, &tmp).is_err() {
9100        return None;
9101    }
9102    let did = card.get("did").and_then(Value::as_str)?.to_string();
9103    let handle = card
9104        .get("handle")
9105        .and_then(Value::as_str)
9106        .map(str::to_string)
9107        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9108    let (kid_full, krec) = card
9109        .get("verify_keys")
9110        .and_then(Value::as_object)
9111        .and_then(|m| m.iter().next())?;
9112    let key_id = kid_full
9113        .strip_prefix("ed25519:")
9114        .unwrap_or(kid_full)
9115        .to_string();
9116    let key = krec.get("key").and_then(Value::as_str)?.to_string();
9117    Some((handle, did, key_id, key))
9118}
9119
9120/// `wire group invite <group>` — mint a self-contained join code (the serialized
9121/// signed group: room coords + roster + member keys). The code IS the room key.
9122fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
9123    let group = crate::group::resolve_group(group_ref)?;
9124    if group.slot_id.is_empty() || group.relay_url.is_empty() {
9125        bail!(
9126            "group `{}` has no room slot — nothing to invite into",
9127            group.name
9128        );
9129    }
9130    if group.creator_sig.is_empty() {
9131        bail!(
9132            "group `{}` roster is unsigned — add a member or recreate before inviting",
9133            group.name
9134        );
9135    }
9136    let payload = serde_json::to_vec(&group)?;
9137    let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
9138    if as_json {
9139        println!(
9140            "{}",
9141            serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
9142        );
9143    } else {
9144        println!(
9145            "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
9146            group.name
9147        );
9148        println!("{code}\n");
9149        println!("they run:  wire group join <code>");
9150    }
9151    Ok(())
9152}
9153
9154/// `wire group join <code>` — redeem a join code: verify the roster, materialize
9155/// the room locally, introduce-pin existing members, and announce ourselves to
9156/// the room so members verify our messages. Lands at group tier Introduced.
9157fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
9158    if !config::is_initialized()? {
9159        bail!("not initialized — run `wire up` first");
9160    }
9161    let raw = code.trim();
9162    let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
9163    let payload =
9164        crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
9165    let group: crate::group::Group = serde_json::from_slice(&payload)
9166        .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
9167    if group.slot_id.is_empty() || group.relay_url.is_empty() {
9168        bail!("join code carries no room coords");
9169    }
9170    // Verify the roster against the creator's key carried IN the roster (TOFU on
9171    // the code — you obtained it over a trusted channel). Rejects a tampered code.
9172    let creator_key = group
9173        .members
9174        .iter()
9175        .find(|m| m.did == group.creator_did)
9176        .map(|m| m.key.clone())
9177        .filter(|k| !k.is_empty())
9178        .and_then(|k| crate::signing::b64decode(&k).ok())
9179        .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
9180    if !group.verify(&creator_key) {
9181        bail!("join code failed its signature check (tampered or corrupt)");
9182    }
9183    let (self_did, self_handle, _, _) = group_self()?;
9184    if group.creator_did == self_did {
9185        bail!("you created group `{}` — you're already in it", group.name);
9186    }
9187
9188    // Materialize locally + introduce-pin existing members so we can verify them.
9189    crate::group::save_group(&group)?;
9190    let mut trust = config::read_trust()?;
9191    for m in &group.members {
9192        if m.did == self_did || m.key.is_empty() {
9193            continue;
9194        }
9195        introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
9196    }
9197    config::write_trust(&trust)?;
9198
9199    // Announce ourselves to the room (carry our card) so members introduce-pin us.
9200    let card = config::read_agent_card()?;
9201    let sk_seed = config::read_private_key()?;
9202    let pk_b64 = card
9203        .get("verify_keys")
9204        .and_then(Value::as_object)
9205        .and_then(|m| m.values().next())
9206        .and_then(|v| v.get("key"))
9207        .and_then(Value::as_str)
9208        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
9209    let pk_bytes = crate::signing::b64decode(pk_b64)?;
9210    let now_iso = time::OffsetDateTime::now_utc()
9211        .format(&time::format_description::well_known::Rfc3339)
9212        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
9213    let event = json!({
9214        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9215        "timestamp": now_iso,
9216        "from": self_did,
9217        "to": format!("did:wire:group:{}", group.id),
9218        "type": "group_join",
9219        "kind": parse_kind("group_join")?,
9220        "body": {
9221            "group_id": group.id,
9222            "group_name": group.name,
9223            "epoch": group.epoch,
9224            "joiner_card": card,
9225            "text": "joined",
9226        },
9227    });
9228    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
9229        .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
9230    let client = crate::relay_client::RelayClient::new(&group.relay_url);
9231    let announced = client
9232        .post_event(&group.slot_id, &group.slot_token, &signed)
9233        .is_ok();
9234
9235    if as_json {
9236        println!(
9237            "{}",
9238            serde_json::to_string(&json!({
9239                "group": group.id, "name": group.name, "joined": true,
9240                "members": group.members.len(), "announced": announced
9241            }))?
9242        );
9243    } else {
9244        println!(
9245            "joined group `{}` ({} member(s)) at Introduced tier.",
9246            group.name,
9247            group.members.len()
9248        );
9249        if announced {
9250            println!("  announced to the room — members will verify your messages.");
9251        } else {
9252            println!(
9253                "  ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
9254            );
9255        }
9256        println!(
9257            "  read: `wire group tail {}`   talk: `wire group send {} \"hi\"`",
9258            group.id, group.id
9259        );
9260    }
9261    Ok(())
9262}
9263
9264fn cmd_group_list(as_json: bool) -> Result<()> {
9265    let groups = crate::group::list_groups()?;
9266    if as_json {
9267        let arr: Vec<Value> = groups
9268            .iter()
9269            .map(|g| {
9270                json!({
9271                    "id": g.id,
9272                    "name": g.name,
9273                    "epoch": g.epoch,
9274                    "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
9275                })
9276            })
9277            .collect();
9278        println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
9279    } else if groups.is_empty() {
9280        println!("no groups yet — create one with `wire group create <name>`");
9281    } else {
9282        for g in &groups {
9283            println!(
9284                "{} ({}) — {} member(s), epoch {}",
9285                g.name,
9286                g.id,
9287                g.members.len(),
9288                g.epoch
9289            );
9290            for m in &g.members {
9291                println!("    {} [{}]", m.handle, m.tier.as_str());
9292            }
9293        }
9294    }
9295    Ok(())
9296}
9297
9298/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
9299/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
9300fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
9301    match cmd {
9302        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
9303        MeshCommand::Broadcast {
9304            kind,
9305            scope,
9306            exclude,
9307            noreply,
9308            body,
9309            json,
9310        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
9311        MeshCommand::Role { action } => cmd_mesh_role(action),
9312        MeshCommand::Route {
9313            role,
9314            strategy,
9315            exclude,
9316            kind,
9317            body,
9318            json,
9319        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
9320    }
9321}
9322
9323/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
9324/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
9325/// picks ONE via the requested strategy, then signs + pushes the event
9326/// to that peer. Pinned-peers-only by construction (same as broadcast).
9327fn cmd_mesh_route(
9328    role: &str,
9329    strategy: &str,
9330    exclude: &[String],
9331    kind: &str,
9332    body_arg: &str,
9333    as_json: bool,
9334) -> Result<()> {
9335    use std::time::Instant;
9336
9337    if !config::is_initialized()? {
9338        bail!("not initialized — run `wire init <handle>` first");
9339    }
9340    let strategy = strategy.to_ascii_lowercase();
9341    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
9342        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
9343    }
9344
9345    // Our pinned-peer set: only these handles are addressable. mesh-route
9346    // refuses to invent a recipient, same posture as broadcast.
9347    let state = config::read_relay_state()?;
9348    let pinned: std::collections::BTreeSet<String> = state["peers"]
9349        .as_object()
9350        .map(|m| m.keys().cloned().collect())
9351        .unwrap_or_default();
9352
9353    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
9354
9355    // Enumerate every sister on the box, read each one's role from its
9356    // signed agent-card. Filter: matching role AND pinned AND not
9357    // excluded. `list_sessions` returns the cross-session view (using the
9358    // v0.6.4 inside-session sessions_root fallback).
9359    let sessions = crate::session::list_sessions()?;
9360    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
9361    for s in &sessions {
9362        let handle = match s.handle.as_ref() {
9363            Some(h) => h.clone(),
9364            None => continue,
9365        };
9366        if exclude_set.contains(handle.as_str()) {
9367            continue;
9368        }
9369        if !pinned.contains(&handle) {
9370            continue;
9371        }
9372        let card_path = s
9373            .home_dir
9374            .join("config")
9375            .join("wire")
9376            .join("agent-card.json");
9377        let card_role = std::fs::read(&card_path)
9378            .ok()
9379            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
9380            .and_then(|c| {
9381                c.get("profile")
9382                    .and_then(|p| p.get("role"))
9383                    .and_then(Value::as_str)
9384                    .map(str::to_string)
9385            });
9386        if card_role.as_deref() == Some(role) {
9387            candidates.push((handle, s.did.clone()));
9388        }
9389    }
9390
9391    candidates.sort_by(|a, b| a.0.cmp(&b.0));
9392    candidates.dedup_by(|a, b| a.0 == b.0);
9393
9394    if candidates.is_empty() {
9395        bail!(
9396            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
9397        );
9398    }
9399
9400    let chosen = match strategy.as_str() {
9401        "first" => candidates[0].clone(),
9402        "random" => {
9403            use rand::Rng;
9404            let idx = rand::thread_rng().gen_range(0..candidates.len());
9405            candidates[idx].clone()
9406        }
9407        "round-robin" => {
9408            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
9409            // `{role: last_picked_handle}`. Next pick = first candidate
9410            // alphabetically AFTER last_picked, wrapping around when no
9411            // candidate is greater.
9412            let cursor_path = mesh_route_cursor_path()?;
9413            let mut cursors: std::collections::BTreeMap<String, String> =
9414                read_mesh_route_cursors(&cursor_path);
9415            let last = cursors.get(role).cloned();
9416            let pick = match last {
9417                None => candidates[0].clone(),
9418                Some(last_h) => candidates
9419                    .iter()
9420                    .find(|(h, _)| h.as_str() > last_h.as_str())
9421                    .cloned()
9422                    .unwrap_or_else(|| candidates[0].clone()),
9423            };
9424            cursors.insert(role.to_string(), pick.0.clone());
9425            write_mesh_route_cursors(&cursor_path, &cursors)?;
9426            pick
9427        }
9428        _ => unreachable!(),
9429    };
9430
9431    let (chosen_handle, _chosen_did) = chosen;
9432
9433    // Body parsing follows wire send / mesh broadcast.
9434    let body_value: Value = if body_arg == "-" {
9435        use std::io::Read;
9436        let mut raw = String::new();
9437        std::io::stdin()
9438            .read_to_string(&mut raw)
9439            .with_context(|| "reading body from stdin")?;
9440        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
9441    } else if let Some(path) = body_arg.strip_prefix('@') {
9442        let raw =
9443            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
9444        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
9445    } else {
9446        Value::String(body_arg.to_string())
9447    };
9448
9449    let sk_seed = config::read_private_key()?;
9450    let card = config::read_agent_card()?;
9451    let did = card
9452        .get("did")
9453        .and_then(Value::as_str)
9454        .ok_or_else(|| anyhow!("agent-card missing did"))?
9455        .to_string();
9456    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
9457    let pk_b64 = card
9458        .get("verify_keys")
9459        .and_then(Value::as_object)
9460        .and_then(|m| m.values().next())
9461        .and_then(|v| v.get("key"))
9462        .and_then(Value::as_str)
9463        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
9464    let pk_bytes = crate::signing::b64decode(pk_b64)?;
9465
9466    let kind_id = parse_kind(kind)?;
9467    let now_iso = time::OffsetDateTime::now_utc()
9468        .format(&time::format_description::well_known::Rfc3339)
9469        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
9470
9471    let event = json!({
9472        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9473        "timestamp": now_iso,
9474        "from": did,
9475        "to": format!("did:wire:{chosen_handle}"),
9476        "type": kind,
9477        "kind": kind_id,
9478        "body": json!({
9479            "content": body_value,
9480            "routed_via": {
9481                "role": role,
9482                "strategy": strategy,
9483            },
9484        }),
9485    });
9486    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
9487        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
9488    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
9489
9490    let line = serde_json::to_vec(&signed)?;
9491    config::append_outbox_record(&chosen_handle, &line)?;
9492
9493    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
9494    if endpoints.is_empty() {
9495        bail!(
9496            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
9497        );
9498    }
9499    let start = Instant::now();
9500    let mut delivered = false;
9501    let mut last_err: Option<String> = None;
9502    let mut via_scope: Option<String> = None;
9503    for ep in &endpoints {
9504        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
9505        // route via uds_request, others via reqwest. Allows peers with
9506        // UDS-tagged endpoints in their agent-card to receive events
9507        // over the local socket instead of loopback HTTP.
9508        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
9509            Ok(_) => {
9510                delivered = true;
9511                via_scope = Some(
9512                    match ep.scope {
9513                        crate::endpoints::EndpointScope::Local => "local",
9514                        crate::endpoints::EndpointScope::Lan => "lan",
9515                        crate::endpoints::EndpointScope::Uds => "uds",
9516                        crate::endpoints::EndpointScope::Federation => "federation",
9517                    }
9518                    .to_string(),
9519                );
9520                break;
9521            }
9522            Err(e) => last_err = Some(format!("{e:#}")),
9523        }
9524    }
9525    let rtt_ms = start.elapsed().as_millis() as u64;
9526
9527    let summary = json!({
9528        "role": role,
9529        "strategy": strategy,
9530        "routed_to": chosen_handle,
9531        "event_id": event_id,
9532        "delivered": delivered,
9533        "delivered_via": via_scope,
9534        "rtt_ms": rtt_ms,
9535        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
9536        "error": last_err,
9537    });
9538
9539    if as_json {
9540        println!("{}", serde_json::to_string(&summary)?);
9541    } else if delivered {
9542        let via = via_scope.as_deref().unwrap_or("?");
9543        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
9544    } else {
9545        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
9546        bail!("delivery to `{chosen_handle}` failed: {err}");
9547    }
9548    Ok(())
9549}
9550
9551fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
9552    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
9553}
9554
9555fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
9556    std::fs::read(path)
9557        .ok()
9558        .and_then(|b| serde_json::from_slice(&b).ok())
9559        .unwrap_or_default()
9560}
9561
9562fn write_mesh_route_cursors(
9563    path: &std::path::Path,
9564    cursors: &std::collections::BTreeMap<String, String>,
9565) -> Result<()> {
9566    if let Some(parent) = path.parent() {
9567        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
9568    }
9569    let body = serde_json::to_vec_pretty(cursors)?;
9570    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
9571    Ok(())
9572}
9573
9574/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
9575/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
9576/// behind a discoverability-friendlier surface, plus cross-session
9577/// enumeration for the list path.
9578fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
9579    match action {
9580        MeshRoleAction::Set { role, json } => {
9581            validate_role_tag(&role)?;
9582            let new_profile =
9583                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
9584            if json {
9585                println!(
9586                    "{}",
9587                    serde_json::to_string(&json!({
9588                        "role": role,
9589                        "profile": new_profile,
9590                    }))?
9591                );
9592            } else {
9593                println!("self role = {role} (signed into agent-card)");
9594            }
9595        }
9596        MeshRoleAction::Get { peer, json } => {
9597            let (who, role) = match peer.as_deref() {
9598                None => {
9599                    let card = config::read_agent_card()?;
9600                    let role = card
9601                        .get("profile")
9602                        .and_then(|p| p.get("role"))
9603                        .and_then(Value::as_str)
9604                        .map(str::to_string);
9605                    let who = card
9606                        .get("did")
9607                        .and_then(Value::as_str)
9608                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
9609                        .unwrap_or_else(|| "self".to_string());
9610                    (who, role)
9611                }
9612                Some(handle) => {
9613                    let bare = crate::agent_card::bare_handle(handle).to_string();
9614                    let trust = config::read_trust()?;
9615                    let role = trust
9616                        .get("agents")
9617                        .and_then(|a| a.get(&bare))
9618                        .and_then(|a| a.get("card"))
9619                        .and_then(|c| c.get("profile"))
9620                        .and_then(|p| p.get("role"))
9621                        .and_then(Value::as_str)
9622                        .map(str::to_string);
9623                    (bare, role)
9624                }
9625            };
9626            if json {
9627                println!(
9628                    "{}",
9629                    serde_json::to_string(&json!({
9630                        "handle": who,
9631                        "role": role,
9632                    }))?
9633                );
9634            } else {
9635                match role {
9636                    Some(r) => println!("{who}: {r}"),
9637                    None => println!("{who}: (unset)"),
9638                }
9639            }
9640        }
9641        MeshRoleAction::List { json } => {
9642            let mut self_did: Option<String> = None;
9643            if let Ok(card) = config::read_agent_card() {
9644                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
9645            }
9646            let sessions = crate::session::list_sessions()?;
9647            let mut rows: Vec<Value> = Vec::new();
9648            for s in &sessions {
9649                let card_path = s
9650                    .home_dir
9651                    .join("config")
9652                    .join("wire")
9653                    .join("agent-card.json");
9654                let role = std::fs::read(&card_path)
9655                    .ok()
9656                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
9657                    .and_then(|c| {
9658                        c.get("profile")
9659                            .and_then(|p| p.get("role"))
9660                            .and_then(Value::as_str)
9661                            .map(str::to_string)
9662                    });
9663                let is_self = match (&self_did, &s.did) {
9664                    (Some(a), Some(b)) => a == b,
9665                    _ => false,
9666                };
9667                rows.push(json!({
9668                    "name": s.name,
9669                    "handle": s.handle,
9670                    "role": role,
9671                    "self": is_self,
9672                }));
9673            }
9674            rows.sort_by(|a, b| {
9675                a["name"]
9676                    .as_str()
9677                    .unwrap_or("")
9678                    .cmp(b["name"].as_str().unwrap_or(""))
9679            });
9680            if json {
9681                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
9682            } else if rows.is_empty() {
9683                println!("no sister sessions on this machine.");
9684            } else {
9685                println!("SISTER ROLES (this machine):");
9686                for r in &rows {
9687                    let name = r["name"].as_str().unwrap_or("?");
9688                    let role = r["role"].as_str().unwrap_or("(unset)");
9689                    let marker = if r["self"].as_bool().unwrap_or(false) {
9690                        "    ← you"
9691                    } else {
9692                        ""
9693                    };
9694                    println!("  {name:<24} {role}{marker}");
9695                }
9696            }
9697        }
9698        MeshRoleAction::Clear { json } => {
9699            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
9700            if json {
9701                println!(
9702                    "{}",
9703                    serde_json::to_string(&json!({
9704                        "cleared": true,
9705                        "profile": new_profile,
9706                    }))?
9707                );
9708            } else {
9709                println!("self role cleared");
9710            }
9711        }
9712    }
9713    Ok(())
9714}
9715
9716/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
9717/// No vocabulary check — operators choose the taxonomy (planner /
9718/// reviewer / dispatcher / your-custom-tag). The constraint is purely
9719/// to keep the tag safe for filenames / URLs / shell args.
9720fn validate_role_tag(role: &str) -> Result<()> {
9721    if role.is_empty() {
9722        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
9723    }
9724    if role.len() > 32 {
9725        bail!("role too long ({} chars; max 32)", role.len());
9726    }
9727    for c in role.chars() {
9728        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
9729            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
9730        }
9731    }
9732    Ok(())
9733}
9734
9735/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
9736///
9737/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
9738/// canonical event including `to:`, so per-recipient signing is required;
9739/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
9740/// Per-recipient pushes happen in parallel via `std::thread::scope` so
9741/// broadcast-to-5 takes ~1× RTT, not 5×.
9742///
9743/// **Scope filter.** Default `local` — only peers reachable via a same-
9744/// machine local relay (priority-1 endpoint has `scope=local`). This is
9745/// the lowest-blast-radius default: local-only broadcasts cannot escape
9746/// the operator's machine. `federation` flips to public-relay peers
9747/// only; `both` removes the filter.
9748///
9749/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
9750/// resolution, never trust["agents"] expansion. Closes #8-class
9751/// phonebook-scrape vectors by construction: an attacker pinning a
9752/// hostile handle has to first be pinned bidirectionally by the
9753/// operator, and even then `--exclude` is the loud opt-out.
9754fn cmd_mesh_broadcast(
9755    kind: &str,
9756    scope_str: &str,
9757    exclude: &[String],
9758    _noreply: bool,
9759    body_arg: &str,
9760    as_json: bool,
9761) -> Result<()> {
9762    use std::time::Instant;
9763
9764    if !config::is_initialized()? {
9765        bail!("not initialized — run `wire init <handle>` first");
9766    }
9767
9768    let scope = match scope_str {
9769        "local" => crate::endpoints::EndpointScope::Local,
9770        "federation" => crate::endpoints::EndpointScope::Federation,
9771        "both" => {
9772            // Sentinel: we don't actually have a `Both` variant on the
9773            // scope enum; use a tri-state below. Treat as Local for the
9774            // typed match and special-case it via the bool below.
9775            crate::endpoints::EndpointScope::Local
9776        }
9777        other => bail!("unknown scope `{other}` — use local | federation | both"),
9778    };
9779    let any_scope = scope_str == "both";
9780
9781    let state = config::read_relay_state()?;
9782    let peers = state["peers"].as_object().cloned().unwrap_or_default();
9783    if peers.is_empty() {
9784        bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
9785    }
9786
9787    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
9788
9789    // Walk the pinned-peer set, filter by scope + exclude. Keep the
9790    // priority-ordered endpoint list for each match so the push can
9791    // try local first then fall through to federation (when scope=both).
9792    struct Target {
9793        handle: String,
9794        endpoints: Vec<crate::endpoints::Endpoint>,
9795    }
9796    let mut targets: Vec<Target> = Vec::new();
9797    let mut skipped_wrong_scope: Vec<String> = Vec::new();
9798    let mut skipped_excluded: Vec<String> = Vec::new();
9799    for handle in peers.keys() {
9800        if exclude_set.contains(handle.as_str()) {
9801            skipped_excluded.push(handle.clone());
9802            continue;
9803        }
9804        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
9805        let filtered: Vec<crate::endpoints::Endpoint> = ordered
9806            .into_iter()
9807            .filter(|ep| any_scope || ep.scope == scope)
9808            .collect();
9809        if filtered.is_empty() {
9810            skipped_wrong_scope.push(handle.clone());
9811            continue;
9812        }
9813        targets.push(Target {
9814            handle: handle.clone(),
9815            endpoints: filtered,
9816        });
9817    }
9818
9819    if targets.is_empty() {
9820        bail!(
9821            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
9822            skipped_excluded.len(),
9823            skipped_wrong_scope.len()
9824        );
9825    }
9826
9827    // Load signing material once; share across per-peer signatures.
9828    let sk_seed = config::read_private_key()?;
9829    let card = config::read_agent_card()?;
9830    let did = card
9831        .get("did")
9832        .and_then(Value::as_str)
9833        .ok_or_else(|| anyhow!("agent-card missing did"))?
9834        .to_string();
9835    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
9836    let pk_b64 = card
9837        .get("verify_keys")
9838        .and_then(Value::as_object)
9839        .and_then(|m| m.values().next())
9840        .and_then(|v| v.get("key"))
9841        .and_then(Value::as_str)
9842        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
9843    let pk_bytes = crate::signing::b64decode(pk_b64)?;
9844
9845    let body_value: Value = if body_arg == "-" {
9846        use std::io::Read;
9847        let mut raw = String::new();
9848        std::io::stdin()
9849            .read_to_string(&mut raw)
9850            .with_context(|| "reading body from stdin")?;
9851        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
9852    } else if let Some(path) = body_arg.strip_prefix('@') {
9853        let raw =
9854            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
9855        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
9856    } else {
9857        Value::String(body_arg.to_string())
9858    };
9859
9860    let kind_id = parse_kind(kind)?;
9861    let now_iso = time::OffsetDateTime::now_utc()
9862        .format(&time::format_description::well_known::Rfc3339)
9863        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
9864
9865    let broadcast_id = generate_broadcast_id();
9866    let target_count = targets.len();
9867
9868    // Build + sign every event up front (sequential, ~50µs/sig). Then
9869    // queue to outbox + push to relay in parallel per-peer. Returns
9870    // a per-peer outcome we then sort by handle for deterministic output.
9871    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
9872        Vec::with_capacity(targets.len());
9873    for t in &targets {
9874        let body = json!({
9875            "content": body_value,
9876            "broadcast_id": broadcast_id,
9877            "broadcast_target_count": target_count,
9878        });
9879        let event = json!({
9880            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9881            "timestamp": now_iso,
9882            "from": did,
9883            "to": format!("did:wire:{}", t.handle),
9884            "type": kind,
9885            "kind": kind_id,
9886            "body": body,
9887        });
9888        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
9889            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
9890        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
9891        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
9892    }
9893
9894    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
9895    // holds a per-path mutex; writes are independent across handles but
9896    // we want the side-effect ordering deterministic).
9897    for (peer, _, signed, _) in &signed_per_peer {
9898        let line = serde_json::to_vec(signed)?;
9899        config::append_outbox_record(peer, &line)?;
9900    }
9901
9902    // Per-peer parallel push. Each thread tries the priority-ordered
9903    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
9904    // error_opt) over a channel.
9905    use std::sync::mpsc;
9906    let (tx, rx) = mpsc::channel::<Value>();
9907    std::thread::scope(|s| {
9908        for (peer, endpoints, signed, event_id) in &signed_per_peer {
9909            let tx = tx.clone();
9910            let peer = peer.clone();
9911            let event_id = event_id.clone();
9912            let endpoints = endpoints.clone();
9913            let signed = signed.clone();
9914            s.spawn(move || {
9915                let start = Instant::now();
9916                let mut delivered = false;
9917                let mut last_err: Option<String> = None;
9918                let mut delivered_via: Option<String> = None;
9919                for ep in &endpoints {
9920                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
9921                    // uds_request, else reqwest). Same as cmd_send's
9922                    // single-peer path above; this is the parallel
9923                    // multi-peer broadcast loop.
9924                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
9925                        Ok(_) => {
9926                            delivered = true;
9927                            delivered_via = Some(
9928                                match ep.scope {
9929                                    crate::endpoints::EndpointScope::Local => "local",
9930                                    crate::endpoints::EndpointScope::Lan => "lan",
9931                                    crate::endpoints::EndpointScope::Uds => "uds",
9932                                    crate::endpoints::EndpointScope::Federation => "federation",
9933                                }
9934                                .to_string(),
9935                            );
9936                            break;
9937                        }
9938                        Err(e) => last_err = Some(format!("{e:#}")),
9939                    }
9940                }
9941                let rtt_ms = start.elapsed().as_millis() as u64;
9942                let _ = tx.send(json!({
9943                    "peer": peer,
9944                    "event_id": event_id,
9945                    "delivered": delivered,
9946                    "delivered_via": delivered_via,
9947                    "rtt_ms": rtt_ms,
9948                    "error": last_err,
9949                }));
9950            });
9951        }
9952    });
9953    drop(tx);
9954
9955    let mut results: Vec<Value> = rx.iter().collect();
9956    results.sort_by(|a, b| {
9957        a["peer"]
9958            .as_str()
9959            .unwrap_or("")
9960            .cmp(b["peer"].as_str().unwrap_or(""))
9961    });
9962
9963    let delivered = results
9964        .iter()
9965        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
9966        .count();
9967    let failed = results.len() - delivered;
9968
9969    let summary = json!({
9970        "broadcast_id": broadcast_id,
9971        "kind": kind,
9972        "scope": scope_str,
9973        "target_count": target_count,
9974        "delivered": delivered,
9975        "failed": failed,
9976        "skipped_excluded": skipped_excluded,
9977        "skipped_wrong_scope": skipped_wrong_scope,
9978        "results": results,
9979    });
9980
9981    if as_json {
9982        println!("{}", serde_json::to_string(&summary)?);
9983        return Ok(());
9984    }
9985
9986    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
9987    for r in &results {
9988        let peer = r["peer"].as_str().unwrap_or("?");
9989        let delivered = r["delivered"].as_bool().unwrap_or(false);
9990        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
9991        let via = r["delivered_via"].as_str().unwrap_or("");
9992        if delivered {
9993            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
9994        } else {
9995            let err = r["error"].as_str().unwrap_or("?");
9996            println!("  {peer:<24} ✗ failed — {err}");
9997        }
9998    }
9999    if !skipped_excluded.is_empty() {
10000        println!("  excluded: {}", skipped_excluded.join(", "));
10001    }
10002    if !skipped_wrong_scope.is_empty() {
10003        println!(
10004            "  skipped (wrong scope): {}",
10005            skipped_wrong_scope.join(", ")
10006        );
10007    }
10008    println!("broadcast_id: {broadcast_id}");
10009    Ok(())
10010}
10011
10012/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
10013/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
10014/// correlate by string equality, the shape is for human readability.
10015fn generate_broadcast_id() -> String {
10016    use rand::RngCore;
10017    let mut buf = [0u8; 16];
10018    rand::thread_rng().fill_bytes(&mut buf);
10019    let h = hex::encode(buf);
10020    format!(
10021        "{}-{}-{}-{}-{}",
10022        &h[0..8],
10023        &h[8..12],
10024        &h[12..16],
10025        &h[16..20],
10026        &h[20..32],
10027    )
10028}
10029
10030fn cmd_session(cmd: SessionCommand) -> Result<()> {
10031    match cmd {
10032        SessionCommand::New {
10033            name,
10034            relay,
10035            with_local,
10036            local_relay,
10037            with_lan,
10038            lan_relay,
10039            with_uds,
10040            uds_socket,
10041            no_daemon,
10042            local_only,
10043            json,
10044        } => cmd_session_new(
10045            name.as_deref(),
10046            &relay,
10047            with_local,
10048            &local_relay,
10049            with_lan,
10050            lan_relay.as_deref(),
10051            with_uds,
10052            uds_socket.as_deref(),
10053            no_daemon,
10054            local_only,
10055            json,
10056        ),
10057        SessionCommand::List { json } => cmd_session_list(json),
10058        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
10059        SessionCommand::PairAllLocal {
10060            settle_secs,
10061            federation_relay,
10062            json,
10063        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
10064        SessionCommand::MeshStatus { stale_secs, json } => {
10065            cmd_session_mesh_status(stale_secs, json)
10066        }
10067        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
10068        SessionCommand::Current { json } => cmd_session_current(json),
10069        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
10070        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
10071    }
10072}
10073
10074fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
10075    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10076    let cwd_str = crate::session::normalize_cwd_key(&cwd);
10077
10078    let resolved_name = match name_arg {
10079        Some(n) => crate::session::sanitize_name(n),
10080        None => crate::session::sanitize_name(
10081            cwd.file_name()
10082                .and_then(|s| s.to_str())
10083                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
10084        ),
10085    };
10086
10087    let session_home = crate::session::session_dir(&resolved_name)?;
10088    if !session_home.exists() {
10089        bail!(
10090            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
10091            session_home.display()
10092        );
10093    }
10094
10095    let prior = crate::session::read_registry()
10096        .ok()
10097        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
10098    if prior.as_deref() == Some(resolved_name.as_str()) {
10099        if json {
10100            println!(
10101                "{}",
10102                serde_json::to_string(&json!({
10103                    "cwd": cwd_str,
10104                    "session": resolved_name,
10105                    "changed": false,
10106                }))?
10107            );
10108        } else {
10109            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
10110        }
10111        return Ok(());
10112    }
10113    if let Some(prior_name) = &prior {
10114        eprintln!(
10115            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
10116        );
10117    }
10118
10119    crate::session::update_registry(|reg| {
10120        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
10121        Ok(())
10122    })?;
10123
10124    if json {
10125        println!(
10126            "{}",
10127            serde_json::to_string(&json!({
10128                "cwd": cwd_str,
10129                "session": resolved_name,
10130                "changed": true,
10131                "previous": prior,
10132            }))?
10133        );
10134    } else {
10135        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
10136        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
10137    }
10138    Ok(())
10139}
10140
10141fn resolve_session_name(name: Option<&str>) -> Result<String> {
10142    if let Some(n) = name {
10143        return Ok(crate::session::sanitize_name(n));
10144    }
10145    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10146    let registry = crate::session::read_registry().unwrap_or_default();
10147    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
10148}
10149
10150#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
10151// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
10152// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
10153fn cmd_session_new(
10154    name_arg: Option<&str>,
10155    relay: &str,
10156    with_local: bool,
10157    local_relay: &str,
10158    with_lan: bool,
10159    lan_relay: Option<&str>,
10160    with_uds: bool,
10161    uds_socket: Option<&std::path::Path>,
10162    no_daemon: bool,
10163    local_only: bool,
10164    as_json: bool,
10165) -> Result<()> {
10166    // v0.6.6: --local-only implies --with-local (a federation-free
10167    // session with no endpoints at all would be unaddressable).
10168    let with_local = with_local || local_only;
10169    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
10170    if with_lan && lan_relay.is_none() {
10171        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
10172    }
10173    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
10174    if with_uds && uds_socket.is_none() {
10175        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
10176    }
10177    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10178    let mut registry = crate::session::read_registry().unwrap_or_default();
10179    let name = match name_arg {
10180        Some(n) => crate::session::sanitize_name(n),
10181        None => crate::session::derive_name_from_cwd(&cwd, &registry),
10182    };
10183    let session_home = crate::session::session_dir(&name)?;
10184
10185    let already_exists = session_home.exists()
10186        && session_home
10187            .join("config")
10188            .join("wire")
10189            .join("agent-card.json")
10190            .exists();
10191    if already_exists {
10192        // Idempotent: re-register the cwd (if not already), refresh the
10193        // daemon if requested, surface the env-var line. Do not re-init
10194        // identity — that would clobber the keypair.
10195        registry
10196            .by_cwd
10197            .insert(cwd.to_string_lossy().into_owned(), name.clone());
10198        crate::session::write_registry(&registry)?;
10199        let info = render_session_info(&name, &session_home, &cwd)?;
10200        emit_session_new_result(&info, "already_exists", as_json)?;
10201        if !no_daemon {
10202            ensure_session_daemon(&session_home)?;
10203        }
10204        return Ok(());
10205    }
10206
10207    std::fs::create_dir_all(&session_home)
10208        .with_context(|| format!("creating session dir {session_home:?}"))?;
10209
10210    // Phase 1: init identity in the new session's WIRE_HOME. For
10211    // federation-bound sessions we pass `--relay` so init also
10212    // allocates a federation slot in the same step; for `--local-only`
10213    // we run init with `--offline` (v0.9 requires explicit reachability
10214    // acknowledgement at init time) because cmd_session_new allocates
10215    // the local-relay slot itself via try_allocate_local_slot below.
10216    // The session is not actually slotless — init is just deferred to
10217    // the subsequent allocation pass.
10218    let init_args: Vec<&str> = if local_only {
10219        vec!["init", &name, "--offline"]
10220    } else {
10221        vec!["init", &name, "--relay", relay]
10222    };
10223    let init_status = run_wire_with_home(&session_home, &init_args)?;
10224    if !init_status.success() {
10225        let how = if local_only {
10226            format!("`wire init {name}` (local-only)")
10227        } else {
10228            format!("`wire init {name} --relay {relay}`")
10229        };
10230        bail!("{how} failed inside session dir {session_home:?}");
10231    }
10232
10233    // Phase 2: claim the handle on the federation relay — SKIPPED when
10234    // `--local-only`. Local-only sessions have no public address and
10235    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
10236    // tries to publish them.
10237    let effective_handle = if local_only {
10238        name.clone()
10239    } else {
10240        let mut claim_attempt = 0u32;
10241        let mut effective = name.clone();
10242        loop {
10243            claim_attempt += 1;
10244            let status =
10245                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
10246            if status.success() {
10247                break;
10248            }
10249            if claim_attempt >= 5 {
10250                bail!(
10251                    "5 failed attempts to claim a handle on {relay} for session {name}. \
10252                     Try `wire session destroy {name} --force` and re-run with a different name, \
10253                     or use `--local-only` if you don't need a federation address."
10254                );
10255            }
10256            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
10257            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
10258            let token = suffix
10259                .rsplit('-')
10260                .next()
10261                .filter(|t| t.len() == 4)
10262                .map(str::to_string)
10263                .unwrap_or_else(|| format!("{claim_attempt}"));
10264            effective = format!("{name}-{token}");
10265        }
10266        effective
10267    };
10268
10269    // Persist the cwd → name mapping NOW so subsequent invocations from
10270    // this directory short-circuit to the "already_exists" branch.
10271    registry
10272        .by_cwd
10273        .insert(cwd.to_string_lossy().into_owned(), name.clone());
10274    crate::session::write_registry(&registry)?;
10275
10276    // v0.5.17: --with-local probes the local relay and, if it's
10277    // reachable, allocates a second slot there. The session's
10278    // relay_state.json grows a `self.endpoints[]` array carrying both
10279    // endpoints; routing layer (cmd_push) prefers local for sister-
10280    // session peers that also have a local slot.
10281    //
10282    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
10283    // allocation; a failed probe leaves the session with no endpoints,
10284    // which we surface as a hard error (the operator asked for local-
10285    // only but the local relay isn't running — fix that first).
10286    if with_local {
10287        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
10288        if local_only {
10289            // Verify the local slot landed. If the local relay was
10290            // unreachable, the session would be unreachable from
10291            // anywhere — surface that loudly instead of leaving an
10292            // orphaned session dir.
10293            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
10294            let state: Value = std::fs::read(&relay_state_path)
10295                .ok()
10296                .and_then(|b| serde_json::from_slice(&b).ok())
10297                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
10298            let endpoints = crate::endpoints::self_endpoints(&state);
10299            let has_local = endpoints
10300                .iter()
10301                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
10302            if !has_local {
10303                bail!(
10304                    "--local-only requested but local-relay probe at {local_relay} failed — \
10305                     ensure the local relay is running (`wire service install --local-relay`), \
10306                     then re-run `wire session new {name} --local-only`."
10307                );
10308            }
10309        }
10310    }
10311
10312    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
10313    // Sits AFTER local because cmd_session_new's flow is "add endpoints
10314    // alongside existing self.endpoints[]" — order independent post-init.
10315    if with_lan && let Some(lan_url) = lan_relay {
10316        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
10317    }
10318    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
10319    if with_uds && let Some(socket_path) = uds_socket {
10320        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
10321    }
10322
10323    if !no_daemon {
10324        ensure_session_daemon(&session_home)?;
10325    }
10326
10327    let info = render_session_info(&name, &session_home, &cwd)?;
10328    emit_session_new_result(&info, "created", as_json)
10329}
10330
10331/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
10332/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
10333/// sister sessions can route over the local socket instead of loopback
10334/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
10335/// alpha.17 — reqwest has no UDS support.
10336///
10337/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
10338/// and try_allocate_lan_slot semantics): session stays at existing
10339/// endpoint mix, operator can retry once the UDS relay is up.
10340#[cfg(unix)]
10341fn try_allocate_uds_slot(
10342    session_home: &std::path::Path,
10343    handle: &str,
10344    uds_socket: &std::path::Path,
10345) {
10346    // Probe healthz first so we fail fast with a clear stderr if the
10347    // socket doesn't exist OR isn't a wire relay.
10348    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
10349        Ok((200, _)) => true,
10350        Ok((status, body)) => {
10351            eprintln!(
10352                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
10353                String::from_utf8_lossy(&body)
10354            );
10355            return;
10356        }
10357        Err(e) => {
10358            eprintln!(
10359                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
10360                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
10361            );
10362            return;
10363        }
10364    };
10365    if !healthz {
10366        return;
10367    }
10368
10369    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
10370    let alloc_body = serde_json::json!({"handle": handle}).to_string();
10371    let (status, body) = match crate::relay_client::uds_request(
10372        uds_socket,
10373        "POST",
10374        "/v1/slot/allocate",
10375        &[("Content-Type", "application/json")],
10376        alloc_body.as_bytes(),
10377    ) {
10378        Ok(r) => r,
10379        Err(e) => {
10380            eprintln!(
10381                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
10382            );
10383            return;
10384        }
10385    };
10386    if status >= 300 {
10387        eprintln!(
10388            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
10389            String::from_utf8_lossy(&body)
10390        );
10391        return;
10392    }
10393    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
10394        Ok(a) => a,
10395        Err(e) => {
10396            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
10397            return;
10398        }
10399    };
10400
10401    let state_path = session_home.join("config").join("wire").join("relay.json");
10402    let mut state: serde_json::Value = std::fs::read(&state_path)
10403        .ok()
10404        .and_then(|b| serde_json::from_slice(&b).ok())
10405        .unwrap_or_else(|| serde_json::json!({}));
10406
10407    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
10408        .get("self")
10409        .and_then(|s| s.get("endpoints"))
10410        .and_then(|e| e.as_array())
10411        .map(|arr| {
10412            arr.iter()
10413                .filter_map(|v| {
10414                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
10415                })
10416                .collect()
10417        })
10418        .unwrap_or_default();
10419    endpoints.push(crate::endpoints::Endpoint::uds(
10420        format!("unix://{}", uds_socket.display()),
10421        alloc.slot_id.clone(),
10422        alloc.slot_token.clone(),
10423    ));
10424
10425    let self_obj = state
10426        .as_object_mut()
10427        .expect("relay_state root is an object")
10428        .entry("self")
10429        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
10430    if !self_obj.is_object() {
10431        *self_obj = serde_json::Value::Object(serde_json::Map::new());
10432    }
10433    if let Some(obj) = self_obj.as_object_mut() {
10434        obj.insert(
10435            "endpoints".into(),
10436            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
10437        );
10438    }
10439    if let Err(e) = std::fs::write(
10440        &state_path,
10441        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
10442    ) {
10443        eprintln!("wire session new: failed to write {state_path:?}: {e}");
10444        return;
10445    }
10446    eprintln!(
10447        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
10448        uds_socket.display(),
10449        alloc.slot_id
10450    );
10451}
10452
10453#[cfg(not(unix))]
10454fn try_allocate_uds_slot(
10455    _session_home: &std::path::Path,
10456    _handle: &str,
10457    _uds_socket: &std::path::Path,
10458) {
10459    eprintln!(
10460        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
10461    );
10462}
10463
10464/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
10465/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
10466/// pulling the agent-card see a third reachable address.
10467///
10468/// Mirrors `try_allocate_local_slot` but tags the endpoint
10469/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
10470/// session stays at whatever endpoint mix it already had — operators
10471/// can retry with `wire session new --with-lan --lan-relay <url>` once
10472/// the LAN relay is up.
10473fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
10474    let probe = match crate::relay_client::build_blocking_client(Some(
10475        std::time::Duration::from_millis(500),
10476    )) {
10477        Ok(c) => c,
10478        Err(e) => {
10479            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
10480            return;
10481        }
10482    };
10483    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
10484    match probe.get(&healthz_url).send() {
10485        Ok(resp) if resp.status().is_success() => {}
10486        Ok(resp) => {
10487            eprintln!(
10488                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
10489                resp.status()
10490            );
10491            return;
10492        }
10493        Err(e) => {
10494            eprintln!(
10495                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
10496                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
10497                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
10498            );
10499            return;
10500        }
10501    };
10502
10503    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
10504    let alloc = match lan_client.allocate_slot(Some(handle)) {
10505        Ok(a) => a,
10506        Err(e) => {
10507            eprintln!(
10508                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
10509            );
10510            return;
10511        }
10512    };
10513
10514    let state_path = session_home.join("config").join("wire").join("relay.json");
10515    let mut state: serde_json::Value = std::fs::read(&state_path)
10516        .ok()
10517        .and_then(|b| serde_json::from_slice(&b).ok())
10518        .unwrap_or_else(|| serde_json::json!({}));
10519
10520    // Read existing endpoints array and add the LAN one. Preserve
10521    // federation / local entries already there.
10522    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
10523        .get("self")
10524        .and_then(|s| s.get("endpoints"))
10525        .and_then(|e| e.as_array())
10526        .map(|arr| {
10527            arr.iter()
10528                .filter_map(|v| {
10529                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
10530                })
10531                .collect()
10532        })
10533        .unwrap_or_default();
10534    endpoints.push(crate::endpoints::Endpoint::lan(
10535        lan_relay.trim_end_matches('/').to_string(),
10536        alloc.slot_id.clone(),
10537        alloc.slot_token.clone(),
10538    ));
10539
10540    let self_obj = state
10541        .as_object_mut()
10542        .expect("relay_state root is an object")
10543        .entry("self")
10544        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
10545    if !self_obj.is_object() {
10546        *self_obj = serde_json::Value::Object(serde_json::Map::new());
10547    }
10548    if let Some(obj) = self_obj.as_object_mut() {
10549        obj.insert(
10550            "endpoints".into(),
10551            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
10552        );
10553    }
10554    if let Err(e) = std::fs::write(
10555        &state_path,
10556        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
10557    ) {
10558        eprintln!("wire session new: failed to write {state_path:?}: {e}");
10559        return;
10560    }
10561    eprintln!(
10562        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
10563        alloc.slot_id
10564    );
10565}
10566
10567/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
10568/// a short timeout, allocate a slot there and update the session's
10569/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
10570///
10571/// Failure to reach the local relay is NOT fatal — the session stays
10572/// federation-only. Logs to stderr on failure so operators can tell
10573/// the local relay isn't running, but doesn't abort the bootstrap.
10574fn try_allocate_local_slot(
10575    session_home: &std::path::Path,
10576    handle: &str,
10577    _federation_relay: &str,
10578    local_relay: &str,
10579) {
10580    // Probe healthz with a tight timeout. Use a fresh client (don't
10581    // share the daemon-wide one) so the timeout is local to this call.
10582    let probe = match crate::relay_client::build_blocking_client(Some(
10583        std::time::Duration::from_millis(500),
10584    )) {
10585        Ok(c) => c,
10586        Err(e) => {
10587            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
10588            return;
10589        }
10590    };
10591    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
10592    match probe.get(&healthz_url).send() {
10593        Ok(resp) if resp.status().is_success() => {}
10594        Ok(resp) => {
10595            eprintln!(
10596                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
10597                resp.status()
10598            );
10599            return;
10600        }
10601        Err(e) => {
10602            eprintln!(
10603                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
10604                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
10605                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
10606            );
10607            return;
10608        }
10609    };
10610
10611    // Allocate a slot on the local relay.
10612    let local_client = crate::relay_client::RelayClient::new(local_relay);
10613    let alloc = match local_client.allocate_slot(Some(handle)) {
10614        Ok(a) => a,
10615        Err(e) => {
10616            eprintln!(
10617                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
10618            );
10619            return;
10620        }
10621    };
10622
10623    // Merge into the session's relay.json. We invoke wire via
10624    // run_wire_with_home for federation calls (subprocess isolation),
10625    // but relay.json is a simple file we can edit directly
10626    // — and need to, because there's no `wire bind-relay --add-local`
10627    // command yet (could add later; out of scope for v0.5.17 MVP).
10628    //
10629    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
10630    // does not exist (canonical filename is `relay.json` per
10631    // `config::relay_state_path`). The mis-named file write succeeded
10632    // but landed in a sibling path nothing else reads. Every
10633    // `wire session new --with-local` invocation silently degraded to
10634    // federation-only despite the "local slot allocated" stderr line.
10635    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
10636    // session's relay.json — it had only the federation endpoint.
10637    let state_path = session_home.join("config").join("wire").join("relay.json");
10638    let mut state: serde_json::Value = std::fs::read(&state_path)
10639        .ok()
10640        .and_then(|b| serde_json::from_slice(&b).ok())
10641        .unwrap_or_else(|| serde_json::json!({}));
10642    // Read the existing federation self info (already written by
10643    // `wire init` + `wire bind-relay` path during session bootstrap).
10644    let fed_endpoint = state.get("self").and_then(|s| {
10645        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
10646        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
10647        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
10648        Some(crate::endpoints::Endpoint::federation(
10649            url.to_string(),
10650            slot_id.to_string(),
10651            slot_token.to_string(),
10652        ))
10653    });
10654
10655    let local_endpoint = crate::endpoints::Endpoint::local(
10656        local_relay.trim_end_matches('/').to_string(),
10657        alloc.slot_id.clone(),
10658        alloc.slot_token.clone(),
10659    );
10660
10661    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
10662    if let Some(f) = fed_endpoint.clone() {
10663        endpoints.push(f);
10664    }
10665    endpoints.push(local_endpoint);
10666
10667    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
10668    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
10669    // `slot_token` fields must point at the LOCAL endpoint so callers
10670    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
10671    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
10672    // still find a valid slot. Pre-v0.6.6 this branch wrote
10673    // `relay_url: federation_relay` with no slot_id, which produced
10674    // half-populated self state that broke pair-accept on local-only
10675    // sessions.
10676    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
10677        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
10678        None => (
10679            local_relay.trim_end_matches('/').to_string(),
10680            alloc.slot_id.clone(),
10681            alloc.slot_token.clone(),
10682        ),
10683    };
10684    let self_obj = state
10685        .as_object_mut()
10686        .expect("relay_state root is an object")
10687        .entry("self")
10688        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
10689    // The entry might be Value::Null (left by read_relay_state's default
10690    // template) — replace with an object before mutating.
10691    if !self_obj.is_object() {
10692        *self_obj = serde_json::Value::Object(serde_json::Map::new());
10693    }
10694    if let Some(obj) = self_obj.as_object_mut() {
10695        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
10696        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
10697        obj.insert(
10698            "slot_token".into(),
10699            serde_json::Value::String(legacy_slot_token),
10700        );
10701        obj.insert(
10702            "endpoints".into(),
10703            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
10704        );
10705    }
10706
10707    if let Err(e) = std::fs::write(
10708        &state_path,
10709        serde_json::to_vec_pretty(&state).unwrap_or_default(),
10710    ) {
10711        eprintln!(
10712            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
10713        );
10714        return;
10715    }
10716    eprintln!(
10717        "wire session new: local slot allocated on {local_relay} (slot_id={})",
10718        alloc.slot_id
10719    );
10720}
10721
10722fn render_session_info(
10723    name: &str,
10724    session_home: &std::path::Path,
10725    cwd: &std::path::Path,
10726) -> Result<serde_json::Value> {
10727    let card_path = session_home
10728        .join("config")
10729        .join("wire")
10730        .join("agent-card.json");
10731    let (did, handle) = if card_path.exists() {
10732        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
10733        let did = card
10734            .get("did")
10735            .and_then(Value::as_str)
10736            .unwrap_or("")
10737            .to_string();
10738        let handle = card
10739            .get("handle")
10740            .and_then(Value::as_str)
10741            .map(str::to_string)
10742            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
10743        (did, handle)
10744    } else {
10745        (String::new(), String::new())
10746    };
10747    Ok(json!({
10748        "name": name,
10749        "home_dir": session_home.to_string_lossy(),
10750        "cwd": cwd.to_string_lossy(),
10751        "did": did,
10752        "handle": handle,
10753        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
10754    }))
10755}
10756
10757fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
10758    if as_json {
10759        let mut obj = info.clone();
10760        obj["status"] = json!(status);
10761        println!("{}", serde_json::to_string(&obj)?);
10762    } else {
10763        let name = info["name"].as_str().unwrap_or("?");
10764        let handle = info["handle"].as_str().unwrap_or("?");
10765        let home = info["home_dir"].as_str().unwrap_or("?");
10766        let did = info["did"].as_str().unwrap_or("?");
10767        let export = info["export"].as_str().unwrap_or("?");
10768        let prefix = if status == "already_exists" {
10769            "session already exists (re-registered cwd)"
10770        } else {
10771            "session created"
10772        };
10773        println!(
10774            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
10775        );
10776    }
10777    Ok(())
10778}
10779
10780fn run_wire_with_home(
10781    session_home: &std::path::Path,
10782    args: &[&str],
10783) -> Result<std::process::ExitStatus> {
10784    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
10785    let status = std::process::Command::new(&bin)
10786        .env("WIRE_HOME", session_home)
10787        .env_remove("RUST_LOG")
10788        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
10789        // We already own the session; nested init would clobber state.
10790        .env("WIRE_AUTO_INIT", "0")
10791        .args(args)
10792        .status()
10793        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10794    Ok(status)
10795}
10796
10797/// v0.7.0-alpha.2: idempotent per-cwd session creation.
10798///
10799/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
10800/// registered session for the current cwd — including via parent-walk —
10801/// this creates one inline so every Claude tab in a fresh project gets
10802/// its own wire identity rather than collapsing onto the machine-wide
10803/// default. Without this, multiple Claudes in unwired cwds all render
10804/// the same character (the default identity's character), defeating the
10805/// "every session looks different" promise.
10806///
10807/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
10808/// `run_wire_with_home` subprocess context).
10809///
10810/// Best-effort: any failure (no home dir, name collision pathology,
10811/// `wire init` subprocess crash) is logged to stderr and we fall back
10812/// to default identity. Must not block MCP startup.
10813///
10814/// MUST be called BEFORE worker thread spawn (env::set_var safety).
10815pub fn maybe_auto_init_cwd_session(label: &str) {
10816    if std::env::var("WIRE_HOME").is_ok() {
10817        return; // explicit override OR auto-detect already won
10818    }
10819    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
10820        return; // operator opt-out
10821    }
10822    let cwd = match std::env::current_dir() {
10823        Ok(c) => c,
10824        Err(_) => return,
10825    };
10826    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
10827    // already runs but we want to be robust to ordering).
10828    if crate::session::detect_session_wire_home(&cwd).is_some() {
10829        return;
10830    }
10831
10832    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
10833    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
10834    // Two different cwds with the same basename (e.g. /a/projx +
10835    // /b/projx) used to race outside the lock: both read empty
10836    // registry, both derived name="projx", per-name lock didn't help
10837    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
10838    //
10839    // Single lock serializes ALL auto-init across the sessions_root.
10840    // Inside the lock: re-read registry, derive_name_from_cwd which
10841    // adds path-hash suffix when basename is occupied by another cwd
10842    // already committed to the registry. Different cwds get DIFFERENT
10843    // names guaranteed.
10844    //
10845    // Cost: parallel auto-inits in different cwds now serialize
10846    // (~hundreds of ms each when local relay is up). Acceptable —
10847    // auto-init runs once per cwd per machine; not a hot path.
10848    use fs2::FileExt;
10849    let sessions_root = match crate::session::sessions_root() {
10850        Ok(r) => r,
10851        Err(_) => return,
10852    };
10853    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
10854        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
10855        return;
10856    }
10857    let lock_path = sessions_root.join(".auto-init.lock");
10858    let lock_file = match std::fs::OpenOptions::new()
10859        .create(true)
10860        .truncate(false)
10861        .read(true)
10862        .write(true)
10863        .open(&lock_path)
10864    {
10865        Ok(f) => f,
10866        Err(e) => {
10867            eprintln!(
10868                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
10869            );
10870            return;
10871        }
10872    };
10873    if let Err(e) = lock_file.lock_exclusive() {
10874        eprintln!(
10875            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
10876        );
10877        return;
10878    }
10879    // Lock acquired. Read registry + derive name now that all parallel
10880    // racers serialize through us — derive_name_from_cwd adds a
10881    // path-hash suffix if the basename is already claimed by another
10882    // cwd in the (now-stable) registry.
10883    let registry = crate::session::read_registry().unwrap_or_default();
10884    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
10885    let session_home = match crate::session::session_dir(&name) {
10886        Ok(h) => h,
10887        Err(_) => {
10888            let _ = fs2::FileExt::unlock(&lock_file);
10889            return;
10890        }
10891    };
10892    let agent_card_path = session_home
10893        .join("config")
10894        .join("wire")
10895        .join("agent-card.json");
10896    let needs_init = !agent_card_path.exists();
10897
10898    if needs_init {
10899        if let Err(e) = std::fs::create_dir_all(&session_home) {
10900            eprintln!(
10901                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
10902            );
10903            let _ = fs2::FileExt::unlock(&lock_file);
10904            return;
10905        }
10906        // v0.9: --offline; the surrounding session-spawn path runs
10907        // try_allocate_local_slot afterward to attach an inbound slot
10908        // when a local relay is available. Init itself stays slotless
10909        // because it's a precursor step, not the final state.
10910        match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
10911            Ok(status) if status.success() => {}
10912            Ok(status) => {
10913                eprintln!(
10914                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
10915                );
10916                let _ = fs2::FileExt::unlock(&lock_file);
10917                return;
10918            }
10919            Err(e) => {
10920                eprintln!(
10921                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
10922                );
10923                let _ = fs2::FileExt::unlock(&lock_file);
10924                return;
10925            }
10926        }
10927        // Best-effort: allocate a local-relay slot so this auto-init'd
10928        // session is addressable by sister sessions. Skipped silently when
10929        // the local relay isn't running (the function itself reports to
10930        // stderr). Auto-init'd sessions without endpoints can still
10931        // surface their character but cannot receive pair_drops until the
10932        // operator runs `wire bind-relay` or restarts the local relay.
10933        try_allocate_local_slot(
10934            &session_home,
10935            &name,
10936            "https://wireup.net",
10937            "http://127.0.0.1:8771",
10938        );
10939    } else {
10940        // Race loser path: peer already created the session. Surface
10941        // this honestly so the operator can see we adopted rather than
10942        // double-initialized.
10943        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10944            eprintln!(
10945                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
10946            );
10947        }
10948    }
10949    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
10950    // BEFORE releasing the auto-init lock. Pre-fix released the lock
10951    // here and committed the registry update afterward — racers in
10952    // OTHER cwds with the same basename would acquire the lock,
10953    // read the registry (still without our entry), and derive the
10954    // SAME name we just claimed. Live regression test caught it:
10955    // two cwds /a/projx + /b/projx both got name "projx", both
10956    // mapped to the same identity. Update the registry WHILE STILL
10957    // holding the auto-init lock so the next racer sees our claim.
10958    let cwd_key = crate::session::normalize_cwd_key(&cwd);
10959    let name_for_reg = name.clone();
10960    if let Err(e) = crate::session::update_registry(|reg| {
10961        reg.by_cwd.insert(cwd_key, name_for_reg);
10962        Ok(())
10963    }) {
10964        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
10965        // proceed — env var still gets set below
10966    }
10967    // NOW release the lock — racers waiting will see our registry
10968    // entry on their re-read.
10969    let _ = fs2::FileExt::unlock(&lock_file);
10970
10971    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10972        eprintln!(
10973            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
10974            cwd.display(),
10975            session_home.display()
10976        );
10977    }
10978    // SAFETY: caller contract is "before any thread spawn." MCP::run
10979    // calls this immediately after `maybe_adopt_session_wire_home`.
10980    unsafe {
10981        std::env::set_var("WIRE_HOME", &session_home);
10982    }
10983}
10984
10985fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
10986    // Check if a daemon is already alive in this session's WIRE_HOME.
10987    // If so, no-op (let the existing process keep running).
10988    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10989    if pidfile.exists() {
10990        let bytes = std::fs::read(&pidfile).unwrap_or_default();
10991        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10992            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10993        } else {
10994            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10995        };
10996        if let Some(p) = pid {
10997            let alive = {
10998                #[cfg(target_os = "linux")]
10999                {
11000                    std::path::Path::new(&format!("/proc/{p}")).exists()
11001                }
11002                #[cfg(not(target_os = "linux"))]
11003                {
11004                    std::process::Command::new("kill")
11005                        .args(["-0", &p.to_string()])
11006                        .output()
11007                        .map(|o| o.status.success())
11008                        .unwrap_or(false)
11009                }
11010            };
11011            if alive {
11012                return Ok(());
11013            }
11014        }
11015    }
11016
11017    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
11018    // versioned pidfile; we just kick it off and return.
11019    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
11020    let log_path = session_home.join("state").join("wire").join("daemon.log");
11021    if let Some(parent) = log_path.parent() {
11022        std::fs::create_dir_all(parent).ok();
11023    }
11024    let log_file = std::fs::OpenOptions::new()
11025        .create(true)
11026        .append(true)
11027        .open(&log_path)
11028        .with_context(|| format!("opening daemon log {log_path:?}"))?;
11029    let log_err = log_file.try_clone()?;
11030    std::process::Command::new(&bin)
11031        .env("WIRE_HOME", session_home)
11032        .env_remove("RUST_LOG")
11033        .args(["daemon", "--interval", "5"])
11034        .stdout(log_file)
11035        .stderr(log_err)
11036        .stdin(std::process::Stdio::null())
11037        .spawn()
11038        .with_context(|| "spawning session-local `wire daemon`")?;
11039    Ok(())
11040}
11041
11042fn cmd_session_list(as_json: bool) -> Result<()> {
11043    let items = crate::session::list_sessions()?;
11044    if as_json {
11045        println!("{}", serde_json::to_string(&items)?);
11046        return Ok(());
11047    }
11048    if items.is_empty() {
11049        println!("no sessions on this machine. `wire session new` to create one.");
11050        return Ok(());
11051    }
11052    println!(
11053        "{:<22} {:<24} {:<24} {:<10} CWD",
11054        "PERSONA", "NAME", "HANDLE", "DAEMON"
11055    );
11056    for s in items {
11057        // ANSI-escape-wrapped character takes more visual width than its
11058        // displayed glyph count; pad based on the plain-text form, then
11059        // wrap in escapes so the column lines up across rows.
11060        let plain = s
11061            .character
11062            .as_ref()
11063            .map(|c| c.short())
11064            .unwrap_or_else(|| "?".to_string());
11065        let colored = s
11066            .character
11067            .as_ref()
11068            .map(|c| c.colored())
11069            .unwrap_or_else(|| "?".to_string());
11070        // Approximate display width: emoji renders as ~2 cells in most
11071        // terminals; the rest are 1 cell each. We pad to 18 displayed
11072        // chars (≈22 byte slots when counting emoji).
11073        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
11074        let pad = 22usize.saturating_sub(displayed_width);
11075        println!(
11076            "{}{}  {:<24} {:<24} {:<10} {}",
11077            colored,
11078            " ".repeat(pad),
11079            s.name,
11080            s.handle.as_deref().unwrap_or("?"),
11081            if s.daemon_running { "running" } else { "down" },
11082            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
11083        );
11084    }
11085    Ok(())
11086}
11087
11088/// v0.5.19: `wire session list-local` — sister-session discovery.
11089///
11090/// For each on-disk session, read its `relay-state.json` and surface
11091/// the ones that have a Local-scope endpoint (allocated via
11092/// `wire session new --with-local`). Group by the local-relay URL so
11093/// the operator can see at a glance which sessions are mutually
11094/// reachable over the same loopback relay.
11095///
11096/// Read-only, no daemon contact. Useful as the prelude to teaming /
11097/// pairing same-box sister claudes (see also `wire session
11098/// pair-all-local` once implemented).
11099fn cmd_session_list_local(as_json: bool) -> Result<()> {
11100    let listing = crate::session::list_local_sessions()?;
11101    if as_json {
11102        println!("{}", serde_json::to_string(&listing)?);
11103        return Ok(());
11104    }
11105
11106    if listing.local.is_empty() && listing.federation_only.is_empty() {
11107        println!(
11108            "no sessions on this machine. `wire session new --with-local` to create one \
11109             with a local-relay endpoint (start the relay first: \
11110             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
11111        );
11112        return Ok(());
11113    }
11114
11115    if listing.local.is_empty() {
11116        println!(
11117            "no sister sessions reachable via a local relay. \
11118             Re-run `wire session new --with-local` to add a Local endpoint, or \
11119             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
11120        );
11121    } else {
11122        // Stable iteration order: sort the relay URLs.
11123        let mut keys: Vec<&String> = listing.local.keys().collect();
11124        keys.sort();
11125        for relay_url in keys {
11126            let group = &listing.local[relay_url];
11127            println!("LOCAL RELAY: {relay_url}");
11128            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
11129            for s in group {
11130                println!(
11131                    "  {:<24} {:<32} {:<10} {}",
11132                    s.name,
11133                    s.handle.as_deref().unwrap_or("?"),
11134                    if s.daemon_running { "running" } else { "down" },
11135                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
11136                );
11137            }
11138            println!();
11139        }
11140    }
11141
11142    if !listing.federation_only.is_empty() {
11143        println!("federation-only (no local endpoint):");
11144        for s in &listing.federation_only {
11145            println!(
11146                "  {:<24} {:<32} {}",
11147                s.name,
11148                s.handle.as_deref().unwrap_or("?"),
11149                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
11150            );
11151        }
11152    }
11153    Ok(())
11154}
11155
11156/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
11157/// session that has a Local-scope endpoint. Skips already-paired
11158/// pairs; reports a per-pair outcome JSON suitable for scripting.
11159///
11160/// Same-uid trust anchor: the caller owns every session enumerated by
11161/// `list_local_sessions`, so the operator running this command IS the
11162/// consent for both sides. The bilateral SAS / network-level handshake
11163/// assumes strangers; same-uid sister sessions are not strangers.
11164///
11165/// Per-pair flow (sequential to keep relay-side load + log clarity):
11166///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
11167///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
11168///   3. sleep settle_secs                       (pair_drop reaches B)
11169///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
11170///   5. WIRE_HOME=B wire pair-accept <A-bare>   (B pins A, sends ack)
11171///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
11172///   7. sleep settle_secs                       (ack reaches A)
11173///   8. WIRE_HOME=A wire pull --json            (A pins B)
11174fn cmd_session_pair_all_local(
11175    settle_secs: u64,
11176    federation_relay: &str,
11177    as_json: bool,
11178) -> Result<()> {
11179    use std::collections::BTreeSet;
11180    use std::time::Duration;
11181
11182    let listing = crate::session::list_local_sessions()?;
11183    // Flatten + dedup by session NAME (same session can appear under
11184    // multiple local-relay URLs if it advertises two local endpoints;
11185    // rare, but pair each pair exactly once).
11186    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
11187        Default::default();
11188    for group in listing.local.into_values() {
11189        for s in group {
11190            by_name.entry(s.name.clone()).or_insert(s);
11191        }
11192    }
11193    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
11194
11195    if sessions.len() < 2 {
11196        let msg = format!(
11197            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
11198            sessions.len()
11199        );
11200        if as_json {
11201            println!(
11202                "{}",
11203                serde_json::to_string(&json!({
11204                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
11205                    "pairs_attempted": 0,
11206                    "pairs_succeeded": 0,
11207                    "pairs_skipped_already_paired": 0,
11208                    "pairs_failed": 0,
11209                    "note": msg,
11210                }))?
11211            );
11212        } else {
11213            println!("{msg}");
11214            if let Some(s) = sessions.first() {
11215                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
11216            }
11217            println!("Use `wire session new --with-local` to add more.");
11218        }
11219        return Ok(());
11220    }
11221
11222    let fed_host = host_of_url(federation_relay);
11223    if fed_host.is_empty() {
11224        bail!(
11225            "federation_relay `{federation_relay}` has no parseable host — \
11226             pass a full URL like `https://wireup.net`."
11227        );
11228    }
11229
11230    // Enumerate unordered pairs deterministically by session name.
11231    let mut attempted = 0u32;
11232    let mut succeeded = 0u32;
11233    let mut skipped_already = 0u32;
11234    let mut failed = 0u32;
11235    let mut per_pair: Vec<Value> = Vec::new();
11236
11237    for i in 0..sessions.len() {
11238        for j in (i + 1)..sessions.len() {
11239            let a = &sessions[i];
11240            let b = &sessions[j];
11241            attempted += 1;
11242
11243            // Already-paired check: if A's relay-state has B's CARD
11244            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
11245            // are character handles (not session names), so we use
11246            // each side's handle field (already on the LocalSessionView)
11247            // for the lookup rather than the session name.
11248            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
11249            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
11250            let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
11251            let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
11252            if a_pinned_b && b_pinned_a {
11253                skipped_already += 1;
11254                per_pair.push(json!({
11255                    "from": a.name,
11256                    "to": b.name,
11257                    "status": "already_paired",
11258                }));
11259                continue;
11260            }
11261
11262            let pair_result = drive_bilateral_pair(
11263                &a.home_dir,
11264                &a.name,
11265                &b.home_dir,
11266                &b.name,
11267                &fed_host,
11268                federation_relay,
11269                settle_secs,
11270            );
11271
11272            match pair_result {
11273                Ok(()) => {
11274                    succeeded += 1;
11275                    per_pair.push(json!({
11276                        "from": a.name,
11277                        "to": b.name,
11278                        "status": "paired",
11279                    }));
11280                }
11281                Err(e) => {
11282                    failed += 1;
11283                    let detail = format!("{e:#}");
11284                    per_pair.push(json!({
11285                        "from": a.name,
11286                        "to": b.name,
11287                        "status": "failed",
11288                        "error": detail,
11289                    }));
11290                }
11291            }
11292
11293            // Brief settle between pairs so we don't slam the relay
11294            // with N(N-1) parallel requests.
11295            std::thread::sleep(Duration::from_millis(200));
11296        }
11297    }
11298
11299    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
11300    let summary = json!({
11301        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
11302        "pairs_attempted": attempted,
11303        "pairs_succeeded": succeeded,
11304        "pairs_skipped_already_paired": skipped_already,
11305        "pairs_failed": failed,
11306        "results": per_pair,
11307    });
11308    if as_json {
11309        println!("{}", serde_json::to_string(&summary)?);
11310    } else {
11311        println!(
11312            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
11313            sessions.len(),
11314            attempted
11315        );
11316        println!("  paired:                 {succeeded}");
11317        println!("  skipped (already pinned): {skipped_already}");
11318        println!("  failed:                 {failed}");
11319        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
11320            let from = entry["from"].as_str().unwrap_or("?");
11321            let to = entry["to"].as_str().unwrap_or("?");
11322            let status = entry["status"].as_str().unwrap_or("?");
11323            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
11324            if err.is_empty() {
11325                println!("  {from:<24} ↔ {to:<24} {status}");
11326            } else {
11327                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
11328            }
11329        }
11330    }
11331    Ok(())
11332}
11333
11334/// Check whether `session_home`'s `relay.json` already lists `peer_name`
11335/// under `state.peers`. Best-effort — any read/parse error → false.
11336fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
11337    val_session_relay_state(session_home)
11338        .and_then(|v| v.get("peers").cloned())
11339        .and_then(|p| p.get(peer_name).cloned())
11340        .is_some()
11341}
11342
11343/// Read a session's `relay.json` directly without mutating the process'
11344/// WIRE_HOME env (which would race other threads / processes). Returns
11345/// `None` on any read or parse error — callers treat missing state as
11346/// "no peers / no endpoints" rather than aborting.
11347fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
11348    let path = session_home.join("config").join("wire").join("relay.json");
11349    let bytes = std::fs::read(&path).ok()?;
11350    serde_json::from_slice(&bytes).ok()
11351}
11352
11353/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
11354/// One probe per directed edge against the relay backing that edge's
11355/// priority-1 endpoint; output groups by undirected pair.
11356fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
11357    use std::collections::BTreeMap;
11358
11359    // Flatten by session NAME — same dedup logic as pair-all-local so a
11360    // session advertising two local endpoints doesn't get double-counted.
11361    let listing = crate::session::list_local_sessions()?;
11362    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
11363    for group in listing.local.into_values() {
11364        for s in group {
11365            by_name.entry(s.name.clone()).or_insert(s);
11366        }
11367    }
11368    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
11369    let federation_only = listing.federation_only;
11370
11371    if sessions.is_empty() {
11372        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
11373        if as_json {
11374            println!(
11375                "{}",
11376                serde_json::to_string(&json!({
11377                    "sessions": [],
11378                    "edges": [],
11379                    "local_relay": null,
11380                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
11381                    "summary": {
11382                        "session_count": 0,
11383                        "edge_count": 0,
11384                        "healthy": 0,
11385                        "stale": 0,
11386                        "asymmetric": 0,
11387                    },
11388                    "note": msg,
11389                }))?
11390            );
11391        } else {
11392            println!("{msg}");
11393            println!("Use `wire session new --with-local` to create one.");
11394        }
11395        return Ok(());
11396    }
11397
11398    // Build a name → session-state map: relay_state + reachable handle set.
11399    struct SessionState {
11400        view: crate::session::LocalSessionView,
11401        relay_state: Value,
11402        local_relay_url: Option<String>,
11403    }
11404    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
11405    for s in sessions {
11406        let relay_state = val_session_relay_state(&s.home_dir)
11407            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
11408        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
11409        sstates.push(SessionState {
11410            view: s,
11411            relay_state,
11412            local_relay_url,
11413        });
11414    }
11415
11416    // Probe each unique local-relay URL once for healthz so the operator
11417    // sees one liveness line per local relay, not one per edge.
11418    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
11419    for s in &sstates {
11420        if let Some(url) = &s.local_relay_url
11421            && !local_relays.contains_key(url)
11422        {
11423            let healthy = probe_relay_healthz(url);
11424            local_relays.insert(url.clone(), healthy);
11425        }
11426    }
11427
11428    let now = std::time::SystemTime::now()
11429        .duration_since(std::time::UNIX_EPOCH)
11430        .map(|d| d.as_secs())
11431        .unwrap_or(0);
11432
11433    // Edges: walk every unordered pair, surface bilateral state + each
11434    // direction's last_pull. Probe priority-1 endpoint (local preferred
11435    // by `peer_endpoints_in_priority_order`).
11436    let mut edges: Vec<Value> = Vec::new();
11437    let mut healthy_count = 0u32;
11438    let mut stale_count = 0u32;
11439    let mut asymmetric_count = 0u32;
11440
11441    for i in 0..sstates.len() {
11442        for j in (i + 1)..sstates.len() {
11443            let a = &sstates[i];
11444            let b = &sstates[j];
11445            // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
11446            // (DID-derived character), not the session name. Look the
11447            // peer up by its handle (with a session-name fallback for
11448            // pre-v0.11 sessions that haven't re-init'd yet).
11449            let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
11450            let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
11451            let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
11452            let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
11453
11454            let bilateral = a_to_b.pinned && b_to_a.pinned;
11455            // Scope = the most-local scope available in either direction.
11456            // (If a→b is local and b→a is federation, the asymmetric
11457            // detail surfaces below; the headline scope is the better.)
11458            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
11459                (Some("local"), _) | (_, Some("local")) => "local",
11460                (Some("federation"), _) | (_, Some("federation")) => "federation",
11461                _ => "unknown",
11462            };
11463
11464            // Health: stale if either direction's last_pull is older than
11465            // `stale_secs`, or never observed when both sides are pinned.
11466            let mut status = if bilateral { "healthy" } else { "asymmetric" };
11467            if bilateral {
11468                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
11469                    Some(s) => s > stale_secs,
11470                    None => d.probed,
11471                });
11472                if either_stale {
11473                    status = "stale";
11474                }
11475            }
11476
11477            match status {
11478                "healthy" => healthy_count += 1,
11479                "stale" => stale_count += 1,
11480                "asymmetric" => asymmetric_count += 1,
11481                _ => {}
11482            }
11483
11484            edges.push(json!({
11485                "from": a.view.name,
11486                "to": b.view.name,
11487                "bilateral": bilateral,
11488                "scope": scope,
11489                "status": status,
11490                "directions": {
11491                    a.view.name.clone(): direction_summary(&a_to_b),
11492                    b.view.name.clone(): direction_summary(&b_to_a),
11493                },
11494            }));
11495        }
11496    }
11497
11498    let summary = json!({
11499        "sessions": sstates.iter().map(|s| json!({
11500            "name": s.view.name,
11501            "handle": s.view.handle,
11502            "cwd": s.view.cwd,
11503            "daemon_running": s.view.daemon_running,
11504            "local_relay": s.local_relay_url,
11505        })).collect::<Vec<_>>(),
11506        "edges": edges,
11507        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
11508            "url": url,
11509            "healthy": healthy,
11510        })).collect::<Vec<_>>(),
11511        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
11512        "summary": {
11513            "session_count": sstates.len(),
11514            "edge_count": edges.len(),
11515            "healthy": healthy_count,
11516            "stale": stale_count,
11517            "asymmetric": asymmetric_count,
11518            "stale_threshold_secs": stale_secs,
11519        },
11520    });
11521
11522    if as_json {
11523        println!("{}", serde_json::to_string(&summary)?);
11524        return Ok(());
11525    }
11526
11527    println!(
11528        "wire mesh: {} session(s), {} edge(s)",
11529        sstates.len(),
11530        edges.len()
11531    );
11532    for (url, healthy) in &local_relays {
11533        let tick = if *healthy { "✓" } else { "✗" };
11534        println!("  local-relay {url} {tick}");
11535    }
11536    if !federation_only.is_empty() {
11537        print!("  federation-only sessions:");
11538        for f in &federation_only {
11539            print!(" {}", f.name);
11540        }
11541        println!();
11542    }
11543
11544    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
11545    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
11546    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
11547    print!("\n{:>col_w$}", "", col_w = col_w);
11548    for n in &names {
11549        print!("{n:>col_w$}");
11550    }
11551    println!();
11552    for (i, row) in names.iter().enumerate() {
11553        print!("{row:>col_w$}");
11554        for (j, col) in names.iter().enumerate() {
11555            let cell = if i == j {
11556                "self".to_string()
11557            } else {
11558                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
11559                match d.scope.as_deref() {
11560                    Some("local") => "local".to_string(),
11561                    Some("federation") => "fed".to_string(),
11562                    _ => "—".to_string(),
11563                }
11564            };
11565            print!("{cell:>col_w$}");
11566        }
11567        println!();
11568    }
11569
11570    println!("\nHealth (stale threshold: {stale_secs}s):");
11571    for e in &edges {
11572        let from = e["from"].as_str().unwrap_or("?");
11573        let to = e["to"].as_str().unwrap_or("?");
11574        let scope = e["scope"].as_str().unwrap_or("?");
11575        let status = e["status"].as_str().unwrap_or("?");
11576        let mark = match status {
11577            "healthy" => "✓",
11578            "stale" => "⚠",
11579            "asymmetric" => "!",
11580            _ => "?",
11581        };
11582        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
11583        let mut details: Vec<String> = Vec::new();
11584        for (who, d) in &dirs {
11585            let silent = d.get("silent_secs").and_then(Value::as_u64);
11586            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
11587            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
11588            let label = match (pinned, probed, silent) {
11589                (false, _, _) => format!("{who} has not pinned"),
11590                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
11591                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
11592                (true, true, Some(s)) => format!("{who} silent {s}s"),
11593                (true, true, None) => format!("{who} never pulled"),
11594            };
11595            details.push(label);
11596        }
11597        println!(
11598            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
11599            details.join(" | ")
11600        );
11601    }
11602    Ok(())
11603}
11604
11605#[derive(Default)]
11606struct DirectedEdge {
11607    pinned: bool,
11608    scope: Option<String>,
11609    last_pull_at_unix: Option<u64>,
11610    silent_secs: Option<u64>,
11611    probed: bool,
11612    event_count: usize,
11613}
11614
11615/// Probe a single directed edge from `from_state`'s view of `to_name`.
11616/// Picks the priority-1 endpoint (local preferred when reachable) and
11617/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
11618/// failure (the function records `probed = true`, `last_pull = None`,
11619/// which the caller treats as "never pulled, route exists" = stale).
11620fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
11621    let pinned = from_state
11622        .get("peers")
11623        .and_then(|p| p.get(to_name))
11624        .is_some();
11625    if !pinned {
11626        return DirectedEdge::default();
11627    }
11628    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
11629    let ep = match endpoints.into_iter().next() {
11630        Some(e) => e,
11631        None => {
11632            return DirectedEdge {
11633                pinned: true,
11634                ..Default::default()
11635            };
11636        }
11637    };
11638    let scope = Some(
11639        match ep.scope {
11640            crate::endpoints::EndpointScope::Local => "local",
11641            crate::endpoints::EndpointScope::Lan => "lan",
11642            crate::endpoints::EndpointScope::Uds => "uds",
11643            crate::endpoints::EndpointScope::Federation => "federation",
11644        }
11645        .to_string(),
11646    );
11647    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
11648    let (count, last) = client
11649        .slot_state(&ep.slot_id, &ep.slot_token)
11650        .unwrap_or((0, None));
11651    let silent = last.map(|t| now.saturating_sub(t));
11652    DirectedEdge {
11653        pinned: true,
11654        scope,
11655        last_pull_at_unix: last,
11656        silent_secs: silent,
11657        probed: true,
11658        event_count: count,
11659    }
11660}
11661
11662fn direction_summary(d: &DirectedEdge) -> Value {
11663    json!({
11664        "pinned": d.pinned,
11665        "scope": d.scope,
11666        "probed": d.probed,
11667        "last_pull_at_unix": d.last_pull_at_unix,
11668        "silent_secs": d.silent_secs,
11669        "event_count": d.event_count,
11670    })
11671}
11672
11673/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
11674fn probe_relay_healthz(url: &str) -> bool {
11675    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
11676    let client = match reqwest::blocking::Client::builder()
11677        .timeout(std::time::Duration::from_millis(500))
11678        .build()
11679    {
11680        Ok(c) => c,
11681        Err(_) => return false,
11682    };
11683    match client.get(&probe_url).send() {
11684        Ok(r) => r.status().is_success(),
11685        Err(_) => false,
11686    }
11687}
11688
11689/// Drive one bilateral pair handshake between two sister sessions
11690/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
11691/// flow so failures bubble up at the offending step, not buried in
11692/// a parallel race. See `cmd_session_pair_all_local` docstring.
11693///
11694/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
11695/// federation `.well-known/wire/agent` resolution. Reads B's card +
11696/// endpoints directly off disk under `b_home` and pins them. This
11697/// makes pair-all-local work for sister sessions whose federation
11698/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
11699/// for sessions created with `wire session new --local-only`
11700/// (no federation slot at all). The `_federation_relay` / `_fed_host`
11701/// parameters are retained for callers that want to log them but
11702/// the handshake itself no longer touches federation.
11703fn drive_bilateral_pair(
11704    a_home: &std::path::Path,
11705    a_name: &str,
11706    b_home: &std::path::Path,
11707    b_name: &str,
11708    _fed_host: &str,
11709    _federation_relay: &str,
11710    settle_secs: u64,
11711) -> Result<()> {
11712    use std::time::Duration;
11713    let bin = std::env::current_exe().context("locating self exe")?;
11714
11715    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
11716        let out = std::process::Command::new(&bin)
11717            .env("WIRE_HOME", home)
11718            .env_remove("RUST_LOG")
11719            .args(args)
11720            .output()
11721            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
11722        if !out.status.success() {
11723            bail!(
11724                "`wire {}` failed: stderr={}",
11725                args.join(" "),
11726                String::from_utf8_lossy(&out.stderr).trim()
11727            );
11728        }
11729        Ok(())
11730    };
11731
11732    // v0.11: each session's agent-card.handle is the DID-derived
11733    // character, not the session name. pair-accept lookups key on the
11734    // CARD HANDLE, so we discover each side's canonical handle from
11735    // its agent-card on disk before driving the pair flow.
11736    let read_card_handle = |home: &std::path::Path| -> Result<String> {
11737        let card_path = home.join("config").join("wire").join("agent-card.json");
11738        let bytes = std::fs::read(&card_path)
11739            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
11740        let card: Value = serde_json::from_slice(&bytes)?;
11741        card.get("handle")
11742            .and_then(Value::as_str)
11743            .map(str::to_string)
11744            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
11745    };
11746    let a_handle = read_card_handle(a_home)
11747        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
11748    let b_handle = read_card_handle(b_home)
11749        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
11750
11751    // 1. A initiates via --local-sister (uses the session NAME for
11752    // the registry lookup; cmd_add_local_sister auto-resolves
11753    // session→handle internally).
11754    run(a_home, &["add", b_name, "--local-sister", "--json"])
11755        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
11756
11757    // 3. settle so pair_drop reaches B's slot
11758    std::thread::sleep(Duration::from_secs(settle_secs));
11759
11760    // 4. B pulls pair_drop → 5. B pair-accept (pins A by CARD HANDLE,
11761    // not by session name — under v0.11 these differ) → 6. B push ack
11762    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
11763    run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
11764        format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
11765    })?;
11766    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
11767
11768    // 7. settle so ack reaches A's slot
11769    std::thread::sleep(Duration::from_secs(settle_secs));
11770
11771    // 8. A pulls ack (pins B by CARD HANDLE)
11772    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
11773    // suppress unused warning when both handles are consumed
11774    let _ = &b_handle;
11775
11776    Ok(())
11777}
11778
11779fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
11780    let name = resolve_session_name(name_arg)?;
11781    let session_home = crate::session::session_dir(&name)?;
11782    if !session_home.exists() {
11783        bail!(
11784            "no session named {name:?} on this machine. `wire session list` to enumerate, \
11785             `wire session new {name}` to create."
11786        );
11787    }
11788    if as_json {
11789        println!(
11790            "{}",
11791            serde_json::to_string(&json!({
11792                "name": name,
11793                "home_dir": session_home.to_string_lossy(),
11794                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
11795            }))?
11796        );
11797    } else {
11798        println!("export WIRE_HOME={}", session_home.to_string_lossy());
11799    }
11800    Ok(())
11801}
11802
11803fn cmd_session_current(as_json: bool) -> Result<()> {
11804    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
11805    let registry = crate::session::read_registry().unwrap_or_default();
11806    let cwd_key = crate::session::normalize_cwd_key(&cwd);
11807    // Backward-compat: O(n) normalized scan on read-miss. Mirrors the
11808    // same pattern in session::derive_name_from_cwd /
11809    // detect_session_wire_home — handles both consistent-casing and
11810    // cross-casing upgraders (see session.rs for the full rationale).
11811    let name = registry
11812        .by_cwd
11813        .get(&cwd_key)
11814        .or_else(|| {
11815            registry
11816                .by_cwd
11817                .iter()
11818                .find(|(k, _)| {
11819                    crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
11820                })
11821                .map(|(_, v)| v)
11822        })
11823        .cloned();
11824    if as_json {
11825        println!(
11826            "{}",
11827            serde_json::to_string(&json!({
11828                "cwd": cwd_key,
11829                "session": name,
11830            }))?
11831        );
11832    } else if let Some(n) = name {
11833        println!("{n}");
11834    } else {
11835        println!("(no session registered for this cwd)");
11836    }
11837    Ok(())
11838}
11839
11840fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
11841    let name = crate::session::sanitize_name(name_arg);
11842    let session_home = crate::session::session_dir(&name)?;
11843    if !session_home.exists() {
11844        if as_json {
11845            println!(
11846                "{}",
11847                serde_json::to_string(&json!({
11848                    "name": name,
11849                    "destroyed": false,
11850                    "reason": "no such session",
11851                }))?
11852            );
11853        } else {
11854            println!("no session named {name:?} — nothing to destroy.");
11855        }
11856        return Ok(());
11857    }
11858    if !force {
11859        bail!(
11860            "destroying session {name:?} would delete its keypair + state irrecoverably. \
11861             Pass --force to confirm."
11862        );
11863    }
11864
11865    // Kill the session-local daemon if alive.
11866    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
11867    if let Ok(bytes) = std::fs::read(&pidfile) {
11868        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
11869            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
11870        } else {
11871            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
11872        };
11873        if let Some(p) = pid {
11874            let _ = std::process::Command::new("kill")
11875                .args(["-TERM", &p.to_string()])
11876                .output();
11877        }
11878    }
11879
11880    std::fs::remove_dir_all(&session_home)
11881        .with_context(|| format!("removing session dir {session_home:?}"))?;
11882
11883    // Strip from registry.
11884    let mut registry = crate::session::read_registry().unwrap_or_default();
11885    registry.by_cwd.retain(|_, v| v != &name);
11886    crate::session::write_registry(&registry)?;
11887
11888    if as_json {
11889        println!(
11890            "{}",
11891            serde_json::to_string(&json!({
11892                "name": name,
11893                "destroyed": true,
11894            }))?
11895        );
11896    } else {
11897        println!("destroyed session {name:?}.");
11898    }
11899    Ok(())
11900}
11901
11902// ---------- diag (structured trace) ----------
11903
11904fn cmd_diag(action: DiagAction) -> Result<()> {
11905    let state = config::state_dir()?;
11906    let knob = state.join("diag.enabled");
11907    let log_path = state.join("diag.jsonl");
11908    match action {
11909        DiagAction::Tail { limit, json } => {
11910            let entries = crate::diag::tail(limit);
11911            if json {
11912                for e in entries {
11913                    println!("{}", serde_json::to_string(&e)?);
11914                }
11915            } else if entries.is_empty() {
11916                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
11917            } else {
11918                for e in entries {
11919                    let ts = e["ts"].as_u64().unwrap_or(0);
11920                    let ty = e["type"].as_str().unwrap_or("?");
11921                    let pid = e["pid"].as_u64().unwrap_or(0);
11922                    let payload = e["payload"].to_string();
11923                    println!("[{ts}] pid={pid} {ty} {payload}");
11924                }
11925            }
11926        }
11927        DiagAction::Enable => {
11928            config::ensure_dirs()?;
11929            std::fs::write(&knob, "1")?;
11930            println!("wire diag: enabled at {knob:?}");
11931        }
11932        DiagAction::Disable => {
11933            if knob.exists() {
11934                std::fs::remove_file(&knob)?;
11935            }
11936            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
11937        }
11938        DiagAction::Status { json } => {
11939            let enabled = crate::diag::is_enabled();
11940            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
11941            if json {
11942                println!(
11943                    "{}",
11944                    serde_json::to_string(&serde_json::json!({
11945                        "enabled": enabled,
11946                        "log_path": log_path,
11947                        "log_size_bytes": size,
11948                    }))?
11949                );
11950            } else {
11951                println!("wire diag status");
11952                println!("  enabled:    {enabled}");
11953                println!("  log:        {log_path:?}");
11954                println!("  log size:   {size} bytes");
11955            }
11956        }
11957    }
11958    Ok(())
11959}
11960
11961// ---------- service (install / uninstall / status) ----------
11962
11963fn cmd_service(action: ServiceAction) -> Result<()> {
11964    let kind = |local_relay: bool| {
11965        if local_relay {
11966            crate::service::ServiceKind::LocalRelay
11967        } else {
11968            crate::service::ServiceKind::Daemon
11969        }
11970    };
11971    let (report, as_json) = match action {
11972        ServiceAction::Install { local_relay, json } => {
11973            (crate::service::install_kind(kind(local_relay))?, json)
11974        }
11975        ServiceAction::Uninstall { local_relay, json } => {
11976            (crate::service::uninstall_kind(kind(local_relay))?, json)
11977        }
11978        ServiceAction::Status { local_relay, json } => {
11979            (crate::service::status_kind(kind(local_relay))?, json)
11980        }
11981    };
11982    if as_json {
11983        println!("{}", serde_json::to_string(&report)?);
11984    } else {
11985        println!("wire service {}", report.action);
11986        println!("  platform:  {}", report.platform);
11987        println!("  unit:      {}", report.unit_path);
11988        println!("  status:    {}", report.status);
11989        println!("  detail:    {}", report.detail);
11990    }
11991    Ok(())
11992}
11993
11994// ---------- update (self-update from crates.io / prebuilt release) ----------
11995
11996const CRATE_NAME: &str = "slancha-wire";
11997
11998/// (target-triple, binary-extension) of the GitHub release asset for THIS
11999/// platform — names mirror `.github/workflows/release.yml`. `None` if no
12000/// prebuilt is published for this target.
12001fn release_asset_triple() -> Option<(&'static str, &'static str)> {
12002    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
12003    {
12004        return Some(("x86_64-pc-windows-msvc", ".exe"));
12005    }
12006    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
12007    {
12008        return Some(("aarch64-apple-darwin", ""));
12009    }
12010    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
12011    {
12012        return Some(("x86_64-apple-darwin", ""));
12013    }
12014    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
12015    {
12016        return Some(("x86_64-unknown-linux-musl", ""));
12017    }
12018    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
12019    {
12020        return Some(("aarch64-unknown-linux-musl", ""));
12021    }
12022    #[allow(unreachable_code)]
12023    None
12024}
12025
12026/// Latest stable version published on crates.io.
12027fn fetch_latest_published_version() -> Result<String> {
12028    let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
12029    let client = reqwest::blocking::Client::builder()
12030        .timeout(std::time::Duration::from_secs(20))
12031        .build()?;
12032    let resp = client
12033        .get(&url)
12034        // crates.io rejects requests without a descriptive User-Agent (403).
12035        .header(
12036            "User-Agent",
12037            format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
12038        )
12039        .send()?;
12040    if !resp.status().is_success() {
12041        bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
12042    }
12043    let v: Value = resp.json()?;
12044    v.get("crate")
12045        .and_then(|c| {
12046            c.get("max_stable_version")
12047                .or_else(|| c.get("newest_version"))
12048        })
12049        .and_then(Value::as_str)
12050        .map(str::to_string)
12051        .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
12052}
12053
12054/// True iff `latest` is strictly newer than `current` (numeric major.minor.patch;
12055/// pre-release suffixes ignored).
12056fn version_is_newer(latest: &str, current: &str) -> bool {
12057    let parse = |s: &str| -> (u64, u64, u64) {
12058        let core = s.split('-').next().unwrap_or(s);
12059        let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
12060        (
12061            it.next().unwrap_or(0),
12062            it.next().unwrap_or(0),
12063            it.next().unwrap_or(0),
12064        )
12065    };
12066    parse(latest) > parse(current)
12067}
12068
12069fn cargo_on_path() -> bool {
12070    std::process::Command::new("cargo")
12071        .arg("--version")
12072        .stdout(std::process::Stdio::null())
12073        .stderr(std::process::Stdio::null())
12074        .status()
12075        .map(|s| s.success())
12076        .unwrap_or(false)
12077}
12078
12079/// Download the prebuilt release binary for `latest` and replace THIS binary
12080/// in place — the toolchain-free update path (for boxes with no `cargo`).
12081fn self_update_from_release(latest: &str) -> Result<()> {
12082    let (triple, ext) = release_asset_triple().ok_or_else(|| {
12083        anyhow!(
12084            "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
12085             or `cargo install {CRATE_NAME}`"
12086        )
12087    })?;
12088    let base =
12089        format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
12090    let client = reqwest::blocking::Client::builder()
12091        .timeout(std::time::Duration::from_secs(120))
12092        .build()?;
12093    let resp = client
12094        .get(&base)
12095        .header("User-Agent", "wire-self-update")
12096        .send()?;
12097    if !resp.status().is_success() {
12098        bail!("downloading {base} returned {}", resp.status());
12099    }
12100    let bytes = resp.bytes()?;
12101
12102    // Verify the SHA-256 sidecar if present (best-effort; absence is non-fatal).
12103    if let Ok(sha) = client
12104        .get(format!("{base}.sha256"))
12105        .header("User-Agent", "wire-self-update")
12106        .send()
12107        && sha.status().is_success()
12108    {
12109        let expected = sha
12110            .text()?
12111            .split_whitespace()
12112            .next()
12113            .unwrap_or("")
12114            .to_string();
12115        if !expected.is_empty() {
12116            use sha2::{Digest, Sha256};
12117            let mut h = Sha256::new();
12118            h.update(&bytes);
12119            let actual = hex::encode(h.finalize());
12120            if expected != actual {
12121                bail!(
12122                    "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
12123                );
12124            }
12125        }
12126    }
12127
12128    let exe = std::env::current_exe().context("locating current exe")?;
12129    let dir = exe
12130        .parent()
12131        .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
12132    let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
12133    std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
12134    #[cfg(unix)]
12135    {
12136        use std::os::unix::fs::PermissionsExt;
12137        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
12138        // Unix: rename over the running binary — the running process keeps the
12139        // old inode; the new file takes the path for the next invocation.
12140        std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
12141    }
12142    #[cfg(windows)]
12143    {
12144        // Windows can't overwrite a running .exe — rename it aside first
12145        // (allowed even while running), then move the new one into place.
12146        let old = exe.with_extension("old");
12147        let _ = std::fs::remove_file(&old);
12148        std::fs::rename(&exe, &old)
12149            .with_context(|| format!("renaming running exe {exe:?} aside"))?;
12150        std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
12151    }
12152    Ok(())
12153}
12154
12155/// Outcome of the crates.io self-update step (the front half of `wire upgrade`).
12156struct UpdateOutcome {
12157    current: String,
12158    latest: String,
12159    /// A newer stable version is published.
12160    available: bool,
12161    /// We actually installed it this run.
12162    installed: bool,
12163    /// How it was installed ("cargo install" / "prebuilt release binary").
12164    via: Option<&'static str>,
12165}
12166
12167/// Check crates.io for a newer published wire and, when `install` is true,
12168/// self-install it (cargo if a toolchain is on PATH, else the prebuilt release
12169/// binary). The front half of `wire upgrade`; `install=false` is check-only.
12170fn self_update_step(install: bool) -> Result<UpdateOutcome> {
12171    let current = env!("CARGO_PKG_VERSION").to_string();
12172    let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
12173    let available = version_is_newer(&latest, &current);
12174    if !install || !available {
12175        return Ok(UpdateOutcome {
12176            current,
12177            latest,
12178            available,
12179            installed: false,
12180            via: None,
12181        });
12182    }
12183    let via = if cargo_on_path() {
12184        eprintln!(
12185            "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
12186        );
12187        let status = std::process::Command::new("cargo")
12188            .args([
12189                "install",
12190                CRATE_NAME,
12191                "--version",
12192                &latest,
12193                "--force",
12194                "--locked",
12195            ])
12196            .status()
12197            .context("running cargo install")?;
12198        if !status.success() {
12199            bail!("`cargo install {CRATE_NAME}` failed");
12200        }
12201        "cargo install"
12202    } else {
12203        eprintln!(
12204            "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
12205        );
12206        self_update_from_release(&latest)?;
12207        "prebuilt release binary"
12208    };
12209    Ok(UpdateOutcome {
12210        current,
12211        latest,
12212        available,
12213        installed: true,
12214        via: Some(via),
12215    })
12216}
12217
12218// ---------- upgrade (atomic daemon swap) ----------
12219
12220/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
12221/// fresh one from the currently-installed binary, write a new versioned
12222/// pidfile. The fix for today's exact failure mode: a daemon process that
12223/// kept running OLD binary text in memory under a symlink that had since
12224/// been repointed at a NEW binary on disk.
12225///
12226/// Idempotent. If no stale daemon is running, just starts a fresh one
12227/// (same as `wire daemon &` but with the wait-until-alive guard from
12228/// ensure_up::ensure_daemon_running).
12229///
12230/// `--check` mode reports drift without acting — lists the processes
12231/// that WOULD be killed and the binary version of each.
12232///
12233/// Session-scoped upgrade kill set (v0.13.2, B fix): THIS session's own daemon
12234/// (`my_pid`, from its pidfile — reliable even when the OS process scan can't
12235/// see it, as on Windows) plus TRUE orphans (found `wire daemon` pids owned by
12236/// no session), EXCLUDING sibling sessions' daemons. Pure + unit-tested so the
12237/// session-scoping is locked — the box-wide predecessor accumulated daemons.
12238fn upgrade_kill_set(
12239    my_pid: Option<u32>,
12240    found_daemon_pids: &[u32],
12241    owned_session_pids: &std::collections::HashSet<u32>,
12242) -> Vec<u32> {
12243    let mut k: Vec<u32> = Vec::new();
12244    if let Some(p) = my_pid {
12245        k.push(p);
12246    }
12247    for &p in found_daemon_pids {
12248        if !owned_session_pids.contains(&p) && Some(p) != my_pid {
12249            k.push(p); // true orphan — owned by no session
12250        }
12251    }
12252    k.sort_unstable();
12253    k.dedup();
12254    k
12255}
12256
12257/// One distinct `wire` binary discovered on `$PATH`, with enrichment used by
12258/// the `wire upgrade` PATH-shadowing diagnostic (issue #80).
12259///
12260/// "Distinct" = unique canonical path; symlink chains collapse to a single
12261/// entry at the FIRST PATH position that surfaced them. This is what
12262/// `which -a` would show modulo symlink dedup.
12263#[derive(Debug, Clone)]
12264struct PathWireBinary {
12265    /// PATH entry under which this binary was discovered (NOT canonicalized,
12266    /// so the operator sees the path they wrote in their shell config).
12267    path: std::path::PathBuf,
12268    /// Canonical filesystem path (symlinks resolved). Used for dedup so a
12269    /// symlink that points at the real binary doesn't show up as a second
12270    /// "distinct" entry.
12271    canonical: std::path::PathBuf,
12272    /// SHA-256 hex of the binary contents. `None` if unreadable (rare; would
12273    /// require a race or perms change after the existence check).
12274    sha256: Option<String>,
12275    /// Last-modified time of the binary. `None` if metadata unreadable.
12276    mtime: Option<std::time::SystemTime>,
12277    /// Zero-based PATH position (after dedup). `0` = the binary bare `wire`
12278    /// resolves to (the winner of PATH precedence).
12279    path_index: usize,
12280    /// True iff this is the binary currently executing the running `wire
12281    /// upgrade` process (i.e. `std::env::current_exe()` canonicalized matches).
12282    /// When this is NOT the `path_index == 0` entry, the operator just ran
12283    /// `wire upgrade` against a SHADOWED binary and bare `wire` will continue
12284    /// to use the active one — the central footgun #80 exists to catch.
12285    is_current_exe: bool,
12286}
12287
12288impl PathWireBinary {
12289    /// True iff bare `wire` resolves here (the PATH-precedence winner).
12290    fn is_active(&self) -> bool {
12291        self.path_index == 0
12292    }
12293    /// Short sha256 (first 8 hex chars) for compact display; `?` filler when
12294    /// the hash couldn't be computed.
12295    fn sha256_short(&self) -> String {
12296        self.sha256
12297            .as_deref()
12298            .map(|s| s[..s.len().min(8)].to_string())
12299            .unwrap_or_else(|| "????????".to_string())
12300    }
12301    /// Pretty mtime in UTC RFC3339 seconds; `?` when missing or unrepresentable.
12302    fn mtime_display(&self) -> String {
12303        let Some(ts) = self.mtime else {
12304            return "?".to_string();
12305        };
12306        let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
12307            Ok(d) => d.as_secs() as i64,
12308            Err(_) => return "?".to_string(),
12309        };
12310        time::OffsetDateTime::from_unix_timestamp(secs)
12311            .ok()
12312            .and_then(|dt| {
12313                dt.format(&time::format_description::well_known::Rfc3339)
12314                    .ok()
12315            })
12316            .unwrap_or_else(|| "?".to_string())
12317    }
12318}
12319
12320/// SHA-256 hex of a file's contents (streamed; safe for any size).
12321fn sha256_file(p: &std::path::Path) -> Result<String> {
12322    use sha2::{Digest, Sha256};
12323    let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
12324    let mut h = Sha256::new();
12325    std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
12326    Ok(hex::encode(h.finalize()))
12327}
12328
12329/// Walk `$PATH` left-to-right, find all distinct files named `wire` (plus
12330/// `wire.exe` on Windows), and return them in PATH order with sha256+mtime
12331/// enrichment. Issue #80.
12332///
12333/// Invariants:
12334/// - First entry (`path_index == 0`) is what bare `wire` resolves to.
12335/// - Symlink chains collapse: only the first PATH position surfaces; later
12336///   entries pointing at the same canonical file are dropped (NOT counted
12337///   as a "shadow").
12338/// - Best-effort: I/O errors degrade to `None` on per-binary fields,
12339///   never abort the whole walk.
12340/// - Empty / missing PATH → empty Vec (NOT an error; the caller is already
12341///   running, so SOMETHING resolved this binary, just not via PATH).
12342fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
12343    let path = std::env::var("PATH").unwrap_or_default();
12344    let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
12345        .ok()
12346        .and_then(|p| p.canonicalize().ok());
12347    enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
12348}
12349
12350/// Pure (testable) inner of [`enumerate_path_wire_binaries`]: takes the PATH
12351/// string and an optional already-canonicalized `current_exe` so tests don't
12352/// have to mutate process-wide environment (which would race with any other
12353/// test that reads PATH).
12354fn enumerate_path_wire_binaries_from(
12355    path: &str,
12356    current_exe_canon: Option<&std::path::Path>,
12357) -> Vec<PathWireBinary> {
12358    if path.is_empty() {
12359        return Vec::new();
12360    }
12361    // Unix splits PATH on ':', Windows on ';'. We don't use
12362    // `std::env::split_paths` because we want to be explicit and consistent
12363    // with the existing v0.6.8 detection that this helper replaces (which
12364    // used `.split(':')` unconditionally — a Unix-only bug; fixed here).
12365    let separator = if cfg!(windows) { ';' } else { ':' };
12366    let names: &[&str] = if cfg!(windows) {
12367        // Try .exe first — that's what CreateProcess resolves bare `wire` to
12368        // under PATHEXT. A plain `wire` script (e.g. msys) only wins if
12369        // there's no wire.exe in the same directory.
12370        &["wire.exe", "wire"]
12371    } else {
12372        &["wire"]
12373    };
12374
12375    let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
12376    let mut out: Vec<PathWireBinary> = Vec::new();
12377    for dir in path.split(separator) {
12378        if dir.is_empty() {
12379            continue;
12380        }
12381        for name in names {
12382            let candidate = std::path::PathBuf::from(dir).join(name);
12383            // `is_file()` (not `.exists()`) so a directory named `wire`
12384            // doesn't false-positive — `.exists()` returns true for dirs.
12385            if !candidate.is_file() {
12386                continue;
12387            }
12388            let canon = candidate
12389                .canonicalize()
12390                .unwrap_or_else(|_| candidate.clone());
12391            if !seen.insert(canon.clone()) {
12392                // An earlier PATH entry already surfaced this canonical file
12393                // (symlink chain). Don't double-count as a shadow.
12394                break;
12395            }
12396            let meta = std::fs::metadata(&canon).ok();
12397            let mtime = meta.as_ref().and_then(|m| m.modified().ok());
12398            let sha256 = sha256_file(&canon).ok();
12399            let is_current_exe = current_exe_canon
12400                .map(|c| c == canon.as_path())
12401                .unwrap_or(false);
12402            let path_index = out.len();
12403            out.push(PathWireBinary {
12404                path: candidate,
12405                canonical: canon,
12406                sha256,
12407                mtime,
12408                path_index,
12409                is_current_exe,
12410            });
12411            // One entry per PATH dir — don't surface both wire AND wire.exe
12412            // from the same directory.
12413            break;
12414        }
12415    }
12416    out
12417}
12418
12419/// Render a multi-line WARN message for the PATH-shadow case, or `None` if
12420/// there's nothing to warn about. Issue #80.
12421///
12422/// Triggers (any one fires the warning):
12423/// - `>= 2 distinct wire binaries` on PATH (classic shadow case).
12424/// - Exactly 1 binary on PATH AND that binary isn't the one currently
12425///   running this `wire upgrade` (operator ran an off-PATH binary; bare
12426///   `wire` would resolve to a DIFFERENT binary that this upgrade just
12427///   bypassed).
12428/// - `0 binaries` on PATH at all (this `wire upgrade` ran via an absolute
12429///   path; bare `wire` would fail in any future shell).
12430fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
12431    let any_current = bins.iter().any(|b| b.is_current_exe);
12432    let multi = bins.len() >= 2;
12433    let off_path = !bins.is_empty() && !any_current;
12434    let none_on_path = bins.is_empty();
12435    if !multi && !off_path && !none_on_path {
12436        return None;
12437    }
12438    let mut out = String::new();
12439    if multi {
12440        out.push_str(&format!(
12441            "WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
12442            bins.len()
12443        ));
12444        for b in bins {
12445            let mut tags: Vec<&str> = Vec::new();
12446            if b.is_active() {
12447                tags.push("ACTIVE (bare `wire` resolves here)");
12448            }
12449            if b.is_current_exe {
12450                tags.push("THIS upgrade ran against this binary");
12451            }
12452            let tag_str = if tags.is_empty() {
12453                String::new()
12454            } else {
12455                format!("  ← {}", tags.join("; "))
12456            };
12457            out.push_str(&format!(
12458                "  [{}] {}  (sha256:{}  mtime:{}){}\n",
12459                b.path_index,
12460                b.path.display(),
12461                b.sha256_short(),
12462                b.mtime_display(),
12463                tag_str,
12464            ));
12465        }
12466        if !any_current {
12467            out.push_str(
12468                "  NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
12469            );
12470            out.push_str(
12471                "        Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
12472            );
12473        } else if !bins[0].is_current_exe {
12474            out.push_str(
12475                "  Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
12476            );
12477            out.push_str(
12478                "  ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
12479            );
12480            out.push_str(&format!(
12481                "    - rm {}  (or symlink it to the upgraded binary)\n",
12482                bins[0].path.display(),
12483            ));
12484            out.push_str(
12485                "    - or reorder PATH so the upgraded binary's directory precedes the active one\n",
12486            );
12487            out.push_str("  Verify with: which -a wire\n");
12488        }
12489    } else if off_path {
12490        // Single PATH binary, but THIS upgrade ran against a different file.
12491        let active = &bins[0];
12492        out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
12493        out.push_str(&format!(
12494            "      bare `wire` resolves to {} (sha256:{}),\n",
12495            active.path.display(),
12496            active.sha256_short(),
12497        ));
12498        out.push_str(
12499            "      which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
12500        );
12501        out.push_str("      will continue to invoke the old binary.\n");
12502    } else if none_on_path {
12503        out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
12504        out.push_str("      This upgrade ran against an absolute-path invocation only.\n");
12505    }
12506    Some(out.trim_end().to_string())
12507}
12508
12509#[cfg(test)]
12510mod upgrade_tests {
12511    use super::*;
12512    use std::collections::HashSet;
12513
12514    #[test]
12515    fn upgrade_kill_set_is_session_scoped() {
12516        // owned: my daemon 100, sibling session daemon 200.
12517        let owned: HashSet<u32> = [100, 200].into_iter().collect();
12518        // found by the process scan: mine (100), sibling (200), a true orphan (999).
12519        let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
12520        assert!(k.contains(&100), "must kill my own daemon (to replace it)");
12521        assert!(k.contains(&999), "must sweep a true orphan");
12522        assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
12523
12524        // CRITICAL: even when the process scan returns EMPTY (Windows CIM can't
12525        // match the quoted command line), my own daemon is still killed via its
12526        // pidfile pid — this is the B-accumulation fix.
12527        assert_eq!(
12528            upgrade_kill_set(Some(100), &[], &owned),
12529            vec![100],
12530            "own daemon killed even when the process scan is empty"
12531        );
12532
12533        // Uninitialized session (no own daemon): only true orphans.
12534        assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
12535    }
12536
12537    // ----- issue #80: PATH-shadow detection -----
12538    //
12539    // We test the pure inner `enumerate_path_wire_binaries_from(path, cur)`
12540    // so we never mutate the process-wide PATH — that would race with any
12541    // other test in the binary that reads PATH (e.g. `process_alive_self`
12542    // resolving the test binary via PATH).
12543
12544    fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
12545        use std::io::Write;
12546        let p = dir.join("wire");
12547        let mut f = std::fs::File::create(&p).expect("create fake wire");
12548        f.write_all(body).expect("write fake wire");
12549        drop(f);
12550        #[cfg(unix)]
12551        {
12552            use std::os::unix::fs::PermissionsExt;
12553            let mut perm = std::fs::metadata(&p).unwrap().permissions();
12554            perm.set_mode(0o755);
12555            std::fs::set_permissions(&p, perm).unwrap();
12556        }
12557        p
12558    }
12559
12560    #[test]
12561    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
12562    fn enumerate_finds_no_binaries_when_path_empty() {
12563        let bins = enumerate_path_wire_binaries_from("", None);
12564        assert!(
12565            bins.is_empty(),
12566            "empty PATH yields no binaries, got {bins:?}"
12567        );
12568    }
12569
12570    #[test]
12571    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
12572    fn enumerate_detects_two_distinct_binaries_in_path_order() {
12573        let d1 = tempfile::tempdir().unwrap();
12574        let d2 = tempfile::tempdir().unwrap();
12575        let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
12576        let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
12577        let path = format!("{}:{}", d1.path().display(), d2.path().display());
12578
12579        let bins = enumerate_path_wire_binaries_from(&path, None);
12580        assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
12581        assert_eq!(bins[0].path_index, 0);
12582        assert_eq!(bins[1].path_index, 1);
12583        assert!(bins[0].is_active(), "first PATH entry is active");
12584        assert!(!bins[1].is_active(), "second PATH entry is not active");
12585        // sha256 differs because contents differ.
12586        assert_ne!(
12587            bins[0].sha256, bins[1].sha256,
12588            "distinct contents must hash differently"
12589        );
12590        // path field is the un-canonicalized PATH-relative shape.
12591        assert_eq!(bins[0].path, p1);
12592        assert_eq!(bins[1].path, p2);
12593    }
12594
12595    #[test]
12596    #[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
12597    fn enumerate_collapses_symlink_chains_to_one_entry() {
12598        let real_dir = tempfile::tempdir().unwrap();
12599        let link_dir = tempfile::tempdir().unwrap();
12600        let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
12601        let link = link_dir.path().join("wire");
12602        #[cfg(unix)]
12603        std::os::unix::fs::symlink(&real, &link).unwrap();
12604
12605        // Put the SYMLINK first in PATH; the real binary second. Both
12606        // resolve to the same canonical file — should collapse to ONE entry
12607        // at the first PATH position.
12608        let path = format!(
12609            "{}:{}",
12610            link_dir.path().display(),
12611            real_dir.path().display()
12612        );
12613        let bins = enumerate_path_wire_binaries_from(&path, None);
12614        assert_eq!(
12615            bins.len(),
12616            1,
12617            "symlink chain must collapse to a single entry: {bins:?}"
12618        );
12619        assert!(bins[0].is_active());
12620        // path is the symlink (what the operator wrote), canonical is the real file.
12621        assert_eq!(bins[0].path, link);
12622        assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
12623    }
12624
12625    #[test]
12626    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
12627    fn shadow_warning_off_path_when_current_exe_not_on_path() {
12628        // One binary on PATH, but current_exe points somewhere else.
12629        // The off-PATH branch fires.
12630        let d = tempfile::tempdir().unwrap();
12631        write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
12632        let elsewhere = tempfile::tempdir().unwrap();
12633        let cur = elsewhere.path().join("not-on-path-wire");
12634        let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
12635        assert_eq!(bins.len(), 1);
12636        assert!(!bins[0].is_current_exe);
12637        let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
12638        assert!(
12639            warn.contains("off-PATH binary"),
12640            "off-path WARN must mention off-PATH; got: {warn}"
12641        );
12642    }
12643
12644    #[test]
12645    fn shadow_warning_fires_when_no_binaries_at_all() {
12646        let bins: Vec<PathWireBinary> = Vec::new();
12647        let warn = path_shadow_warning(&bins).expect("empty must warn");
12648        assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
12649    }
12650
12651    #[test]
12652    #[cfg_attr(windows, ignore = "PATH separator differs")]
12653    fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
12654        let d1 = tempfile::tempdir().unwrap();
12655        let d2 = tempfile::tempdir().unwrap();
12656        write_fake_wire(d1.path(), b"published\n");
12657        write_fake_wire(d2.path(), b"head\n");
12658        let path = format!("{}:{}", d1.path().display(), d2.path().display());
12659        let bins = enumerate_path_wire_binaries_from(&path, None);
12660        let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
12661        assert!(warn.contains("2 distinct"), "got: {warn}");
12662        assert!(warn.contains("ACTIVE"), "must mark the active binary");
12663        assert!(
12664            warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
12665            "must guide the operator to a fix; got: {warn}"
12666        );
12667    }
12668}
12669
12670fn cmd_upgrade(
12671    check_only: bool,
12672    local: bool,
12673    restart_mcp: bool,
12674    refresh_stale_children: bool,
12675    as_json: bool,
12676) -> Result<()> {
12677    // 0. (v0.13.3 — merged `update`) ALWAYS check crates.io first and, unless
12678    // this is a --check or --local run, self-install a newer release BEFORE the
12679    // daemon swap below — the respawn then picks up the new on-disk binary. A
12680    // crates.io/network failure must NOT block the restart, so it degrades to a
12681    // warning. `--local` skips it entirely (offline / local dev build).
12682    let update: Option<UpdateOutcome> = if local {
12683        None
12684    } else {
12685        match self_update_step(!check_only) {
12686            Ok(o) => Some(o),
12687            Err(e) => {
12688                if !check_only {
12689                    eprintln!("wire upgrade: update check skipped — {e:#}");
12690                }
12691                None
12692            }
12693        }
12694    };
12695    if let Some(o) = &update
12696        && o.installed
12697    {
12698        eprintln!(
12699            "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
12700            o.latest,
12701            o.current,
12702            o.via.unwrap_or("self-update")
12703        );
12704    }
12705
12706    // 1. Identify all running wire processes. v0.7.3: walks `pgrep -f`
12707    // on unix / `Get-CimInstance Win32_Process` on Windows via the
12708    // shared `platform::find_processes_by_cmdline`. Covers both the
12709    // long-lived sync `wire daemon` *and* the `wire relay-server`
12710    // local-only loopback — the pre-v0.7.3 upgrade only swept daemons
12711    // and left stale relay-server children pinned on the old binary,
12712    // forcing operators to `pkill -f relay-server` manually after
12713    // every version bump.
12714    let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
12715    let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
12716    // v0.14.x: also enumerate `wire mcp` server subprocesses. These are
12717    // pinned by their MCP host (Claude Code / Claude.app desktop), NOT
12718    // in wire's pidfile registry. We do NOT kill them — that would
12719    // disconnect every Claude tab's wire MCP toolset until each session
12720    // explicitly `/mcp` reconnects — but we surface their count so the
12721    // operator knows their sister sessions still run pre-upgrade code
12722    // until they reconnect. See `feedback_wire_upgrade_skips_mcp_servers`.
12723    let mcp_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire mcp");
12724    let running_pids: Vec<u32> = daemon_pids
12725        .iter()
12726        .chain(relay_pids.iter())
12727        .copied()
12728        .collect();
12729
12730    // 2. Read pidfile to surface what the daemon THINKS it is.
12731    let record = crate::ensure_up::read_pid_record("daemon");
12732    let recorded_version: Option<String> = match &record {
12733        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
12734        crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
12735        _ => None,
12736    };
12737    let cli_version = env!("CARGO_PKG_VERSION").to_string();
12738
12739    // 2b. v0.13.2 (B fix — session-scoped upgrade). `wire upgrade` now
12740    // refreshes THIS session's daemon, not the whole box. The old box-wide
12741    // design (kill every `wire daemon` process, wipe every session's pidfile,
12742    // respawn every session) was wrong for a multi-session / shared-relay box
12743    // AND broke on Windows: the CIM scan can't match the quoted
12744    // `"...\wire.exe" daemon` command line (no contiguous `wire daemon`), so it
12745    // found nothing to kill, then the respawn loop ACCUMULATED daemons
12746    // (glossy-magnolia: 2->5->8->11). The kill set is now:
12747    //   (a) THIS session's own daemon, via its pidfile pid — reliable and
12748    //       CIM-independent; plus
12749    //   (b) TRUE orphans: `wire daemon` pids owned by NO session.
12750    // It SPARES sibling sessions' daemons AND the shared loopback relay-server
12751    // (killing it would break every same-box session's routing).
12752    let my_daemon_pid = record.pid();
12753    let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
12754        .unwrap_or_default()
12755        .iter()
12756        .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
12757        .collect();
12758    let mut kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
12759    // relay_pids are intentionally NOT killed — the local relay is shared.
12760    //
12761    // v0.14.3 (closes the #198 follow-up): when `--refresh-stale-children`
12762    // is set, extend the kill set with the daemons of supervisor-reported
12763    // `stale_binary_sessions` so the supervisor respawns them on the new
12764    // binary on its next 10s poll. The supervisor's existing-pidfile check
12765    // is what made those daemons stick around in the first place — only an
12766    // explicit opt-in upgrade flag should override that policy, because
12767    // killing a daemon interrupts any in-flight sync for that session.
12768    // Errors reading supervisor state are non-fatal (no-op).
12769    let stale_children_killed: Vec<serde_json::Value> = if refresh_stale_children {
12770        match crate::daemon_supervisor::read_supervisor_state() {
12771            Ok(sv) => {
12772                let mut killed: Vec<serde_json::Value> = Vec::new();
12773                let cli_v = env!("CARGO_PKG_VERSION");
12774                for s in &sv.sessions {
12775                    if !sv.stale_binary_sessions.contains(&s.name) {
12776                        continue;
12777                    }
12778                    if let Some(pid) = s.daemon_pid {
12779                        // Don't double-add if it's already in the kill
12780                        // set (paranoia: shouldn't happen since stale
12781                        // children are sister sessions by definition).
12782                        if !kill_set.contains(&pid) {
12783                            kill_set.push(pid);
12784                        }
12785                        killed.push(json!({
12786                            "session": s.name,
12787                            "pid": pid,
12788                            "prev_version": s.daemon_version,
12789                            "cli_version": cli_v,
12790                        }));
12791                    }
12792                }
12793                if !killed.is_empty() && !as_json {
12794                    eprintln!(
12795                        "wire upgrade: --refresh-stale-children will kill {} stale-binary session daemon(s); supervisor respawns each on next 10s poll.",
12796                        killed.len()
12797                    );
12798                }
12799                killed
12800            }
12801            Err(e) => {
12802                if !as_json {
12803                    eprintln!(
12804                        "wire upgrade: --refresh-stale-children skipped — could not read supervisor state ({e:#}). \
12805                         The flag is a no-op when no `wire daemon --all-sessions` supervisor is running."
12806                    );
12807                }
12808                Vec::new()
12809            }
12810        }
12811    } else {
12812        Vec::new()
12813    };
12814
12815    if check_only {
12816        // v0.6.8: also surface session-level state + PATH dupes in --check.
12817        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
12818            .unwrap_or_default()
12819            .iter()
12820            .filter(|s| s.daemon_running)
12821            .map(|s| s.name.clone())
12822            .collect();
12823        let path_bins = enumerate_path_wire_binaries();
12824        let path_dupes: Vec<String> = path_bins
12825            .iter()
12826            .map(|b| b.canonical.to_string_lossy().into_owned())
12827            .collect();
12828        let path_binaries_detail: Vec<serde_json::Value> = path_bins
12829            .iter()
12830            .map(|b| {
12831                json!({
12832                    "path": b.path.to_string_lossy(),
12833                    "canonical": b.canonical.to_string_lossy(),
12834                    "sha256": b.sha256,
12835                    "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
12836                    "path_index": b.path_index,
12837                    "is_active": b.is_active(),
12838                    "is_current_exe": b.is_current_exe,
12839                })
12840            })
12841            .collect();
12842        let path_warning_check = path_shadow_warning(&path_bins);
12843        // v0.7.3: enumerate which service units WOULD be refreshed.
12844        // Read-only — `status_kind` doesn't touch anything.
12845        let installed_service_kinds: Vec<&'static str> = [
12846            (crate::service::ServiceKind::Daemon, "daemon"),
12847            (crate::service::ServiceKind::LocalRelay, "local-relay"),
12848        ]
12849        .into_iter()
12850        .filter_map(|(k, label)| {
12851            crate::service::status_kind(k)
12852                .ok()
12853                .filter(|r| r.status != "absent")
12854                .map(|_| label)
12855        })
12856        .collect();
12857        let (update_latest, update_available) = match &update {
12858            Some(o) => (Some(o.latest.clone()), o.available),
12859            None => (None, false),
12860        };
12861        let report = json!({
12862            "running_pids": running_pids,
12863            "running_daemons": daemon_pids,
12864            "running_relay_servers": relay_pids,
12865            // v0.14.x: surface stale `wire mcp` host-pinned server count
12866            // so JSON consumers can drive their own /mcp-reconnect UX.
12867            // `would_warn_stale_mcp_servers` is true iff there ARE any
12868            // AND --restart-mcp was NOT passed. `would_restart_mcp_servers`
12869            // is true iff --restart-mcp WAS passed (v0.14.2) — kills the
12870            // MCP procs so the host respawns them on the new binary.
12871            "running_mcp_servers": mcp_pids,
12872            "would_warn_stale_mcp_servers": !mcp_pids.is_empty() && !restart_mcp,
12873            "would_restart_mcp_servers": restart_mcp && !mcp_pids.is_empty(),
12874            "restart_mcp_requested": restart_mcp,
12875            "pidfile_version": recorded_version,
12876            "cli_version": cli_version,
12877            "latest_published": update_latest,
12878            "update_available": update_available,
12879            "would_kill": kill_set,
12880            "would_refresh_services": installed_service_kinds,
12881            "session_daemons_running": sessions_with_daemons,
12882            "path_binaries": path_dupes,
12883            "path_binaries_detail": path_binaries_detail,
12884            "path_duplicate_warning": path_dupes.len() > 1,
12885            "path_warning": path_warning_check,
12886        });
12887        if as_json {
12888            println!("{}", serde_json::to_string(&report)?);
12889        } else {
12890            println!("wire upgrade --check");
12891            println!("  cli version:      {cli_version}");
12892            match (&update_latest, update_available) {
12893                (Some(l), true) => println!("  latest published: {l}  (UPDATE AVAILABLE)"),
12894                (Some(l), false) => println!("  latest published: {l}  (up to date)"),
12895                (None, _) => println!("  latest published: (crates.io check skipped)"),
12896            }
12897            println!(
12898                "  pidfile version:  {}",
12899                recorded_version.as_deref().unwrap_or("(missing)")
12900            );
12901            if running_pids.is_empty() {
12902                println!("  running daemons:  none");
12903                println!("  running relays:   none");
12904            } else {
12905                if daemon_pids.is_empty() {
12906                    println!("  running daemons:  none");
12907                } else {
12908                    let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
12909                    println!("  running daemons:  pids {}", p.join(", "));
12910                }
12911                if relay_pids.is_empty() {
12912                    println!("  running relays:   none");
12913                } else {
12914                    let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
12915                    println!("  running relays:   pids {}", p.join(", "));
12916                }
12917                println!("  would kill all + spawn fresh");
12918            }
12919            // v0.14.x: surface the MCP-server pin gotcha in `--check` too
12920            // so an operator probing "what will this do?" sees the full
12921            // story BEFORE running the actual upgrade. v0.14.2: line
12922            // adapts to --restart-mcp.
12923            if !mcp_pids.is_empty() {
12924                let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12925                if restart_mcp {
12926                    println!(
12927                        "  wire mcp servers: pids {} (would be killed via --restart-mcp; host respawns on new binary)",
12928                        p.join(", ")
12929                    );
12930                } else {
12931                    println!(
12932                        "  wire mcp servers: pids {} (NOT killed; each Claude tab must `/mcp` reconnect, or re-run with --restart-mcp to signal them now)",
12933                        p.join(", ")
12934                    );
12935                }
12936            }
12937            if !installed_service_kinds.is_empty() {
12938                println!(
12939                    "  would refresh:    {} installed service unit(s) → new binary path",
12940                    installed_service_kinds.join(", ")
12941                );
12942            }
12943            if !sessions_with_daemons.is_empty() {
12944                println!(
12945                    "  session daemons:  {} (would respawn under new binary)",
12946                    sessions_with_daemons.join(", ")
12947                );
12948            }
12949            // v0.14.3: preview the --refresh-stale-children effect in
12950            // --check too so operators can dry-run "what would the
12951            // flag do?" before committing.
12952            if let Ok(sv) = crate::daemon_supervisor::read_supervisor_state()
12953                && !sv.stale_binary_sessions.is_empty()
12954            {
12955                let cli_v = env!("CARGO_PKG_VERSION");
12956                if refresh_stale_children {
12957                    println!(
12958                        "  stale children:   {} session(s) on old binary; --refresh-stale-children WOULD kill each so supervisor respawns on v{cli_v}",
12959                        sv.stale_binary_sessions.len()
12960                    );
12961                } else {
12962                    println!(
12963                        "  stale children:   {} session(s) on old binary (v{cli_v} is current); rerun with --refresh-stale-children to refresh them",
12964                        sv.stale_binary_sessions.len()
12965                    );
12966                }
12967                for name in &sv.stale_binary_sessions {
12968                    let ver = sv
12969                        .sessions
12970                        .iter()
12971                        .find(|s| &s.name == name)
12972                        .and_then(|s| s.daemon_version.clone())
12973                        .unwrap_or_else(|| "?".to_string());
12974                    println!("                    - {name} running v{ver}");
12975                }
12976            }
12977            if let Some(w) = &path_warning_check {
12978                println!("  PATH check:");
12979                for line in w.lines() {
12980                    println!("    {line}");
12981                }
12982            }
12983        }
12984        return Ok(());
12985    }
12986
12987    // 3. Terminate the kill set. Graceful first, then FORCE-kill any survivor.
12988    //
12989    // v0.13.2 (B fix #2): the force-kill must NOT be gated on graceful having
12990    // "succeeded". On Windows, `taskkill /PID /T` WITHOUT `/F` is a no-op for a
12991    // windowless daemon (it returns failure), so the rc9 logic — which only
12992    // force-killed pids that graceful had reported killing — force-killed
12993    // NOTHING, and the daemon survived every `wire upgrade` (glossy: pidfile
12994    // pids 3676/25236/24660 all survived → accumulation). Now we attempt
12995    // graceful best-effort, grace-wait, then force-kill EVERY pid still alive
12996    // regardless of the graceful result. Force-kill (`taskkill /F /T` /
12997    // SIGKILL) is the load-bearing step.
12998    for pid in &kill_set {
12999        let _ = crate::platform::kill_process(*pid, false); // best-effort graceful
13000    }
13001    if !kill_set.is_empty() {
13002        // Brief grace for platforms where graceful works (Unix SIGTERM).
13003        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
13004        while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
13005        {
13006            std::thread::sleep(std::time::Duration::from_millis(50));
13007        }
13008        // Force-kill every survivor — this is what actually kills the
13009        // windowless daemon on Windows.
13010        for pid in &kill_set {
13011            if process_alive_pid(*pid) {
13012                let _ = crate::platform::kill_process(*pid, true);
13013            }
13014        }
13015        std::thread::sleep(std::time::Duration::from_millis(200)); // settle
13016    }
13017    // Report what's actually gone (drives the "no stale" message + JSON).
13018    let killed: Vec<u32> = kill_set
13019        .iter()
13020        .copied()
13021        .filter(|p| !process_alive_pid(*p))
13022        .collect();
13023
13024    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
13025    //    old daemon is still owning it.
13026    let pidfile = config::state_dir()?.join("daemon.pid");
13027    if pidfile.exists() {
13028        let _ = std::fs::remove_file(&pidfile);
13029    }
13030
13031    // 4b. v0.13.2: session-scoped — only THIS session's pidfile is wiped
13032    // (already removed at step 4 above). We deliberately DO NOT touch sibling
13033    // sessions' pidfiles: their daemons were spared, so wiping their pidfiles
13034    // would make them look down and the old box-wide respawn would spawn
13035    // duplicates (the accumulation bug). Each sibling refreshes itself on its
13036    // own `wire upgrade`.
13037
13038    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
13039    // multiple distinct files on $PATH, surface the conflict — operators
13040    // get bitten when an old binary at /usr/local/bin shadows a fresh
13041    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
13042    let path_bins = enumerate_path_wire_binaries();
13043    let path_dupes: Vec<String> = path_bins
13044        .iter()
13045        .map(|b| b.canonical.to_string_lossy().into_owned())
13046        .collect();
13047    let path_binaries_detail: Vec<Value> = path_bins
13048        .iter()
13049        .map(|b| {
13050            json!({
13051                "path": b.path.to_string_lossy(),
13052                "canonical": b.canonical.to_string_lossy(),
13053                "sha256": b.sha256,
13054                "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
13055                "path_index": b.path_index,
13056                "is_active": b.is_active(),
13057                "is_current_exe": b.is_current_exe,
13058            })
13059        })
13060        .collect();
13061    let path_warning = path_shadow_warning(&path_bins);
13062
13063    // 4d. v0.7.3 NEW: refresh installed service units so they point at
13064    // the freshly-installed binary path. Without this step, an upgrade
13065    // would: kill the old daemon, leave the launchd plist /
13066    // systemd unit / Windows scheduled task pointing at the OLD
13067    // binary path (or, worse, an old binary location that's been
13068    // unlinked), and then the OS's auto-respawn would either fail or
13069    // bring the OLD binary back from the dead. Reinstalling rewrites
13070    // the unit with `std::env::current_exe()` (the freshly-resolved
13071    // path of the running upgrade-driver process) and re-bootstraps /
13072    // re-enables / re-registers so the next OS-driven start uses it.
13073    //
13074    // Only refreshes units that are already installed — does NOT
13075    // install services the operator never opted into.
13076    let mut service_refreshes: Vec<Value> = Vec::new();
13077    for kind in [
13078        crate::service::ServiceKind::Daemon,
13079        crate::service::ServiceKind::LocalRelay,
13080    ] {
13081        let already_installed = crate::service::status_kind(kind)
13082            .map(|r| r.status != "absent")
13083            .unwrap_or(false);
13084        if !already_installed {
13085            continue;
13086        }
13087        match crate::service::install_kind(kind) {
13088            Ok(rep) => service_refreshes.push(json!({
13089                "kind": rep.kind,
13090                "platform": rep.platform,
13091                "status": rep.status,
13092                "unit_path": rep.unit_path,
13093                "action": "refreshed",
13094            })),
13095            Err(e) => service_refreshes.push(json!({
13096                "kind": format!("{kind:?}"),
13097                "action": "refresh_failed",
13098                "error": format!("{e:#}"),
13099            })),
13100        }
13101    }
13102
13103    // 5. Spawn fresh daemon via ensure_up — atomically waits for
13104    //    process_alive + writes the versioned pidfile.
13105    //
13106    // v0.14.2 (#170 supervisor follow-up): when the Daemon service
13107    // was successfully refreshed AND its launchd / systemd / Task
13108    // Scheduler bootstrap succeeded, the OS will (re)start the
13109    // `wire daemon --all-sessions` supervisor on the new binary
13110    // within seconds, and the supervisor will spawn this session's
13111    // child within its 10s registry poll. ensure_daemon_running()'s
13112    // single-session foreground spawn is redundant in that path —
13113    // it would create a transient daemon that the supervisor's
13114    // singleton-guard subsequently no-ops, AND the
13115    // "wire upgrade: spawned fresh daemon (pid N)" line in the
13116    // output misleads operators into thinking pid N is the
13117    // long-lived owner.
13118    //
13119    // Skip the redundant spawn only when BOTH conditions hold:
13120    //   1. The Daemon service refresh succeeded (entry present,
13121    //      action=="refreshed").
13122    //   2. The bootstrap step itself returned a "loaded" / "enabled"
13123    //      / "registered" status (per platform). This is what
13124    //      `install_kind` reports in its `status` field when
13125    //      launchctl bootstrap / systemctl enable --now / schtasks
13126    //      Create succeeded. Anything else (status=="written")
13127    //      means the OS bootstrap failed — fall back to the
13128    //      foreground spawn so this session still has a daemon.
13129    let supervisor_will_spawn = service_refreshes.iter().any(|r| {
13130        let kind = r.get("kind").and_then(Value::as_str).unwrap_or("");
13131        let action = r.get("action").and_then(Value::as_str).unwrap_or("");
13132        let status = r.get("status").and_then(Value::as_str).unwrap_or("");
13133        kind == "daemon"
13134            && action == "refreshed"
13135            && matches!(
13136                status,
13137                "loaded" | "enabled" | "active" | "registered" | "running"
13138            )
13139    });
13140    let spawned = if supervisor_will_spawn {
13141        // Defer to launchd / systemd / Task Scheduler. Pidfile reads
13142        // below still report the eventual supervisor child's state.
13143        None
13144    } else {
13145        Some(crate::ensure_up::ensure_daemon_running()?)
13146    };
13147
13148    // 5b. v0.13.2: session-scoped — no sibling respawn. `ensure_daemon_running`
13149    // above already respawned THIS session's daemon; sibling sessions were
13150    // spared (never killed), so there is nothing to respawn for them. Each
13151    // refreshes itself on its own `wire upgrade`.
13152    let session_respawns: Vec<Value> = Vec::new();
13153
13154    let new_record = crate::ensure_up::read_pid_record("daemon");
13155    let new_pid = new_record.pid();
13156    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
13157        Some(d.version.clone())
13158    } else {
13159        None
13160    };
13161
13162    // 5c. v0.14.2: --restart-mcp also signals host-pinned `wire mcp` server
13163    // subprocesses to restart on the new binary. Per
13164    // `feedback_wire_upgrade_skips_mcp_servers`: macOS mmap + harness-pinned
13165    // MCP subprocesses mean sister Claude / Copilot CLI sessions stay on
13166    // pre-upgrade MCP code until each session explicitly `/mcp` reconnects.
13167    // Killing the MCP child closes its stdio; the MCP host (Claude Code /
13168    // Claude.app / Copilot CLI) auto-respawns it via its own restart
13169    // logic — picking up the new on-disk binary.
13170    //
13171    // Cross-session impact: kills EVERY `wire mcp` subprocess found, not
13172    // just this session's. There is no per-session MCP pidfile registry
13173    // (these procs are host-spawned). Operators opting in via the flag
13174    // accept the brief MCP-tool-unavailable window while hosts respawn.
13175    //
13176    // Same graceful-then-force-kill pattern as the daemon kill loop above —
13177    // taskkill /F is load-bearing on Windows for windowless subprocs.
13178    let killed_mcp: Vec<u32> = if restart_mcp && !mcp_pids.is_empty() {
13179        for pid in &mcp_pids {
13180            let _ = crate::platform::kill_process(*pid, false);
13181        }
13182        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
13183        while std::time::Instant::now() < deadline && mcp_pids.iter().any(|p| process_alive_pid(*p))
13184        {
13185            std::thread::sleep(std::time::Duration::from_millis(50));
13186        }
13187        for pid in &mcp_pids {
13188            if process_alive_pid(*pid) {
13189                let _ = crate::platform::kill_process(*pid, true);
13190            }
13191        }
13192        mcp_pids
13193            .iter()
13194            .copied()
13195            .filter(|p| !process_alive_pid(*p))
13196            .collect()
13197    } else {
13198        Vec::new()
13199    };
13200
13201    if as_json {
13202        println!(
13203            "{}",
13204            serde_json::to_string(&json!({
13205                "killed": killed,
13206                "found_daemons": daemon_pids,
13207                "spared_relay_servers": relay_pids,
13208                // v0.14.x: same surface as `--check` — JSON consumers
13209                // get the stale-MCP-server pid list so they can drive
13210                // operator UX (e.g., a tab-restart prompt). With
13211                // --restart-mcp (v0.14.2), `killed_mcp_server_pids`
13212                // carries what the upgrade itself signaled; the host
13213                // (Claude Code / Claude.app / Copilot CLI) respawns
13214                // them on the new binary. Without the flag, the procs
13215                // were NEVER candidates for the kill set and the
13216                // `stale_mcp_warning` is the human-readable nudge.
13217                "stale_mcp_server_pids": mcp_pids,
13218                "killed_mcp_server_pids": killed_mcp,
13219                "restart_mcp_requested": restart_mcp,
13220                "stale_mcp_warning": if mcp_pids.is_empty() || restart_mcp {
13221                    Value::Null
13222                } else {
13223                    json!(format!(
13224                        "{} `wire mcp` server subprocess(es) still on pre-upgrade code; each Claude tab must `/mcp` reconnect to pick up the new binary (or re-run with `wire upgrade --restart-mcp` to signal them now)",
13225                        mcp_pids.len()
13226                    ))
13227                },
13228                "service_refreshes": service_refreshes,
13229                "spawned_fresh_daemon": spawned,
13230                "new_pid": new_pid,
13231                "new_version": new_version,
13232                "cli_version": cli_version,
13233                "session_respawns": session_respawns,
13234                "stale_children_killed": stale_children_killed,
13235                "path_binaries": path_dupes,
13236                "path_binaries_detail": path_binaries_detail,
13237                "path_warning": path_warning,
13238            }))?
13239        );
13240    } else {
13241        if killed.is_empty() {
13242            println!("wire upgrade: no stale wire processes running");
13243        } else {
13244            let killed_list = killed
13245                .iter()
13246                .map(|p| p.to_string())
13247                .collect::<Vec<_>>()
13248                .join(", ");
13249            // Session-scoped: report what was actually killed, and that the
13250            // shared relay-server was SPARED (not killed) — the old wording
13251            // lumped the spared relay into the killed count and read like it
13252            // had been terminated (glossy-magnolia nit).
13253            if relay_pids.is_empty() {
13254                println!(
13255                    "wire upgrade: killed {} daemon(s) [{killed_list}]",
13256                    killed.len()
13257                );
13258            } else {
13259                let relay_list = relay_pids
13260                    .iter()
13261                    .map(|p| p.to_string())
13262                    .collect::<Vec<_>>()
13263                    .join(", ");
13264                println!(
13265                    "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
13266                    killed.len(),
13267                    relay_pids.len()
13268                );
13269            }
13270        }
13271        if !stale_children_killed.is_empty() {
13272            let cli_v = env!("CARGO_PKG_VERSION");
13273            println!(
13274                "wire upgrade: refreshed {} stale-binary session daemon(s) (supervisor respawns on v{cli_v} on next 10s poll):",
13275                stale_children_killed.len()
13276            );
13277            for entry in &stale_children_killed {
13278                let name = entry.get("session").and_then(Value::as_str).unwrap_or("?");
13279                let pid = entry.get("pid").and_then(Value::as_u64).unwrap_or(0);
13280                let prev = entry
13281                    .get("prev_version")
13282                    .and_then(Value::as_str)
13283                    .unwrap_or("?");
13284                println!("                    - {name} (pid {pid}, was v{prev})");
13285            }
13286        }
13287        if !service_refreshes.is_empty() {
13288            println!(
13289                "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
13290                service_refreshes.len()
13291            );
13292            for r in &service_refreshes {
13293                let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
13294                let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
13295                let status = r.get("status").and_then(Value::as_str).unwrap_or("");
13296                let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
13297                if action == "refreshed" {
13298                    println!("                    - {kind}: {action} ({status}, {platform})");
13299                } else {
13300                    let err = r.get("error").and_then(Value::as_str).unwrap_or("");
13301                    println!("                    - {kind}: {action} ({err})");
13302                }
13303            }
13304        }
13305        match spawned {
13306            Some(true) => println!(
13307                "wire upgrade: spawned fresh daemon (pid {} v{})",
13308                new_pid
13309                    .map(|p| p.to_string())
13310                    .unwrap_or_else(|| "?".to_string()),
13311                new_version.as_deref().unwrap_or(&cli_version),
13312            ),
13313            Some(false) => {
13314                println!("wire upgrade: daemon was already running on current binary");
13315            }
13316            // v0.14.2 (#170 follow-up): Daemon service refresh
13317            // succeeded → launchd / systemd / Task Scheduler will
13318            // (re)start the `--all-sessions` supervisor on the new
13319            // binary, which spawns this session's child within its
13320            // next registry poll (default 10s). No foreground spawn
13321            // needed.
13322            None => println!(
13323                "wire upgrade: daemon refresh deferred to {} supervisor (will spawn within 10s)",
13324                if cfg!(target_os = "macos") {
13325                    "launchd"
13326                } else if cfg!(target_os = "linux") {
13327                    "systemd"
13328                } else if cfg!(target_os = "windows") {
13329                    "Task Scheduler"
13330                } else {
13331                    "OS"
13332                }
13333            ),
13334        }
13335        if !session_respawns.is_empty() {
13336            println!(
13337                "wire upgrade: refreshed {} session daemon(s):",
13338                session_respawns.len()
13339            );
13340            for r in &session_respawns {
13341                let h = r["session_home"].as_str().unwrap_or("?");
13342                let s = r["status"].as_str().unwrap_or("?");
13343                let label = std::path::Path::new(h)
13344                    .file_name()
13345                    .map(|f| f.to_string_lossy().into_owned())
13346                    .unwrap_or_else(|| h.to_string());
13347                println!("  {label:<24} {s}");
13348            }
13349        }
13350        if let Some(msg) = &path_warning {
13351            eprintln!("wire upgrade: {msg}");
13352        }
13353        // v0.14.x: surface MCP-server subprocess status. Without
13354        // --restart-mcp, warn the operator that sister Claude tabs
13355        // keep running pre-upgrade code until each one explicitly
13356        // `/mcp` reconnects — the "fix shipped but my sister session
13357        // still shows the old behavior" support-ping pattern that
13358        // surfaced this gap. With --restart-mcp (v0.14.2), report
13359        // what we signaled so the operator sees the brief
13360        // MCP-tool-unavailable window is by design.
13361        if restart_mcp {
13362            if !killed_mcp.is_empty() {
13363                let p: Vec<String> = killed_mcp.iter().map(|p| p.to_string()).collect();
13364                println!(
13365                    "wire upgrade: killed {} `wire mcp` server subprocess(es) [{}]; host (Claude Code / Claude.app / Copilot CLI) will respawn on the new binary.",
13366                    killed_mcp.len(),
13367                    p.join(", ")
13368                );
13369            } else if mcp_pids.is_empty() {
13370                // --restart-mcp was set but no MCP servers were running.
13371                // Common when the operator runs `wire upgrade` from a
13372                // shell with no Claude / Copilot session attached.
13373                println!(
13374                    "wire upgrade: --restart-mcp set, but no `wire mcp` server subprocesses were running."
13375                );
13376            } else {
13377                // Asked to restart but none of them actually died — the
13378                // operator should investigate (likely a permission
13379                // issue or a sibling-user pid that wire can't signal).
13380                let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
13381                eprintln!(
13382                    "wire upgrade: WARNING — --restart-mcp requested but {} `wire mcp` subprocess(es) [{}] survived signaling. Check process ownership / OS permissions.",
13383                    mcp_pids.len(),
13384                    p.join(", ")
13385                );
13386            }
13387        } else if !mcp_pids.is_empty() {
13388            let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
13389            eprintln!(
13390                "wire upgrade: NOTE — {} `wire mcp` server subprocess(es) [{}] still on pre-upgrade code (Claude Code / Claude.app pin these at session start). Each Claude tab must `/mcp` reconnect (or restart the host app) to pick up the new binary. Run `wire upgrade --restart-mcp` to signal them now.",
13391                mcp_pids.len(),
13392                p.join(", ")
13393            );
13394        }
13395    }
13396    Ok(())
13397}
13398
13399/// v0.9.1: should this command emit JSON by default?
13400///
13401/// - `explicit=true` → operator passed `--json`, always JSON.
13402/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
13403///   captured output parses cleanly without operators remembering to
13404///   append `--json`. Mirrors `gh`, `kubectl`, etc.
13405/// - interactive TTY → human format (false).
13406/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
13407///   that parsed the human text by accident).
13408fn json_default(explicit: bool) -> bool {
13409    if explicit {
13410        return true;
13411    }
13412    if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
13413        return false;
13414    }
13415    use std::io::IsTerminal;
13416    !std::io::stdout().is_terminal()
13417}
13418
13419fn process_alive_pid(pid: u32) -> bool {
13420    // v0.7.3: delegate to the cross-platform helper. See
13421    // `platform::process_alive` for the per-OS dispatch — Windows now
13422    // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
13423    // gave a hard-coded false on Windows pre-v0.7.3.
13424    crate::platform::process_alive(pid)
13425}
13426
13427// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
13428
13429/// Iterative Levenshtein distance between two strings, case-insensitive.
13430/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
13431/// resolves against (typically <30 chars).
13432fn levenshtein_ci(a: &str, b: &str) -> usize {
13433    let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
13434    let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
13435    let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
13436    let (m, n) = (a.len(), b.len());
13437    if m == 0 {
13438        return n;
13439    }
13440    let mut prev: Vec<usize> = (0..=m).collect();
13441    let mut curr = vec![0usize; m + 1];
13442    for j in 1..=n {
13443        curr[0] = j;
13444        for i in 1..=m {
13445            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
13446            curr[i] = std::cmp::min(
13447                std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
13448                prev[i - 1] + cost,
13449            );
13450        }
13451        std::mem::swap(&mut prev, &mut curr);
13452    }
13453    prev[m]
13454}
13455
13456/// Return up to `max_results` names from `pool` whose edit distance to
13457/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
13458/// "did you mean" suggestions on resolution miss.
13459pub fn closest_candidates(
13460    needle: &str,
13461    pool: &[String],
13462    max_distance: usize,
13463    max_results: usize,
13464) -> Vec<String> {
13465    let mut scored: Vec<(usize, &String)> = pool
13466        .iter()
13467        .map(|c| (levenshtein_ci(needle, c), c))
13468        .filter(|(d, _)| *d <= max_distance)
13469        .collect();
13470    scored.sort_by_key(|(d, _)| *d);
13471    scored
13472        .into_iter()
13473        .take(max_results)
13474        .map(|(_, c)| c.clone())
13475        .collect()
13476}
13477
13478/// Collect every name that `resolve_name_to_target` would currently
13479/// match: pinned-peer handles, pinned-peer character nicknames, sister
13480/// session names, sister character nicknames, sister handles. Used for
13481/// the `did_you_mean` pool on resolution miss.
13482fn known_local_names() -> Vec<String> {
13483    let mut names: Vec<String> = Vec::new();
13484    if let Ok(trust) = config::read_trust() {
13485        // (debug eprintln removed; left bug-trail in commit message)
13486        // trust.agents is an object keyed by handle, NOT an array —
13487        // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
13488        // the object's keys (which ARE the handles) plus each entry's
13489        // did for the DID-derived character nickname.
13490        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
13491            for (handle, agent) in agents {
13492                names.push(handle.clone());
13493                if let Some(did) = agent.get("did").and_then(Value::as_str) {
13494                    let ch = crate::character::Character::from_did(did);
13495                    names.push(ch.nickname);
13496                }
13497            }
13498        }
13499    }
13500    if let Ok(sessions) = crate::session::list_sessions() {
13501        for s in sessions {
13502            names.push(s.name.clone());
13503            if let Some(h) = &s.handle {
13504                names.push(h.clone());
13505            }
13506            if let Some(ch) = &s.character {
13507                names.push(ch.nickname.clone());
13508            }
13509        }
13510    }
13511    names.sort();
13512    names.dedup();
13513    names
13514}
13515
13516/// v0.9.2 deprecation banner with two ergonomic guards:
13517/// 1. Suppress in JSON mode (the caller is expected to fold the
13518///    deprecation note into its JSON output instead).
13519/// 2. Cache once-per-shell-session via a marker env var; subsequent
13520///    invocations in the same shell stay silent.
13521///
13522/// `verb` is the legacy verb name, `replacement` is the canonical one.
13523fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
13524    if json_mode {
13525        return;
13526    }
13527    // Pull a marker from environment of THIS process. Persistent across
13528    // multiple wire invocations only when the shell sets and exports
13529    // WIRE_DEPRECATION_NAGGED — operators rarely do, so practically
13530    // this nags once per `wire foo` invocation. The single-process
13531    // dedup matters most for scripts that call multiple deprecated
13532    // verbs in one wire run, which is currently impossible (one verb
13533    // per process) but documented for future loop-style wire shells.
13534    let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
13535    if std::env::var(&key).is_ok() {
13536        return;
13537    }
13538    // SAFETY: deprecation_warn is called from sync dispatcher code paths
13539    // before any worker thread spawns; env::set_var in Rust 2024 is
13540    // safe at that point. Pattern matches maybe_adopt_session_wire_home.
13541    unsafe {
13542        std::env::set_var(&key, "1");
13543    }
13544    eprintln!(
13545        "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
13546         Will be removed in v1.0 (target 2026-Q3). \
13547         Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
13548        verb.replace('-', "_")
13549    );
13550}
13551
13552// ---------- doctor (single-command diagnostic) ----------
13553
13554/// One DoctorCheck = one verdict on one health dimension.
13555#[derive(Clone, Debug, serde::Serialize)]
13556pub struct DoctorCheck {
13557    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
13558    /// Stable across versions for tooling consumption.
13559    pub id: String,
13560    /// PASS / WARN / FAIL.
13561    pub status: String,
13562    /// One-line human summary.
13563    pub detail: String,
13564    /// Optional remediation hint shown after the failing line.
13565    #[serde(skip_serializing_if = "Option::is_none")]
13566    pub fix: Option<String>,
13567}
13568
13569impl DoctorCheck {
13570    fn pass(id: &str, detail: impl Into<String>) -> Self {
13571        Self {
13572            id: id.into(),
13573            status: "PASS".into(),
13574            detail: detail.into(),
13575            fix: None,
13576        }
13577    }
13578    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
13579        Self {
13580            id: id.into(),
13581            status: "WARN".into(),
13582            detail: detail.into(),
13583            fix: Some(fix.into()),
13584        }
13585    }
13586    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
13587        Self {
13588            id: id.into(),
13589            status: "FAIL".into(),
13590            detail: detail.into(),
13591            fix: Some(fix.into()),
13592        }
13593    }
13594}
13595
13596/// `wire doctor` — single-command diagnostic for the silent-fail classes
13597/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
13598/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
13599/// so operators don't have to know where each lives.
13600fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
13601    let checks: Vec<DoctorCheck> = vec![
13602        check_daemon_health(),
13603        check_daemon_pid_consistency(),
13604        check_relay_reachable(),
13605        check_pair_rejections(recent_rejections),
13606        check_cursor_progress(),
13607        check_peer_staleness(7),
13608        check_and_heal_self_userinfo_endpoints(),
13609        check_stale_inbound_pairs(),
13610    ];
13611
13612    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
13613    let warns = checks.iter().filter(|c| c.status == "WARN").count();
13614
13615    if as_json {
13616        println!(
13617            "{}",
13618            serde_json::to_string(&json!({
13619                "checks": checks,
13620                "fail_count": fails,
13621                "warn_count": warns,
13622                "ok": fails == 0,
13623            }))?
13624        );
13625    } else {
13626        println!("wire doctor — {} checks", checks.len());
13627        for c in &checks {
13628            let bullet = match c.status.as_str() {
13629                "PASS" => "✓",
13630                "WARN" => "!",
13631                "FAIL" => "✗",
13632                _ => "?",
13633            };
13634            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
13635            if let Some(fix) = &c.fix {
13636                println!("      fix: {fix}");
13637            }
13638        }
13639        println!();
13640        if fails == 0 && warns == 0 {
13641            println!("ALL GREEN");
13642        } else {
13643            println!("{fails} FAIL, {warns} WARN");
13644        }
13645    }
13646
13647    if fails > 0 {
13648        std::process::exit(1);
13649    }
13650    Ok(())
13651}
13652
13653/// Check: daemon running, exactly one instance, no orphans.
13654///
13655/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
13656/// days, advancing cursor without pinning). `wire status` lied about it.
13657/// `wire doctor` must catch THIS class: multiple daemons running, OR
13658/// pid-file claims daemon down while a process is actually up.
13659fn check_daemon_health() -> DoctorCheck {
13660    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
13661    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
13662    // hardening): every surface routes through ensure_up::daemon_liveness
13663    // so they share one view of the world. No more parallel liveness
13664    // logic to drift out of sync.
13665    let snap = crate::ensure_up::daemon_liveness();
13666    let pgrep_pids = &snap.pgrep_pids;
13667    let pidfile_pid = snap.pidfile_pid;
13668    let pidfile_alive = snap.pidfile_alive;
13669    let orphan_pids = &snap.orphan_pids;
13670
13671    let fmt_pids = |xs: &[u32]| -> String {
13672        xs.iter()
13673            .map(|p| p.to_string())
13674            .collect::<Vec<_>>()
13675            .join(", ")
13676    };
13677
13678    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
13679        (0, _, _) => DoctorCheck::fail(
13680            "daemon",
13681            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
13682            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
13683        ),
13684        // Single daemon AND it matches the pidfile → healthy.
13685        (1, true, true) => DoctorCheck::pass(
13686            "daemon",
13687            format!(
13688                "one daemon running (pid {}, matches pidfile)",
13689                pgrep_pids[0]
13690            ),
13691        ),
13692        // Pidfile is alive but pgrep ALSO sees orphan processes.
13693        (n, true, false) => DoctorCheck::fail(
13694            "daemon",
13695            format!(
13696                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
13697                 The orphans race the relay cursor — they advance past events your current binary can't process. \
13698                 (Issue #2 exact class.)",
13699                fmt_pids(pgrep_pids),
13700                pidfile_pid.unwrap(),
13701                fmt_pids(orphan_pids),
13702            ),
13703            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
13704        ),
13705        // Pidfile is dead but processes ARE running → all are orphans.
13706        (n, false, _) => DoctorCheck::fail(
13707            "daemon",
13708            format!(
13709                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
13710                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
13711                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
13712                fmt_pids(pgrep_pids),
13713                match pidfile_pid {
13714                    Some(p) => format!("claims pid {p} which is dead"),
13715                    None => "is missing".to_string(),
13716                },
13717            ),
13718            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
13719        ),
13720        // v0.14.2 (#170 supervisor follow-up): the
13721        // `(n>1, true, orphan_pids.is_empty())` case is the
13722        // legitimate `wire daemon --all-sessions` supervisor topology
13723        // — supervisor + N session children, all accounted for via
13724        // their per-session pidfiles + the central supervisor.pid.
13725        // Pre-fix this fell through to the legacy "Multiple daemons
13726        // race the relay cursor" warning with a destructive
13727        // `pkill -f "wire daemon"; wire daemon &` recommendation
13728        // that would WIPE the working supervisor + every session
13729        // child. Operators on the #170 path saw it on every
13730        // `wire doctor`.
13731        (n, true, true) => {
13732            // Probe: is one of these pids the supervisor?
13733            let supervisor_pid: Option<u32> = crate::session::sessions_root()
13734                .ok()
13735                .map(|root| root.join("supervisor.pid"))
13736                .filter(|p| p.exists())
13737                .and_then(|p| std::fs::read_to_string(p).ok())
13738                .and_then(|s| s.trim().parse::<u32>().ok())
13739                .filter(|p| crate::ensure_up::pid_is_alive(*p));
13740            if let Some(sup) = supervisor_pid
13741                && pgrep_pids.contains(&sup)
13742            {
13743                let child_count = n.saturating_sub(1);
13744                DoctorCheck::pass(
13745                    "daemon",
13746                    format!(
13747                        "supervisor (pid {sup}) + {child_count} session child daemon(s) — legitimate #170 `--all-sessions` topology, no orphans"
13748                    ),
13749                )
13750            } else {
13751                DoctorCheck::warn(
13752                    "daemon",
13753                    format!(
13754                        "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
13755                        fmt_pids(pgrep_pids)
13756                    ),
13757                    "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
13758                )
13759            }
13760        }
13761    }
13762}
13763
13764/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
13765/// check. Surfaces version mismatch (daemon running old binary text in
13766/// memory under a current symlink — today's exact bug class), schema
13767/// drift (future format bumps), and identity contamination (daemon's
13768/// recorded DID doesn't match this box's configured DID).
13769///
13770/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
13771/// JSON pid record whose recorded `pid` is no longer a live OS process.
13772/// Pre-hardening this check PASSed in that state (it only validated
13773/// content, not liveness), letting `wire status: DOWN` and
13774/// `wire doctor: PASS` disagree for 25 min in incident #2.
13775fn check_daemon_pid_consistency() -> DoctorCheck {
13776    let snap = crate::ensure_up::daemon_liveness();
13777    match &snap.record {
13778        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
13779            "daemon_pid_consistency",
13780            "no daemon.pid yet — fresh box or daemon never started",
13781        ),
13782        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
13783            "daemon_pid_consistency",
13784            format!("daemon.pid is corrupt: {reason}"),
13785            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
13786        ),
13787        crate::ensure_up::PidRecord::LegacyInt(pid) => {
13788            // Legacy pidfile: still surface liveness so a dead legacy pid
13789            // doesn't quietly PASS this check while status says DOWN.
13790            let pid = *pid;
13791            if !crate::ensure_up::pid_is_alive(pid) {
13792                return DoctorCheck::warn(
13793                    "daemon_pid_consistency",
13794                    format!(
13795                        "daemon.pid (legacy-int) points at pid {pid} which is not running. \
13796                         Stale pidfile from a crashed pre-0.5.11 daemon. \
13797                         (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
13798                    ),
13799                    "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
13800                );
13801            }
13802            DoctorCheck::warn(
13803                "daemon_pid_consistency",
13804                format!(
13805                    "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
13806                     Daemon was started by a pre-0.5.11 binary."
13807                ),
13808                "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
13809            )
13810        }
13811        crate::ensure_up::PidRecord::Json(d) => {
13812            // v0.5.19 liveness gate: if the recorded pid is dead, the
13813            // pidfile is stale and the rest of the content drift checks
13814            // are moot — `wire upgrade` is the answer regardless.
13815            if !snap.pidfile_alive {
13816                return DoctorCheck::warn(
13817                    "daemon_pid_consistency",
13818                    format!(
13819                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
13820                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
13821                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
13822                        pid = d.pid,
13823                        version = d.version,
13824                    ),
13825                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
13826                     (kills any orphan daemon advancing the cursor without coordination)",
13827                );
13828            }
13829            let mut issues: Vec<String> = Vec::new();
13830            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
13831                issues.push(format!(
13832                    "schema={} (expected {})",
13833                    d.schema,
13834                    crate::ensure_up::DAEMON_PID_SCHEMA
13835                ));
13836            }
13837            let cli_version = env!("CARGO_PKG_VERSION");
13838            if d.version != cli_version {
13839                issues.push(format!("version daemon={} cli={cli_version}", d.version));
13840            }
13841            if !std::path::Path::new(&d.bin_path).exists() {
13842                issues.push(format!("bin_path {} missing on disk", d.bin_path));
13843            }
13844            // Cross-check DID + relay against current config (best-effort).
13845            if let Ok(card) = config::read_agent_card()
13846                && let Some(current_did) = card.get("did").and_then(Value::as_str)
13847                && let Some(recorded_did) = &d.did
13848                && recorded_did != current_did
13849            {
13850                issues.push(format!(
13851                    "did daemon={recorded_did} config={current_did} — identity drift"
13852                ));
13853            }
13854            if let Ok(state) = config::read_relay_state()
13855                && let Some(current_relay) = state
13856                    .get("self")
13857                    .and_then(|s| s.get("relay_url"))
13858                    .and_then(Value::as_str)
13859                && let Some(recorded_relay) = &d.relay_url
13860                && recorded_relay != current_relay
13861            {
13862                issues.push(format!(
13863                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
13864                ));
13865            }
13866            if issues.is_empty() {
13867                DoctorCheck::pass(
13868                    "daemon_pid_consistency",
13869                    format!(
13870                        "daemon v{} bound to {} as {}",
13871                        d.version,
13872                        d.relay_url.as_deref().unwrap_or("?"),
13873                        d.did.as_deref().unwrap_or("?")
13874                    ),
13875                )
13876            } else {
13877                DoctorCheck::warn(
13878                    "daemon_pid_consistency",
13879                    format!("daemon pidfile drift: {}", issues.join("; ")),
13880                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
13881                )
13882            }
13883        }
13884    }
13885}
13886
13887/// Check: bound relay's /healthz returns 200.
13888fn check_relay_reachable() -> DoctorCheck {
13889    let state = match config::read_relay_state() {
13890        Ok(s) => s,
13891        Err(e) => {
13892            return DoctorCheck::fail(
13893                "relay",
13894                format!("could not read relay state: {e}"),
13895                "run `wire up <handle>@<relay>` to bootstrap",
13896            );
13897        }
13898    };
13899    let url = state
13900        .get("self")
13901        .and_then(|s| s.get("relay_url"))
13902        .and_then(Value::as_str)
13903        .unwrap_or("");
13904    if url.is_empty() {
13905        return DoctorCheck::warn(
13906            "relay",
13907            "no relay bound — wire send/pull will not work",
13908            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
13909        );
13910    }
13911    let client = crate::relay_client::RelayClient::new(url);
13912    match client.check_healthz() {
13913        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
13914        Err(e) => DoctorCheck::fail(
13915            "relay",
13916            format!("{url} unreachable: {e}"),
13917            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
13918        ),
13919    }
13920}
13921
13922/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
13923/// entry there is a silent failure that, pre-0.5.11, would have left the
13924/// operator wondering why pairing didn't complete.
13925fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
13926    let path = match config::state_dir() {
13927        Ok(d) => d.join("pair-rejected.jsonl"),
13928        Err(e) => {
13929            return DoctorCheck::warn(
13930                "pair_rejections",
13931                format!("could not resolve state dir: {e}"),
13932                "set WIRE_HOME or fix XDG_STATE_HOME",
13933            );
13934        }
13935    };
13936    if !path.exists() {
13937        return DoctorCheck::pass(
13938            "pair_rejections",
13939            "no pair-rejected.jsonl — no recorded pair failures",
13940        );
13941    }
13942    let body = match std::fs::read_to_string(&path) {
13943        Ok(b) => b,
13944        Err(e) => {
13945            return DoctorCheck::warn(
13946                "pair_rejections",
13947                format!("could not read {path:?}: {e}"),
13948                "check file permissions",
13949            );
13950        }
13951    };
13952    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
13953    if lines.is_empty() {
13954        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
13955    }
13956    let total = lines.len();
13957    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
13958    let mut summary: Vec<String> = Vec::new();
13959    for line in &recent {
13960        if let Ok(rec) = serde_json::from_str::<Value>(line) {
13961            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
13962            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
13963            summary.push(format!("{peer}/{code}"));
13964        }
13965    }
13966    DoctorCheck::warn(
13967        "pair_rejections",
13968        format!(
13969            "{total} pair failures recorded. recent: [{}]",
13970            summary.join(", ")
13971        ),
13972        format!(
13973            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
13974        ),
13975    )
13976}
13977
13978/// Check: cursor isn't stuck. We can't tell without polling — but we can
13979/// report the current cursor position so operators see if it changes.
13980/// Real "stuck" detection needs two pulls separated in time; defer that
13981/// behaviour to a `wire doctor --watch` mode.
13982///
13983/// Heal stale userinfo from this agent's own published relay endpoints.
13984///
13985/// Failure mode this check closes:
13986///   PR #61 added a guard at the WRITE side that prevents NEW userinfo-
13987///   bearing endpoints (`https://<handle>@<host>`) from ever being
13988///   persisted or published. But operators who ran a pre-#61 `wire up
13989///   <handle>@<relay>` already had the malformed endpoint baked into
13990///   their on-disk `self.endpoints[]` AND their signed agent-card AND
13991///   their phonebook entry. The fix prevented the bleeding; it didn't
13992///   heal the wound. Symptoms still visible:
13993///     - Every inbound POST to the malformed endpoint (pair_drop_ack,
13994///       messages) gets a Cloudflare 400 ("missing Bearer token" /
13995///       bare 400). Peers running pre-#62 wire can't deliver to us at
13996///       all (the failover from #62 lets newer peers walk past the
13997///       bad first endpoint to a clean one if both are published —
13998///       but two-endpoint operators still get a 400 for every event
13999///       on their FIRST attempt, and operators with only the
14000///       malformed endpoint are unreachable).
14001///     - `wire pull` from our own malformed slot 400s on every cycle
14002///       (the operator sees a stderr error line every poll).
14003///     - Surfaced concretely when swift-harbor ↔ slate-lotus paired
14004///       2026-05-27: slate-lotus's pair_drop_ack 400'd; my own pulls
14005///       400'd; bilateral handshake couldn't complete via the bad
14006///       endpoint.
14007///
14008/// This is a healable failure mode — the same `strip_relay_url_userinfo`
14009/// logic from #61 can be applied to existing on-disk state. We do it
14010/// inside `wire doctor` (rather than a separate `wire heal` command)
14011/// because:
14012///   1. `wire doctor` is the canonical "what's wrong + fix it" surface
14013///      operators already know to run when something looks off.
14014///   2. The mutation is unambiguously correct — userinfo on a self-
14015///      published relay endpoint has zero legitimate cases (the
14016///      one-name rule means the handle is DID-derived, never URL
14017///      userinfo).
14018///   3. Auto-heal is consistent with what `wire bind-relay https://...`
14019///      / `wire claim` already do at the WRITE side under #61 —
14020///      this just extends the same guard to read-side cleanup.
14021///
14022/// What this check does:
14023///   - Reads `relay.json` and inspects `self.endpoints[]` plus the
14024///     legacy top-level `self.relay_url`/`slot_id`/`slot_token` triple.
14025///   - If any endpoint's `relay_url` contains userinfo, removes that
14026///     endpoint from the array AND (if the legacy top-level was the
14027///     malformed one) promotes the first clean endpoint's coords to
14028///     the legacy slots.
14029///   - Atomically writes back via `write_relay_state` (full lock +
14030///     tmp+rename, same path every other writer uses).
14031///   - Reports PASS if nothing needed healing, WARN if healing happened
14032///     (with the list of stripped URLs + a remediation pointer to
14033///     `wire claim <persona>` for re-publishing the agent-card to the
14034///     phonebook).
14035///
14036/// Re-claim is NOT auto-run here: the doctor check is read-state-bound,
14037/// and `wire claim` requires a clean agent-card resign + network
14038/// round-trip + persona arg. Operators get the explicit next step in
14039/// the WARN fix text. Two-step is the right friction: heal silently,
14040/// claim explicitly.
14041fn check_and_heal_self_userinfo_endpoints() -> DoctorCheck {
14042    let mut state = match config::read_relay_state() {
14043        Ok(s) => s,
14044        Err(_) => {
14045            return DoctorCheck::pass(
14046                "self-userinfo-endpoints",
14047                "no relay state yet — nothing published to heal".to_string(),
14048            );
14049        }
14050    };
14051    let self_block = match state.get_mut("self").and_then(Value::as_object_mut) {
14052        Some(s) => s,
14053        None => {
14054            return DoctorCheck::pass(
14055                "self-userinfo-endpoints",
14056                "no self block in relay state — nothing published to heal".to_string(),
14057            );
14058        }
14059    };
14060
14061    let mut stripped: Vec<String> = Vec::new();
14062    let mut clean_seed: Option<(String, String, String)> = None;
14063
14064    if let Some(endpoints) = self_block
14065        .get_mut("endpoints")
14066        .and_then(Value::as_array_mut)
14067    {
14068        endpoints.retain(|ep| {
14069            let url = ep.get("relay_url").and_then(Value::as_str).unwrap_or("");
14070            // Reuse the exact same authority-only userinfo detection as
14071            // #61's assert_relay_url_clean_for_publish so any future
14072            // change to that authority parse stays in lockstep.
14073            if assert_relay_url_clean_for_publish(url).is_err() {
14074                stripped.push(url.to_string());
14075                false
14076            } else {
14077                if clean_seed.is_none() {
14078                    clean_seed = Some((
14079                        url.to_string(),
14080                        ep.get("slot_id")
14081                            .and_then(Value::as_str)
14082                            .unwrap_or("")
14083                            .to_string(),
14084                        ep.get("slot_token")
14085                            .and_then(Value::as_str)
14086                            .unwrap_or("")
14087                            .to_string(),
14088                    ));
14089                }
14090                true
14091            }
14092        });
14093    }
14094
14095    // Heal the legacy top-level relay_url/slot_id/slot_token triple if it
14096    // was the malformed one. Without this, v0.5.16-era readers (and the
14097    // pair_drop_ack path that falls back to legacy fields) still pick up
14098    // the userinfo URL even after we cleaned endpoints[].
14099    let mut legacy_healed = false;
14100    let legacy_url = self_block
14101        .get("relay_url")
14102        .and_then(Value::as_str)
14103        .unwrap_or("")
14104        .to_string();
14105    if !legacy_url.is_empty() && assert_relay_url_clean_for_publish(&legacy_url).is_err() {
14106        if let Some((url, sid, tok)) = &clean_seed {
14107            self_block.insert("relay_url".to_string(), Value::String(url.clone()));
14108            self_block.insert("slot_id".to_string(), Value::String(sid.clone()));
14109            self_block.insert("slot_token".to_string(), Value::String(tok.clone()));
14110            legacy_healed = true;
14111            stripped.push(format!("(legacy top-level) {legacy_url}"));
14112        } else {
14113            // No clean endpoint exists to promote — the operator only
14114            // has malformed endpoints. We can't auto-heal this safely
14115            // (would leave them with no inbox); surface as WARN with
14116            // explicit re-bind instructions and DON'T mutate.
14117            return DoctorCheck::warn(
14118                "self-userinfo-endpoints",
14119                format!(
14120                    "your published endpoint is malformed (`{legacy_url}` — handle as URL \
14121                     userinfo, the bug PR #61 prevents going forward) AND no clean endpoint \
14122                     exists to fall back to. Inbound POSTs to this endpoint 4xx; bilateral \
14123                     pairing can't complete."
14124                ),
14125                "Bind a clean federation slot first, then re-run doctor to heal: \
14126                 `wire bind-relay https://wireup.net` (or your own relay). The bind \
14127                 adds a clean endpoint additively; the next `wire doctor` run then \
14128                 strips the malformed one safely. Finally re-publish your card with \
14129                 `wire claim <your-persona>` so the phonebook serves the clean shape."
14130                    .to_string(),
14131            );
14132        }
14133    }
14134
14135    if stripped.is_empty() && !legacy_healed {
14136        return DoctorCheck::pass(
14137            "self-userinfo-endpoints",
14138            "no malformed endpoints in self-state".to_string(),
14139        );
14140    }
14141
14142    // Persist the healed state. Best-effort: if the write fails, the
14143    // operator still sees the WARN and can run `wire claim` to re-publish;
14144    // they keep the malformed entry on disk until the next doctor cycle.
14145    if let Err(e) = config::write_relay_state(&state) {
14146        return DoctorCheck::warn(
14147            "self-userinfo-endpoints",
14148            format!(
14149                "detected {} malformed userinfo-bearing endpoint(s) in self-state but \
14150                 failed to persist the heal: {e:#}. Found: {}",
14151                stripped.len(),
14152                stripped.join(", ")
14153            ),
14154            "re-run `wire doctor` — likely a transient lock contention".to_string(),
14155        );
14156    }
14157
14158    DoctorCheck::warn(
14159        "self-userinfo-endpoints",
14160        format!(
14161            "healed {} malformed endpoint(s) in self-state on disk: {}. \
14162             These were the `https://<handle>@<host>` shape that PR #61 prevents \
14163             at the write side but couldn't retroactively scrub from existing \
14164             operators. relay.json is now clean.",
14165            stripped.len(),
14166            stripped.join(", ")
14167        ),
14168        "re-publish your agent-card to the phonebook so peers resolve to the \
14169         clean endpoint: `wire claim <your-persona>` (find your persona with \
14170         `wire whoami`)."
14171            .to_string(),
14172    )
14173}
14174
14175/// v0.14.3: surface pre-#171 stale pending_inbound records for
14176/// peers already at VERIFIED+ tier. The record itself is benign
14177/// (operator can clear with `wire reject <handle>`) but until
14178/// cleared it keeps surfacing in `wire status --json` as
14179/// `stale_inbound_handles`, which leaks into automation. Doctor is
14180/// the right place to surface low-priority hygiene — operators
14181/// scan it intentionally instead of seeing it on every status
14182/// call.
14183fn check_stale_inbound_pairs() -> DoctorCheck {
14184    let pinned_verified: std::collections::HashSet<String> = config::read_trust()
14185        .ok()
14186        .and_then(|t| t.get("agents").and_then(Value::as_object).cloned())
14187        .map(|agents| {
14188            agents
14189                .into_iter()
14190                .filter_map(|(h, a)| {
14191                    let tier = a.get("tier").and_then(Value::as_str).unwrap_or("");
14192                    if matches!(tier, "VERIFIED" | "ORG_VERIFIED" | "ATTESTED") {
14193                        Some(h)
14194                    } else {
14195                        None
14196                    }
14197                })
14198                .collect()
14199        })
14200        .unwrap_or_default();
14201    let stale: Vec<String> = crate::pending_inbound_pair::list_pending_inbound()
14202        .unwrap_or_default()
14203        .into_iter()
14204        .filter(|p| pinned_verified.contains(&p.peer_handle))
14205        .map(|p| p.peer_handle)
14206        .collect();
14207    if stale.is_empty() {
14208        return DoctorCheck::pass(
14209            "stale-inbound-pairs",
14210            "no pre-#171 leftover pending_inbound records for VERIFIED peers",
14211        );
14212    }
14213    let n = stale.len();
14214    let list = stale.join(", ");
14215    let fix_list = stale
14216        .iter()
14217        .map(|h| format!("wire reject {h}"))
14218        .collect::<Vec<_>>()
14219        .join(" && ");
14220    DoctorCheck::warn(
14221        "stale-inbound-pairs",
14222        format!(
14223            "{n} VERIFIED peer(s) still carry a pre-#171 pending_inbound record: {list}. Benign but leaks into `wire status --json.pending_pairs.stale_inbound_handles`."
14224        ),
14225        format!("clear with `{fix_list}`"),
14226    )
14227}
14228
14229fn check_peer_staleness(max_silent_days: u64) -> DoctorCheck {
14230    let state = match config::read_relay_state() {
14231        Ok(s) => s,
14232        Err(_) => {
14233            return DoctorCheck::pass(
14234                "peer-staleness",
14235                "no relay state yet — nothing pinned to check".to_string(),
14236            );
14237        }
14238    };
14239    let peers = match state.get("peers").and_then(Value::as_object) {
14240        Some(p) => p,
14241        None => {
14242            return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
14243        }
14244    };
14245    if peers.is_empty() {
14246        return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
14247    }
14248    let inbox_dir = match config::inbox_dir() {
14249        Ok(d) => d,
14250        Err(_) => {
14251            return DoctorCheck::warn(
14252                "peer-staleness",
14253                "could not resolve inbox dir; skipping peer-staleness check".to_string(),
14254                "check `wire status` for state-dir resolution".to_string(),
14255            );
14256        }
14257    };
14258    let threshold_secs = max_silent_days * 24 * 60 * 60;
14259    let threshold = std::time::Duration::from_secs(threshold_secs);
14260    let now = std::time::SystemTime::now();
14261    // v0.14.3 (#14): prefer the daemon-written
14262    // `peers[<peer>].last_inbound_event_at` (RFC3339) over inbox
14263    // file mtime — mtime is fragile (backup/restore/cp/touch all
14264    // break it; FAT32 has 2s resolution etc.) and the daemon-side
14265    // field is the load-bearing sender-side staleness signal.
14266    // Falls back to mtime when the field is absent (pre-v0.14.3
14267    // sessions, or never-received-anything peers).
14268    let now_unix = now
14269        .duration_since(std::time::UNIX_EPOCH)
14270        .map(|d| d.as_secs() as i64)
14271        .unwrap_or(0);
14272    let mut stale: Vec<(String, u64, &'static str)> = Vec::new();
14273    for (peer, info) in peers {
14274        // v0.14.3 first-pass: the daemon-written field.
14275        let daemon_signal_ts = info
14276            .get("last_inbound_event_at")
14277            .and_then(Value::as_str)
14278            .and_then(|s| {
14279                time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
14280            })
14281            .map(|odt| odt.unix_timestamp());
14282        if let Some(ts) = daemon_signal_ts {
14283            let age = now_unix.saturating_sub(ts) as u64;
14284            if age > threshold_secs {
14285                stale.push((peer.clone(), age / (24 * 60 * 60), "silent"));
14286            }
14287            continue;
14288        }
14289        // Fallback: inbox file mtime (pre-v0.14.3 or never-pulled peer).
14290        let path = inbox_dir.join(format!("{peer}.jsonl"));
14291        let (age_days, kind) = match std::fs::metadata(&path) {
14292            Ok(meta) => match meta
14293                .modified()
14294                .ok()
14295                .and_then(|m| now.duration_since(m).ok())
14296            {
14297                Some(d) if d > threshold => (d.as_secs() / (24 * 60 * 60), "silent"),
14298                Some(_) => continue, // fresh — not stale
14299                None => (0, "unknown-mtime"),
14300            },
14301            Err(_) => (max_silent_days + 1, "no-inbox-file"),
14302        };
14303        stale.push((peer.clone(), age_days, kind));
14304    }
14305    if stale.is_empty() {
14306        return DoctorCheck::pass(
14307            "peer-staleness",
14308            format!(
14309                "all {} pinned peer(s) have inbox traffic within the last {max_silent_days} day(s)",
14310                peers.len()
14311            ),
14312        );
14313    }
14314    let detail = stale
14315        .iter()
14316        .map(|(p, d, k)| match *k {
14317            "no-inbox-file" => format!("{p} (no inbox file)"),
14318            "unknown-mtime" => format!("{p} (unknown last-event time)"),
14319            _ => format!("{p} ({d}d silent)"),
14320        })
14321        .collect::<Vec<_>>()
14322        .join(", ");
14323    DoctorCheck::warn(
14324        "peer-staleness",
14325        format!(
14326            "{} pinned peer(s) silent for >{max_silent_days}d: {detail}. \
14327             If the peer re-bound their relay slot, our pin is now stale — \
14328             we push successfully to a dead slot and they never see us \
14329             (asymmetric failure, both sides report green).",
14330            stale.len()
14331        ),
14332        "re-pair with `wire add <peer>@<relay>` to refresh the slot. \
14333         Once issue #15 lands, this also auto-resolves on 410 Gone."
14334            .to_string(),
14335    )
14336}
14337
14338fn check_cursor_progress() -> DoctorCheck {
14339    let state = match config::read_relay_state() {
14340        Ok(s) => s,
14341        Err(e) => {
14342            return DoctorCheck::warn(
14343                "cursor",
14344                format!("could not read relay state: {e}"),
14345                "check ~/Library/Application Support/wire/relay.json",
14346            );
14347        }
14348    };
14349    let cursor = state
14350        .get("self")
14351        .and_then(|s| s.get("last_pulled_event_id"))
14352        .and_then(Value::as_str)
14353        .map(|s| s.chars().take(16).collect::<String>())
14354        .unwrap_or_else(|| "<none>".to_string());
14355    DoctorCheck::pass(
14356        "cursor",
14357        format!(
14358            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
14359        ),
14360    )
14361}
14362
14363#[cfg(test)]
14364mod doctor_tests {
14365    use super::*;
14366
14367    #[test]
14368    fn doctor_check_constructors_set_status_correctly() {
14369        // Silent-fail-prevention rule: pass/warn/fail must be visibly
14370        // distinguishable to operators. If any constructor lets the wrong
14371        // status through, `wire doctor` lies and we're back to today's
14372        // 30-minute debug.
14373        let p = DoctorCheck::pass("x", "ok");
14374        assert_eq!(p.status, "PASS");
14375        assert_eq!(p.fix, None);
14376
14377        let w = DoctorCheck::warn("x", "watch out", "do this");
14378        assert_eq!(w.status, "WARN");
14379        assert_eq!(w.fix, Some("do this".to_string()));
14380
14381        let f = DoctorCheck::fail("x", "broken", "fix it");
14382        assert_eq!(f.status, "FAIL");
14383        assert_eq!(f.fix, Some("fix it".to_string()));
14384    }
14385
14386    #[test]
14387    fn check_pair_rejections_no_file_is_pass() {
14388        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
14389        // as a problem.
14390        config::test_support::with_temp_home(|| {
14391            config::ensure_dirs().unwrap();
14392            let c = check_pair_rejections(5);
14393            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
14394        });
14395    }
14396
14397    #[test]
14398    fn check_pair_rejections_with_entries_warns() {
14399        // Existence of rejections is itself a signal — even if each entry
14400        // is a "known good failure," the operator wants to know they
14401        // happened.
14402        config::test_support::with_temp_home(|| {
14403            config::ensure_dirs().unwrap();
14404            crate::pair_invite::record_pair_rejection(
14405                "willard",
14406                "pair_drop_ack_send_failed",
14407                "POST 502",
14408            );
14409            let c = check_pair_rejections(5);
14410            assert_eq!(c.status, "WARN");
14411            assert!(c.detail.contains("1 pair failures"));
14412            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
14413        });
14414    }
14415
14416    #[test]
14417    fn check_peer_staleness_no_peers_is_pass() {
14418        // Fresh box / no pin yet: must NOT report this as a problem
14419        // (nothing to be stale about).
14420        config::test_support::with_temp_home(|| {
14421            config::ensure_dirs().unwrap();
14422            let c = check_peer_staleness(7);
14423            assert_eq!(c.status, "PASS", "no peers should be PASS, got {c:?}");
14424        });
14425    }
14426
14427    #[test]
14428    fn check_peer_staleness_pinned_with_no_inbox_file_warns() {
14429        // Issue #14 asymmetric-stale-pin: peer is pinned but we've NEVER
14430        // received an event from them (no inbox file at all). That's
14431        // exactly the "we pushed N events, got 0 back" smell the WARN is
14432        // designed to catch.
14433        config::test_support::with_temp_home(|| {
14434            config::ensure_dirs().unwrap();
14435            // Seed a pinned peer with no corresponding inbox file.
14436            let mut state = json!({
14437                "peers": {
14438                    "stale-peer": {
14439                        "relay_url": "https://wireup.net",
14440                        "slot_id": "deadslot",
14441                        "slot_token": "tok",
14442                    }
14443                }
14444            });
14445            state["self"] = json!({});
14446            config::write_relay_state(&state).unwrap();
14447
14448            let c = check_peer_staleness(7);
14449            assert_eq!(
14450                c.status, "WARN",
14451                "pinned peer with no inbox file must surface: {c:?}"
14452            );
14453            assert!(
14454                c.detail.contains("stale-peer"),
14455                "WARN must name the silent peer so the operator can act: {}",
14456                c.detail
14457            );
14458            assert!(
14459                c.detail.contains("asymmetric")
14460                    || c.detail.contains("stale")
14461                    || c.detail.contains("dead slot"),
14462                "WARN must surface the failure-mode language so the operator \
14463                 finds the diagnosis without re-tracing: {}",
14464                c.detail
14465            );
14466            assert!(
14467                c.fix
14468                    .as_ref()
14469                    .is_some_and(|f| f.contains("wire add") && f.contains("#15")),
14470                "fix pointer must reference both the manual re-pair AND the \
14471                 follow-up issue (#15) that will automate this: {:?}",
14472                c.fix
14473            );
14474        });
14475    }
14476
14477    #[test]
14478    fn check_peer_staleness_pinned_with_fresh_inbox_is_pass() {
14479        // Negative case: pinned peer with a recent inbox event must NOT
14480        // be reported. This prevents the false-positive that would otherwise
14481        // make operators ignore the WARN.
14482        config::test_support::with_temp_home(|| {
14483            config::ensure_dirs().unwrap();
14484            let mut state = json!({
14485                "peers": {
14486                    "active-peer": {
14487                        "relay_url": "https://wireup.net",
14488                        "slot_id": "freshslot",
14489                        "slot_token": "tok",
14490                    }
14491                }
14492            });
14493            state["self"] = json!({});
14494            config::write_relay_state(&state).unwrap();
14495
14496            let inbox = config::inbox_dir().unwrap();
14497            std::fs::create_dir_all(&inbox).unwrap();
14498            std::fs::write(
14499                inbox.join("active-peer.jsonl"),
14500                "{\"event_id\":\"recent\"}\n",
14501            )
14502            .unwrap();
14503
14504            let c = check_peer_staleness(7);
14505            assert_eq!(c.status, "PASS", "fresh inbox should not warn: {c:?}");
14506        });
14507    }
14508
14509    #[test]
14510    fn check_peer_staleness_daemon_field_overrides_mtime() {
14511        // v0.14.3 (#14): when peers[<p>].last_inbound_event_at is
14512        // present, that signal trumps file mtime. Even with a
14513        // fresh inbox file mtime, an OLD daemon-written timestamp
14514        // must trigger the WARN — backup-restore should not mask
14515        // the real silence.
14516        config::test_support::with_temp_home(|| {
14517            config::ensure_dirs().unwrap();
14518            let mut state = json!({
14519                "peers": {
14520                    "ghost-peer": {
14521                        "relay_url": "https://wireup.net",
14522                        "slot_id": "ghostslot",
14523                        "slot_token": "tok",
14524                        "last_inbound_event_at": "2026-05-01T00:00:00Z",
14525                    }
14526                }
14527            });
14528            state["self"] = json!({});
14529            config::write_relay_state(&state).unwrap();
14530            // Fresh inbox mtime — would PASS via the fallback.
14531            let inbox = config::inbox_dir().unwrap();
14532            std::fs::create_dir_all(&inbox).unwrap();
14533            std::fs::write(inbox.join("ghost-peer.jsonl"), "{\"event_id\":\"x\"}\n").unwrap();
14534            let c = check_peer_staleness(7);
14535            assert_eq!(
14536                c.status, "WARN",
14537                "daemon-field staleness must override fresh mtime: {c:?}"
14538            );
14539            assert!(c.detail.contains("ghost-peer"), "got: {}", c.detail);
14540        });
14541    }
14542
14543    #[test]
14544    fn check_peer_staleness_daemon_field_fresh_overrides_old_mtime() {
14545        // Mirror case: a recent daemon-written timestamp must
14546        // PASS even with an old inbox file mtime. Backup-restore
14547        // case in reverse — operator restored an old inbox file
14548        // but pulled fresh events since.
14549        config::test_support::with_temp_home(|| {
14550            config::ensure_dirs().unwrap();
14551            // Stamp NOW-ish via OffsetDateTime so we don't drift.
14552            let now = time::OffsetDateTime::now_utc()
14553                .format(&time::format_description::well_known::Rfc3339)
14554                .unwrap();
14555            let mut state = json!({
14556                "peers": {
14557                    "active-peer": {
14558                        "relay_url": "https://wireup.net",
14559                        "slot_id": "freshslot",
14560                        "slot_token": "tok",
14561                        "last_inbound_event_at": now,
14562                    }
14563                }
14564            });
14565            state["self"] = json!({});
14566            config::write_relay_state(&state).unwrap();
14567            // Old inbox file mtime — would FAIL via the fallback.
14568            // We skip making it old (today's mtime works since the
14569            // field-driven path runs first and short-circuits).
14570            let c = check_peer_staleness(7);
14571            assert_eq!(
14572                c.status, "PASS",
14573                "recent daemon-field stamp must PASS regardless of mtime: {c:?}"
14574            );
14575        });
14576    }
14577
14578    #[test]
14579    fn check_self_userinfo_no_state_is_pass() {
14580        // Fresh box (no relay.json yet) must NOT WARN — there's nothing
14581        // published to heal, and treating a missing file as a problem
14582        // would scare every new operator on first `wire doctor` run.
14583        config::test_support::with_temp_home(|| {
14584            // Don't even call ensure_dirs — simulate truly fresh state.
14585            let c = check_and_heal_self_userinfo_endpoints();
14586            assert_eq!(c.status, "PASS", "no state should be PASS, got {c:?}");
14587        });
14588    }
14589
14590    #[test]
14591    fn check_self_userinfo_clean_state_is_pass_no_mutation() {
14592        // Negative case: clean self.endpoints[] must not trigger a heal,
14593        // must not mutate relay.json. Prevents the false-positive that
14594        // would make operators distrust the doctor.
14595        config::test_support::with_temp_home(|| {
14596            config::ensure_dirs().unwrap();
14597            let state = json!({
14598                "self": {
14599                    "endpoints": [
14600                        {
14601                            "relay_url": "https://wireup.net",
14602                            "scope": "Federation",
14603                            "slot_id": "abc",
14604                            "slot_token": "tok"
14605                        }
14606                    ],
14607                    "relay_url": "https://wireup.net",
14608                    "slot_id": "abc",
14609                    "slot_token": "tok"
14610                },
14611                "peers": {}
14612            });
14613            config::write_relay_state(&state).unwrap();
14614
14615            let c = check_and_heal_self_userinfo_endpoints();
14616            assert_eq!(c.status, "PASS", "clean state should be PASS: {c:?}");
14617
14618            // Verify state is byte-identical (no spurious write).
14619            let after = config::read_relay_state().unwrap();
14620            assert_eq!(after, state, "PASS path must NOT mutate relay.json");
14621        });
14622    }
14623
14624    #[test]
14625    fn check_self_userinfo_heals_malformed_endpoint_and_promotes_clean() {
14626        // THE regression case (swift-harbor / slate-lotus pairing 2026-05-27):
14627        // relay.json has a malformed first endpoint from before #61 AND a
14628        // clean second endpoint from a later `wire bind-relay`. The check
14629        // must (a) strip the malformed one, (b) promote the clean one's
14630        // coords to the legacy top-level triple, (c) write back, (d) emit
14631        // a WARN with the stripped URL + `wire claim` remediation pointer.
14632        config::test_support::with_temp_home(|| {
14633            config::ensure_dirs().unwrap();
14634            let state = json!({
14635                "self": {
14636                    "endpoints": [
14637                        {
14638                            "relay_url": "https://copilot-agent@wireup.net",
14639                            "scope": "Federation",
14640                            "slot_id": "stale-id",
14641                            "slot_token": "stale-token"
14642                        },
14643                        {
14644                            "relay_url": "https://wireup.net",
14645                            "scope": "Federation",
14646                            "slot_id": "clean-id",
14647                            "slot_token": "clean-token"
14648                        }
14649                    ],
14650                    "relay_url": "https://copilot-agent@wireup.net",
14651                    "slot_id": "stale-id",
14652                    "slot_token": "stale-token"
14653                },
14654                "peers": {}
14655            });
14656            config::write_relay_state(&state).unwrap();
14657
14658            let c = check_and_heal_self_userinfo_endpoints();
14659            assert_eq!(c.status, "WARN", "heal should report WARN: {c:?}");
14660            assert!(
14661                c.detail.contains("healed") && c.detail.contains("copilot-agent@wireup.net"),
14662                "WARN must name the stripped URL so the operator sees what changed: {}",
14663                c.detail
14664            );
14665            assert!(
14666                c.fix.as_ref().is_some_and(|f| f.contains("wire claim")),
14667                "fix must point at re-publishing the agent-card so the phonebook entry \
14668                 matches the healed state on disk: {:?}",
14669                c.fix
14670            );
14671
14672            // Verify the file on disk is healed:
14673            //   - endpoints[] contains ONLY the clean entry.
14674            //   - legacy top-level fields promoted from the clean entry.
14675            let after = config::read_relay_state().unwrap();
14676            let endpoints = after["self"]["endpoints"].as_array().unwrap();
14677            assert_eq!(endpoints.len(), 1, "malformed endpoint must be removed");
14678            assert_eq!(endpoints[0]["relay_url"], "https://wireup.net");
14679            assert_eq!(after["self"]["relay_url"], "https://wireup.net");
14680            assert_eq!(after["self"]["slot_id"], "clean-id");
14681            assert_eq!(after["self"]["slot_token"], "clean-token");
14682        });
14683    }
14684
14685    #[test]
14686    fn check_self_userinfo_no_clean_fallback_warns_without_mutating() {
14687        // Edge: operator only has the malformed endpoint, no clean fallback
14688        // to promote. Auto-healing would leave them with NO inbox slot at
14689        // all — strictly worse than the malformed shape (peers can at least
14690        // try the bad endpoint). Check must surface a WARN with explicit
14691        // re-bind instructions and DO NOT touch the state.
14692        config::test_support::with_temp_home(|| {
14693            config::ensure_dirs().unwrap();
14694            let state = json!({
14695                "self": {
14696                    "endpoints": [
14697                        {
14698                            "relay_url": "https://copilot-agent@wireup.net",
14699                            "scope": "Federation",
14700                            "slot_id": "stale-id",
14701                            "slot_token": "stale-token"
14702                        }
14703                    ],
14704                    "relay_url": "https://copilot-agent@wireup.net",
14705                    "slot_id": "stale-id",
14706                    "slot_token": "stale-token"
14707                },
14708                "peers": {}
14709            });
14710            config::write_relay_state(&state).unwrap();
14711
14712            let c = check_and_heal_self_userinfo_endpoints();
14713            assert_eq!(c.status, "WARN");
14714            assert!(
14715                c.fix
14716                    .as_ref()
14717                    .is_some_and(|f| f.contains("wire bind-relay") && f.contains("wire claim")),
14718                "no-clean-fallback fix must require BOTH a clean bind AND a re-claim: {:?}",
14719                c.fix
14720            );
14721
14722            // CRITICAL: state must NOT be mutated (would leave operator with
14723            // no inbox slot). Verify byte-identical.
14724            let after = config::read_relay_state().unwrap();
14725            assert_eq!(
14726                after, state,
14727                "no-clean-fallback path must NOT mutate state (would strand operator)"
14728            );
14729        });
14730    }
14731}
14732
14733// ---------- up megacommand (full bootstrap) ----------
14734
14735/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
14736/// pair. Composes the steps that today's onboarding walks operators through
14737/// one by one (init / bind-relay / claim / background daemon / arm monitor
14738/// recipe). Idempotent: every step checks current state and skips if done.
14739///
14740/// Argument parsing accepts:
14741///   - `<nick>@<relay-host>` — explicit relay
14742///   - `<nick>`              — defaults to wireup.net (the configured
14743///     public relay)
14744fn cmd_up(
14745    relay_arg: Option<&str>,
14746    name: Option<&str>,
14747    with_local: Option<&str>,
14748    no_local: bool,
14749    as_json: bool,
14750) -> Result<()> {
14751    // No nick to parse — your handle is your DID-derived persona (one-name
14752    // rule). The optional arg is only which relay to bind/claim on. Accepts
14753    // `@host`, bare `host`, or a full URL; defaults to the public relay.
14754    let relay_url = match relay_arg {
14755        Some(r) => {
14756            let r = r.trim_start_matches('@');
14757            if r.starts_with("http://") || r.starts_with("https://") {
14758                r.to_string()
14759            } else {
14760                format!("https://{r}")
14761            }
14762        }
14763        None => crate::pair_invite::DEFAULT_RELAY.to_string(),
14764    };
14765
14766    // Strip any URL userinfo (`<handle>@<host>`) before doing any state-
14767    // mutating work — otherwise the malformed endpoint gets persisted in
14768    // `relay_state` AND published in the signed agent-card, where every
14769    // inbound POST to it 4xxes. Mirrors `cmd_up`'s already-bound branch,
14770    // which has always ignored the userinfo on the "keeping existing
14771    // binding" warning path.
14772    let relay_url = strip_relay_url_userinfo(&relay_url);
14773
14774    let mut report: Vec<(String, String)> = Vec::new();
14775    let mut step = |stage: &str, detail: String| {
14776        report.push((stage.to_string(), detail.clone()));
14777        if !as_json {
14778            eprintln!("wire up: {stage} — {detail}");
14779        }
14780    };
14781
14782    // 1. init (or note existing identity). No typed name — cmd_init(None)
14783    // generates the persona from the freshly-minted keypair (one-name rule).
14784    if config::is_initialized()? {
14785        step("init", "already initialized".to_string());
14786    } else {
14787        cmd_init(
14788            None,
14789            name,
14790            Some(&relay_url),
14791            false,
14792            /* as_json */ false,
14793        )?;
14794        step("init", format!("created identity bound to {relay_url}"));
14795    }
14796
14797    // Canonical persona handle — the one name we claim and are addressed by.
14798    let canonical = {
14799        let card = config::read_agent_card()?;
14800        let did = card.get("did").and_then(Value::as_str).unwrap_or("");
14801        crate::agent_card::display_handle_from_did(did).to_string()
14802    };
14803    step("identity", format!("persona is `{canonical}`"));
14804
14805    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
14806    // already initialized we may need to bind to the requested relay
14807    // separately (operator switched relays).
14808    let relay_state = config::read_relay_state()?;
14809    let bound_relay = relay_state
14810        .get("self")
14811        .and_then(|s| s.get("relay_url"))
14812        .and_then(Value::as_str)
14813        .unwrap_or("")
14814        .to_string();
14815    if bound_relay.is_empty() {
14816        // Identity exists but never bound to a relay — bind now.
14817        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
14818        // Pass `false` so the safety check kicks in if state was non-empty.
14819        cmd_bind_relay(
14820            &relay_url, /* scope */ None, // infer from URL (federation for wireup.net)
14821            /* replace */ false, /* migrate_pinned */ false, /* as_json */ false,
14822        )?;
14823        step("bind-relay", format!("bound to {relay_url}"));
14824    } else if bound_relay != relay_url {
14825        step(
14826            "bind-relay",
14827            format!(
14828                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
14829                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
14830            ),
14831        );
14832    } else {
14833        step("bind-relay", format!("already bound to {bound_relay}"));
14834    }
14835
14836    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
14837    // re-claims are accepted by the relay.
14838    match cmd_claim(
14839        &canonical,
14840        Some(&relay_url),
14841        None,
14842        /* hidden */ false,
14843        /* as_json */ false,
14844    ) {
14845        Ok(()) => step(
14846            "claim",
14847            format!("{canonical}@{} claimed", strip_proto(&relay_url)),
14848        ),
14849        Err(e) => step(
14850            "claim",
14851            format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
14852        ),
14853    }
14854
14855    // 3b. Opportunistic local dual-slot (additive). Gives same-box sister
14856    // sessions sub-millisecond loopback routing alongside the federation
14857    // slot. Local relays carry no handle directory — nothing to claim
14858    // there; sister discovery is via `wire session list-local`.
14859    if no_local {
14860        step("local-slot", "skipped (--no-local)".to_string());
14861    } else {
14862        let local_url = with_local
14863            .unwrap_or("http://127.0.0.1:8771")
14864            .trim_end_matches('/');
14865        let already_local = crate::endpoints::self_endpoints(
14866            &config::read_relay_state().unwrap_or_else(|_| json!({})),
14867        )
14868        .iter()
14869        .any(|e| e.relay_url == local_url);
14870        if relay_url.trim_end_matches('/') == local_url || already_local {
14871            step("local-slot", "already covered".to_string());
14872        } else if crate::relay_client::RelayClient::new(local_url)
14873            .check_healthz()
14874            .is_ok()
14875        {
14876            match cmd_bind_relay(
14877                local_url,
14878                Some("local"),
14879                /* replace */ false,
14880                /* migrate_pinned */ false,
14881                /* as_json */ false,
14882            ) {
14883                Ok(()) => step(
14884                    "local-slot",
14885                    format!("dual-bound local relay {local_url} for sister routing"),
14886                ),
14887                Err(e) => step("local-slot", format!("skipped local relay: {e}")),
14888            }
14889        } else {
14890            step(
14891                "local-slot",
14892                format!(
14893                    "no local relay reachable at {local_url} — federation only \
14894                     (sisters resolve via session-list)"
14895                ),
14896            );
14897        }
14898    }
14899
14900    // 4. Background daemon — must be running for pull/push/ack to flow.
14901    match crate::ensure_up::ensure_daemon_running() {
14902        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
14903        Ok(false) => step("daemon", "already running".to_string()),
14904        Err(e) => step(
14905            "daemon",
14906            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
14907        ),
14908    }
14909
14910    // 5. Final summary — point operator at the next commands.
14911    let summary =
14912        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
14913         `wire monitor` to watch incoming events."
14914            .to_string();
14915    step("ready", summary.clone());
14916
14917    if as_json {
14918        let steps_json: Vec<_> = report
14919            .iter()
14920            .map(|(k, v)| json!({"stage": k, "detail": v}))
14921            .collect();
14922        println!(
14923            "{}",
14924            serde_json::to_string(&json!({
14925                "nick": canonical,
14926                "relay": relay_url,
14927                "steps": steps_json,
14928            }))?
14929        );
14930    }
14931    Ok(())
14932}
14933
14934/// Strip http:// or https:// prefix for display in `wire up` step output.
14935fn strip_proto(url: &str) -> String {
14936    url.trim_start_matches("https://")
14937        .trim_start_matches("http://")
14938        .to_string()
14939}
14940
14941/// Strip URL userinfo (`https://<userinfo>@<host>...`) from a relay URL,
14942/// warning to stderr if any was stripped. Returns the cleaned URL.
14943///
14944/// Bug 1 this fixes: `wire up <handle>@<relay>` and `wire bind-relay
14945/// <handle>@<relay>` previously prepended `https://` to the literal arg,
14946/// recording and publishing the endpoint as `https://<handle>@<relay>` —
14947/// handle parsed as URL userinfo. Every inbound event POST to that
14948/// endpoint (pair_drop_ack, messages) gets a 4xx (Cloudflare 400 on
14949/// wireup.net) because the upstream rejects the userinfo on plain
14950/// GETs/POSTs. Bilateral pairing can't complete; messages sit
14951/// undelivered. Also surfaced cosmetically (Bug 3) as a doubled-handle
14952/// echo at the claim step (`<nick>@<nick>@<host>`) because `strip_proto`
14953/// left the userinfo in.
14954///
14955/// Behavior: strip-and-warn rather than hard-reject. In v0.11+ the handle
14956/// is DID-derived (one-name rule), so the userinfo isn't *needed* — but
14957/// `<handle>@<relay>` is literally the wire dial-address format
14958/// (`wire dial coral-weasel@wireup.net`), so an operator who types
14959/// `wire up <handle>@<relay>` is making a natural-by-analogy mistake, not
14960/// a hostile request. Mirrors `cmd_up`'s already-bound branch, which has
14961/// always ignored the userinfo prefix when keeping an existing clean
14962/// slot. The hard invariant either way: a userinfo-bearing URL must
14963/// never reach `self.endpoints[]` or the published agent-card.
14964/// Self-pair guard (issue #30, explicit "Optional" ask).
14965///
14966/// Refuses to proceed when the resolved peer DID matches our own DID. Two
14967/// ways this fires:
14968///
14969///   1. The operator literally dialed their own handle by mistake.
14970///   2. Two terminals / agents that should be DISTINCT collapsed onto one
14971///      wire identity — either because v0.13's session-key resolution
14972///      didn't reach the wire process (env var not propagated; see #29 and
14973///      the Windows symptoms in #30) or because both terminals share a
14974///      WIRE_HOME without setting WIRE_SESSION_ID.
14975///
14976/// Pre-guard, case (2) silently produced a pair_drop targeting our own
14977/// slot — bilateral handshake could never complete and the operator could
14978/// only see "pending forever" with no diagnostic. The guard makes the
14979/// failure mode debuggable instead of silent by surfacing the exact DID
14980/// collision and pointing at the `wire whoami` / `WIRE_SESSION_ID`
14981/// diagnostic that the v0.13.5 session-key adapter introduced.
14982///
14983/// Companion to the lightweight nickname-match guard at the top of
14984/// `cmd_add` (which catches the literal `wire add <our-nick>@<relay>`
14985/// case before WebFinger). This DID-level guard is the load-bearing one
14986/// because case (2) — two collapsed terminals with DIFFERENT typed
14987/// nicknames that BOTH resolve to the shared DID — can't be caught
14988/// without the post-resolution comparison.
14989/// Issue #69 follow-up to #15: predicate "does this error smell like a
14990/// 4xx slot rotation?" — used by `try_reresolve_peer_on_slot_4xx` to
14991/// decide whether to spend a whois RTT on a re-resolve.
14992///
14993/// Original #15 implementation used `last_err.contains("410") ||
14994/// last_err.contains("404")`, which false-triggers on any unrelated
14995/// substring with `"410"`/`"404"` in it — e.g. `"slot 4101 expired"`,
14996/// `"request_id=410abc..."`, `"received 4040 bytes"`. False-trigger cost
14997/// is a single wasted whois per push call per peer (rate-limited by
14998/// `already_tried`), but it muddies the doctor diagnostic by inserting
14999/// spurious "peer slot rotated" log lines.
15000///
15001/// This predicate gates on the status code appearing as a *whole token*
15002/// — preceded by start-of-string / space / colon / tab / newline AND
15003/// followed by end-of-string / space / colon / tab / newline. That
15004/// matches both real-world shapes:
15005///
15006/// - `reqwest::StatusCode` Display, via `relay_client.rs` line ~339
15007///   `format!("post_event failed: {status}: {detail}")` →
15008///   `"post_event failed: 410 Gone: <body>"` (token `"410"` is followed
15009///   by space).
15010/// - UDS bare-`u16` Display, via `relay_client.rs` line ~227
15011///   `format!("post_event (uds {socket_path}) failed: {status}: ...")` →
15012///   `"post_event (uds /tmp/...sock) failed: 410: <body>"` (token
15013///   `"410"` is followed by colon).
15014///
15015/// And rejects the false-positive shapes documented in
15016/// `error_smells_like_slot_4xx_tests` below.
15017pub fn error_smells_like_slot_4xx(last_err: &str) -> bool {
15018    fn is_token_boundary(b: u8) -> bool {
15019        matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
15020    }
15021    let bytes = last_err.as_bytes();
15022    for code in ["410", "404"] {
15023        let code_bytes = code.as_bytes();
15024        let mut search_from = 0usize;
15025        while let Some(rel) = last_err[search_from..].find(code) {
15026            let abs = search_from + rel;
15027            let end = abs + code_bytes.len();
15028            let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
15029            let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
15030            if before_ok && after_ok {
15031                return true;
15032            }
15033            // Step past this candidate to find the next occurrence; using
15034            // `+ 1` (rather than `+ code_bytes.len()`) keeps the scan
15035            // cheap and guarantees forward progress even on overlap.
15036            search_from = abs + 1;
15037        }
15038    }
15039    false
15040}
15041
15042/// Issue #15: detect a 4xx-shaped push failure that smells like "slot
15043/// rotated by peer" and update the peer's pin in place with the freshly
15044/// resolved slot from the relay's handle directory.
15045///
15046/// Returns:
15047/// - `Ok(true)` — peer's pin was rotated; caller should refresh
15048///   `peer_endpoints_in_priority_order(&state, ...)` and retry.
15049/// - `Ok(false)` — re-resolve completed but the slot id was unchanged
15050///   (false-alarm 4xx, e.g. throttling); caller should NOT retry.
15051/// - `Err(e)` — re-resolve itself failed (network down, relay 5xx,
15052///   handle no longer claimed, etc.); caller should fall through to the
15053///   existing "skipped" path.
15054///
15055/// Only triggers when:
15056///   - The error string carries a 4xx slot-rotation status token (`410`/`404`)
15057///     as a *whole token* — preceded by start/space/colon/tab/newline and
15058///     followed by end/space/colon/tab/newline. This matches both the
15059///     `reqwest::StatusCode` Display shape (`": 410 Gone"`) and the UDS
15060///     bare-`u16` shape (`": 410:"`) emitted by `post_event` in
15061///     `src/relay_client.rs`, while rejecting substring false-positives
15062///     like `"slot 4101 expired"` or `"request_id=410abc..."`. See
15063///     `error_smells_like_slot_4xx` below.
15064///   - The peer has a pinned `relay_url` we can parse a handle@domain from.
15065///   - The caller hasn't already re-resolved this peer in the current push
15066///     call (caller's responsibility — pass `already_tried` from a set kept
15067///     in the outer per-peer loop). One whois per peer per push call,
15068///     exactly the rate limit the issue specifies.
15069///
15070/// Updates `state.peers[peer_handle]` in place (rotates the federation
15071/// endpoint's slot_id + slot_token to the fresh resolve), and emits a
15072/// stderr WARN so the operator can see the rotation event in their
15073/// terminal alongside the unrelated `wire push` output. Caller is
15074/// responsible for persisting `state` back to disk via
15075/// `config::write_relay_state` after all per-peer re-resolves settle.
15076fn try_reresolve_peer_on_slot_4xx(
15077    state: &mut Value,
15078    peer_handle: &str,
15079    last_err: &str,
15080    already_tried: &std::collections::HashSet<String>,
15081) -> Result<bool> {
15082    if !error_smells_like_slot_4xx(last_err) {
15083        // Not the slot-rotation shape. Don't waste a whois on this.
15084        return Ok(false);
15085    }
15086    if already_tried.contains(peer_handle) {
15087        // Rate limit: at most one whois per peer per push call.
15088        return Ok(false);
15089    }
15090    // Find the peer's pinned federation endpoint to re-resolve against.
15091    let peer_entry = state
15092        .get("peers")
15093        .and_then(|p| p.get(peer_handle))
15094        .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
15095    let peer_relay = peer_entry
15096        .get("endpoints")
15097        .and_then(Value::as_array)
15098        .and_then(|arr| {
15099            arr.iter().find(|e| {
15100                e.get("scope").and_then(Value::as_str) == Some("federation")
15101                    || e.get("scope").and_then(Value::as_str) == Some("Federation")
15102            })
15103        })
15104        .and_then(|e| e.get("relay_url").and_then(Value::as_str))
15105        .or_else(|| peer_entry.get("relay_url").and_then(Value::as_str))
15106        .ok_or_else(|| {
15107            anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
15108        })?
15109        .to_string();
15110    // Strip scheme + path to get the relay domain. Same shape parse used by
15111    // pair_profile::resolve_handle's input contract.
15112    let domain = peer_relay
15113        .trim_start_matches("https://")
15114        .trim_start_matches("http://")
15115        .split('/')
15116        .next()
15117        .unwrap_or(&peer_relay)
15118        .to_string();
15119    let handle = crate::pair_profile::Handle {
15120        nick: peer_handle.to_string(),
15121        domain,
15122    };
15123    let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
15124    let new_slot_id = resolved
15125        .get("slot_id")
15126        .and_then(Value::as_str)
15127        .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
15128        .to_string();
15129    // Compare against the currently-pinned federation slot.
15130    let peers = state
15131        .get_mut("peers")
15132        .and_then(Value::as_object_mut)
15133        .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
15134    let peer_entry = peers
15135        .get_mut(peer_handle)
15136        .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
15137    let current_slot_id = peer_entry
15138        .get("endpoints")
15139        .and_then(Value::as_array)
15140        .and_then(|arr| {
15141            arr.iter().find(|e| {
15142                let scope = e.get("scope").and_then(Value::as_str);
15143                scope == Some("federation") || scope == Some("Federation")
15144            })
15145        })
15146        .and_then(|e| e.get("slot_id").and_then(Value::as_str))
15147        .unwrap_or("")
15148        .to_string();
15149    if current_slot_id == new_slot_id {
15150        // Same slot — the 4xx was something else (rate limit, server burp).
15151        return Ok(false);
15152    }
15153    // Rotate in place. We update slot_id but DROP the slot_token: only the
15154    // peer's freshly-issued slot_token (which arrives via a new pair_drop_ack)
15155    // is valid. Sending against the new slot without a fresh token gets 401,
15156    // so the operator will see one more "skipped: 401" and the next pair
15157    // cycle (or a manual `wire add <peer>@<relay>` per the doctor #14 fix)
15158    // refreshes the token. This is the same trade-off the issue spells out:
15159    // auto-rotation closes the slot mismatch; token refresh still needs the
15160    // bilateral pair gate.
15161    if let Some(endpoints) = peer_entry
15162        .get_mut("endpoints")
15163        .and_then(Value::as_array_mut)
15164    {
15165        for ep in endpoints.iter_mut() {
15166            let scope = ep.get("scope").and_then(Value::as_str);
15167            if scope == Some("federation") || scope == Some("Federation") {
15168                ep["slot_id"] = Value::String(new_slot_id.clone());
15169                ep["slot_token"] = Value::String(String::new());
15170            }
15171        }
15172    }
15173    // Also update the legacy top-level fields for v0.5.16-era readers (the
15174    // same back-compat surface pair_drop_ack uses).
15175    peer_entry["slot_id"] = Value::String(new_slot_id.clone());
15176    peer_entry["slot_token"] = Value::String(String::new());
15177    eprintln!(
15178        "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
15179         now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
15180         {peer_handle}@<relay>` to refresh the slot_token."
15181    );
15182    Ok(true)
15183}
15184
15185fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
15186    if our_did == peer_did {
15187        bail!(
15188            "refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
15189             DID. Two terminals can collapse onto one wire identity when the per-\
15190             session key isn't reaching the wire process (issue #30 / #29).\n\n\
15191             Diagnose:\n  \
15192             • `wire whoami` in each terminal — DIDs MUST differ.\n  \
15193             • `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
15194             (PowerShell) — must be set + distinct per session.\n\n\
15195             Force distinct identities before relaunching the agent:\n  \
15196             • bash/zsh:   `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n  \
15197             • PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
15198        );
15199    }
15200    Ok(())
15201}
15202
15203fn strip_relay_url_userinfo(url: &str) -> String {
15204    // Locate the authority segment: everything after `://` (or the whole
15205    // string if there is no scheme yet), up to the first `/`, `?`, or `#`.
15206    let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
15207    let rest = &url[authority_start..];
15208    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
15209    let authority = &rest[..authority_end];
15210
15211    let Some(at_pos) = authority.find('@') else {
15212        return url.to_string();
15213    };
15214
15215    let userinfo = &authority[..at_pos];
15216    let host = &authority[at_pos + 1..];
15217    let scheme = &url[..authority_start];
15218    let tail = &rest[authority_end..];
15219    let cleaned = format!("{scheme}{host}{tail}");
15220
15221    eprintln!(
15222        "wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
15223         in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
15224         is just the bare relay. Binding to `{cleaned}` instead."
15225    );
15226
15227    cleaned
15228}
15229
15230/// Hard assertion that a URL about to be persisted to `relay_state` /
15231/// published in the signed agent-card carries no userinfo. The
15232/// `strip_relay_url_userinfo` filter at every public entry point already
15233/// removes it; this is the belt-and-suspenders check at the actual mutation
15234/// site — a future code path that bypasses the entry filter must NOT be
15235/// able to leak a malformed endpoint into a signed card or the persisted
15236/// relay state.
15237fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
15238    let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
15239    let rest = &url[authority_start..];
15240    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
15241    let authority = &rest[..authority_end];
15242    if authority.contains('@') {
15243        bail!(
15244            "internal invariant violated: relay URL `{url}` still carries userinfo at \
15245             the persist/publish boundary — `strip_relay_url_userinfo` must be called \
15246             before this point. Refusing to publish a malformed endpoint."
15247        );
15248    }
15249    Ok(())
15250}
15251
15252// ---------- pair megacommand (zero-paste handle-based) ----------
15253
15254/// `wire pair <nick@domain>` zero-shot. Dispatched from Command::Pair when
15255/// the handle is in `nick@domain` form. Wraps:
15256///
15257///   1. cmd_add — resolve, pin, drop intro
15258///   2. Wait up to `timeout_secs` for the peer's `pair_drop_ack` to arrive
15259///      (signalled by `peers.<handle>.slot_token` populating in relay state)
15260///   3. Verify bilateral pin: trust contains peer + relay state has token
15261///   4. Print final state — both sides VERIFIED + can `wire send`
15262///
15263/// On timeout: hard-errors with the specific stuck step so the operator
15264/// knows which side to chase. No silent partial success.
15265fn cmd_pair_megacommand(
15266    handle_arg: &str,
15267    relay_override: Option<&str>,
15268    timeout_secs: u64,
15269    _as_json: bool,
15270) -> Result<()> {
15271    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
15272    let peer_handle = parsed.nick.clone();
15273
15274    eprintln!("wire pair: resolving {handle_arg}...");
15275    cmd_add(
15276        handle_arg,
15277        relay_override,
15278        /* local_sister */ false,
15279        /* as_json */ false,
15280    )?;
15281
15282    eprintln!(
15283        "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
15284         to ack (their daemon must be running + pulling)..."
15285    );
15286
15287    // Trigger an immediate daemon-style pull so we don't wait the full daemon
15288    // interval. Best-effort — if it fails, we still fall through to the
15289    // polling loop.
15290    let _ = run_sync_pull();
15291
15292    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
15293    let poll_interval = std::time::Duration::from_millis(500);
15294
15295    loop {
15296        // Drain anything new from the relay (e.g. our pair_drop_ack landing).
15297        let _ = run_sync_pull();
15298        let relay_state = config::read_relay_state()?;
15299        let peer_entry = relay_state
15300            .get("peers")
15301            .and_then(|p| p.get(&peer_handle))
15302            .cloned();
15303        let token = peer_entry
15304            .as_ref()
15305            .and_then(|e| e.get("slot_token"))
15306            .and_then(Value::as_str)
15307            .unwrap_or("");
15308
15309        if !token.is_empty() {
15310            // Bilateral pin complete — we have their slot_token, we can send.
15311            let trust = config::read_trust()?;
15312            let pinned_in_trust = trust
15313                .get("agents")
15314                .and_then(|a| a.get(&peer_handle))
15315                .is_some();
15316            println!(
15317                "wire pair: paired with {peer_handle}.\n  trust: {}  bilateral: yes (slot_token recorded)\n  next: `wire send {peer_handle} \"<msg>\"`",
15318                if pinned_in_trust {
15319                    "VERIFIED"
15320                } else {
15321                    "MISSING (bug)"
15322                }
15323            );
15324            return Ok(());
15325        }
15326
15327        if std::time::Instant::now() >= deadline {
15328            // Timeout — surface the EXACT stuck step. Likely culprits:
15329            //   - peer daemon not running on their box
15330            //   - peer's relay slot is offline
15331            //   - their daemon is on an older binary that doesn't know
15332            //     pair_drop kind=1100 (the P0.1 class — now visible via
15333            //     wire pull --json on their side as a blocking rejection)
15334            bail!(
15335                "wire pair: timed out after {timeout_secs}s. \
15336                 peer {peer_handle} never sent pair_drop_ack. \
15337                 likely causes: (a) their daemon is down — ask them to run \
15338                 `wire status` and `wire daemon &`; (b) their binary is older \
15339                 than 0.5.x and doesn't understand pair_drop events — ask \
15340                 them to `wire upgrade`; (c) network / relay blip — re-run \
15341                 `wire pair {handle_arg}` to retry."
15342            );
15343        }
15344
15345        std::thread::sleep(poll_interval);
15346    }
15347}
15348
15349fn cmd_claim(
15350    nick: &str,
15351    relay_override: Option<&str>,
15352    public_url: Option<&str>,
15353    hidden: bool,
15354    as_json: bool,
15355) -> Result<()> {
15356    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
15357    // + claim handle. Operator should never have to run init/bind-relay first.
15358    let (_did, relay_url, slot_id, slot_token) =
15359        crate::pair_invite::ensure_self_with_relay(relay_override)?;
15360    let card = config::read_agent_card()?;
15361
15362    // v0.13.1 one-name enforcement: the handle you claim in the phonebook
15363    // MUST equal your DID-derived persona, so the directory entry can never
15364    // drift from your agent-card handle. A typed nick that differs is ignored
15365    // (mirrors how `wire init` coerces the typed name). This closes the
15366    // claim-path reopening of the v0.11 "two names" footgun — before this,
15367    // `wire claim coffee-ghost` published coffee-ghost@relay -> your DID while
15368    // your card said e.g. outback-sandpiper. The typed `nick` arg is now
15369    // vestigial, exactly like the one `wire init` / `wire up` already accept.
15370    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
15371    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
15372    if !canonical.is_empty() && nick != canonical && !as_json {
15373        eprintln!(
15374            "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
15375        );
15376    }
15377    let nick = if canonical.is_empty() {
15378        nick
15379    } else {
15380        canonical.as_str()
15381    };
15382    if !crate::pair_profile::is_valid_nick(nick) {
15383        bail!(
15384            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
15385        );
15386    }
15387
15388    let client = crate::relay_client::RelayClient::new(&relay_url);
15389    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
15390    // (back-compat); Some(false) for `--hidden`. Relays older than
15391    // v0.5.19 ignore the field, so this is safe to always send.
15392    let discoverable = if hidden { Some(false) } else { None };
15393    let resp =
15394        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
15395
15396    if as_json {
15397        println!(
15398            "{}",
15399            serde_json::to_string(&json!({
15400                "nick": nick,
15401                "relay": relay_url,
15402                "response": resp,
15403            }))?
15404        );
15405    } else {
15406        // Best-effort: derive the public domain from the relay URL. If
15407        // operator passed --public-url that's the canonical address; else
15408        // the relay URL itself. Falls back to a placeholder if both miss.
15409        let domain = public_url
15410            .unwrap_or(&relay_url)
15411            .trim_start_matches("https://")
15412            .trim_start_matches("http://")
15413            .trim_end_matches('/')
15414            .split('/')
15415            .next()
15416            .unwrap_or("<this-relay-domain>")
15417            .to_string();
15418        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
15419        println!("verify with: wire whois {nick}@{domain}");
15420    }
15421    Ok(())
15422}
15423
15424fn cmd_profile(action: ProfileAction) -> Result<()> {
15425    match action {
15426        ProfileAction::Set { field, value, json } => {
15427            // Try parsing the value as JSON; if that fails, treat it as a
15428            // bare string. Lets operators pass either `42` or `"hello"` or
15429            // `["rust","late-night"]` without quoting hell.
15430            let parsed: Value =
15431                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
15432            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
15433            let published = republish_card_to_phonebook();
15434            if json {
15435                println!(
15436                    "{}",
15437                    serde_json::to_string(&json!({
15438                        "field": field,
15439                        "profile": new_profile,
15440                        "published_to": published,
15441                    }))?
15442                );
15443            } else {
15444                println!("profile.{field} set");
15445                print_profile_publish_result(&published);
15446            }
15447        }
15448        ProfileAction::Get { json } => return cmd_whois(None, json, None),
15449        ProfileAction::Clear { field, json } => {
15450            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
15451            let published = republish_card_to_phonebook();
15452            if json {
15453                println!(
15454                    "{}",
15455                    serde_json::to_string(&json!({
15456                        "field": field,
15457                        "cleared": true,
15458                        "profile": new_profile,
15459                        "published_to": published,
15460                    }))?
15461                );
15462            } else {
15463                println!("profile.{field} cleared");
15464                print_profile_publish_result(&published);
15465            }
15466        }
15467    }
15468    Ok(())
15469}
15470
15471/// Best-effort: re-publish the (freshly re-signed) agent-card to every relay
15472/// this identity already holds a federation slot on, so a `wire profile`
15473/// edit reaches the public phonebook immediately instead of waiting for the
15474/// next `wire up`. Silent no-op when the identity holds no federation slot
15475/// (offline / local-only). `discoverable: None` makes the relay PRESERVE the
15476/// prior setting, so a `--hidden` agent stays hidden across the re-claim.
15477/// Returns the relay URLs the card was published to.
15478fn republish_card_to_phonebook() -> Vec<String> {
15479    let Ok(card) = config::read_agent_card() else {
15480        return Vec::new();
15481    };
15482    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
15483    let persona = crate::agent_card::display_handle_from_did(did).to_string();
15484    if persona.is_empty() {
15485        return Vec::new();
15486    }
15487    let Ok(state) = config::read_relay_state() else {
15488        return Vec::new();
15489    };
15490    let mut published = Vec::new();
15491    for ep in crate::endpoints::self_endpoints(&state) {
15492        if ep.scope != crate::endpoints::EndpointScope::Federation
15493            || ep.slot_id.is_empty()
15494            || ep.slot_token.is_empty()
15495        {
15496            continue;
15497        }
15498        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
15499        if client
15500            .handle_claim_v2(&persona, &ep.slot_id, &ep.slot_token, None, &card, None)
15501            .is_ok()
15502        {
15503            published.push(ep.relay_url.clone());
15504        }
15505    }
15506    published
15507}
15508
15509fn print_profile_publish_result(published: &[String]) {
15510    if published.is_empty() {
15511        println!(
15512            "  (local only — not bound to a federation relay; run `wire up` to publish to the phonebook)"
15513        );
15514    } else {
15515        println!("  published to phonebook: {}", published.join(", "));
15516    }
15517}
15518
15519// ---------- setup — one-shot MCP host registration ----------
15520
15521fn cmd_setup(apply: bool) -> Result<()> {
15522    use crate::adapters::harness::HARNESS_ADAPTERS;
15523    use std::path::PathBuf;
15524
15525    // v0.14.x: no `env` mapping. Per-session identity for Claude Code is
15526    // resolved by `crate::session::resolve_session_key`, which reads
15527    // `WIRE_SESSION_ID` then falls back to `CLAUDE_CODE_SESSION_ID`. Current
15528    // Claude Code (verified 2026-05) propagates `CLAUDE_CODE_SESSION_ID`
15529    // into every MCP subprocess by default, so the historical mapping was
15530    // redundant and triggered a misleading MCP Config Diagnostics warning.
15531    let entry = json!({
15532        "command": "wire",
15533        "args": ["mcp"]
15534    });
15535    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
15536
15537    // v0.14.2 (#92 category 1): per-host detection + upsert logic lives
15538    // in `adapters::harness::HARNESS_ADAPTERS`. Adding a new harness is
15539    // one struct entry there + one test. This loop is the only consumer.
15540    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
15541    for adapter in HARNESS_ADAPTERS {
15542        for path in (adapter.paths_fn)() {
15543            targets.push((adapter.name, path));
15544        }
15545    }
15546
15547    println!("wire setup\n");
15548    println!("MCP server snippet (add this to your client's mcpServers):");
15549    println!();
15550    println!("{entry_pretty}");
15551    println!();
15552
15553    if !apply {
15554        println!("Probable MCP host config locations on this machine:");
15555        for (name, path) in &targets {
15556            let marker = if path.exists() {
15557                "✓ found"
15558            } else {
15559                "  (would create)"
15560            };
15561            println!("  {marker:14}  {name}: {}", path.display());
15562        }
15563        println!();
15564        println!("Run `wire setup --apply` to merge wire into each config above.");
15565        println!(
15566            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
15567        );
15568        return Ok(());
15569    }
15570
15571    let mut modified: Vec<String> = Vec::new();
15572    let mut skipped: Vec<String> = Vec::new();
15573    for adapter in HARNESS_ADAPTERS {
15574        for path in (adapter.paths_fn)() {
15575            match (adapter.upsert_fn)(&path, "wire", &entry) {
15576                Ok(true) => {
15577                    modified.push(format!("✓ {} ({})", adapter.name, path.display()));
15578                }
15579                Ok(false) => skipped.push(format!(
15580                    "  {} ({}): already configured",
15581                    adapter.name,
15582                    path.display()
15583                )),
15584                Err(e) => skipped.push(format!("✗ {} ({}): {e}", adapter.name, path.display())),
15585            }
15586        }
15587    }
15588    if !modified.is_empty() {
15589        println!("Modified:");
15590        for line in &modified {
15591            println!("  {line}");
15592        }
15593        println!();
15594        println!("Restart the app(s) above to load wire MCP.");
15595    }
15596    if !skipped.is_empty() {
15597        println!();
15598        println!("Skipped:");
15599        for line in &skipped {
15600            println!("  {line}");
15601        }
15602    }
15603    Ok(())
15604}
15605
15606// v0.14.2 (#92 cat 1): `fn upsert_mcp_entry` retired. Its three
15607// shape-dispatching branches (standard / vscode / opencode) moved into
15608// per-shape `upsert_*` fns in `adapters::harness`. Adding a new shape
15609// = one new upsert fn + one registry entry, instead of editing this
15610// switch statement.
15611
15612// ---------- setup --statusline ----------
15613
15614/// Bundled Claude Code statusLine renderer (persona emoji + nickname + cwd,
15615/// pidfile+tasklist liveness). Embedded at compile time; written to the
15616/// Claude config dir on `wire setup --statusline --apply`.
15617const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
15618
15619/// `wire setup --statusline [--apply] [--remove]` — install/remove a Claude
15620/// Code statusLine that renders this session's wire persona. Honors
15621/// `$CLAUDE_CONFIG_DIR` (default `~/.claude`). Writes the renderer script and
15622/// merges a `statusLine` block into settings.json, preserving existing keys
15623/// and refusing to clobber a settings.json that exists but isn't valid JSON.
15624fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
15625    use std::path::PathBuf;
15626    let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
15627        .map(PathBuf::from)
15628        .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
15629        .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
15630    let settings_path = cfg_dir.join("settings.json");
15631    let script_path = cfg_dir.join("wire-statusline.sh");
15632    // Resolve the shell invocation. On Windows a bare `bash` resolves to
15633    // System32\bash.exe (WSL) — wrong environment, Windows paths invalid,
15634    // statusline breaks — so we emit the absolute git-bash path. On Unix a
15635    // bare `bash <script>` is correct. Script path is quoted for spaces.
15636    let (command, command_warn) = statusline_command(&script_path);
15637
15638    println!("wire setup --statusline\n");
15639    println!("Claude config dir: {}", cfg_dir.display());
15640    println!("  renderer:  {}", script_path.display());
15641    println!("  settings:  {}", settings_path.display());
15642    if let Some(w) = &command_warn {
15643        println!("  ⚠ {w}");
15644    }
15645    println!();
15646
15647    if remove {
15648        if !apply {
15649            println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
15650            println!("Run `wire setup --statusline --remove --apply` to do it.");
15651            return Ok(());
15652        }
15653        let dropped = remove_statusline_entry(&settings_path)?;
15654        let script_gone = if script_path.exists() {
15655            std::fs::remove_file(&script_path).is_ok()
15656        } else {
15657            false
15658        };
15659        println!(
15660            "Removed: statusLine key {} · renderer {}",
15661            if dropped { "dropped" } else { "absent" },
15662            if script_gone { "deleted" } else { "absent" }
15663        );
15664        return Ok(());
15665    }
15666
15667    if !apply {
15668        println!("Would write the renderer above and merge into settings.json:");
15669        println!();
15670        println!("  \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
15671        println!();
15672        println!("Resulting statusline:  ● <emoji> <nickname> · <cwd>");
15673        println!("Run `wire setup --statusline --apply` to install.");
15674        println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
15675        return Ok(());
15676    }
15677
15678    if let Some(parent) = script_path.parent() {
15679        std::fs::create_dir_all(parent).context("creating Claude config dir")?;
15680    }
15681    std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
15682    #[cfg(unix)]
15683    {
15684        use std::os::unix::fs::PermissionsExt;
15685        if let Ok(meta) = std::fs::metadata(&script_path) {
15686            let mut perms = meta.permissions();
15687            perms.set_mode(0o755);
15688            let _ = std::fs::set_permissions(&script_path, perms);
15689        }
15690    }
15691    let changed = upsert_statusline_entry(&settings_path, &command)?;
15692    println!("✓ renderer written: {}", script_path.display());
15693    if changed {
15694        println!("✓ merged statusLine into: {}", settings_path.display());
15695    } else {
15696        println!(
15697            "  settings.json already configured: {}",
15698            settings_path.display()
15699        );
15700    }
15701    println!();
15702    println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
15703    Ok(())
15704}
15705
15706/// Merge a `statusLine` command block into a Claude settings.json, preserving
15707/// all other keys. Returns Ok(true) if changed. Refuses to clobber a file that
15708/// exists but is not valid JSON.
15709fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
15710    let mut cfg: Value = if path.exists() {
15711        let body = std::fs::read_to_string(path).context("reading settings.json")?;
15712        if body.trim().is_empty() {
15713            json!({})
15714        } else {
15715            serde_json::from_str(&body).context(
15716                "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
15717            )?
15718        }
15719    } else {
15720        json!({})
15721    };
15722    if !cfg.is_object() {
15723        bail!("settings.json root is not a JSON object — refusing to clobber");
15724    }
15725    let desired = json!({"type": "command", "command": command});
15726    let root = cfg.as_object_mut().unwrap();
15727    if root.get("statusLine") == Some(&desired) {
15728        return Ok(false);
15729    }
15730    root.insert("statusLine".to_string(), desired);
15731    if let Some(parent) = path.parent()
15732        && !parent.as_os_str().is_empty()
15733    {
15734        std::fs::create_dir_all(parent).context("creating parent dir")?;
15735    }
15736    let out = serde_json::to_string_pretty(&cfg)? + "\n";
15737    std::fs::write(path, out).context("writing settings.json")?;
15738    Ok(true)
15739}
15740
15741/// Drop the `statusLine` key from settings.json. Ok(true) if a key was removed,
15742/// Ok(false) if file/key absent. Refuses to edit invalid JSON.
15743fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
15744    if !path.exists() {
15745        return Ok(false);
15746    }
15747    let body = std::fs::read_to_string(path).context("reading settings.json")?;
15748    if body.trim().is_empty() {
15749        return Ok(false);
15750    }
15751    let mut cfg: Value = serde_json::from_str(&body)
15752        .context("settings.json is not valid JSON — refusing to edit")?;
15753    let Some(root) = cfg.as_object_mut() else {
15754        return Ok(false);
15755    };
15756    if root.remove("statusLine").is_none() {
15757        return Ok(false);
15758    }
15759    let out = serde_json::to_string_pretty(&cfg)? + "\n";
15760    std::fs::write(path, out).context("writing settings.json")?;
15761    Ok(true)
15762}
15763
15764/// Build the `statusLine.command` string for this platform. Returns the
15765/// command plus an optional warning to surface to the operator.
15766fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
15767    #[cfg(windows)]
15768    {
15769        match resolve_git_bash() {
15770            Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
15771            None => (
15772                format!("bash \"{}\"", script_path.display()),
15773                Some(
15774                    "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
15775                     WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
15776                     Windows or set statusLine.command to your git-bash bash.exe path."
15777                        .to_string(),
15778                ),
15779            ),
15780        }
15781    }
15782    #[cfg(unix)]
15783    {
15784        (format!("bash \"{}\"", script_path.display()), None)
15785    }
15786}
15787
15788/// Locate the git-bash `bash.exe` on Windows, avoiding the WSL launcher at
15789/// `System32\bash.exe`. Claude Code's statusLine command needs the real
15790/// git-bash so the renderer runs in a POSIX-ish env with valid paths.
15791#[cfg(windows)]
15792fn resolve_git_bash() -> Option<String> {
15793    use std::path::PathBuf;
15794    // 1. `where.exe bash` — take the first hit that is NOT under System32
15795    //    (that one is the WSL `bash.exe` launcher).
15796    if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
15797        && out.status.success()
15798    {
15799        for line in String::from_utf8_lossy(&out.stdout).lines() {
15800            let p = line.trim();
15801            if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
15802                return Some(p.to_string());
15803            }
15804        }
15805    }
15806    // 2. Common Git-for-Windows install locations.
15807    let candidates = [
15808        std::env::var("ProgramFiles")
15809            .ok()
15810            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
15811        std::env::var("ProgramFiles(x86)")
15812            .ok()
15813            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
15814        std::env::var("LocalAppData")
15815            .ok()
15816            .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
15817    ];
15818    candidates
15819        .into_iter()
15820        .flatten()
15821        .find(|c| PathBuf::from(c).exists())
15822}
15823
15824#[cfg(test)]
15825mod scan_jsonl_dir_tests {
15826    use super::*;
15827
15828    #[test]
15829    fn scan_jsonl_dir_excludes_pushed_audit_files() {
15830        // Pre-fix `wire status` reported `outbox.events` as the sum of
15831        // both the live outbox files AND the audit-only `*.pushed.jsonl`
15832        // lifecycle logs. On a long-running operator's box that turned
15833        // "11 events queued" into "71811 events queued" — confusing
15834        // and load-bearing-wrong for the silent-send detection class.
15835        let dir = tempfile::tempdir().unwrap();
15836        // Live outbox: one peer, 2 events.
15837        std::fs::write(
15838            dir.path().join("alice.jsonl"),
15839            "{\"event_id\":\"a\"}\n{\"event_id\":\"b\"}\n",
15840        )
15841        .unwrap();
15842        // Audit log: one peer, 100 events. Must NOT count.
15843        let many: String = (0..100)
15844            .map(|i| format!("{{\"event_id\":\"x{i}\",\"ts\":\"...\"}}\n"))
15845            .collect();
15846        std::fs::write(dir.path().join("alice.pushed.jsonl"), many).unwrap();
15847        let result = scan_jsonl_dir(dir.path()).unwrap();
15848        assert_eq!(
15849            result["events"], 2,
15850            "events count must include only live outbox lines, not pushed-log audit lines"
15851        );
15852        assert_eq!(
15853            result["files"], 1,
15854            "files count must reflect 1 live outbox file (the .pushed.jsonl audit log doesn't count as a queued-events surface)"
15855        );
15856    }
15857
15858    #[test]
15859    fn scan_jsonl_dir_zero_when_only_pushed_log_present() {
15860        // Edge case: a peer who's drained their queue still has an
15861        // append-only `<peer>.pushed.jsonl` file but no `<peer>.jsonl`.
15862        // Should report zero events, zero files — there's no pending
15863        // outbox work.
15864        let dir = tempfile::tempdir().unwrap();
15865        std::fs::write(
15866            dir.path().join("alice.pushed.jsonl"),
15867            "{\"event_id\":\"a\"}\n",
15868        )
15869        .unwrap();
15870        let result = scan_jsonl_dir(dir.path()).unwrap();
15871        assert_eq!(result["events"], 0);
15872        assert_eq!(result["files"], 0);
15873    }
15874
15875    #[test]
15876    fn scan_jsonl_dir_returns_zero_for_missing_dir() {
15877        let result = scan_jsonl_dir(std::path::Path::new("/nonexistent")).unwrap();
15878        assert_eq!(result["events"], 0);
15879        assert_eq!(result["files"], 0);
15880    }
15881}
15882
15883// v0.14.2 (#92 cat 1): setup_tests module retired. Coverage migrated
15884// to `adapters::harness::tests` — each shape gets its own dedicated
15885// test alongside the upsert fn that implements it.
15886
15887#[cfg(test)]
15888mod statusline_tests {
15889    use super::*;
15890
15891    #[test]
15892    fn statusline_merge_preserves_keys_and_is_idempotent() {
15893        let dir = tempfile::tempdir().unwrap();
15894        let path = dir.path().join("settings.json");
15895        std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
15896        // First merge changes the file but keeps existing keys.
15897        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
15898        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15899        assert_eq!(v["theme"], "dark");
15900        assert_eq!(v["model"], "opus");
15901        assert_eq!(v["statusLine"]["type"], "command");
15902        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
15903        // Identical re-merge = no change.
15904        assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
15905        // Remove drops ONLY statusLine.
15906        assert!(remove_statusline_entry(&path).unwrap());
15907        let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15908        assert_eq!(v2["theme"], "dark");
15909        assert!(v2.get("statusLine").is_none());
15910        // Remove again = no-op.
15911        assert!(!remove_statusline_entry(&path).unwrap());
15912    }
15913
15914    #[test]
15915    fn statusline_merge_refuses_to_clobber_invalid_json() {
15916        let dir = tempfile::tempdir().unwrap();
15917        let path = dir.path().join("settings.json");
15918        std::fs::write(&path, "this is not json {").unwrap();
15919        let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
15920        assert!(
15921            format!("{err:#}").contains("not valid JSON"),
15922            "err: {err:#}"
15923        );
15924        // File left untouched.
15925        assert_eq!(
15926            std::fs::read_to_string(&path).unwrap(),
15927            "this is not json {"
15928        );
15929    }
15930
15931    #[test]
15932    fn statusline_creates_settings_when_absent() {
15933        let dir = tempfile::tempdir().unwrap();
15934        let path = dir.path().join("settings.json");
15935        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
15936        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15937        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
15938    }
15939}
15940
15941// ---------- notify (Goal 2) ----------
15942
15943fn cmd_notify(
15944    interval_secs: u64,
15945    peer_filter: Option<&str>,
15946    once: bool,
15947    as_json: bool,
15948) -> Result<()> {
15949    use crate::inbox_watch::InboxWatcher;
15950    let cursor_path = config::state_dir()?.join("notify.cursor");
15951    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
15952    // v0.13.x identity work: a long-running notify loop racing another
15953    // wire process on the same inbox cursor silently drops toasts.
15954    // Skipped under `--once` (single sweep, no cursor ownership).
15955    if !once {
15956        crate::session::warn_on_identity_collision(std::process::id(), "notify");
15957    }
15958
15959    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
15960        let events = watcher.poll()?;
15961        for ev in events {
15962            if let Some(p) = peer_filter
15963                && ev.peer != p
15964            {
15965                continue;
15966            }
15967            if as_json {
15968                println!("{}", serde_json::to_string(&ev)?);
15969            } else {
15970                os_notify_inbox_event(&ev);
15971            }
15972        }
15973        watcher.save_cursors(&cursor_path)?;
15974        Ok(())
15975    };
15976
15977    if once {
15978        return sweep(&mut watcher);
15979    }
15980
15981    let interval = std::time::Duration::from_secs(interval_secs.max(1));
15982    loop {
15983        if let Err(e) = sweep(&mut watcher) {
15984            eprintln!("wire notify: sweep error: {e}");
15985        }
15986        std::thread::sleep(interval);
15987    }
15988}
15989
15990fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
15991    let who = persona_label(&ev.peer);
15992    let title = if ev.verified {
15993        format!("wire ← {who}")
15994    } else {
15995        format!("wire ← {who} (UNVERIFIED)")
15996    };
15997    let body = format!("{}: {}", ev.kind, ev.body_preview);
15998    // Issue #81: dedup by (peer, event_id) so that overlapping monitor
15999    // sweeps / restarts with a torn cursor don't fire the same toast over
16000    // and over. `event_id` may be empty for pre-v0.5 legacy events; fall
16001    // back to the body preview in that case so the key still varies per
16002    // event rather than collapsing every keyless event into one entry.
16003    let id = if ev.event_id.is_empty() {
16004        ev.body_preview.as_str()
16005    } else {
16006        ev.event_id.as_str()
16007    };
16008    let dedup_key = format!("inbox:{}:{}", ev.peer, id);
16009    crate::os_notify::toast_dedup(&dedup_key, &title, &body);
16010}
16011
16012#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
16013fn os_toast(title: &str, body: &str) {
16014    eprintln!("[wire notify] {title}\n  {body}");
16015}
16016
16017// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).
16018
16019#[cfg(test)]
16020mod relay_url_tests {
16021    use super::*;
16022
16023    #[test]
16024    fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
16025        // Bug 1: `wire up <handle>@<relay>` and `wire bind-relay
16026        // <handle>@<relay>` previously persisted/published the endpoint as
16027        // `https://<handle>@<relay>` — handle stuck in URL userinfo. Every
16028        // inbound event POST to that endpoint 4xxed (Cloudflare 400 on
16029        // wireup.net); bilateral pairing couldn't complete.
16030        //
16031        // Strip+warn (not hard-reject): mirrors cmd_up's already-bound
16032        // branch, which has always ignored the userinfo on the "keeping
16033        // existing binding" warning path. `<handle>@<relay>` is also
16034        // literally the wire dial-address format — natural by analogy.
16035
16036        assert_eq!(
16037            strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
16038            "https://wireup.net",
16039            "https URL with handle userinfo is stripped to the bare host"
16040        );
16041        assert_eq!(
16042            strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
16043            "http://127.0.0.1:8771",
16044            "http + port + userinfo is stripped, port preserved"
16045        );
16046        // user:password@host — both halves of userinfo are dropped.
16047        assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
16048        // Authority with port + userinfo.
16049        assert_eq!(
16050            strip_relay_url_userinfo("https://nick@host:8443"),
16051            "https://host:8443"
16052        );
16053        // Schemeless `<handle>@<host>` — strips correctly. (cmd_up's
16054        // bare-host normalize prepends https:// before calling, but the
16055        // function is robust to either input.)
16056        assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
16057        // Path / query / fragment AFTER the authority are preserved.
16058        assert_eq!(
16059            strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
16060            "https://wireup.net/v1/events?x=1#frag"
16061        );
16062    }
16063
16064    #[test]
16065    fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
16066        // Bare host (https / http, with and without port, with path / query).
16067        for ok in [
16068            "https://wireup.net",
16069            "http://wireup.net",
16070            "http://127.0.0.1:8771",
16071            "https://relay.example.com:9443/v1/wire",
16072            "https://wireup.net/?env=prod",
16073            // Path / query containing `@` is fine — it's not in the authority.
16074            "https://wireup.net/users/me@example.com",
16075            "https://wireup.net/?to=me@example.com",
16076            // Fragment with @ — fine.
16077            "https://wireup.net/#contact@me",
16078            // IPv6 literal (no @ in authority).
16079            "http://[::1]:8771",
16080            // Schemeless bare host — also fine.
16081            "wireup.net",
16082            "wireup.net:8443",
16083        ] {
16084            assert_eq!(
16085                strip_relay_url_userinfo(ok),
16086                ok,
16087                "clean URL `{ok}` must pass through unchanged"
16088            );
16089        }
16090    }
16091
16092    #[test]
16093    fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
16094        // Belt-and-suspenders: even if a future code path bypasses
16095        // strip_relay_url_userinfo at the entry, the persist/publish
16096        // boundary must refuse a userinfo URL. This is the second line
16097        // of defense that keeps a malformed endpoint out of the SIGNED
16098        // agent-card and the persisted relay_state.
16099        assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
16100        assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
16101        assert!(
16102            assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
16103        );
16104
16105        let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
16106            .unwrap_err()
16107            .to_string();
16108        assert!(
16109            err.contains("invariant violated"),
16110            "persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
16111        );
16112        assert!(
16113            err.contains("strip_relay_url_userinfo"),
16114            "error must name the upstream filter so the caller can audit the bypass: {err}"
16115        );
16116        // user:password@host is just as bad — userinfo is userinfo.
16117        assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
16118        // Authority with port + userinfo.
16119        assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
16120    }
16121
16122    #[test]
16123    fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
16124        // Bug 3 (cosmetic): `wire up <handle>@<relay>` echoed `claimed
16125        // <nick>@<nick>@<relay>` because strip_proto left the userinfo in.
16126        // With Bug 1's strip+warn in cmd_up, the claim step receives a
16127        // bare host — strip_proto returns `<host>` and the echo is
16128        // `<nick>@<host>` exactly once. Verified end-to-end here:
16129        let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
16130        assert_eq!(after_strip, "https://wireup.net");
16131        assert_eq!(strip_proto(&after_strip), "wireup.net");
16132        // And the doubled-echo failure mode that motivated the fix:
16133        assert!(
16134            strip_proto("https://nick@wireup.net").contains('@'),
16135            "strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
16136        );
16137    }
16138}
16139
16140#[cfg(test)]
16141mod self_pair_guard_tests {
16142    use super::*;
16143
16144    #[test]
16145    fn reject_self_pair_after_resolution_blocks_matching_dids() {
16146        // Issue #30 (explicit "Optional" ask): when both terminals collapse
16147        // onto one wire identity (a v0.13-era WIRE_SESSION_ID propagation
16148        // gap or a shared WIRE_HOME), the resolved peer DID matches the
16149        // local DID and pair_drop silently goes nowhere. Guard surfaces
16150        // it as a refusable error with the diagnostic remediation path.
16151
16152        let err = reject_self_pair_after_resolution(
16153            "did:wire:winter-bay-4092b577",
16154            "did:wire:winter-bay-4092b577",
16155        )
16156        .unwrap_err()
16157        .to_string();
16158        assert!(
16159            err.contains("refusing to self-pair"),
16160            "must explicitly refuse, not silently bail: {err}"
16161        );
16162        assert!(
16163            err.contains("did:wire:winter-bay-4092b577"),
16164            "must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
16165        );
16166        assert!(
16167            err.contains("issue #30") || err.contains("issue #29"),
16168            "must point at the tracking issue so historical context is one search away: {err}"
16169        );
16170        // Remediation must be copy-paste ready — both POSIX and PowerShell
16171        // (the failure mode is Windows-prevalent per #30).
16172        assert!(
16173            err.contains("WIRE_SESSION_ID"),
16174            "remediation must name the env var operators set: {err}"
16175        );
16176        assert!(
16177            err.contains("uuidgen") || err.contains("NewGuid"),
16178            "remediation must include a concrete command to mint a unique id: {err}"
16179        );
16180    }
16181
16182    #[test]
16183    fn reject_self_pair_after_resolution_allows_distinct_dids() {
16184        // Sanity: the guard must not fire for any normal pair attempt
16185        // between two distinct identities. Cover the common shapes:
16186        // adjective-noun personas (post-v0.11), bare keypair hashes, and
16187        // mixed-case DIDs that happen to share a prefix.
16188        reject_self_pair_after_resolution(
16189            "did:wire:winter-bay-4092b577",
16190            "did:wire:cedar-bayou-0616dc6c",
16191        )
16192        .unwrap();
16193        reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
16194            .unwrap();
16195        // Same persona prefix, different suffix-hash → distinct DIDs (the
16196        // suffix is the load-bearing identifier). Must NOT trigger the
16197        // guard.
16198        reject_self_pair_after_resolution(
16199            "did:wire:noble-canyon-deadbeef",
16200            "did:wire:noble-canyon-cafef00d",
16201        )
16202        .unwrap();
16203    }
16204}
16205
16206#[cfg(test)]
16207mod slot_reresolve_tests {
16208    use super::*;
16209
16210    /// Issue #15: the gating logic of try_reresolve_peer_on_slot_4xx
16211    /// must short-circuit BEFORE any network call when the error shape
16212    /// doesn't smell like slot rotation, when the peer was already
16213    /// re-resolved this push, or when there's no peer entry to work
16214    /// against. Three of those four short-circuit paths are testable
16215    /// without a mock relay; the fourth (the actual whois + slot
16216    /// comparison) requires either a live test server or a mock
16217    /// transport, so it's covered manually via the failover_tests
16218    /// helper + integration check in a separate PR.
16219    ///
16220    /// What these tests pin:
16221    ///   - 200/500/timeout-shape errors do NOT trigger a re-resolve
16222    ///     (avoids wasted whois RTTs and churn in steady-state).
16223    ///   - Same peer twice in one push call only attempts re-resolve
16224    ///     once (rate limit the issue specifies).
16225    ///   - Missing peer entry surfaces as an explicit error, NOT a
16226    ///     silent skip (operator can see the malformed state).
16227    ///   - Peer with no federation endpoint surfaces as an explicit
16228    ///     error (you can't re-resolve a slot you can't address).
16229
16230    #[test]
16231    fn try_reresolve_skips_when_error_is_not_4xx_shape() {
16232        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
16233        let already = std::collections::HashSet::new();
16234        // 200 OK shouldn't ever land in this path, but sanity check the
16235        // negative filter: any error string without "404"/"410" is a no-op.
16236        let res =
16237            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
16238                .unwrap();
16239        assert!(!res, "502 must NOT trigger a re-resolve");
16240
16241        let res =
16242            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
16243                .unwrap();
16244        assert!(!res, "transport errors must NOT trigger a re-resolve");
16245
16246        let res = try_reresolve_peer_on_slot_4xx(
16247            &mut state,
16248            "some-peer",
16249            "post failed: 401 Unauthorized",
16250            &already,
16251        )
16252        .unwrap();
16253        assert!(
16254            !res,
16255            "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
16256        );
16257    }
16258
16259    #[test]
16260    fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
16261        // The issue's rate limit: "at most one whois per peer per push call."
16262        // Caller tracks via `already_tried`; helper must honor it BEFORE
16263        // attempting any I/O (otherwise a bad-state peer would burn a
16264        // network call per event in the outbox).
16265        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
16266        let mut already = std::collections::HashSet::new();
16267        already.insert("some-peer".to_string());
16268        let res = try_reresolve_peer_on_slot_4xx(
16269            &mut state,
16270            "some-peer",
16271            "post failed: 410 Gone",
16272            &already,
16273        )
16274        .unwrap();
16275        assert!(
16276            !res,
16277            "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
16278        );
16279    }
16280
16281    #[test]
16282    fn try_reresolve_errors_when_peer_missing_from_state() {
16283        // Surface state corruption explicitly rather than silently
16284        // returning Ok(false). If a peer disappeared from relay_state
16285        // mid-loop the operator needs to see it.
16286        let mut state = json!({"peers": {}});
16287        let already = std::collections::HashSet::new();
16288        let err = try_reresolve_peer_on_slot_4xx(
16289            &mut state,
16290            "missing-peer",
16291            "post failed: 410 Gone",
16292            &already,
16293        )
16294        .unwrap_err()
16295        .to_string();
16296        assert!(
16297            err.contains("missing-peer") && err.contains("not in relay_state"),
16298            "missing-peer error must name the peer + the failure: {err}"
16299        );
16300    }
16301
16302    #[test]
16303    fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
16304        // A peer with only local-scope endpoints (UDS / 127.0.0.1) has
16305        // no relay domain to whois against. Helper must surface this as
16306        // an actionable error, not a silent skip — the operator's
16307        // remediation is "pair via federation" or "you're on the same
16308        // box, the slot can't be 410'd by a peer who controls the
16309        // socket."
16310        let mut state = json!({
16311            "peers": {
16312                "local-only": {
16313                    "endpoints": [
16314                        {
16315                            "scope": "Local",
16316                            "relay_url": "http://127.0.0.1:8771",
16317                            "slot_id": "loc",
16318                            "slot_token": "tok"
16319                        }
16320                    ]
16321                }
16322            }
16323        });
16324        let already = std::collections::HashSet::new();
16325        let err = try_reresolve_peer_on_slot_4xx(
16326            &mut state,
16327            "local-only",
16328            "post failed: 410 Gone",
16329            &already,
16330        )
16331        .unwrap_err()
16332        .to_string();
16333        assert!(
16334            err.contains("federation endpoint"),
16335            "no-federation error must name the problem: {err}"
16336        );
16337    }
16338
16339    /// Issue #69: pin the word-boundary behavior of
16340    /// `error_smells_like_slot_4xx`. Prior implementation used a bare
16341    /// `contains("410") || contains("404")` substring match, which
16342    /// false-triggered on any unrelated error string containing those
16343    /// digits — e.g. slot ids that happen to start with `410`, request
16344    /// IDs, byte counts, etc.  Each false-positive cost a wasted whois
16345    /// per peer per push and a misleading "peer slot rotated" log line.
16346    ///
16347    /// These tests pin three classes:
16348    ///   - Real reqwest StatusCode Display shapes (`": 410 Gone"`,
16349    ///     `": 404 Not Found"`) trigger.
16350    ///   - Real UDS bare-`u16` shapes (`": 410:"`, `": 404:"`) trigger.
16351    ///   - Substring lookalikes (`"slot 4101 expired"`,
16352    ///     `"request_id=410abc"`, `"received 4040 bytes"`,
16353    ///     `"event 0x4104"`) do NOT trigger.
16354    #[test]
16355    fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
16356        // reqwest::StatusCode Display is "<u16> <reason>", embedded in
16357        // the post_event failure format string as "...failed: <status>: <detail>".
16358        assert!(error_smells_like_slot_4xx(
16359            "post_event failed: 410 Gone: slot rotated by peer"
16360        ));
16361        assert!(error_smells_like_slot_4xx(
16362            "post_event failed: 404 Not Found: handle no longer claimed"
16363        ));
16364    }
16365
16366    #[test]
16367    fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
16368        // UDS path formats status as a bare u16, so the shape is
16369        // "...failed: 410: <detail>" with the status flanked by spaces
16370        // and colons (no reason phrase).
16371        assert!(error_smells_like_slot_4xx(
16372            "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
16373        ));
16374        assert!(error_smells_like_slot_4xx(
16375            "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
16376        ));
16377    }
16378
16379    #[test]
16380    fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
16381        // The bug being fixed: the prior `contains("410")` predicate
16382        // matched ALL of these, burning a whois RTT and emitting a
16383        // spurious "peer slot rotated" log line each time.
16384        let false_positives = [
16385            "push aborted: slot 4101 expired",
16386            "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
16387            "post_event failed: 500: received 4040 bytes, expected envelope",
16388            "post_event failed: 500: event 0x4104 malformed",
16389            "post_event failed: 503: backlog=4102 entries pending",
16390            // 4044 is "received bytes" or anything containing 404 mid-token.
16391            "post_event failed: 500: tx_id=4044beef",
16392            // pure digit substrings inside identifiers / hashes:
16393            "post_event failed: 500: hash=abc410def",
16394        ];
16395        for case in false_positives {
16396            assert!(
16397                !error_smells_like_slot_4xx(case),
16398                "must NOT trigger re-resolve on substring lookalike: {case:?}"
16399            );
16400        }
16401    }
16402
16403    #[test]
16404    fn error_smells_like_slot_4xx_handles_edge_positions() {
16405        // Token at start of string (no preceding char).
16406        assert!(error_smells_like_slot_4xx("410 Gone"));
16407        assert!(error_smells_like_slot_4xx("404 Not Found"));
16408        // Token at end of string (no trailing char).
16409        assert!(error_smells_like_slot_4xx("got 410"));
16410        assert!(error_smells_like_slot_4xx("got 404"));
16411        // Tab and newline as separators (logs sometimes carry these).
16412        assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
16413        assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
16414        // Pure digit-only input that IS the code — token at start AND end.
16415        assert!(error_smells_like_slot_4xx("410"));
16416        assert!(error_smells_like_slot_4xx("404"));
16417        // Empty / no-match.
16418        assert!(!error_smells_like_slot_4xx(""));
16419        assert!(!error_smells_like_slot_4xx("no relevant status"));
16420        // 411-414, 401-403, 405-409 must NOT trigger (only 410/404 are
16421        // the slot-rotation shape per issue #15).
16422        assert!(!error_smells_like_slot_4xx(
16423            "post_event failed: 401 Unauthorized"
16424        ));
16425        assert!(!error_smells_like_slot_4xx(
16426            "post_event failed: 403 Forbidden"
16427        ));
16428        assert!(!error_smells_like_slot_4xx(
16429            "post_event failed: 411 Length Required"
16430        ));
16431    }
16432}
16433
16434// v0.14: tests for op-claims surfacing on operator read verbs.
16435// Pure-over-Value helper; no I/O, no filesystem fixtures needed.
16436#[cfg(test)]
16437mod op_claims_surfacing_tests {
16438    use super::*;
16439
16440    #[test]
16441    fn op_claims_extracts_present_non_null_fields() {
16442        let card = json!({
16443            "did": "did:wire:foo-deadbeef",
16444            "handle": "foo",
16445            "op_did": "did:wire:op:foo-aaaa",
16446            "op_pubkey": "PKB64==",
16447            "op_cert": "SIGB64==",
16448            "org_memberships": [{"org_did": "did:wire:org:slancha-bbbb"}],
16449            "schema_version": "v3.2",
16450        });
16451        let claims = op_claims_from_card(&card);
16452        assert_eq!(claims.len(), 5);
16453        assert_eq!(
16454            claims.get("op_did").and_then(Value::as_str),
16455            Some("did:wire:op:foo-aaaa")
16456        );
16457        assert!(
16458            claims
16459                .get("org_memberships")
16460                .and_then(Value::as_array)
16461                .is_some()
16462        );
16463    }
16464
16465    #[test]
16466    fn op_claims_empty_on_pre_v014_card() {
16467        // A pre-v0.14 card has none of the inline op_* fields. The
16468        // helper must return an EMPTY map so older peers surface
16469        // identically on every read verb (no `null`-spam in JSON,
16470        // no new lines in human output).
16471        let card = json!({
16472            "did": "did:wire:bar-cafebabe",
16473            "handle": "bar",
16474            "capabilities": ["wire/v3.1"],
16475        });
16476        assert!(op_claims_from_card(&card).is_empty());
16477    }
16478
16479    #[test]
16480    fn op_claims_skips_explicit_null_fields() {
16481        // Defensive: a card where republish has serialized op_did as
16482        // `null` (e.g., post-unenroll rebuild) must not surface a
16483        // `null` field — operators read absence to mean "not enrolled".
16484        let card = json!({
16485            "did": "did:wire:baz-12341234",
16486            "op_did": Value::Null,
16487            "org_memberships": Value::Null,
16488            "schema_version": "v3.2",
16489        });
16490        let claims = op_claims_from_card(&card);
16491        assert_eq!(claims.len(), 1);
16492        assert!(claims.get("op_did").is_none());
16493        assert!(claims.get("org_memberships").is_none());
16494        assert_eq!(
16495            claims.get("schema_version").and_then(Value::as_str),
16496            Some("v3.2")
16497        );
16498    }
16499}
16500
16501#[cfg(test)]
16502mod enroll_add_membership_tests {
16503    use super::*;
16504    use crate::enroll::issue_member_cert;
16505    use crate::signing::{b64encode, generate_keypair};
16506
16507    fn seed_op() -> ([u8; 32], [u8; 32], String) {
16508        let (sk, pk) = generate_keypair();
16509        crate::config::write_op_key(&sk).unwrap();
16510        crate::config::write_op_handle("opfoo").unwrap();
16511        let op_did = crate::agent_card::did_for_op("opfoo", &pk);
16512        (sk, pk, op_did)
16513    }
16514
16515    #[test]
16516    fn add_membership_happy_path_stores_and_is_idempotent() {
16517        config::test_support::with_temp_home(|| {
16518            config::ensure_dirs().unwrap();
16519            let (_op_sk, _op_pk, op_did) = seed_op();
16520            let (org_sk, org_pk) = generate_keypair();
16521            let org_did = crate::agent_card::did_for_org("acme", &org_pk);
16522            let cert = issue_member_cert(&org_sk, &op_did).unwrap();
16523            let bundle = json!({
16524                "org_did": org_did,
16525                "org_pubkey": b64encode(&org_pk),
16526                "member_cert": cert,
16527            })
16528            .to_string();
16529            cmd_enroll_add_membership(Some(bundle.clone()), None, None, None, true).unwrap();
16530            let stored = config::read_memberships().unwrap();
16531            assert_eq!(stored.len(), 1);
16532            assert_eq!(
16533                stored[0].get("org_did").and_then(Value::as_str),
16534                Some(org_did.as_str())
16535            );
16536            // Idempotent: re-running with the same org_did replaces, not duplicates.
16537            cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap();
16538            assert_eq!(config::read_memberships().unwrap().len(), 1);
16539        });
16540    }
16541
16542    #[test]
16543    fn add_membership_rejects_cert_for_wrong_op_did() {
16544        config::test_support::with_temp_home(|| {
16545            config::ensure_dirs().unwrap();
16546            let (_op_sk, _op_pk, _op_did) = seed_op();
16547            let (org_sk, org_pk) = generate_keypair();
16548            let org_did = crate::agent_card::did_for_org("acme", &org_pk);
16549            // Cert signed for a DIFFERENT op_did. Verify must refuse.
16550            let other_did = "did:wire:op:ghost-deadbeefdeadbeefdeadbeefdeadbeef";
16551            let cert = issue_member_cert(&org_sk, other_did).unwrap();
16552            let bundle = json!({
16553                "org_did": org_did,
16554                "org_pubkey": b64encode(&org_pk),
16555                "member_cert": cert,
16556            })
16557            .to_string();
16558            let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
16559            assert!(
16560                err.to_string().contains("verification failed"),
16561                "got: {err:#}"
16562            );
16563            // And nothing landed on disk.
16564            assert!(config::read_memberships().unwrap().is_empty());
16565        });
16566    }
16567
16568    #[test]
16569    fn add_membership_rejects_when_not_enrolled() {
16570        config::test_support::with_temp_home(|| {
16571            config::ensure_dirs().unwrap();
16572            // No op key written → we don't know our own op_did → refuse.
16573            let (org_sk, org_pk) = generate_keypair();
16574            let org_did = crate::agent_card::did_for_org("acme", &org_pk);
16575            let cert = issue_member_cert(&org_sk, "did:wire:op:anybody-aaaa").unwrap();
16576            let bundle = json!({
16577                "org_did": org_did,
16578                "org_pubkey": b64encode(&org_pk),
16579                "member_cert": cert,
16580            })
16581            .to_string();
16582            let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
16583            assert!(err.to_string().contains("not enrolled"), "got: {err:#}");
16584        });
16585    }
16586
16587    #[test]
16588    fn add_membership_rejects_malformed_org_did() {
16589        config::test_support::with_temp_home(|| {
16590            config::ensure_dirs().unwrap();
16591            let _ = seed_op();
16592            let bundle = json!({
16593                "org_did": "did:wire:not-an-org",
16594                "org_pubkey": "AAAA",
16595                "member_cert": "AAAA",
16596            })
16597            .to_string();
16598            let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
16599            assert!(
16600                err.to_string().contains("not a valid organization DID"),
16601                "got: {err:#}"
16602            );
16603        });
16604    }
16605}