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`, `dial`, `accept`/`reject`,
11//!     `invite`/`accept-invite`. The bilateral gate (operator-side `accept`)
12//!     preserves the human-in-loop step — see `docs/THREAT_MODEL.md` T10/T14.
13
14use anyhow::{Context, Result, anyhow, bail};
15use clap::{Parser, Subcommand};
16use serde_json::{Value, json};
17
18use crate::{
19    agent_card::{build_agent_card, sign_agent_card},
20    config,
21    signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
22    trust::{add_self_to_trust, empty_trust},
23};
24
25/// Top-level CLI.
26#[derive(Parser, Debug)]
27#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
28pub struct Cli {
29    #[command(subcommand)]
30    pub command: Command,
31}
32
33#[derive(Subcommand, Debug)]
34pub enum Command {
35    /// Generate a keypair, write self-card, and bind an inbound slot.
36    /// (HUMAN-ONLY — DO NOT exec from agents.)
37    ///
38    /// v0.9: refuses to create a slotless session by default. Pre-v0.9
39    /// the silent slotless state caused the 2026-05-23 silent-fail
40    /// incident — pairing + sending succeeded but peers black-holed
41    /// inbound. Operators must now name how the session is reachable:
42    /// `--relay <url>` (binds a slot inline) or `--offline` (opt into
43    /// slotless, acknowledge `wire bind-relay` is required before any
44    /// pair or send).
45    ///
46    /// v0.13.1: folded into `wire up` and hidden. Your handle is your
47    /// DID-derived persona (one-name rule), so the typed `handle` arg is a
48    /// vestigial seed with no effect on identity. Kept callable for explicit
49    /// offline keygen (`wire init x --offline`); everyone else uses `wire up`.
50    #[command(hide = true)]
51    Init {
52        /// Vestigial seed — ignored; your handle is your DID-derived persona.
53        handle: String,
54        /// Optional display name (defaults to capitalized handle).
55        #[arg(long)]
56        name: Option<String>,
57        /// Relay URL — binds an inbound slot in the same step. Required
58        /// unless `--offline` is passed. Example:
59        /// `--relay http://127.0.0.1:8771` (local), `--relay https://wireup.net`
60        /// (federation).
61        #[arg(long)]
62        relay: Option<String>,
63        /// v0.9: opt into a slotless session — keypair only, no inbound
64        /// mailbox. You MUST run `wire bind-relay <url>` before any
65        /// pair / send / dial; until then peers cannot reach you.
66        /// Useful for offline keypair generation; rare in practice.
67        #[arg(long, conflicts_with = "relay")]
68        offline: bool,
69        /// Emit JSON.
70        #[arg(long)]
71        json: bool,
72    },
73    /// Print this agent's identity (DID, fingerprint, mailbox slot).
74    Whoami {
75        #[arg(long)]
76        json: bool,
77        /// Print just `<emoji> <nickname>` (e.g. `🦊 foxtrot-meadow`).
78        /// Plain text, no ANSI escapes. Useful for piping into other tools.
79        #[arg(long, conflicts_with = "json")]
80        short: bool,
81        /// Print `<emoji> <nickname>` wrapped in ANSI 256-color escapes.
82        /// Drop into a Claude Code statusline command for live identity display.
83        #[arg(long, conflicts_with_all = ["json", "short"])]
84        colored: bool,
85    },
86    /// List pinned peers with their tiers and capabilities.
87    Peers {
88        #[arg(long)]
89        json: bool,
90    },
91    /// v0.9.5: emit shell completion script to stdout. Pipe to your
92    /// shell's completion dir to enable tab-completion of wire verbs
93    /// + handles + flags.
94    ///
95    /// Example installs:
96    ///   bash:       `wire completions bash > /etc/bash_completion.d/wire`
97    ///   zsh:        `wire completions zsh > ~/.zsh/completions/_wire`
98    ///   fish:       `wire completions fish > ~/.config/fish/completions/wire.fish`
99    ///   pwsh:       `wire completions powershell > $PROFILE` (append)
100    ///   elvish:     `wire completions elvish > ~/.elvish/lib/wire.elv`
101    Completions {
102        /// Shell to generate completions for.
103        #[arg(value_enum)]
104        shell: clap_complete::Shell,
105    },
106    /// v0.9.3: one-screen "you are here" view. Prints the current
107    /// session's character + handle + cwd, plus a short list of
108    /// neighbors (sister sessions on the local relay, pinned peers).
109    /// Designed for the operator's quick "wait which Claude is this,
110    /// and who's around?" question — no `--json` shuffling, no
111    /// remembering `wire whoami` vs `wire peers` vs `wire session
112    /// list-local`.
113    Here {
114        #[arg(long)]
115        json: bool,
116    },
117    /// v0.9 canonical surface: list pending-inbound pair requests waiting
118    /// for your consent. Operators reach for "what's pending?" not a
119    /// longer table-dump verb.
120    Pending {
121        #[arg(long)]
122        json: bool,
123    },
124    /// Sign and queue an event to a peer.
125    ///
126    /// Forms (P0.S 0.5.11):
127    ///   wire send <peer> <body>              # kind defaults to "claim"
128    ///   wire send <peer> <kind> <body>       # explicit kind (back-compat)
129    ///   wire send <peer> -                   # body from stdin (kind=claim)
130    ///   wire send <peer> @/path/to/body.json # body from file
131    Send {
132        /// Peer handle (without `did:wire:` prefix).
133        peer: String,
134        /// When `<body>` is omitted, this is the event body (kind defaults
135        /// to `claim`). When both this and `<body>` are given, this is the
136        /// event kind (`decision`, `claim`, etc., or numeric kind id) and
137        /// the next positional is the body.
138        kind_or_body: String,
139        /// Event body — free-form text, `@/path/to/body.json` to load from
140        /// a file, or `-` to read from stdin. Optional; omit to use
141        /// `<kind_or_body>` as the body with kind=`claim`.
142        body: Option<String>,
143        /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
144        #[arg(long)]
145        deadline: Option<String>,
146        /// v0.10: skip the v0.9 auto-pair-on-miss behavior. Send fails
147        /// loudly if the peer isn't pinned yet. Use when you want strict
148        /// "no implicit dialing" semantics — scripts that error vs.
149        /// performing a side-effecting pair as a fallback.
150        #[arg(long)]
151        no_auto_pair: bool,
152        /// v0.14.2: opt back into the legacy outbox→daemon-push pipeline.
153        /// By default `wire send` POSTs to the peer's relay slot
154        /// synchronously and returns a real `delivered` / `duplicate` /
155        /// `failed` verdict. With `--queue` the event is appended to
156        /// `<outbox_dir>/<peer>.jsonl` and the daemon's push loop
157        /// drains it later (pre-v0.14.2 behavior). Use for offline
158        /// buffering, batch sends, or pre-pair queueing.
159        #[arg(long)]
160        queue: bool,
161        /// Emit JSON.
162        #[arg(long)]
163        json: bool,
164    },
165    /// v0.8 — "go talk to this name." The one verb operators reach for.
166    ///
167    /// `wire dial <name>` accepts a character nickname (`noble-slate`),
168    /// a session name (`slancha-api`), a card handle, or a DID — whichever
169    /// face you happen to know the peer by. Resolution order:
170    ///
171    /// 1. Already-pinned peer? → no-op (or send if a message was passed).
172    /// 2. Local sister session? → bilateral pair via the disk-read
173    ///    `--local-sister` path (no relay round-trip, no .well-known
174    ///    lookup, no SAS digits).
175    /// 3. Otherwise → bail with a clear hint pointing at federation
176    ///    syntax (`wire dial <handle>@<relay>` for cross-machine peers).
177    ///
178    /// With an optional message, `wire dial <name> "<msg>"` also sends
179    /// the message synchronously after the pair lands (#187 collapsed
180    /// the legacy queue→push step into a single direct relay POST;
181    /// the response carries the actual delivered/duplicate/etc.
182    /// verdict). Idempotent: re-dialling a known peer just sends.
183    Dial {
184        /// Peer name. Character nickname (preferred), session name,
185        /// card handle, or DID — anything that identifies the peer to
186        /// you.
187        name: String,
188        /// Optional first message to send after the pair lands. Same
189        /// semantics as the body argument to `wire send`. Defaults to
190        /// kind=claim.
191        message: Option<String>,
192        /// Emit JSON.
193        #[arg(long)]
194        json: bool,
195    },
196    /// Stream signed events from peers.
197    ///
198    /// Defaults to NEWEST-N orientation: with `--limit N`, prints the most
199    /// recent N events across all matched peers, sorted chronologically
200    /// (oldest of the window first, newest last — same orientation as Unix
201    /// `tail`). Pass `--oldest` to flip back to first-N (FIFO) behaviour.
202    /// `--limit 0` returns the full inbox in chronological order.
203    Tail {
204        /// Optional peer filter; if omitted, tails all peers.
205        peer: Option<String>,
206        /// Emit JSONL (one event per line).
207        #[arg(long)]
208        json: bool,
209        /// Maximum events to print. 0 = print everything (oldest → newest).
210        #[arg(long, default_value_t = 0)]
211        limit: usize,
212        /// Return the FIRST `--limit` events (oldest-N) instead of the
213        /// default last-N (newest-N). No effect when `--limit` is 0.
214        #[arg(long)]
215        oldest: bool,
216    },
217    /// Live tail of new inbox events across all pinned peers — one line per
218    /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
219    /// by default.
220    ///
221    /// Designed to be left running in an agent harness's stream-watcher
222    /// (Claude Code Monitor tool, etc.) so peer messages surface in the
223    /// session as they arrive, not on next manual `wire pull`.
224    ///
225    /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
226    /// template.
227    Monitor {
228        /// Only show events from this peer.
229        #[arg(long)]
230        peer: Option<String>,
231        /// Emit JSONL (one InboxEvent per line) for tooling consumption.
232        #[arg(long)]
233        json: bool,
234        /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
235        /// Default filters them out as noise.
236        #[arg(long)]
237        include_handshake: bool,
238        /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
239        #[arg(long, default_value_t = 500)]
240        interval_ms: u64,
241        /// Replay last N events from history before going live (0 = none).
242        #[arg(long, default_value_t = 0)]
243        replay: usize,
244    },
245    /// Verify a signed event from a JSON file or stdin (`-`).
246    Verify {
247        /// Path to event JSON, or `-` for stdin.
248        path: String,
249        /// Emit JSON.
250        #[arg(long)]
251        json: bool,
252    },
253    /// Run the MCP (Model Context Protocol) server over stdio.
254    /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
255    /// `wire_send`, `wire_tail`, etc. as native tools.
256    Mcp,
257    /// Run a relay server on this host.
258    RelayServer {
259        /// Bind address (e.g. `127.0.0.1:8770`).
260        #[arg(long, default_value = "127.0.0.1:8770")]
261        bind: String,
262        /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
263        /// skip `.well-known/wire/agent` serving. The relay becomes
264        /// invisible from outside the box — only same-machine processes
265        /// can pair through it. Right call for within-machine agent
266        /// coordination where you don't want metadata leaking to a
267        /// public relay. Pair this with `wire session new` which probes
268        /// `127.0.0.1:8771` and allocates a local slot automatically.
269        #[arg(long)]
270        local_only: bool,
271        /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
272        /// When set, --bind is ignored. Implies --local-only semantics
273        /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
274        /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
275        /// sister sessions. Unix only (Windows refuses).
276        #[arg(long)]
277        uds: Option<std::path::PathBuf>,
278    },
279    /// Allocate a slot on a relay; bind it to this agent's identity.
280    ///
281    /// v0.5.19 (issue #7): if any peers are pinned to this agent's
282    /// current slot, this command refuses by default — silent migration
283    /// silently black-holes their inbound messages. Pass
284    /// `--migrate-pinned` to acknowledge the risk and proceed, or use
285    /// `wire rotate-slot` (which emits a `wire_close` event to peers)
286    /// for safe rotation.
287    BindRelay {
288        /// Relay base URL, e.g. `http://127.0.0.1:8770`.
289        url: String,
290        /// Endpoint scope: `federation` | `local` | `lan` | `uds`.
291        /// Default inferred from the URL (loopback host -> local,
292        /// `unix://` -> uds, otherwise federation). Pass explicitly when
293        /// the inference is ambiguous (e.g. a federation relay on a
294        /// loopback address in tests).
295        #[arg(long)]
296        scope: Option<String>,
297        /// DESTRUCTIVE: drop all existing self slots and bind only this
298        /// relay (the pre-v0.12 single-slot behavior). Default is
299        /// ADDITIVE — the new slot is appended to `self.endpoints[]`,
300        /// keeping any existing slots so pinned peers are not
301        /// black-holed.
302        #[arg(long)]
303        replace: bool,
304        /// Acknowledge that pinned peers will black-hole until they
305        /// re-pin manually. Required for `--replace` (and same-relay
306        /// rotation) when `state.peers` is non-empty; ignored on fresh
307        /// boxes. Use `wire rotate-slot` instead for the supported
308        /// same-relay rotation path.
309        #[arg(long)]
310        migrate_pinned: bool,
311        #[arg(long)]
312        json: bool,
313    },
314    /// Manually pin a peer's relay slot. (Replaces SAS pairing for v0.1 bootstrap;
315    /// real `wire join` lands in the SPAKE2 iter.)
316    AddPeerSlot {
317        /// Peer handle (becomes did:wire:<handle>).
318        handle: String,
319        /// Peer's relay base URL.
320        url: String,
321        /// Peer's slot id.
322        slot_id: String,
323        /// Slot bearer token (shared between paired peers in v0.1).
324        slot_token: String,
325        #[arg(long)]
326        json: bool,
327    },
328    /// Drain outbox JSONL files to peers' relay slots.
329    Push {
330        /// Optional peer filter; default = all peers with outbox entries.
331        peer: Option<String>,
332        #[arg(long)]
333        json: bool,
334    },
335    /// Pull events from our relay slot, verify, write to inbox.
336    Pull {
337        #[arg(long)]
338        json: bool,
339    },
340    /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
341    /// Useful as a single "where am I" check.
342    Status {
343        /// Inspect a paired peer's transport / attention / responder health.
344        #[arg(long)]
345        peer: Option<String>,
346        #[arg(long)]
347        json: bool,
348    },
349    /// Publish or inspect auto-responder health for this slot.
350    Responder {
351        #[command(subcommand)]
352        command: ResponderCommand,
353    },
354    /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
355    /// — fallback path; the canonical flow is `wire dial <handle>@<relay>`.)
356    Pin {
357        /// Path to peer's signed agent-card JSON.
358        card_file: String,
359        #[arg(long)]
360        json: bool,
361    },
362    /// Allocate a NEW slot on the same relay and abandon the old one.
363    /// Sends a kind=1201 wire_close event to every paired peer over the OLD
364    /// slot announcing the new mailbox before swapping. After rotation,
365    /// peers must re-pair (or operator runs `add-peer-slot` with the new
366    /// coords) — auto-update via wire_close is a v0.2 daemon feature.
367    ///
368    /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
369    /// abusive bearer-holder spamming your slot). Rotate → old slot is
370    /// orphaned → attacker's leverage gone. Operator pairs again with
371    /// peers they still want.
372    RotateSlot {
373        /// Skip the wire_close announcement to peers (faster but they won't know
374        /// where you went).
375        #[arg(long)]
376        no_announce: bool,
377        #[arg(long)]
378        json: bool,
379    },
380    /// Remove a peer from trust + relay state. Inbox/outbox files for that
381    /// peer are NOT deleted (operator can grep history); pass --purge to
382    /// also wipe the JSONL files.
383    ForgetPeer {
384        /// Peer handle to forget.
385        handle: String,
386        /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
387        #[arg(long)]
388        purge: bool,
389        #[arg(long)]
390        json: bool,
391    },
392    /// v0.14.2 (#170): multi-session topology view — supervisor
393    /// liveness + per-session daemon liveness + unmanaged `wire daemon`
394    /// pids. `wire status` answers "is THIS session syncing?";
395    /// `wire supervisor` answers "what is the supervisor (and every
396    /// session's daemon) doing across the box?". Replaces the manual
397    /// `pgrep -fl 'wire daemon' | cross-ref each per-session pidfile`
398    /// dance honey-pine ran during her launchd diagnosis.
399    Supervisor {
400        /// Emit JSON instead of human-readable text. The shape matches
401        /// the `SupervisorState` struct in `daemon_supervisor.rs`.
402        #[arg(long)]
403        json: bool,
404    },
405    /// Run a long-lived sync loop: every <interval> seconds, push outbox to
406    /// peers' relay slots and pull inbox from our own slot. Foreground process;
407    /// background it with systemd / `&` / tmux as you prefer.
408    Daemon {
409        /// Sync interval in seconds. Default 5.
410        #[arg(long, default_value_t = 5)]
411        interval: u64,
412        /// Run a single sync cycle and exit (useful for cron-driven setups).
413        #[arg(long)]
414        once: bool,
415        /// v0.14.2 (#162): supervisor mode — read the session registry +
416        /// fork-exec one child `wire daemon` per initialized session,
417        /// each with its own WIRE_HOME pinned. Closes the launchd-blind
418        /// session-isolation gap honey-pine reported: with no cwd
419        /// context, a single launchd-spawned daemon resolves the
420        /// default WIRE_HOME and silently skips every other session.
421        /// Operator-facing: install this mode via `wire service install`
422        /// — the plist now uses `--all-sessions` so every session syncs
423        /// at login without the operator running N tmux panes.
424        #[arg(long)]
425        all_sessions: bool,
426        /// v0.14.2 (#162): run the daemon loop pinned to a specific
427        /// named session by setting WIRE_HOME for the process. The
428        /// supervisor (`--all-sessions`) spawns children with this
429        /// flag; operators can also use it directly for a one-session
430        /// foreground daemon outside the supervisor.
431        #[arg(long)]
432        session: Option<String>,
433        #[arg(long)]
434        json: bool,
435    },
436    /// Manage isolated wire sessions on this machine (v0.5.16).
437    ///
438    /// Each session = its own DID + handle + relay slot + daemon + inbox/
439    /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
440    /// in different projects) run on the same machine — without sessions
441    /// they all share one identity and race the inbox cursor.
442    ///
443    /// Names are derived from `basename(cwd)` and cached in a registry,
444    /// so re-entering the same project reuses the same identity.
445    #[command(subcommand)]
446    Session(SessionCommand),
447    /// Manage this session's identity display layer (character override).
448    /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
449    /// itself picks a custom nickname + emoji that overrides the
450    /// auto-derived hash-based defaults.
451    Identity {
452        #[command(subcommand)]
453        cmd: IdentityCommand,
454    },
455    /// v0.6.3 (issues #18 / #19 / #20 / #21): orchestration verbs for the
456    /// sister-session mesh. `wire mesh status` is the live view of every
457    /// paired sister (alias for `wire session mesh-status`); `wire mesh
458    /// broadcast` fans one signed event to every pinned peer.
459    #[command(subcommand)]
460    Mesh(MeshCommand),
461    /// Group chat (v0.13.3): create a named group, add VERIFIED peers, and
462    /// send/tail messages across the whole member set. Membership is a signed
463    /// roster (group-scoped tiers, separate from bilateral peer trust).
464    #[command(subcommand)]
465    Group(GroupCommand),
466    /// Mint operator / organization identities for the offline org-membership
467    /// layer (RFC-001): `wire enroll op` / `org-create` / `org-add-member`.
468    #[command(subcommand)]
469    Enroll(EnrollCommand),
470    /// Detect known MCP host config locations (Claude Desktop, Claude Code,
471    /// Cursor, project-local) and either print or auto-merge the wire MCP
472    /// server entry. Default prints; pass `--apply` to actually modify config
473    /// files. Idempotent — re-running is safe.
474    Setup {
475        /// Actually write the changes (default = print only).
476        #[arg(long)]
477        apply: bool,
478        /// Install a Claude Code statusLine showing your wire persona
479        /// (liveness dot + emoji + nickname in the persona's accent color +
480        /// cwd) instead of merging the MCP server. Writes a renderer script
481        /// and merges a `statusLine` block into Claude Code's settings.json
482        /// (honors $CLAUDE_CONFIG_DIR). Combine with --apply to write.
483        #[arg(long)]
484        statusline: bool,
485        /// With --statusline: uninstall it (drop the statusLine key + remove
486        /// the renderer script) instead of installing.
487        #[arg(long)]
488        remove: bool,
489    },
490    /// Show an agent's profile. With no arg, prints local self. With a
491    /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
492    /// endpoint and verifies the returned signed card before display.
493    Whois {
494        /// Optional handle (`nick@domain`). Omit to show self.
495        handle: Option<String>,
496        #[arg(long)]
497        json: bool,
498        /// Override the relay base URL used for resolution (default:
499        /// `https://<domain>` from the handle).
500        #[arg(long)]
501        relay: Option<String>,
502    },
503    /// Zero-paste pair with a known handle. Resolves `nick@domain` via that
504    /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
505    /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
506    /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
507    /// their slot_token so we can `wire send` to them).
508    Add {
509        /// Peer handle (`nick@domain`), OR a bare sister-session name
510        /// when `--local-sister` is set.
511        handle: String,
512        /// Override the relay base URL used for resolution.
513        #[arg(long)]
514        relay: Option<String>,
515        /// v0.6.6: pair with a sister session on this machine without
516        /// touching federation. Looks up `handle` as a session name in
517        /// `wire session list`, reads that session's agent-card +
518        /// endpoints from disk, pins directly, then delivers the
519        /// `pair_drop` to the sister's local-relay slot. No `.well-known`
520        /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
521        /// addressable because they don't need a federation claim.
522        #[arg(long)]
523        local_sister: bool,
524        #[arg(long)]
525        json: bool,
526    },
527    /// Come online in one command — `wire up` does what used to take five
528    /// (init + bind-relay + claim your persona + background daemon +
529    /// restart-on-login). Idempotent: re-run on an already-set-up box prints
530    /// state without churn.
531    ///
532    /// There is no name to choose: your handle IS your DID-derived persona
533    /// (one-name rule). The optional argument is just which relay to use.
534    ///
535    /// Examples:
536    ///   wire up                        # default public relay (wireup.net)
537    ///   wire up @wireup.net            # explicit federation relay
538    ///   wire up http://127.0.0.1:8771  # a local / self-hosted relay
539    Up {
540        /// Relay to bind + claim your persona on: `@wireup.net`, `wireup.net`,
541        /// or a full URL. Omit for the default public relay. No nick — your
542        /// handle is your DID-derived persona.
543        relay: Option<String>,
544        /// Optional display name for your profile card (cosmetic; distinct
545        /// from your addressable handle/persona).
546        #[arg(long)]
547        name: Option<String>,
548        /// Also additively dual-bind a LOCAL relay slot for fast same-box
549        /// sister-session routing. Defaults to probing
550        /// `http://127.0.0.1:8771`; pass a URL to override. Local relays
551        /// carry no handle directory, so nothing is claimed there.
552        #[arg(long)]
553        with_local: Option<String>,
554        /// Skip the opportunistic local dual-bind entirely.
555        #[arg(long)]
556        no_local: bool,
557        #[arg(long)]
558        json: bool,
559    },
560    /// Diagnose wire setup health. Single command that surfaces every
561    /// silent-fail class — daemon down or duplicated, relay unreachable,
562    /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
563    /// Replaces today's 30-minute manual debug.
564    ///
565    /// Exit code non-zero if any FAIL findings.
566    Doctor {
567        /// Emit JSON.
568        #[arg(long)]
569        json: bool,
570        /// Show last N entries from pair-rejected.jsonl in the report.
571        #[arg(long, default_value_t = 5)]
572        recent_rejections: usize,
573    },
574    /// Update + restart in one step (alias: `wire update`). ALWAYS checks
575    /// crates.io for a newer published wire; if one exists it installs it
576    /// (via `cargo install slancha-wire` when a Rust toolchain is on PATH,
577    /// else by downloading + SHA-256-verifying the prebuilt release binary
578    /// and replacing this one in place), then does the atomic daemon swap —
579    /// kill every `wire daemon`, respawn from the (now-current) binary, write
580    /// a fresh pidfile. No newer version → it skips the install and just
581    /// restarts the daemon. `--check` reports what would happen (available
582    /// update + processes that would be restarted) without doing it;
583    /// `--local` skips the crates.io check and only restarts the daemon
584    /// (offline, or running a local dev build).
585    #[command(visible_alias = "update")]
586    Upgrade {
587        /// Report current vs latest + drift without taking action.
588        #[arg(long)]
589        check: bool,
590        /// Skip the crates.io update check; just restart the daemon from the
591        /// current binary (offline / local dev build).
592        #[arg(long)]
593        local: bool,
594        /// Also kill `wire mcp` server subprocesses after the daemon swap so
595        /// their MCP host (Claude Code / Claude.app / Copilot CLI) respawns
596        /// them on the new binary. Without this, sister sessions keep
597        /// running pre-upgrade MCP code until each one explicitly `/mcp`
598        /// reconnects. Cross-session impact: kills every `wire mcp` found.
599        #[arg(long = "restart-mcp")]
600        restart_mcp: bool,
601        /// v0.14.3 (closes the #198 follow-up): kill the daemons reported in
602        /// `wire supervisor`'s `stale_binary_sessions` set — sister-session
603        /// children alive on an old binary that the supervisor's
604        /// existing-pidfile check intentionally protected from respawn. Once
605        /// each is killed, the `--all-sessions` supervisor respawns it on
606        /// the new binary on its next 10s registry poll. Cross-session
607        /// impact: only sessions flagged stale are touched; in-sync siblings
608        /// are spared. No-op (silent) when no supervisor is running OR no
609        /// stale daemons exist.
610        #[arg(long = "refresh-stale-children")]
611        refresh_stale_children: bool,
612        #[arg(long)]
613        json: bool,
614    },
615    /// Hard-reset this machine to a clean wire state: kill daemons,
616    /// remove service units, de-register the wire MCP entry from host
617    /// configs, and wipe all wire dirs. `--purge` also removes the
618    /// binary + shell lines. Requires --force or a typed confirmation.
619    Nuke {
620        /// Skip the typed confirmation (for automation / test harness).
621        /// `--yes` is an accepted alias.
622        #[arg(long, visible_alias = "yes")]
623        force: bool,
624        /// Also remove the `wire` binary + shell PATH/env lines.
625        #[arg(long)]
626        purge: bool,
627        /// Print what would be removed and exit without changing anything.
628        #[arg(long)]
629        dry_run: bool,
630        #[arg(long)]
631        json: bool,
632    },
633    /// Install / inspect / remove a launchd plist (macOS) or systemd
634    /// user unit (linux) that runs `wire daemon` on login + restarts
635    /// on crash. Replaces today's "background it with tmux/&/systemd
636    /// as you prefer" footgun.
637    Service {
638        #[command(subcommand)]
639        action: ServiceAction,
640    },
641    /// Inspect or toggle the structured diagnostic trace
642    /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
643    /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
644    /// (writes the file knob a running daemon picks up automatically).
645    Diag {
646        #[command(subcommand)]
647        action: DiagAction,
648    },
649    /// Claim your persona on a relay's handle directory. Anyone can then
650    /// reach this agent by `<persona>@<relay-domain>` via the relay's
651    /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
652    ///
653    /// ONE-NAME RULE (v0.13.1): the claimed handle is always your DID-derived
654    /// persona. The `nick` arg is vestigial — if it differs it is ignored
655    /// (like the typed name `wire init` / `wire up` already ignore), so your
656    /// phonebook entry can never drift from your agent-card handle.
657    ///
658    /// v0.13.1: hidden — `wire up` claims your persona for you. Kept callable
659    /// (idempotent re-claim) but not a user verb; there is no nick to choose.
660    #[command(hide = true)]
661    Claim {
662        /// Vestigial: ignored if it differs from your DID-derived persona.
663        nick: String,
664        /// Relay to claim the nick on. Default = relay our slot is on.
665        #[arg(long)]
666        relay: Option<String>,
667        /// Public URL the relay should advertise to resolvers (default = relay).
668        #[arg(long)]
669        public_url: Option<String>,
670        /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
671        /// directory listing. The handle stays claimed (FCFS still
672        /// applies) and direct `.well-known/wire/agent?handle=X` lookup
673        /// still resolves, so peers you share the handle with out-of-band
674        /// can still pair. Bulk scrapers / phonebook crawlers will not
675        /// see the nick. Use this for handles meant for known-peer
676        /// pairing only — see issue #9.
677        #[arg(long)]
678        hidden: bool,
679        #[arg(long)]
680        json: bool,
681    },
682    /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
683    /// avatar_url, handle, now). Re-signs the agent-card atomically.
684    ///
685    /// Examples:
686    ///   wire profile set motto "compiles or dies trying"
687    ///   wire profile set emoji "🦀"
688    ///   wire profile set vibe '["rust","late-night","no-async-please"]'
689    ///   wire profile set handle "coffee-ghost@anthropic.dev"
690    ///   wire profile get
691    Profile {
692        #[command(subcommand)]
693        action: ProfileAction,
694    },
695    /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
696    /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
697    /// a relay slot on first use. Default TTL 24h, single-use.
698    #[command(hide = true)] // v0.9 deprecated
699    Invite {
700        /// Override the relay URL for first-time auto-allocation.
701        #[arg(long, default_value = "https://wireup.net")]
702        relay: String,
703        /// Invite lifetime in seconds (default 86400 = 24h).
704        #[arg(long, default_value_t = 86_400)]
705        ttl: u64,
706        /// Number of distinct peers that can accept this invite before it's
707        /// consumed (default 1).
708        #[arg(long, default_value_t = 1)]
709        uses: u32,
710        /// Register the invite at the relay's short-URL endpoint and print
711        /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
712        /// Installs wire if missing, then accepts the invite, then pairs.
713        #[arg(long)]
714        share: bool,
715        /// Emit JSON.
716        #[arg(long)]
717        json: bool,
718    },
719    /// v0.9: accept a pending-inbound pair request by character
720    /// nickname or card handle.
721    ///
722    /// v0.9.4: the URL-vs-name smart-dispatch from v0.9 is gone. To
723    /// accept a federation invite URL use `wire accept-invite <URL>`
724    /// (split out as an explicit verb to eliminate the input-shape
725    /// ambiguity). `wire accept <URL>` still works for back-compat
726    /// but emits a deprecation banner pointing at `accept-invite`.
727    Accept {
728        /// Pending peer name (character nickname or card handle).
729        target: String,
730        /// Emit JSON.
731        #[arg(long)]
732        json: bool,
733    },
734    /// v0.9.4: accept a federation invite URL minted by `wire invite`.
735    /// Pins issuer, sends signed card to issuer's slot. Auto-inits +
736    /// auto-allocates as needed.
737    ///
738    /// Split out from `wire accept` to eliminate the URL-vs-name
739    /// smart-dispatch ambiguity (peer handles can legitimately collide
740    /// with URL-shaped strings; the explicit verb removes the inference).
741    #[command(alias = "invite-accept")]
742    AcceptInvite {
743        /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
744        url: String,
745        /// Emit JSON.
746        #[arg(long)]
747        json: bool,
748    },
749    /// v0.9: refuse a pending-inbound pair request without pairing.
750    Reject {
751        /// Peer name (character nickname or handle) from `wire pending`.
752        peer: String,
753        /// Emit JSON.
754        #[arg(long)]
755        json: bool,
756    },
757    /// Watch the inbox for new verified events and fire an OS notification per
758    /// event. Long-running; background under systemd / `&` / tmux. Cursor is
759    /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
760    /// re-emit history.
761    Notify {
762        /// Poll interval in seconds.
763        #[arg(long, default_value_t = 2)]
764        interval: u64,
765        /// Only notify for events from this peer (handle, no did: prefix).
766        #[arg(long)]
767        peer: Option<String>,
768        /// Run a single sweep and exit (useful for cron / tests).
769        #[arg(long)]
770        once: bool,
771        /// Suppress the OS notification call; print one JSON line per event to
772        /// stdout instead (for piping into other tooling or smoke-testing
773        /// without a desktop session).
774        #[arg(long)]
775        json: bool,
776    },
777    /// Silence (or re-enable) all wire desktop toasts. Persistent across
778    /// daemon restarts via a file at `<config_dir>/quiet`. `wire quiet on`
779    /// = silence; `wire quiet off` = restore; `wire quiet status` = report.
780    /// Same effect as exporting `WIRE_NO_TOASTS=1` (the env-var override
781    /// is for launchd contexts where the daemon's env isn't writable from
782    /// the operator's shell).
783    Quiet {
784        #[command(subcommand)]
785        action: QuietAction,
786    },
787}
788
789#[derive(Subcommand, Debug)]
790pub enum QuietAction {
791    /// Touch `<config_dir>/quiet` — silences every wire desktop toast
792    /// (pair_drop, monitor, inbox). Idempotent.
793    On,
794    /// Remove `<config_dir>/quiet` — re-enables toasts. Idempotent (no
795    /// error if already off / file absent).
796    Off,
797    /// Report current state: `on` (file present) / `off` (file absent) /
798    /// `forced-on-by-env` (`WIRE_NO_TOASTS=1` in env, overrides file).
799    Status {
800        /// Emit `{"state": "...", "via": "file"|"env"|"none"}` JSON
801        /// instead of the human one-liner.
802        #[arg(long)]
803        json: bool,
804    },
805}
806
807#[derive(Subcommand, Debug)]
808pub enum DiagAction {
809    /// Tail the last N entries from diag.jsonl.
810    Tail {
811        #[arg(long, default_value_t = 20)]
812        limit: usize,
813        #[arg(long)]
814        json: bool,
815    },
816    /// Flip the file-based knob ON. Running daemons pick this up on
817    /// the next emit call without restart.
818    Enable,
819    /// Flip the file-based knob OFF.
820    Disable,
821    /// Report whether diag is currently enabled + the file's size.
822    Status {
823        #[arg(long)]
824        json: bool,
825    },
826}
827
828/// `wire enroll …` — mint the operator/org identities + certs the offline
829/// org-membership layer (RFC-001) consumes. Keys are stored 0600 alongside
830/// `private.key`. (Publishing these claims on the agent's own card — the
831/// card-emit integration — is a separate follow-up.)
832#[derive(Subcommand, Debug)]
833pub enum EnrollCommand {
834    /// Mint this machine's operator root key (`op.key`) and print its `op_did`.
835    Op {
836        /// Operator handle (display only; the op_did commits to the key).
837        #[arg(long, default_value = "operator")]
838        handle: String,
839        #[arg(long)]
840        json: bool,
841    },
842    /// Mint an organization root key and print its `org_did` + `org_pubkey`.
843    OrgCreate {
844        /// Org handle (display only; the org_did commits to the key).
845        #[arg(long)]
846        handle: String,
847        #[arg(long)]
848        json: bool,
849    },
850    /// Issue a membership cert: the named org signs an operator's `op_did`.
851    /// Prints the `{org_did, org_pubkey, member_cert}` bundle for the operator
852    /// to add to their card's `org_memberships[]`.
853    OrgAddMember {
854        /// The operator DID to vouch for (`did:wire:op:…`).
855        op_did: String,
856        /// Which org signs (its `org_did`).
857        #[arg(long)]
858        org: String,
859        #[arg(long)]
860        json: bool,
861    },
862    /// Rebuild the agent card with the **current** enrollment state and
863    /// republish to the phonebook. Closes the enroll-after-`init` DX gap:
864    /// claims are normally attached at card-build time, but an operator who
865    /// enrolls AFTER `init` has a stored card that pre-dates the claims. Run
866    /// this once after `wire enroll op` / `org-add-member` to surface them.
867    /// Idempotent: not-enrolled rebuilds a claims-free card; not-bound prints
868    /// "local only".
869    Republish {
870        #[arg(long)]
871        json: bool,
872    },
873    /// Ingest a membership cert handed to this operator by an org owner.
874    ///
875    /// Closes the DX gap surfaced in #127 (slate-lotus 2026-05-30 audit):
876    /// `wire enroll org-add-member` printed an `{org_did, org_pubkey,
877    /// member_cert}` bundle but the receiver had no verb to store it —
878    /// joining an org required hand-editing
879    /// `<config>/wire/memberships.json`. This verb wraps the existing
880    /// `config::add_membership` helper + verifies the cert against
881    /// `org_pubkey` and this operator's `op_did` before storing, so a
882    /// malformed / wrong-key bundle fails loudly instead of corrupting
883    /// the next `wire enroll republish`.
884    ///
885    /// Accepts either a single `--bundle '<json>'` (the verbatim
886    /// org-add-member output) or the three fields separately. Idempotent:
887    /// re-running with the same `org_did` replaces the prior entry.
888    AddMembership {
889        /// Verbatim `org-add-member` output (overrides individual flags
890        /// when set). Shape: `{"org_did":"…","org_pubkey":"…","member_cert":"…"}`.
891        #[arg(long)]
892        bundle: Option<String>,
893        /// Required when `--bundle` is not set.
894        #[arg(long)]
895        org: Option<String>,
896        /// Required when `--bundle` is not set. Base64.
897        #[arg(long = "org-pubkey")]
898        org_pubkey: Option<String>,
899        /// Required when `--bundle` is not set. Base64-encoded Ed25519
900        /// signature by `org_pubkey` over this operator's `op_did`.
901        #[arg(long = "member-cert")]
902        member_cert: Option<String>,
903        #[arg(long)]
904        json: bool,
905    },
906}
907
908#[derive(Subcommand, Debug)]
909pub enum IdentityCommand {
910    /// Print the current character (DID-derived, the only name).
911    /// Equivalent to `wire whoami --short` but scoped here for grouping.
912    Show {
913        #[arg(long)]
914        json: bool,
915    },
916    /// List all identities on this machine — one row per session, with
917    /// each session's character, DID, federation handle, and cwd. Same
918    /// shape as `wire session list`, scoped here for the v0.7+ noun-
919    /// CLI surface.
920    List {
921        #[arg(long)]
922        json: bool,
923    },
924    /// Promote this identity to FEDERATION lifecycle: claim your persona on
925    /// the relay so peers can `wire dial <persona>@<relay-domain>` you.
926    /// Re-claims with current display fields so the relay always serves the
927    /// latest signed card. Equivalent to `wire claim`.
928    ///
929    /// v0.13.1: hidden — `wire up` publishes your persona for you, and the
930    /// nick is vestigial (one-name rule). Kept callable for re-publish.
931    #[command(hide = true)]
932    Publish {
933        /// Vestigial: ignored; your handle is your DID-derived persona.
934        nick: String,
935        /// Override the relay URL. Defaults to the session's bound relay
936        /// from `wire init --relay <url>`. Public relay if unset.
937        #[arg(long)]
938        relay: Option<String>,
939        /// Public-facing URL for the agent-card location (when the relay
940        /// is behind a CDN with a different public domain).
941        #[arg(long, alias = "public")]
942        public_url: Option<String>,
943        /// Skip listing in the relay's public phonebook. The card is
944        /// still claimable + reachable; just doesn't appear in
945        /// `wireup.net/phonebook` for stranger-discovery.
946        #[arg(long)]
947        hidden: bool,
948        #[arg(long)]
949        json: bool,
950    },
951    /// Destroy a session entirely — keys, agent-card, relay state, daemon.
952    /// Equivalent to `wire session destroy <name>`, scoped here for the
953    /// noun-CLI surface. Requires `--force` (the underlying command does).
954    Destroy {
955        /// Session name to destroy (use `wire identity list` to see).
956        name: String,
957        /// Bypass the confirmation prompt.
958        #[arg(long)]
959        force: bool,
960        #[arg(long)]
961        json: bool,
962    },
963    /// Create an identity in an EXPLICIT lifecycle state (vs. the
964    /// implicit `wire init` + `wire claim` flow).
965    /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
966    ///
967    /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
968    /// next reboot). In-memory semantics not yet supported — the
969    /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
970    /// For pure-RAM identities, see v1.0 vision.
971    ///
972    /// `--local` is the explicit form of today's default; identity
973    /// persists to the machine-wide sessions root.
974    Create {
975        /// Session name. Defaults to derived from cwd (anonymous mode
976        /// uses a random name).
977        #[arg(long)]
978        name: Option<String>,
979        /// Create an ANONYMOUS identity (tmpdir-backed, dies on
980        /// reboot, no federation). Mutually exclusive with --local.
981        #[arg(long, conflicts_with = "local")]
982        anonymous: bool,
983        /// Create a LOCAL identity (machine-persistent, no federation).
984        /// Default — explicit flag for clarity.
985        #[arg(long)]
986        local: bool,
987        #[arg(long)]
988        json: bool,
989    },
990    /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
991    /// the machine-wide sessions root + register in the cwd map.
992    /// After persist, the identity survives reboot.
993    /// v0.7.0-alpha.20.
994    Persist {
995        /// The anonymous identity's name (from `wire identity list`).
996        name: String,
997        /// Optional rename during persist. Default: keep the anon name.
998        #[arg(long = "as", value_name = "NEW_NAME")]
999        as_name: Option<String>,
1000        #[arg(long)]
1001        json: bool,
1002    },
1003    /// Demote an identity ONE level in the lifecycle:
1004    ///   federation → local: removes the relay slot binding but keeps
1005    ///   the keypair + agent-card. Operator can later re-publish with
1006    ///   `wire identity publish`. v0.7.0-alpha.20.
1007    ///
1008    /// (local → anonymous is not exposed; the safer flow is destroy +
1009    /// recreate, since "demoting" a persistent identity to ephemeral
1010    /// has surprising semantics — what about the keypair? what about
1011    /// pinned peers? Better to be explicit with destroy.)
1012    Demote {
1013        /// Session name to demote.
1014        name: String,
1015        #[arg(long)]
1016        json: bool,
1017    },
1018}
1019
1020#[derive(Subcommand, Debug)]
1021pub enum SessionCommand {
1022    /// Bootstrap a new isolated session in this machine's sessions root.
1023    /// With no name, derives one from `basename(cwd)` and caches it in
1024    /// the registry so re-running from the same project reuses it.
1025    /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1026    /// the new session's WIRE_HOME. Output includes the `export
1027    /// WIRE_HOME=...` line operators paste into their shell to activate
1028    /// it.
1029    New {
1030        /// Optional session name. Default = derived from `basename(cwd)`.
1031        name: Option<String>,
1032        /// Relay URL for the session's slot allocation + handle claim.
1033        #[arg(long, default_value = "https://wireup.net")]
1034        relay: String,
1035        /// v0.5.17: also allocate a second slot on a same-machine local
1036        /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1037        /// sister-session traffic prefers this path: zero round-trip
1038        /// latency, zero metadata exposure to the public relay. Probes
1039        /// `<local-relay>/healthz` first; silently skips if the local
1040        /// relay isn't running.
1041        #[arg(long)]
1042        with_local: bool,
1043        /// v0.5.17: override the local relay URL probed by `--with-local`.
1044        /// Default is `http://127.0.0.1:8771` to match
1045        /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1046        #[arg(long, default_value = "http://127.0.0.1:8771")]
1047        local_relay: String,
1048        /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1049        /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1050        /// Lets other machines on the same network reach this session
1051        /// directly without round-tripping the public federation relay
1052        /// at https://wireup.net. LAN endpoint is published in the
1053        /// agent-card; opt-in per session (default off).
1054        #[arg(long)]
1055        with_lan: bool,
1056        /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1057        /// LAN IP — operator must type the address). Example:
1058        /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1059        #[arg(long)]
1060        lan_relay: Option<String>,
1061        /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1062        /// relay (must be running e.g. via `wire relay-server --uds
1063        /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1064        /// bypasses the macOS firewall + Tailscale userspace-netstack
1065        /// class of issues entirely for sister-session traffic. UDS
1066        /// endpoint is published in the agent-card.
1067        #[arg(long)]
1068        with_uds: bool,
1069        /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1070        /// is set. Example: `/tmp/wire.sock` or
1071        /// `~/.wire/local.sock`.
1072        #[arg(long)]
1073        uds_socket: Option<std::path::PathBuf>,
1074        /// Skip spawning the session-local daemon. Use when you want
1075        /// to drive sync explicitly from the agent or test rig.
1076        #[arg(long)]
1077        no_daemon: bool,
1078        /// v0.6.6: create a federation-free session — no nick claim on
1079        /// `--relay`, no federation slot allocation. Implies
1080        /// `--with-local`. The session exists only to coordinate with
1081        /// other sister sessions on this machine; it has no public
1082        /// address and cannot be reached from outside. Reserved nicks
1083        /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1084        /// to publish them.
1085        #[arg(long)]
1086        local_only: bool,
1087        /// Emit JSON.
1088        #[arg(long)]
1089        json: bool,
1090    },
1091    /// List all sessions on this machine with their handle, DID,
1092    /// daemon liveness, and the cwd they're associated with.
1093    List {
1094        #[arg(long)]
1095        json: bool,
1096    },
1097    /// List sister sessions reachable via a same-machine local relay
1098    /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1099    /// share. Sessions without a Local-scope endpoint are listed
1100    /// separately so the operator can tell which are federation-only.
1101    /// Read-only — does not probe any relay or touch daemons.
1102    ListLocal {
1103        #[arg(long)]
1104        json: bool,
1105    },
1106    /// v0.6.0 (issue #12): mesh-pair every sister session against every
1107    /// other in O(N²) handshakes. For each unordered pair (A, B) that
1108    /// is not already paired, drives the bilateral flow end-to-end:
1109    /// `wire add` from A → B (queued + pushed), `wire accept` on
1110    /// B's side, then a final pull on A so the ack lands. Idempotent —
1111    /// re-running skips pairs already in `state.peers`.
1112    ///
1113    /// **Trust anchor:** the operator running this command owns every
1114    /// session listed in `wire session list-local` (they all live under
1115    /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1116    /// That filesystem-permission boundary IS the consent for both
1117    /// sides — the bilateral SAS / network-level handshake assumes
1118    /// strangers; same-uid sister sessions are by definition not
1119    /// strangers. Cross-uid sister sessions are out of scope; today
1120    /// `wire session list-local` only enumerates this user's sessions.
1121    PairAllLocal {
1122        /// Seconds to wait between handshake stages for pair_drop /
1123        /// pair_drop_ack to propagate over the relay. Default 1s
1124        /// (local-relay is typically <100ms RTT). Bump if you see
1125        /// "pending-inbound never arrived" errors on a slow relay.
1126        #[arg(long, default_value_t = 1)]
1127        settle_secs: u64,
1128        /// Federation relay to bind each `wire add` against. Default
1129        /// `https://wireup.net`. Sister sessions should be bound to
1130        /// the same federation relay; the pair handshake routes through
1131        /// it for the .well-known resolution + pair_drop deposit.
1132        #[arg(long, default_value = "https://wireup.net")]
1133        federation_relay: String,
1134        #[arg(long)]
1135        json: bool,
1136    },
1137    /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1138    /// machine. Enumerates every session in `wire session list-local`,
1139    /// walks each session's `relay.json#peers` to find which other sister
1140    /// sessions it has pinned, and probes the local relay for each edge's
1141    /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1142    /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1143    /// local_relay, summary}` so scripts can scrape.
1144    ///
1145    /// Read-only — does NOT touch peers or daemons, only the relay's
1146    /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1147    /// already hold. Silent on any probe failure (degrades to "no
1148    /// signal" rather than abort) so a half-broken mesh is still
1149    /// inspectable.
1150    MeshStatus {
1151        /// Threshold in seconds for "stale" classification on an edge.
1152        /// An edge whose receiver hasn't polled their slot in this long
1153        /// is flagged. Default 300s (5 min) — same as the per-send
1154        /// `phyllis` attentiveness nag.
1155        #[arg(long, default_value_t = 300)]
1156        stale_secs: u64,
1157        #[arg(long)]
1158        json: bool,
1159    },
1160    /// Print the `export WIRE_HOME=...` line for a session, so a shell
1161    /// can `eval $(wire session env <name>)` to activate it. With no
1162    /// name, resolves the cwd through the registry.
1163    Env {
1164        /// Session name. Default = derived from cwd via the registry.
1165        name: Option<String>,
1166        #[arg(long)]
1167        json: bool,
1168    },
1169    /// Identify which session the current cwd maps to in the registry.
1170    /// Prints `(none)` if cwd isn't registered — `wire session new`
1171    /// would create one.
1172    Current {
1173        #[arg(long)]
1174        json: bool,
1175    },
1176    /// Attach an existing session to the current cwd in the registry,
1177    /// so subsequent auto-detect from this cwd resolves to that session
1178    /// instead of walking up to an ancestor's binding. Use when an
1179    /// ancestor dir (e.g. `~/Source`) is already registered and is
1180    /// shadowing per-project identities for cwds beneath it. Idempotent;
1181    /// re-binding to the same name is a no-op. Re-binding to a different
1182    /// name overwrites the prior entry with a stderr warning.
1183    Bind {
1184        /// Session name to bind. Must already exist (run `wire session
1185        /// new <name>` first if not). With no name, auto-derives from
1186        /// `basename(cwd)` and errors if no session of that name exists.
1187        name: Option<String>,
1188        #[arg(long)]
1189        json: bool,
1190    },
1191    /// Tear down a session: kills its daemon (if running), deletes its
1192    /// state directory, and removes it from the registry. Requires
1193    /// `--force` because state loss is unrecoverable (keypair gone).
1194    Destroy {
1195        name: String,
1196        /// Confirm state-deleting operation.
1197        #[arg(long)]
1198        force: bool,
1199        #[arg(long)]
1200        json: bool,
1201    },
1202}
1203
1204/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1205/// session's view of the pinned peer set. `status` is the read-only
1206/// observability primitive (alias for `wire session mesh-status`);
1207/// Group-chat verbs (v0.13.3). Membership is a creator-signed roster
1208/// (`src/group.rs`); send fans a signed message over the member set.
1209#[derive(Subcommand, Debug)]
1210pub enum GroupCommand {
1211    /// Create a new group — you become the creator + sole member, roster signed.
1212    Create {
1213        /// Group name (human label).
1214        name: String,
1215        #[arg(long)]
1216        json: bool,
1217    },
1218    /// Add a bilaterally-VERIFIED pinned peer to a group you created (Member tier).
1219    Add {
1220        /// Group id or name.
1221        group: String,
1222        /// Peer handle (must be a VERIFIED pinned peer).
1223        peer: String,
1224        #[arg(long)]
1225        json: bool,
1226    },
1227    /// Send a message to every other member of a group (signed fan-out).
1228    Send {
1229        /// Group id or name.
1230        group: String,
1231        /// Message text.
1232        message: String,
1233        #[arg(long)]
1234        json: bool,
1235    },
1236    /// Show recent messages received for a group.
1237    Tail {
1238        /// Group id or name.
1239        group: String,
1240        /// Max messages to show.
1241        #[arg(long, default_value_t = 20)]
1242        limit: usize,
1243        #[arg(long)]
1244        json: bool,
1245    },
1246    /// List your groups + their members and tiers.
1247    List {
1248        #[arg(long)]
1249        json: bool,
1250    },
1251    /// Mint a shareable join code for a group (a self-contained token carrying
1252    /// the room coords + signed roster). Anyone you give it to can `wire group
1253    /// join <code>` to enter the room at Introduced tier. The code IS the room
1254    /// key — share it only with people you want in the room.
1255    Invite {
1256        /// Group id or name.
1257        group: String,
1258        #[arg(long)]
1259        json: bool,
1260    },
1261    /// Join a group from a code minted by `wire group invite`. Materializes the
1262    /// room locally, pins the existing members on the creator's vouch, and
1263    /// announces you to the room so members can verify your messages.
1264    Join {
1265        /// The `wire-group:` code (or bare base64 payload).
1266        code: String,
1267        #[arg(long)]
1268        json: bool,
1269    },
1270}
1271
1272/// `broadcast` fans a signed event to every pinned peer in one call.
1273#[derive(Subcommand, Debug)]
1274pub enum MeshCommand {
1275    /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1276    /// per-edge health roll-up across every sister session on this machine.
1277    Status {
1278        /// Threshold in seconds for "stale" classification on an edge.
1279        #[arg(long, default_value_t = 300)]
1280        stale_secs: u64,
1281        #[arg(long)]
1282        json: bool,
1283    },
1284    /// Fan one signed event to every pinned peer. Each peer receives a
1285    /// distinct `event_id` but every copy shares the same `broadcast_id`
1286    /// UUID so receivers can correlate them as a single broadcast.
1287    ///
1288    /// `--scope local` (default) only fans to peers reachable via a same-
1289    /// machine local relay. `--scope federation` only to public-relay
1290    /// peers. `--scope both` to every pinned peer.
1291    ///
1292    /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1293    /// for "ack-loop" prevention: a peer responding to a broadcast can
1294    /// exclude its own broadcaster when re-broadcasting.
1295    ///
1296    /// Body parsing follows `wire send`: literal string, `@/path` reads a
1297    /// file, `-` reads stdin (JSON if parseable, else literal).
1298    ///
1299    /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1300    /// peers — that would re-introduce the phonebook-scrape risk closed
1301    /// in v0.5.14 (T8).
1302    Broadcast {
1303        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1304        /// `heartbeat`. Same vocabulary as `wire send`.
1305        #[arg(long, default_value = "claim")]
1306        kind: String,
1307        /// `local`, `federation`, or `both`. Default `local`.
1308        #[arg(long, default_value = "local")]
1309        scope: String,
1310        /// Skip a specific peer handle. Repeatable.
1311        #[arg(long)]
1312        exclude: Vec<String>,
1313        /// Drop the broadcast event ID from the relay-side attentiveness
1314        /// nag (`phyllis`) — useful when broadcasting to many peers and
1315        /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1316        #[arg(long)]
1317        noreply: bool,
1318        /// Body — string, `@/path` for a file, or `-` for stdin.
1319        body: String,
1320        #[arg(long)]
1321        json: bool,
1322    },
1323    /// v0.6.4 (issue #20): assign role tags to sister sessions for
1324    /// capability-aware addressing. Stored as `profile.role` on the
1325    /// signed agent-card — propagates over the existing pair / .well-
1326    /// known plumbing, no new persistence.
1327    ///
1328    /// First slice of the Layer-2 capability metadata umbrella (#13).
1329    /// `wire mesh route` (issue #21) will consume these tags to pick
1330    /// the right sister for a task.
1331    Role {
1332        #[command(subcommand)]
1333        action: MeshRoleAction,
1334    },
1335    /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1336    /// to one sister session and deliver an event to that one peer.
1337    /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1338    /// can now address "the reviewer" instead of hard-coding a handle.
1339    ///
1340    /// Strategies:
1341    ///   - `round-robin` (default): per-role cursor, persisted at
1342    ///     `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1343    ///   - `first`: alphabetically-first matching sister. Deterministic.
1344    ///   - `random`: uniform random among matches. Stateless.
1345    ///
1346    /// Pinned-peers-only by construction (same posture as `broadcast`).
1347    /// Caller must already have the target sister pinned in
1348    /// `state.peers` — otherwise we can't sign + push. Run
1349    /// `wire session pair-all-local` first if the mesh isn't wired.
1350    Route {
1351        /// Role to match (operator-defined tag from `wire mesh role set`).
1352        role: String,
1353        /// `round-robin` (default), `first`, or `random`.
1354        #[arg(long, default_value = "round-robin")]
1355        strategy: String,
1356        /// Skip a specific sister handle. Repeatable.
1357        #[arg(long)]
1358        exclude: Vec<String>,
1359        /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1360        /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1361        #[arg(long, default_value = "claim")]
1362        kind: String,
1363        /// Body — string, `@/path` for a file, or `-` for stdin.
1364        body: String,
1365        #[arg(long)]
1366        json: bool,
1367    },
1368}
1369
1370/// v0.6.4: subcommands of `wire mesh role`.
1371#[derive(Subcommand, Debug)]
1372pub enum MeshRoleAction {
1373    /// Assign self to a role. Role is a free-form ASCII string
1374    /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1375    /// the vocabulary out-of-band — common starters: `planner`,
1376    /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1377    Set {
1378        role: String,
1379        #[arg(long)]
1380        json: bool,
1381    },
1382    /// Read self or a peer's role. With no arg, prints self. With a
1383    /// handle, reads from the peer's pinned agent-card.
1384    Get {
1385        peer: Option<String>,
1386        #[arg(long)]
1387        json: bool,
1388    },
1389    /// List roles across every sister session on this machine. Reads
1390    /// each session's agent-card by path — no network, no env mutation.
1391    List {
1392        #[arg(long)]
1393        json: bool,
1394    },
1395    /// Remove self from any assigned role. Re-signs the card with
1396    /// `profile.role: null`.
1397    Clear {
1398        #[arg(long)]
1399        json: bool,
1400    },
1401}
1402
1403#[derive(Subcommand, Debug)]
1404pub enum ServiceAction {
1405    /// Write the launchd plist (macOS) or systemd user unit (linux) and
1406    /// load it. Idempotent — re-running re-bootstraps an existing service.
1407    ///
1408    /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1409    /// process). Pass `--local-relay` to install the loopback relay
1410    /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1411    /// transport sister-Claudes use to coordinate on the same machine
1412    /// (v0.5.17 dual-slot). The two services have distinct labels +
1413    /// log files, so you can install both.
1414    Install {
1415        /// Install the local-relay service instead of the daemon.
1416        #[arg(long)]
1417        local_relay: bool,
1418        #[arg(long)]
1419        json: bool,
1420    },
1421    /// Unload + delete the service unit. Daemon keeps running until the
1422    /// next reboot or `wire upgrade`; this only changes the boot-time
1423    /// behaviour.
1424    Uninstall {
1425        /// Uninstall the local-relay service instead of the daemon.
1426        #[arg(long)]
1427        local_relay: bool,
1428        #[arg(long)]
1429        json: bool,
1430    },
1431    /// Report whether the unit is installed + active.
1432    Status {
1433        /// Show status of the local-relay service instead of the daemon.
1434        #[arg(long)]
1435        local_relay: bool,
1436        #[arg(long)]
1437        json: bool,
1438    },
1439}
1440
1441#[derive(Subcommand, Debug)]
1442pub enum ResponderCommand {
1443    /// Publish this agent's auto-responder health.
1444    Set {
1445        /// One of: online, offline, oauth_locked, rate_limited, degraded.
1446        status: String,
1447        /// Optional operator-facing reason.
1448        #[arg(long)]
1449        reason: Option<String>,
1450        /// Emit JSON.
1451        #[arg(long)]
1452        json: bool,
1453    },
1454    /// Read responder health for self, or for a paired peer.
1455    Get {
1456        /// Optional peer handle; omitted means this agent's own slot.
1457        peer: Option<String>,
1458        /// Emit JSON.
1459        #[arg(long)]
1460        json: bool,
1461    },
1462}
1463
1464#[derive(Subcommand, Debug)]
1465pub enum ProfileAction {
1466    /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1467    /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1468    /// (JSON array) and `now` (JSON object).
1469    Set {
1470        field: String,
1471        value: String,
1472        #[arg(long)]
1473        json: bool,
1474    },
1475    /// Show all profile fields. Equivalent to `wire whois`.
1476    Get {
1477        #[arg(long)]
1478        json: bool,
1479    },
1480    /// Clear a profile field.
1481    Clear {
1482        field: String,
1483        #[arg(long)]
1484        json: bool,
1485    },
1486}
1487
1488/// Entry point — parse and dispatch.
1489pub fn run() -> Result<()> {
1490    // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1491    // the session registry and adopt that session's home for this
1492    // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1493    // detect — `wire whoami` / `wire monitor` from a project cwd now
1494    // resolve to that project's session identity, not the machine
1495    // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1496    //
1497    // MUST run before any thread spawn — call it FIRST, before
1498    // `Cli::parse` (which uses clap internals only) and before any
1499    // command dispatch (which may spawn workers).
1500    crate::session::maybe_adopt_session_wire_home("cli");
1501    let cli = Cli::parse();
1502    match cli.command {
1503        Command::Init {
1504            handle,
1505            name,
1506            relay,
1507            offline,
1508            json,
1509        } => cmd_init(
1510            Some(&handle),
1511            name.as_deref(),
1512            relay.as_deref(),
1513            offline,
1514            json,
1515        ),
1516        Command::Status { peer, json } => {
1517            if let Some(peer) = peer {
1518                cmd_status_peer(&peer, json)
1519            } else {
1520                cmd_status(json)
1521            }
1522        }
1523        Command::Whoami {
1524            json,
1525            short,
1526            colored,
1527        } => cmd_whoami(json_default(json), short, colored),
1528        Command::Peers { json } => cmd_peers(json_default(json)),
1529        Command::Here { json } => cmd_here(json_default(json)),
1530        Command::Completions { shell } => {
1531            // v0.9.5: print shell completion script to stdout. Operator
1532            // pipes into their shell's completion dir; tab completion
1533            // covers verbs (dial, send, pending, accept, etc.) AND
1534            // their flags. Peer-name dynamic completion is a future
1535            // shell-side enhancement; clap_complete only ships the
1536            // static grammar.
1537            use clap::CommandFactory;
1538            let mut cmd = Cli::command();
1539            clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1540            Ok(())
1541        }
1542        Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1543        Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1544        Command::Send {
1545            peer,
1546            kind_or_body,
1547            body,
1548            deadline,
1549            no_auto_pair,
1550            queue,
1551            json,
1552        } => {
1553            // P0.S: smart-positional API. `wire send peer body` =
1554            // kind=claim. `wire send peer kind body` = explicit kind.
1555            let (kind, body) = match body {
1556                Some(real_body) => (kind_or_body, real_body),
1557                None => ("claim".to_string(), kind_or_body),
1558            };
1559            cmd_send(
1560                &peer,
1561                &kind,
1562                &body,
1563                deadline.as_deref(),
1564                no_auto_pair,
1565                queue,
1566                json_default(json),
1567            )
1568        }
1569        Command::Dial {
1570            name,
1571            message,
1572            json,
1573        } => cmd_dial(&name, message.as_deref(), json_default(json)),
1574        Command::Tail {
1575            peer,
1576            json,
1577            limit,
1578            oldest,
1579        } => cmd_tail(peer.as_deref(), json, limit, oldest),
1580        Command::Monitor {
1581            peer,
1582            json,
1583            include_handshake,
1584            interval_ms,
1585            replay,
1586        } => cmd_monitor(
1587            peer.as_deref(),
1588            json,
1589            include_handshake,
1590            interval_ms,
1591            replay,
1592        ),
1593        Command::Verify { path, json } => cmd_verify(&path, json),
1594        Command::Responder { command } => match command {
1595            ResponderCommand::Set {
1596                status,
1597                reason,
1598                json,
1599            } => cmd_responder_set(&status, reason.as_deref(), json),
1600            ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1601        },
1602        Command::Mcp => cmd_mcp(),
1603        Command::RelayServer {
1604            bind,
1605            local_only,
1606            uds,
1607        } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1608        Command::BindRelay {
1609            url,
1610            scope,
1611            replace,
1612            migrate_pinned,
1613            json,
1614        } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1615        Command::AddPeerSlot {
1616            handle,
1617            url,
1618            slot_id,
1619            slot_token,
1620            json,
1621        } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1622        Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1623        Command::Pull { json } => cmd_pull(json),
1624        Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1625        Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1626        Command::ForgetPeer {
1627            handle,
1628            purge,
1629            json,
1630        } => cmd_forget_peer(&handle, purge, json),
1631        Command::Supervisor { json } => cmd_supervisor(json),
1632        Command::Daemon {
1633            interval,
1634            once,
1635            all_sessions,
1636            session,
1637            json,
1638        } => cmd_daemon(interval, once, all_sessions, session, json),
1639        Command::Session(cmd) => cmd_session(cmd),
1640        Command::Identity { cmd } => cmd_identity(cmd),
1641        Command::Mesh(cmd) => cmd_mesh(cmd),
1642        Command::Group(cmd) => cmd_group(cmd),
1643        Command::Enroll(cmd) => cmd_enroll(cmd),
1644        Command::Invite {
1645            relay,
1646            ttl,
1647            uses,
1648            share,
1649            json,
1650        } => cmd_invite(&relay, ttl, uses, share, json),
1651        Command::Accept { target, json } => {
1652            // `wire accept <name>` — canonical pending-pair consent step.
1653            // URL-shaped input is no longer accepted here; use `wire accept-invite <url>`.
1654            let j = json_default(json);
1655            if target.starts_with("wire://pair?") || target.starts_with("http") {
1656                anyhow::bail!(
1657                    "`wire accept` takes a peer name, not a URL. \
1658                     Use `wire accept-invite {target}` to accept an invite URL."
1659                );
1660            } else {
1661                cmd_pair_accept(&target, j)
1662            }
1663        }
1664        Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1665        Command::Whois {
1666            handle,
1667            json,
1668            relay,
1669        } => {
1670            // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1671            // resolves through the local identity layer (pinned peers
1672            // + local sister sessions). `wire whois <nick>@<relay>`
1673            // keeps the existing federation `.well-known/wire/agent`
1674            // path. `wire whois` (no arg) prints self via the original
1675            // path. The character nickname is the canonical operator-
1676            // facing name as of v0.8 — most callers should hit the
1677            // local route.
1678            match handle.as_deref() {
1679                Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1680                other => cmd_whois(other, json, relay.as_deref()),
1681            }
1682        }
1683        Command::Add {
1684            handle,
1685            relay,
1686            local_sister,
1687            json,
1688        } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1689        Command::Up {
1690            relay,
1691            name,
1692            with_local,
1693            no_local,
1694            json,
1695        } => cmd_up(
1696            relay.as_deref(),
1697            name.as_deref(),
1698            with_local.as_deref(),
1699            no_local,
1700            json,
1701        ),
1702        Command::Doctor {
1703            json,
1704            recent_rejections,
1705        } => cmd_doctor(json, recent_rejections),
1706        Command::Upgrade {
1707            check,
1708            local,
1709            restart_mcp,
1710            refresh_stale_children,
1711            json,
1712        } => cmd_upgrade(check, local, restart_mcp, refresh_stale_children, json),
1713        Command::Service { action } => cmd_service(action),
1714        Command::Diag { action } => cmd_diag(action),
1715        Command::Claim {
1716            nick,
1717            relay,
1718            public_url,
1719            hidden,
1720            json,
1721        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1722        Command::Profile { action } => cmd_profile(action),
1723        Command::Setup {
1724            apply,
1725            statusline,
1726            remove,
1727        } => {
1728            if statusline {
1729                cmd_setup_statusline(apply, remove)
1730            } else {
1731                cmd_setup(apply)
1732            }
1733        }
1734        Command::Notify {
1735            interval,
1736            peer,
1737            once,
1738            json,
1739        } => cmd_notify(interval, peer.as_deref(), once, json),
1740        Command::Nuke {
1741            force,
1742            purge,
1743            dry_run,
1744            json,
1745        } => cmd_nuke(force, purge, dry_run, json),
1746        Command::Quiet { action } => cmd_quiet(action),
1747    }
1748}
1749
1750// ---------- quiet (v0.14.x toast kill switch) ----------
1751
1752/// Path to the file that, when present, silences every wire desktop
1753/// toast. Created by `wire quiet on`, removed by `wire quiet off`. Read
1754/// per-toast-call by `crate::os_notify::toasts_disabled` — no daemon
1755/// restart needed for the toggle to take effect, just for binary swap.
1756fn quiet_flag_path() -> Result<std::path::PathBuf> {
1757    Ok(config::config_dir()?.join("quiet"))
1758}
1759
1760fn cmd_quiet(action: QuietAction) -> Result<()> {
1761    match action {
1762        QuietAction::On => {
1763            let path = quiet_flag_path()?;
1764            if let Some(parent) = path.parent() {
1765                std::fs::create_dir_all(parent).with_context(|| {
1766                    format!("creating config dir for quiet flag: {}", parent.display())
1767                })?;
1768            }
1769            // Idempotent: open with create-if-missing, write nothing.
1770            std::fs::OpenOptions::new()
1771                .create(true)
1772                .truncate(true)
1773                .write(true)
1774                .open(&path)
1775                .with_context(|| format!("writing {}", path.display()))?;
1776            println!(
1777                "wire quiet: ON (toasts silenced — file at {})",
1778                path.display()
1779            );
1780            Ok(())
1781        }
1782        QuietAction::Off => {
1783            let path = quiet_flag_path()?;
1784            match std::fs::remove_file(&path) {
1785                Ok(()) => println!("wire quiet: OFF (toasts re-enabled)"),
1786                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1787                    println!("wire quiet: OFF (was already off)")
1788                }
1789                Err(e) => return Err(anyhow!("removing {}: {e}", path.display())),
1790            }
1791            // Re-check env: a user can override file-off with WIRE_NO_TOASTS=1.
1792            if std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0") {
1793                println!(
1794                    "  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).",
1795                    std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
1796                );
1797            }
1798            Ok(())
1799        }
1800        QuietAction::Status { json } => {
1801            let env_set = std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0");
1802            let file_present = quiet_flag_path()?.exists();
1803            let (state, via) = match (env_set, file_present) {
1804                (true, _) => ("on", "env"),
1805                (false, true) => ("on", "file"),
1806                (false, false) => ("off", "none"),
1807            };
1808            if json {
1809                println!(
1810                    "{}",
1811                    serde_json::to_string(&json!({
1812                        "state": state,
1813                        "via": via,
1814                        "file": quiet_flag_path()?.display().to_string(),
1815                        "env_WIRE_NO_TOASTS": std::env::var("WIRE_NO_TOASTS").ok(),
1816                    }))?
1817                );
1818            } else {
1819                match (env_set, file_present) {
1820                    (true, _) => println!(
1821                        "wire quiet: ON (via WIRE_NO_TOASTS={} in env)",
1822                        std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
1823                    ),
1824                    (false, true) => println!(
1825                        "wire quiet: ON (via file at {})",
1826                        quiet_flag_path()?.display()
1827                    ),
1828                    (false, false) => println!("wire quiet: OFF"),
1829                }
1830            }
1831            Ok(())
1832        }
1833    }
1834}
1835
1836// ---------- nuke ----------
1837
1838fn cmd_nuke(force: bool, purge: bool, dry_run: bool, as_json: bool) -> Result<()> {
1839    use std::io::{IsTerminal, Write};
1840    let plan = crate::nuke::NukePlan::compute(purge)?;
1841
1842    // Render what will/would be removed.
1843    if as_json && dry_run {
1844        println!("{}", serde_json::to_string_pretty(&plan)?);
1845        return Ok(());
1846    }
1847    if !as_json {
1848        eprintln!("wire nuke will remove:");
1849        for p in &plan.paths {
1850            eprintln!("  dir   {}", p.display());
1851        }
1852        for m in &plan.mcp_files {
1853            eprintln!("  mcp   {} (de-register `wire`)", m.display());
1854        }
1855        eprintln!("  units launchd/systemd/schtasks (daemon + local-relay)");
1856        eprintln!("  procs any running wire daemon / supervisor / relay-server");
1857        if purge {
1858            eprintln!("  PURGE the `wire` binary + shell PATH/env lines");
1859        }
1860    }
1861    if dry_run {
1862        return Ok(());
1863    }
1864
1865    // Gate.
1866    if !crate::nuke::should_proceed(force, std::io::stdin().is_terminal(), || {
1867        eprint!("\nType `nuke` to confirm: ");
1868        let _ = std::io::stderr().flush();
1869        let mut line = String::new();
1870        let _ = std::io::stdin().read_line(&mut line);
1871        line
1872    }) {
1873        if !as_json {
1874            eprintln!("aborted — nothing removed. (Use --force for automation.)");
1875        }
1876        anyhow::bail!("nuke not confirmed");
1877    }
1878
1879    // Kill survivors not covered by unit teardown (best-effort).
1880    let killed = kill_wire_processes();
1881
1882    // Execute.
1883    let mut report = plan.execute()?;
1884    report.killed_pids = killed;
1885
1886    // --purge: remove binary + shell lines.
1887    if purge {
1888        report.binary_removed = purge_binary_and_shell(&mut report.warnings);
1889    }
1890
1891    if as_json {
1892        println!("{}", serde_json::to_string_pretty(&report)?);
1893    } else {
1894        eprintln!(
1895            "nuked: {} dir(s), {} mcp entr(ies), {} unit(s), {} proc(s){}",
1896            report.removed_paths.len(),
1897            report.removed_mcp_entries.len(),
1898            report.removed_units.len(),
1899            report.killed_pids.len(),
1900            if report.binary_removed {
1901                ", binary+shell"
1902            } else {
1903                ""
1904            },
1905        );
1906        for w in &report.warnings {
1907            eprintln!("  warn: {w}");
1908        }
1909    }
1910    Ok(())
1911}
1912
1913/// Best-effort kill of any wire daemon / supervisor / relay-server
1914/// process. Returns the pids we asked the OS to terminate.
1915fn kill_wire_processes() -> Vec<u32> {
1916    let mut killed = Vec::new();
1917    #[cfg(unix)]
1918    for pat in ["wire daemon", "relay-server"] {
1919        if let Ok(out) = std::process::Command::new("pkill")
1920            .arg("-f")
1921            .arg(pat)
1922            .output()
1923        {
1924            // pkill exit 0 = killed something; record nothing granular (best-effort).
1925            let _ = out;
1926        }
1927    }
1928    #[cfg(windows)]
1929    {
1930        // Kill wire.exe by PID, EXCLUDING our own process — a broad
1931        // `taskkill /IM wire.exe` would terminate this very `wire nuke`
1932        // run mid-execution. Enumerate via `tasklist` CSV and skip self.
1933        let self_pid = std::process::id();
1934        if let Ok(out) = std::process::Command::new("tasklist")
1935            .args(["/FI", "IMAGENAME eq wire.exe", "/FO", "CSV", "/NH"])
1936            .output()
1937        {
1938            for line in String::from_utf8_lossy(&out.stdout).lines() {
1939                // CSV row: "wire.exe","1234","Console","1","12,345 K"
1940                if let Some(pid) = line
1941                    .split(',')
1942                    .nth(1)
1943                    .and_then(|s| s.trim().trim_matches('"').parse::<u32>().ok())
1944                {
1945                    if pid != self_pid {
1946                        let _ = std::process::Command::new("taskkill")
1947                            .args(["/F", "/PID", &pid.to_string()])
1948                            .output();
1949                        killed.push(pid);
1950                    }
1951                }
1952            }
1953        }
1954    }
1955    // unix records nothing granular (pkill is coarse, best-effort); the
1956    // vec stays empty there, which keeps the report shape stable.
1957    let _ = &mut killed;
1958    killed
1959}
1960
1961/// --purge: remove the wire binary + scrub shell PATH/env lines.
1962/// Returns true if the binary was removed (false on the Windows
1963/// self-delete case, where we print the manual command instead).
1964fn purge_binary_and_shell(warnings: &mut Vec<String>) -> bool {
1965    let exe = match std::env::current_exe() {
1966        Ok(e) => e,
1967        Err(e) => {
1968            warnings.push(format!("resolve exe: {e:#}"));
1969            return false;
1970        }
1971    };
1972    #[cfg(windows)]
1973    {
1974        eprintln!("purge: a running .exe can't delete itself. Remove it manually:");
1975        eprintln!("  del \"{}\"", exe.display());
1976        warnings.push("binary self-delete skipped on Windows (manual del printed)".into());
1977        return false;
1978    }
1979    #[cfg(unix)]
1980    {
1981        match std::fs::remove_file(&exe) {
1982            Ok(()) => {
1983                // Best-effort shell-line scrub: well-known rc files.
1984                scrub_shell_lines(warnings);
1985                true
1986            }
1987            Err(e) => {
1988                warnings.push(format!("rm binary {}: {e:#}", exe.display()));
1989                false
1990            }
1991        }
1992    }
1993}
1994
1995#[cfg(unix)]
1996fn scrub_shell_lines(warnings: &mut Vec<String>) {
1997    let Some(home) = dirs::home_dir() else {
1998        return;
1999    };
2000    for rc in [".bashrc", ".zshrc", ".profile", ".config/fish/config.fish"] {
2001        let path = home.join(rc);
2002        let Ok(content) = std::fs::read_to_string(&path) else {
2003            continue;
2004        };
2005        let filtered: String = content
2006            .lines()
2007            .filter(|l| !(l.contains("wire") && (l.contains("PATH") || l.contains("WIRE_"))))
2008            .collect::<Vec<_>>()
2009            .join("\n");
2010        if filtered != content
2011            && let Err(e) = std::fs::write(&path, filtered + "\n")
2012        {
2013            warnings.push(format!("scrub {}: {e:#}", path.display()));
2014        }
2015    }
2016}
2017
2018// ---------- init ----------
2019
2020fn cmd_init(
2021    handle: Option<&str>,
2022    name: Option<&str>,
2023    relay: Option<&str>,
2024    offline: bool,
2025    as_json: bool,
2026) -> Result<()> {
2027    // One-name rule: a typed handle (if any) is only a vanity seed — the
2028    // persona is derived from the keypair fingerprint, so it has no effect
2029    // on the resulting identity. `wire up` passes None (there is no name to
2030    // type); an explicit `wire init <handle>` passes Some and we surface the
2031    // "ignored in favor of persona" notice for transparency.
2032    if let Some(h) = handle
2033        && !h
2034            .chars()
2035            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2036    {
2037        bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
2038    }
2039    if config::is_initialized()? {
2040        bail!(
2041            "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
2042            config::config_dir()?
2043        );
2044    }
2045    // v0.9.1 smart-default reachability. If the operator passed neither
2046    // --relay nor --offline, probe the conventional local relay at
2047    // http://127.0.0.1:8771 and auto-attach if healthy. Closes the
2048    // silent-slotless footgun WITHOUT the v0.9 rejection wall, which
2049    // forced operators through a three-flag decision tree on first
2050    // invocation. Bare `wire init <handle>` is now ergonomic again
2051    // whenever a local relay is running (the common dev setup).
2052    //
2053    // Probe order:
2054    //   1. --relay <url>          → use it
2055    //   2. --offline               → skip slot allocation (rare power-user)
2056    //   3. local relay reachable  → auto-attach + log to stderr
2057    //   4. otherwise               → bail with actionable options
2058    let mut resolved_relay: Option<String> = relay.map(str::to_string);
2059    if resolved_relay.is_none() && !offline {
2060        let default_local = "http://127.0.0.1:8771";
2061        let client = crate::relay_client::RelayClient::new(default_local);
2062        if client.check_healthz().is_ok() {
2063            eprintln!(
2064                "wire init: local relay at {default_local} reachable — auto-attaching. \
2065                 Use --relay <url> to pick a different relay, --offline to skip."
2066            );
2067            resolved_relay = Some(default_local.to_string());
2068        } else {
2069            // v0.9.5: interactive prompt for first-time operators
2070            // when the smart-default can't auto-attach. Detect TTY on
2071            // stdin AND stderr — only prompt for humans. CI / agents
2072            // / non-interactive shells fall through to the explicit
2073            // error wall (unchanged behavior since v0.9.1).
2074            use std::io::{BufRead, IsTerminal, Write};
2075            let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
2076            if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
2077                eprintln!("wire init: no local relay reachable at {default_local}.");
2078                eprint!(
2079                    "  Bind to public federation relay https://wireup.net instead? \
2080                     [Y/n/offline/url]: "
2081                );
2082                let _ = std::io::stderr().flush();
2083                let mut input = String::new();
2084                let _ = std::io::stdin().lock().read_line(&mut input);
2085                let answer = input.trim();
2086                match answer {
2087                    "" | "y" | "Y" | "yes" | "YES" => {
2088                        eprintln!("wire init: binding to https://wireup.net");
2089                        resolved_relay = Some("https://wireup.net".to_string());
2090                    }
2091                    "n" | "N" | "no" | "NO" => {
2092                        bail!(
2093                            "wire init: declined federation default; re-run with --relay <url> or --offline."
2094                        );
2095                    }
2096                    "offline" | "OFFLINE" => {
2097                        eprintln!(
2098                            "wire init: proceeding offline. \
2099                             Run `wire bind-relay <url>` before pairing."
2100                        );
2101                        // Fall through with resolved_relay still None;
2102                        // the `offline` flag is conceptually set but
2103                        // the caller's local doesn't need updating —
2104                        // resolved_relay = None + offline behavior
2105                        // is identical for the rest of cmd_init.
2106                    }
2107                    url if url.starts_with("http://") || url.starts_with("https://") => {
2108                        eprintln!("wire init: binding to {url}");
2109                        resolved_relay = Some(url.to_string());
2110                    }
2111                    other => {
2112                        bail!(
2113                            "wire init: unrecognized answer `{other}` — \
2114                             expected Y/n/offline/<url>. Re-run with --relay or --offline."
2115                        );
2116                    }
2117                }
2118            } else {
2119                bail!(
2120                    "wire init: no relay specified and no local relay reachable at \
2121                     http://127.0.0.1:8771.\n\
2122                     Pick one (or just run `wire up`):\n\
2123                     • `wire service install --local-relay` — start the local relay, then re-run\n\
2124                     • `wire up @wireup.net` — bind to public federation in one command\n\
2125                     • `wire init --offline` — generate keypair only \
2126                     (peers cannot reach you until you `wire bind-relay <url>` later)"
2127                );
2128            }
2129        }
2130    }
2131    let relay = resolved_relay.as_deref();
2132
2133    config::ensure_dirs()?;
2134    let (sk_seed, pk_bytes) = generate_keypair();
2135    config::write_private_key(&sk_seed)?;
2136
2137    // v0.11 ONE-NAME: derive the character nickname from a synthetic DID
2138    // using the freshly-generated pubkey, then USE THE CHARACTER as the
2139    // canonical handle. The operator-typed `handle` arg becomes either:
2140    //   - identical to character (already-canonical input — no-op), OR
2141    //   - overridden in favor of character (operator-typed name was a
2142    //     vanity layer that would never have been federation-reachable).
2143    // Either way, agent-card.handle ends up == character, and every
2144    // downstream surface (relay phonebook, .well-known, dial/send) keys
2145    // on the same name an operator sees in their statusline.
2146    //
2147    // Per the v0.11 directive: "If you can't call someone via a name,
2148    // don't let them have it as a name." Operator-typed handles violated
2149    // that rule because the character was the displayed name but the
2150    // handle was the addressable one. Now they're the same string.
2151    // The seed string only fills the (immediately-discarded) handle portion
2152    // of a synthetic DID; the persona derives from the fp suffix regardless,
2153    // so any seed yields the same identity.
2154    let seed = handle.unwrap_or("agent");
2155    let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
2156    let character = crate::character::Character::from_did(&synth_did);
2157    let canonical_handle: &str = &character.nickname;
2158    if let Some(typed) = handle
2159        && typed != canonical_handle
2160    {
2161        eprintln!(
2162            "wire init: one-name rule — typed `{typed}` ignored in favor of \
2163             DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
2164        );
2165    }
2166
2167    let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
2168    // Card-emit (RFC-001 Phase 1b): attach operator/org claims if enrolled
2169    // (fail-soft no-op otherwise; signed below so the sig covers the claims).
2170    let card = crate::enroll::with_op_claims_if_enrolled(card)?;
2171    let signed = sign_agent_card(&card, &sk_seed);
2172    config::write_agent_card(&signed)?;
2173
2174    let mut trust = empty_trust();
2175    add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
2176    config::write_trust(&trust)?;
2177
2178    let fp = fingerprint(&pk_bytes);
2179    let key_id = make_key_id(canonical_handle, &pk_bytes);
2180    // Rebind `handle` for the rest of cmd_init so downstream prints,
2181    // relay-state writes, etc. all reference the canonical name.
2182    let handle = canonical_handle;
2183
2184    // If --relay was passed, also bind a slot inline so init+bind happen in one step.
2185    let mut relay_info: Option<(String, String)> = None;
2186    if let Some(url) = relay {
2187        let normalized = url.trim_end_matches('/');
2188        let client = crate::relay_client::RelayClient::new(normalized);
2189        client.check_healthz()?;
2190        let alloc = client.allocate_slot(Some(handle))?;
2191        let mut state = config::read_relay_state()?;
2192        state["self"] = json!({
2193            "relay_url": normalized,
2194            "slot_id": alloc.slot_id.clone(),
2195            "slot_token": alloc.slot_token,
2196        });
2197        config::write_relay_state(&state)?;
2198        relay_info = Some((normalized.to_string(), alloc.slot_id));
2199    }
2200
2201    let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
2202    if as_json {
2203        let mut out = json!({
2204            "did": did_str.clone(),
2205            "fingerprint": fp,
2206            "key_id": key_id,
2207            "config_dir": config::config_dir()?.to_string_lossy(),
2208        });
2209        if let Some((url, slot_id)) = &relay_info {
2210            out["relay_url"] = json!(url);
2211            out["slot_id"] = json!(slot_id);
2212        }
2213        println!("{}", serde_json::to_string(&out)?);
2214    } else {
2215        println!("generated {did_str} (ed25519:{key_id})");
2216        println!(
2217            "config written to {}",
2218            config::config_dir()?.to_string_lossy()
2219        );
2220        if let Some((url, slot_id)) = &relay_info {
2221            println!("bound to relay {url} (slot {slot_id})");
2222            println!();
2223            println!("next step: `wire dial <handle>@{url}` to pair with a peer.");
2224        } else {
2225            println!();
2226            println!("next step: `wire dial <handle>@<relay>` to bind a relay + pair with a peer.");
2227        }
2228    }
2229    Ok(())
2230}
2231
2232// ---------- status ----------
2233
2234fn cmd_status(as_json: bool) -> Result<()> {
2235    let initialized = config::is_initialized()?;
2236
2237    let mut summary = json!({
2238        "initialized": initialized,
2239    });
2240
2241    if initialized {
2242        let card = config::read_agent_card()?;
2243        let did = card
2244            .get("did")
2245            .and_then(Value::as_str)
2246            .unwrap_or("")
2247            .to_string();
2248        // Prefer the explicit `handle` field added in v0.5.7. Fall back to
2249        // stripping the DID prefix (and the v0.5.7+ pubkey suffix) for
2250        // legacy cards.
2251        let handle = card
2252            .get("handle")
2253            .and_then(Value::as_str)
2254            .map(str::to_string)
2255            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2256        let pk_b64 = card
2257            .get("verify_keys")
2258            .and_then(Value::as_object)
2259            .and_then(|m| m.values().next())
2260            .and_then(|v| v.get("key"))
2261            .and_then(Value::as_str)
2262            .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2263        let pk_bytes = crate::signing::b64decode(pk_b64)?;
2264        summary["did"] = json!(did);
2265        summary["handle"] = json!(handle);
2266        summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2267        summary["capabilities"] = card
2268            .get("capabilities")
2269            .cloned()
2270            .unwrap_or_else(|| json!([]));
2271
2272        let trust = config::read_trust()?;
2273        let relay_state_for_tier =
2274            config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2275        let mut peers = Vec::new();
2276        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2277            for (peer_handle, _agent) in agents {
2278                if peer_handle == &handle {
2279                    continue; // self
2280                }
2281                // P0.Y (0.5.11): use effective tier — surfaces PENDING_ACK
2282                // for peers we've pinned but never received a pair_drop_ack
2283                // from, so the operator sees the "we can't send to them yet"
2284                // state instead of seeing a misleading VERIFIED.
2285                peers.push(json!({
2286                    "handle": peer_handle,
2287                    "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2288                }));
2289            }
2290        }
2291        summary["peers"] = json!(peers);
2292
2293        let relay_state = config::read_relay_state()?;
2294        summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2295        if !summary["self_relay"].is_null() {
2296            // Hide slot_token from default view.
2297            if let Some(obj) = summary["self_relay"].as_object_mut() {
2298                obj.remove("slot_token");
2299            }
2300        }
2301        summary["peer_slots_count"] = json!(
2302            relay_state
2303                .get("peers")
2304                .and_then(Value::as_object)
2305                .map(|m| m.len())
2306                .unwrap_or(0)
2307        );
2308
2309        // Outbox / inbox queue depth (file count + total events)
2310        let outbox = config::outbox_dir()?;
2311        let inbox = config::inbox_dir()?;
2312        summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2313        summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2314
2315        // v0.5.19: liveness snapshot through a single helper so this
2316        // surface and `wire doctor` agree by construction. Issue #2:
2317        // doctor PASSed while status said DOWN for 25 min because each
2318        // computed liveness independently. ensure_up::daemon_liveness
2319        // is the only path now.
2320        let snap = crate::ensure_up::daemon_liveness();
2321        let mut daemon = json!({
2322            "running": snap.pidfile_alive,
2323            "pid": snap.pidfile_pid,
2324            "all_running_pids": snap.pgrep_pids,
2325            "orphans": snap.orphan_pids,
2326        });
2327        if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2328            daemon["version"] = json!(d.version);
2329            daemon["bin_path"] = json!(d.bin_path);
2330            daemon["did"] = json!(d.did);
2331            daemon["relay_url"] = json!(d.relay_url);
2332            daemon["started_at"] = json!(d.started_at);
2333            daemon["schema"] = json!(d.schema);
2334            if d.version != env!("CARGO_PKG_VERSION") {
2335                daemon["version_mismatch"] = json!({
2336                    "daemon": d.version.clone(),
2337                    "cli": env!("CARGO_PKG_VERSION"),
2338                });
2339            }
2340        }
2341        // v0.14.2 (#162): surface "is the sync loop actually running RIGHT NOW?"
2342        // distinct from "is there a process named `wire daemon` somewhere?".
2343        // pidfile_alive + a fresh last_sync are both required for "healthy
2344        // sync"; pidfile_alive + no recent last_sync = the daemon is up but
2345        // wedged. last_sync_age_seconds = null = no record (never ran here).
2346        let last_sync_age = crate::ensure_up::last_sync_age_seconds();
2347        if let Some(rec) = crate::ensure_up::read_last_sync_record() {
2348            daemon["last_sync_at"] = json!(rec.ts);
2349            daemon["last_sync_age_seconds"] = json!(last_sync_age);
2350            daemon["last_sync_push_n"] = json!(rec.push_n);
2351            daemon["last_sync_pull_n"] = json!(rec.pull_n);
2352            daemon["last_sync_rejected_n"] = json!(rec.rejected_n);
2353        } else {
2354            daemon["last_sync_at"] = Value::Null;
2355            daemon["last_sync_age_seconds"] = Value::Null;
2356        }
2357        // v0.14.2 (#162 fix #2 + #7 surface gap, post-merge of #167/#168):
2358        // honey-pine round-trip dogfood (2026-06-01) confirmed pending_push_count
2359        // + stale_sync + stream_state surface in MCP wire_status but not in CLI
2360        // `wire status`. Shared helpers in config.rs keep both surfaces in lock
2361        // so future doctor/web checks pick up the same numbers.
2362        // Per-peer breakdown introduced 2026-06-01 after coral
2363        // dogfood found 3 events stuck on `orchid-savanna`
2364        // (PENDING_ACK pair). Aggregate count was already
2365        // surfaced; the missing piece was attribution — operator
2366        // had to manually walk per-peer outbox files to learn
2367        // which pair was wedged. Compute both from a single
2368        // breakdown so total + per-peer detail can't diverge.
2369        let pending_breakdown = config::compute_pending_push_breakdown();
2370        let pending_total: u64 = pending_breakdown.iter().map(|p| p.count).sum();
2371        daemon["pending_push_count"] = json!(pending_total);
2372        daemon["pending_push_breakdown"] = json!(pending_breakdown);
2373        daemon["stale_sync"] = json!(config::stale_sync(last_sync_age));
2374        daemon["stream_state"] = config::read_stream_state();
2375        // v0.14.2 (#162 diagnostic, post-#170): annotate orphan pids
2376        // with their cmdline + the `--session <name>` arg the
2377        // supervisor (or operator) tagged them with. honey-pine spent
2378        // multiple sessions diagnosing "wire status reports DOWN
2379        // while comms work" — turned out the orphan was a launchd-
2380        // spawned daemon serving a different WIRE_HOME. Surfacing
2381        // "(serving session 'X')" on each orphan collapses the
2382        // diagnostic time. Best-effort: cmdline read can race exit
2383        // → fields just stay absent rather than failing the status
2384        // call.
2385        // v0.14.2 #173 follow-up (post-#174 hotfix): the supervisor's
2386        // children no longer carry `--session <name>` in their cmdline
2387        // (WIRE_HOME env is the sole contract), so the pid → session
2388        // mapping has to walk per-session pidfiles instead. The
2389        // cmdline `parse_session_arg` path is kept as a fallback for
2390        // operator-spawned `wire daemon --session foo` runs.
2391        let pid_session_map = crate::session::pid_to_session_map();
2392        let orphans_detail: Vec<Value> = snap
2393            .orphan_pids
2394            .iter()
2395            .map(|pid| {
2396                let cmdline = crate::platform::pid_cmdline(*pid);
2397                let session = pid_session_map.get(pid).cloned().or_else(|| {
2398                    cmdline
2399                        .as_deref()
2400                        .and_then(crate::platform::parse_session_arg)
2401                        .map(str::to_string)
2402                });
2403                json!({
2404                    "pid": pid,
2405                    "cmdline": cmdline,
2406                    "session": session,
2407                })
2408            })
2409            .collect();
2410        daemon["orphans_detail"] = json!(orphans_detail);
2411        summary["daemon"] = daemon;
2412
2413        // v0.5.14: pending-inbound zero-paste pair_drops awaiting accept.
2414        // v0.14.2: filter out records whose peer is already pinned at
2415        // VERIFIED+ tier (i.e., bilateral completed via some other
2416        // path). Pre-#171 `maybe_consume_pair_drop_ack` didn't clear
2417        // pending_inbound on receipt of the peer's ack; operators
2418        // with pre-#171 data on disk see their VERIFIED peers show
2419        // up in `inbound pair requests`, prompting a misleading
2420        // `wire accept` suggestion. The stale records still exist on
2421        // disk (operator can clear via `wire reject` if they care);
2422        // the status surface just stops showing them.
2423        // Records for genuinely-not-pinned peers — or peers at
2424        // UNTRUSTED/PENDING_ACK — surface normally.
2425        let pinned_verified_handles: std::collections::HashSet<String> =
2426            crate::config::read_trust()
2427                .ok()
2428                .and_then(|t| t.get("agents").and_then(Value::as_object).cloned())
2429                .map(|agents| {
2430                    agents
2431                        .into_iter()
2432                        .filter_map(|(handle, agent)| {
2433                            let tier = agent.get("tier").and_then(Value::as_str).unwrap_or("");
2434                            if matches!(tier, "VERIFIED" | "ORG_VERIFIED") {
2435                                Some(handle)
2436                            } else {
2437                                None
2438                            }
2439                        })
2440                        .collect()
2441                })
2442                .unwrap_or_default();
2443        let raw_pending_inbound =
2444            crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2445        let stale_inbound_handles: Vec<&str> = raw_pending_inbound
2446            .iter()
2447            .filter(|p| pinned_verified_handles.contains(&p.peer_handle))
2448            .map(|p| p.peer_handle.as_str())
2449            .collect();
2450        let pending_inbound: Vec<_> = raw_pending_inbound
2451            .iter()
2452            .filter(|p| !pinned_verified_handles.contains(&p.peer_handle))
2453            .collect();
2454        let inbound_handles: Vec<&str> = pending_inbound
2455            .iter()
2456            .map(|p| p.peer_handle.as_str())
2457            .collect();
2458        summary["pending_pairs"] = json!({
2459            "inbound_count": pending_inbound.len(),
2460            "inbound_handles": inbound_handles,
2461            // Surface the filtered-as-stale set so operators with
2462            // pre-#171 leftover records can find + clean them via
2463            // `wire reject <handle>` if they care.
2464            "stale_inbound_count": stale_inbound_handles.len(),
2465            "stale_inbound_handles": stale_inbound_handles,
2466        });
2467    }
2468
2469    if as_json {
2470        println!("{}", serde_json::to_string(&summary)?);
2471    } else if !initialized {
2472        println!("not initialized — run `wire init <handle>` first");
2473    } else {
2474        println!("did:           {}", summary["did"].as_str().unwrap_or("?"));
2475        println!(
2476            "fingerprint:   {}",
2477            summary["fingerprint"].as_str().unwrap_or("?")
2478        );
2479        println!("capabilities:  {}", summary["capabilities"]);
2480        if !summary["self_relay"].is_null() {
2481            println!(
2482                "self relay:    {} (slot {})",
2483                summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2484                summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2485            );
2486        } else {
2487            println!("self relay:    (not bound — run `wire bind-relay <url>` to bind)");
2488        }
2489        println!(
2490            "peers:         {}",
2491            summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2492        );
2493        for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2494            println!(
2495                "  - {:<20} tier={}",
2496                p["handle"].as_str().unwrap_or(""),
2497                p["tier"].as_str().unwrap_or("?")
2498            );
2499        }
2500        println!(
2501            "outbox:        {} file(s), {} event(s) queued",
2502            summary["outbox"]["files"].as_u64().unwrap_or(0),
2503            summary["outbox"]["events"].as_u64().unwrap_or(0)
2504        );
2505        println!(
2506            "inbox:         {} file(s), {} event(s) received",
2507            summary["inbox"]["files"].as_u64().unwrap_or(0),
2508            summary["inbox"]["events"].as_u64().unwrap_or(0)
2509        );
2510        let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2511        let daemon_pid = summary["daemon"]["pid"]
2512            .as_u64()
2513            .map(|p| p.to_string())
2514            .unwrap_or_else(|| "—".to_string());
2515        let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2516        let version_suffix = if !daemon_version.is_empty() {
2517            format!(" v{daemon_version}")
2518        } else {
2519            String::new()
2520        };
2521        println!(
2522            "daemon:        {} (pid {}{})",
2523            if daemon_running { "running" } else { "DOWN" },
2524            daemon_pid,
2525            version_suffix,
2526        );
2527        // P1.7: surface version mismatch + orphan procs loudly.
2528        if let Some(mm) = summary["daemon"].get("version_mismatch") {
2529            println!(
2530                "               !! version mismatch: daemon={} CLI={}. \
2531                 run `wire upgrade` to swap atomically.",
2532                mm["daemon"].as_str().unwrap_or("?"),
2533                mm["cli"].as_str().unwrap_or("?"),
2534            );
2535        }
2536        if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2537            && !orphans.is_empty()
2538        {
2539            let pids: Vec<String> = orphans
2540                .iter()
2541                .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2542                .collect();
2543            println!(
2544                "               !! orphan daemon process(es): pids {}. \
2545                 pgrep saw them but pidfile didn't — likely stale process from \
2546                 prior install. Multiple daemons race the relay cursor.",
2547                pids.join(", ")
2548            );
2549            // v0.14.2 (#162 diagnostic): per-orphan annotation so
2550            // operators don't have to grep ps themselves. Each orphan
2551            // shows its --session arg (or "(no --session)" for legacy
2552            // launchd daemons + operator-spawned `wire daemon` without
2553            // the flag — those default to dirs::state_dir() WIRE_HOME,
2554            // which often diverges from the shell's cwd-mapped session).
2555            if let Some(details) = summary["daemon"]["orphans_detail"].as_array() {
2556                for d in details {
2557                    let pid = d["pid"].as_u64().unwrap_or(0);
2558                    let session = d["session"].as_str();
2559                    let cmdline = d["cmdline"].as_str();
2560                    // v0.14.2: distinguish the supervisor (orchestrator —
2561                    // doesn't sync any single WIRE_HOME) from a legacy
2562                    // single-session daemon (DOES sync a WIRE_HOME, just
2563                    // not via --session). Pre-fix both were labelled "no
2564                    // --session — serving default WIRE_HOME" which was
2565                    // misleading for the supervisor case: it doesn't
2566                    // serve any home, it spawns child daemons that do.
2567                    let is_supervisor = cmdline
2568                        .map(|c| c.contains("--all-sessions"))
2569                        .unwrap_or(false);
2570                    match (session, cmdline, is_supervisor) {
2571                        (Some(s), _, _) => {
2572                            println!("                  pid {pid}: serving session '{s}'")
2573                        }
2574                        (None, Some(c), true) if !c.is_empty() => println!(
2575                            "                  pid {pid}: supervisor — orchestrates one daemon per session, doesn't sync directly (cmdline={c})"
2576                        ),
2577                        (None, Some(c), false) if !c.is_empty() => println!(
2578                            "                  pid {pid}: (no --session — serving default WIRE_HOME) cmdline={c}"
2579                        ),
2580                        _ => println!(
2581                            "                  pid {pid}: (cmdline unavailable — pid may have just exited)"
2582                        ),
2583                    }
2584                }
2585            }
2586        }
2587        // v0.14.2 (#162 #2/#7 surface): three lines that catch the
2588        // silent-send class operators kept missing on 0.14.1. Order matters
2589        // — last_sync first (is the loop running?), then pending_push_count
2590        // (am I leaking sends?), then stream_state (will live-monitor see
2591        // anything?).
2592        let last_sync_age = summary["daemon"]["last_sync_age_seconds"].as_u64();
2593        let last_sync_at = summary["daemon"]["last_sync_at"].as_str();
2594        match (last_sync_at, last_sync_age) {
2595            (Some(ts), Some(age)) => {
2596                let stale = summary["daemon"]["stale_sync"].as_bool().unwrap_or(false);
2597                let stale_tag = if stale { "  !! STALE (>60s)" } else { "" };
2598                let p = summary["daemon"]["last_sync_push_n"].as_u64().unwrap_or(0);
2599                let pl = summary["daemon"]["last_sync_pull_n"].as_u64().unwrap_or(0);
2600                let r = summary["daemon"]["last_sync_rejected_n"]
2601                    .as_u64()
2602                    .unwrap_or(0);
2603                println!(
2604                    "last sync:     {ts} ({age}s ago) push={p} pull={pl} rejected={r}{stale_tag}"
2605                );
2606            }
2607            _ => {
2608                println!(
2609                    "last sync:     (none recorded) — daemon hasn't completed a cycle in this WIRE_HOME"
2610                );
2611            }
2612        }
2613        let pending_push = summary["daemon"]["pending_push_count"]
2614            .as_u64()
2615            .unwrap_or(0);
2616        if pending_push > 0 {
2617            println!(
2618                "pending push:  {pending_push} event(s) queued but not yet pushed to relay — \
2619                 if stale_sync, this is the silent-send class (#162 fix #2)"
2620            );
2621            // v0.14.3: per-peer attribution. coral dogfood
2622            // (2026-06-01) found 3 events stuck on a PENDING_ACK
2623            // pair; the aggregate count gave no hint which pair.
2624            // Expand into one line per peer with tier + a hint
2625            // about the action the tier implies.
2626            if let Some(breakdown) = summary["daemon"]["pending_push_breakdown"].as_array() {
2627                for entry in breakdown {
2628                    let peer = entry.get("peer").and_then(Value::as_str).unwrap_or("?");
2629                    let tier = entry
2630                        .get("tier")
2631                        .and_then(Value::as_str)
2632                        .unwrap_or("UNKNOWN");
2633                    let count = entry.get("count").and_then(Value::as_u64).unwrap_or(0);
2634                    // Tier-specific hint. PENDING_ACK = wedged
2635                    // pair (operator action: `wire accept`
2636                    // or `wire reject`). UNTRUSTED = peer not yet
2637                    // pinned (rare but possible if trust file
2638                    // was hand-edited). VERIFIED + queued =
2639                    // #162 silent-send class; daemon should push
2640                    // imminently or `stale_sync` will flip.
2641                    let hint = match tier {
2642                        "PENDING_ACK" => {
2643                            " — pair never completed; daemon won't push until accept/reject"
2644                        }
2645                        "UNTRUSTED" => " — peer not pinned; daemon won't push to UNTRUSTED",
2646                        _ => "",
2647                    };
2648                    println!("  {count:>4} → {peer} ({tier}){hint}");
2649                }
2650            }
2651        } else {
2652            println!("pending push:  0");
2653        }
2654        match summary["daemon"]["stream_state"]
2655            .get("state")
2656            .and_then(Value::as_str)
2657        {
2658            Some(s) => {
2659                let last_evt = summary["daemon"]["stream_state"]
2660                    .get("last_event_at")
2661                    .and_then(Value::as_str)
2662                    .unwrap_or("never");
2663                let reconnects = summary["daemon"]["stream_state"]
2664                    .get("reconnect_count")
2665                    .and_then(Value::as_u64)
2666                    .unwrap_or(0);
2667                println!("stream:        {s} (last event {last_evt}, reconnects {reconnects})");
2668            }
2669            None => {
2670                println!(
2671                    "stream:        (no stream_state.json) — daemon predates #168 or hasn't \
2672                     subscribed yet; live monitor will fall back to polling cadence"
2673                );
2674            }
2675        }
2676        let inbound_count = summary["pending_pairs"]["inbound_count"]
2677            .as_u64()
2678            .unwrap_or(0);
2679        if inbound_count == 0 {
2680            println!("pending pairs: none");
2681        }
2682        // v0.5.14: separate line for pending-inbound zero-paste requests.
2683        // Loud because each one is awaiting an operator gesture and the
2684        // capability hasn't flowed yet.
2685        if inbound_count > 0 {
2686            let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2687                .as_array()
2688                .map(|a| {
2689                    a.iter()
2690                        .filter_map(|v| v.as_str().map(str::to_string))
2691                        .collect()
2692                })
2693                .unwrap_or_default();
2694            println!(
2695                "inbound pair requests ({inbound_count}): {} — `wire pending` to inspect, `wire accept <peer>` to accept, `wire reject <peer>` to refuse",
2696                handles.join(", "),
2697            );
2698        }
2699    }
2700    Ok(())
2701}
2702
2703pub(crate) fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2704    if !dir.exists() {
2705        return Ok(json!({"files": 0, "events": 0}));
2706    }
2707    let mut files = 0usize;
2708    let mut events = 0usize;
2709    for entry in std::fs::read_dir(dir)? {
2710        let path = entry?.path();
2711        // v0.14.2: skip pushed-log audit files (`<peer>.pushed.jsonl`)
2712        // when scanning the outbox dir. Those are append-only audit
2713        // logs of "queued → pushed" lifecycle events (#162 fix #2);
2714        // counting them as outbox events inflates `outbox.events` in
2715        // `wire status` by orders of magnitude. Pre-fix, an operator
2716        // with 8328 events delivered across a peer's lifetime saw
2717        // "outbox: 71811 events queued" when actual unpushed work was
2718        // 11 events. Inbox scans are unaffected because the inbox dir
2719        // contains only `<peer>.jsonl`, never `.pushed.jsonl`.
2720        if path.extension().map(|x| x == "jsonl").unwrap_or(false)
2721            && !path
2722                .file_name()
2723                .and_then(|s| s.to_str())
2724                .map(|n| n.ends_with(".pushed.jsonl"))
2725                .unwrap_or(false)
2726        {
2727            files += 1;
2728            if let Ok(body) = std::fs::read_to_string(&path) {
2729                events += body.lines().filter(|l| !l.trim().is_empty()).count();
2730            }
2731        }
2732    }
2733    Ok(json!({"files": files, "events": events}))
2734}
2735
2736// ---------- responder health ----------
2737
2738fn responder_status_allowed(status: &str) -> bool {
2739    matches!(
2740        status,
2741        "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2742    )
2743}
2744
2745fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2746    let state = config::read_relay_state()?;
2747    let (label, slot_info) = match peer {
2748        Some(peer) => (
2749            peer.to_string(),
2750            state
2751                .get("peers")
2752                .and_then(|p| p.get(peer))
2753                .ok_or_else(|| {
2754                    anyhow!(
2755                        "unknown peer {peer:?} in relay state — pair with them first:\n  \
2756                         wire add {peer}@wireup.net   (or {peer}@<their-relay>)\n\
2757                         (`wire peers` lists who you've already paired with.)"
2758                    )
2759                })?,
2760        ),
2761        None => (
2762            "self".to_string(),
2763            state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2764                anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2765            })?,
2766        ),
2767    };
2768    let relay_url = slot_info["relay_url"]
2769        .as_str()
2770        .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2771        .to_string();
2772    let slot_id = slot_info["slot_id"]
2773        .as_str()
2774        .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2775        .to_string();
2776    let slot_token = slot_info["slot_token"]
2777        .as_str()
2778        .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2779        .to_string();
2780    Ok((label, relay_url, slot_id, slot_token))
2781}
2782
2783/// v0.14.2 (#170 / honey-pine BUG 3): `wire supervisor` — operator-
2784/// facing multi-session topology view. Reads `SupervisorState` and
2785/// renders it as JSON or pretty text. `wire status` covers the
2786/// "is THIS session syncing?" question; `wire supervisor` covers
2787/// "what is the supervisor and every session's daemon doing across
2788/// the box?". No mutation.
2789fn cmd_supervisor(as_json: bool) -> Result<()> {
2790    let state = crate::daemon_supervisor::read_supervisor_state()?;
2791    if as_json {
2792        println!("{}", serde_json::to_string(&state)?);
2793        return Ok(());
2794    }
2795    let pid_label = state
2796        .supervisor_pid
2797        .map(|p| p.to_string())
2798        .unwrap_or_else(|| "—".to_string());
2799    println!(
2800        "supervisor:    {} (pid {pid_label})",
2801        if state.supervisor_alive {
2802            "running"
2803        } else {
2804            "DOWN"
2805        },
2806    );
2807    let sessions_total = state.sessions.len();
2808    let sessions_with_daemon = state.sessions.iter().filter(|s| s.daemon_alive).count();
2809    println!(
2810        "sessions:      {sessions_total} initialized, {sessions_with_daemon} with live daemon"
2811    );
2812    // Per-session table — only show sessions whose daemon state is
2813    // "interesting" (alive OR has a stale pidfile pointing at a dead
2814    // process) to keep the output bounded on a 100+-session box. Pure
2815    // healthy sessions get a single summary line above.
2816    let mut shown = 0usize;
2817    for s in &state.sessions {
2818        // Skip sessions with no pidfile at all — they've never had a
2819        // daemon, nothing to report.
2820        if s.daemon_pid.is_none() {
2821            continue;
2822        }
2823        // Skip a "boringly healthy" session: alive daemon + recent
2824        // sync. Only worth showing when something's off.
2825        let recent = matches!(s.last_sync_age_seconds, Some(age) if age <= 60);
2826        if s.daemon_alive && recent {
2827            continue;
2828        }
2829        shown += 1;
2830        let age = s
2831            .last_sync_age_seconds
2832            .map(|a| format!("{a}s"))
2833            .unwrap_or_else(|| "?".to_string());
2834        let pid = s
2835            .daemon_pid
2836            .map(|p| p.to_string())
2837            .unwrap_or_else(|| "—".to_string());
2838        let liveness = if s.daemon_alive { "running" } else { "DOWN" };
2839        println!(
2840            "  {:<24} pid {:<7} {} last_sync {}",
2841            s.name, pid, liveness, age
2842        );
2843    }
2844    if shown == 0 && sessions_with_daemon > 0 {
2845        println!(
2846            "  (every session with a daemon is alive + synced within 60s — pass --json for full per-session detail)"
2847        );
2848    }
2849    if !state.unmanaged_pids.is_empty() {
2850        let pids: Vec<String> = state.unmanaged_pids.iter().map(u32::to_string).collect();
2851        println!(
2852            "unmanaged:     {} pid(s) — {} — `wire daemon` processes not mapped to any session's pidfile.",
2853            state.unmanaged_pids.len(),
2854            pids.join(", ")
2855        );
2856        // Annotate each unmanaged pid the same way `wire status` does
2857        // for orphans: cmdline + parsed --session arg.
2858        for pid in &state.unmanaged_pids {
2859            let cmdline = crate::platform::pid_cmdline(*pid);
2860            let session = cmdline
2861                .as_deref()
2862                .and_then(crate::platform::parse_session_arg);
2863            match (session, cmdline.as_deref()) {
2864                (Some(s), _) => println!("  pid {pid}: --session '{s}'"),
2865                (None, Some(c)) if !c.is_empty() => println!("  pid {pid}: cmdline={c}"),
2866                _ => println!("  pid {pid}: cmdline unavailable"),
2867            }
2868        }
2869    }
2870    // v0.14.2: surface sessions whose live daemon is on a stale
2871    // binary version. Supervisor's existing-pidfile check protects
2872    // alive daemons from respawn regardless of binary age, so
2873    // mid-upgrade fleets accumulate version-drifted children.
2874    // Operators see the list here + can act (manual kill, or a
2875    // future `wire upgrade --refresh-stale-children`).
2876    if !state.stale_binary_sessions.is_empty() {
2877        let our_version = env!("CARGO_PKG_VERSION");
2878        println!(
2879            "stale binary:  {} session(s) running daemons older than this CLI (v{our_version}). Supervisor won't respawn them until they exit.",
2880            state.stale_binary_sessions.len()
2881        );
2882        for name in &state.stale_binary_sessions {
2883            // Look up the recorded version + pid so the diagnostic
2884            // line is actionable: operator can `kill <pid>` to let
2885            // the supervisor respawn on the fresh binary.
2886            let session = state.sessions.iter().find(|s| &s.name == name);
2887            let ver = session
2888                .and_then(|s| s.daemon_version.clone())
2889                .unwrap_or_else(|| "?".to_string());
2890            let pid = session
2891                .and_then(|s| s.daemon_pid)
2892                .map(|p| p.to_string())
2893                .unwrap_or_else(|| "?".to_string());
2894            println!("  {name:<24} running v{ver} (pid {pid})");
2895        }
2896    }
2897    Ok(())
2898}
2899
2900fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2901    if !responder_status_allowed(status) {
2902        bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2903    }
2904    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2905    let now = time::OffsetDateTime::now_utc()
2906        .format(&time::format_description::well_known::Rfc3339)
2907        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2908    let mut record = json!({
2909        "status": status,
2910        "set_at": now,
2911    });
2912    if let Some(reason) = reason {
2913        record["reason"] = json!(reason);
2914    }
2915    if status == "online" {
2916        record["last_success_at"] = json!(now);
2917    }
2918    let client = crate::relay_client::RelayClient::new(&relay_url);
2919    let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2920    if as_json {
2921        println!("{}", serde_json::to_string(&saved)?);
2922    } else {
2923        let reason = saved
2924            .get("reason")
2925            .and_then(Value::as_str)
2926            .map(|r| format!(" — {r}"))
2927            .unwrap_or_default();
2928        println!(
2929            "responder {}{}",
2930            saved
2931                .get("status")
2932                .and_then(Value::as_str)
2933                .unwrap_or(status),
2934            reason
2935        );
2936    }
2937    Ok(())
2938}
2939
2940fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2941    let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2942    let client = crate::relay_client::RelayClient::new(&relay_url);
2943    let health = client.responder_health_get(&slot_id, &slot_token)?;
2944    if as_json {
2945        println!(
2946            "{}",
2947            serde_json::to_string(&json!({
2948                "target": label,
2949                "responder_health": health,
2950            }))?
2951        );
2952    } else if health.is_null() {
2953        println!("{label}: responder health not reported");
2954    } else {
2955        let status = health
2956            .get("status")
2957            .and_then(Value::as_str)
2958            .unwrap_or("unknown");
2959        let reason = health
2960            .get("reason")
2961            .and_then(Value::as_str)
2962            .map(|r| format!(" — {r}"))
2963            .unwrap_or_default();
2964        let last_success = health
2965            .get("last_success_at")
2966            .and_then(Value::as_str)
2967            .map(|t| format!(" (last_success: {t})"))
2968            .unwrap_or_default();
2969        println!("{label}: {status}{reason}{last_success}");
2970    }
2971    Ok(())
2972}
2973
2974fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2975    let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2976    let client = crate::relay_client::RelayClient::new(&relay_url);
2977
2978    let started = std::time::Instant::now();
2979    let transport_ok = client.healthz().unwrap_or(false);
2980    let latency_ms = started.elapsed().as_millis() as u64;
2981
2982    let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2983    let now = std::time::SystemTime::now()
2984        .duration_since(std::time::UNIX_EPOCH)
2985        .map(|d| d.as_secs())
2986        .unwrap_or(0);
2987    let attention = match last_pull_at_unix {
2988        Some(last) if now.saturating_sub(last) <= 300 => json!({
2989            "status": "ok",
2990            "last_pull_at_unix": last,
2991            "age_seconds": now.saturating_sub(last),
2992            "event_count": event_count,
2993        }),
2994        Some(last) => json!({
2995            "status": "stale",
2996            "last_pull_at_unix": last,
2997            "age_seconds": now.saturating_sub(last),
2998            "event_count": event_count,
2999        }),
3000        None => json!({
3001            "status": "never_pulled",
3002            "last_pull_at_unix": Value::Null,
3003            "event_count": event_count,
3004        }),
3005    };
3006
3007    let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
3008    let responder = if responder_health.is_null() {
3009        json!({"status": "not_reported", "record": Value::Null})
3010    } else {
3011        json!({
3012            "status": responder_health
3013                .get("status")
3014                .and_then(Value::as_str)
3015                .unwrap_or("unknown"),
3016            "record": responder_health,
3017        })
3018    };
3019
3020    let report = json!({
3021        "peer": peer,
3022        "transport": {
3023            "status": if transport_ok { "ok" } else { "error" },
3024            "relay_url": relay_url,
3025            "latency_ms": latency_ms,
3026        },
3027        "attention": attention,
3028        "responder": responder,
3029    });
3030
3031    if as_json {
3032        println!("{}", serde_json::to_string(&report)?);
3033    } else {
3034        let transport_line = if transport_ok {
3035            format!("ok relay reachable ({latency_ms}ms)")
3036        } else {
3037            "error relay unreachable".to_string()
3038        };
3039        println!("transport      {transport_line}");
3040        match report["attention"]["status"].as_str().unwrap_or("unknown") {
3041            "ok" => println!(
3042                "attention      ok last pull {}s ago",
3043                report["attention"]["age_seconds"].as_u64().unwrap_or(0)
3044            ),
3045            "stale" => println!(
3046                "attention      stale last pull {}m ago",
3047                report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
3048            ),
3049            "never_pulled" => println!("attention      never pulled since relay reset"),
3050            other => println!("attention      {other}"),
3051        }
3052        if report["responder"]["status"] == "not_reported" {
3053            println!("auto-responder not reported");
3054        } else {
3055            let record = &report["responder"]["record"];
3056            let status = record
3057                .get("status")
3058                .and_then(Value::as_str)
3059                .unwrap_or("unknown");
3060            let reason = record
3061                .get("reason")
3062                .and_then(Value::as_str)
3063                .map(|r| format!(" — {r}"))
3064                .unwrap_or_default();
3065            println!("auto-responder {status}{reason}");
3066        }
3067    }
3068    Ok(())
3069}
3070
3071// (Old cmd_join stub removed — superseded by wire_dial / cmd_pair_accept.)
3072
3073// ---------- whoami ----------
3074
3075/// Return the current cwd with the user's home dir abbreviated to `~/`.
3076/// Used in whoami `--short` / `--colored` output so multi-window operators
3077/// see *what project* each Claude is working in alongside the character.
3078fn current_cwd_display() -> String {
3079    let cwd = match std::env::current_dir() {
3080        Ok(c) => c,
3081        Err(_) => return String::from("?"),
3082    };
3083    if let Some(home) = dirs::home_dir()
3084        && let Ok(rel) = cwd.strip_prefix(&home)
3085    {
3086        // strip_prefix returns "" for cwd == home itself; show "~" then.
3087        let rel_str = rel.to_string_lossy();
3088        if rel_str.is_empty() {
3089            return String::from("~");
3090        }
3091        return format!("~/{rel_str}");
3092    }
3093    cwd.to_string_lossy().into_owned()
3094}
3095
3096/// v0.14: extract the inline op claims from an agent card (or pinned
3097/// trust row) for surfacing on operator-facing read paths. Returns the
3098/// subset of fields actually present and non-null — operators read the
3099/// absence to mean "not enrolled / older peer".
3100///
3101/// Surfaced fields: `op_did`, `op_pubkey`, `op_cert`, `org_memberships`,
3102/// `schema_version`. All RFC-001-defined; all public commits, safe to
3103/// surface on every read verb. Centralized here so whoami / peers / whois
3104/// stay in lock-step as the inline set grows (e.g. `sso_attest` in v0.15).
3105///
3106/// `pub(crate)` so the MCP surface (`src/mcp.rs`) wires the same helper
3107/// into `tool_whoami` / `tool_peers` — agents reading MCP responses must
3108/// see the same op claims that operators see via CLI.
3109pub(crate) fn op_claims_from_card(card: &Value) -> serde_json::Map<String, Value> {
3110    let mut out = serde_json::Map::new();
3111    for key in [
3112        "op_did",
3113        "op_pubkey",
3114        "op_cert",
3115        "org_memberships",
3116        "schema_version",
3117    ] {
3118        if let Some(v) = card.get(key)
3119            && !v.is_null()
3120        {
3121            out.insert(key.to_string(), v.clone());
3122        }
3123    }
3124    out
3125}
3126
3127fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
3128    if !config::is_initialized()? {
3129        // v0.14.x: with per-session WIRE_HOME (`sessions/by-key/<hash>`), a
3130        // freshly-spawned session's home starts EMPTY until `wire up`. The
3131        // machine-readable consumers that poll whoami every render — statusline
3132        // scripts, the `.wire-name` cache refreshers — hit that uninitialized
3133        // state constantly. Bailing (exit 1, no stdout) made them crash on
3134        // empty stdin or freeze on a stale name. Degrade gracefully here,
3135        // matching `wire here --json`, so a missing identity is a parseable
3136        // signal rather than a hard failure. The bare interactive (tty, no
3137        // JSON) path keeps its actionable hint + exit 1.
3138        // Precedence mirrors the initialized path below: an explicit --short
3139        // / --colored beats the piped-stdout JSON default (`json_default`),
3140        // and bare interactive `wire whoami` still gets the actionable hint.
3141        if short {
3142            println!("(uninitialized) · {}", current_cwd_display());
3143            return Ok(());
3144        }
3145        if colored {
3146            println!(
3147                "\x1b[2m(uninitialized)\x1b[0m \x1b[2m·\x1b[0m {}",
3148                current_cwd_display()
3149            );
3150            return Ok(());
3151        }
3152        if as_json {
3153            println!(
3154                "{}",
3155                serde_json::to_string(&json!({
3156                    "initialized": false,
3157                    "cwd": current_cwd_display(),
3158                }))?
3159            );
3160            return Ok(());
3161        }
3162        bail!("not initialized — run `wire init <handle>` first");
3163    }
3164    let card = config::read_agent_card()?;
3165    let did = card
3166        .get("did")
3167        .and_then(Value::as_str)
3168        .unwrap_or("")
3169        .to_string();
3170    let handle = card
3171        .get("handle")
3172        .and_then(Value::as_str)
3173        .map(str::to_string)
3174        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
3175    // v0.11: character is purely DID-derived. No overrides — the
3176    // operator-rename verb is gone and display.json reads are stripped
3177    // because they introduced a second name that peers couldn't find.
3178    let character = crate::character::Character::from_did(&did);
3179
3180    // v0.7.0-alpha.3: append the current cwd (home-abbreviated to `~/`)
3181    // so operators tab-flipping between multiple Claude windows see both
3182    // *who* this session is (character) and *what* it's working on (cwd).
3183    // The cwd is the OPERATOR's cwd, not WIRE_HOME — gives them the
3184    // anchor they're looking for: "🐅 winter-bay · ~/Source/wire".
3185    let cwd_display = current_cwd_display();
3186
3187    // Fast paths used by statuslines, piping, scripts. No agent-card parsing
3188    // beyond did — these calls are hot (statusline polls ~300ms).
3189    if short {
3190        println!("{} · {}", character.short(), cwd_display);
3191        return Ok(());
3192    }
3193    if colored {
3194        println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
3195        return Ok(());
3196    }
3197
3198    let pk_b64 = card
3199        .get("verify_keys")
3200        .and_then(Value::as_object)
3201        .and_then(|m| m.values().next())
3202        .and_then(|v| v.get("key"))
3203        .and_then(Value::as_str)
3204        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3205    let pk_bytes = crate::signing::b64decode(pk_b64)?;
3206    let fp = fingerprint(&pk_bytes);
3207    let key_id = make_key_id(&handle, &pk_bytes);
3208    let capabilities = card
3209        .get("capabilities")
3210        .cloned()
3211        .unwrap_or_else(|| json!(["wire/v3.1"]));
3212
3213    if as_json {
3214        // v0.11: character_override is always false now (no rename verb,
3215        // no display.json reads). Field stays for back-compat with v0.10
3216        // JSON consumers that key off it.
3217        let has_override = false;
3218        let mut payload = serde_json::Map::new();
3219        // Symmetric with the uninitialized branch above so consumers can
3220        // branch on a single key instead of probing for `did`.
3221        payload.insert("initialized".into(), json!(true));
3222        payload.insert("did".into(), json!(did));
3223        payload.insert("handle".into(), json!(handle));
3224        payload.insert("fingerprint".into(), json!(fp));
3225        payload.insert("key_id".into(), json!(key_id));
3226        payload.insert("public_key_b64".into(), json!(pk_b64));
3227        payload.insert("capabilities".into(), capabilities);
3228        payload.insert(
3229            "config_dir".into(),
3230            json!(config::config_dir()?.to_string_lossy()),
3231        );
3232        // RFC-008 §A: surface WHICH signal won session/home resolution, so an
3233        // operator diagnosing a wrong/shared identity sees the cause in one
3234        // command instead of a forensic deep-dive (cf. #210). Additive,
3235        // read-only; absent only on pre-RFC-008 binaries.
3236        payload.insert(
3237            "session_source".into(),
3238            json!(crate::session::session_source()),
3239        );
3240        payload.insert("persona".into(), serde_json::to_value(&character)?);
3241        payload.insert("persona_override".into(), json!(has_override));
3242        // v0.14: surface the RFC-001 op claims (when enrolled) on the
3243        // canonical operator read verb. Absent ⇒ pre-v0.14 card or not
3244        // yet enrolled. See `op_claims_from_card` rationale.
3245        for (k, v) in op_claims_from_card(&card) {
3246            payload.insert(k, v);
3247        }
3248        println!("{}", serde_json::to_string(&payload)?);
3249    } else {
3250        println!("{}", character.colored());
3251        println!("{did} (ed25519:{key_id})");
3252        println!("fingerprint: {fp}");
3253        println!("capabilities: {capabilities}");
3254        // v0.14: when enrolled, surface op_did + membership count so
3255        // the operator can spot at a glance whether the marquee identity
3256        // layer is active. Silent when not enrolled (no clutter for
3257        // pre-v0.14 cards).
3258        if let Some(op_did) = card.get("op_did").and_then(Value::as_str) {
3259            let memberships = card
3260                .get("org_memberships")
3261                .and_then(Value::as_array)
3262                .map(|a| a.len())
3263                .unwrap_or(0);
3264            let plural = if memberships == 1 { "" } else { "s" };
3265            println!("enrolled: {op_did} ({memberships} org membership{plural})");
3266        }
3267    }
3268    Ok(())
3269}
3270
3271// ---------- identity (v0.7.0-alpha.3) ----------
3272
3273fn cmd_enroll(cmd: EnrollCommand) -> Result<()> {
3274    match cmd {
3275        EnrollCommand::Op { handle, json } => {
3276            let (sk, pk) = crate::signing::generate_keypair();
3277            crate::config::write_op_key(&sk)?;
3278            crate::config::write_op_handle(&handle)?;
3279            let op_did = crate::agent_card::did_for_op(&handle, &pk);
3280            let op_pubkey = crate::signing::b64encode(&pk);
3281            if json {
3282                println!(
3283                    "{}",
3284                    serde_json::to_string(&json!({"op_did": op_did, "op_pubkey": op_pubkey}))?
3285                );
3286            } else {
3287                println!(
3288                    "→ operator enrolled\n  op_did:    {op_did}\n  op_pubkey: {op_pubkey}\n  key saved 0600 at {:?}",
3289                    crate::config::op_key_path()?
3290                );
3291            }
3292            Ok(())
3293        }
3294        EnrollCommand::OrgCreate { handle, json } => {
3295            let (sk, pk) = crate::signing::generate_keypair();
3296            let org_did = crate::agent_card::did_for_org(&handle, &pk);
3297            crate::config::write_org_key(&org_did, &sk)?;
3298            let org_pubkey = crate::signing::b64encode(&pk);
3299            if json {
3300                println!(
3301                    "{}",
3302                    serde_json::to_string(&json!({"org_did": org_did, "org_pubkey": org_pubkey}))?
3303                );
3304            } else {
3305                println!(
3306                    "→ organization created\n  org_did:    {org_did}\n  org_pubkey: {org_pubkey}\n  key saved 0600 at {:?}",
3307                    crate::config::org_key_path(&org_did)?
3308                );
3309            }
3310            Ok(())
3311        }
3312        EnrollCommand::OrgAddMember { op_did, org, json } => {
3313            if !crate::agent_card::is_op_did(&op_did) {
3314                bail!("not a valid operator DID (did:wire:op:<handle>-<32hex>): {op_did}");
3315            }
3316            let org_sk = crate::config::read_org_key(&org).with_context(|| {
3317                format!("no stored key for org {org} — run `wire enroll org-create` first")
3318            })?;
3319            let org_pk = ed25519_dalek::SigningKey::from_bytes(&org_sk)
3320                .verifying_key()
3321                .to_bytes();
3322            let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did)?;
3323            let org_pubkey = crate::signing::b64encode(&org_pk);
3324            // Store locally so card-emit can attach it (same-machine operator);
3325            // also printed below for the cross-machine share case.
3326            crate::config::add_membership(&org, &org_pubkey, &member_cert)?;
3327            if json {
3328                println!(
3329                    "{}",
3330                    serde_json::to_string(&json!({
3331                        "org_did": org, "org_pubkey": org_pubkey, "member_cert": member_cert
3332                    }))?
3333                );
3334            } else {
3335                println!(
3336                    "→ 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}\"}}"
3337                );
3338            }
3339            Ok(())
3340        }
3341        EnrollCommand::AddMembership {
3342            bundle,
3343            org,
3344            org_pubkey,
3345            member_cert,
3346            json,
3347        } => cmd_enroll_add_membership(bundle, org, org_pubkey, member_cert, json),
3348        EnrollCommand::Republish { json } => {
3349            // Rebuild the on-disk card with current enrollment, then republish
3350            // via the same path `profile set` uses. Closes the enroll-after-init
3351            // DX gap (see `enroll::rebuild_card_with_current_claims`).
3352            let card = crate::enroll::rebuild_card_with_current_claims()?;
3353            let published = republish_card_to_phonebook();
3354            let op_did = card
3355                .get("op_did")
3356                .and_then(Value::as_str)
3357                .map(str::to_string);
3358            let n_memberships = card
3359                .get("org_memberships")
3360                .and_then(Value::as_array)
3361                .map(Vec::len)
3362                .unwrap_or(0);
3363            if json {
3364                println!(
3365                    "{}",
3366                    serde_json::to_string(&json!({
3367                        "op_did": op_did,
3368                        "org_memberships": n_memberships,
3369                        "published": published,
3370                    }))?
3371                );
3372            } else {
3373                match op_did {
3374                    Some(did) => println!(
3375                        "→ card rebuilt with current enrollment\n  op_did:    {did}\n  memberships: {n_memberships}"
3376                    ),
3377                    None => println!(
3378                        "→ card rebuilt — no operator enrolled (claims stripped if previously present)"
3379                    ),
3380                }
3381                print_profile_publish_result(&published);
3382            }
3383            Ok(())
3384        }
3385    }
3386}
3387
3388/// Implementation of `wire enroll add-membership` (closes #127).
3389///
3390/// Validates the bundle before storing — a malformed / wrong-key cert
3391/// would corrupt the next `wire enroll republish` (the bundle is
3392/// attached verbatim to the agent card; a bad bundle propagates to
3393/// peers and gets rejected on `evaluate_card_membership`). Verifying
3394/// up-front means the failure is at ingest time, not at publish time.
3395fn cmd_enroll_add_membership(
3396    bundle: Option<String>,
3397    org: Option<String>,
3398    org_pubkey: Option<String>,
3399    member_cert: Option<String>,
3400    as_json: bool,
3401) -> Result<()> {
3402    // Resolve the three fields from either --bundle or the individual flags.
3403    let (org_did, org_pk_b64, cert_b64) = if let Some(b) = bundle {
3404        let v: Value = serde_json::from_str(&b).with_context(|| "parsing --bundle as JSON")?;
3405        let o = v
3406            .get("org_did")
3407            .and_then(Value::as_str)
3408            .ok_or_else(|| anyhow!("--bundle missing 'org_did'"))?
3409            .to_string();
3410        let p = v
3411            .get("org_pubkey")
3412            .and_then(Value::as_str)
3413            .ok_or_else(|| anyhow!("--bundle missing 'org_pubkey'"))?
3414            .to_string();
3415        let c = v
3416            .get("member_cert")
3417            .and_then(Value::as_str)
3418            .ok_or_else(|| anyhow!("--bundle missing 'member_cert'"))?
3419            .to_string();
3420        (o, p, c)
3421    } else {
3422        let o = org.ok_or_else(|| anyhow!("--org is required when --bundle is not set"))?;
3423        let p = org_pubkey
3424            .ok_or_else(|| anyhow!("--org-pubkey is required when --bundle is not set"))?;
3425        let c = member_cert
3426            .ok_or_else(|| anyhow!("--member-cert is required when --bundle is not set"))?;
3427        (o, p, c)
3428    };
3429
3430    // Validate org_did shape — refuse before touching disk.
3431    if !crate::agent_card::is_org_did(&org_did) {
3432        bail!("not a valid organization DID (did:wire:org:<handle>-<32hex>): {org_did}");
3433    }
3434
3435    // This operator must be enrolled — we need op_did to verify the cert
3436    // is FOR US, not for a different operator. A cert valid against some
3437    // other op_did would still verify on the org_pubkey but storing it
3438    // here would be a misattribution.
3439    let op_sk = crate::config::read_op_key().with_context(
3440        || "this operator is not enrolled — run `wire enroll op` first to mint op_did",
3441    )?;
3442    let op_handle = crate::config::read_op_handle()
3443        .ok()
3444        .flatten()
3445        .unwrap_or_else(|| "operator".to_string());
3446    let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
3447        .verifying_key()
3448        .to_bytes();
3449    let op_did = crate::agent_card::did_for_op(&op_handle, &op_pk);
3450
3451    // Decode + verify the cert against org_pubkey + this op_did. Failure
3452    // here is the load-bearing guard against the "stored bundle corrupts
3453    // republish" footgun.
3454    let org_pk_bytes =
3455        crate::signing::b64decode(&org_pk_b64).with_context(|| "decoding --org-pubkey (base64)")?;
3456    crate::identity::verify_member_cert(&org_pk_bytes, &cert_b64, &op_did)
3457        .map_err(|e| anyhow!("member_cert verification failed: {e:?} — bundle is not valid for this operator (op_did={op_did})"))?;
3458
3459    // Idempotent store. add_membership retains-then-pushes so re-running
3460    // with the same org_did replaces the prior entry; multiple distinct
3461    // orgs accumulate.
3462    crate::config::add_membership(&org_did, &org_pk_b64, &cert_b64)?;
3463
3464    if as_json {
3465        println!(
3466            "{}",
3467            serde_json::to_string(&json!({
3468                "stored": true,
3469                "org_did": org_did,
3470                "op_did": op_did,
3471                "note": "run `wire enroll republish` to attach the claim to your agent card and republish",
3472            }))?
3473        );
3474    } else {
3475        println!(
3476            "→ membership stored\n  org_did:  {org_did}\n  op_did:   {op_did}\n  next: `wire enroll republish` to attach + publish"
3477        );
3478    }
3479    Ok(())
3480}
3481
3482fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
3483    match cmd {
3484        // v0.11: IdentityCommand::Rename deleted. The character is the
3485        // one canonical name (DID-derived); a local-display rename
3486        // would create a second name peers can't find, violating the
3487        // "names must be findable" invariant. Aliases (if needed
3488        // later) become relay-claimed entries that ARE findable —
3489        // a different architectural shape from rename.
3490        IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
3491        IdentityCommand::List { json } => cmd_session_list(json),
3492        IdentityCommand::Publish {
3493            nick,
3494            relay,
3495            public_url,
3496            hidden,
3497            json,
3498        } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
3499        IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
3500        IdentityCommand::Create {
3501            name,
3502            anonymous,
3503            local: _,
3504            json,
3505        } => cmd_identity_create(name.as_deref(), anonymous, json),
3506        IdentityCommand::Persist {
3507            name,
3508            as_name,
3509            json,
3510        } => cmd_identity_persist(&name, as_name.as_deref(), json),
3511        IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
3512    }
3513}
3514
3515/// v0.7.0-alpha.20: anonymous identity = sessions root remapped to a
3516/// per-invocation tmpdir. Operator gets a `WIRE_HOME=...` export they
3517/// paste into their shell; the identity lives there until reboot
3518/// clears /tmp. Persist promotes it to the real sessions root.
3519fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
3520    if anonymous {
3521        // Generate a unique tmpdir for this anonymous identity.
3522        let rand_suffix = format!("{:08x}", rand::random::<u32>());
3523        let anon_name = name
3524            .map(crate::session::sanitize_name)
3525            .unwrap_or_else(|| format!("anon-{rand_suffix}"));
3526        let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
3527        std::fs::create_dir_all(&anon_root)
3528            .with_context(|| format!("creating anon root {anon_root:?}"))?;
3529        // Run `wire init <name>` with WIRE_HOME = anon_root/sessions/<name>
3530        let session_home = anon_root.join("sessions").join(&anon_name);
3531        std::fs::create_dir_all(&session_home)?;
3532        let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
3533        if !status.success() {
3534            bail!("anonymous identity init failed: {status}");
3535        }
3536        // Register the anonymous name in a SIDE registry so persist
3537        // can find it later. Stored at <anon_root>/anon-marker.json.
3538        let marker = anon_root.join("anon-marker.json");
3539        std::fs::write(
3540            &marker,
3541            serde_json::to_vec_pretty(&serde_json::json!({
3542                "name": anon_name,
3543                "session_home": session_home.to_string_lossy(),
3544                "created_at": time::OffsetDateTime::now_utc()
3545                    .format(&time::format_description::well_known::Rfc3339)
3546                    .unwrap_or_default(),
3547                "kind": "anonymous",
3548            }))?,
3549        )?;
3550        let card = serde_json::from_slice::<Value>(&std::fs::read(
3551            session_home
3552                .join("config")
3553                .join("wire")
3554                .join("agent-card.json"),
3555        )?)?;
3556        let did = card
3557            .get("did")
3558            .and_then(Value::as_str)
3559            .unwrap_or("")
3560            .to_string();
3561        if as_json {
3562            println!(
3563                "{}",
3564                serde_json::to_string(&json!({
3565                    "kind": "anonymous",
3566                    "name": anon_name,
3567                    "did": did,
3568                    "session_home": session_home.to_string_lossy(),
3569                    "anon_root": anon_root.to_string_lossy(),
3570                }))?
3571            );
3572        } else {
3573            println!("created anonymous identity `{anon_name}` ({did})");
3574            println!(
3575                "  session_home: {} (dies on reboot — /tmp)",
3576                session_home.display()
3577            );
3578            println!();
3579            println!("activate in this shell:");
3580            println!("  export WIRE_HOME={}", session_home.display());
3581            println!();
3582            println!("promote to persistent later with:");
3583            println!("  wire identity persist {anon_name}");
3584        }
3585        return Ok(());
3586    }
3587    // --local (or default): delegate to existing session new flow.
3588    let name_arg = name.map(|s| s.to_string());
3589    cmd_session_new(
3590        name_arg.as_deref(),
3591        "https://wireup.net",
3592        false,
3593        "http://127.0.0.1:8771",
3594        false,
3595        None,
3596        false,
3597        None,
3598        true, // no_daemon: identity create just allocates the identity, no daemon
3599        true, // local_only: explicit lifecycle
3600        as_json,
3601    )
3602}
3603
3604/// v0.7.0-alpha.20: promote anonymous → local. Moves session dir from
3605/// tmpdir to the persistent sessions root + registers in the cwd map.
3606fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
3607    // Find the anon-marker.json by scanning /tmp/wire-anon-*.
3608    let temp = std::env::temp_dir();
3609    let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3610    for entry in std::fs::read_dir(&temp)?.flatten() {
3611        let path = entry.path();
3612        if !path
3613            .file_name()
3614            .and_then(|s| s.to_str())
3615            .map(|s| s.starts_with("wire-anon-"))
3616            .unwrap_or(false)
3617        {
3618            continue;
3619        }
3620        let marker = path.join("anon-marker.json");
3621        if let Ok(bytes) = std::fs::read(&marker)
3622            && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
3623            && json.get("name").and_then(Value::as_str) == Some(name)
3624        {
3625            let session_home = json
3626                .get("session_home")
3627                .and_then(Value::as_str)
3628                .map(std::path::PathBuf::from)
3629                .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
3630            found = Some((path, session_home));
3631            break;
3632        }
3633    }
3634    let (anon_root, anon_session_home) = found.ok_or_else(|| {
3635        anyhow!(
3636            "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
3637             run `wire identity list` to see available identities"
3638        )
3639    })?;
3640
3641    let new_name = as_name.unwrap_or(name);
3642    let new_session_home = crate::session::session_dir(new_name)?;
3643    if new_session_home.exists() {
3644        bail!(
3645            "target session `{new_name}` already exists at {new_session_home:?} — \
3646             pick a different name with --as <new-name>"
3647        );
3648    }
3649
3650    // Move the session dir from tmpdir to persistent root.
3651    if let Some(parent) = new_session_home.parent() {
3652        std::fs::create_dir_all(parent)?;
3653    }
3654    std::fs::rename(&anon_session_home, &new_session_home)
3655        .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
3656
3657    // Clean up the (now-empty) anon root + marker.
3658    let _ = std::fs::remove_dir_all(&anon_root);
3659
3660    // Register cwd → new_name (operator may have cd'd elsewhere; use the
3661    // session_home's grandparent as the conceptual "cwd" if no other).
3662    let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
3663    let cwd_key = crate::session::normalize_cwd_key(&cwd);
3664    let new_name_for_reg = new_name.to_string();
3665    if let Err(e) = crate::session::update_registry(|reg| {
3666        reg.by_cwd.insert(cwd_key, new_name_for_reg);
3667        Ok(())
3668    }) {
3669        eprintln!("wire identity persist: failed to update registry: {e:#}");
3670    }
3671
3672    if as_json {
3673        println!(
3674            "{}",
3675            serde_json::to_string(&json!({
3676                "kind": "persisted",
3677                "from_name": name,
3678                "to_name": new_name,
3679                "session_home": new_session_home.to_string_lossy(),
3680            }))?
3681        );
3682    } else {
3683        println!("persisted anonymous identity `{name}` → local session `{new_name}`");
3684        println!(
3685            "  session_home: {} (survives reboot)",
3686            new_session_home.display()
3687        );
3688        println!("  registered cwd: {}", cwd.display());
3689    }
3690    Ok(())
3691}
3692
3693/// v0.7.0-alpha.20: demote federation → local. Removes the federation
3694/// slot binding from relay.json (and the legacy top-level fields). Keeps
3695/// the keypair + agent-card so re-publish later just calls `wire identity
3696/// publish` again. local → anonymous is NOT supported; destroy + recreate
3697/// is the safer path for that step-down.
3698fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
3699    let sessions = crate::session::list_sessions()?;
3700    let session = sessions
3701        .iter()
3702        .find(|s| s.name == name)
3703        .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
3704    let relay_state_path = session
3705        .home_dir
3706        .join("config")
3707        .join("wire")
3708        .join("relay.json");
3709    if !relay_state_path.exists() {
3710        bail!("session `{name}` has no relay state — already demoted?");
3711    }
3712    let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
3713    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
3714    let had_fed = self_obj
3715        .get("relay_url")
3716        .and_then(Value::as_str)
3717        .map(|u| {
3718            u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
3719        })
3720        .unwrap_or(false);
3721    if !had_fed {
3722        if as_json {
3723            println!(
3724                "{}",
3725                serde_json::to_string(
3726                    &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
3727                )?
3728            );
3729        } else {
3730            println!("session `{name}` has no federation slot — nothing to demote");
3731        }
3732        return Ok(());
3733    }
3734    // Strip federation: remove top-level relay_url/slot_id/slot_token,
3735    // remove federation-scope entries from endpoints[].
3736    if let Some(self_mut) = state
3737        .as_object_mut()
3738        .and_then(|m| m.get_mut("self"))
3739        .and_then(|s| s.as_object_mut())
3740    {
3741        self_mut.remove("relay_url");
3742        self_mut.remove("slot_id");
3743        self_mut.remove("slot_token");
3744        if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
3745            eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
3746        }
3747    }
3748    std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
3749
3750    if as_json {
3751        println!(
3752            "{}",
3753            serde_json::to_string(
3754                &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
3755            )?
3756        );
3757    } else {
3758        println!("demoted `{name}` from federation → local");
3759        println!("  relay slot binding removed; keypair + agent-card retained");
3760        println!("  re-publish with `wire identity publish <nick>`");
3761    }
3762    Ok(())
3763}
3764
3765/// Thin wrapper — kept as a function for tests + back-compat with
3766/// the small handful of callsites that already use this name.
3767/// Implementation moved to `crate::trust::effective_tier` so the
3768/// canonical derivation is shared with `compute_pending_push_breakdown`.
3769fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
3770    crate::trust::effective_tier(trust, relay_state, handle)
3771}
3772
3773fn cmd_peers(as_json: bool) -> Result<()> {
3774    let trust = config::read_trust()?;
3775    let agents = trust
3776        .get("agents")
3777        .and_then(Value::as_object)
3778        .cloned()
3779        .unwrap_or_default();
3780    let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
3781
3782    let mut self_did: Option<String> = None;
3783    if let Ok(card) = config::read_agent_card() {
3784        self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
3785    }
3786
3787    let mut peers = Vec::new();
3788    for (handle, agent) in agents.iter() {
3789        let did = agent
3790            .get("did")
3791            .and_then(Value::as_str)
3792            .unwrap_or("")
3793            .to_string();
3794        if Some(did.as_str()) == self_did.as_deref() {
3795            continue; // skip self-attestation
3796        }
3797        let tier = effective_peer_tier(&trust, &relay_state, handle);
3798        let capabilities = agent
3799            .get("card")
3800            .and_then(|c| c.get("capabilities"))
3801            .cloned()
3802            .unwrap_or_else(|| json!([]));
3803        // v0.7.0-alpha.6: prefer peer's published character override
3804        // (display.nickname / display.emoji on their pinned agent-card).
3805        // Falls back to auto-derived if peer hasn't renamed themselves
3806        // OR runs an older wire that doesn't publish the field.
3807        let character = if did.is_empty() {
3808            None
3809        } else {
3810            let card_obj = agent.get("card");
3811            Some(match card_obj {
3812                Some(card) => crate::character::Character::from_card(card),
3813                None => crate::character::Character::from_did(&did),
3814            })
3815        };
3816        // v0.14: surface peer's op claims when their pinned card carries
3817        // them (post-v0.14 peers). Older peers ⇒ absent keys; same shape
3818        // as `wire whoami --json` so operators have one mental model.
3819        let peer_op_claims = agent
3820            .get("card")
3821            .map(op_claims_from_card)
3822            .unwrap_or_default();
3823        let mut row = serde_json::Map::new();
3824        row.insert("handle".into(), json!(handle));
3825        row.insert("did".into(), json!(did));
3826        row.insert("tier".into(), json!(tier));
3827        row.insert("capabilities".into(), capabilities);
3828        row.insert("persona".into(), serde_json::to_value(&character)?);
3829        for (k, v) in peer_op_claims {
3830            row.insert(k, v);
3831        }
3832        peers.push(Value::Object(row));
3833    }
3834
3835    if as_json {
3836        println!("{}", serde_json::to_string(&peers)?);
3837    } else if peers.is_empty() {
3838        println!("no peers pinned (run `wire join <code>` to pair)");
3839    } else {
3840        // v0.7.0-alpha.8 (review-fix #3): reuse the character we ALREADY
3841        // computed above (from peer's agent-card, honoring override) so
3842        // text and JSON output never diverge. Pre-alpha.8 the text loop
3843        // recomputed via Character::from_did (no override) — operators
3844        // saw different identities depending on --json flag.
3845        for p in &peers {
3846            let char_json = &p["persona"];
3847            let (colored_char, plain_len): (String, usize) = match char_json {
3848                serde_json::Value::Null => ("?".to_string(), 1),
3849                v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3850                    Ok(c) => {
3851                        let plain = c.short().chars().count() + 1; // +1 emoji-wide compensation
3852                        (c.colored(), plain)
3853                    }
3854                    Err(_) => ("?".to_string(), 1),
3855                },
3856            };
3857            let pad = 22usize.saturating_sub(plain_len);
3858            println!(
3859                "{}{}  {:<20} {:<10} {}",
3860                colored_char,
3861                " ".repeat(pad),
3862                p["handle"].as_str().unwrap_or(""),
3863                p["tier"].as_str().unwrap_or(""),
3864                p["did"].as_str().unwrap_or(""),
3865            );
3866        }
3867    }
3868    Ok(())
3869}
3870
3871// ---------- send ----------
3872
3873/// R4 attentiveness pre-flight. Best-effort: any failure is silent.
3874///
3875/// Looks up `peer` in relay-state for slot_id + slot_token + relay_url, asks
3876/// the relay for the slot's `last_pull_at_unix`, and prints a warning to
3877/// stderr if the peer hasn't polled in > 5min (or never has). Threshold of
3878/// 300s is the same wire daemon polling cadence rule-of-thumb — a peer
3879/// hasn't crossed two heartbeats means probably degraded.
3880fn maybe_warn_peer_attentiveness(peer: &str) {
3881    let state = match config::read_relay_state() {
3882        Ok(s) => s,
3883        Err(_) => return,
3884    };
3885    let p = state.get("peers").and_then(|p| p.get(peer));
3886    let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3887        Some(s) if !s.is_empty() => s,
3888        _ => return,
3889    };
3890    let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3891        Some(s) if !s.is_empty() => s,
3892        _ => return,
3893    };
3894    let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3895        Some(s) if !s.is_empty() => s.to_string(),
3896        _ => match state
3897            .get("self")
3898            .and_then(|s| s.get("relay_url"))
3899            .and_then(Value::as_str)
3900        {
3901            Some(s) if !s.is_empty() => s.to_string(),
3902            _ => return,
3903        },
3904    };
3905    let client = crate::relay_client::RelayClient::new(&relay_url);
3906    let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3907        Ok(t) => t,
3908        Err(_) => return,
3909    };
3910    let now = std::time::SystemTime::now()
3911        .duration_since(std::time::UNIX_EPOCH)
3912        .map(|d| d.as_secs())
3913        .unwrap_or(0);
3914    match last_pull {
3915        None => {
3916            eprintln!(
3917                "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3918            );
3919        }
3920        Some(t) if now.saturating_sub(t) > 300 => {
3921            let mins = now.saturating_sub(t) / 60;
3922            eprintln!(
3923                "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3924            );
3925        }
3926        _ => {}
3927    }
3928}
3929
3930pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3931    let trimmed = input.trim();
3932    if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3933    {
3934        return Ok(trimmed.to_string());
3935    }
3936    let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3937    let n: i64 = amount
3938        .parse()
3939        .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3940    if n <= 0 {
3941        bail!("deadline duration must be positive: {input:?}");
3942    }
3943    let duration = match unit {
3944        "m" => time::Duration::minutes(n),
3945        "h" => time::Duration::hours(n),
3946        "d" => time::Duration::days(n),
3947        _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3948    };
3949    Ok((time::OffsetDateTime::now_utc() + duration)
3950        .format(&time::format_description::well_known::Rfc3339)
3951        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3952}
3953
3954fn cmd_send(
3955    peer: &str,
3956    kind: &str,
3957    body_arg: &str,
3958    deadline: Option<&str>,
3959    // v0.10: when true, refuse to auto-pair on miss; fail loudly so
3960    // scripts can branch on the error instead of accepting an implicit
3961    // side effect.
3962    no_auto_pair: bool,
3963    // v0.14.2: opt back into the legacy outbox→daemon-push path. When
3964    // false (default), we POST synchronously and return a real
3965    // `delivered` / `duplicate` / `failed` verdict.
3966    queue: bool,
3967    as_json: bool,
3968) -> Result<()> {
3969    if !config::is_initialized()? {
3970        bail!("not initialized — run `wire init <handle>` first");
3971    }
3972    let peer_in = crate::agent_card::bare_handle(peer).to_string();
3973    // v0.7.0-alpha.2/.5: nickname-as-handle resolution. Exact handle
3974    // match wins; nickname (DID-hash auto-derived) is the fallback.
3975    // Ambiguous nicknames (two pinned peers DID-hash to the same
3976    // adj-noun pair) fail loudly with disambiguation; unknown handles
3977    // pass through and surface as `peer_unknown` from the sync
3978    // delivery layer (post-#187 `wire send` is sync by default;
3979    // `--queue` opts back into the legacy outbox-write path).
3980    let peer = match resolve_peer_handle(&peer_in) {
3981        Ok(Some(resolved)) if resolved != peer_in => {
3982            eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3983            resolved
3984        }
3985        Ok(Some(canonical)) => canonical, // exact handle match
3986        Ok(None) => peer_in,              // unknown — pass through, downstream errors
3987        Err(ResolveError::Ambiguous(candidates)) => bail!(
3988            "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3989             Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3990            candidates.len(),
3991            candidates.join(", ")
3992        ),
3993        Err(ResolveError::NotFound) => peer_in, // (unreachable for this fn but defensive)
3994    };
3995
3996    // v0.9 auto-pair-on-miss: if the resolved peer isn't pinned yet but
3997    // matches a local sister session, pair first (disk-read --local-sister
3998    // path) then continue. Pre-v0.14.2 closed the "wire send returns queued but
3999    // peer never receives because we were never paired" silent-fail
4000    // class. Equivalent to `wire dial <name>` followed by `wire send
4001    // <name> ...` in one step.
4002    let peer_is_pinned = config::read_relay_state()
4003        .ok()
4004        .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
4005        .map(|peers| peers.contains_key(&peer))
4006        .unwrap_or(false);
4007    if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
4008        if no_auto_pair {
4009            bail!(
4010                "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
4011                 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
4012                 then re-run send."
4013            );
4014        }
4015        eprintln!(
4016            "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
4017             Pass --no-auto-pair to refuse implicit dialing."
4018        );
4019        cmd_add_local_sister(&sister_name, true).map_err(|e| {
4020            anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
4021        })?;
4022    }
4023
4024    let peer = peer.as_str();
4025    let sk_seed = config::read_private_key()?;
4026    let card = config::read_agent_card()?;
4027    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4028    let handle = crate::agent_card::display_handle_from_did(did).to_string();
4029    let pk_b64 = card
4030        .get("verify_keys")
4031        .and_then(Value::as_object)
4032        .and_then(|m| m.values().next())
4033        .and_then(|v| v.get("key"))
4034        .and_then(Value::as_str)
4035        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
4036    let pk_bytes = crate::signing::b64decode(pk_b64)?;
4037
4038    // Body: literal string, `@/path/to/body.json`, or `-` for stdin.
4039    // P0.S (0.5.11): stdin support lets shells pipe in long content
4040    // without quoting/escaping ceremony, and supports heredocs naturally:
4041    //   wire send peer - <<EOF ... EOF
4042    let body_value: Value = if body_arg == "-" {
4043        use std::io::Read;
4044        let mut raw = String::new();
4045        std::io::stdin()
4046            .read_to_string(&mut raw)
4047            .with_context(|| "reading body from stdin")?;
4048        // Try parsing as JSON first; fall back to string literal for
4049        // plain-text bodies.
4050        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
4051    } else if let Some(path) = body_arg.strip_prefix('@') {
4052        let raw =
4053            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
4054        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
4055    } else {
4056        Value::String(body_arg.to_string())
4057    };
4058
4059    let kind_id = parse_kind(kind)?;
4060
4061    let now = time::OffsetDateTime::now_utc()
4062        .format(&time::format_description::well_known::Rfc3339)
4063        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4064
4065    // v0.14.2 (#162 fix #4): canonicalize `to:` against the pinned
4066    // peer's full DID. Bare-handle `to:did:wire:<handle>` misses the
4067    // long-fingerprint suffix (`did:wire:sunlit-aurora-ec6f890d`) that
4068    // pinned peers actually publish; mismatch risks receiver rejection
4069    // at canonical/cursor verification. resolve_peer_did falls back to
4070    // the bare form for unknown peers (pre-pair queue best-effort).
4071    let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
4072    let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
4073    let mut event = json!({
4074        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4075        "timestamp": now,
4076        "from": did,
4077        "to": to_did,
4078        "type": kind,
4079        "kind": kind_id,
4080        "body": body_value,
4081    });
4082    if let Some(deadline) = deadline {
4083        event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
4084    }
4085    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
4086    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
4087
4088    // R4: best-effort attentiveness pre-flight. Look up the peer's slot
4089    // coords in relay-state and ask the relay how recently the peer pulled.
4090    // Warn on stderr if the peer hasn't pulled in >5min OR has never pulled.
4091    // Never blocks the send — the sync POST or `--queue` outbox-write
4092    // happens below regardless.
4093    maybe_warn_peer_attentiveness(peer);
4094
4095    // v0.14.2 (paul, 2026-06-01): collapse the legacy 3-step
4096    // (outbox-write → daemon push → relay) into a single synchronous
4097    // POST when `--queue` is NOT set. The old path silently dropped
4098    // events in three distinct classes (daemon-down,
4099    // wrong-WIRE_HOME, stale-slot); the new path returns the real
4100    // verdict inline.
4101    if !queue {
4102        let outcome = crate::send::attempt_deliver(peer, &signed)?;
4103        if as_json {
4104            println!(
4105                "{}",
4106                serde_json::to_string(&crate::send::delivery_json(&outcome, peer))?
4107            );
4108        } else {
4109            use crate::send::SyncDelivery;
4110            match &outcome {
4111                SyncDelivery::Delivered {
4112                    event_id,
4113                    relay_url,
4114                    slot_id,
4115                } => println!("delivered {event_id} → {peer} (relay {relay_url} slot {slot_id})"),
4116                SyncDelivery::Duplicate {
4117                    event_id,
4118                    relay_url,
4119                    slot_id,
4120                } => println!(
4121                    "duplicate {event_id} → {peer} (already on relay {relay_url} slot {slot_id} — change the body to send a distinct event)"
4122                ),
4123                SyncDelivery::PeerUnknown { event_id } => println!(
4124                    "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."
4125                ),
4126                SyncDelivery::SlotStale {
4127                    event_id, detail, ..
4128                } => println!(
4129                    "FAILED {event_id} → {peer}: relay says slot is stale ({detail}). Run `wire dial {peer}` to re-pair."
4130                ),
4131                SyncDelivery::TransportError {
4132                    event_id, detail, ..
4133                } => println!(
4134                    "FAILED {event_id} → {peer}: transport error ({detail}). Retry, or pass --queue to outbox the event for daemon retry."
4135                ),
4136            }
4137        }
4138        // Non-zero exit for non-delivered states so scripts can
4139        // branch. Delivered + Duplicate both count as success (both
4140        // mean the peer can pull).
4141        if !outcome.reached_relay() {
4142            std::process::exit(2);
4143        }
4144        return Ok(());
4145    }
4146
4147    // Legacy --queue path: append to per-peer outbox JSONL, daemon
4148    // push loop drains. Same code shape as pre-v0.14.2.
4149    //
4150    // Honesty check: if the peer is BOTH not pinned in trust AND has
4151    // no pending pair, the daemon has no relay endpoint to push to
4152    // and never will until the operator pairs. The CLI shouldn't
4153    // silently accept this — coral dogfood today (2026-06-01) found
4154    // a year-old `no-such-peer.jsonl` outbox file from a typo'd send,
4155    // still on disk because the daemon has nowhere to send it. Emit
4156    // a one-line stderr warning so the operator knows what's going
4157    // to happen (the write proceeds — `--queue` is the documented
4158    // pre-pair best-effort path and we don't want to break the
4159    // "queue → then dial → then push" workflow).
4160    let peer_pinned_in_trust = trust_for_did
4161        .get("agents")
4162        .and_then(Value::as_object)
4163        .map(|a| a.contains_key(peer))
4164        .unwrap_or(false);
4165    if !peer_pinned_in_trust && !peer_is_pinned {
4166        // We received an invite drop awaiting accept (explicit peer_handle).
4167        let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
4168            .ok()
4169            .map(|v| v.iter().any(|p| p.peer_handle == peer))
4170            .unwrap_or(false);
4171        if !pending_inbound {
4172            eprintln!(
4173                "wire send: WARN — `{peer}` is not pinned and has no pending pair. \
4174                 The event will sit in outbox forever unless you pair first \
4175                 (`wire dial {peer}` or accept an inbound invite)."
4176            );
4177        }
4178    }
4179    let line = serde_json::to_vec(&signed)?;
4180    let outbox = config::append_outbox_record(peer, &line)?;
4181    if as_json {
4182        println!(
4183            "{}",
4184            serde_json::to_string(&json!({
4185                "event_id": event_id,
4186                "status": "queued",
4187                "peer": peer,
4188                "outbox": outbox.to_string_lossy(),
4189            }))?
4190        );
4191    } else {
4192        println!(
4193            "queued event {event_id} → {peer} (outbox: {}; daemon will push)",
4194            outbox.display()
4195        );
4196    }
4197    Ok(())
4198}
4199
4200fn parse_kind(s: &str) -> Result<u32> {
4201    if let Ok(n) = s.parse::<u32>() {
4202        return Ok(n);
4203    }
4204    for (id, name) in crate::signing::kinds() {
4205        if *name == s {
4206            return Ok(*id);
4207        }
4208    }
4209    // Unknown name — default to kind 1 (decision) for v0.1.
4210    Ok(1)
4211}
4212
4213// ---------- here (v0.9.3 you-are-here view) ----------
4214
4215/// `wire here` — one-screen "you are this session, your neighbors are
4216/// these." Combines what `wire whoami`, `wire peers`, and `wire session
4217/// list-local` would otherwise force the operator to call separately.
4218fn cmd_here(as_json: bool) -> Result<()> {
4219    let initialized = config::is_initialized().unwrap_or(false);
4220
4221    // Self identity.
4222    let (self_did, self_handle, self_character) = if initialized {
4223        let card = config::read_agent_card().ok();
4224        let did = card
4225            .as_ref()
4226            .and_then(|c| c.get("did").and_then(Value::as_str))
4227            .unwrap_or("")
4228            .to_string();
4229        let handle = if did.is_empty() {
4230            String::new()
4231        } else {
4232            crate::agent_card::display_handle_from_did(&did).to_string()
4233        };
4234        let character = if did.is_empty() {
4235            None
4236        } else {
4237            // v0.11: DID-derived only. No display.json overrides.
4238            Some(crate::character::Character::from_did(&did))
4239        };
4240        (did, handle, character)
4241    } else {
4242        (String::new(), String::new(), None)
4243    };
4244
4245    let cwd = std::env::current_dir()
4246        .map(|p| p.to_string_lossy().into_owned())
4247        .unwrap_or_default();
4248    let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
4249
4250    // Sister sessions (same-machine).
4251    let mut sisters: Vec<Value> = Vec::new();
4252    if let Ok(listing) = crate::session::list_local_sessions() {
4253        for group in listing.local.values() {
4254            for s in group {
4255                if s.handle.as_deref() == Some(self_handle.as_str()) {
4256                    continue; // skip self
4257                }
4258                let ch = s.did.as_deref().map(crate::character::Character::from_did);
4259                sisters.push(json!({
4260                    "session": s.name,
4261                    "handle": s.handle,
4262                    "persona": ch,
4263                }));
4264            }
4265        }
4266    }
4267
4268    // Pinned peers (trust ring agents).
4269    let mut peers: Vec<Value> = Vec::new();
4270    if initialized
4271        && let Ok(trust) = config::read_trust()
4272        && let Some(agents) = trust.get("agents").and_then(Value::as_object)
4273    {
4274        // Read relay_state once so the effective-tier lookup
4275        // doesn't hammer disk per peer. Missing file is fine —
4276        // effective_tier handles it.
4277        let relay_state =
4278            config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
4279        for (handle, agent) in agents {
4280            if handle == &self_handle {
4281                continue; // skip self
4282            }
4283            let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4284            let ch = if did.is_empty() {
4285                None
4286            } else {
4287                Some(crate::character::Character::from_did(did))
4288            };
4289            // v0.14.3: use effective tier so `wire here`, `wire
4290            // peers`, and `wire status` agree on what the daemon
4291            // can actually do. Raw trust tier alone was lying when
4292            // a VERIFIED peer's relay credentials were never
4293            // delivered (slot_token empty, bilateral_completed_at
4294            // missing). coral dogfood 2026-06-01 saw
4295            // orchid-savanna as VERIFIED here but PENDING_ACK in
4296            // the other two — same screen, two answers.
4297            peers.push(json!({
4298                "handle": handle,
4299                "did": did,
4300                "tier": crate::trust::effective_tier(&trust, &relay_state, handle),
4301                "persona": ch,
4302            }));
4303        }
4304    }
4305
4306    if as_json {
4307        println!(
4308            "{}",
4309            serde_json::to_string(&json!({
4310                "self": {
4311                    "handle": self_handle,
4312                    "did": self_did,
4313                    "persona": self_character,
4314                    "cwd": cwd,
4315                    "wire_home": wire_home,
4316                },
4317                "sister_sessions": sisters,
4318                "pinned_peers": peers,
4319            }))?
4320        );
4321        return Ok(());
4322    }
4323
4324    // Human format.
4325    if !initialized {
4326        println!("not initialized — run `wire init <handle>` to bootstrap.");
4327        return Ok(());
4328    }
4329    let glyph = self_character
4330        .as_ref()
4331        .map(crate::character::emoji_with_fallback)
4332        .unwrap_or_else(|| "?".to_string());
4333    let nick = self_character
4334        .as_ref()
4335        .map(|c| c.nickname.clone())
4336        .unwrap_or_default();
4337    println!("you are {glyph} {nick}  ({self_handle})");
4338    if !cwd.is_empty() {
4339        println!("  cwd:    {cwd}");
4340    }
4341    // Helper closure that mirrors emoji_with_fallback over a JSON-encoded
4342    // character object (because we already collected sisters/peers into
4343    // Value rows above). Looks up the canonical emoji-name and falls
4344    // back to that — never repeats the nickname inside the brackets.
4345    let render_glyph = |character: &Value| -> String {
4346        let emoji = character
4347            .get("emoji")
4348            .and_then(Value::as_str)
4349            .unwrap_or("?");
4350        let nickname = character
4351            .get("nickname")
4352            .and_then(Value::as_str)
4353            .unwrap_or("?");
4354        if crate::character::terminal_supports_emoji() {
4355            return emoji.to_string();
4356        }
4357        // Synthesize a minimal Character so emoji_with_fallback's
4358        // lookup table picks the right ASCII tag.
4359        let synth = crate::character::Character {
4360            nickname: nickname.to_string(),
4361            emoji: emoji.to_string(),
4362            palette: crate::character::Palette {
4363                primary_hex: String::new(),
4364                accent_hex: String::new(),
4365                ansi256_primary: 0,
4366                ansi256_accent: 0,
4367            },
4368        };
4369        crate::character::emoji_with_fallback(&synth)
4370    };
4371    if !sisters.is_empty() {
4372        println!();
4373        println!("sister sessions on this machine:");
4374        for s in &sisters {
4375            let session = s["session"].as_str().unwrap_or("?");
4376            let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
4377            let glyph = render_glyph(&s["persona"]);
4378            println!("  {glyph} {ch_nick}  ({session})");
4379        }
4380    }
4381    if !peers.is_empty() {
4382        println!();
4383        println!("pinned peers:");
4384        for p in &peers {
4385            let handle = p["handle"].as_str().unwrap_or("?");
4386            let tier = p["tier"].as_str().unwrap_or("");
4387            let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
4388            let glyph = render_glyph(&p["persona"]);
4389            println!("  {glyph} {ch_nick}  ({handle})  [{tier}]");
4390        }
4391    }
4392    if sisters.is_empty() && peers.is_empty() {
4393        println!();
4394        println!(
4395            "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
4396        );
4397    }
4398    Ok(())
4399}
4400
4401// ---------- dial / whois (v0.8 canonical addressing) ----------
4402
4403/// `wire dial <name> [message]` — the one verb operators reach for.
4404/// Resolves any name (nickname/handle/session/DID) to a peer and
4405/// drives the right pair flow + optional first message. See the
4406/// `Command::Dial` doc for the resolution ladder.
4407///
4408/// v0.9: when `name` contains `@<relay>`, route through the federation
4409/// `wire add <handle>@<relay>` path (`.well-known/wire/agent` resolution
4410/// plus cross-machine pair_drop). No more bail with "federation isn't
4411/// implemented yet" — one verb across both orbits.
4412fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
4413    if name.contains('@') {
4414        // Federation path. cmd_add already auto-detects (per v0.7.4)
4415        // when input has `@` and routes through the .well-known
4416        // resolver + pair_drop deposit. After it returns, the peer
4417        // is in pending-outbound; bilateral completes when the peer
4418        // accepts. Optionally send the first message after the add.
4419        cmd_add(name, None, false, true)
4420            .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
4421        if let Some(msg) = message {
4422            // Peer handle for send = the nick part before the `@`.
4423            let bare = name.split('@').next().unwrap_or(name);
4424            cmd_send(bare, "claim", msg, None, false, false, as_json)?;
4425        }
4426        return Ok(());
4427    }
4428
4429    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
4430    // success with `{found: false, candidates: [...]}` instead of
4431    // erroring. Agents can branch on `found` without wrapping in a
4432    // try/catch.
4433    let resolution = match resolve_name_to_target(name) {
4434        Ok(r) => r,
4435        Err(e) if as_json => {
4436            let pool = known_local_names();
4437            let suggestions = closest_candidates(name, &pool, 3, 3);
4438            println!(
4439                "{}",
4440                serde_json::to_string(&json!({
4441                    "name_input": name,
4442                    "found": false,
4443                    "candidates": suggestions,
4444                    "error": format!("{e:#}"),
4445                }))?
4446            );
4447            return Ok(());
4448        }
4449        Err(e) => return Err(e),
4450    };
4451    let mut steps: Vec<Value> = Vec::new();
4452
4453    match &resolution {
4454        DialTarget::PinnedPeer { handle, .. } => {
4455            steps.push(json!({
4456                "step": "resolved",
4457                "kind": "already_pinned",
4458                "handle": handle,
4459            }));
4460        }
4461        DialTarget::LocalSister { session_name, .. } => {
4462            steps.push(json!({
4463                "step": "resolved",
4464                "kind": "local_sister",
4465                "session": session_name,
4466            }));
4467            // Drive the bilateral pair via the disk-read sister path.
4468            // cmd_add_local_sister already handles "already paired"
4469            // gracefully (its internal state.peers check returns the
4470            // existing pin instead of re-issuing a pair_drop), so
4471            // re-dialling is idempotent.
4472            cmd_add_local_sister(session_name, true).map_err(|e| {
4473                anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
4474            })?;
4475            steps.push(json!({
4476                "step": "paired",
4477                "via": "local_sister",
4478            }));
4479        }
4480    }
4481
4482    let send_handle = match &resolution {
4483        DialTarget::PinnedPeer { handle, .. } => handle.clone(),
4484        DialTarget::LocalSister { handle, .. } => handle.clone(),
4485    };
4486
4487    let send_result = if let Some(msg) = message {
4488        let r = cmd_send(&send_handle, "claim", msg, None, false, false, true);
4489        match &r {
4490            Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
4491            Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
4492        }
4493        Some(r)
4494    } else {
4495        None
4496    };
4497
4498    if as_json {
4499        println!(
4500            "{}",
4501            serde_json::to_string(&json!({
4502                "name_input": name,
4503                "resolved_handle": send_handle,
4504                "steps": steps,
4505            }))?
4506        );
4507    } else {
4508        println!("wire dial: resolved `{name}` → handle `{send_handle}`");
4509        for s in &steps {
4510            let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
4511            println!("  - {step}");
4512        }
4513        if message.is_some() {
4514            println!("  (use `wire tail {send_handle}` to read replies)");
4515        }
4516    }
4517    if let Some(Err(e)) = send_result {
4518        return Err(e);
4519    }
4520    Ok(())
4521}
4522
4523/// `wire whois <name>` — resolve any local name (nickname/session/
4524/// handle/DID) to the full identity row. The inspector for the
4525/// canonical addressing layer. For federation `handle@relay-domain`
4526/// resolution see `cmd_whois` (line 5536+) — the dispatcher chooses
4527/// based on whether the input contains `@`.
4528fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
4529    // v0.9.2 helpful-miss: in JSON mode, a resolution miss returns
4530    // success (exit 0) with `{found: false, candidates: [...]}` so
4531    // agents don't need try/catch around `wire whois <name>`. In
4532    // human mode, the bail's did-you-mean line points at the
4533    // closest candidate.
4534    let resolution = match resolve_name_to_target(name) {
4535        Ok(r) => r,
4536        Err(e) if as_json => {
4537            let pool = known_local_names();
4538            let suggestions = closest_candidates(name, &pool, 3, 3);
4539            println!(
4540                "{}",
4541                serde_json::to_string(&json!({
4542                    "name_input": name,
4543                    "found": false,
4544                    "candidates": suggestions,
4545                    "error": format!("{e:#}"),
4546                }))?
4547            );
4548            return Ok(());
4549        }
4550        Err(e) => return Err(e),
4551    };
4552    match resolution {
4553        DialTarget::PinnedPeer {
4554            handle,
4555            did,
4556            nickname,
4557            emoji,
4558            tier,
4559        } => {
4560            // v0.14: re-read trust to pull the pinned peer's card for op
4561            // claims surfacing. Pinned ⇒ card lives in trust.json (no
4562            // network round-trip). Older peers ⇒ no op_* fields ⇒ empty.
4563            let op_claims = config::read_trust()
4564                .ok()
4565                .and_then(|t| {
4566                    t.get("agents")
4567                        .and_then(Value::as_object)
4568                        .and_then(|m| m.get(&handle))
4569                        .and_then(|a| a.get("card").cloned())
4570                })
4571                .map(|c| op_claims_from_card(&c))
4572                .unwrap_or_default();
4573
4574            if as_json {
4575                let mut payload = serde_json::Map::new();
4576                payload.insert("kind".into(), json!("pinned_peer"));
4577                payload.insert("handle".into(), json!(handle));
4578                payload.insert("did".into(), json!(did));
4579                payload.insert("nickname".into(), json!(nickname));
4580                payload.insert("emoji".into(), json!(emoji));
4581                payload.insert("tier".into(), json!(tier));
4582                for (k, v) in &op_claims {
4583                    payload.insert(k.clone(), v.clone());
4584                }
4585                println!("{}", serde_json::to_string(&payload)?);
4586            } else {
4587                let n = nickname.as_deref().unwrap_or("(no character)");
4588                let e = emoji.as_deref().unwrap_or("?");
4589                println!("{e} {n}");
4590                println!("  handle:   {handle}");
4591                println!("  did:      {did}");
4592                println!("  tier:     {tier}");
4593                // v0.14: surface peer's op_did when the pinned card
4594                // carries one. Silent for pre-v0.14 peers.
4595                if let Some(op_did) = op_claims.get("op_did").and_then(Value::as_str) {
4596                    println!("  op_did:   {op_did}");
4597                }
4598                println!("  reach:    pinned peer (already in trust ring + slot pinned)");
4599            }
4600        }
4601        DialTarget::LocalSister {
4602            session_name,
4603            handle,
4604            did,
4605            nickname,
4606            emoji,
4607        } => {
4608            if as_json {
4609                println!(
4610                    "{}",
4611                    serde_json::to_string(&json!({
4612                        "kind": "local_sister",
4613                        "session_name": session_name,
4614                        "handle": handle,
4615                        "did": did,
4616                        "nickname": nickname,
4617                        "emoji": emoji,
4618                    }))?
4619                );
4620            } else {
4621                let n = nickname.as_deref().unwrap_or("(no character)");
4622                let e = emoji.as_deref().unwrap_or("?");
4623                println!("{e} {n}");
4624                println!("  session:  {session_name}");
4625                println!("  handle:   {handle}");
4626                println!(
4627                    "  did:      {}",
4628                    did.as_deref().unwrap_or("(card unreadable)")
4629                );
4630                println!("  reach:    local sister on this machine — `wire dial {n}` pairs us");
4631            }
4632        }
4633    }
4634    Ok(())
4635}
4636
4637pub(crate) enum DialTarget {
4638    PinnedPeer {
4639        handle: String,
4640        did: String,
4641        nickname: Option<String>,
4642        emoji: Option<String>,
4643        tier: String,
4644    },
4645    LocalSister {
4646        session_name: String,
4647        handle: String,
4648        did: Option<String>,
4649        nickname: Option<String>,
4650        emoji: Option<String>,
4651    },
4652}
4653
4654/// Resolution order: pinned peers first (already in our trust ring),
4655/// then local sister sessions (on-disk discovery). Case-insensitive
4656/// match against handle, character nickname, session name, or DID.
4657///
4658/// `pub(crate)` so the MCP `tool_whois` surface mirrors the CLI's
4659/// bare-nick resolution (closes the known `missing '@' separator`
4660/// rejection on bare nicks — agents reading via MCP now resolve
4661/// pinned peers + local sisters identically to operators reading via
4662/// CLI).
4663pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
4664    let needle = name.trim();
4665    if needle.is_empty() {
4666        bail!("empty name");
4667    }
4668
4669    // 1. Pinned peers — `wire peers` data. trust.agents is an object
4670    // keyed by handle (not an array); iterate as a map.
4671    if config::is_initialized().unwrap_or(false) {
4672        let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
4673        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
4674            for (handle_key, agent) in agents {
4675                let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4676                if did.is_empty() {
4677                    continue;
4678                }
4679                let handle = handle_key.clone();
4680                let character = crate::character::Character::from_did(did);
4681                let tier = agent
4682                    .get("tier")
4683                    .and_then(Value::as_str)
4684                    .unwrap_or("UNKNOWN")
4685                    .to_string();
4686                let matches = handle.eq_ignore_ascii_case(needle)
4687                    || did.eq_ignore_ascii_case(needle)
4688                    || character.nickname.eq_ignore_ascii_case(needle);
4689                if matches {
4690                    return Ok(DialTarget::PinnedPeer {
4691                        handle,
4692                        did: did.to_string(),
4693                        nickname: Some(character.nickname),
4694                        emoji: Some(character.emoji.to_string()),
4695                        tier,
4696                    });
4697                }
4698            }
4699        }
4700    }
4701
4702    // 2. Local sister sessions.
4703    if let Some(session_name) = crate::session::resolve_local_sister(needle) {
4704        let sessions = crate::session::list_sessions().unwrap_or_default();
4705        let s = sessions.iter().find(|s| s.name == session_name);
4706        if let Some(s) = s {
4707            return Ok(DialTarget::LocalSister {
4708                session_name: s.name.clone(),
4709                handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
4710                did: s.did.clone(),
4711                nickname: s.character.as_ref().map(|c| c.nickname.clone()),
4712                emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
4713            });
4714        }
4715    }
4716
4717    // v0.9.2: fuzzy did-you-mean suggestion on resolution miss. Walks
4718    // the union of pinned-peer handles + character nicknames + sister
4719    // session names + sister character nicknames, returns up to 3 names
4720    // within Levenshtein distance 3 of the operator's typed name.
4721    let pool = known_local_names();
4722    let suggestions = closest_candidates(name, &pool, 3, 3);
4723    if suggestions.is_empty() {
4724        bail!(
4725            "no peer matched `{name}`.\n\
4726             Tried: pinned peers (`wire peers`) + local sister sessions \
4727             (`wire session list-local`).\n\
4728             For cross-machine federation: `wire dial <handle>@<relay-domain>`."
4729        );
4730    }
4731    bail!(
4732        "no peer matched `{name}`.\n\
4733         Did you mean: {}?\n\
4734         List all: `wire peers`, `wire session list-local`.",
4735        suggestions
4736            .iter()
4737            .map(|s| format!("`{s}`"))
4738            .collect::<Vec<_>>()
4739            .join(", ")
4740    );
4741}
4742
4743// ---------- tail ----------
4744
4745/// Print recent events from this agent's inbox.
4746///
4747/// **Orientation (wire #79):** defaults to NEWEST-N — with `limit > 0`, the
4748/// last `limit` events across all matched peer jsonl files are returned,
4749/// sorted chronologically (by `timestamp`, then by per-file append order as
4750/// tiebreaker) and printed oldest-of-window first / newest last. This matches
4751/// `tail -n` semantics on log files; previously `wire tail --limit N` returned
4752/// the OLDEST N which silently hid live-context for any agent harness that
4753/// re-tailed an established inbox.
4754///
4755/// `oldest=true` flips back to FIFO (first-N) for operators who need the
4756/// original orientation (e.g. replaying an inbox from the start). `limit=0`
4757/// prints every event in chronological order.
4758fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize, oldest: bool) -> Result<()> {
4759    let inbox = config::inbox_dir()?;
4760    if !inbox.exists() {
4761        if !as_json {
4762            eprintln!("no inbox yet — daemon hasn't run, or no events received");
4763        }
4764        return Ok(());
4765    }
4766    let trust = config::read_trust()?;
4767
4768    let entries: Vec<_> = std::fs::read_dir(&inbox)?
4769        .filter_map(|e| e.ok())
4770        .map(|e| e.path())
4771        .filter(|p| {
4772            p.extension().map(|x| x == "jsonl").unwrap_or(false)
4773                && match peer {
4774                    Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
4775                    None => true,
4776                }
4777        })
4778        .collect();
4779
4780    // Collect every parseable event across all matched peer files. Each entry
4781    // carries a sort key `(timestamp, line_idx)` so multi-peer interleaving
4782    // sorts deterministically by event time, with append-order as the
4783    // tiebreaker for events that share a timestamp (or for events with no
4784    // timestamp string at all).
4785    let mut events: Vec<(String, usize, Value)> = Vec::new();
4786    for path in &entries {
4787        let body = std::fs::read_to_string(path)?;
4788        for (idx, line) in body.lines().enumerate() {
4789            let event: Value = match serde_json::from_str(line) {
4790                Ok(v) => v,
4791                Err(_) => continue,
4792            };
4793            let ts = event
4794                .get("timestamp")
4795                .and_then(Value::as_str)
4796                .unwrap_or("")
4797                .to_string();
4798            events.push((ts, idx, event));
4799        }
4800    }
4801    events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
4802
4803    // Pick the window. limit=0 → all events; oldest → first N; default → last N.
4804    let total = events.len();
4805    let window: &[(String, usize, Value)] = if limit == 0 {
4806        &events[..]
4807    } else if oldest {
4808        &events[..limit.min(total)]
4809    } else {
4810        let start = total.saturating_sub(limit);
4811        &events[start..]
4812    };
4813
4814    for (_, _, event) in window {
4815        let verified = verify_message_v31(event, &trust).is_ok();
4816        if as_json {
4817            let mut event_with_meta = event.clone();
4818            if let Some(obj) = event_with_meta.as_object_mut() {
4819                obj.insert("verified".into(), json!(verified));
4820            }
4821            println!("{}", serde_json::to_string(&event_with_meta)?);
4822        } else {
4823            let ts = event
4824                .get("timestamp")
4825                .and_then(Value::as_str)
4826                .unwrap_or("?");
4827            let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
4828            let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
4829            let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
4830            let summary = event
4831                .get("body")
4832                .map(|b| match b {
4833                    Value::String(s) => s.clone(),
4834                    _ => b.to_string(),
4835                })
4836                .unwrap_or_default();
4837            let mark = if verified { "✓" } else { "✗" };
4838            let deadline = event
4839                .get("time_sensitive_until")
4840                .and_then(Value::as_str)
4841                .map(|d| format!(" deadline: {d}"))
4842                .unwrap_or_default();
4843            println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
4844        }
4845    }
4846    Ok(())
4847}
4848
4849// ---------- monitor (live-tail across all peers, harness-friendly) ----------
4850
4851/// Events filtered out of `wire monitor` by default — pair handshake +
4852/// liveness pings. Operators almost never want these surfaced; an explicit
4853/// `--include-handshake` brings them back.
4854fn monitor_is_noise_kind(kind: &str) -> bool {
4855    matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
4856}
4857
4858/// Resolve a pinned peer's persona (the DID-derived nickname + emoji,
4859/// respecting an advertised override on their card). `None` if the peer
4860/// isn't in trust or can't be resolved — callers fall back to the handle.
4861fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
4862    let trust = config::read_trust().ok()?;
4863    let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
4864    if let Some(card) = agent.get("card") {
4865        Some(crate::character::Character::from_card(card))
4866    } else {
4867        let did = agent.get("did").and_then(Value::as_str)?;
4868        Some(crate::character::Character::from_did(did))
4869    }
4870}
4871
4872/// "emoji nickname" label for a peer, falling back to the raw handle.
4873fn persona_label(peer_handle: &str) -> String {
4874    match resolve_persona(peer_handle) {
4875        Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
4876        None => peer_handle.to_string(),
4877    }
4878}
4879
4880/// Render a single InboxEvent for `wire monitor` output. JSON form emits the
4881/// full structured event for tooling consumption; the plain form is a tight
4882/// one-line summary suitable as a harness stream-watcher notification.
4883///
4884/// Kept PURE (no trust I/O) so it stays deterministic and cheap per event.
4885/// Persona enrichment for `--json` belongs at InboxEvent construction in
4886/// `inbox_watch` (a follow-up), not here.
4887fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
4888    if as_json {
4889        Ok(serde_json::to_string(e)?)
4890    } else {
4891        let eid_short: String = e.event_id.chars().take(12).collect();
4892        let body = e.body_preview.replace('\n', " ");
4893        let ts: String = e.timestamp.chars().take(19).collect();
4894        Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
4895    }
4896}
4897
4898/// `wire monitor` — long-running line-per-event stream of new inbox events.
4899///
4900/// Built for agent harnesses that have an "every stdout line is a chat
4901/// notification" stream watcher (Claude Code Monitor tool, etc.). One
4902/// command, persistent, filtered. Replaces the manual `tail -F inbox/*.jsonl
4903/// | python parse | grep -v pair_drop` pipeline operators improvise on day
4904/// one of every wire session.
4905///
4906/// Default filter strips `pair_drop`, `pair_drop_ack`, and `heartbeat` —
4907/// pure handshake / liveness noise that operators almost never want
4908/// surfaced. Pass `--include-handshake` if you do.
4909///
4910/// Cursor: in-memory only. Starts from EOF (so a fresh `wire monitor`
4911/// doesn't drown the operator in replay), with optional `--replay N` to
4912/// emit the last N events first.
4913fn cmd_monitor(
4914    peer_filter: Option<&str>,
4915    as_json: bool,
4916    include_handshake: bool,
4917    interval_ms: u64,
4918    replay: usize,
4919) -> Result<()> {
4920    let inbox_dir = config::inbox_dir()?;
4921    if !inbox_dir.exists() && !as_json {
4922        eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
4923    }
4924    // v0.13.x identity work: monitor owns the inbox cursor across the
4925    // long-running poll loop; collision with another wire process under
4926    // the same WIRE_HOME causes "I'm not seeing X's events" debugging
4927    // rabbit holes. Warn at startup so the operator catches it fast.
4928    crate::session::warn_on_identity_collision(std::process::id(), "monitor");
4929    // Still proceed — InboxWatcher::from_dir_head handles missing dir.
4930
4931    // Optional replay — read existing files and emit the last `replay` events
4932    // (post-filter) before going live. Useful when the harness restarts and
4933    // wants recent context.
4934    if replay > 0 && inbox_dir.exists() {
4935        let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
4936        for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
4937            let path = entry.path();
4938            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4939                continue;
4940            }
4941            let peer = match path.file_stem().and_then(|s| s.to_str()) {
4942                Some(s) => s.to_string(),
4943                None => continue,
4944            };
4945            if let Some(filter) = peer_filter
4946                && peer != filter
4947            {
4948                continue;
4949            }
4950            let body = std::fs::read_to_string(&path).unwrap_or_default();
4951            for line in body.lines() {
4952                let line = line.trim();
4953                if line.is_empty() {
4954                    continue;
4955                }
4956                let signed: Value = match serde_json::from_str(line) {
4957                    Ok(v) => v,
4958                    Err(_) => continue,
4959                };
4960                let ev = crate::inbox_watch::InboxEvent::from_signed(
4961                    &peer, signed, /* verified */ true,
4962                );
4963                if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4964                    continue;
4965                }
4966                all.push(ev);
4967            }
4968        }
4969        // Sort by timestamp string (RFC3339-ish — lexicographic order matches
4970        // chronological for same-zoned timestamps).
4971        all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
4972        let start = all.len().saturating_sub(replay);
4973        for ev in &all[start..] {
4974            println!("{}", monitor_render(ev, as_json)?);
4975        }
4976        use std::io::Write;
4977        std::io::stdout().flush().ok();
4978    }
4979
4980    // Live loop. InboxWatcher::from_head() seeds cursors at current EOF, so
4981    // the first poll only returns events that arrived AFTER startup.
4982    let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
4983    let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
4984
4985    loop {
4986        // Never die silently. wisp-blossom (Win10) saw `wire monitor` exit 1
4987        // with ZERO bytes on stdout+stderr when a cursor-block (untrusted
4988        // signer's pair event) tripped the watcher — a silent death looks
4989        // identical to "still watching" and breaks the sister-collab model.
4990        // Surface the reason and KEEP watching instead of propagating a fatal
4991        // `?` that some callers swallow.
4992        let events = match w.poll() {
4993            Ok(evs) => evs,
4994            Err(e) => {
4995                eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
4996                std::thread::sleep(sleep_dur);
4997                continue;
4998            }
4999        };
5000        let mut wrote = false;
5001        for ev in events {
5002            if let Some(filter) = peer_filter
5003                && ev.peer != filter
5004            {
5005                continue;
5006            }
5007            if !include_handshake && monitor_is_noise_kind(&ev.kind) {
5008                continue;
5009            }
5010            println!("{}", monitor_render(&ev, as_json)?);
5011            wrote = true;
5012        }
5013        if wrote {
5014            use std::io::Write;
5015            std::io::stdout().flush().ok();
5016        }
5017        std::thread::sleep(sleep_dur);
5018    }
5019}
5020
5021#[cfg(test)]
5022mod tier_tests {
5023    use super::*;
5024    use serde_json::json;
5025
5026    fn trust_with(handle: &str, tier: &str) -> Value {
5027        json!({
5028            "version": 1,
5029            "agents": {
5030                handle: {
5031                    "tier": tier,
5032                    "did": format!("did:wire:{handle}"),
5033                    "card": {"capabilities": ["wire/v3.1"]}
5034                }
5035            }
5036        })
5037    }
5038
5039    #[test]
5040    fn pending_ack_when_verified_but_no_slot_token() {
5041        // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
5042        // slot_token hasn't arrived yet. Display PENDING_ACK so the
5043        // operator knows wire send won't work yet.
5044        let trust = trust_with("willard", "VERIFIED");
5045        let relay_state = json!({
5046            "peers": {
5047                "willard": {
5048                    "relay_url": "https://relay",
5049                    "slot_id": "abc",
5050                    "slot_token": "",
5051                }
5052            }
5053        });
5054        assert_eq!(
5055            effective_peer_tier(&trust, &relay_state, "willard"),
5056            "PENDING_ACK"
5057        );
5058    }
5059
5060    #[test]
5061    fn verified_when_slot_token_present() {
5062        let trust = trust_with("willard", "VERIFIED");
5063        let relay_state = json!({
5064            "peers": {
5065                "willard": {
5066                    "relay_url": "https://relay",
5067                    "slot_id": "abc",
5068                    "slot_token": "tok123",
5069                }
5070            }
5071        });
5072        assert_eq!(
5073            effective_peer_tier(&trust, &relay_state, "willard"),
5074            "VERIFIED"
5075        );
5076    }
5077
5078    #[test]
5079    fn raw_tier_passes_through_for_non_verified() {
5080        // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
5081        // UNTRUSTED regardless of slot_token state.
5082        let trust = trust_with("willard", "UNTRUSTED");
5083        let relay_state = json!({
5084            "peers": {"willard": {"slot_token": ""}}
5085        });
5086        assert_eq!(
5087            effective_peer_tier(&trust, &relay_state, "willard"),
5088            "UNTRUSTED"
5089        );
5090    }
5091
5092    #[test]
5093    fn pending_ack_when_relay_state_missing_peer() {
5094        // After wire add, trust gets updated BEFORE relay_state.peers does.
5095        // If relay_state has no entry for the peer at all, the operator
5096        // still hasn't completed the bilateral pin — show PENDING_ACK.
5097        let trust = trust_with("willard", "VERIFIED");
5098        let relay_state = json!({"peers": {}});
5099        assert_eq!(
5100            effective_peer_tier(&trust, &relay_state, "willard"),
5101            "PENDING_ACK"
5102        );
5103    }
5104}
5105
5106#[cfg(test)]
5107mod monitor_tests {
5108    use super::*;
5109    use crate::inbox_watch::InboxEvent;
5110    use serde_json::Value;
5111
5112    fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
5113        InboxEvent {
5114            peer: peer.to_string(),
5115            event_id: "abcd1234567890ef".to_string(),
5116            kind: kind.to_string(),
5117            body_preview: body.to_string(),
5118            verified: true,
5119            timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
5120            raw: Value::Null,
5121        }
5122    }
5123
5124    #[test]
5125    fn monitor_filter_drops_handshake_kinds_by_default() {
5126        // The whole point: pair_drop / pair_drop_ack / heartbeat are
5127        // protocol noise. If they leak into the operator's chat stream by
5128        // default, the recipe is useless ("wire monitor talks too much,
5129        // disabled it"). Burn this rule in.
5130        assert!(monitor_is_noise_kind("pair_drop"));
5131        assert!(monitor_is_noise_kind("pair_drop_ack"));
5132        assert!(monitor_is_noise_kind("heartbeat"));
5133
5134        // Real-payload kinds — operator wants every one.
5135        assert!(!monitor_is_noise_kind("claim"));
5136        assert!(!monitor_is_noise_kind("decision"));
5137        assert!(!monitor_is_noise_kind("ack"));
5138        assert!(!monitor_is_noise_kind("request"));
5139        assert!(!monitor_is_noise_kind("note"));
5140        // Unknown future kinds shouldn't be filtered as noise either —
5141        // operator probably wants to see something they don't recognise,
5142        // not have it silently dropped (the P0.1 lesson at the UX layer).
5143        assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
5144    }
5145
5146    #[test]
5147    fn monitor_render_plain_is_one_short_line() {
5148        let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
5149        let line = monitor_render(&e, false).unwrap();
5150        // Must be single-line.
5151        assert!(!line.contains('\n'), "render must be one line: {line}");
5152        // Must include peer, kind, body fragment, short event_id.
5153        assert!(line.contains("willard"));
5154        assert!(line.contains("claim"));
5155        assert!(line.contains("real v8 train"));
5156        // Short event id (first 12 chars).
5157        assert!(line.contains("abcd12345678"));
5158        assert!(
5159            !line.contains("abcd1234567890ef"),
5160            "should truncate full id"
5161        );
5162        // RFC3339-ish second precision.
5163        assert!(line.contains("2026-05-15T23:14:07"));
5164    }
5165
5166    #[test]
5167    fn monitor_render_strips_newlines_from_body() {
5168        // Multi-line bodies (markdown lists, code, etc.) must collapse to
5169        // one line — otherwise a single message produces multiple
5170        // notifications in the harness, ruining the "one event = one line"
5171        // contract the Monitor tool relies on.
5172        let e = ev("spark", "claim", "line one\nline two\nline three");
5173        let line = monitor_render(&e, false).unwrap();
5174        assert!(!line.contains('\n'), "newlines must be stripped: {line}");
5175        assert!(line.contains("line one line two line three"));
5176    }
5177
5178    #[test]
5179    fn monitor_render_json_is_valid_jsonl() {
5180        let e = ev("spark", "claim", "hi");
5181        let line = monitor_render(&e, true).unwrap();
5182        assert!(!line.contains('\n'));
5183        let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
5184        assert_eq!(parsed["peer"], "spark");
5185        assert_eq!(parsed["kind"], "claim");
5186        assert_eq!(parsed["body_preview"], "hi");
5187    }
5188
5189    #[test]
5190    fn monitor_does_not_drop_on_verified_null() {
5191        // Spark's bug confession on 2026-05-15: their monitor pipeline ran
5192        // `select(.verified == true)` against inbox JSONL. Daemon writes
5193        // events with verified=null (verification happens at tail-time, not
5194        // write-time), so the filter silently rejected everything — same
5195        // anti-pattern as P0.1 at the JSON-jq level. Cost: 4 of my events
5196        // never surfaced for ~30min.
5197        //
5198        // wire monitor's render path must NOT consult `.verified` for any
5199        // filter decision. Lock that in here so a future "be conservative,
5200        // only emit verified" patch can't quietly land.
5201        let mut e = ev("spark", "claim", "from disk with verified=null");
5202        e.verified = false; // worst case — even if disk says unverified, emit
5203        let line = monitor_render(&e, false).unwrap();
5204        assert!(line.contains("from disk with verified=null"));
5205        // Noise filter operates purely on kind, never on verified.
5206        assert!(!monitor_is_noise_kind("claim"));
5207    }
5208}
5209
5210// ---------- verify ----------
5211
5212fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
5213    let body = if path == "-" {
5214        let mut buf = String::new();
5215        use std::io::Read;
5216        std::io::stdin().read_to_string(&mut buf)?;
5217        buf
5218    } else {
5219        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
5220    };
5221    let event: Value = serde_json::from_str(&body)?;
5222    let trust = config::read_trust()?;
5223    match verify_message_v31(&event, &trust) {
5224        Ok(()) => {
5225            if as_json {
5226                println!("{}", serde_json::to_string(&json!({"verified": true}))?);
5227            } else {
5228                println!("verified ✓");
5229            }
5230            Ok(())
5231        }
5232        Err(e) => {
5233            let reason = e.to_string();
5234            if as_json {
5235                println!(
5236                    "{}",
5237                    serde_json::to_string(&json!({"verified": false, "reason": reason}))?
5238                );
5239            } else {
5240                eprintln!("FAILED: {reason}");
5241            }
5242            std::process::exit(1);
5243        }
5244    }
5245}
5246
5247// ---------- mcp / relay-server stubs ----------
5248
5249fn cmd_mcp() -> Result<()> {
5250    crate::mcp::run()
5251}
5252
5253fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
5254    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
5255    // overriding --bind. Implies --local-only semantics. Routed to a
5256    // separate serve_uds entry point with a manual hyper accept loop
5257    // (axum 0.7's `serve` is TcpListener-only).
5258    if let Some(socket_path) = uds {
5259        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
5260            std::path::PathBuf::from(home)
5261                .join("state")
5262                .join("wire-relay")
5263                .join("uds")
5264        } else {
5265            dirs::state_dir()
5266                .or_else(dirs::data_local_dir)
5267                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
5268                .join("wire-relay")
5269                .join("uds")
5270        };
5271        let runtime = tokio::runtime::Builder::new_multi_thread()
5272            .enable_all()
5273            .build()?;
5274        return runtime.block_on(crate::relay_server::serve_uds(
5275            socket_path.to_path_buf(),
5276            base,
5277        ));
5278    }
5279    // v0.5.17: --local-only refuses non-loopback binds. Catches the
5280    // "wait did I just bind a publicly-reachable local-only relay" mistake
5281    // at startup rather than discovering it via an empty phonebook later.
5282    if local_only {
5283        validate_loopback_bind(bind)?;
5284    }
5285    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
5286    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
5287    // so a single user can run both client and server on one machine.
5288    // For --local-only, suffix with /local so a single operator can run
5289    // both a federation relay and a local-only relay without state collision.
5290    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
5291        std::path::PathBuf::from(home)
5292            .join("state")
5293            .join("wire-relay")
5294    } else {
5295        dirs::state_dir()
5296            .or_else(dirs::data_local_dir)
5297            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
5298            .join("wire-relay")
5299    };
5300    let state_dir = if local_only { base.join("local") } else { base };
5301    let runtime = tokio::runtime::Builder::new_multi_thread()
5302        .enable_all()
5303        .build()?;
5304    runtime.block_on(crate::relay_server::serve_with_mode(
5305        bind,
5306        state_dir,
5307        crate::relay_server::ServerMode { local_only },
5308    ))
5309}
5310
5311/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
5312/// resolves to something outside `127.0.0.0/8` or `::1`.
5313///
5314/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
5315/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
5316/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
5317///
5318/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
5319/// is the IP range Tailscale uses for tailnet addresses. Lets operators
5320/// pair wire across machines using their tailnet IPs (e.g. Mac at
5321/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
5322/// auth + encryption + NAT traversal, wire handles protocol + identity.
5323/// Sidesteps host firewall config entirely (utun interface bypass).
5324///
5325/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
5326/// multicast, broadcast. Those would publish a "local-only" relay to
5327/// the global internet — the v0.5.17 security gate's whole point.
5328fn validate_loopback_bind(bind: &str) -> Result<()> {
5329    // Split host:port. IPv6 literals use `[::]:port` form.
5330    let host = if let Some(stripped) = bind.strip_prefix('[') {
5331        let close = stripped
5332            .find(']')
5333            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
5334        stripped[..close].to_string()
5335    } else {
5336        bind.rsplit_once(':')
5337            .map(|(h, _)| h.to_string())
5338            .unwrap_or_else(|| bind.to_string())
5339    };
5340    use std::net::{IpAddr, ToSocketAddrs};
5341    let probe = format!("{host}:0");
5342    let resolved: Vec<_> = probe
5343        .to_socket_addrs()
5344        .with_context(|| format!("resolving bind host {host:?}"))?
5345        .collect();
5346    if resolved.is_empty() {
5347        bail!("--local-only: bind host {host:?} resolved to no addresses");
5348    }
5349    for addr in &resolved {
5350        let ip = addr.ip();
5351        let is_acceptable = match ip {
5352            IpAddr::V4(v4) => {
5353                v4.is_loopback() || v4.is_private() || {
5354                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
5355                    let octets = v4.octets();
5356                    octets[0] == 100 && (64..=127).contains(&octets[1])
5357                }
5358            }
5359            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
5360        };
5361        if !is_acceptable {
5362            bail!(
5363                "--local-only refuses non-private bind: {host:?} resolves to {ip} \
5364                 which is not loopback (127/8, ::1), RFC 1918 private \
5365                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
5366                 (100.64.0.0/10). Remove --local-only to bind publicly."
5367            );
5368        }
5369    }
5370    Ok(())
5371}
5372
5373// ---------- bind-relay ----------
5374
5375fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
5376    use crate::endpoints::EndpointScope;
5377    match s.to_lowercase().as_str() {
5378        "federation" | "fed" => Ok(EndpointScope::Federation),
5379        "local" => Ok(EndpointScope::Local),
5380        "lan" => Ok(EndpointScope::Lan),
5381        "uds" => Ok(EndpointScope::Uds),
5382        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
5383    }
5384}
5385
5386/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
5387/// appended to `self.endpoints[]`, keeping any existing slots so an agent
5388/// can hold a local relay AND a federation relay simultaneously without
5389/// black-holing pinned peers. `--replace` restores the pre-v0.12
5390/// destructive single-slot behavior (guarded by issue #7).
5391fn cmd_bind_relay(
5392    url: &str,
5393    scope: Option<&str>,
5394    replace: bool,
5395    migrate_pinned: bool,
5396    as_json: bool,
5397) -> Result<()> {
5398    use crate::endpoints::{Endpoint, self_endpoints};
5399
5400    if !config::is_initialized()? {
5401        bail!("not initialized — run `wire init <handle>` first");
5402    }
5403    let card = config::read_agent_card()?;
5404    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5405    let handle = crate::agent_card::display_handle_from_did(did).to_string();
5406
5407    let normalized_raw = url.trim_end_matches('/');
5408    // Refuse to record/publish a relay endpoint that embeds userinfo —
5409    // `https://<handle>@<host>` 4xxes every inbound event POST. Strip and
5410    // warn so operators learn the right shape without losing the call.
5411    let normalized_owned = strip_relay_url_userinfo(normalized_raw);
5412    let normalized = normalized_owned.as_str();
5413    // Belt-and-suspenders: confirm the post-strip URL is clean before any
5414    // persist / publish. A future code path that bypasses the strip filter
5415    // MUST NOT be able to leak userinfo into the signed agent-card.
5416    assert_relay_url_clean_for_publish(normalized)?;
5417    let new_scope = match scope {
5418        Some(s) => parse_scope(s)?,
5419        None => crate::endpoints::infer_scope_from_url(normalized),
5420    };
5421
5422    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
5423    let pinned: Vec<String> = existing
5424        .get("peers")
5425        .and_then(|p| p.as_object())
5426        .map(|o| o.keys().cloned().collect())
5427        .unwrap_or_default();
5428
5429    let existing_eps = self_endpoints(&existing);
5430    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
5431
5432    // Destructive paths that black-hole pinned peers (issue #7):
5433    //   • `--replace` drops every other slot.
5434    //   • re-binding the SAME relay rotates that slot in place.
5435    // An additive bind of a NEW relay keeps existing slots, so peers stay
5436    // reachable — no acknowledgement required. This is the v0.12 default
5437    // that unblocks simultaneous local + remote.
5438    let destructive = replace || is_rebind_same;
5439    if destructive && !pinned.is_empty() && !migrate_pinned {
5440        let list = pinned.join(", ");
5441        let why = if replace {
5442            "`--replace` drops your other slot(s)"
5443        } else {
5444            "re-binding the same relay rotates its slot"
5445        };
5446        bail!(
5447            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
5448             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
5449             read.\n\n\
5450             SAFE PATHS:\n\
5451             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
5452             slots — no black-hole.\n\
5453             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
5454             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
5455             peer out-of-band.\n\n\
5456             Issue #7 (silent black-hole on relay change) caught this.",
5457            n = pinned.len(),
5458        );
5459    }
5460
5461    let client = crate::relay_client::RelayClient::new(normalized);
5462    client.check_healthz()?;
5463    let alloc = client.allocate_slot(Some(&handle))?;
5464
5465    if destructive && !pinned.is_empty() {
5466        eprintln!(
5467            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
5468             until they re-pin: {peers}",
5469            mode = if replace { "replacing" } else { "rotating" },
5470            n = pinned.len(),
5471            peers = pinned.join(", "),
5472        );
5473    }
5474
5475    // Write the new slot via the single source of truth for the self-slot
5476    // shape. Additive by default; --replace starts from an empty self so
5477    // only this slot remains.
5478    let mut state = existing;
5479    if replace {
5480        state["self"] = Value::Null;
5481    }
5482    crate::endpoints::upsert_self_endpoint(
5483        &mut state,
5484        Endpoint {
5485            relay_url: normalized.to_string(),
5486            slot_id: alloc.slot_id.clone(),
5487            slot_token: alloc.slot_token.clone(),
5488            scope: new_scope,
5489        },
5490    );
5491    config::write_relay_state(&state)?;
5492    let eps = self_endpoints(&state);
5493
5494    let scope_str = format!("{new_scope:?}").to_lowercase();
5495    if as_json {
5496        println!(
5497            "{}",
5498            serde_json::to_string(&json!({
5499                "relay_url": normalized,
5500                "slot_id": alloc.slot_id,
5501                "scope": scope_str,
5502                "endpoints": eps.len(),
5503                "additive": !replace,
5504                "slot_token_present": true,
5505            }))?
5506        );
5507    } else {
5508        println!(
5509            "bound {scope_str} slot on {normalized} (slot {})",
5510            alloc.slot_id
5511        );
5512        println!(
5513            "self now has {n} endpoint(s): {list}",
5514            n = eps.len(),
5515            list = eps
5516                .iter()
5517                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
5518                .collect::<Vec<_>>()
5519                .join(", "),
5520        );
5521    }
5522    Ok(())
5523}
5524
5525// ---------- add-peer-slot ----------
5526
5527fn cmd_add_peer_slot(
5528    handle: &str,
5529    url: &str,
5530    slot_id: &str,
5531    slot_token: &str,
5532    as_json: bool,
5533) -> Result<()> {
5534    use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
5535    let mut state = config::read_relay_state()?;
5536
5537    // E3 (v0.13.2): ADD this slot to the peer's endpoint set — don't REPLACE
5538    // the whole entry. The old flat `peers.insert` clobbered an existing
5539    // peer's federation endpoint when pinning a local slot, silently dropping
5540    // the federation route (glossy-magnolia + wisp-blossom repro: pinning a
5541    // loopback slot made the peer flat loopback-only). Mirror bind-relay's
5542    // additive semantics: upsert by relay_url into the peer's endpoints[].
5543    let new_ep = Endpoint {
5544        relay_url: url.to_string(),
5545        slot_id: slot_id.to_string(),
5546        slot_token: slot_token.to_string(),
5547        scope: infer_scope_from_url(url),
5548    };
5549    let mut endpoints: Vec<Endpoint> = state
5550        .get("peers")
5551        .and_then(|p| p.get(handle))
5552        .and_then(|e| e.get("endpoints"))
5553        .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
5554        .unwrap_or_default();
5555    // Back-compat: seed from legacy flat fields when the peer predates endpoints[].
5556    if endpoints.is_empty()
5557        && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
5558        && let (Some(ru), Some(si), Some(st)) = (
5559            peer.get("relay_url").and_then(Value::as_str),
5560            peer.get("slot_id").and_then(Value::as_str),
5561            peer.get("slot_token").and_then(Value::as_str),
5562        )
5563    {
5564        endpoints.push(Endpoint {
5565            relay_url: ru.to_string(),
5566            slot_id: si.to_string(),
5567            slot_token: st.to_string(),
5568            scope: infer_scope_from_url(ru),
5569        });
5570    }
5571    // Upsert by relay_url: refresh in place if already pinned, else append.
5572    if let Some(existing) = endpoints
5573        .iter_mut()
5574        .find(|e| e.relay_url == new_ep.relay_url)
5575    {
5576        *existing = new_ep;
5577    } else {
5578        endpoints.push(new_ep);
5579    }
5580    let n = endpoints.len();
5581    pin_peer_endpoints(&mut state, handle, &endpoints)?;
5582    config::write_relay_state(&state)?;
5583    if as_json {
5584        println!(
5585            "{}",
5586            serde_json::to_string(&json!({
5587                "handle": handle,
5588                "relay_url": url,
5589                "slot_id": slot_id,
5590                "added": true,
5591                "endpoint_count": n,
5592            }))?
5593        );
5594    } else {
5595        println!(
5596            "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
5597        );
5598    }
5599    Ok(())
5600}
5601
5602// ---------- push ----------
5603
5604fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
5605    let mut state = config::read_relay_state()?;
5606    let peers = state["peers"].as_object().cloned().unwrap_or_default();
5607    if peers.is_empty() {
5608        bail!(
5609            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
5610        );
5611    }
5612    let outbox_dir = config::outbox_dir()?;
5613    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
5614    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
5615    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
5616    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
5617    if outbox_dir.exists() {
5618        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
5619        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
5620            let path = entry.path();
5621            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5622                continue;
5623            }
5624            let stem = match path.file_stem().and_then(|s| s.to_str()) {
5625                Some(s) => s.to_string(),
5626                None => continue,
5627            };
5628            if pinned.contains(&stem) {
5629                continue;
5630            }
5631            // Try the bare-handle of the orphaned stem — if THAT matches a
5632            // pinned peer, the stem is a stale FQDN-suffixed file.
5633            let bare = crate::agent_card::bare_handle(&stem);
5634            if pinned.contains(bare) {
5635                eprintln!(
5636                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
5637                     Merge with: `cat {} >> {}` then delete the FQDN file.",
5638                    stem,
5639                    path.display(),
5640                    outbox_dir.join(format!("{bare}.jsonl")).display(),
5641                );
5642            }
5643        }
5644    }
5645    if !outbox_dir.exists() {
5646        if as_json {
5647            println!(
5648                "{}",
5649                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
5650            );
5651        } else {
5652            println!("phyllis: nothing to dial out — write a message first with `wire send`");
5653        }
5654        return Ok(());
5655    }
5656
5657    let mut pushed = Vec::new();
5658    let mut skipped = Vec::new();
5659
5660    // Issue #15: track which peers we've already re-resolved this push call
5661    // so we don't whois more than once per peer per push (the rate limit the
5662    // issue specifies). Lifetime is the whole `cmd_push` invocation; clears
5663    // every time the operator (or daemon) runs `wire push` again.
5664    let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
5665    // Track whether we mutated `state` so we can write it back exactly
5666    // once at the end (avoids a write per peer).
5667    let mut state_dirty = false;
5668
5669    // v0.5.17: walk each peer's pinned endpoints in priority order (local
5670    // first if we share a local relay, federation second). Try POST on the
5671    // first endpoint; on transport failure, fall through to the next.
5672    // Falls back to the v0.5.16 legacy single-endpoint code path when the
5673    // peer record carries no `endpoints[]` array (back-compat).
5674    for (peer_handle, _) in peers.iter() {
5675        if let Some(want) = peer_filter
5676            && peer_handle != want
5677        {
5678            continue;
5679        }
5680        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5681        if !outbox.exists() {
5682            continue;
5683        }
5684        let mut ordered_endpoints =
5685            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
5686        if ordered_endpoints.is_empty() {
5687            // Unreachable peer (no federation endpoint AND our local
5688            // relay doesn't match the peer's). Skip with a loud reason
5689            // rather than silently dropping events.
5690            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
5691                let event: Value = match serde_json::from_str(line) {
5692                    Ok(v) => v,
5693                    Err(_) => continue,
5694                };
5695                let event_id = event
5696                    .get("event_id")
5697                    .and_then(Value::as_str)
5698                    .unwrap_or("")
5699                    .to_string();
5700                skipped.push(json!({
5701                    "peer": peer_handle,
5702                    "event_id": event_id,
5703                    "reason": "no reachable endpoint pinned for peer",
5704                }));
5705            }
5706            continue;
5707        }
5708        let body = std::fs::read_to_string(&outbox)?;
5709        for line in body.lines() {
5710            let event: Value = match serde_json::from_str(line) {
5711                Ok(v) => v,
5712                Err(_) => continue,
5713            };
5714            let event_id = event
5715                .get("event_id")
5716                .and_then(Value::as_str)
5717                .unwrap_or("")
5718                .to_string();
5719
5720            // Capture the most recent per-endpoint error reason via a RefCell
5721            // so we can preserve cmd_push's pre-existing "last-error wins"
5722            // semantics for the skipped-with-reason path. The shared
5723            // try_post_event_with_failover helper (from #62) handles iteration,
5724            // priority order, and early-return on first success; the closure
5725            // applies the existing `format_transport_error` formatting on
5726            // each individual error so the operator sees the same diagnostic
5727            // text as before the dedup.
5728            let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
5729            match crate::relay_client::try_post_event_with_failover(
5730                &ordered_endpoints,
5731                &event,
5732                |endpoint, ev| {
5733                    let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5734                    match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
5735                        Ok(resp) => Ok(resp),
5736                        Err(e) => {
5737                            *last_err.borrow_mut() =
5738                                Some(crate::relay_client::format_transport_error(&e));
5739                            Err(e)
5740                        }
5741                    }
5742                },
5743            ) {
5744                Ok((endpoint, resp)) => {
5745                    if resp.status == "duplicate" {
5746                        skipped.push(json!({
5747                            "peer": peer_handle,
5748                            "event_id": event_id,
5749                            "reason": "duplicate",
5750                            "endpoint": endpoint.relay_url,
5751                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5752                        }));
5753                    } else {
5754                        pushed.push(json!({
5755                            "peer": peer_handle,
5756                            "event_id": event_id,
5757                            "endpoint": endpoint.relay_url,
5758                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5759                        }));
5760                    }
5761                }
5762                Err(_) => {
5763                    // Issue #15: before reporting the event as skipped, see
5764                    // if the failure smelled like a slot-rotation (4xx 404 /
5765                    // 410). If yes AND we haven't already re-resolved this
5766                    // peer in this push call, attempt one whois lookup. On
5767                    // a real rotation, the helper updates `state.peers[peer]`
5768                    // in place; we refresh `ordered_endpoints` from the
5769                    // mutated state and retry the same event once. Composes
5770                    // with the doctor #14 staleness check from PR #68: #14
5771                    // surfaces the symptom, #15 closes the loop.
5772                    let last_err_text = last_err.borrow().clone().unwrap_or_default();
5773                    let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
5774                    match try_reresolve_peer_on_slot_4xx(
5775                        &mut state,
5776                        peer_handle,
5777                        &last_err_text,
5778                        &rotated_this_push,
5779                    ) {
5780                        Ok(true) => {
5781                            // Mark this peer as already re-resolved this push.
5782                            rotated_this_push.insert(peer_handle.clone());
5783                            state_dirty = true;
5784                            // Refresh endpoints from the updated state and
5785                            // retry exactly once. last_err is also reset so
5786                            // the retry's error (if any) replaces the prior
5787                            // one in the eventual skipped reason.
5788                            ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
5789                                &state,
5790                                peer_handle,
5791                            );
5792                            *last_err.borrow_mut() = None;
5793                            if let Ok((endpoint, resp)) =
5794                                crate::relay_client::try_post_event_with_failover(
5795                                    &ordered_endpoints,
5796                                    &event,
5797                                    |endpoint, ev| {
5798                                        let client = crate::relay_client::RelayClient::new(
5799                                            &endpoint.relay_url,
5800                                        );
5801                                        match client.post_event(
5802                                            &endpoint.slot_id,
5803                                            &endpoint.slot_token,
5804                                            ev,
5805                                        ) {
5806                                            Ok(resp) => Ok(resp),
5807                                            Err(e) => {
5808                                                *last_err.borrow_mut() = Some(
5809                                                    crate::relay_client::format_transport_error(&e),
5810                                                );
5811                                                Err(e)
5812                                            }
5813                                        }
5814                                    },
5815                                )
5816                            {
5817                                delivered_via_retry = Some((endpoint, resp));
5818                            }
5819                        }
5820                        Ok(false) => {
5821                            // Either not a slot-rotation shape, or already
5822                            // re-resolved this push, or slot id unchanged —
5823                            // fall through to the original skipped path.
5824                        }
5825                        Err(e) => {
5826                            // Re-resolve itself failed (DNS down, relay 5xx,
5827                            // handle unclaimed, etc.). Don't fail the push —
5828                            // fall through to skipped with the resolve error
5829                            // appended for diagnostic context.
5830                            *last_err.borrow_mut() = Some(format!(
5831                                "{}; re-resolve also failed: {e:#}",
5832                                last_err.borrow().clone().unwrap_or_default()
5833                            ));
5834                            // Mark as tried so we don't loop on the next event.
5835                            rotated_this_push.insert(peer_handle.clone());
5836                        }
5837                    }
5838                    if let Some((endpoint, resp)) = delivered_via_retry {
5839                        if resp.status == "duplicate" {
5840                            skipped.push(json!({
5841                                "peer": peer_handle,
5842                                "event_id": event_id,
5843                                "reason": "duplicate",
5844                                "endpoint": endpoint.relay_url,
5845                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5846                                "via": "slot_reresolve_retry",
5847                            }));
5848                        } else {
5849                            pushed.push(json!({
5850                                "peer": peer_handle,
5851                                "event_id": event_id,
5852                                "endpoint": endpoint.relay_url,
5853                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5854                                "via": "slot_reresolve_retry",
5855                            }));
5856                        }
5857                    } else {
5858                        // Every endpoint failed even after (any) retry.
5859                        // Preserve the prior "last reason is what gets
5860                        // reported" UX (the closure captured the last per-
5861                        // endpoint error via `last_err`).
5862                        skipped.push(json!({
5863                            "peer": peer_handle,
5864                            "event_id": event_id,
5865                            "reason": last_err
5866                                .borrow()
5867                                .clone()
5868                                .unwrap_or_else(|| "all endpoints failed".to_string()),
5869                        }));
5870                    }
5871                }
5872            }
5873        }
5874    }
5875
5876    // Issue #15: persist any in-place slot rotations from the per-peer loop
5877    // exactly once at the end. Best-effort: if the write fails the operator
5878    // still gets a valid push report, and the next push will re-attempt the
5879    // resolve (cheap) before retrying delivery.
5880    if state_dirty && let Err(e) = config::write_relay_state(&state) {
5881        eprintln!(
5882            "wire push: WARN failed to persist rotated peer slots: {e:#}. \
5883             Slot rotation will be re-attempted on next push."
5884        );
5885    }
5886
5887    if as_json {
5888        println!(
5889            "{}",
5890            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
5891        );
5892    } else {
5893        println!(
5894            "pushed {} event(s); skipped {} ({})",
5895            pushed.len(),
5896            skipped.len(),
5897            if skipped.is_empty() {
5898                "none"
5899            } else {
5900                "see --json for detail"
5901            }
5902        );
5903    }
5904    Ok(())
5905}
5906
5907// ---------- pull ----------
5908
5909fn cmd_pull(as_json: bool) -> Result<()> {
5910    let state = config::read_relay_state()?;
5911    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5912    if self_state.is_null() {
5913        bail!("self slot not bound — run `wire bind-relay <url>` first");
5914    }
5915
5916    // v0.5.17: pull from every endpoint in self.endpoints (federation +
5917    // optional local). Each endpoint has its own per-scope cursor so we
5918    // don't re-pull events we've already seen on that path. Events from
5919    // all endpoints feed into the same inbox JSONL via process_events;
5920    // dedup by event_id is the last line of defense.
5921    // Falls back to a single federation endpoint synthesized from the
5922    // top-level legacy fields when self.endpoints is absent (v0.5.16
5923    // back-compat).
5924    let endpoints = crate::endpoints::self_endpoints(&state);
5925    if endpoints.is_empty() {
5926        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
5927    }
5928
5929    let inbox_dir = config::inbox_dir()?;
5930    config::ensure_dirs()?;
5931
5932    let mut total_seen = 0usize;
5933    let mut all_written: Vec<Value> = Vec::new();
5934    let mut all_rejected: Vec<Value> = Vec::new();
5935    let mut all_blocked = false;
5936    let mut all_advance_cursor_to: Option<String> = None;
5937
5938    for endpoint in &endpoints {
5939        let cursor_key = endpoint_cursor_key(endpoint.scope);
5940        let last_event_id = self_state
5941            .get(&cursor_key)
5942            .and_then(Value::as_str)
5943            .map(str::to_string);
5944        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5945        let events = match client.list_events(
5946            &endpoint.slot_id,
5947            &endpoint.slot_token,
5948            last_event_id.as_deref(),
5949            Some(1000),
5950        ) {
5951            Ok(ev) => ev,
5952            Err(e) => {
5953                // One endpoint's failure shouldn't kill the whole pull.
5954                // The local-relay-down case in particular needs to
5955                // gracefully continue against federation.
5956                eprintln!(
5957                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
5958                    endpoint.relay_url,
5959                    endpoint.scope,
5960                    crate::relay_client::format_transport_error(&e),
5961                );
5962                continue;
5963            }
5964        };
5965        total_seen += events.len();
5966        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
5967        all_written.extend(result.written.iter().cloned());
5968        all_rejected.extend(result.rejected.iter().cloned());
5969        if result.blocked {
5970            all_blocked = true;
5971        }
5972        // Advance per-endpoint cursor. The cursor key is scope-specific
5973        // so federation and local don't trample each other.
5974        if let Some(eid) = result.advance_cursor_to.clone() {
5975            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
5976                all_advance_cursor_to = Some(eid.clone());
5977            }
5978            let key = cursor_key.clone();
5979            config::update_relay_state(|state| {
5980                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5981                    self_obj.insert(key, Value::String(eid));
5982                }
5983                Ok(())
5984            })?;
5985        }
5986    }
5987
5988    // Compatibility shim for the legacy single-cursor code paths below:
5989    // `result` used to come from one process_events call; we now have
5990    // per-endpoint results aggregated into the all_* accumulators.
5991    // Reconstruct a synthetic result for the remaining display logic.
5992    let result = crate::pull::PullResult {
5993        written: all_written,
5994        rejected: all_rejected,
5995        blocked: all_blocked,
5996        advance_cursor_to: all_advance_cursor_to,
5997    };
5998    let events_len = total_seen;
5999
6000    // Cursor advance happened per-endpoint above; no aggregate cursor
6001    // write needed here.
6002
6003    if as_json {
6004        println!(
6005            "{}",
6006            serde_json::to_string(&json!({
6007                "written": result.written,
6008                "rejected": result.rejected,
6009                "total_seen": events_len,
6010                "cursor_blocked": result.blocked,
6011                "cursor_advanced_to": result.advance_cursor_to,
6012            }))?
6013        );
6014    } else {
6015        let blocking = result
6016            .rejected
6017            .iter()
6018            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
6019            .count();
6020        if blocking > 0 {
6021            println!(
6022                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
6023                events_len,
6024                result.written.len(),
6025                result.rejected.len(),
6026                blocking,
6027            );
6028        } else {
6029            println!(
6030                "pulled {} event(s); wrote {}; rejected {}",
6031                events_len,
6032                result.written.len(),
6033                result.rejected.len(),
6034            );
6035        }
6036    }
6037    Ok(())
6038}
6039
6040/// v0.5.17: cursor key for an endpoint's per-scope read position.
6041/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
6042/// back-compat with on-disk relay_state files; local uses a
6043/// `_local` suffix.
6044fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
6045    match scope {
6046        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
6047        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
6048        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
6049        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
6050    }
6051}
6052
6053// ---------- rotate-slot ----------
6054
6055fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
6056    if !config::is_initialized()? {
6057        bail!("not initialized — run `wire init <handle>` first");
6058    }
6059    let mut state = config::read_relay_state()?;
6060    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
6061    if self_state.is_null() {
6062        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
6063    }
6064    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
6065    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
6066    // top-level legacy fields directly and bailed for those sessions.
6067    let primary = crate::endpoints::self_primary_endpoint(&state)
6068        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
6069    let url = primary.relay_url.clone();
6070    let old_slot_id = primary.slot_id.clone();
6071    let old_slot_token = primary.slot_token.clone();
6072
6073    // Read identity to sign the announcement.
6074    let card = config::read_agent_card()?;
6075    let did = card
6076        .get("did")
6077        .and_then(Value::as_str)
6078        .unwrap_or("")
6079        .to_string();
6080    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6081    let pk_b64 = card
6082        .get("verify_keys")
6083        .and_then(Value::as_object)
6084        .and_then(|m| m.values().next())
6085        .and_then(|v| v.get("key"))
6086        .and_then(Value::as_str)
6087        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
6088        .to_string();
6089    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
6090    let sk_seed = config::read_private_key()?;
6091
6092    // Allocate new slot on the same relay.
6093    let normalized = url.trim_end_matches('/').to_string();
6094    let client = crate::relay_client::RelayClient::new(&normalized);
6095    client
6096        .check_healthz()
6097        .context("aborting rotation; old slot still valid")?;
6098    let alloc = client.allocate_slot(Some(&handle))?;
6099    let new_slot_id = alloc.slot_id.clone();
6100    let new_slot_token = alloc.slot_token.clone();
6101
6102    // Optionally announce the rotation to every paired peer via the OLD slot.
6103    // Each peer's recipient-side `wire pull` will pick up this event before
6104    // their daemon next polls the new slot — but auto-update of peer's
6105    // relay.json from a wire_close event is a v0.2 daemon feature; for now
6106    // peers see the event and an operator must manually `add-peer-slot` the
6107    // new coords, OR re-pair via SAS.
6108    let mut announced: Vec<String> = Vec::new();
6109    if !no_announce {
6110        let now = time::OffsetDateTime::now_utc()
6111            .format(&time::format_description::well_known::Rfc3339)
6112            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6113        let body = json!({
6114            "reason": "operator-initiated slot rotation",
6115            "new_relay_url": url,
6116            "new_slot_id": new_slot_id,
6117            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
6118            // In v0.1 slot tokens are bilateral-shared, so peer can post via
6119            // existing add-peer-slot flow if operator chooses to re-issue.
6120        });
6121        let peers = state["peers"].as_object().cloned().unwrap_or_default();
6122        for (peer_handle, _peer_info) in peers.iter() {
6123            let event = json!({
6124                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6125                "timestamp": now.clone(),
6126                "from": did,
6127                "to": format!("did:wire:{peer_handle}"),
6128                "type": "wire_close",
6129                "kind": 1201,
6130                "body": body.clone(),
6131            });
6132            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
6133                Ok(s) => s,
6134                Err(e) => {
6135                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
6136                    continue;
6137                }
6138            };
6139            // Post to OUR old slot (we're announcing on our own slot, NOT
6140            // peer's slot — peer reads from us). Wait, this is wrong: peers
6141            // read from THEIR OWN slot via wire pull. To reach peer A, we
6142            // post to peer A's slot. Use the existing per-peer slot mapping.
6143            let peer_info = match state["peers"].get(peer_handle) {
6144                Some(p) => p.clone(),
6145                None => continue,
6146            };
6147            let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
6148            let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
6149            let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
6150            if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
6151                continue;
6152            }
6153            let peer_client = if peer_url == url {
6154                client.clone()
6155            } else {
6156                crate::relay_client::RelayClient::new(peer_url)
6157            };
6158            match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
6159                Ok(_) => announced.push(peer_handle.clone()),
6160                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
6161            }
6162        }
6163    }
6164
6165    // Swap the self-slot to the new one.
6166    state["self"] = json!({
6167        "relay_url": url,
6168        "slot_id": new_slot_id,
6169        "slot_token": new_slot_token,
6170    });
6171    config::write_relay_state(&state)?;
6172
6173    if as_json {
6174        println!(
6175            "{}",
6176            serde_json::to_string(&json!({
6177                "rotated": true,
6178                "old_slot_id": old_slot_id,
6179                "new_slot_id": new_slot_id,
6180                "relay_url": url,
6181                "announced_to": announced,
6182            }))?
6183        );
6184    } else {
6185        println!("rotated slot on {url}");
6186        println!(
6187            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
6188        );
6189        println!("  new slot_id: {new_slot_id}");
6190        if !announced.is_empty() {
6191            println!(
6192                "  announced wire_close (kind=1201) to: {}",
6193                announced.join(", ")
6194            );
6195        }
6196        println!();
6197        println!("next steps:");
6198        println!("  - peers see the wire_close event in their next `wire pull`");
6199        println!(
6200            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
6201        );
6202        println!("    (or full re-pair via `wire dial <handle>@<relay>`)");
6203        println!("  - until they do, you'll receive but they won't be able to reach you");
6204        // Suppress unused warning
6205        let _ = old_slot_token;
6206    }
6207    Ok(())
6208}
6209
6210// ---------- forget-peer ----------
6211
6212fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
6213    let mut trust = config::read_trust()?;
6214    let mut removed_from_trust = false;
6215    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
6216        && agents.remove(handle).is_some()
6217    {
6218        removed_from_trust = true;
6219    }
6220    config::write_trust(&trust)?;
6221
6222    let mut state = config::read_relay_state()?;
6223    let mut removed_from_relay = false;
6224    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
6225        && peers.remove(handle).is_some()
6226    {
6227        removed_from_relay = true;
6228    }
6229    config::write_relay_state(&state)?;
6230
6231    let mut purged: Vec<String> = Vec::new();
6232    if purge {
6233        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
6234            let path = dir.join(format!("{handle}.jsonl"));
6235            if path.exists() {
6236                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
6237                purged.push(path.to_string_lossy().into());
6238            }
6239        }
6240    }
6241
6242    if !removed_from_trust && !removed_from_relay {
6243        if as_json {
6244            println!(
6245                "{}",
6246                serde_json::to_string(&json!({
6247                    "removed": false,
6248                    "reason": format!("peer {handle:?} not pinned"),
6249                }))?
6250            );
6251        } else {
6252            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
6253        }
6254        return Ok(());
6255    }
6256
6257    if as_json {
6258        println!(
6259            "{}",
6260            serde_json::to_string(&json!({
6261                "handle": handle,
6262                "removed_from_trust": removed_from_trust,
6263                "removed_from_relay_state": removed_from_relay,
6264                "purged_files": purged,
6265            }))?
6266        );
6267    } else {
6268        println!("forgot peer {handle:?}");
6269        if removed_from_trust {
6270            println!("  - removed from trust.json");
6271        }
6272        if removed_from_relay {
6273            println!("  - removed from relay.json");
6274        }
6275        if !purged.is_empty() {
6276            for p in &purged {
6277                println!("  - deleted {p}");
6278            }
6279        } else if !purge {
6280            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
6281        }
6282    }
6283    Ok(())
6284}
6285
6286// ---------- daemon (long-lived push+pull sync) ----------
6287
6288fn cmd_daemon(
6289    interval_secs: u64,
6290    once: bool,
6291    all_sessions: bool,
6292    session: Option<String>,
6293    as_json: bool,
6294) -> Result<()> {
6295    // v0.14.2 (#162): supervisor mode is mutually exclusive with --once and
6296    // --session — the supervisor IS the multi-session orchestrator, and
6297    // --once is a single-cycle exit (no supervision). Surface loudly
6298    // rather than silently picking one branch.
6299    if all_sessions {
6300        if once {
6301            bail!("--all-sessions and --once are mutually exclusive (supervisor runs forever)");
6302        }
6303        if session.is_some() {
6304            bail!(
6305                "--all-sessions and --session are mutually exclusive (supervisor manages every session, not a single named one)"
6306            );
6307        }
6308        return crate::daemon_supervisor::run_supervisor(interval_secs, as_json);
6309    }
6310    // v0.14.2 (#162): pin this process's WIRE_HOME to the named session's
6311    // home dir BEFORE any config read. Used by the supervisor when it
6312    // fork-execs children, and operator-facing when running a one-session
6313    // foreground daemon outside launchd.
6314    if let Some(ref name) = session {
6315        // v0.14.2 #44: resolve via the layout-aware helper so v0.13
6316        // by-key sessions (where the on-disk dir is a hash and the
6317        // operator-typed name is the persona handle, e.g.
6318        // "coral-weasel") work as well as legacy v0.6 top-level
6319        // sessions. Pre-fix: `session_dir(name)` only resolved the
6320        // legacy form → operator running `wire daemon --session
6321        // coral-weasel` in a tmux pane saw "session not found" even
6322        // though `wire session list` clearly enumerated it.
6323        let home = crate::session::find_session_home_by_name(name)
6324            .with_context(|| format!("resolving session home for --session {name}"))?
6325            .ok_or_else(|| {
6326                anyhow!(
6327                    "session '{name}' not found — run `wire session list` to see initialized sessions"
6328                )
6329            })?;
6330        // SAFETY: cmd_daemon is the one process-lifetime entrypoint that
6331        // chooses a session. No other thread reads WIRE_HOME yet.
6332        unsafe {
6333            std::env::set_var("WIRE_HOME", &home);
6334        }
6335        if !as_json {
6336            eprintln!(
6337                "wire daemon: pinned to session '{name}' (WIRE_HOME={})",
6338                home.display()
6339            );
6340        }
6341    }
6342    if !config::is_initialized()? {
6343        bail!("not initialized — run `wire init <handle>` first");
6344    }
6345    // v0.14.2 (#162): pidfile singleton on the persistent daemon. If
6346    // another live `wire daemon` already owns the pidfile, exit 0 with a
6347    // human/JSON message instead of starting a second polling loop —
6348    // honey-pine's report observed 3 concurrent daemons polling the same
6349    // slot, wasteful and a possible source of duplicate-pull races.
6350    // `--once` is a single sync cycle and doesn't own the cursor; the
6351    // singleton check is skipped for it (matches the existing collision
6352    // warning's `--once` carve-out). Test escape hatch:
6353    // `WIRE_DAEMON_NO_SINGLETON=1`.
6354    let _pid_guard = if !once && std::env::var("WIRE_DAEMON_NO_SINGLETON").is_err() {
6355        if let Some(holder_pid) = crate::ensure_up::daemon_singleton_holder() {
6356            if as_json {
6357                println!(
6358                    "{}",
6359                    serde_json::to_string(&json!({
6360                        "status": "skipped",
6361                        "reason": "daemon already running",
6362                        "holder_pid": holder_pid,
6363                    }))?
6364                );
6365            } else {
6366                eprintln!(
6367                    "wire daemon: another daemon is already running (pid {holder_pid}); not starting a second polling loop. Set WIRE_DAEMON_NO_SINGLETON=1 to override."
6368                );
6369            }
6370            return Ok(());
6371        }
6372        Some(crate::ensure_up::claim_daemon_singleton()?)
6373    } else {
6374        None
6375    };
6376    // v0.13.x identity work: a long-running daemon racing another wire
6377    // process for the same inbox cursor silently loses messages. Surface
6378    // the collision the same way `wire mcp` does. Skipped under `--once`:
6379    // a single sync cycle is atomic and doesn't own the cursor.
6380    if !once {
6381        crate::session::warn_on_identity_collision(std::process::id(), "daemon");
6382    }
6383    let interval = std::time::Duration::from_secs(interval_secs.max(1));
6384
6385    if !as_json {
6386        if once {
6387            eprintln!("wire daemon: single sync cycle, then exit");
6388        } else {
6389            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
6390        }
6391    }
6392
6393    // Claim the daemon pidfile for this process so `wire status` / doctor /
6394    // the singleton guard can see us when started directly (not via
6395    // ensure_background). Best-effort.
6396    if let Err(e) = crate::ensure_up::write_self_daemon_pid() {
6397        eprintln!("daemon: pidfile write error: {e:#}");
6398    }
6399
6400    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
6401    // to our slot, the subscriber signals `wake_rx`; we use it as the
6402    // sleep-or-wake gate of the polling loop. Polling stays as the
6403    // safety net — stream errors fall back transparently to the existing
6404    // interval-based cadence.
6405    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
6406    if !once {
6407        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
6408    }
6409
6410    loop {
6411        let pushed = run_sync_push().unwrap_or_else(|e| {
6412            eprintln!("daemon: push error: {e:#}");
6413            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
6414        });
6415        let pulled = run_sync_pull().unwrap_or_else(|e| {
6416            eprintln!("daemon: pull error: {e:#}");
6417            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
6418        });
6419
6420        // v0.14.2 (#162): persist a `last_sync.json` record after every
6421        // cycle (including --once + cycles that pushed/pulled zero events
6422        // — the "idle daemon is alive" signal is exactly what the
6423        // detection layers need). Readers: `wire status`,
6424        // `mcp__wire__wire_status`, `mcp__wire__wire_send` annotations.
6425        // Best-effort: errors log + don't abort the loop.
6426        let cycle_push_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
6427        let cycle_pull_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
6428        let cycle_rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
6429        crate::ensure_up::write_last_sync_record(cycle_push_n, cycle_pull_n, cycle_rejected_n);
6430
6431        if as_json {
6432            println!(
6433                "{}",
6434                serde_json::to_string(&json!({
6435                    "ts": time::OffsetDateTime::now_utc()
6436                        .format(&time::format_description::well_known::Rfc3339)
6437                        .unwrap_or_default(),
6438                    "push": pushed,
6439                    "pull": pulled,
6440                }))?
6441            );
6442        } else if cycle_push_n > 0 || cycle_pull_n > 0 || cycle_rejected_n > 0 {
6443            eprintln!(
6444                "daemon: pushed={cycle_push_n} pulled={cycle_pull_n} rejected={cycle_rejected_n}"
6445            );
6446        }
6447
6448        if once {
6449            return Ok(());
6450        }
6451        // Wait either for the next poll-interval tick OR for a stream
6452        // wake signal — whichever comes first. Drain any additional
6453        // wake-ups that accumulated during the previous cycle since one
6454        // pull catches up everything.
6455        //
6456        // v0.13.2 (wisp-blossom): if the stream subscriber thread has gone
6457        // away, `wake_rx` is Disconnected and `recv_timeout` returns
6458        // INSTANTLY — which would busy-spin the sync loop (hammering push/pull
6459        // + the relay with zero delay). Fall back to a plain sleep so a dead
6460        // stream degrades to normal polling and never kills or pegs the
6461        // daemon. (Realizes the "decouple stream from sync" hardening — a
6462        // stream failure must never affect the push/pull loop.)
6463        match wake_rx.recv_timeout(interval) {
6464            Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
6465            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
6466                std::thread::sleep(interval);
6467            }
6468        }
6469        while wake_rx.try_recv().is_ok() {}
6470    }
6471}
6472
6473/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
6474/// shape `wire push --json` emits.
6475fn run_sync_push() -> Result<Value> {
6476    let state = config::read_relay_state()?;
6477    let peers = state["peers"].as_object().cloned().unwrap_or_default();
6478    if peers.is_empty() {
6479        return Ok(json!({"pushed": [], "skipped": []}));
6480    }
6481    let outbox_dir = config::outbox_dir()?;
6482    if !outbox_dir.exists() {
6483        return Ok(json!({"pushed": [], "skipped": []}));
6484    }
6485    let mut pushed = Vec::new();
6486    let mut skipped = Vec::new();
6487    for (peer_handle, slot_info) in peers.iter() {
6488        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
6489        if !outbox.exists() {
6490            continue;
6491        }
6492        let url = slot_info["relay_url"].as_str().unwrap_or("");
6493        let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
6494        let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
6495        if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
6496            continue;
6497        }
6498        let client = crate::relay_client::RelayClient::new(url);
6499        let body = std::fs::read_to_string(&outbox)?;
6500        for line in body.lines() {
6501            let event: Value = match serde_json::from_str(line) {
6502                Ok(v) => v,
6503                Err(_) => continue,
6504            };
6505            let event_id = event
6506                .get("event_id")
6507                .and_then(Value::as_str)
6508                .unwrap_or("")
6509                .to_string();
6510            match client.post_event(slot_id, slot_token, &event) {
6511                Ok(resp) => {
6512                    // v0.14.2 (#162 fix #2): record the queued → pushed
6513                    // transition in the per-peer lifecycle log. Both
6514                    // `ok` and `duplicate` count as pushed — the relay
6515                    // has the event either way, and an operator who
6516                    // hits the dedup path didn't lose the event. Failure
6517                    // here is non-fatal: the sync loop must keep
6518                    // running even if the lifecycle log can't be
6519                    // appended.
6520                    let now = time::OffsetDateTime::now_utc()
6521                        .format(&time::format_description::well_known::Rfc3339)
6522                        .unwrap_or_default();
6523                    if let Err(e) = config::append_pushed_log(peer_handle, &event_id, &now) {
6524                        eprintln!(
6525                            "daemon: pushed-log append for {peer_handle}/{event_id} failed (non-fatal): {e:#}"
6526                        );
6527                    }
6528                    if resp.status == "duplicate" {
6529                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
6530                    } else {
6531                        pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
6532                    }
6533                }
6534                Err(e) => {
6535                    // v0.5.13: flatten the anyhow chain so TLS / DNS / timeout
6536                    // errors aren't hidden behind the topmost-context URL string.
6537                    // Issue #6 highest-impact silent-fail fix.
6538                    let reason = crate::relay_client::format_transport_error(&e);
6539                    skipped
6540                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
6541                }
6542            }
6543        }
6544    }
6545    Ok(json!({"pushed": pushed, "skipped": skipped}))
6546}
6547
6548/// Programmatic pull. Same shape as `wire pull --json`.
6549///
6550/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
6551/// created via `wire session new --with-local` (which only writes
6552/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
6553/// Pre-v0.9 this function read only the top-level fields and silently
6554/// returned `{}` for any v0.5.17+ session.
6555pub fn run_sync_pull() -> Result<Value> {
6556    let state = config::read_relay_state()?;
6557    if state.get("self").map(Value::is_null).unwrap_or(true) {
6558        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
6559    }
6560    // E2 (v0.13.2): pull EVERY self endpoint, not just the primary. A session
6561    // that bound a local slot (additive) alongside its federation slot used to
6562    // have the daemon pull ONLY the primary (federation) endpoint — the local
6563    // slot was never serviced, so same-box loopback delivery silently never
6564    // happened until a manual restart re-seeded the (startup-only) stream
6565    // subscriber. Now each endpoint is pulled with its OWN cursor.
6566    let endpoints = crate::endpoints::self_endpoints(&state);
6567    if endpoints.is_empty() {
6568        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
6569    }
6570    let inbox_dir = config::inbox_dir()?;
6571    config::ensure_dirs()?;
6572
6573    // Per-slot cursors live at `self.cursors.<slot_id>`. The legacy global
6574    // `self.last_pulled_event_id` is migrated as the cursor for the PRIMARY
6575    // slot only (a federation event id won't match a local slot's log); other
6576    // slots start from None and `process_events` dedups against the inbox.
6577    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
6578    let legacy_cursor = self_obj
6579        .get("last_pulled_event_id")
6580        .and_then(Value::as_str)
6581        .map(str::to_string);
6582    let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
6583    let mut cursors: serde_json::Map<String, Value> = self_obj
6584        .get("cursors")
6585        .and_then(Value::as_object)
6586        .cloned()
6587        .unwrap_or_default();
6588
6589    let mut all_written: Vec<Value> = Vec::new();
6590    let mut all_rejected: Vec<Value> = Vec::new();
6591    let mut total_seen = 0usize;
6592    let mut blocked_any = false;
6593
6594    for ep in &endpoints {
6595        if ep.relay_url.is_empty() {
6596            continue;
6597        }
6598        let cursor = cursors
6599            .get(&ep.slot_id)
6600            .and_then(Value::as_str)
6601            .map(str::to_string)
6602            .or_else(|| {
6603                if Some(&ep.slot_id) == primary_slot.as_ref() {
6604                    legacy_cursor.clone()
6605                } else {
6606                    None
6607                }
6608            });
6609        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
6610        // One endpoint erroring (relay down, slot gone) must NOT stop the
6611        // others — a dead local relay shouldn't black-hole federation pulls.
6612        let events =
6613            match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
6614                Ok(e) => e,
6615                Err(e) => {
6616                    eprintln!(
6617                        "daemon: pull error on {} slot {} (continuing): {e:#}",
6618                        ep.relay_url, ep.slot_id
6619                    );
6620                    continue;
6621                }
6622            };
6623        total_seen += events.len();
6624        // P0.1 shared cursor-blocking logic (matches `wire pull`). A block on
6625        // one slot only stalls THAT slot's cursor; other slots keep flowing.
6626        let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
6627        if let Some(eid) = &result.advance_cursor_to {
6628            cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
6629        }
6630        blocked_any |= result.blocked;
6631        all_written.extend(result.written);
6632        all_rejected.extend(result.rejected);
6633    }
6634
6635    // P0.3 flock-protected RMW: persist per-slot cursors + keep the legacy
6636    // global cursor in sync with the primary slot for back-compat with older
6637    // binaries that only read `last_pulled_event_id`.
6638    let primary_cursor = primary_slot
6639        .as_ref()
6640        .and_then(|s| cursors.get(s))
6641        .and_then(Value::as_str)
6642        .map(str::to_string);
6643    // v0.14.3 (#14): group `written` by sender handle, take max
6644    // timestamp, write to `peers[<handle>].last_inbound_event_at`.
6645    // RFC3339-comparable as lex sort (same offset, ISO 8601). This
6646    // is the daemon-written signal `check_peer_staleness` needs —
6647    // robust against backup/restore/`touch` that breaks inbox-mtime
6648    // detection. Additive field: pre-v0.14.3 readers ignore it,
6649    // older daemons just don't write it.
6650    let mut latest_inbound: std::collections::HashMap<String, String> =
6651        std::collections::HashMap::new();
6652    for w in &all_written {
6653        let from = match w.get("from").and_then(Value::as_str) {
6654            Some(s) => s.to_string(),
6655            None => continue,
6656        };
6657        let ts = match w.get("timestamp").and_then(Value::as_str) {
6658            Some(s) if !s.is_empty() => s.to_string(),
6659            _ => continue,
6660        };
6661        latest_inbound
6662            .entry(from)
6663            .and_modify(|existing| {
6664                if ts > *existing {
6665                    *existing = ts.clone();
6666                }
6667            })
6668            .or_insert(ts);
6669    }
6670    config::update_relay_state(|state| {
6671        if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
6672            self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
6673            if let Some(pc) = &primary_cursor {
6674                self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
6675            }
6676        }
6677        if !latest_inbound.is_empty()
6678            && let Some(peers_obj) = state.get_mut("peers").and_then(Value::as_object_mut)
6679        {
6680            for (handle, ts) in &latest_inbound {
6681                let entry = peers_obj.entry(handle.clone()).or_insert_with(|| json!({}));
6682                if let Some(obj) = entry.as_object_mut() {
6683                    obj.insert("last_inbound_event_at".into(), Value::String(ts.clone()));
6684                }
6685            }
6686        }
6687        Ok(())
6688    })?;
6689
6690    Ok(json!({
6691        "written": all_written,
6692        "rejected": all_rejected,
6693        "total_seen": total_seen,
6694        "cursor_blocked": blocked_any,
6695        "endpoints_pulled": endpoints.len(),
6696    }))
6697}
6698
6699// ---------- pin (manual out-of-band peer pairing) ----------
6700
6701fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
6702    let body =
6703        std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
6704    let card: Value =
6705        serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
6706    crate::agent_card::verify_agent_card(&card)
6707        .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
6708
6709    let mut trust = config::read_trust()?;
6710    crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
6711
6712    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
6713    let handle = crate::agent_card::display_handle_from_did(did).to_string();
6714    config::write_trust(&trust)?;
6715
6716    if as_json {
6717        println!(
6718            "{}",
6719            serde_json::to_string(&json!({
6720                "handle": handle,
6721                "did": did,
6722                "tier": "VERIFIED",
6723                "pinned": true,
6724            }))?
6725        );
6726    } else {
6727        println!("pinned {handle} ({did}) at tier VERIFIED");
6728    }
6729    Ok(())
6730}
6731
6732// ---------- invite / accept — one-paste pair (v0.4.0) ----------
6733
6734fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6735    let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6736
6737    // If --share, register the invite at the relay's short-URL endpoint and
6738    // build the one-curl onboarding line for the peer to paste.
6739    let share_payload: Option<Value> = if share {
6740        let client = reqwest::blocking::Client::new();
6741        let single_use = if uses == 1 { Some(1u32) } else { None };
6742        let body = json!({
6743            "invite_url": url,
6744            "ttl_seconds": ttl,
6745            "uses": single_use,
6746        });
6747        let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6748        let resp = client.post(&endpoint).json(&body).send()?;
6749        if !resp.status().is_success() {
6750            let code = resp.status();
6751            let txt = resp.text().unwrap_or_default();
6752            bail!("relay {code} on /v1/invite/register: {txt}");
6753        }
6754        let parsed: Value = resp.json()?;
6755        let token = parsed
6756            .get("token")
6757            .and_then(Value::as_str)
6758            .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6759            .to_string();
6760        let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6761        let curl_line = format!("curl -fsSL {share_url} | sh");
6762        Some(json!({
6763            "token": token,
6764            "share_url": share_url,
6765            "curl": curl_line,
6766            "expires_unix": parsed.get("expires_unix"),
6767        }))
6768    } else {
6769        None
6770    };
6771
6772    if as_json {
6773        let mut out = json!({
6774            "invite_url": url,
6775            "ttl_secs": ttl,
6776            "uses": uses,
6777            "relay": relay,
6778        });
6779        if let Some(s) = &share_payload {
6780            out["share"] = s.clone();
6781        }
6782        println!("{}", serde_json::to_string(&out)?);
6783    } else if let Some(s) = share_payload {
6784        let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6785        eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6786        eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6787        println!("{curl}");
6788    } else {
6789        eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6790        eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6791        println!("{url}");
6792    }
6793    Ok(())
6794}
6795
6796fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6797    // If the user pasted an HTTP(S) short URL (e.g. https://wireup.net/i/AB12),
6798    // resolve it to the underlying wire://pair?... URL via ?format=url before
6799    // accepting. Saves them from having to know which URL shape goes where.
6800    let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6801        let sep = if url.contains('?') { '&' } else { '?' };
6802        let resolve_url = format!("{url}{sep}format=url");
6803        let client = reqwest::blocking::Client::new();
6804        let resp = client
6805            .get(&resolve_url)
6806            .send()
6807            .with_context(|| format!("GET {resolve_url}"))?;
6808        if !resp.status().is_success() {
6809            bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6810        }
6811        let body = resp.text().unwrap_or_default().trim().to_string();
6812        if !body.starts_with("wire://pair?") {
6813            bail!(
6814                "short URL {url} did not resolve to a wire:// invite. \
6815                 (got: {}{})",
6816                body.chars().take(80).collect::<String>(),
6817                if body.chars().count() > 80 { "…" } else { "" }
6818            );
6819        }
6820        body
6821    } else {
6822        url.to_string()
6823    };
6824
6825    let result = crate::pair_invite::accept_invite(&resolved)?;
6826    if as_json {
6827        println!("{}", serde_json::to_string(&result)?);
6828    } else {
6829        let did = result
6830            .get("paired_with")
6831            .and_then(Value::as_str)
6832            .unwrap_or("?");
6833        println!("paired with {did}");
6834        println!(
6835            "you can now: wire send {} <kind> <body>",
6836            crate::agent_card::display_handle_from_did(did)
6837        );
6838    }
6839    Ok(())
6840}
6841
6842// ---------- whois / profile (v0.5) ----------
6843
6844fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6845    if let Some(h) = handle {
6846        let parsed = crate::pair_profile::parse_handle(h)?;
6847        // Special-case: if the supplied handle matches our own, skip the
6848        // network round-trip and print local.
6849        if config::is_initialized()? {
6850            let card = config::read_agent_card()?;
6851            let local_handle = card
6852                .get("profile")
6853                .and_then(|p| p.get("handle"))
6854                .and_then(Value::as_str)
6855                .map(str::to_string);
6856            if local_handle.as_deref() == Some(h) {
6857                return cmd_whois(None, as_json, None);
6858            }
6859        }
6860        // Remote resolution via .well-known/wire/agent on the handle's domain.
6861        let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6862        if as_json {
6863            println!("{}", serde_json::to_string(&resolved)?);
6864        } else {
6865            print_resolved_profile(&resolved);
6866        }
6867        return Ok(());
6868    }
6869    let card = config::read_agent_card()?;
6870    if as_json {
6871        let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6872        let mut payload = serde_json::Map::new();
6873        payload.insert(
6874            "did".into(),
6875            card.get("did").cloned().unwrap_or(Value::Null),
6876        );
6877        payload.insert("profile".into(), profile);
6878        // v0.14: surface inline op claims on self-whois too, for parity
6879        // with `wire whoami --json`. Single mental model across read
6880        // verbs; absent ⇒ not enrolled.
6881        for (k, v) in op_claims_from_card(&card) {
6882            payload.insert(k, v);
6883        }
6884        println!("{}", serde_json::to_string(&payload)?);
6885    } else {
6886        print!("{}", crate::pair_profile::render_self_summary()?);
6887    }
6888    Ok(())
6889}
6890
6891fn print_resolved_profile(resolved: &Value) {
6892    let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6893    let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6894    let relay = resolved
6895        .get("relay_url")
6896        .and_then(Value::as_str)
6897        .unwrap_or("");
6898    let slot = resolved
6899        .get("slot_id")
6900        .and_then(Value::as_str)
6901        .unwrap_or("");
6902    let profile = resolved
6903        .get("card")
6904        .and_then(|c| c.get("profile"))
6905        .cloned()
6906        .unwrap_or(Value::Null);
6907    println!("{did}");
6908    println!("  nick:         {nick}");
6909    if !relay.is_empty() {
6910        println!("  relay_url:    {relay}");
6911    }
6912    if !slot.is_empty() {
6913        println!("  slot_id:      {slot}");
6914    }
6915    let pick =
6916        |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6917    if let Some(s) = pick("display_name") {
6918        println!("  display_name: {s}");
6919    }
6920    if let Some(s) = pick("emoji") {
6921        println!("  emoji:        {s}");
6922    }
6923    if let Some(s) = pick("motto") {
6924        println!("  motto:        {s}");
6925    }
6926    if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6927        let joined: Vec<String> = arr
6928            .iter()
6929            .filter_map(|v| v.as_str().map(str::to_string))
6930            .collect();
6931        println!("  vibe:         {}", joined.join(", "));
6932    }
6933    if let Some(s) = pick("pronouns") {
6934        println!("  pronouns:     {s}");
6935    }
6936}
6937
6938/// `wire add <nick@domain>` — zero-paste pair. Resolve handle, build a
6939/// signed pair_drop event with our card + slot coords, deliver via the
6940/// peer relay's `/v1/handle/intro/<nick>` endpoint (no slot_token needed).
6941/// Peer's daemon completes the bilateral pin on its next pull and emits a
6942/// pair_drop_ack carrying their slot_token so we can send back.
6943/// Extract just the host portion from `https://host:port/path` → `host`.
6944/// Returns empty string if the URL is malformed.
6945fn host_of_url(url: &str) -> String {
6946    let no_scheme = url
6947        .trim_start_matches("https://")
6948        .trim_start_matches("http://");
6949    no_scheme
6950        .split('/')
6951        .next()
6952        .unwrap_or("")
6953        .split(':')
6954        .next()
6955        .unwrap_or("")
6956        .to_string()
6957}
6958
6959/// v0.5.19 (#9.4): is this relay domain on the known-good list, or the
6960/// operator's own relay? Used to suppress the cross-relay phishing
6961/// warning in `wire add` for the happy path.
6962fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6963    // Hard-coded known-good list. wireup.net is the default relay.
6964    const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6965    let peer_domain = peer_domain.trim().to_ascii_lowercase();
6966    if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6967        return true;
6968    }
6969    // Operator's OWN relay is implicitly trusted — they're already
6970    // bound to it; pairing same-relay peers is the common case.
6971    let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6972    if !our_host.is_empty() && our_host == peer_domain {
6973        return true;
6974    }
6975    false
6976}
6977
6978/// v0.6.6: pair with a sister session on this machine without federation.
6979/// Reads the sister's agent-card + endpoints from disk, pins them into our
6980/// trust + relay_state, builds the same `pair_drop` event the federation
6981/// path would emit, then POSTs it directly to the sister's local-relay slot.
6982/// No `.well-known/wire/agent` resolution. Reserved-nick sessions (like
6983/// the cwd-derived `wire`) are addressable because the local relay never
6984/// needed a public claim for sister coordination.
6985/// v0.7.0-alpha.2/3: resolve an input (session name or character nickname)
6986/// to a local sister session.
6987///
6988/// `wire add --local-sister <name-or-nickname>` and adjacent commands take
6989/// either form. Exact session-name matches always win; nickname matches
6990/// are a fallback so operators can type "winter-bay" instead of "wire".
6991/// When a nickname is ambiguous (two sessions share it, e.g. auto-derived
6992/// for one + override on another), returns `Err(ResolveError::Ambiguous)`
6993/// with the candidate list so the caller can surface a disambiguation
6994/// hint instead of silently picking one.
6995fn resolve_local_session<'a>(
6996    sessions: &'a [crate::session::SessionInfo],
6997    input: &str,
6998) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6999    // Exact session-name match always wins, even if a nickname elsewhere
7000    // also matches. Predictable for scripts and operator muscle memory.
7001    if let Some(s) = sessions.iter().find(|s| s.name == input) {
7002        return Ok(s);
7003    }
7004    let nick_matches: Vec<&crate::session::SessionInfo> = sessions
7005        .iter()
7006        .filter(|s| {
7007            s.character
7008                .as_ref()
7009                .map(|c| c.nickname == input)
7010                .unwrap_or(false)
7011        })
7012        .collect();
7013    match nick_matches.len() {
7014        0 => Err(ResolveError::NotFound),
7015        1 => Ok(nick_matches[0]),
7016        _ => Err(ResolveError::Ambiguous(
7017            nick_matches.iter().map(|s| s.name.clone()).collect(),
7018        )),
7019    }
7020}
7021
7022#[derive(Debug)]
7023enum ResolveError {
7024    NotFound,
7025    Ambiguous(Vec<String>),
7026}
7027
7028/// v0.7.0-alpha.2/.5: resolve a peer input (handle or character nickname)
7029/// to a pinned peer's canonical handle.
7030///
7031/// `wire send <peer>` accepts either the handle the peer registered with
7032/// or their character nickname (DID-hash-derived). Exact handle match
7033/// always wins. When a nickname matches multiple peers (theoretically
7034/// possible via DID-hash collision in the (adj, noun) space), returns
7035/// `Ambiguous` so the caller can surface a disambiguation hint instead
7036/// of silently picking one.
7037///
7038/// Only AUTO-DERIVED peer characters are matchable; operator-chosen
7039/// overrides on the peer's side live in their local `display.json` and
7040/// aren't yet published via agent-card. (That's the v0.7+ federation
7041/// lifecycle work — peers publishing overrides so we resolve by what
7042/// they call themselves, not just what their DID hashes to.)
7043fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
7044    let trust = match config::read_trust() {
7045        Ok(t) => t,
7046        Err(_) => return Ok(None),
7047    };
7048    let agents = match trust.get("agents").and_then(|a| a.as_object()) {
7049        Some(a) => a,
7050        None => return Ok(None),
7051    };
7052    if agents.contains_key(input) {
7053        return Ok(Some(input.to_string()));
7054    }
7055    let mut nick_matches: Vec<String> = Vec::new();
7056    for (handle, agent) in agents.iter() {
7057        // v0.7.0-alpha.6: prefer peer's published display nickname over
7058        // auto-derived. Allows `wire send <their-chosen-name>` not just
7059        // `wire send <their-did-hash-derived-name>`.
7060        let character = match agent.get("card") {
7061            Some(card) => crate::character::Character::from_card(card),
7062            None => match agent.get("did").and_then(Value::as_str) {
7063                Some(did) => crate::character::Character::from_did(did),
7064                None => continue,
7065            },
7066        };
7067        if character.nickname == input {
7068            nick_matches.push(handle.clone());
7069        }
7070    }
7071    match nick_matches.len() {
7072        0 => Ok(None),
7073        1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
7074        _ => Err(ResolveError::Ambiguous(nick_matches)),
7075    }
7076}
7077
7078fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
7079    // 1. Locate sister session by name OR character nickname.
7080    let sessions = crate::session::list_sessions()?;
7081    let sister = match resolve_local_session(&sessions, sister_name) {
7082        Ok(s) => s,
7083        Err(ResolveError::NotFound) => bail!(
7084            "no sister session named `{sister_name}` (matched by session name or character nickname). \
7085             Run `wire session list` to see what's available."
7086        ),
7087        Err(ResolveError::Ambiguous(candidates)) => bail!(
7088            "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
7089             Disambiguate by passing the session name (one of those listed) instead of the nickname.",
7090            candidates.len(),
7091            candidates.join(", ")
7092        ),
7093    };
7094    // If we matched via nickname (not exact name), surface that so the
7095    // operator sees what we resolved to. Quiet when names match exactly.
7096    if sister.name != sister_name {
7097        eprintln!(
7098            "wire add: resolved nickname `{sister_name}` → session `{}`",
7099            sister.name
7100        );
7101    }
7102
7103    // 2. Refuse self-pair — operator owns both sides, but a self-loop
7104    // breaks the bilateral state machine.
7105    let our_card = config::read_agent_card()
7106        .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
7107    let our_did = our_card
7108        .get("did")
7109        .and_then(Value::as_str)
7110        .ok_or_else(|| anyhow!("agent-card missing did"))?
7111        .to_string();
7112    if let Some(sister_did) = sister.did.as_deref()
7113        && sister_did == our_did
7114    {
7115        bail!("refusing to add self (`{sister_name}` is this very session)");
7116    }
7117
7118    // 3. Read sister's agent-card + relay state from disk.
7119    let sister_card_path = sister
7120        .home_dir
7121        .join("config")
7122        .join("wire")
7123        .join("agent-card.json");
7124    let sister_card: Value = serde_json::from_slice(
7125        &std::fs::read(&sister_card_path)
7126            .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
7127    )
7128    .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
7129    let sister_relay_state: Value = std::fs::read(
7130        sister
7131            .home_dir
7132            .join("config")
7133            .join("wire")
7134            .join("relay.json"),
7135    )
7136    .ok()
7137    .and_then(|b| serde_json::from_slice(&b).ok())
7138    .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7139
7140    let sister_did = sister_card
7141        .get("did")
7142        .and_then(Value::as_str)
7143        .ok_or_else(|| anyhow!("sister card missing did"))?
7144        .to_string();
7145    let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
7146
7147    // Pull sister's full endpoint set; we want the local one for delivery
7148    // and we'll pin all of them so OUR pushes prefer local-first per the
7149    // existing routing logic.
7150    let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
7151    if sister_endpoints.is_empty() {
7152        bail!(
7153            "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
7154        );
7155    }
7156    let sister_local = sister_endpoints
7157        .iter()
7158        .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
7159    let delivery_endpoint = match sister_local {
7160        Some(e) => e.clone(),
7161        None => sister_endpoints[0].clone(),
7162    };
7163
7164    // 4. Ensure WE have a slot to advertise back. For local-only sessions
7165    // this is the local slot; for dual-slot sessions, federation is fine.
7166    // `ensure_self_with_relay(None)` defaults to wireup.net which is wrong
7167    // for pure local-only — instead, pick our own existing federation
7168    // endpoint if present, else fall back to whatever's first.
7169    let our_relay_state = config::read_relay_state()?;
7170    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7171    if our_endpoints.is_empty() {
7172        bail!(
7173            "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
7174        );
7175    }
7176    let our_advertised = our_endpoints
7177        .iter()
7178        .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
7179        .cloned()
7180        .unwrap_or_else(|| our_endpoints[0].clone());
7181
7182    // 5. Pin sister into our trust (VERIFIED — operator-owned siblings) +
7183    // relay_state.peers with their full endpoint set. slot_token lands
7184    // via pair_drop_ack as usual.
7185    let mut trust = config::read_trust()?;
7186    crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
7187    config::write_trust(&trust)?;
7188    let mut relay_state = config::read_relay_state()?;
7189    crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
7190    config::write_relay_state(&relay_state)?;
7191
7192    // 6. Build the same pair_drop event the federation path emits, with
7193    // our card + endpoints in the body so the sister can pin us back.
7194    let sk_seed = config::read_private_key()?;
7195    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7196    let pk_b64 = our_card
7197        .get("verify_keys")
7198        .and_then(Value::as_object)
7199        .and_then(|m| m.values().next())
7200        .and_then(|v| v.get("key"))
7201        .and_then(Value::as_str)
7202        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7203    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7204    let now = time::OffsetDateTime::now_utc()
7205        .format(&time::format_description::well_known::Rfc3339)
7206        .unwrap_or_default();
7207    let mut body = json!({
7208        "card": our_card,
7209        "relay_url": our_advertised.relay_url,
7210        "slot_id": our_advertised.slot_id,
7211        "slot_token": our_advertised.slot_token,
7212    });
7213    body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7214    let event = json!({
7215        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7216        "timestamp": now,
7217        "from": our_did,
7218        "to": sister_did,
7219        "type": "pair_drop",
7220        "kind": 1100u32,
7221        "body": body,
7222    });
7223    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7224    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7225
7226    // 7. Deliver direct to sister's local slot. Skip /v1/handle/intro
7227    // (the federation handle indexer) — we already know the slot coords
7228    // from disk, so post_event is sufficient.
7229    let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
7230    client
7231        .post_event(
7232            &delivery_endpoint.slot_id,
7233            &delivery_endpoint.slot_token,
7234            &signed,
7235        )
7236        .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
7237
7238    if as_json {
7239        println!(
7240            "{}",
7241            serde_json::to_string(&json!({
7242                "handle": sister_name,
7243                "paired_with": sister_did,
7244                "peer_handle": sister_handle,
7245                "event_id": event_id,
7246                "delivered_via": match delivery_endpoint.scope {
7247                    crate::endpoints::EndpointScope::Local => "local",
7248                    crate::endpoints::EndpointScope::Lan => "lan",
7249                    crate::endpoints::EndpointScope::Uds => "uds",
7250                    crate::endpoints::EndpointScope::Federation => "federation",
7251                },
7252                "status": "drop_sent",
7253            }))?
7254        );
7255    } else {
7256        let scope = match delivery_endpoint.scope {
7257            crate::endpoints::EndpointScope::Local => "local",
7258            crate::endpoints::EndpointScope::Lan => "lan",
7259            crate::endpoints::EndpointScope::Uds => "uds",
7260            crate::endpoints::EndpointScope::Federation => "federation",
7261        };
7262        println!(
7263            "→ 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.",
7264            delivery_endpoint.relay_url
7265        );
7266    }
7267    Ok(())
7268}
7269
7270fn cmd_add(
7271    handle_arg: &str,
7272    relay_override: Option<&str>,
7273    local_sister: bool,
7274    as_json: bool,
7275) -> Result<()> {
7276    // v0.7.4: nickname-friendly local-sister resolution. Whether the
7277    // operator passed `--local-sister` explicitly OR just typed a bare
7278    // name (no `@<relay>`), try to resolve through the local sessions
7279    // registry so character nicknames AND session names AND card
7280    // handles all work as input. Closes the "I only know this peer by
7281    // its character name" ergonomic gap that forced operators into
7282    // `wire session list-local | grep <nick> | awk` dances.
7283    if local_sister {
7284        let resolved = crate::session::resolve_local_sister(handle_arg)
7285            .unwrap_or_else(|| handle_arg.to_string());
7286        return cmd_add_local_sister(&resolved, as_json);
7287    }
7288    if !handle_arg.contains('@')
7289        && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
7290    {
7291        eprintln!(
7292            "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
7293             — routing via --local-sister (disk-read card, no relay lookup)."
7294        );
7295        return cmd_add_local_sister(&resolved, as_json);
7296    }
7297    if !handle_arg.contains('@') {
7298        bail!(
7299            "`{handle_arg}` doesn't match any local sister session and has no \
7300             @<relay> suffix for federation.\n\
7301             — Local sisters: `wire session list-local` (operator types name OR \
7302             character nickname)\n\
7303             — Federation:    `wire add <handle>@<relay-domain>` (e.g. \
7304             `wire add alice@wireup.net`)"
7305        );
7306    }
7307    let parsed = crate::pair_profile::parse_handle(handle_arg)?;
7308
7309    // 1. Auto-init self if needed + ensure a relay slot.
7310    let (our_did, our_relay, our_slot_id, our_slot_token) =
7311        crate::pair_invite::ensure_self_with_relay(relay_override)?;
7312    if our_did == format!("did:wire:{}", parsed.nick) {
7313        // Lazy guard — actual self-add would also be caught by FCFS later.
7314        bail!("refusing to add self (handle matches own DID)");
7315    }
7316
7317    // v0.5.14 bilateral-completion path: if a pair_drop from this peer is
7318    // already sitting in pending-inbound, the operator is now accepting it.
7319    // Pin trust, save relay coords + slot_token from the stored drop, ship
7320    // our own slot_token back via pair_drop_ack, delete the pending record.
7321    //
7322    // This branch is the OTHER half of the v0.5.14 fix to maybe_consume_pair_drop:
7323    // receiver-side auto-promote was removed there; operator consent flows
7324    // through here. After this branch returns, both sides are bilaterally
7325    // pinned and capability flows in both directions.
7326    if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
7327        return cmd_add_accept_pending(
7328            handle_arg,
7329            &parsed.nick,
7330            &pending,
7331            &our_relay,
7332            &our_slot_id,
7333            &our_slot_token,
7334            as_json,
7335        );
7336    }
7337
7338    // v0.5.19 (#9.4): cross-relay phishing guardrail.
7339    //
7340    // Threat: operator wants to add `boss@wireup.net` but types
7341    // `boss@evil-relay.example` (typo, malicious link, look-alike domain).
7342    // The .well-known resolution returns whoever claimed the nick on the
7343    // *typo* relay, the bilateral gate still completes (the attacker
7344    // accepts the pair on their side), and the operator pins the
7345    // attacker as "boss". v0.5.14 bilateral gate doesn't catch this —
7346    // there's no asymmetry to detect when the attacker WANTS to be
7347    // paired.
7348    //
7349    // Mitigation: warn loudly when the peer's relay domain is novel
7350    // (not the operator's own relay, not in a small known-good set).
7351    // Doesn't block — operators have legitimate reasons to pair across
7352    // relays. The signal lands in shell history so a phished operator
7353    // can find it in retrospect.
7354    if !is_known_relay_domain(&parsed.domain, &our_relay) {
7355        eprintln!(
7356            "wire add: WARN unfamiliar relay domain `{}`.",
7357            parsed.domain
7358        );
7359        eprintln!(
7360            "  This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
7361            host_of_url(&our_relay)
7362        );
7363        eprintln!(
7364            "  and not on the known-good list. If you meant `{}@wireup.net`, ",
7365            parsed.nick
7366        );
7367        eprintln!(
7368            "  run `wire add {}@wireup.net` instead. Otherwise verify with your",
7369            parsed.nick
7370        );
7371        eprintln!("  peer out-of-band that they actually run a relay at this domain");
7372        eprintln!("  before relying on the pair. (See issue #9.4.)");
7373    }
7374
7375    // 2. Resolve peer via .well-known on their relay.
7376    let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
7377    let peer_card = resolved
7378        .get("card")
7379        .cloned()
7380        .ok_or_else(|| anyhow!("resolved missing card"))?;
7381    let peer_did = resolved
7382        .get("did")
7383        .and_then(Value::as_str)
7384        .ok_or_else(|| anyhow!("resolved missing did"))?
7385        .to_string();
7386    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
7387
7388    // Self-pair guard (issue #30, explicit "Optional" ask). Refuses loudly
7389    // when the resolved peer DID matches our own. See
7390    // `reject_self_pair_after_resolution` for the full failure-mode and
7391    // remediation rationale.
7392    reject_self_pair_after_resolution(&our_did, &peer_did)?;
7393
7394    let peer_slot_id = resolved
7395        .get("slot_id")
7396        .and_then(Value::as_str)
7397        .ok_or_else(|| anyhow!("resolved missing slot_id"))?
7398        .to_string();
7399    let peer_relay = resolved
7400        .get("relay_url")
7401        .and_then(Value::as_str)
7402        .map(str::to_string)
7403        .or_else(|| relay_override.map(str::to_string))
7404        .unwrap_or_else(|| format!("https://{}", parsed.domain));
7405
7406    // 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
7407    let mut trust = config::read_trust()?;
7408    crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
7409    config::write_trust(&trust)?;
7410    let mut relay_state = config::read_relay_state()?;
7411    // Additive re-pin (v0.13.2, E3 token-bleed fix). The old code REPLACED the
7412    // whole peer entry with a flat federation-only one, seeding the token from
7413    // the entry's TOP-LEVEL `slot_token`. Two bugs (glossy-magnolia repro):
7414    //   1. re-dialing a peer that had a local endpoint (from add-peer-slot)
7415    //      CLOBBERED that local endpoint.
7416    //   2. after a local add-peer-slot the top-level token was the LOCAL token,
7417    //      so the federation endpoint inherited a stale LOCAL bearer →
7418    //      federation delivery would 401.
7419    // Fix: merge the federation endpoint into the peer's endpoints[] (preserve
7420    // the local one), and seed its token ONLY from a prior FEDERATION endpoint
7421    // on the same relay (re-dialing an already-acked peer), never a local one —
7422    // empty until the pair_drop_ack lands otherwise.
7423    let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
7424        .get("peers")
7425        .and_then(|p| p.get(&peer_handle))
7426        .and_then(|e| e.get("endpoints"))
7427        .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
7428        .unwrap_or_default();
7429    let fed_token = endpoints
7430        .iter()
7431        .find(|e| {
7432            e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
7433        })
7434        .map(|e| e.slot_token.clone())
7435        .unwrap_or_default();
7436    let fed_ep = crate::endpoints::Endpoint {
7437        relay_url: peer_relay.clone(),
7438        slot_id: peer_slot_id.clone(),
7439        slot_token: fed_token, // empty until pair_drop_ack lands
7440        scope: crate::endpoints::EndpointScope::Federation,
7441    };
7442    if let Some(existing) = endpoints
7443        .iter_mut()
7444        .find(|e| e.relay_url == fed_ep.relay_url)
7445    {
7446        *existing = fed_ep;
7447    } else {
7448        endpoints.push(fed_ep);
7449    }
7450    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
7451    config::write_relay_state(&relay_state)?;
7452
7453    // 4. Build signed pair_drop with our card + coords (no pair_nonce — this
7454    // is the v0.5 zero-paste open-mode path).
7455    let our_card = config::read_agent_card()?;
7456    let sk_seed = config::read_private_key()?;
7457    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7458    let pk_b64 = our_card
7459        .get("verify_keys")
7460        .and_then(Value::as_object)
7461        .and_then(|m| m.values().next())
7462        .and_then(|v| v.get("key"))
7463        .and_then(Value::as_str)
7464        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7465    let pk_bytes = crate::signing::b64decode(pk_b64)?;
7466    let now = time::OffsetDateTime::now_utc()
7467        .format(&time::format_description::well_known::Rfc3339)
7468        .unwrap_or_default();
7469    // v0.5.17: advertise all our endpoints (federation + optional local)
7470    // to the peer in the pair_drop body. Back-compat: top-level
7471    // relay_url/slot_id/slot_token still point at the federation
7472    // endpoint so v0.5.16-and-earlier peers ingest unchanged.
7473    let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
7474    let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7475    let mut body = json!({
7476        "card": our_card,
7477        "relay_url": our_relay,
7478        "slot_id": our_slot_id,
7479        "slot_token": our_slot_token,
7480    });
7481    if !our_endpoints.is_empty() {
7482        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7483    }
7484    let event = json!({
7485        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7486        "timestamp": now,
7487        "from": our_did,
7488        "to": peer_did,
7489        "type": "pair_drop",
7490        "kind": 1100u32,
7491        "body": body,
7492    });
7493    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7494
7495    // 5. Deliver via /v1/handle/intro/<nick> (auth-free; relay validates kind).
7496    let client = crate::relay_client::RelayClient::new(&peer_relay);
7497    let resp = client.handle_intro(&parsed.nick, &signed)?;
7498    let event_id = signed
7499        .get("event_id")
7500        .and_then(Value::as_str)
7501        .unwrap_or("")
7502        .to_string();
7503
7504    if as_json {
7505        println!(
7506            "{}",
7507            serde_json::to_string(&json!({
7508                "handle": handle_arg,
7509                "paired_with": peer_did,
7510                "peer_handle": peer_handle,
7511                "event_id": event_id,
7512                "drop_response": resp,
7513                "status": "drop_sent",
7514            }))?
7515        );
7516    } else {
7517        println!(
7518            "→ 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."
7519        );
7520    }
7521    Ok(())
7522}
7523
7524/// v0.5.14 bilateral-completion path for `wire add`. Called when the peer's
7525/// pair_drop is already sitting in `pending-inbound`. Pin trust, write relay
7526/// coords + slot_token from the stored drop, ship our slot_token back via
7527/// `pair_drop_ack`, delete the pending record. Symmetric with the SPAKE2
7528/// invite-URL path (which is already bilateral by virtue of the pre-shared
7529/// nonce).
7530fn cmd_add_accept_pending(
7531    handle_arg: &str,
7532    peer_nick: &str,
7533    pending: &crate::pending_inbound_pair::PendingInboundPair,
7534    _our_relay: &str,
7535    _our_slot_id: &str,
7536    _our_slot_token: &str,
7537    as_json: bool,
7538) -> Result<()> {
7539    // 1. Pin peer in trust with VERIFIED — operator gestured consent by running
7540    //    `wire add` against this handle while a drop was waiting.
7541    let mut trust = config::read_trust()?;
7542    crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
7543    config::write_trust(&trust)?;
7544
7545    // 2. Record peer's relay coords + slot_token (already shipped to us in
7546    //    the original drop body; held back until now).
7547    // v0.5.17: pin all advertised endpoints (federation + optional local).
7548    // Falls back to a single federation entry when the record was written
7549    // by v0.5.16-era code that didn't carry endpoints[].
7550    let mut relay_state = config::read_relay_state()?;
7551    let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
7552        vec![crate::endpoints::Endpoint::federation(
7553            pending.peer_relay_url.clone(),
7554            pending.peer_slot_id.clone(),
7555            pending.peer_slot_token.clone(),
7556        )]
7557    } else {
7558        pending.peer_endpoints.clone()
7559    };
7560    crate::endpoints::pin_peer_endpoints(
7561        &mut relay_state,
7562        &pending.peer_handle,
7563        &endpoints_to_pin,
7564    )?;
7565    config::write_relay_state(&relay_state)?;
7566
7567    // 3. Ship our slot_token to peer via pair_drop_ack — try every advertised
7568    //    peer endpoint in priority order (Bug 2). `endpoints_to_pin` was
7569    //    already built from `pending.peer_endpoints` (with legacy-triple
7570    //    fallback) just above, so we reuse it rather than rebuilding.
7571    crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
7572        || {
7573            format!(
7574                "pair_drop_ack send to {} (across {} endpoint(s)) failed",
7575                pending.peer_handle,
7576                endpoints_to_pin.len()
7577            )
7578        },
7579    )?;
7580
7581    // 4. Delete the pending-inbound record now that bilateral is complete.
7582    crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
7583
7584    if as_json {
7585        println!(
7586            "{}",
7587            serde_json::to_string(&json!({
7588                "handle": handle_arg,
7589                "paired_with": pending.peer_did,
7590                "peer_handle": pending.peer_handle,
7591                "status": "bilateral_accepted",
7592                "via": "pending_inbound",
7593            }))?
7594        );
7595    } else {
7596        println!(
7597            "→ 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} \"...\"`.",
7598            peer = pending.peer_handle,
7599        );
7600    }
7601    Ok(())
7602}
7603
7604/// `wire accept <peer>` (v0.9+) — bilateral-completion path for a
7605/// pending-inbound pair request. Pin trust, write relay_state from the stored
7606/// pair_drop, send `pair_drop_ack` with our slot_token, delete the pending
7607/// record. Equivalent to running `wire add <peer>@<their-relay>` when a
7608/// pending-inbound record exists, but without needing to remember the peer's
7609/// relay domain.
7610fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
7611    let nick = crate::agent_card::bare_handle(peer_nick);
7612    let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
7613        anyhow!(
7614            "no pending pair request from {nick}. Run `wire pending` to see who is waiting, \
7615             or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
7616        )
7617    })?;
7618    let (_our_did, our_relay, our_slot_id, our_slot_token) =
7619        crate::pair_invite::ensure_self_with_relay(None)?;
7620    let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
7621    cmd_add_accept_pending(
7622        &handle_arg,
7623        nick,
7624        &pending,
7625        &our_relay,
7626        &our_slot_id,
7627        &our_slot_token,
7628        as_json,
7629    )
7630}
7631
7632/// `wire pending --json` — programmatic access to pending-inbound for scripts.
7633/// Returns a flat array of records sorted oldest-first.
7634fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
7635    let items = crate::pending_inbound_pair::list_pending_inbound()?;
7636    if as_json {
7637        println!("{}", serde_json::to_string(&items)?);
7638        return Ok(());
7639    }
7640    if items.is_empty() {
7641        println!("no pending pair requests — your inbox is clear.");
7642        return Ok(());
7643    }
7644    // v0.9.3: conversational output. Tabular data is for --json. Humans
7645    // get one short sentence per pending peer, each rendered with the
7646    // peer's character (DID-derived emoji + nickname) so they can match
7647    // the speaker against their statusline / mesh-status view at a
7648    // glance. The "next step" sentence at the bottom names the exact
7649    // verbs to run.
7650    let plural = if items.len() == 1 { "" } else { "s" };
7651    println!("{} pending pair request{plural}:\n", items.len());
7652    for p in &items {
7653        let ch = crate::character::Character::from_did(&p.peer_did);
7654        let glyph = crate::character::emoji_with_fallback(&ch);
7655        // ASCII-friendly arrow if the operator's terminal can't render
7656        // emoji (the same routine drives the fallback).
7657        println!(
7658            "  {glyph} {nick}  ({handle})  wants to pair with you",
7659            nick = ch.nickname,
7660            handle = p.peer_handle,
7661        );
7662    }
7663    println!();
7664    println!(
7665        "→ to accept any: `wire accept <name>`  (e.g. `wire accept {first}`)",
7666        first = items
7667            .first()
7668            .map(|p| {
7669                let ch = crate::character::Character::from_did(&p.peer_did);
7670                ch.nickname
7671            })
7672            .unwrap_or_else(|| "<name>".to_string())
7673    );
7674    println!("→ to refuse:    `wire reject <name>`");
7675    Ok(())
7676}
7677
7678/// `wire reject <peer>` (v0.9+) — drop a pending-inbound record without
7679/// pairing. No event is sent back to the peer; their side stays pending
7680/// until they time out or the operator-side data ages out.
7681fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
7682    let nick = crate::agent_card::bare_handle(peer_nick);
7683    let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
7684    crate::pending_inbound_pair::consume_pending_inbound(nick)?;
7685
7686    if as_json {
7687        println!(
7688            "{}",
7689            serde_json::to_string(&json!({
7690                "peer": nick,
7691                "rejected": existed.is_some(),
7692                "had_pending": existed.is_some(),
7693            }))?
7694        );
7695    } else if existed.is_some() {
7696        println!(
7697            "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
7698        );
7699    } else {
7700        println!("no pending pair from {nick} — nothing to reject");
7701    }
7702    Ok(())
7703}
7704
7705// ---------- session (v0.5.16) ----------
7706//
7707// Multi-session wire on one machine. See src/session.rs for the storage
7708// layout + naming rules. The CLI dispatcher here orchestrates child
7709// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
7710// each session-local `init` / `claim` / `daemon` runs in its own world
7711// without cross-contamination via env vars in this process.
7712
7713// ---------- group chat (v0.13.3) ----------
7714
7715fn cmd_group(cmd: GroupCommand) -> Result<()> {
7716    match cmd {
7717        GroupCommand::Create { name, json } => cmd_group_create(&name, json),
7718        GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
7719        GroupCommand::Send {
7720            group,
7721            message,
7722            json,
7723        } => cmd_group_send(&group, &message, json),
7724        GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
7725        GroupCommand::List { json } => cmd_group_list(json),
7726        GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
7727        GroupCommand::Join { code, json } => cmd_group_join(&code, json),
7728    }
7729}
7730
7731/// This agent's (did, handle) from its signed card.
7732/// This agent's signing identity for group ops: (did, handle, key_id, pk_b64).
7733fn group_self() -> Result<(String, String, String, String)> {
7734    let card = config::read_agent_card()?;
7735    let did = card
7736        .get("did")
7737        .and_then(Value::as_str)
7738        .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
7739        .to_string();
7740    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7741    let pk_b64 = card
7742        .get("verify_keys")
7743        .and_then(Value::as_object)
7744        .and_then(|m| m.values().next())
7745        .and_then(|v| v.get("key"))
7746        .and_then(Value::as_str)
7747        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
7748        .to_string();
7749    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7750    let key_id = make_key_id(&handle, &pk_bytes);
7751    Ok((did, handle, key_id, pk_b64))
7752}
7753
7754/// Relay to host a group room on — prefer the federation endpoint (remote
7755/// members can reach it), fall back to LAN, then local, then any.
7756fn group_room_relay_url() -> Result<String> {
7757    use crate::endpoints::EndpointScope;
7758    let state = config::read_relay_state()?;
7759    let eps = crate::endpoints::self_endpoints(&state);
7760    let pick = eps
7761        .iter()
7762        .find(|e| e.scope == EndpointScope::Federation)
7763        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
7764        .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
7765        .or_else(|| eps.first());
7766    match pick {
7767        Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
7768        _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
7769    }
7770}
7771
7772/// Sign a `group_invite` (carrying the full creator-signed Group) and queue it
7773/// to every other member's outbox. The daemon/push delivers; the recipient's
7774/// `ingest_group_invites` materializes the room + introduce-pins members.
7775fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
7776    let (_, self_handle, _, pk_b64) = group_self()?;
7777    let sk_seed = config::read_private_key()?;
7778    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7779    let now_iso = time::OffsetDateTime::now_utc()
7780        .format(&time::format_description::well_known::Rfc3339)
7781        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7782    let group_json = serde_json::to_value(group)?;
7783    let mut delivered = 0usize;
7784    for handle in group.other_member_handles(self_did) {
7785        let event = json!({
7786            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7787            "timestamp": now_iso,
7788            "from": self_did,
7789            "to": format!("did:wire:{handle}"),
7790            "type": "group_invite",
7791            "kind": parse_kind("group_invite")?,
7792            "body": group_json,
7793        });
7794        let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7795            .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
7796        let line = serde_json::to_vec(&signed)?;
7797        if config::append_outbox_record(&handle, &line).is_ok() {
7798            delivered += 1;
7799        }
7800    }
7801    Ok(delivered)
7802}
7803
7804/// Introduce-pin a member's key on the creator's vouch: ensure
7805/// `trust.agents[handle]` carries this key so the member's group messages
7806/// verify, WITHOUT granting bilateral trust. Never lowers an existing tier
7807/// (a directly-VERIFIED peer stays VERIFIED); only adds the key if missing.
7808/// Returns `true` iff it actually changed `trust` (new entry or added key) —
7809/// callers use this to decide whether to persist.
7810fn introduce_pin(
7811    trust: &mut Value,
7812    handle: &str,
7813    did: &str,
7814    key_id: &str,
7815    key: &str,
7816    group_id: &str,
7817) -> bool {
7818    let now = time::OffsetDateTime::now_utc()
7819        .format(&time::format_description::well_known::Rfc3339)
7820        .unwrap_or_default();
7821    let agents = trust
7822        .as_object_mut()
7823        .expect("trust is an object")
7824        .entry("agents")
7825        .or_insert_with(|| json!({}));
7826    let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
7827    match agents.get_mut(handle) {
7828        Some(existing) => {
7829            // Already pinned (maybe at a higher bilateral tier) — just ensure
7830            // the key is present. Do NOT touch the tier.
7831            let keys = existing
7832                .as_object_mut()
7833                .and_then(|o| o.get_mut("public_keys"))
7834                .and_then(Value::as_array_mut);
7835            if let Some(keys) = keys {
7836                let have = keys
7837                    .iter()
7838                    .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
7839                if !have {
7840                    keys.push(key_rec);
7841                    return true;
7842                }
7843            }
7844            false
7845        }
7846        None => {
7847            // First sight — pin at bilateral UNTRUSTED (disjoint from GroupTier).
7848            agents[handle] = json!({
7849                "tier": "UNTRUSTED",
7850                "did": did,
7851                "public_keys": [key_rec],
7852                "introduced_via": group_id,
7853                "pinned_at": now,
7854            });
7855            true
7856        }
7857    }
7858}
7859
7860/// Scan the inbox for `group_invite` events from pinned creators, verify them
7861/// (event signature + roster `creator_sig`), materialize/refresh the local
7862/// group at its highest epoch, and introduce-pin every other member. Lazy:
7863/// runs at the top of group send/tail/list so a member just-pulled an invite
7864/// is immediately usable. Skips groups this agent created.
7865fn ingest_group_invites() -> Result<()> {
7866    let inbox = config::inbox_dir()?;
7867    if !inbox.exists() {
7868        return Ok(());
7869    }
7870    let (self_did, ..) = group_self()?;
7871    let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7872    // group_id -> highest-epoch verified roster seen in the inbox.
7873    let mut best: std::collections::HashMap<String, crate::group::Group> =
7874        std::collections::HashMap::new();
7875
7876    for entry in std::fs::read_dir(&inbox)?.flatten() {
7877        let path = entry.path();
7878        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7879            continue;
7880        }
7881        for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
7882            let event: Value = match serde_json::from_str(line) {
7883                Ok(v) => v,
7884                Err(_) => continue,
7885            };
7886            if event.get("type").and_then(Value::as_str) != Some("group_invite") {
7887                continue;
7888            }
7889            // Event-level: the invite must be from a pinned peer (the creator)
7890            // with a valid signature.
7891            if verify_message_v31(&event, &trust_now).is_err() {
7892                continue;
7893            }
7894            let Some(body) = event.get("body") else {
7895                continue;
7896            };
7897            let group: crate::group::Group = match serde_json::from_value(body.clone()) {
7898                Ok(g) => g,
7899                Err(_) => continue,
7900            };
7901            if group.creator_did == self_did {
7902                continue; // never overwrite a group I created
7903            }
7904            // The invite's sender must be the group's creator.
7905            let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7906            if from_did != group.creator_did {
7907                continue;
7908            }
7909            // Roster integrity: creator_sig must verify against the creator's
7910            // independently-pinned key (we paired with the creator → have it).
7911            let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
7912            let creator_key = trust_now
7913                .get("agents")
7914                .and_then(|a| a.get(creator_handle))
7915                .and_then(|a| a.get("public_keys"))
7916                .and_then(Value::as_array)
7917                .and_then(|ks| ks.first())
7918                .and_then(|k| k.get("key"))
7919                .and_then(Value::as_str)
7920                .and_then(|b| crate::signing::b64decode(b).ok());
7921            let Some(creator_key) = creator_key else {
7922                continue;
7923            };
7924            if !group.verify(&creator_key) {
7925                continue;
7926            }
7927            match best.get(&group.id) {
7928                Some(prev) if prev.epoch >= group.epoch => {}
7929                _ => {
7930                    best.insert(group.id.clone(), group);
7931                }
7932            }
7933        }
7934    }
7935
7936    if best.is_empty() {
7937        return Ok(());
7938    }
7939    let mut trust = config::read_trust()?;
7940    for group in best.values() {
7941        // Don't regress a locally-known group to a stale epoch.
7942        if let Ok(local) = crate::group::load_group(&group.id)
7943            && local.epoch >= group.epoch
7944        {
7945            continue;
7946        }
7947        crate::group::save_group(group)?;
7948        for m in &group.members {
7949            if m.did == self_did || m.key.is_empty() {
7950                continue;
7951            }
7952            introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7953        }
7954    }
7955    config::write_trust(&trust)?;
7956    Ok(())
7957}
7958
7959fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
7960    if !config::is_initialized()? {
7961        bail!("not initialized — run `wire up` first");
7962    }
7963    let (did, handle, key_id, pk_b64) = group_self()?;
7964    let relay_url = group_room_relay_url()?;
7965    // Allocate the shared group-room slot on the relay.
7966    let client = crate::relay_client::RelayClient::new(&relay_url);
7967    let room = client
7968        .allocate_slot(Some(&format!("group:{name}")))
7969        .with_context(|| format!("allocating group room on {relay_url}"))?;
7970    let id = format!("g{:016x}", rand::random::<u64>());
7971    let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
7972    group.set_room(relay_url, room.slot_id, room.slot_token);
7973    group.set_member_keys(&did, key_id, pk_b64)?;
7974    let sk = config::read_private_key()?;
7975    group.sign(&sk)?;
7976    crate::group::save_group(&group)?;
7977    if as_json {
7978        println!(
7979            "{}",
7980            serde_json::to_string(&json!({
7981                "id": id, "name": name, "members": 1, "relay_url": group.relay_url
7982            }))?
7983        );
7984    } else {
7985        println!(
7986            "created group `{name}` (id {id}) — room on {}. You are the creator.",
7987            group.relay_url
7988        );
7989        println!("  add peers: `wire group add {id} <peer>`   talk: `wire group send {id} \"hi\"`");
7990    }
7991    Ok(())
7992}
7993
7994fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
7995    let (self_did, ..) = group_self()?;
7996    let mut group = crate::group::resolve_group(group_ref)?;
7997    if group.creator_did != self_did {
7998        bail!("only the group creator can add members (the creator signs the roster)");
7999    }
8000    // T22 consent: a Member must be a peer you bilaterally VERIFIED.
8001    let bare = crate::agent_card::bare_handle(peer).to_string();
8002    let trust = config::read_trust()?;
8003    let agent = trust
8004        .get("agents")
8005        .and_then(|a| a.get(&bare))
8006        .ok_or_else(|| {
8007            anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
8008        })?;
8009    let tier = agent
8010        .get("tier")
8011        .and_then(Value::as_str)
8012        .unwrap_or("UNTRUSTED");
8013    if tier != "VERIFIED" {
8014        bail!(
8015            "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
8016        );
8017    }
8018    let peer_did = agent
8019        .get("did")
8020        .and_then(Value::as_str)
8021        .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
8022        .to_string();
8023    // Capture the peer's signing key from trust so the creator can vouch for it
8024    // in the signed roster (members introduce-pin it to verify this peer).
8025    let key = agent
8026        .get("public_keys")
8027        .and_then(Value::as_array)
8028        .and_then(|ks| {
8029            ks.iter()
8030                .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
8031        })
8032        .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
8033    let peer_key_id = key
8034        .get("key_id")
8035        .and_then(Value::as_str)
8036        .unwrap_or_default()
8037        .to_string();
8038    let peer_pk = key
8039        .get("key")
8040        .and_then(Value::as_str)
8041        .unwrap_or_default()
8042        .to_string();
8043
8044    group.add_member(
8045        bare.clone(),
8046        peer_did.clone(),
8047        crate::group::GroupTier::Member,
8048    )?;
8049    group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
8050    let sk = config::read_private_key()?;
8051    group.sign(&sk)?;
8052    crate::group::save_group(&group)?;
8053    // Distribute the refreshed signed roster (room coords + everyone's keys) to
8054    // ALL members so each can post + verify the others.
8055    let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
8056    if as_json {
8057        println!(
8058            "{}",
8059            serde_json::to_string(&json!({
8060                "group": group.id, "added": bare, "epoch": group.epoch,
8061                "members": group.members.len(), "invites_queued": delivered
8062            }))?
8063        );
8064    } else {
8065        println!(
8066            "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
8067            group.name,
8068            group.members.len(),
8069            group.epoch
8070        );
8071    }
8072    Ok(())
8073}
8074
8075fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
8076    if !config::is_initialized()? {
8077        bail!("not initialized — run `wire up` first");
8078    }
8079    ingest_group_invites()?;
8080    let (self_did, self_handle, _, pk_b64) = group_self()?;
8081    let group = crate::group::resolve_group(group_ref)?;
8082    // Membership for SEND is room-token possession: having the group locally
8083    // (with its slot_token) is the capability. The signed roster gates who you
8084    // can VERIFY, not whether you may post — a code-redeemed joiner isn't in the
8085    // creator-signed roster but legitimately holds the room key.
8086    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8087        bail!(
8088            "group `{}` has no room slot (legacy/partial group)",
8089            group.name
8090        );
8091    }
8092    let sk_seed = config::read_private_key()?;
8093    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8094    let now_iso = time::OffsetDateTime::now_utc()
8095        .format(&time::format_description::well_known::Rfc3339)
8096        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8097    let event = json!({
8098        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8099        "timestamp": now_iso,
8100        "from": self_did,
8101        "to": format!("did:wire:group:{}", group.id),
8102        "type": "group_msg",
8103        "kind": parse_kind("group_msg")?,
8104        "body": {
8105            "group_id": group.id,
8106            "group_name": group.name,
8107            "epoch": group.epoch,
8108            "text": message,
8109        },
8110    });
8111    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8112        .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
8113    // Post the one message to the shared group slot.
8114    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8115    client
8116        .post_event(&group.slot_id, &group.slot_token, &signed)
8117        .with_context(|| {
8118            format!(
8119                "posting to group room {} on {}",
8120                group.slot_id, group.relay_url
8121            )
8122        })?;
8123    if as_json {
8124        println!(
8125            "{}",
8126            serde_json::to_string(&json!({
8127                "group": group.id, "epoch": group.epoch, "status": "posted",
8128                "members": group.members.len()
8129            }))?
8130        );
8131    } else {
8132        println!(
8133            "group `{}`: posted to the room ({} member(s))",
8134            group.name,
8135            group.members.len()
8136        );
8137    }
8138    Ok(())
8139}
8140
8141fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
8142    ingest_group_invites()?;
8143    let group = crate::group::resolve_group(group_ref)?;
8144    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8145        bail!(
8146            "group `{}` has no room slot (legacy/partial group)",
8147            group.name
8148        );
8149    }
8150    let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
8151    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8152    // Pull the shared room; cap generously then show the last `limit`.
8153    let fetch = if limit == 0 {
8154        1000
8155    } else {
8156        (limit * 4).min(1000)
8157    };
8158    let events = client
8159        .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
8160        .with_context(|| {
8161            format!(
8162                "pulling group room {} on {}",
8163                group.slot_id, group.relay_url
8164            )
8165        })?;
8166
8167    // Pass 1: introduce-pin anyone who announced a join. A `group_join` carries
8168    // the joiner's card and must self-consistently sign under it; posting to the
8169    // room requires the room token, so possession is the authorization (pinned
8170    // at bilateral UNTRUSTED, group tier Introduced). This lets their later
8171    // group messages verify even though they're not in the creator-signed roster.
8172    let mut trust_changed = false;
8173    for event in &events {
8174        if event.get("type").and_then(Value::as_str) != Some("group_join") {
8175            continue;
8176        }
8177        if let Some((h, did, kid, key)) = group_join_pin_material(event)
8178            && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
8179        {
8180            trust_changed = true;
8181        }
8182    }
8183    if trust_changed {
8184        let _ = config::write_trust(&trust);
8185    }
8186
8187    // Pass 2: build the timeline — group messages (verified against the
8188    // now-augmented trust) interleaved with join notices.
8189    enum Line {
8190        Msg {
8191            from: String,
8192            text: String,
8193            verified: bool,
8194        },
8195        Join {
8196            who: String,
8197        },
8198    }
8199    let mut timeline: Vec<(String, Line)> = Vec::new();
8200    for event in &events {
8201        let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
8202        let body = match event.get("body") {
8203            Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
8204            Some(v) => Some(v.clone()),
8205            None => None,
8206        };
8207        let Some(body) = body else { continue };
8208        if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
8209            continue;
8210        }
8211        let ts = event
8212            .get("timestamp")
8213            .and_then(Value::as_str)
8214            .unwrap_or("")
8215            .to_string();
8216        let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
8217        let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
8218        match ty {
8219            "group_msg" => {
8220                let text = body
8221                    .get("text")
8222                    .and_then(Value::as_str)
8223                    .unwrap_or("")
8224                    .to_string();
8225                let verified = verify_message_v31(event, &trust).is_ok();
8226                timeline.push((
8227                    ts,
8228                    Line::Msg {
8229                        from: from_handle,
8230                        text,
8231                        verified,
8232                    },
8233                ));
8234            }
8235            "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
8236            _ => {}
8237        }
8238    }
8239    timeline.sort_by(|a, b| a.0.cmp(&b.0));
8240    let start = if limit > 0 {
8241        timeline.len().saturating_sub(limit)
8242    } else {
8243        0
8244    };
8245    let recent = &timeline[start..];
8246    if as_json {
8247        let arr: Vec<Value> = recent
8248            .iter()
8249            .map(|(ts, l)| match l {
8250                Line::Msg {
8251                    from,
8252                    text,
8253                    verified,
8254                } => {
8255                    json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
8256                }
8257                Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
8258            })
8259            .collect();
8260        println!(
8261            "{}",
8262            serde_json::to_string(
8263                &json!({"group": group.id, "name": group.name, "messages": arr})
8264            )?
8265        );
8266    } else if recent.is_empty() {
8267        println!("group `{}`: no messages yet", group.name);
8268    } else {
8269        for (ts, l) in recent {
8270            let short_ts: String = ts.chars().take(19).collect();
8271            match l {
8272                Line::Msg {
8273                    from,
8274                    text,
8275                    verified,
8276                } => {
8277                    let mark = if *verified { "✓" } else { "✗" };
8278                    println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
8279                }
8280                Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
8281            }
8282        }
8283    }
8284    Ok(())
8285}
8286
8287/// Validate a `group_join` room event and extract the joiner's pin material:
8288/// (handle, did, key_id, key_b64). The event MUST self-consistently sign under
8289/// the key in the card it carries — so a forged join (card A, signed by key B)
8290/// is rejected. Authorization to be in the room is proven by the post itself
8291/// (it required the room token).
8292fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
8293    let body = match event.get("body") {
8294        Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
8295        Some(v) => v.clone(),
8296        None => return None,
8297    };
8298    let card = body.get("joiner_card")?;
8299    // Verify the event signs under the card it carries (one-entry trust).
8300    let mut tmp = json!({"agents": {}});
8301    crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
8302    if verify_message_v31(event, &tmp).is_err() {
8303        return None;
8304    }
8305    let did = card.get("did").and_then(Value::as_str)?.to_string();
8306    let handle = card
8307        .get("handle")
8308        .and_then(Value::as_str)
8309        .map(str::to_string)
8310        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8311    let (kid_full, krec) = card
8312        .get("verify_keys")
8313        .and_then(Value::as_object)
8314        .and_then(|m| m.iter().next())?;
8315    let key_id = kid_full
8316        .strip_prefix("ed25519:")
8317        .unwrap_or(kid_full)
8318        .to_string();
8319    let key = krec.get("key").and_then(Value::as_str)?.to_string();
8320    Some((handle, did, key_id, key))
8321}
8322
8323/// `wire group invite <group>` — mint a self-contained join code (the serialized
8324/// signed group: room coords + roster + member keys). The code IS the room key.
8325fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
8326    let group = crate::group::resolve_group(group_ref)?;
8327    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8328        bail!(
8329            "group `{}` has no room slot — nothing to invite into",
8330            group.name
8331        );
8332    }
8333    if group.creator_sig.is_empty() {
8334        bail!(
8335            "group `{}` roster is unsigned — add a member or recreate before inviting",
8336            group.name
8337        );
8338    }
8339    let payload = serde_json::to_vec(&group)?;
8340    let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
8341    if as_json {
8342        println!(
8343            "{}",
8344            serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
8345        );
8346    } else {
8347        println!(
8348            "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
8349            group.name
8350        );
8351        println!("{code}\n");
8352        println!("they run:  wire group join <code>");
8353    }
8354    Ok(())
8355}
8356
8357/// `wire group join <code>` — redeem a join code: verify the roster, materialize
8358/// the room locally, introduce-pin existing members, and announce ourselves to
8359/// the room so members verify our messages. Lands at group tier Introduced.
8360fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
8361    if !config::is_initialized()? {
8362        bail!("not initialized — run `wire up` first");
8363    }
8364    let raw = code.trim();
8365    let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
8366    let payload =
8367        crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
8368    let group: crate::group::Group = serde_json::from_slice(&payload)
8369        .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
8370    if group.slot_id.is_empty() || group.relay_url.is_empty() {
8371        bail!("join code carries no room coords");
8372    }
8373    // Verify the roster against the creator's key carried IN the roster (TOFU on
8374    // the code — you obtained it over a trusted channel). Rejects a tampered code.
8375    let creator_key = group
8376        .members
8377        .iter()
8378        .find(|m| m.did == group.creator_did)
8379        .map(|m| m.key.clone())
8380        .filter(|k| !k.is_empty())
8381        .and_then(|k| crate::signing::b64decode(&k).ok())
8382        .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
8383    if !group.verify(&creator_key) {
8384        bail!("join code failed its signature check (tampered or corrupt)");
8385    }
8386    let (self_did, self_handle, _, _) = group_self()?;
8387    if group.creator_did == self_did {
8388        bail!("you created group `{}` — you're already in it", group.name);
8389    }
8390
8391    // Materialize locally + introduce-pin existing members so we can verify them.
8392    crate::group::save_group(&group)?;
8393    let mut trust = config::read_trust()?;
8394    for m in &group.members {
8395        if m.did == self_did || m.key.is_empty() {
8396            continue;
8397        }
8398        introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
8399    }
8400    config::write_trust(&trust)?;
8401
8402    // Announce ourselves to the room (carry our card) so members introduce-pin us.
8403    let card = config::read_agent_card()?;
8404    let sk_seed = config::read_private_key()?;
8405    let pk_b64 = card
8406        .get("verify_keys")
8407        .and_then(Value::as_object)
8408        .and_then(|m| m.values().next())
8409        .and_then(|v| v.get("key"))
8410        .and_then(Value::as_str)
8411        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8412    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8413    let now_iso = time::OffsetDateTime::now_utc()
8414        .format(&time::format_description::well_known::Rfc3339)
8415        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8416    let event = json!({
8417        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8418        "timestamp": now_iso,
8419        "from": self_did,
8420        "to": format!("did:wire:group:{}", group.id),
8421        "type": "group_join",
8422        "kind": parse_kind("group_join")?,
8423        "body": {
8424            "group_id": group.id,
8425            "group_name": group.name,
8426            "epoch": group.epoch,
8427            "joiner_card": card,
8428            "text": "joined",
8429        },
8430    });
8431    let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8432        .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
8433    let client = crate::relay_client::RelayClient::new(&group.relay_url);
8434    let announced = client
8435        .post_event(&group.slot_id, &group.slot_token, &signed)
8436        .is_ok();
8437
8438    if as_json {
8439        println!(
8440            "{}",
8441            serde_json::to_string(&json!({
8442                "group": group.id, "name": group.name, "joined": true,
8443                "members": group.members.len(), "announced": announced
8444            }))?
8445        );
8446    } else {
8447        println!(
8448            "joined group `{}` ({} member(s)) at Introduced tier.",
8449            group.name,
8450            group.members.len()
8451        );
8452        if announced {
8453            println!("  announced to the room — members will verify your messages.");
8454        } else {
8455            println!(
8456                "  ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
8457            );
8458        }
8459        println!(
8460            "  read: `wire group tail {}`   talk: `wire group send {} \"hi\"`",
8461            group.id, group.id
8462        );
8463    }
8464    Ok(())
8465}
8466
8467fn cmd_group_list(as_json: bool) -> Result<()> {
8468    let groups = crate::group::list_groups()?;
8469    if as_json {
8470        let arr: Vec<Value> = groups
8471            .iter()
8472            .map(|g| {
8473                json!({
8474                    "id": g.id,
8475                    "name": g.name,
8476                    "epoch": g.epoch,
8477                    "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
8478                })
8479            })
8480            .collect();
8481        println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
8482    } else if groups.is_empty() {
8483        println!("no groups yet — create one with `wire group create <name>`");
8484    } else {
8485        for g in &groups {
8486            println!(
8487                "{} ({}) — {} member(s), epoch {}",
8488                g.name,
8489                g.id,
8490                g.members.len(),
8491                g.epoch
8492            );
8493            for m in &g.members {
8494                println!("    {} [{}]", m.handle, m.tier.as_str());
8495            }
8496        }
8497    }
8498    Ok(())
8499}
8500
8501/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
8502/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
8503fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
8504    match cmd {
8505        MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
8506        MeshCommand::Broadcast {
8507            kind,
8508            scope,
8509            exclude,
8510            noreply,
8511            body,
8512            json,
8513        } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
8514        MeshCommand::Role { action } => cmd_mesh_role(action),
8515        MeshCommand::Route {
8516            role,
8517            strategy,
8518            exclude,
8519            kind,
8520            body,
8521            json,
8522        } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
8523    }
8524}
8525
8526/// v0.6.5 (issue #21): capability-match routing. Walks sister sessions,
8527/// filters by `profile.role` + `--exclude` + must-be-pinned-in-our-peers,
8528/// picks ONE via the requested strategy, then signs + pushes the event
8529/// to that peer. Pinned-peers-only by construction (same as broadcast).
8530fn cmd_mesh_route(
8531    role: &str,
8532    strategy: &str,
8533    exclude: &[String],
8534    kind: &str,
8535    body_arg: &str,
8536    as_json: bool,
8537) -> Result<()> {
8538    use std::time::Instant;
8539
8540    if !config::is_initialized()? {
8541        bail!("not initialized — run `wire init <handle>` first");
8542    }
8543    let strategy = strategy.to_ascii_lowercase();
8544    if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
8545        bail!("unknown strategy `{strategy}` — use round-robin | first | random");
8546    }
8547
8548    // Our pinned-peer set: only these handles are addressable. mesh-route
8549    // refuses to invent a recipient, same posture as broadcast.
8550    let state = config::read_relay_state()?;
8551    let pinned: std::collections::BTreeSet<String> = state["peers"]
8552        .as_object()
8553        .map(|m| m.keys().cloned().collect())
8554        .unwrap_or_default();
8555
8556    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8557
8558    // Enumerate every sister on the box, read each one's role from its
8559    // signed agent-card. Filter: matching role AND pinned AND not
8560    // excluded. `list_sessions` returns the cross-session view (using the
8561    // v0.6.4 inside-session sessions_root fallback).
8562    let sessions = crate::session::list_sessions()?;
8563    let mut candidates: Vec<(String, Option<String>)> = Vec::new(); // (handle, did)
8564    for s in &sessions {
8565        let handle = match s.handle.as_ref() {
8566            Some(h) => h.clone(),
8567            None => continue,
8568        };
8569        if exclude_set.contains(handle.as_str()) {
8570            continue;
8571        }
8572        if !pinned.contains(&handle) {
8573            continue;
8574        }
8575        let card_path = s
8576            .home_dir
8577            .join("config")
8578            .join("wire")
8579            .join("agent-card.json");
8580        let card_role = std::fs::read(&card_path)
8581            .ok()
8582            .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8583            .and_then(|c| {
8584                c.get("profile")
8585                    .and_then(|p| p.get("role"))
8586                    .and_then(Value::as_str)
8587                    .map(str::to_string)
8588            });
8589        if card_role.as_deref() == Some(role) {
8590            candidates.push((handle, s.did.clone()));
8591        }
8592    }
8593
8594    candidates.sort_by(|a, b| a.0.cmp(&b.0));
8595    candidates.dedup_by(|a, b| a.0 == b.0);
8596
8597    if candidates.is_empty() {
8598        bail!(
8599            "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
8600        );
8601    }
8602
8603    let chosen = match strategy.as_str() {
8604        "first" => candidates[0].clone(),
8605        "random" => {
8606            use rand::Rng;
8607            let idx = rand::thread_rng().gen_range(0..candidates.len());
8608            candidates[idx].clone()
8609        }
8610        "round-robin" => {
8611            // Cursor persisted at <state_dir>/mesh-route-cursor.json:
8612            // `{role: last_picked_handle}`. Next pick = first candidate
8613            // alphabetically AFTER last_picked, wrapping around when no
8614            // candidate is greater.
8615            let cursor_path = mesh_route_cursor_path()?;
8616            let mut cursors: std::collections::BTreeMap<String, String> =
8617                read_mesh_route_cursors(&cursor_path);
8618            let last = cursors.get(role).cloned();
8619            let pick = match last {
8620                None => candidates[0].clone(),
8621                Some(last_h) => candidates
8622                    .iter()
8623                    .find(|(h, _)| h.as_str() > last_h.as_str())
8624                    .cloned()
8625                    .unwrap_or_else(|| candidates[0].clone()),
8626            };
8627            cursors.insert(role.to_string(), pick.0.clone());
8628            write_mesh_route_cursors(&cursor_path, &cursors)?;
8629            pick
8630        }
8631        _ => unreachable!(),
8632    };
8633
8634    let (chosen_handle, _chosen_did) = chosen;
8635
8636    // Body parsing follows wire send / mesh broadcast.
8637    let body_value: Value = if body_arg == "-" {
8638        use std::io::Read;
8639        let mut raw = String::new();
8640        std::io::stdin()
8641            .read_to_string(&mut raw)
8642            .with_context(|| "reading body from stdin")?;
8643        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8644    } else if let Some(path) = body_arg.strip_prefix('@') {
8645        let raw =
8646            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8647        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8648    } else {
8649        Value::String(body_arg.to_string())
8650    };
8651
8652    let sk_seed = config::read_private_key()?;
8653    let card = config::read_agent_card()?;
8654    let did = card
8655        .get("did")
8656        .and_then(Value::as_str)
8657        .ok_or_else(|| anyhow!("agent-card missing did"))?
8658        .to_string();
8659    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8660    let pk_b64 = card
8661        .get("verify_keys")
8662        .and_then(Value::as_object)
8663        .and_then(|m| m.values().next())
8664        .and_then(|v| v.get("key"))
8665        .and_then(Value::as_str)
8666        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8667    let pk_bytes = crate::signing::b64decode(pk_b64)?;
8668
8669    let kind_id = parse_kind(kind)?;
8670    let now_iso = time::OffsetDateTime::now_utc()
8671        .format(&time::format_description::well_known::Rfc3339)
8672        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8673
8674    let event = json!({
8675        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8676        "timestamp": now_iso,
8677        "from": did,
8678        "to": format!("did:wire:{chosen_handle}"),
8679        "type": kind,
8680        "kind": kind_id,
8681        "body": json!({
8682            "content": body_value,
8683            "routed_via": {
8684                "role": role,
8685                "strategy": strategy,
8686            },
8687        }),
8688    });
8689    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8690        .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
8691    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8692
8693    let line = serde_json::to_vec(&signed)?;
8694    config::append_outbox_record(&chosen_handle, &line)?;
8695
8696    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
8697    if endpoints.is_empty() {
8698        bail!(
8699            "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
8700        );
8701    }
8702    let start = Instant::now();
8703    let mut delivered = false;
8704    let mut last_err: Option<String> = None;
8705    let mut via_scope: Option<String> = None;
8706    for ep in &endpoints {
8707        // v0.7.0-alpha.19: scheme-aware dispatch — `unix://` endpoints
8708        // route via uds_request, others via reqwest. Allows peers with
8709        // UDS-tagged endpoints in their agent-card to receive events
8710        // over the local socket instead of loopback HTTP.
8711        match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8712            Ok(_) => {
8713                delivered = true;
8714                via_scope = Some(
8715                    match ep.scope {
8716                        crate::endpoints::EndpointScope::Local => "local",
8717                        crate::endpoints::EndpointScope::Lan => "lan",
8718                        crate::endpoints::EndpointScope::Uds => "uds",
8719                        crate::endpoints::EndpointScope::Federation => "federation",
8720                    }
8721                    .to_string(),
8722                );
8723                break;
8724            }
8725            Err(e) => last_err = Some(format!("{e:#}")),
8726        }
8727    }
8728    let rtt_ms = start.elapsed().as_millis() as u64;
8729
8730    let summary = json!({
8731        "role": role,
8732        "strategy": strategy,
8733        "routed_to": chosen_handle,
8734        "event_id": event_id,
8735        "delivered": delivered,
8736        "delivered_via": via_scope,
8737        "rtt_ms": rtt_ms,
8738        "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
8739        "error": last_err,
8740    });
8741
8742    if as_json {
8743        println!("{}", serde_json::to_string(&summary)?);
8744    } else if delivered {
8745        let via = via_scope.as_deref().unwrap_or("?");
8746        println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
8747    } else {
8748        let err = last_err.as_deref().unwrap_or("no endpoints reachable");
8749        bail!("delivery to `{chosen_handle}` failed: {err}");
8750    }
8751    Ok(())
8752}
8753
8754fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
8755    Ok(config::state_dir()?.join("mesh-route-cursor.json"))
8756}
8757
8758fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
8759    std::fs::read(path)
8760        .ok()
8761        .and_then(|b| serde_json::from_slice(&b).ok())
8762        .unwrap_or_default()
8763}
8764
8765fn write_mesh_route_cursors(
8766    path: &std::path::Path,
8767    cursors: &std::collections::BTreeMap<String, String>,
8768) -> Result<()> {
8769    if let Some(parent) = path.parent() {
8770        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
8771    }
8772    let body = serde_json::to_vec_pretty(cursors)?;
8773    std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
8774    Ok(())
8775}
8776
8777/// v0.6.4 (issue #20): mesh role tag dispatcher. Wraps the existing
8778/// `profile.role` persistence (re-uses `pair_profile::write_profile_field`)
8779/// behind a discoverability-friendlier surface, plus cross-session
8780/// enumeration for the list path.
8781fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
8782    match action {
8783        MeshRoleAction::Set { role, json } => {
8784            validate_role_tag(&role)?;
8785            let new_profile =
8786                crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
8787            if json {
8788                println!(
8789                    "{}",
8790                    serde_json::to_string(&json!({
8791                        "role": role,
8792                        "profile": new_profile,
8793                    }))?
8794                );
8795            } else {
8796                println!("self role = {role} (signed into agent-card)");
8797            }
8798        }
8799        MeshRoleAction::Get { peer, json } => {
8800            let (who, role) = match peer.as_deref() {
8801                None => {
8802                    let card = config::read_agent_card()?;
8803                    let role = card
8804                        .get("profile")
8805                        .and_then(|p| p.get("role"))
8806                        .and_then(Value::as_str)
8807                        .map(str::to_string);
8808                    let who = card
8809                        .get("did")
8810                        .and_then(Value::as_str)
8811                        .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
8812                        .unwrap_or_else(|| "self".to_string());
8813                    (who, role)
8814                }
8815                Some(handle) => {
8816                    let bare = crate::agent_card::bare_handle(handle).to_string();
8817                    let trust = config::read_trust()?;
8818                    let role = trust
8819                        .get("agents")
8820                        .and_then(|a| a.get(&bare))
8821                        .and_then(|a| a.get("card"))
8822                        .and_then(|c| c.get("profile"))
8823                        .and_then(|p| p.get("role"))
8824                        .and_then(Value::as_str)
8825                        .map(str::to_string);
8826                    (bare, role)
8827                }
8828            };
8829            if json {
8830                println!(
8831                    "{}",
8832                    serde_json::to_string(&json!({
8833                        "handle": who,
8834                        "role": role,
8835                    }))?
8836                );
8837            } else {
8838                match role {
8839                    Some(r) => println!("{who}: {r}"),
8840                    None => println!("{who}: (unset)"),
8841                }
8842            }
8843        }
8844        MeshRoleAction::List { json } => {
8845            let mut self_did: Option<String> = None;
8846            if let Ok(card) = config::read_agent_card() {
8847                self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
8848            }
8849            let sessions = crate::session::list_sessions()?;
8850            let mut rows: Vec<Value> = Vec::new();
8851            for s in &sessions {
8852                let card_path = s
8853                    .home_dir
8854                    .join("config")
8855                    .join("wire")
8856                    .join("agent-card.json");
8857                let role = std::fs::read(&card_path)
8858                    .ok()
8859                    .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8860                    .and_then(|c| {
8861                        c.get("profile")
8862                            .and_then(|p| p.get("role"))
8863                            .and_then(Value::as_str)
8864                            .map(str::to_string)
8865                    });
8866                let is_self = match (&self_did, &s.did) {
8867                    (Some(a), Some(b)) => a == b,
8868                    _ => false,
8869                };
8870                rows.push(json!({
8871                    "name": s.name,
8872                    "handle": s.handle,
8873                    "role": role,
8874                    "self": is_self,
8875                }));
8876            }
8877            rows.sort_by(|a, b| {
8878                a["name"]
8879                    .as_str()
8880                    .unwrap_or("")
8881                    .cmp(b["name"].as_str().unwrap_or(""))
8882            });
8883            if json {
8884                println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
8885            } else if rows.is_empty() {
8886                println!("no sister sessions on this machine.");
8887            } else {
8888                println!("SISTER ROLES (this machine):");
8889                for r in &rows {
8890                    let name = r["name"].as_str().unwrap_or("?");
8891                    let role = r["role"].as_str().unwrap_or("(unset)");
8892                    let marker = if r["self"].as_bool().unwrap_or(false) {
8893                        "    ← you"
8894                    } else {
8895                        ""
8896                    };
8897                    println!("  {name:<24} {role}{marker}");
8898                }
8899            }
8900        }
8901        MeshRoleAction::Clear { json } => {
8902            let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
8903            if json {
8904                println!(
8905                    "{}",
8906                    serde_json::to_string(&json!({
8907                        "cleared": true,
8908                        "profile": new_profile,
8909                    }))?
8910                );
8911            } else {
8912                println!("self role cleared");
8913            }
8914        }
8915    }
8916    Ok(())
8917}
8918
8919/// v0.6.4: role tag must be ASCII alphanumeric + `-` + `_`, 1-32 chars.
8920/// No vocabulary check — operators choose the taxonomy (planner /
8921/// reviewer / dispatcher / your-custom-tag). The constraint is purely
8922/// to keep the tag safe for filenames / URLs / shell args.
8923fn validate_role_tag(role: &str) -> Result<()> {
8924    if role.is_empty() {
8925        bail!("role must not be empty (use `wire mesh role --clear` to unset)");
8926    }
8927    if role.len() > 32 {
8928        bail!("role too long ({} chars; max 32)", role.len());
8929    }
8930    for c in role.chars() {
8931        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
8932            bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
8933        }
8934    }
8935    Ok(())
8936}
8937
8938/// v0.6.3 (issue #19): fan one signed event to every pinned peer.
8939///
8940/// **Routing.** Each recipient gets its own signed event (Ed25519 over the
8941/// canonical event including `to:`, so per-recipient signing is required;
8942/// the cost is one sign per peer = ~50µs each, dominated by relay RTT).
8943/// Per-recipient pushes happen in parallel via `std::thread::scope` so
8944/// broadcast-to-5 takes ~1× RTT, not 5×.
8945///
8946/// **Scope filter.** Default `local` — only peers reachable via a same-
8947/// machine local relay (priority-1 endpoint has `scope=local`). This is
8948/// the lowest-blast-radius default: local-only broadcasts cannot escape
8949/// the operator's machine. `federation` flips to public-relay peers
8950/// only; `both` removes the filter.
8951///
8952/// **Pinned-peers-only.** Walks `state.peers` — never .well-known
8953/// resolution, never trust["agents"] expansion. Closes #8-class
8954/// phonebook-scrape vectors by construction: an attacker pinning a
8955/// hostile handle has to first be pinned bidirectionally by the
8956/// operator, and even then `--exclude` is the loud opt-out.
8957fn cmd_mesh_broadcast(
8958    kind: &str,
8959    scope_str: &str,
8960    exclude: &[String],
8961    _noreply: bool,
8962    body_arg: &str,
8963    as_json: bool,
8964) -> Result<()> {
8965    use std::time::Instant;
8966
8967    if !config::is_initialized()? {
8968        bail!("not initialized — run `wire init <handle>` first");
8969    }
8970
8971    let scope = match scope_str {
8972        "local" => crate::endpoints::EndpointScope::Local,
8973        "federation" => crate::endpoints::EndpointScope::Federation,
8974        "both" => {
8975            // Sentinel: we don't actually have a `Both` variant on the
8976            // scope enum; use a tri-state below. Treat as Local for the
8977            // typed match and special-case it via the bool below.
8978            crate::endpoints::EndpointScope::Local
8979        }
8980        other => bail!("unknown scope `{other}` — use local | federation | both"),
8981    };
8982    let any_scope = scope_str == "both";
8983
8984    let state = config::read_relay_state()?;
8985    let peers = state["peers"].as_object().cloned().unwrap_or_default();
8986    if peers.is_empty() {
8987        bail!(
8988            "no peers pinned — run `wire accept-invite <invite-url>` or `wire dial <peer>@<relay>` first"
8989        );
8990    }
8991
8992    let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8993
8994    // Walk the pinned-peer set, filter by scope + exclude. Keep the
8995    // priority-ordered endpoint list for each match so the push can
8996    // try local first then fall through to federation (when scope=both).
8997    struct Target {
8998        handle: String,
8999        endpoints: Vec<crate::endpoints::Endpoint>,
9000    }
9001    let mut targets: Vec<Target> = Vec::new();
9002    let mut skipped_wrong_scope: Vec<String> = Vec::new();
9003    let mut skipped_excluded: Vec<String> = Vec::new();
9004    for handle in peers.keys() {
9005        if exclude_set.contains(handle.as_str()) {
9006            skipped_excluded.push(handle.clone());
9007            continue;
9008        }
9009        let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
9010        let filtered: Vec<crate::endpoints::Endpoint> = ordered
9011            .into_iter()
9012            .filter(|ep| any_scope || ep.scope == scope)
9013            .collect();
9014        if filtered.is_empty() {
9015            skipped_wrong_scope.push(handle.clone());
9016            continue;
9017        }
9018        targets.push(Target {
9019            handle: handle.clone(),
9020            endpoints: filtered,
9021        });
9022    }
9023
9024    if targets.is_empty() {
9025        bail!(
9026            "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
9027            skipped_excluded.len(),
9028            skipped_wrong_scope.len()
9029        );
9030    }
9031
9032    // Load signing material once; share across per-peer signatures.
9033    let sk_seed = config::read_private_key()?;
9034    let card = config::read_agent_card()?;
9035    let did = card
9036        .get("did")
9037        .and_then(Value::as_str)
9038        .ok_or_else(|| anyhow!("agent-card missing did"))?
9039        .to_string();
9040    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
9041    let pk_b64 = card
9042        .get("verify_keys")
9043        .and_then(Value::as_object)
9044        .and_then(|m| m.values().next())
9045        .and_then(|v| v.get("key"))
9046        .and_then(Value::as_str)
9047        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
9048    let pk_bytes = crate::signing::b64decode(pk_b64)?;
9049
9050    let body_value: Value = if body_arg == "-" {
9051        use std::io::Read;
9052        let mut raw = String::new();
9053        std::io::stdin()
9054            .read_to_string(&mut raw)
9055            .with_context(|| "reading body from stdin")?;
9056        serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
9057    } else if let Some(path) = body_arg.strip_prefix('@') {
9058        let raw =
9059            std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
9060        serde_json::from_str(&raw).unwrap_or(Value::String(raw))
9061    } else {
9062        Value::String(body_arg.to_string())
9063    };
9064
9065    let kind_id = parse_kind(kind)?;
9066    let now_iso = time::OffsetDateTime::now_utc()
9067        .format(&time::format_description::well_known::Rfc3339)
9068        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
9069
9070    let broadcast_id = generate_broadcast_id();
9071    let target_count = targets.len();
9072
9073    // Build + sign every event up front (sequential, ~50µs/sig). Then
9074    // queue to outbox + push to relay in parallel per-peer. Returns
9075    // a per-peer outcome we then sort by handle for deterministic output.
9076    let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
9077        Vec::with_capacity(targets.len());
9078    for t in &targets {
9079        let body = json!({
9080            "content": body_value,
9081            "broadcast_id": broadcast_id,
9082            "broadcast_target_count": target_count,
9083        });
9084        let event = json!({
9085            "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9086            "timestamp": now_iso,
9087            "from": did,
9088            "to": format!("did:wire:{}", t.handle),
9089            "type": kind,
9090            "kind": kind_id,
9091            "body": body,
9092        });
9093        let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
9094            .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
9095        let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
9096        signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
9097    }
9098
9099    // Persist to per-peer outbox FIRST (sequential — `append_outbox_record`
9100    // holds a per-path mutex; writes are independent across handles but
9101    // we want the side-effect ordering deterministic).
9102    for (peer, _, signed, _) in &signed_per_peer {
9103        let line = serde_json::to_vec(signed)?;
9104        config::append_outbox_record(peer, &line)?;
9105    }
9106
9107    // Per-peer parallel push. Each thread tries the priority-ordered
9108    // endpoint list; first 2xx wins. Aggregate (peer, delivered, rtt_ms,
9109    // error_opt) over a channel.
9110    use std::sync::mpsc;
9111    let (tx, rx) = mpsc::channel::<Value>();
9112    std::thread::scope(|s| {
9113        for (peer, endpoints, signed, event_id) in &signed_per_peer {
9114            let tx = tx.clone();
9115            let peer = peer.clone();
9116            let event_id = event_id.clone();
9117            let endpoints = endpoints.clone();
9118            let signed = signed.clone();
9119            s.spawn(move || {
9120                let start = Instant::now();
9121                let mut delivered = false;
9122                let mut last_err: Option<String> = None;
9123                let mut delivered_via: Option<String> = None;
9124                for ep in &endpoints {
9125                    // v0.7.0-alpha.19: scheme-aware dispatch (UDS via
9126                    // uds_request, else reqwest). Same as cmd_send's
9127                    // single-peer path above; this is the parallel
9128                    // multi-peer broadcast loop.
9129                    match crate::relay_client::post_event_to_endpoint(ep, &signed) {
9130                        Ok(_) => {
9131                            delivered = true;
9132                            delivered_via = Some(
9133                                match ep.scope {
9134                                    crate::endpoints::EndpointScope::Local => "local",
9135                                    crate::endpoints::EndpointScope::Lan => "lan",
9136                                    crate::endpoints::EndpointScope::Uds => "uds",
9137                                    crate::endpoints::EndpointScope::Federation => "federation",
9138                                }
9139                                .to_string(),
9140                            );
9141                            break;
9142                        }
9143                        Err(e) => last_err = Some(format!("{e:#}")),
9144                    }
9145                }
9146                let rtt_ms = start.elapsed().as_millis() as u64;
9147                let _ = tx.send(json!({
9148                    "peer": peer,
9149                    "event_id": event_id,
9150                    "delivered": delivered,
9151                    "delivered_via": delivered_via,
9152                    "rtt_ms": rtt_ms,
9153                    "error": last_err,
9154                }));
9155            });
9156        }
9157    });
9158    drop(tx);
9159
9160    let mut results: Vec<Value> = rx.iter().collect();
9161    results.sort_by(|a, b| {
9162        a["peer"]
9163            .as_str()
9164            .unwrap_or("")
9165            .cmp(b["peer"].as_str().unwrap_or(""))
9166    });
9167
9168    let delivered = results
9169        .iter()
9170        .filter(|r| r["delivered"].as_bool().unwrap_or(false))
9171        .count();
9172    let failed = results.len() - delivered;
9173
9174    let summary = json!({
9175        "broadcast_id": broadcast_id,
9176        "kind": kind,
9177        "scope": scope_str,
9178        "target_count": target_count,
9179        "delivered": delivered,
9180        "failed": failed,
9181        "skipped_excluded": skipped_excluded,
9182        "skipped_wrong_scope": skipped_wrong_scope,
9183        "results": results,
9184    });
9185
9186    if as_json {
9187        println!("{}", serde_json::to_string(&summary)?);
9188        return Ok(());
9189    }
9190
9191    println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
9192    for r in &results {
9193        let peer = r["peer"].as_str().unwrap_or("?");
9194        let delivered = r["delivered"].as_bool().unwrap_or(false);
9195        let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
9196        let via = r["delivered_via"].as_str().unwrap_or("");
9197        if delivered {
9198            println!("  {peer:<24} ✓ delivered ({rtt}ms, {via})");
9199        } else {
9200            let err = r["error"].as_str().unwrap_or("?");
9201            println!("  {peer:<24} ✗ failed — {err}");
9202        }
9203    }
9204    if !skipped_excluded.is_empty() {
9205        println!("  excluded: {}", skipped_excluded.join(", "));
9206    }
9207    if !skipped_wrong_scope.is_empty() {
9208        println!(
9209            "  skipped (wrong scope): {}",
9210            skipped_wrong_scope.join(", ")
9211        );
9212    }
9213    println!("broadcast_id: {broadcast_id}");
9214    Ok(())
9215}
9216
9217/// Random 16-byte UUID-shaped id for correlating a broadcast's recipient
9218/// events. Not strictly UUID v4 (no version/variant bits set) — receivers
9219/// correlate by string equality, the shape is for human readability.
9220fn generate_broadcast_id() -> String {
9221    use rand::RngCore;
9222    let mut buf = [0u8; 16];
9223    rand::thread_rng().fill_bytes(&mut buf);
9224    let h = hex::encode(buf);
9225    format!(
9226        "{}-{}-{}-{}-{}",
9227        &h[0..8],
9228        &h[8..12],
9229        &h[12..16],
9230        &h[16..20],
9231        &h[20..32],
9232    )
9233}
9234
9235fn cmd_session(cmd: SessionCommand) -> Result<()> {
9236    match cmd {
9237        SessionCommand::New {
9238            name,
9239            relay,
9240            with_local,
9241            local_relay,
9242            with_lan,
9243            lan_relay,
9244            with_uds,
9245            uds_socket,
9246            no_daemon,
9247            local_only,
9248            json,
9249        } => cmd_session_new(
9250            name.as_deref(),
9251            &relay,
9252            with_local,
9253            &local_relay,
9254            with_lan,
9255            lan_relay.as_deref(),
9256            with_uds,
9257            uds_socket.as_deref(),
9258            no_daemon,
9259            local_only,
9260            json,
9261        ),
9262        SessionCommand::List { json } => cmd_session_list(json),
9263        SessionCommand::ListLocal { json } => cmd_session_list_local(json),
9264        SessionCommand::PairAllLocal {
9265            settle_secs,
9266            federation_relay,
9267            json,
9268        } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
9269        SessionCommand::MeshStatus { stale_secs, json } => {
9270            cmd_session_mesh_status(stale_secs, json)
9271        }
9272        SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
9273        SessionCommand::Current { json } => cmd_session_current(json),
9274        SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
9275        SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
9276    }
9277}
9278
9279fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
9280    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9281    let cwd_str = crate::session::normalize_cwd_key(&cwd);
9282
9283    let resolved_name = match name_arg {
9284        Some(n) => crate::session::sanitize_name(n),
9285        None => crate::session::sanitize_name(
9286            cwd.file_name()
9287                .and_then(|s| s.to_str())
9288                .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
9289        ),
9290    };
9291
9292    let session_home = crate::session::session_dir(&resolved_name)?;
9293    if !session_home.exists() {
9294        bail!(
9295            "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
9296            session_home.display()
9297        );
9298    }
9299
9300    let prior = crate::session::read_registry()
9301        .ok()
9302        .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
9303    if prior.as_deref() == Some(resolved_name.as_str()) {
9304        if json {
9305            println!(
9306                "{}",
9307                serde_json::to_string(&json!({
9308                    "cwd": cwd_str,
9309                    "session": resolved_name,
9310                    "changed": false,
9311                }))?
9312            );
9313        } else {
9314            println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
9315        }
9316        return Ok(());
9317    }
9318    if let Some(prior_name) = &prior {
9319        eprintln!(
9320            "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
9321        );
9322    }
9323
9324    crate::session::update_registry(|reg| {
9325        reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
9326        Ok(())
9327    })?;
9328
9329    if json {
9330        println!(
9331            "{}",
9332            serde_json::to_string(&json!({
9333                "cwd": cwd_str,
9334                "session": resolved_name,
9335                "changed": true,
9336                "previous": prior,
9337            }))?
9338        );
9339    } else {
9340        println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
9341        println!("(next `wire` invocation from this cwd will auto-detect into this session)");
9342    }
9343    Ok(())
9344}
9345
9346fn resolve_session_name(name: Option<&str>) -> Result<String> {
9347    if let Some(n) = name {
9348        return Ok(crate::session::sanitize_name(n));
9349    }
9350    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9351    let registry = crate::session::read_registry().unwrap_or_default();
9352    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
9353}
9354
9355#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
9356// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
9357// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
9358fn cmd_session_new(
9359    name_arg: Option<&str>,
9360    relay: &str,
9361    with_local: bool,
9362    local_relay: &str,
9363    with_lan: bool,
9364    lan_relay: Option<&str>,
9365    with_uds: bool,
9366    uds_socket: Option<&std::path::Path>,
9367    no_daemon: bool,
9368    local_only: bool,
9369    as_json: bool,
9370) -> Result<()> {
9371    // v0.6.6: --local-only implies --with-local (a federation-free
9372    // session with no endpoints at all would be unaddressable).
9373    let with_local = with_local || local_only;
9374    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
9375    if with_lan && lan_relay.is_none() {
9376        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
9377    }
9378    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
9379    if with_uds && uds_socket.is_none() {
9380        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
9381    }
9382    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9383    let mut registry = crate::session::read_registry().unwrap_or_default();
9384    let name = match name_arg {
9385        Some(n) => crate::session::sanitize_name(n),
9386        None => crate::session::derive_name_from_cwd(&cwd, &registry),
9387    };
9388    let session_home = crate::session::session_dir(&name)?;
9389
9390    let already_exists = session_home.exists()
9391        && session_home
9392            .join("config")
9393            .join("wire")
9394            .join("agent-card.json")
9395            .exists();
9396    if already_exists {
9397        // Idempotent: re-register the cwd (if not already), refresh the
9398        // daemon if requested, surface the env-var line. Do not re-init
9399        // identity — that would clobber the keypair.
9400        registry
9401            .by_cwd
9402            .insert(cwd.to_string_lossy().into_owned(), name.clone());
9403        crate::session::write_registry(&registry)?;
9404        let info = render_session_info(&name, &session_home, &cwd)?;
9405        emit_session_new_result(&info, "already_exists", as_json)?;
9406        if !no_daemon {
9407            ensure_session_daemon(&session_home)?;
9408        }
9409        return Ok(());
9410    }
9411
9412    std::fs::create_dir_all(&session_home)
9413        .with_context(|| format!("creating session dir {session_home:?}"))?;
9414
9415    // Phase 1: init identity in the new session's WIRE_HOME. For
9416    // federation-bound sessions we pass `--relay` so init also
9417    // allocates a federation slot in the same step; for `--local-only`
9418    // we run init with `--offline` (v0.9 requires explicit reachability
9419    // acknowledgement at init time) because cmd_session_new allocates
9420    // the local-relay slot itself via try_allocate_local_slot below.
9421    // The session is not actually slotless — init is just deferred to
9422    // the subsequent allocation pass.
9423    let init_args: Vec<&str> = if local_only {
9424        vec!["init", &name, "--offline"]
9425    } else {
9426        vec!["init", &name, "--relay", relay]
9427    };
9428    let init_status = run_wire_with_home(&session_home, &init_args)?;
9429    if !init_status.success() {
9430        let how = if local_only {
9431            format!("`wire init {name}` (local-only)")
9432        } else {
9433            format!("`wire init {name} --relay {relay}`")
9434        };
9435        bail!("{how} failed inside session dir {session_home:?}");
9436    }
9437
9438    // Phase 2: claim the handle on the federation relay — SKIPPED when
9439    // `--local-only`. Local-only sessions have no public address and
9440    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
9441    // tries to publish them.
9442    let effective_handle = if local_only {
9443        name.clone()
9444    } else {
9445        let mut claim_attempt = 0u32;
9446        let mut effective = name.clone();
9447        loop {
9448            claim_attempt += 1;
9449            let status =
9450                run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
9451            if status.success() {
9452                break;
9453            }
9454            if claim_attempt >= 5 {
9455                bail!(
9456                    "5 failed attempts to claim a handle on {relay} for session {name}. \
9457                     Try `wire session destroy {name} --force` and re-run with a different name, \
9458                     or use `--local-only` if you don't need a federation address."
9459                );
9460            }
9461            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
9462            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
9463            let token = suffix
9464                .rsplit('-')
9465                .next()
9466                .filter(|t| t.len() == 4)
9467                .map(str::to_string)
9468                .unwrap_or_else(|| format!("{claim_attempt}"));
9469            effective = format!("{name}-{token}");
9470        }
9471        effective
9472    };
9473
9474    // Persist the cwd → name mapping NOW so subsequent invocations from
9475    // this directory short-circuit to the "already_exists" branch.
9476    registry
9477        .by_cwd
9478        .insert(cwd.to_string_lossy().into_owned(), name.clone());
9479    crate::session::write_registry(&registry)?;
9480
9481    // v0.5.17: --with-local probes the local relay and, if it's
9482    // reachable, allocates a second slot there. The session's
9483    // relay_state.json grows a `self.endpoints[]` array carrying both
9484    // endpoints; routing layer (cmd_push) prefers local for sister-
9485    // session peers that also have a local slot.
9486    //
9487    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
9488    // allocation; a failed probe leaves the session with no endpoints,
9489    // which we surface as a hard error (the operator asked for local-
9490    // only but the local relay isn't running — fix that first).
9491    if with_local {
9492        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
9493        if local_only {
9494            // Verify the local slot landed. If the local relay was
9495            // unreachable, the session would be unreachable from
9496            // anywhere — surface that loudly instead of leaving an
9497            // orphaned session dir.
9498            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
9499            let state: Value = std::fs::read(&relay_state_path)
9500                .ok()
9501                .and_then(|b| serde_json::from_slice(&b).ok())
9502                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9503            let endpoints = crate::endpoints::self_endpoints(&state);
9504            let has_local = endpoints
9505                .iter()
9506                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
9507            if !has_local {
9508                bail!(
9509                    "--local-only requested but local-relay probe at {local_relay} failed — \
9510                     ensure the local relay is running (`wire service install --local-relay`), \
9511                     then re-run `wire session new {name} --local-only`."
9512                );
9513            }
9514        }
9515    }
9516
9517    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
9518    // Sits AFTER local because cmd_session_new's flow is "add endpoints
9519    // alongside existing self.endpoints[]" — order independent post-init.
9520    if with_lan && let Some(lan_url) = lan_relay {
9521        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
9522    }
9523    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
9524    if with_uds && let Some(socket_path) = uds_socket {
9525        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
9526    }
9527
9528    if !no_daemon {
9529        ensure_session_daemon(&session_home)?;
9530    }
9531
9532    let info = render_session_info(&name, &session_home, &cwd)?;
9533    emit_session_new_result(&info, "created", as_json)
9534}
9535
9536/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
9537/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
9538/// sister sessions can route over the local socket instead of loopback
9539/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
9540/// alpha.17 — reqwest has no UDS support.
9541///
9542/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
9543/// and try_allocate_lan_slot semantics): session stays at existing
9544/// endpoint mix, operator can retry once the UDS relay is up.
9545#[cfg(unix)]
9546fn try_allocate_uds_slot(
9547    session_home: &std::path::Path,
9548    handle: &str,
9549    uds_socket: &std::path::Path,
9550) {
9551    // Probe healthz first so we fail fast with a clear stderr if the
9552    // socket doesn't exist OR isn't a wire relay.
9553    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
9554        Ok((200, _)) => true,
9555        Ok((status, body)) => {
9556            eprintln!(
9557                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
9558                String::from_utf8_lossy(&body)
9559            );
9560            return;
9561        }
9562        Err(e) => {
9563            eprintln!(
9564                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
9565                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
9566            );
9567            return;
9568        }
9569    };
9570    if !healthz {
9571        return;
9572    }
9573
9574    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
9575    let alloc_body = serde_json::json!({"handle": handle}).to_string();
9576    let (status, body) = match crate::relay_client::uds_request(
9577        uds_socket,
9578        "POST",
9579        "/v1/slot/allocate",
9580        &[("Content-Type", "application/json")],
9581        alloc_body.as_bytes(),
9582    ) {
9583        Ok(r) => r,
9584        Err(e) => {
9585            eprintln!(
9586                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
9587            );
9588            return;
9589        }
9590    };
9591    if status >= 300 {
9592        eprintln!(
9593            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
9594            String::from_utf8_lossy(&body)
9595        );
9596        return;
9597    }
9598    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
9599        Ok(a) => a,
9600        Err(e) => {
9601            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
9602            return;
9603        }
9604    };
9605
9606    let state_path = session_home.join("config").join("wire").join("relay.json");
9607    let mut state: serde_json::Value = std::fs::read(&state_path)
9608        .ok()
9609        .and_then(|b| serde_json::from_slice(&b).ok())
9610        .unwrap_or_else(|| serde_json::json!({}));
9611
9612    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9613        .get("self")
9614        .and_then(|s| s.get("endpoints"))
9615        .and_then(|e| e.as_array())
9616        .map(|arr| {
9617            arr.iter()
9618                .filter_map(|v| {
9619                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9620                })
9621                .collect()
9622        })
9623        .unwrap_or_default();
9624    endpoints.push(crate::endpoints::Endpoint::uds(
9625        format!("unix://{}", uds_socket.display()),
9626        alloc.slot_id.clone(),
9627        alloc.slot_token.clone(),
9628    ));
9629
9630    let self_obj = state
9631        .as_object_mut()
9632        .expect("relay_state root is an object")
9633        .entry("self")
9634        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9635    if !self_obj.is_object() {
9636        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9637    }
9638    if let Some(obj) = self_obj.as_object_mut() {
9639        obj.insert(
9640            "endpoints".into(),
9641            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9642        );
9643    }
9644    if let Err(e) = std::fs::write(
9645        &state_path,
9646        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9647    ) {
9648        eprintln!("wire session new: failed to write {state_path:?}: {e}");
9649        return;
9650    }
9651    eprintln!(
9652        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
9653        uds_socket.display(),
9654        alloc.slot_id
9655    );
9656}
9657
9658#[cfg(not(unix))]
9659fn try_allocate_uds_slot(
9660    _session_home: &std::path::Path,
9661    _handle: &str,
9662    _uds_socket: &std::path::Path,
9663) {
9664    eprintln!(
9665        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
9666    );
9667}
9668
9669/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
9670/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
9671/// pulling the agent-card see a third reachable address.
9672///
9673/// Mirrors `try_allocate_local_slot` but tags the endpoint
9674/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
9675/// session stays at whatever endpoint mix it already had — operators
9676/// can retry with `wire session new --with-lan --lan-relay <url>` once
9677/// the LAN relay is up.
9678fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
9679    let probe = match crate::relay_client::build_blocking_client(Some(
9680        std::time::Duration::from_millis(500),
9681    )) {
9682        Ok(c) => c,
9683        Err(e) => {
9684            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
9685            return;
9686        }
9687    };
9688    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
9689    match probe.get(&healthz_url).send() {
9690        Ok(resp) if resp.status().is_success() => {}
9691        Ok(resp) => {
9692            eprintln!(
9693                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
9694                resp.status()
9695            );
9696            return;
9697        }
9698        Err(e) => {
9699            eprintln!(
9700                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
9701                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
9702                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9703            );
9704            return;
9705        }
9706    };
9707
9708    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
9709    let alloc = match lan_client.allocate_slot(Some(handle)) {
9710        Ok(a) => a,
9711        Err(e) => {
9712            eprintln!(
9713                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
9714            );
9715            return;
9716        }
9717    };
9718
9719    let state_path = session_home.join("config").join("wire").join("relay.json");
9720    let mut state: serde_json::Value = std::fs::read(&state_path)
9721        .ok()
9722        .and_then(|b| serde_json::from_slice(&b).ok())
9723        .unwrap_or_else(|| serde_json::json!({}));
9724
9725    // Read existing endpoints array and add the LAN one. Preserve
9726    // federation / local entries already there.
9727    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9728        .get("self")
9729        .and_then(|s| s.get("endpoints"))
9730        .and_then(|e| e.as_array())
9731        .map(|arr| {
9732            arr.iter()
9733                .filter_map(|v| {
9734                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9735                })
9736                .collect()
9737        })
9738        .unwrap_or_default();
9739    endpoints.push(crate::endpoints::Endpoint::lan(
9740        lan_relay.trim_end_matches('/').to_string(),
9741        alloc.slot_id.clone(),
9742        alloc.slot_token.clone(),
9743    ));
9744
9745    let self_obj = state
9746        .as_object_mut()
9747        .expect("relay_state root is an object")
9748        .entry("self")
9749        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9750    if !self_obj.is_object() {
9751        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9752    }
9753    if let Some(obj) = self_obj.as_object_mut() {
9754        obj.insert(
9755            "endpoints".into(),
9756            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9757        );
9758    }
9759    if let Err(e) = std::fs::write(
9760        &state_path,
9761        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9762    ) {
9763        eprintln!("wire session new: failed to write {state_path:?}: {e}");
9764        return;
9765    }
9766    eprintln!(
9767        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
9768        alloc.slot_id
9769    );
9770}
9771
9772/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
9773/// a short timeout, allocate a slot there and update the session's
9774/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
9775///
9776/// Failure to reach the local relay is NOT fatal — the session stays
9777/// federation-only. Logs to stderr on failure so operators can tell
9778/// the local relay isn't running, but doesn't abort the bootstrap.
9779fn try_allocate_local_slot(
9780    session_home: &std::path::Path,
9781    handle: &str,
9782    _federation_relay: &str,
9783    local_relay: &str,
9784) {
9785    // Probe healthz with a tight timeout. Use a fresh client (don't
9786    // share the daemon-wide one) so the timeout is local to this call.
9787    let probe = match crate::relay_client::build_blocking_client(Some(
9788        std::time::Duration::from_millis(500),
9789    )) {
9790        Ok(c) => c,
9791        Err(e) => {
9792            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
9793            return;
9794        }
9795    };
9796    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
9797    match probe.get(&healthz_url).send() {
9798        Ok(resp) if resp.status().is_success() => {}
9799        Ok(resp) => {
9800            eprintln!(
9801                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
9802                resp.status()
9803            );
9804            return;
9805        }
9806        Err(e) => {
9807            eprintln!(
9808                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
9809                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
9810                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9811            );
9812            return;
9813        }
9814    };
9815
9816    // Allocate a slot on the local relay.
9817    let local_client = crate::relay_client::RelayClient::new(local_relay);
9818    let alloc = match local_client.allocate_slot(Some(handle)) {
9819        Ok(a) => a,
9820        Err(e) => {
9821            eprintln!(
9822                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
9823            );
9824            return;
9825        }
9826    };
9827
9828    // Merge into the session's relay.json. We invoke wire via
9829    // run_wire_with_home for federation calls (subprocess isolation),
9830    // but relay.json is a simple file we can edit directly
9831    // — and need to, because there's no `wire bind-relay --add-local`
9832    // command yet (could add later; out of scope for v0.5.17 MVP).
9833    //
9834    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
9835    // does not exist (canonical filename is `relay.json` per
9836    // `config::relay_state_path`). The mis-named file write succeeded
9837    // but landed in a sibling path nothing else reads. Every
9838    // `wire session new --with-local` invocation silently degraded to
9839    // federation-only despite the "local slot allocated" stderr line.
9840    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
9841    // session's relay.json — it had only the federation endpoint.
9842    let state_path = session_home.join("config").join("wire").join("relay.json");
9843    let mut state: serde_json::Value = std::fs::read(&state_path)
9844        .ok()
9845        .and_then(|b| serde_json::from_slice(&b).ok())
9846        .unwrap_or_else(|| serde_json::json!({}));
9847    // Read the existing federation self info (already written by
9848    // `wire init` + `wire bind-relay` path during session bootstrap).
9849    let fed_endpoint = state.get("self").and_then(|s| {
9850        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
9851        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
9852        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
9853        Some(crate::endpoints::Endpoint::federation(
9854            url.to_string(),
9855            slot_id.to_string(),
9856            slot_token.to_string(),
9857        ))
9858    });
9859
9860    let local_endpoint = crate::endpoints::Endpoint::local(
9861        local_relay.trim_end_matches('/').to_string(),
9862        alloc.slot_id.clone(),
9863        alloc.slot_token.clone(),
9864    );
9865
9866    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
9867    if let Some(f) = fed_endpoint.clone() {
9868        endpoints.push(f);
9869    }
9870    endpoints.push(local_endpoint);
9871
9872    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
9873    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
9874    // `slot_token` fields must point at the LOCAL endpoint so callers
9875    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
9876    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
9877    // still find a valid slot. Pre-v0.6.6 this branch wrote
9878    // `relay_url: federation_relay` with no slot_id, which produced
9879    // half-populated self state that broke wire-accept on local-only
9880    // sessions.
9881    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
9882        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
9883        None => (
9884            local_relay.trim_end_matches('/').to_string(),
9885            alloc.slot_id.clone(),
9886            alloc.slot_token.clone(),
9887        ),
9888    };
9889    let self_obj = state
9890        .as_object_mut()
9891        .expect("relay_state root is an object")
9892        .entry("self")
9893        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9894    // The entry might be Value::Null (left by read_relay_state's default
9895    // template) — replace with an object before mutating.
9896    if !self_obj.is_object() {
9897        *self_obj = serde_json::Value::Object(serde_json::Map::new());
9898    }
9899    if let Some(obj) = self_obj.as_object_mut() {
9900        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
9901        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
9902        obj.insert(
9903            "slot_token".into(),
9904            serde_json::Value::String(legacy_slot_token),
9905        );
9906        obj.insert(
9907            "endpoints".into(),
9908            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9909        );
9910    }
9911
9912    if let Err(e) = std::fs::write(
9913        &state_path,
9914        serde_json::to_vec_pretty(&state).unwrap_or_default(),
9915    ) {
9916        eprintln!(
9917            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
9918        );
9919        return;
9920    }
9921    eprintln!(
9922        "wire session new: local slot allocated on {local_relay} (slot_id={})",
9923        alloc.slot_id
9924    );
9925}
9926
9927fn render_session_info(
9928    name: &str,
9929    session_home: &std::path::Path,
9930    cwd: &std::path::Path,
9931) -> Result<serde_json::Value> {
9932    let card_path = session_home
9933        .join("config")
9934        .join("wire")
9935        .join("agent-card.json");
9936    let (did, handle) = if card_path.exists() {
9937        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
9938        let did = card
9939            .get("did")
9940            .and_then(Value::as_str)
9941            .unwrap_or("")
9942            .to_string();
9943        let handle = card
9944            .get("handle")
9945            .and_then(Value::as_str)
9946            .map(str::to_string)
9947            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9948        (did, handle)
9949    } else {
9950        (String::new(), String::new())
9951    };
9952    Ok(json!({
9953        "name": name,
9954        "home_dir": session_home.to_string_lossy(),
9955        "cwd": cwd.to_string_lossy(),
9956        "did": did,
9957        "handle": handle,
9958        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9959    }))
9960}
9961
9962fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
9963    if as_json {
9964        let mut obj = info.clone();
9965        obj["status"] = json!(status);
9966        println!("{}", serde_json::to_string(&obj)?);
9967    } else {
9968        let name = info["name"].as_str().unwrap_or("?");
9969        let handle = info["handle"].as_str().unwrap_or("?");
9970        let home = info["home_dir"].as_str().unwrap_or("?");
9971        let did = info["did"].as_str().unwrap_or("?");
9972        let export = info["export"].as_str().unwrap_or("?");
9973        let prefix = if status == "already_exists" {
9974            "session already exists (re-registered cwd)"
9975        } else {
9976            "session created"
9977        };
9978        println!(
9979            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
9980        );
9981    }
9982    Ok(())
9983}
9984
9985fn run_wire_with_home(
9986    session_home: &std::path::Path,
9987    args: &[&str],
9988) -> Result<std::process::ExitStatus> {
9989    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9990    let status = std::process::Command::new(&bin)
9991        .env("WIRE_HOME", session_home)
9992        .env_remove("RUST_LOG")
9993        // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
9994        // We already own the session; nested init would clobber state.
9995        .env("WIRE_AUTO_INIT", "0")
9996        .args(args)
9997        .status()
9998        .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9999    Ok(status)
10000}
10001
10002/// v0.7.0-alpha.2: idempotent per-cwd session creation.
10003///
10004/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
10005/// registered session for the current cwd — including via parent-walk —
10006/// this creates one inline so every Claude tab in a fresh project gets
10007/// its own wire identity rather than collapsing onto the machine-wide
10008/// default. Without this, multiple Claudes in unwired cwds all render
10009/// the same character (the default identity's character), defeating the
10010/// "every session looks different" promise.
10011///
10012/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
10013/// `run_wire_with_home` subprocess context).
10014///
10015/// Best-effort: any failure (no home dir, name collision pathology,
10016/// `wire init` subprocess crash) is logged to stderr and we fall back
10017/// to default identity. Must not block MCP startup.
10018///
10019/// MUST be called BEFORE worker thread spawn (env::set_var safety).
10020pub fn maybe_auto_init_cwd_session(label: &str) {
10021    if std::env::var("WIRE_HOME").is_ok() {
10022        return; // explicit override OR auto-detect already won
10023    }
10024    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
10025        return; // operator opt-out
10026    }
10027    let cwd = match std::env::current_dir() {
10028        Ok(c) => c,
10029        Err(_) => return,
10030    };
10031    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
10032    // already runs but we want to be robust to ordering).
10033    if crate::session::detect_session_wire_home(&cwd).is_some() {
10034        return;
10035    }
10036
10037    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
10038    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
10039    // Two different cwds with the same basename (e.g. /a/projx +
10040    // /b/projx) used to race outside the lock: both read empty
10041    // registry, both derived name="projx", per-name lock didn't help
10042    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
10043    //
10044    // Single lock serializes ALL auto-init across the sessions_root.
10045    // Inside the lock: re-read registry, derive_name_from_cwd which
10046    // adds path-hash suffix when basename is occupied by another cwd
10047    // already committed to the registry. Different cwds get DIFFERENT
10048    // names guaranteed.
10049    //
10050    // Cost: parallel auto-inits in different cwds now serialize
10051    // (~hundreds of ms each when local relay is up). Acceptable —
10052    // auto-init runs once per cwd per machine; not a hot path.
10053    use fs2::FileExt;
10054    let sessions_root = match crate::session::sessions_root() {
10055        Ok(r) => r,
10056        Err(_) => return,
10057    };
10058    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
10059        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
10060        return;
10061    }
10062    let lock_path = sessions_root.join(".auto-init.lock");
10063    let lock_file = match std::fs::OpenOptions::new()
10064        .create(true)
10065        .truncate(false)
10066        .read(true)
10067        .write(true)
10068        .open(&lock_path)
10069    {
10070        Ok(f) => f,
10071        Err(e) => {
10072            eprintln!(
10073                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
10074            );
10075            return;
10076        }
10077    };
10078    if let Err(e) = lock_file.lock_exclusive() {
10079        eprintln!(
10080            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
10081        );
10082        return;
10083    }
10084    // Lock acquired. Read registry + derive name now that all parallel
10085    // racers serialize through us — derive_name_from_cwd adds a
10086    // path-hash suffix if the basename is already claimed by another
10087    // cwd in the (now-stable) registry.
10088    let registry = crate::session::read_registry().unwrap_or_default();
10089    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
10090    let session_home = match crate::session::session_dir(&name) {
10091        Ok(h) => h,
10092        Err(_) => {
10093            let _ = fs2::FileExt::unlock(&lock_file);
10094            return;
10095        }
10096    };
10097    let agent_card_path = session_home
10098        .join("config")
10099        .join("wire")
10100        .join("agent-card.json");
10101    let needs_init = !agent_card_path.exists();
10102
10103    if needs_init {
10104        if let Err(e) = std::fs::create_dir_all(&session_home) {
10105            eprintln!(
10106                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
10107            );
10108            let _ = fs2::FileExt::unlock(&lock_file);
10109            return;
10110        }
10111        // v0.9: --offline; the surrounding session-spawn path runs
10112        // try_allocate_local_slot afterward to attach an inbound slot
10113        // when a local relay is available. Init itself stays slotless
10114        // because it's a precursor step, not the final state.
10115        match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
10116            Ok(status) if status.success() => {}
10117            Ok(status) => {
10118                eprintln!(
10119                    "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
10120                );
10121                let _ = fs2::FileExt::unlock(&lock_file);
10122                return;
10123            }
10124            Err(e) => {
10125                eprintln!(
10126                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
10127                );
10128                let _ = fs2::FileExt::unlock(&lock_file);
10129                return;
10130            }
10131        }
10132        // Best-effort: allocate a local-relay slot so this auto-init'd
10133        // session is addressable by sister sessions. Skipped silently when
10134        // the local relay isn't running (the function itself reports to
10135        // stderr). Auto-init'd sessions without endpoints can still
10136        // surface their character but cannot receive pair_drops until the
10137        // operator runs `wire bind-relay` or restarts the local relay.
10138        try_allocate_local_slot(
10139            &session_home,
10140            &name,
10141            "https://wireup.net",
10142            "http://127.0.0.1:8771",
10143        );
10144    } else {
10145        // Race loser path: peer already created the session. Surface
10146        // this honestly so the operator can see we adopted rather than
10147        // double-initialized.
10148        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10149            eprintln!(
10150                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
10151            );
10152        }
10153    }
10154    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
10155    // BEFORE releasing the auto-init lock. Pre-fix released the lock
10156    // here and committed the registry update afterward — racers in
10157    // OTHER cwds with the same basename would acquire the lock,
10158    // read the registry (still without our entry), and derive the
10159    // SAME name we just claimed. Live regression test caught it:
10160    // two cwds /a/projx + /b/projx both got name "projx", both
10161    // mapped to the same identity. Update the registry WHILE STILL
10162    // holding the auto-init lock so the next racer sees our claim.
10163    let cwd_key = crate::session::normalize_cwd_key(&cwd);
10164    let name_for_reg = name.clone();
10165    if let Err(e) = crate::session::update_registry(|reg| {
10166        reg.by_cwd.insert(cwd_key, name_for_reg);
10167        Ok(())
10168    }) {
10169        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
10170        // proceed — env var still gets set below
10171    }
10172    // NOW release the lock — racers waiting will see our registry
10173    // entry on their re-read.
10174    let _ = fs2::FileExt::unlock(&lock_file);
10175
10176    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10177        eprintln!(
10178            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
10179            cwd.display(),
10180            session_home.display()
10181        );
10182    }
10183    // SAFETY: caller contract is "before any thread spawn." MCP::run
10184    // calls this immediately after `maybe_adopt_session_wire_home`.
10185    unsafe {
10186        std::env::set_var("WIRE_HOME", &session_home);
10187    }
10188}
10189
10190fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
10191    // Check if a daemon is already alive in this session's WIRE_HOME.
10192    // If so, no-op (let the existing process keep running).
10193    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10194    if pidfile.exists() {
10195        let bytes = std::fs::read(&pidfile).unwrap_or_default();
10196        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10197            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10198        } else {
10199            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10200        };
10201        if let Some(p) = pid {
10202            let alive = {
10203                #[cfg(target_os = "linux")]
10204                {
10205                    std::path::Path::new(&format!("/proc/{p}")).exists()
10206                }
10207                #[cfg(not(target_os = "linux"))]
10208                {
10209                    std::process::Command::new("kill")
10210                        .args(["-0", &p.to_string()])
10211                        .output()
10212                        .map(|o| o.status.success())
10213                        .unwrap_or(false)
10214                }
10215            };
10216            if alive {
10217                return Ok(());
10218            }
10219        }
10220    }
10221
10222    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
10223    // versioned pidfile; we just kick it off and return.
10224    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
10225    let log_path = session_home.join("state").join("wire").join("daemon.log");
10226    if let Some(parent) = log_path.parent() {
10227        std::fs::create_dir_all(parent).ok();
10228    }
10229    let log_file = std::fs::OpenOptions::new()
10230        .create(true)
10231        .append(true)
10232        .open(&log_path)
10233        .with_context(|| format!("opening daemon log {log_path:?}"))?;
10234    let log_err = log_file.try_clone()?;
10235    std::process::Command::new(&bin)
10236        .env("WIRE_HOME", session_home)
10237        .env_remove("RUST_LOG")
10238        .args(["daemon", "--interval", "5"])
10239        .stdout(log_file)
10240        .stderr(log_err)
10241        .stdin(std::process::Stdio::null())
10242        .spawn()
10243        .with_context(|| "spawning session-local `wire daemon`")?;
10244    Ok(())
10245}
10246
10247fn cmd_session_list(as_json: bool) -> Result<()> {
10248    let items = crate::session::list_sessions()?;
10249    if as_json {
10250        println!("{}", serde_json::to_string(&items)?);
10251        return Ok(());
10252    }
10253    if items.is_empty() {
10254        println!("no sessions on this machine. `wire session new` to create one.");
10255        return Ok(());
10256    }
10257    println!(
10258        "{:<22} {:<24} {:<24} {:<10} CWD",
10259        "PERSONA", "NAME", "HANDLE", "DAEMON"
10260    );
10261    for s in items {
10262        // ANSI-escape-wrapped character takes more visual width than its
10263        // displayed glyph count; pad based on the plain-text form, then
10264        // wrap in escapes so the column lines up across rows.
10265        let plain = s
10266            .character
10267            .as_ref()
10268            .map(|c| c.short())
10269            .unwrap_or_else(|| "?".to_string());
10270        let colored = s
10271            .character
10272            .as_ref()
10273            .map(|c| c.colored())
10274            .unwrap_or_else(|| "?".to_string());
10275        // Approximate display width: emoji renders as ~2 cells in most
10276        // terminals; the rest are 1 cell each. We pad to 18 displayed
10277        // chars (≈22 byte slots when counting emoji).
10278        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
10279        let pad = 22usize.saturating_sub(displayed_width);
10280        println!(
10281            "{}{}  {:<24} {:<24} {:<10} {}",
10282            colored,
10283            " ".repeat(pad),
10284            s.name,
10285            s.handle.as_deref().unwrap_or("?"),
10286            if s.daemon_running { "running" } else { "down" },
10287            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10288        );
10289    }
10290    Ok(())
10291}
10292
10293/// v0.5.19: `wire session list-local` — sister-session discovery.
10294///
10295/// For each on-disk session, read its `relay-state.json` and surface
10296/// the ones that have a Local-scope endpoint (allocated via
10297/// `wire session new --with-local`). Group by the local-relay URL so
10298/// the operator can see at a glance which sessions are mutually
10299/// reachable over the same loopback relay.
10300///
10301/// Read-only, no daemon contact. Useful as the prelude to teaming /
10302/// pairing same-box sister claudes (see also `wire session
10303/// pair-all-local` once implemented).
10304fn cmd_session_list_local(as_json: bool) -> Result<()> {
10305    let listing = crate::session::list_local_sessions()?;
10306    if as_json {
10307        println!("{}", serde_json::to_string(&listing)?);
10308        return Ok(());
10309    }
10310
10311    if listing.local.is_empty() && listing.federation_only.is_empty() {
10312        println!(
10313            "no sessions on this machine. `wire session new --with-local` to create one \
10314             with a local-relay endpoint (start the relay first: \
10315             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
10316        );
10317        return Ok(());
10318    }
10319
10320    if listing.local.is_empty() {
10321        println!(
10322            "no sister sessions reachable via a local relay. \
10323             Re-run `wire session new --with-local` to add a Local endpoint, or \
10324             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
10325        );
10326    } else {
10327        // Stable iteration order: sort the relay URLs.
10328        let mut keys: Vec<&String> = listing.local.keys().collect();
10329        keys.sort();
10330        for relay_url in keys {
10331            let group = &listing.local[relay_url];
10332            println!("LOCAL RELAY: {relay_url}");
10333            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
10334            for s in group {
10335                println!(
10336                    "  {:<24} {:<32} {:<10} {}",
10337                    s.name,
10338                    s.handle.as_deref().unwrap_or("?"),
10339                    if s.daemon_running { "running" } else { "down" },
10340                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10341                );
10342            }
10343            println!();
10344        }
10345    }
10346
10347    if !listing.federation_only.is_empty() {
10348        println!("federation-only (no local endpoint):");
10349        for s in &listing.federation_only {
10350            println!(
10351                "  {:<24} {:<32} {}",
10352                s.name,
10353                s.handle.as_deref().unwrap_or("?"),
10354                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10355            );
10356        }
10357    }
10358    Ok(())
10359}
10360
10361/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
10362/// session that has a Local-scope endpoint. Skips already-paired
10363/// pairs; reports a per-pair outcome JSON suitable for scripting.
10364///
10365/// Same-uid trust anchor: the caller owns every session enumerated by
10366/// `list_local_sessions`, so the operator running this command IS the
10367/// consent for both sides. The bilateral SAS / network-level handshake
10368/// assumes strangers; same-uid sister sessions are not strangers.
10369///
10370/// Per-pair flow (sequential to keep relay-side load + log clarity):
10371///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
10372///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
10373///   3. sleep settle_secs                       (pair_drop reaches B)
10374///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
10375///   5. WIRE_HOME=B wire accept <A-bare>   (B pins A, sends ack)
10376///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
10377///   7. sleep settle_secs                       (ack reaches A)
10378///   8. WIRE_HOME=A wire pull --json            (A pins B)
10379fn cmd_session_pair_all_local(
10380    settle_secs: u64,
10381    federation_relay: &str,
10382    as_json: bool,
10383) -> Result<()> {
10384    use std::collections::BTreeSet;
10385    use std::time::Duration;
10386
10387    let listing = crate::session::list_local_sessions()?;
10388    // Flatten + dedup by session NAME (same session can appear under
10389    // multiple local-relay URLs if it advertises two local endpoints;
10390    // rare, but pair each pair exactly once).
10391    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
10392        Default::default();
10393    for group in listing.local.into_values() {
10394        for s in group {
10395            by_name.entry(s.name.clone()).or_insert(s);
10396        }
10397    }
10398    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10399
10400    if sessions.len() < 2 {
10401        let msg = format!(
10402            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
10403            sessions.len()
10404        );
10405        if as_json {
10406            println!(
10407                "{}",
10408                serde_json::to_string(&json!({
10409                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
10410                    "pairs_attempted": 0,
10411                    "pairs_succeeded": 0,
10412                    "pairs_skipped_already_paired": 0,
10413                    "pairs_failed": 0,
10414                    "note": msg,
10415                }))?
10416            );
10417        } else {
10418            println!("{msg}");
10419            if let Some(s) = sessions.first() {
10420                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
10421            }
10422            println!("Use `wire session new --with-local` to add more.");
10423        }
10424        return Ok(());
10425    }
10426
10427    let fed_host = host_of_url(federation_relay);
10428    if fed_host.is_empty() {
10429        bail!(
10430            "federation_relay `{federation_relay}` has no parseable host — \
10431             pass a full URL like `https://wireup.net`."
10432        );
10433    }
10434
10435    // Enumerate unordered pairs deterministically by session name.
10436    let mut attempted = 0u32;
10437    let mut succeeded = 0u32;
10438    let mut skipped_already = 0u32;
10439    let mut failed = 0u32;
10440    let mut per_pair: Vec<Value> = Vec::new();
10441
10442    for i in 0..sessions.len() {
10443        for j in (i + 1)..sessions.len() {
10444            let a = &sessions[i];
10445            let b = &sessions[j];
10446            attempted += 1;
10447
10448            // Already-paired check: if A's relay-state has B's CARD
10449            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
10450            // are character handles (not session names), so we use
10451            // each side's handle field (already on the LocalSessionView)
10452            // for the lookup rather than the session name.
10453            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
10454            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
10455            let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
10456            let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
10457            if a_pinned_b && b_pinned_a {
10458                skipped_already += 1;
10459                per_pair.push(json!({
10460                    "from": a.name,
10461                    "to": b.name,
10462                    "status": "already_paired",
10463                }));
10464                continue;
10465            }
10466
10467            let pair_result = drive_bilateral_pair(
10468                &a.home_dir,
10469                &a.name,
10470                &b.home_dir,
10471                &b.name,
10472                &fed_host,
10473                federation_relay,
10474                settle_secs,
10475            );
10476
10477            match pair_result {
10478                Ok(()) => {
10479                    succeeded += 1;
10480                    per_pair.push(json!({
10481                        "from": a.name,
10482                        "to": b.name,
10483                        "status": "paired",
10484                    }));
10485                }
10486                Err(e) => {
10487                    failed += 1;
10488                    let detail = format!("{e:#}");
10489                    per_pair.push(json!({
10490                        "from": a.name,
10491                        "to": b.name,
10492                        "status": "failed",
10493                        "error": detail,
10494                    }));
10495                }
10496            }
10497
10498            // Brief settle between pairs so we don't slam the relay
10499            // with N(N-1) parallel requests.
10500            std::thread::sleep(Duration::from_millis(200));
10501        }
10502    }
10503
10504    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
10505    let summary = json!({
10506        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
10507        "pairs_attempted": attempted,
10508        "pairs_succeeded": succeeded,
10509        "pairs_skipped_already_paired": skipped_already,
10510        "pairs_failed": failed,
10511        "results": per_pair,
10512    });
10513    if as_json {
10514        println!("{}", serde_json::to_string(&summary)?);
10515    } else {
10516        println!(
10517            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
10518            sessions.len(),
10519            attempted
10520        );
10521        println!("  paired:                 {succeeded}");
10522        println!("  skipped (already pinned): {skipped_already}");
10523        println!("  failed:                 {failed}");
10524        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
10525            let from = entry["from"].as_str().unwrap_or("?");
10526            let to = entry["to"].as_str().unwrap_or("?");
10527            let status = entry["status"].as_str().unwrap_or("?");
10528            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
10529            if err.is_empty() {
10530                println!("  {from:<24} ↔ {to:<24} {status}");
10531            } else {
10532                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
10533            }
10534        }
10535    }
10536    Ok(())
10537}
10538
10539/// Check whether `session_home`'s `relay.json` already lists `peer_name`
10540/// under `state.peers`. Best-effort — any read/parse error → false.
10541fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
10542    val_session_relay_state(session_home)
10543        .and_then(|v| v.get("peers").cloned())
10544        .and_then(|p| p.get(peer_name).cloned())
10545        .is_some()
10546}
10547
10548/// Read a session's `relay.json` directly without mutating the process'
10549/// WIRE_HOME env (which would race other threads / processes). Returns
10550/// `None` on any read or parse error — callers treat missing state as
10551/// "no peers / no endpoints" rather than aborting.
10552fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
10553    let path = session_home.join("config").join("wire").join("relay.json");
10554    let bytes = std::fs::read(&path).ok()?;
10555    serde_json::from_slice(&bytes).ok()
10556}
10557
10558/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
10559/// One probe per directed edge against the relay backing that edge's
10560/// priority-1 endpoint; output groups by undirected pair.
10561fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
10562    use std::collections::BTreeMap;
10563
10564    // Flatten by session NAME — same dedup logic as pair-all-local so a
10565    // session advertising two local endpoints doesn't get double-counted.
10566    let listing = crate::session::list_local_sessions()?;
10567    let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
10568    for group in listing.local.into_values() {
10569        for s in group {
10570            by_name.entry(s.name.clone()).or_insert(s);
10571        }
10572    }
10573    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10574    let federation_only = listing.federation_only;
10575
10576    if sessions.is_empty() {
10577        let msg = "no sister sessions with a local endpoint on this machine.".to_string();
10578        if as_json {
10579            println!(
10580                "{}",
10581                serde_json::to_string(&json!({
10582                    "sessions": [],
10583                    "edges": [],
10584                    "local_relay": null,
10585                    "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10586                    "summary": {
10587                        "session_count": 0,
10588                        "edge_count": 0,
10589                        "healthy": 0,
10590                        "stale": 0,
10591                        "asymmetric": 0,
10592                    },
10593                    "note": msg,
10594                }))?
10595            );
10596        } else {
10597            println!("{msg}");
10598            println!("Use `wire session new --with-local` to create one.");
10599        }
10600        return Ok(());
10601    }
10602
10603    // Build a name → session-state map: relay_state + reachable handle set.
10604    struct SessionState {
10605        view: crate::session::LocalSessionView,
10606        relay_state: Value,
10607        local_relay_url: Option<String>,
10608    }
10609    let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
10610    for s in sessions {
10611        let relay_state = val_session_relay_state(&s.home_dir)
10612            .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
10613        let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
10614        sstates.push(SessionState {
10615            view: s,
10616            relay_state,
10617            local_relay_url,
10618        });
10619    }
10620
10621    // Probe each unique local-relay URL once for healthz so the operator
10622    // sees one liveness line per local relay, not one per edge.
10623    let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
10624    for s in &sstates {
10625        if let Some(url) = &s.local_relay_url
10626            && !local_relays.contains_key(url)
10627        {
10628            let healthy = probe_relay_healthz(url);
10629            local_relays.insert(url.clone(), healthy);
10630        }
10631    }
10632
10633    let now = std::time::SystemTime::now()
10634        .duration_since(std::time::UNIX_EPOCH)
10635        .map(|d| d.as_secs())
10636        .unwrap_or(0);
10637
10638    // Edges: walk every unordered pair, surface bilateral state + each
10639    // direction's last_pull. Probe priority-1 endpoint (local preferred
10640    // by `peer_endpoints_in_priority_order`).
10641    let mut edges: Vec<Value> = Vec::new();
10642    let mut healthy_count = 0u32;
10643    let mut stale_count = 0u32;
10644    let mut asymmetric_count = 0u32;
10645
10646    for i in 0..sstates.len() {
10647        for j in (i + 1)..sstates.len() {
10648            let a = &sstates[i];
10649            let b = &sstates[j];
10650            // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
10651            // (DID-derived character), not the session name. Look the
10652            // peer up by its handle (with a session-name fallback for
10653            // pre-v0.11 sessions that haven't re-init'd yet).
10654            let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
10655            let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
10656            let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
10657            let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
10658
10659            let bilateral = a_to_b.pinned && b_to_a.pinned;
10660            // Scope = the most-local scope available in either direction.
10661            // (If a→b is local and b→a is federation, the asymmetric
10662            // detail surfaces below; the headline scope is the better.)
10663            let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
10664                (Some("local"), _) | (_, Some("local")) => "local",
10665                (Some("federation"), _) | (_, Some("federation")) => "federation",
10666                _ => "unknown",
10667            };
10668
10669            // Health: stale if either direction's last_pull is older than
10670            // `stale_secs`, or never observed when both sides are pinned.
10671            let mut status = if bilateral { "healthy" } else { "asymmetric" };
10672            if bilateral {
10673                let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
10674                    Some(s) => s > stale_secs,
10675                    None => d.probed,
10676                });
10677                if either_stale {
10678                    status = "stale";
10679                }
10680            }
10681
10682            match status {
10683                "healthy" => healthy_count += 1,
10684                "stale" => stale_count += 1,
10685                "asymmetric" => asymmetric_count += 1,
10686                _ => {}
10687            }
10688
10689            edges.push(json!({
10690                "from": a.view.name,
10691                "to": b.view.name,
10692                "bilateral": bilateral,
10693                "scope": scope,
10694                "status": status,
10695                "directions": {
10696                    a.view.name.clone(): direction_summary(&a_to_b),
10697                    b.view.name.clone(): direction_summary(&b_to_a),
10698                },
10699            }));
10700        }
10701    }
10702
10703    let summary = json!({
10704        "sessions": sstates.iter().map(|s| json!({
10705            "name": s.view.name,
10706            "handle": s.view.handle,
10707            "cwd": s.view.cwd,
10708            "daemon_running": s.view.daemon_running,
10709            "local_relay": s.local_relay_url,
10710        })).collect::<Vec<_>>(),
10711        "edges": edges,
10712        "local_relays": local_relays.iter().map(|(url, healthy)| json!({
10713            "url": url,
10714            "healthy": healthy,
10715        })).collect::<Vec<_>>(),
10716        "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10717        "summary": {
10718            "session_count": sstates.len(),
10719            "edge_count": edges.len(),
10720            "healthy": healthy_count,
10721            "stale": stale_count,
10722            "asymmetric": asymmetric_count,
10723            "stale_threshold_secs": stale_secs,
10724        },
10725    });
10726
10727    if as_json {
10728        println!("{}", serde_json::to_string(&summary)?);
10729        return Ok(());
10730    }
10731
10732    println!(
10733        "wire mesh: {} session(s), {} edge(s)",
10734        sstates.len(),
10735        edges.len()
10736    );
10737    for (url, healthy) in &local_relays {
10738        let tick = if *healthy { "✓" } else { "✗" };
10739        println!("  local-relay {url} {tick}");
10740    }
10741    if !federation_only.is_empty() {
10742        print!("  federation-only sessions:");
10743        for f in &federation_only {
10744            print!(" {}", f.name);
10745        }
10746        println!();
10747    }
10748
10749    // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
10750    let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
10751    let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
10752    print!("\n{:>col_w$}", "", col_w = col_w);
10753    for n in &names {
10754        print!("{n:>col_w$}");
10755    }
10756    println!();
10757    for (i, row) in names.iter().enumerate() {
10758        print!("{row:>col_w$}");
10759        for (j, col) in names.iter().enumerate() {
10760            let cell = if i == j {
10761                "self".to_string()
10762            } else {
10763                let d = probe_directed_edge(&sstates[i].relay_state, col, now);
10764                match d.scope.as_deref() {
10765                    Some("local") => "local".to_string(),
10766                    Some("federation") => "fed".to_string(),
10767                    _ => "—".to_string(),
10768                }
10769            };
10770            print!("{cell:>col_w$}");
10771        }
10772        println!();
10773    }
10774
10775    println!("\nHealth (stale threshold: {stale_secs}s):");
10776    for e in &edges {
10777        let from = e["from"].as_str().unwrap_or("?");
10778        let to = e["to"].as_str().unwrap_or("?");
10779        let scope = e["scope"].as_str().unwrap_or("?");
10780        let status = e["status"].as_str().unwrap_or("?");
10781        let mark = match status {
10782            "healthy" => "✓",
10783            "stale" => "⚠",
10784            "asymmetric" => "!",
10785            _ => "?",
10786        };
10787        let dirs = e["directions"].as_object().cloned().unwrap_or_default();
10788        let mut details: Vec<String> = Vec::new();
10789        for (who, d) in &dirs {
10790            let silent = d.get("silent_secs").and_then(Value::as_u64);
10791            let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
10792            let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
10793            let label = match (pinned, probed, silent) {
10794                (false, _, _) => format!("{who} has not pinned"),
10795                (true, false, _) => format!("{who} pinned but no endpoint to probe"),
10796                (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
10797                (true, true, Some(s)) => format!("{who} silent {s}s"),
10798                (true, true, None) => format!("{who} never pulled"),
10799            };
10800            details.push(label);
10801        }
10802        println!(
10803            "  {mark} {from} ↔ {to}  scope={scope} {status:>10}  [{}]",
10804            details.join(" | ")
10805        );
10806    }
10807    Ok(())
10808}
10809
10810#[derive(Default)]
10811struct DirectedEdge {
10812    pinned: bool,
10813    scope: Option<String>,
10814    last_pull_at_unix: Option<u64>,
10815    silent_secs: Option<u64>,
10816    probed: bool,
10817    event_count: usize,
10818}
10819
10820/// Probe a single directed edge from `from_state`'s view of `to_name`.
10821/// Picks the priority-1 endpoint (local preferred when reachable) and
10822/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
10823/// failure (the function records `probed = true`, `last_pull = None`,
10824/// which the caller treats as "never pulled, route exists" = stale).
10825fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
10826    let pinned = from_state
10827        .get("peers")
10828        .and_then(|p| p.get(to_name))
10829        .is_some();
10830    if !pinned {
10831        return DirectedEdge::default();
10832    }
10833    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
10834    let ep = match endpoints.into_iter().next() {
10835        Some(e) => e,
10836        None => {
10837            return DirectedEdge {
10838                pinned: true,
10839                ..Default::default()
10840            };
10841        }
10842    };
10843    let scope = Some(
10844        match ep.scope {
10845            crate::endpoints::EndpointScope::Local => "local",
10846            crate::endpoints::EndpointScope::Lan => "lan",
10847            crate::endpoints::EndpointScope::Uds => "uds",
10848            crate::endpoints::EndpointScope::Federation => "federation",
10849        }
10850        .to_string(),
10851    );
10852    let client = crate::relay_client::RelayClient::new(&ep.relay_url);
10853    let (count, last) = client
10854        .slot_state(&ep.slot_id, &ep.slot_token)
10855        .unwrap_or((0, None));
10856    let silent = last.map(|t| now.saturating_sub(t));
10857    DirectedEdge {
10858        pinned: true,
10859        scope,
10860        last_pull_at_unix: last,
10861        silent_secs: silent,
10862        probed: true,
10863        event_count: count,
10864    }
10865}
10866
10867fn direction_summary(d: &DirectedEdge) -> Value {
10868    json!({
10869        "pinned": d.pinned,
10870        "scope": d.scope,
10871        "probed": d.probed,
10872        "last_pull_at_unix": d.last_pull_at_unix,
10873        "silent_secs": d.silent_secs,
10874        "event_count": d.event_count,
10875    })
10876}
10877
10878/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
10879fn probe_relay_healthz(url: &str) -> bool {
10880    let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
10881    let client = match reqwest::blocking::Client::builder()
10882        .timeout(std::time::Duration::from_millis(500))
10883        .build()
10884    {
10885        Ok(c) => c,
10886        Err(_) => return false,
10887    };
10888    match client.get(&probe_url).send() {
10889        Ok(r) => r.status().is_success(),
10890        Err(_) => false,
10891    }
10892}
10893
10894/// Drive one bilateral pair handshake between two sister sessions
10895/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
10896/// flow so failures bubble up at the offending step, not buried in
10897/// a parallel race. See `cmd_session_pair_all_local` docstring.
10898///
10899/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
10900/// federation `.well-known/wire/agent` resolution. Reads B's card +
10901/// endpoints directly off disk under `b_home` and pins them. This
10902/// makes pair-all-local work for sister sessions whose federation
10903/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
10904/// for sessions created with `wire session new --local-only`
10905/// (no federation slot at all). The `_federation_relay` / `_fed_host`
10906/// parameters are retained for callers that want to log them but
10907/// the handshake itself no longer touches federation.
10908fn drive_bilateral_pair(
10909    a_home: &std::path::Path,
10910    a_name: &str,
10911    b_home: &std::path::Path,
10912    b_name: &str,
10913    _fed_host: &str,
10914    _federation_relay: &str,
10915    settle_secs: u64,
10916) -> Result<()> {
10917    use std::time::Duration;
10918    let bin = std::env::current_exe().context("locating self exe")?;
10919
10920    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
10921        let out = std::process::Command::new(&bin)
10922            .env("WIRE_HOME", home)
10923            .env_remove("RUST_LOG")
10924            .args(args)
10925            .output()
10926            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10927        if !out.status.success() {
10928            bail!(
10929                "`wire {}` failed: stderr={}",
10930                args.join(" "),
10931                String::from_utf8_lossy(&out.stderr).trim()
10932            );
10933        }
10934        Ok(())
10935    };
10936
10937    // v0.11: each session's agent-card.handle is the DID-derived
10938    // character, not the session name. wire-accept lookups key on the
10939    // CARD HANDLE, so we discover each side's canonical handle from
10940    // its agent-card on disk before driving the pair flow.
10941    let read_card_handle = |home: &std::path::Path| -> Result<String> {
10942        let card_path = home.join("config").join("wire").join("agent-card.json");
10943        let bytes = std::fs::read(&card_path)
10944            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
10945        let card: Value = serde_json::from_slice(&bytes)?;
10946        card.get("handle")
10947            .and_then(Value::as_str)
10948            .map(str::to_string)
10949            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
10950    };
10951    let a_handle = read_card_handle(a_home)
10952        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
10953    let b_handle = read_card_handle(b_home)
10954        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
10955
10956    // 1. A initiates via --local-sister (uses the session NAME for
10957    // the registry lookup; cmd_add_local_sister auto-resolves
10958    // session→handle internally).
10959    run(a_home, &["add", b_name, "--local-sister", "--json"])
10960        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
10961
10962    // 3. settle so pair_drop reaches B's slot
10963    std::thread::sleep(Duration::from_secs(settle_secs));
10964
10965    // 4. B pulls pair_drop → 5. B accept (pins A by CARD HANDLE,
10966    // not by session name — under v0.11 these differ) → 6. B push ack
10967    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
10968    run(b_home, &["accept", &a_handle, "--json"]).with_context(|| {
10969        format!("step 5/8: {b_name} `wire accept {a_handle}` (a session={a_name})")
10970    })?;
10971    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
10972
10973    // 7. settle so ack reaches A's slot
10974    std::thread::sleep(Duration::from_secs(settle_secs));
10975
10976    // 8. A pulls ack (pins B by CARD HANDLE)
10977    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
10978    // suppress unused warning when both handles are consumed
10979    let _ = &b_handle;
10980
10981    Ok(())
10982}
10983
10984fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
10985    let name = resolve_session_name(name_arg)?;
10986    let session_home = crate::session::session_dir(&name)?;
10987    if !session_home.exists() {
10988        bail!(
10989            "no session named {name:?} on this machine. `wire session list` to enumerate, \
10990             `wire session new {name}` to create."
10991        );
10992    }
10993    if as_json {
10994        println!(
10995            "{}",
10996            serde_json::to_string(&json!({
10997                "name": name,
10998                "home_dir": session_home.to_string_lossy(),
10999                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
11000            }))?
11001        );
11002    } else {
11003        println!("export WIRE_HOME={}", session_home.to_string_lossy());
11004    }
11005    Ok(())
11006}
11007
11008fn cmd_session_current(as_json: bool) -> Result<()> {
11009    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
11010    let registry = crate::session::read_registry().unwrap_or_default();
11011    let cwd_key = crate::session::normalize_cwd_key(&cwd);
11012    // Backward-compat: O(n) normalized scan on read-miss. Mirrors the
11013    // same pattern in session::derive_name_from_cwd /
11014    // detect_session_wire_home — handles both consistent-casing and
11015    // cross-casing upgraders (see session.rs for the full rationale).
11016    let name = registry
11017        .by_cwd
11018        .get(&cwd_key)
11019        .or_else(|| {
11020            registry
11021                .by_cwd
11022                .iter()
11023                .find(|(k, _)| {
11024                    crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
11025                })
11026                .map(|(_, v)| v)
11027        })
11028        .cloned();
11029    if as_json {
11030        println!(
11031            "{}",
11032            serde_json::to_string(&json!({
11033                "cwd": cwd_key,
11034                "session": name,
11035            }))?
11036        );
11037    } else if let Some(n) = name {
11038        println!("{n}");
11039    } else {
11040        println!("(no session registered for this cwd)");
11041    }
11042    Ok(())
11043}
11044
11045fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
11046    let name = crate::session::sanitize_name(name_arg);
11047    let session_home = crate::session::session_dir(&name)?;
11048    if !session_home.exists() {
11049        if as_json {
11050            println!(
11051                "{}",
11052                serde_json::to_string(&json!({
11053                    "name": name,
11054                    "destroyed": false,
11055                    "reason": "no such session",
11056                }))?
11057            );
11058        } else {
11059            println!("no session named {name:?} — nothing to destroy.");
11060        }
11061        return Ok(());
11062    }
11063    if !force {
11064        bail!(
11065            "destroying session {name:?} would delete its keypair + state irrecoverably. \
11066             Pass --force to confirm."
11067        );
11068    }
11069
11070    // Kill the session-local daemon if alive.
11071    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
11072    if let Ok(bytes) = std::fs::read(&pidfile) {
11073        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
11074            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
11075        } else {
11076            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
11077        };
11078        if let Some(p) = pid {
11079            let _ = std::process::Command::new("kill")
11080                .args(["-TERM", &p.to_string()])
11081                .output();
11082        }
11083    }
11084
11085    std::fs::remove_dir_all(&session_home)
11086        .with_context(|| format!("removing session dir {session_home:?}"))?;
11087
11088    // Strip from registry.
11089    let mut registry = crate::session::read_registry().unwrap_or_default();
11090    registry.by_cwd.retain(|_, v| v != &name);
11091    crate::session::write_registry(&registry)?;
11092
11093    if as_json {
11094        println!(
11095            "{}",
11096            serde_json::to_string(&json!({
11097                "name": name,
11098                "destroyed": true,
11099            }))?
11100        );
11101    } else {
11102        println!("destroyed session {name:?}.");
11103    }
11104    Ok(())
11105}
11106
11107// ---------- diag (structured trace) ----------
11108
11109fn cmd_diag(action: DiagAction) -> Result<()> {
11110    let state = config::state_dir()?;
11111    let knob = state.join("diag.enabled");
11112    let log_path = state.join("diag.jsonl");
11113    match action {
11114        DiagAction::Tail { limit, json } => {
11115            let entries = crate::diag::tail(limit);
11116            if json {
11117                for e in entries {
11118                    println!("{}", serde_json::to_string(&e)?);
11119                }
11120            } else if entries.is_empty() {
11121                println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
11122            } else {
11123                for e in entries {
11124                    let ts = e["ts"].as_u64().unwrap_or(0);
11125                    let ty = e["type"].as_str().unwrap_or("?");
11126                    let pid = e["pid"].as_u64().unwrap_or(0);
11127                    let payload = e["payload"].to_string();
11128                    println!("[{ts}] pid={pid} {ty} {payload}");
11129                }
11130            }
11131        }
11132        DiagAction::Enable => {
11133            config::ensure_dirs()?;
11134            std::fs::write(&knob, "1")?;
11135            println!("wire diag: enabled at {knob:?}");
11136        }
11137        DiagAction::Disable => {
11138            if knob.exists() {
11139                std::fs::remove_file(&knob)?;
11140            }
11141            println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
11142        }
11143        DiagAction::Status { json } => {
11144            let enabled = crate::diag::is_enabled();
11145            let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
11146            if json {
11147                println!(
11148                    "{}",
11149                    serde_json::to_string(&serde_json::json!({
11150                        "enabled": enabled,
11151                        "log_path": log_path,
11152                        "log_size_bytes": size,
11153                    }))?
11154                );
11155            } else {
11156                println!("wire diag status");
11157                println!("  enabled:    {enabled}");
11158                println!("  log:        {log_path:?}");
11159                println!("  log size:   {size} bytes");
11160            }
11161        }
11162    }
11163    Ok(())
11164}
11165
11166// ---------- service (install / uninstall / status) ----------
11167
11168fn cmd_service(action: ServiceAction) -> Result<()> {
11169    let kind = |local_relay: bool| {
11170        if local_relay {
11171            crate::service::ServiceKind::LocalRelay
11172        } else {
11173            crate::service::ServiceKind::Daemon
11174        }
11175    };
11176    let (report, as_json) = match action {
11177        ServiceAction::Install { local_relay, json } => {
11178            (crate::service::install_kind(kind(local_relay))?, json)
11179        }
11180        ServiceAction::Uninstall { local_relay, json } => {
11181            (crate::service::uninstall_kind(kind(local_relay))?, json)
11182        }
11183        ServiceAction::Status { local_relay, json } => {
11184            (crate::service::status_kind(kind(local_relay))?, json)
11185        }
11186    };
11187    if as_json {
11188        println!("{}", serde_json::to_string(&report)?);
11189    } else {
11190        println!("wire service {}", report.action);
11191        println!("  platform:  {}", report.platform);
11192        println!("  unit:      {}", report.unit_path);
11193        println!("  status:    {}", report.status);
11194        println!("  detail:    {}", report.detail);
11195    }
11196    Ok(())
11197}
11198
11199// ---------- update (self-update from crates.io / prebuilt release) ----------
11200
11201const CRATE_NAME: &str = "slancha-wire";
11202
11203/// (target-triple, binary-extension) of the GitHub release asset for THIS
11204/// platform — names mirror `.github/workflows/release.yml`. `None` if no
11205/// prebuilt is published for this target.
11206fn release_asset_triple() -> Option<(&'static str, &'static str)> {
11207    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
11208    {
11209        return Some(("x86_64-pc-windows-msvc", ".exe"));
11210    }
11211    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
11212    {
11213        return Some(("aarch64-apple-darwin", ""));
11214    }
11215    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
11216    {
11217        return Some(("x86_64-apple-darwin", ""));
11218    }
11219    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
11220    {
11221        return Some(("x86_64-unknown-linux-musl", ""));
11222    }
11223    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
11224    {
11225        return Some(("aarch64-unknown-linux-musl", ""));
11226    }
11227    #[allow(unreachable_code)]
11228    None
11229}
11230
11231/// Latest stable version published on crates.io.
11232fn fetch_latest_published_version() -> Result<String> {
11233    let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
11234    let client = reqwest::blocking::Client::builder()
11235        .timeout(std::time::Duration::from_secs(20))
11236        .build()?;
11237    let resp = client
11238        .get(&url)
11239        // crates.io rejects requests without a descriptive User-Agent (403).
11240        .header(
11241            "User-Agent",
11242            format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
11243        )
11244        .send()?;
11245    if !resp.status().is_success() {
11246        bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
11247    }
11248    let v: Value = resp.json()?;
11249    v.get("crate")
11250        .and_then(|c| {
11251            c.get("max_stable_version")
11252                .or_else(|| c.get("newest_version"))
11253        })
11254        .and_then(Value::as_str)
11255        .map(str::to_string)
11256        .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
11257}
11258
11259/// True iff `latest` is strictly newer than `current` (numeric major.minor.patch;
11260/// pre-release suffixes ignored).
11261fn version_is_newer(latest: &str, current: &str) -> bool {
11262    let parse = |s: &str| -> (u64, u64, u64) {
11263        let core = s.split('-').next().unwrap_or(s);
11264        let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
11265        (
11266            it.next().unwrap_or(0),
11267            it.next().unwrap_or(0),
11268            it.next().unwrap_or(0),
11269        )
11270    };
11271    parse(latest) > parse(current)
11272}
11273
11274fn cargo_on_path() -> bool {
11275    std::process::Command::new("cargo")
11276        .arg("--version")
11277        .stdout(std::process::Stdio::null())
11278        .stderr(std::process::Stdio::null())
11279        .status()
11280        .map(|s| s.success())
11281        .unwrap_or(false)
11282}
11283
11284/// Download the prebuilt release binary for `latest` and replace THIS binary
11285/// in place — the toolchain-free update path (for boxes with no `cargo`).
11286fn self_update_from_release(latest: &str) -> Result<()> {
11287    let (triple, ext) = release_asset_triple().ok_or_else(|| {
11288        anyhow!(
11289            "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
11290             or `cargo install {CRATE_NAME}`"
11291        )
11292    })?;
11293    let base =
11294        format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
11295    let client = reqwest::blocking::Client::builder()
11296        .timeout(std::time::Duration::from_secs(120))
11297        .build()?;
11298    let resp = client
11299        .get(&base)
11300        .header("User-Agent", "wire-self-update")
11301        .send()?;
11302    if !resp.status().is_success() {
11303        bail!("downloading {base} returned {}", resp.status());
11304    }
11305    let bytes = resp.bytes()?;
11306
11307    // Verify the SHA-256 sidecar if present (best-effort; absence is non-fatal).
11308    if let Ok(sha) = client
11309        .get(format!("{base}.sha256"))
11310        .header("User-Agent", "wire-self-update")
11311        .send()
11312        && sha.status().is_success()
11313    {
11314        let expected = sha
11315            .text()?
11316            .split_whitespace()
11317            .next()
11318            .unwrap_or("")
11319            .to_string();
11320        if !expected.is_empty() {
11321            use sha2::{Digest, Sha256};
11322            let mut h = Sha256::new();
11323            h.update(&bytes);
11324            let actual = hex::encode(h.finalize());
11325            if expected != actual {
11326                bail!(
11327                    "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
11328                );
11329            }
11330        }
11331    }
11332
11333    let exe = std::env::current_exe().context("locating current exe")?;
11334    let dir = exe
11335        .parent()
11336        .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
11337    let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
11338    std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
11339    #[cfg(unix)]
11340    {
11341        use std::os::unix::fs::PermissionsExt;
11342        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
11343        // Unix: rename over the running binary — the running process keeps the
11344        // old inode; the new file takes the path for the next invocation.
11345        std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
11346    }
11347    #[cfg(windows)]
11348    {
11349        // Windows can't overwrite a running .exe — rename it aside first
11350        // (allowed even while running), then move the new one into place.
11351        let old = exe.with_extension("old");
11352        let _ = std::fs::remove_file(&old);
11353        std::fs::rename(&exe, &old)
11354            .with_context(|| format!("renaming running exe {exe:?} aside"))?;
11355        std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
11356    }
11357    Ok(())
11358}
11359
11360/// Outcome of the crates.io self-update step (the front half of `wire upgrade`).
11361struct UpdateOutcome {
11362    current: String,
11363    latest: String,
11364    /// A newer stable version is published.
11365    available: bool,
11366    /// We actually installed it this run.
11367    installed: bool,
11368    /// How it was installed ("cargo install" / "prebuilt release binary").
11369    via: Option<&'static str>,
11370}
11371
11372/// Check crates.io for a newer published wire and, when `install` is true,
11373/// self-install it (cargo if a toolchain is on PATH, else the prebuilt release
11374/// binary). The front half of `wire upgrade`; `install=false` is check-only.
11375fn self_update_step(install: bool) -> Result<UpdateOutcome> {
11376    let current = env!("CARGO_PKG_VERSION").to_string();
11377    let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
11378    let available = version_is_newer(&latest, &current);
11379    if !install || !available {
11380        return Ok(UpdateOutcome {
11381            current,
11382            latest,
11383            available,
11384            installed: false,
11385            via: None,
11386        });
11387    }
11388    let via = if cargo_on_path() {
11389        eprintln!(
11390            "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
11391        );
11392        let status = std::process::Command::new("cargo")
11393            .args([
11394                "install",
11395                CRATE_NAME,
11396                "--version",
11397                &latest,
11398                "--force",
11399                "--locked",
11400            ])
11401            .status()
11402            .context("running cargo install")?;
11403        if !status.success() {
11404            bail!("`cargo install {CRATE_NAME}` failed");
11405        }
11406        "cargo install"
11407    } else {
11408        eprintln!(
11409            "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
11410        );
11411        self_update_from_release(&latest)?;
11412        "prebuilt release binary"
11413    };
11414    Ok(UpdateOutcome {
11415        current,
11416        latest,
11417        available,
11418        installed: true,
11419        via: Some(via),
11420    })
11421}
11422
11423// ---------- upgrade (atomic daemon swap) ----------
11424
11425/// `wire upgrade` — kill all running `wire daemon` processes, spawn a
11426/// fresh one from the currently-installed binary, write a new versioned
11427/// pidfile. The fix for today's exact failure mode: a daemon process that
11428/// kept running OLD binary text in memory under a symlink that had since
11429/// been repointed at a NEW binary on disk.
11430///
11431/// Idempotent. If no stale daemon is running, just starts a fresh one
11432/// (same as `wire daemon &` but with the wait-until-alive guard from
11433/// ensure_up::ensure_daemon_running).
11434///
11435/// `--check` mode reports drift without acting — lists the processes
11436/// that WOULD be killed and the binary version of each.
11437///
11438/// Session-scoped upgrade kill set (v0.13.2, B fix): THIS session's own daemon
11439/// (`my_pid`, from its pidfile — reliable even when the OS process scan can't
11440/// see it, as on Windows) plus TRUE orphans (found `wire daemon` pids owned by
11441/// no session), EXCLUDING sibling sessions' daemons. Pure + unit-tested so the
11442/// session-scoping is locked — the box-wide predecessor accumulated daemons.
11443fn upgrade_kill_set(
11444    my_pid: Option<u32>,
11445    found_daemon_pids: &[u32],
11446    owned_session_pids: &std::collections::HashSet<u32>,
11447) -> Vec<u32> {
11448    let mut k: Vec<u32> = Vec::new();
11449    if let Some(p) = my_pid {
11450        k.push(p);
11451    }
11452    for &p in found_daemon_pids {
11453        if !owned_session_pids.contains(&p) && Some(p) != my_pid {
11454            k.push(p); // true orphan — owned by no session
11455        }
11456    }
11457    k.sort_unstable();
11458    k.dedup();
11459    k
11460}
11461
11462/// One distinct `wire` binary discovered on `$PATH`, with enrichment used by
11463/// the `wire upgrade` PATH-shadowing diagnostic (issue #80).
11464///
11465/// "Distinct" = unique canonical path; symlink chains collapse to a single
11466/// entry at the FIRST PATH position that surfaced them. This is what
11467/// `which -a` would show modulo symlink dedup.
11468#[derive(Debug, Clone)]
11469struct PathWireBinary {
11470    /// PATH entry under which this binary was discovered (NOT canonicalized,
11471    /// so the operator sees the path they wrote in their shell config).
11472    path: std::path::PathBuf,
11473    /// Canonical filesystem path (symlinks resolved). Used for dedup so a
11474    /// symlink that points at the real binary doesn't show up as a second
11475    /// "distinct" entry.
11476    canonical: std::path::PathBuf,
11477    /// SHA-256 hex of the binary contents. `None` if unreadable (rare; would
11478    /// require a race or perms change after the existence check).
11479    sha256: Option<String>,
11480    /// Last-modified time of the binary. `None` if metadata unreadable.
11481    mtime: Option<std::time::SystemTime>,
11482    /// Zero-based PATH position (after dedup). `0` = the binary bare `wire`
11483    /// resolves to (the winner of PATH precedence).
11484    path_index: usize,
11485    /// True iff this is the binary currently executing the running `wire
11486    /// upgrade` process (i.e. `std::env::current_exe()` canonicalized matches).
11487    /// When this is NOT the `path_index == 0` entry, the operator just ran
11488    /// `wire upgrade` against a SHADOWED binary and bare `wire` will continue
11489    /// to use the active one — the central footgun #80 exists to catch.
11490    is_current_exe: bool,
11491}
11492
11493impl PathWireBinary {
11494    /// True iff bare `wire` resolves here (the PATH-precedence winner).
11495    fn is_active(&self) -> bool {
11496        self.path_index == 0
11497    }
11498    /// Short sha256 (first 8 hex chars) for compact display; `?` filler when
11499    /// the hash couldn't be computed.
11500    fn sha256_short(&self) -> String {
11501        self.sha256
11502            .as_deref()
11503            .map(|s| s[..s.len().min(8)].to_string())
11504            .unwrap_or_else(|| "????????".to_string())
11505    }
11506    /// Pretty mtime in UTC RFC3339 seconds; `?` when missing or unrepresentable.
11507    fn mtime_display(&self) -> String {
11508        let Some(ts) = self.mtime else {
11509            return "?".to_string();
11510        };
11511        let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
11512            Ok(d) => d.as_secs() as i64,
11513            Err(_) => return "?".to_string(),
11514        };
11515        time::OffsetDateTime::from_unix_timestamp(secs)
11516            .ok()
11517            .and_then(|dt| {
11518                dt.format(&time::format_description::well_known::Rfc3339)
11519                    .ok()
11520            })
11521            .unwrap_or_else(|| "?".to_string())
11522    }
11523}
11524
11525/// SHA-256 hex of a file's contents (streamed; safe for any size).
11526fn sha256_file(p: &std::path::Path) -> Result<String> {
11527    use sha2::{Digest, Sha256};
11528    let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
11529    let mut h = Sha256::new();
11530    std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
11531    Ok(hex::encode(h.finalize()))
11532}
11533
11534/// Walk `$PATH` left-to-right, find all distinct files named `wire` (plus
11535/// `wire.exe` on Windows), and return them in PATH order with sha256+mtime
11536/// enrichment. Issue #80.
11537///
11538/// Invariants:
11539/// - First entry (`path_index == 0`) is what bare `wire` resolves to.
11540/// - Symlink chains collapse: only the first PATH position surfaces; later
11541///   entries pointing at the same canonical file are dropped (NOT counted
11542///   as a "shadow").
11543/// - Best-effort: I/O errors degrade to `None` on per-binary fields,
11544///   never abort the whole walk.
11545/// - Empty / missing PATH → empty Vec (NOT an error; the caller is already
11546///   running, so SOMETHING resolved this binary, just not via PATH).
11547fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
11548    let path = std::env::var("PATH").unwrap_or_default();
11549    let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
11550        .ok()
11551        .and_then(|p| p.canonicalize().ok());
11552    enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
11553}
11554
11555/// Pure (testable) inner of [`enumerate_path_wire_binaries`]: takes the PATH
11556/// string and an optional already-canonicalized `current_exe` so tests don't
11557/// have to mutate process-wide environment (which would race with any other
11558/// test that reads PATH).
11559fn enumerate_path_wire_binaries_from(
11560    path: &str,
11561    current_exe_canon: Option<&std::path::Path>,
11562) -> Vec<PathWireBinary> {
11563    if path.is_empty() {
11564        return Vec::new();
11565    }
11566    // Unix splits PATH on ':', Windows on ';'. We don't use
11567    // `std::env::split_paths` because we want to be explicit and consistent
11568    // with the existing v0.6.8 detection that this helper replaces (which
11569    // used `.split(':')` unconditionally — a Unix-only bug; fixed here).
11570    let separator = if cfg!(windows) { ';' } else { ':' };
11571    let names: &[&str] = if cfg!(windows) {
11572        // Try .exe first — that's what CreateProcess resolves bare `wire` to
11573        // under PATHEXT. A plain `wire` script (e.g. msys) only wins if
11574        // there's no wire.exe in the same directory.
11575        &["wire.exe", "wire"]
11576    } else {
11577        &["wire"]
11578    };
11579
11580    let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
11581    let mut out: Vec<PathWireBinary> = Vec::new();
11582    for dir in path.split(separator) {
11583        if dir.is_empty() {
11584            continue;
11585        }
11586        for name in names {
11587            let candidate = std::path::PathBuf::from(dir).join(name);
11588            // `is_file()` (not `.exists()`) so a directory named `wire`
11589            // doesn't false-positive — `.exists()` returns true for dirs.
11590            if !candidate.is_file() {
11591                continue;
11592            }
11593            let canon = candidate
11594                .canonicalize()
11595                .unwrap_or_else(|_| candidate.clone());
11596            if !seen.insert(canon.clone()) {
11597                // An earlier PATH entry already surfaced this canonical file
11598                // (symlink chain). Don't double-count as a shadow.
11599                break;
11600            }
11601            let meta = std::fs::metadata(&canon).ok();
11602            let mtime = meta.as_ref().and_then(|m| m.modified().ok());
11603            let sha256 = sha256_file(&canon).ok();
11604            let is_current_exe = current_exe_canon
11605                .map(|c| c == canon.as_path())
11606                .unwrap_or(false);
11607            let path_index = out.len();
11608            out.push(PathWireBinary {
11609                path: candidate,
11610                canonical: canon,
11611                sha256,
11612                mtime,
11613                path_index,
11614                is_current_exe,
11615            });
11616            // One entry per PATH dir — don't surface both wire AND wire.exe
11617            // from the same directory.
11618            break;
11619        }
11620    }
11621    out
11622}
11623
11624/// Render a multi-line WARN message for the PATH-shadow case, or `None` if
11625/// there's nothing to warn about. Issue #80.
11626///
11627/// Triggers (any one fires the warning):
11628/// - `>= 2 distinct wire binaries` on PATH (classic shadow case).
11629/// - Exactly 1 binary on PATH AND that binary isn't the one currently
11630///   running this `wire upgrade` (operator ran an off-PATH binary; bare
11631///   `wire` would resolve to a DIFFERENT binary that this upgrade just
11632///   bypassed).
11633/// - `0 binaries` on PATH at all (this `wire upgrade` ran via an absolute
11634///   path; bare `wire` would fail in any future shell).
11635fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
11636    let any_current = bins.iter().any(|b| b.is_current_exe);
11637    let multi = bins.len() >= 2;
11638    let off_path = !bins.is_empty() && !any_current;
11639    let none_on_path = bins.is_empty();
11640    if !multi && !off_path && !none_on_path {
11641        return None;
11642    }
11643    let mut out = String::new();
11644    if multi {
11645        out.push_str(&format!(
11646            "WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
11647            bins.len()
11648        ));
11649        for b in bins {
11650            let mut tags: Vec<&str> = Vec::new();
11651            if b.is_active() {
11652                tags.push("ACTIVE (bare `wire` resolves here)");
11653            }
11654            if b.is_current_exe {
11655                tags.push("THIS upgrade ran against this binary");
11656            }
11657            let tag_str = if tags.is_empty() {
11658                String::new()
11659            } else {
11660                format!("  ← {}", tags.join("; "))
11661            };
11662            out.push_str(&format!(
11663                "  [{}] {}  (sha256:{}  mtime:{}){}\n",
11664                b.path_index,
11665                b.path.display(),
11666                b.sha256_short(),
11667                b.mtime_display(),
11668                tag_str,
11669            ));
11670        }
11671        if !any_current {
11672            out.push_str(
11673                "  NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
11674            );
11675            out.push_str(
11676                "        Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
11677            );
11678        } else if !bins[0].is_current_exe {
11679            out.push_str(
11680                "  Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
11681            );
11682            out.push_str(
11683                "  ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
11684            );
11685            out.push_str(&format!(
11686                "    - rm {}  (or symlink it to the upgraded binary)\n",
11687                bins[0].path.display(),
11688            ));
11689            out.push_str(
11690                "    - or reorder PATH so the upgraded binary's directory precedes the active one\n",
11691            );
11692            out.push_str("  Verify with: which -a wire\n");
11693        }
11694    } else if off_path {
11695        // Single PATH binary, but THIS upgrade ran against a different file.
11696        let active = &bins[0];
11697        out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
11698        out.push_str(&format!(
11699            "      bare `wire` resolves to {} (sha256:{}),\n",
11700            active.path.display(),
11701            active.sha256_short(),
11702        ));
11703        out.push_str(
11704            "      which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
11705        );
11706        out.push_str("      will continue to invoke the old binary.\n");
11707    } else if none_on_path {
11708        out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
11709        out.push_str("      This upgrade ran against an absolute-path invocation only.\n");
11710    }
11711    Some(out.trim_end().to_string())
11712}
11713
11714#[cfg(test)]
11715mod upgrade_tests {
11716    use super::*;
11717    use std::collections::HashSet;
11718
11719    #[test]
11720    fn upgrade_kill_set_is_session_scoped() {
11721        // owned: my daemon 100, sibling session daemon 200.
11722        let owned: HashSet<u32> = [100, 200].into_iter().collect();
11723        // found by the process scan: mine (100), sibling (200), a true orphan (999).
11724        let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
11725        assert!(k.contains(&100), "must kill my own daemon (to replace it)");
11726        assert!(k.contains(&999), "must sweep a true orphan");
11727        assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
11728
11729        // CRITICAL: even when the process scan returns EMPTY (Windows CIM can't
11730        // match the quoted command line), my own daemon is still killed via its
11731        // pidfile pid — this is the B-accumulation fix.
11732        assert_eq!(
11733            upgrade_kill_set(Some(100), &[], &owned),
11734            vec![100],
11735            "own daemon killed even when the process scan is empty"
11736        );
11737
11738        // Uninitialized session (no own daemon): only true orphans.
11739        assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
11740    }
11741
11742    // ----- issue #80: PATH-shadow detection -----
11743    //
11744    // We test the pure inner `enumerate_path_wire_binaries_from(path, cur)`
11745    // so we never mutate the process-wide PATH — that would race with any
11746    // other test in the binary that reads PATH (e.g. `process_alive_self`
11747    // resolving the test binary via PATH).
11748
11749    fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
11750        use std::io::Write;
11751        let p = dir.join("wire");
11752        let mut f = std::fs::File::create(&p).expect("create fake wire");
11753        f.write_all(body).expect("write fake wire");
11754        drop(f);
11755        #[cfg(unix)]
11756        {
11757            use std::os::unix::fs::PermissionsExt;
11758            let mut perm = std::fs::metadata(&p).unwrap().permissions();
11759            perm.set_mode(0o755);
11760            std::fs::set_permissions(&p, perm).unwrap();
11761        }
11762        p
11763    }
11764
11765    #[test]
11766    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11767    fn enumerate_finds_no_binaries_when_path_empty() {
11768        let bins = enumerate_path_wire_binaries_from("", None);
11769        assert!(
11770            bins.is_empty(),
11771            "empty PATH yields no binaries, got {bins:?}"
11772        );
11773    }
11774
11775    #[test]
11776    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11777    fn enumerate_detects_two_distinct_binaries_in_path_order() {
11778        let d1 = tempfile::tempdir().unwrap();
11779        let d2 = tempfile::tempdir().unwrap();
11780        let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
11781        let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
11782        let path = format!("{}:{}", d1.path().display(), d2.path().display());
11783
11784        let bins = enumerate_path_wire_binaries_from(&path, None);
11785        assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
11786        assert_eq!(bins[0].path_index, 0);
11787        assert_eq!(bins[1].path_index, 1);
11788        assert!(bins[0].is_active(), "first PATH entry is active");
11789        assert!(!bins[1].is_active(), "second PATH entry is not active");
11790        // sha256 differs because contents differ.
11791        assert_ne!(
11792            bins[0].sha256, bins[1].sha256,
11793            "distinct contents must hash differently"
11794        );
11795        // path field is the un-canonicalized PATH-relative shape.
11796        assert_eq!(bins[0].path, p1);
11797        assert_eq!(bins[1].path, p2);
11798    }
11799
11800    #[test]
11801    #[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
11802    fn enumerate_collapses_symlink_chains_to_one_entry() {
11803        let real_dir = tempfile::tempdir().unwrap();
11804        let link_dir = tempfile::tempdir().unwrap();
11805        let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
11806        let link = link_dir.path().join("wire");
11807        #[cfg(unix)]
11808        std::os::unix::fs::symlink(&real, &link).unwrap();
11809
11810        // Put the SYMLINK first in PATH; the real binary second. Both
11811        // resolve to the same canonical file — should collapse to ONE entry
11812        // at the first PATH position.
11813        let path = format!(
11814            "{}:{}",
11815            link_dir.path().display(),
11816            real_dir.path().display()
11817        );
11818        let bins = enumerate_path_wire_binaries_from(&path, None);
11819        assert_eq!(
11820            bins.len(),
11821            1,
11822            "symlink chain must collapse to a single entry: {bins:?}"
11823        );
11824        assert!(bins[0].is_active());
11825        // path is the symlink (what the operator wrote), canonical is the real file.
11826        assert_eq!(bins[0].path, link);
11827        assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
11828    }
11829
11830    #[test]
11831    #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11832    fn shadow_warning_off_path_when_current_exe_not_on_path() {
11833        // One binary on PATH, but current_exe points somewhere else.
11834        // The off-PATH branch fires.
11835        let d = tempfile::tempdir().unwrap();
11836        write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
11837        let elsewhere = tempfile::tempdir().unwrap();
11838        let cur = elsewhere.path().join("not-on-path-wire");
11839        let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
11840        assert_eq!(bins.len(), 1);
11841        assert!(!bins[0].is_current_exe);
11842        let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
11843        assert!(
11844            warn.contains("off-PATH binary"),
11845            "off-path WARN must mention off-PATH; got: {warn}"
11846        );
11847    }
11848
11849    #[test]
11850    fn shadow_warning_fires_when_no_binaries_at_all() {
11851        let bins: Vec<PathWireBinary> = Vec::new();
11852        let warn = path_shadow_warning(&bins).expect("empty must warn");
11853        assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
11854    }
11855
11856    #[test]
11857    #[cfg_attr(windows, ignore = "PATH separator differs")]
11858    fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
11859        let d1 = tempfile::tempdir().unwrap();
11860        let d2 = tempfile::tempdir().unwrap();
11861        write_fake_wire(d1.path(), b"published\n");
11862        write_fake_wire(d2.path(), b"head\n");
11863        let path = format!("{}:{}", d1.path().display(), d2.path().display());
11864        let bins = enumerate_path_wire_binaries_from(&path, None);
11865        let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
11866        assert!(warn.contains("2 distinct"), "got: {warn}");
11867        assert!(warn.contains("ACTIVE"), "must mark the active binary");
11868        assert!(
11869            warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
11870            "must guide the operator to a fix; got: {warn}"
11871        );
11872    }
11873}
11874
11875fn cmd_upgrade(
11876    check_only: bool,
11877    local: bool,
11878    restart_mcp: bool,
11879    refresh_stale_children: bool,
11880    as_json: bool,
11881) -> Result<()> {
11882    // 0. (v0.13.3 — merged `update`) ALWAYS check crates.io first and, unless
11883    // this is a --check or --local run, self-install a newer release BEFORE the
11884    // daemon swap below — the respawn then picks up the new on-disk binary. A
11885    // crates.io/network failure must NOT block the restart, so it degrades to a
11886    // warning. `--local` skips it entirely (offline / local dev build).
11887    let update: Option<UpdateOutcome> = if local {
11888        None
11889    } else {
11890        match self_update_step(!check_only) {
11891            Ok(o) => Some(o),
11892            Err(e) => {
11893                if !check_only {
11894                    eprintln!("wire upgrade: update check skipped — {e:#}");
11895                }
11896                None
11897            }
11898        }
11899    };
11900    if let Some(o) = &update
11901        && o.installed
11902    {
11903        eprintln!(
11904            "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
11905            o.latest,
11906            o.current,
11907            o.via.unwrap_or("self-update")
11908        );
11909    }
11910
11911    // 1. Identify all running wire processes. v0.7.3: walks `pgrep -f`
11912    // on unix / `Get-CimInstance Win32_Process` on Windows via the
11913    // shared `platform::find_processes_by_cmdline`. Covers both the
11914    // long-lived sync `wire daemon` *and* the `wire relay-server`
11915    // local-only loopback — the pre-v0.7.3 upgrade only swept daemons
11916    // and left stale relay-server children pinned on the old binary,
11917    // forcing operators to `pkill -f relay-server` manually after
11918    // every version bump.
11919    let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
11920    let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
11921    // v0.14.x: also enumerate `wire mcp` server subprocesses. These are
11922    // pinned by their MCP host (Claude Code / Claude.app desktop), NOT
11923    // in wire's pidfile registry. We do NOT kill them — that would
11924    // disconnect every Claude tab's wire MCP toolset until each session
11925    // explicitly `/mcp` reconnects — but we surface their count so the
11926    // operator knows their sister sessions still run pre-upgrade code
11927    // until they reconnect. See `feedback_wire_upgrade_skips_mcp_servers`.
11928    let mcp_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire mcp");
11929    let running_pids: Vec<u32> = daemon_pids
11930        .iter()
11931        .chain(relay_pids.iter())
11932        .copied()
11933        .collect();
11934
11935    // 2. Read pidfile to surface what the daemon THINKS it is.
11936    let record = crate::ensure_up::read_pid_record("daemon");
11937    let recorded_version: Option<String> = match &record {
11938        crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
11939        _ => None,
11940    };
11941    let cli_version = env!("CARGO_PKG_VERSION").to_string();
11942
11943    // 2b. v0.13.2 (B fix — session-scoped upgrade). `wire upgrade` now
11944    // refreshes THIS session's daemon, not the whole box. The old box-wide
11945    // design (kill every `wire daemon` process, wipe every session's pidfile,
11946    // respawn every session) was wrong for a multi-session / shared-relay box
11947    // AND broke on Windows: the CIM scan can't match the quoted
11948    // `"...\wire.exe" daemon` command line (no contiguous `wire daemon`), so it
11949    // found nothing to kill, then the respawn loop ACCUMULATED daemons
11950    // (glossy-magnolia: 2->5->8->11). The kill set is now:
11951    //   (a) THIS session's own daemon, via its pidfile pid — reliable and
11952    //       CIM-independent; plus
11953    //   (b) TRUE orphans: `wire daemon` pids owned by NO session.
11954    // It SPARES sibling sessions' daemons AND the shared loopback relay-server
11955    // (killing it would break every same-box session's routing).
11956    let my_daemon_pid = record.pid();
11957    let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
11958        .unwrap_or_default()
11959        .iter()
11960        .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
11961        .collect();
11962    let mut kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
11963    // relay_pids are intentionally NOT killed — the local relay is shared.
11964    //
11965    // v0.14.3 (closes the #198 follow-up): when `--refresh-stale-children`
11966    // is set, extend the kill set with the daemons of supervisor-reported
11967    // `stale_binary_sessions` so the supervisor respawns them on the new
11968    // binary on its next 10s poll. The supervisor's existing-pidfile check
11969    // is what made those daemons stick around in the first place — only an
11970    // explicit opt-in upgrade flag should override that policy, because
11971    // killing a daemon interrupts any in-flight sync for that session.
11972    // Errors reading supervisor state are non-fatal (no-op).
11973    let stale_children_killed: Vec<serde_json::Value> = if refresh_stale_children {
11974        match crate::daemon_supervisor::read_supervisor_state() {
11975            Ok(sv) => {
11976                let mut killed: Vec<serde_json::Value> = Vec::new();
11977                let cli_v = env!("CARGO_PKG_VERSION");
11978                for s in &sv.sessions {
11979                    if !sv.stale_binary_sessions.contains(&s.name) {
11980                        continue;
11981                    }
11982                    if let Some(pid) = s.daemon_pid {
11983                        // Don't double-add if it's already in the kill
11984                        // set (paranoia: shouldn't happen since stale
11985                        // children are sister sessions by definition).
11986                        if !kill_set.contains(&pid) {
11987                            kill_set.push(pid);
11988                        }
11989                        killed.push(json!({
11990                            "session": s.name,
11991                            "pid": pid,
11992                            "prev_version": s.daemon_version,
11993                            "cli_version": cli_v,
11994                        }));
11995                    }
11996                }
11997                if !killed.is_empty() && !as_json {
11998                    eprintln!(
11999                        "wire upgrade: --refresh-stale-children will kill {} stale-binary session daemon(s); supervisor respawns each on next 10s poll.",
12000                        killed.len()
12001                    );
12002                }
12003                killed
12004            }
12005            Err(e) => {
12006                if !as_json {
12007                    eprintln!(
12008                        "wire upgrade: --refresh-stale-children skipped — could not read supervisor state ({e:#}). \
12009                         The flag is a no-op when no `wire daemon --all-sessions` supervisor is running."
12010                    );
12011                }
12012                Vec::new()
12013            }
12014        }
12015    } else {
12016        Vec::new()
12017    };
12018
12019    if check_only {
12020        // v0.6.8: also surface session-level state + PATH dupes in --check.
12021        let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
12022            .unwrap_or_default()
12023            .iter()
12024            .filter(|s| s.daemon_running)
12025            .map(|s| s.name.clone())
12026            .collect();
12027        let path_bins = enumerate_path_wire_binaries();
12028        let path_dupes: Vec<String> = path_bins
12029            .iter()
12030            .map(|b| b.canonical.to_string_lossy().into_owned())
12031            .collect();
12032        let path_binaries_detail: Vec<serde_json::Value> = path_bins
12033            .iter()
12034            .map(|b| {
12035                json!({
12036                    "path": b.path.to_string_lossy(),
12037                    "canonical": b.canonical.to_string_lossy(),
12038                    "sha256": b.sha256,
12039                    "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
12040                    "path_index": b.path_index,
12041                    "is_active": b.is_active(),
12042                    "is_current_exe": b.is_current_exe,
12043                })
12044            })
12045            .collect();
12046        let path_warning_check = path_shadow_warning(&path_bins);
12047        // v0.7.3: enumerate which service units WOULD be refreshed.
12048        // Read-only — `status_kind` doesn't touch anything.
12049        let installed_service_kinds: Vec<&'static str> = [
12050            (crate::service::ServiceKind::Daemon, "daemon"),
12051            (crate::service::ServiceKind::LocalRelay, "local-relay"),
12052        ]
12053        .into_iter()
12054        .filter_map(|(k, label)| {
12055            crate::service::status_kind(k)
12056                .ok()
12057                .filter(|r| r.status != "absent")
12058                .map(|_| label)
12059        })
12060        .collect();
12061        let (update_latest, update_available) = match &update {
12062            Some(o) => (Some(o.latest.clone()), o.available),
12063            None => (None, false),
12064        };
12065        let report = json!({
12066            "running_pids": running_pids,
12067            "running_daemons": daemon_pids,
12068            "running_relay_servers": relay_pids,
12069            // v0.14.x: surface stale `wire mcp` host-pinned server count
12070            // so JSON consumers can drive their own /mcp-reconnect UX.
12071            // `would_warn_stale_mcp_servers` is true iff there ARE any
12072            // AND --restart-mcp was NOT passed. `would_restart_mcp_servers`
12073            // is true iff --restart-mcp WAS passed (v0.14.2) — kills the
12074            // MCP procs so the host respawns them on the new binary.
12075            "running_mcp_servers": mcp_pids,
12076            "would_warn_stale_mcp_servers": !mcp_pids.is_empty() && !restart_mcp,
12077            "would_restart_mcp_servers": restart_mcp && !mcp_pids.is_empty(),
12078            "restart_mcp_requested": restart_mcp,
12079            "pidfile_version": recorded_version,
12080            "cli_version": cli_version,
12081            "latest_published": update_latest,
12082            "update_available": update_available,
12083            "would_kill": kill_set,
12084            "would_refresh_services": installed_service_kinds,
12085            "session_daemons_running": sessions_with_daemons,
12086            "path_binaries": path_dupes,
12087            "path_binaries_detail": path_binaries_detail,
12088            "path_duplicate_warning": path_dupes.len() > 1,
12089            "path_warning": path_warning_check,
12090        });
12091        if as_json {
12092            println!("{}", serde_json::to_string(&report)?);
12093        } else {
12094            println!("wire upgrade --check");
12095            println!("  cli version:      {cli_version}");
12096            match (&update_latest, update_available) {
12097                (Some(l), true) => println!("  latest published: {l}  (UPDATE AVAILABLE)"),
12098                (Some(l), false) => println!("  latest published: {l}  (up to date)"),
12099                (None, _) => println!("  latest published: (crates.io check skipped)"),
12100            }
12101            println!(
12102                "  pidfile version:  {}",
12103                recorded_version.as_deref().unwrap_or("(missing)")
12104            );
12105            if running_pids.is_empty() {
12106                println!("  running daemons:  none");
12107                println!("  running relays:   none");
12108            } else {
12109                if daemon_pids.is_empty() {
12110                    println!("  running daemons:  none");
12111                } else {
12112                    let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
12113                    println!("  running daemons:  pids {}", p.join(", "));
12114                }
12115                if relay_pids.is_empty() {
12116                    println!("  running relays:   none");
12117                } else {
12118                    let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
12119                    println!("  running relays:   pids {}", p.join(", "));
12120                }
12121                println!("  would kill all + spawn fresh");
12122            }
12123            // v0.14.x: surface the MCP-server pin gotcha in `--check` too
12124            // so an operator probing "what will this do?" sees the full
12125            // story BEFORE running the actual upgrade. v0.14.2: line
12126            // adapts to --restart-mcp.
12127            if !mcp_pids.is_empty() {
12128                let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12129                if restart_mcp {
12130                    println!(
12131                        "  wire mcp servers: pids {} (would be killed via --restart-mcp; host respawns on new binary)",
12132                        p.join(", ")
12133                    );
12134                } else {
12135                    println!(
12136                        "  wire mcp servers: pids {} (NOT killed; each Claude tab must `/mcp` reconnect, or re-run with --restart-mcp to signal them now)",
12137                        p.join(", ")
12138                    );
12139                }
12140            }
12141            if !installed_service_kinds.is_empty() {
12142                println!(
12143                    "  would refresh:    {} installed service unit(s) → new binary path",
12144                    installed_service_kinds.join(", ")
12145                );
12146            }
12147            if !sessions_with_daemons.is_empty() {
12148                println!(
12149                    "  session daemons:  {} (would respawn under new binary)",
12150                    sessions_with_daemons.join(", ")
12151                );
12152            }
12153            // v0.14.3: preview the --refresh-stale-children effect in
12154            // --check too so operators can dry-run "what would the
12155            // flag do?" before committing.
12156            if let Ok(sv) = crate::daemon_supervisor::read_supervisor_state()
12157                && !sv.stale_binary_sessions.is_empty()
12158            {
12159                let cli_v = env!("CARGO_PKG_VERSION");
12160                if refresh_stale_children {
12161                    println!(
12162                        "  stale children:   {} session(s) on old binary; --refresh-stale-children WOULD kill each so supervisor respawns on v{cli_v}",
12163                        sv.stale_binary_sessions.len()
12164                    );
12165                } else {
12166                    println!(
12167                        "  stale children:   {} session(s) on old binary (v{cli_v} is current); rerun with --refresh-stale-children to refresh them",
12168                        sv.stale_binary_sessions.len()
12169                    );
12170                }
12171                for name in &sv.stale_binary_sessions {
12172                    let ver = sv
12173                        .sessions
12174                        .iter()
12175                        .find(|s| &s.name == name)
12176                        .and_then(|s| s.daemon_version.clone())
12177                        .unwrap_or_else(|| "?".to_string());
12178                    println!("                    - {name} running v{ver}");
12179                }
12180            }
12181            if let Some(w) = &path_warning_check {
12182                println!("  PATH check:");
12183                for line in w.lines() {
12184                    println!("    {line}");
12185                }
12186            }
12187        }
12188        return Ok(());
12189    }
12190
12191    // 3. Terminate the kill set. Graceful first, then FORCE-kill any survivor.
12192    //
12193    // v0.13.2 (B fix #2): the force-kill must NOT be gated on graceful having
12194    // "succeeded". On Windows, `taskkill /PID /T` WITHOUT `/F` is a no-op for a
12195    // windowless daemon (it returns failure), so the rc9 logic — which only
12196    // force-killed pids that graceful had reported killing — force-killed
12197    // NOTHING, and the daemon survived every `wire upgrade` (glossy: pidfile
12198    // pids 3676/25236/24660 all survived → accumulation). Now we attempt
12199    // graceful best-effort, grace-wait, then force-kill EVERY pid still alive
12200    // regardless of the graceful result. Force-kill (`taskkill /F /T` /
12201    // SIGKILL) is the load-bearing step.
12202    for pid in &kill_set {
12203        let _ = crate::platform::kill_process(*pid, false); // best-effort graceful
12204    }
12205    if !kill_set.is_empty() {
12206        // Brief grace for platforms where graceful works (Unix SIGTERM).
12207        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
12208        while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
12209        {
12210            std::thread::sleep(std::time::Duration::from_millis(50));
12211        }
12212        // Force-kill every survivor — this is what actually kills the
12213        // windowless daemon on Windows.
12214        for pid in &kill_set {
12215            if process_alive_pid(*pid) {
12216                let _ = crate::platform::kill_process(*pid, true);
12217            }
12218        }
12219        std::thread::sleep(std::time::Duration::from_millis(200)); // settle
12220    }
12221    // Report what's actually gone (drives the "no stale" message + JSON).
12222    let killed: Vec<u32> = kill_set
12223        .iter()
12224        .copied()
12225        .filter(|p| !process_alive_pid(*p))
12226        .collect();
12227
12228    // 4. Remove stale pidfile so ensure_daemon_running doesn't think the
12229    //    old daemon is still owning it.
12230    let pidfile = config::state_dir()?.join("daemon.pid");
12231    if pidfile.exists() {
12232        let _ = std::fs::remove_file(&pidfile);
12233    }
12234
12235    // 4b. v0.13.2: session-scoped — only THIS session's pidfile is wiped
12236    // (already removed at step 4 above). We deliberately DO NOT touch sibling
12237    // sessions' pidfiles: their daemons were spared, so wiping their pidfiles
12238    // would make them look down and the old box-wide respawn would spawn
12239    // duplicates (the accumulation bug). Each sibling refreshes itself on its
12240    // own `wire upgrade`.
12241
12242    // 4c. v0.6.8 PATH duplicate-binary detection. If `wire` resolves to
12243    // multiple distinct files on $PATH, surface the conflict — operators
12244    // get bitten when an old binary at /usr/local/bin shadows a fresh
12245    // ~/.local/bin install (or vice versa). Warning only; no auto-fix.
12246    let path_bins = enumerate_path_wire_binaries();
12247    let path_dupes: Vec<String> = path_bins
12248        .iter()
12249        .map(|b| b.canonical.to_string_lossy().into_owned())
12250        .collect();
12251    let path_binaries_detail: Vec<Value> = path_bins
12252        .iter()
12253        .map(|b| {
12254            json!({
12255                "path": b.path.to_string_lossy(),
12256                "canonical": b.canonical.to_string_lossy(),
12257                "sha256": b.sha256,
12258                "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
12259                "path_index": b.path_index,
12260                "is_active": b.is_active(),
12261                "is_current_exe": b.is_current_exe,
12262            })
12263        })
12264        .collect();
12265    let path_warning = path_shadow_warning(&path_bins);
12266
12267    // 4d. v0.7.3 NEW: refresh installed service units so they point at
12268    // the freshly-installed binary path. Without this step, an upgrade
12269    // would: kill the old daemon, leave the launchd plist /
12270    // systemd unit / Windows scheduled task pointing at the OLD
12271    // binary path (or, worse, an old binary location that's been
12272    // unlinked), and then the OS's auto-respawn would either fail or
12273    // bring the OLD binary back from the dead. Reinstalling rewrites
12274    // the unit with `std::env::current_exe()` (the freshly-resolved
12275    // path of the running upgrade-driver process) and re-bootstraps /
12276    // re-enables / re-registers so the next OS-driven start uses it.
12277    //
12278    // Only refreshes units that are already installed — does NOT
12279    // install services the operator never opted into.
12280    let mut service_refreshes: Vec<Value> = Vec::new();
12281    for kind in [
12282        crate::service::ServiceKind::Daemon,
12283        crate::service::ServiceKind::LocalRelay,
12284    ] {
12285        let already_installed = crate::service::status_kind(kind)
12286            .map(|r| r.status != "absent")
12287            .unwrap_or(false);
12288        if !already_installed {
12289            continue;
12290        }
12291        match crate::service::install_kind(kind) {
12292            Ok(rep) => service_refreshes.push(json!({
12293                "kind": rep.kind,
12294                "platform": rep.platform,
12295                "status": rep.status,
12296                "unit_path": rep.unit_path,
12297                "action": "refreshed",
12298            })),
12299            Err(e) => service_refreshes.push(json!({
12300                "kind": format!("{kind:?}"),
12301                "action": "refresh_failed",
12302                "error": format!("{e:#}"),
12303            })),
12304        }
12305    }
12306
12307    // 5. Spawn fresh daemon via ensure_up — atomically waits for
12308    //    process_alive + writes the versioned pidfile.
12309    //
12310    // v0.14.2 (#170 supervisor follow-up): when the Daemon service
12311    // was successfully refreshed AND its launchd / systemd / Task
12312    // Scheduler bootstrap succeeded, the OS will (re)start the
12313    // `wire daemon --all-sessions` supervisor on the new binary
12314    // within seconds, and the supervisor will spawn this session's
12315    // child within its 10s registry poll. ensure_daemon_running()'s
12316    // single-session foreground spawn is redundant in that path —
12317    // it would create a transient daemon that the supervisor's
12318    // singleton-guard subsequently no-ops, AND the
12319    // "wire upgrade: spawned fresh daemon (pid N)" line in the
12320    // output misleads operators into thinking pid N is the
12321    // long-lived owner.
12322    //
12323    // Skip the redundant spawn only when BOTH conditions hold:
12324    //   1. The Daemon service refresh succeeded (entry present,
12325    //      action=="refreshed").
12326    //   2. The bootstrap step itself returned a "loaded" / "enabled"
12327    //      / "registered" status (per platform). This is what
12328    //      `install_kind` reports in its `status` field when
12329    //      launchctl bootstrap / systemctl enable --now / schtasks
12330    //      Create succeeded. Anything else (status=="written")
12331    //      means the OS bootstrap failed — fall back to the
12332    //      foreground spawn so this session still has a daemon.
12333    let supervisor_will_spawn = service_refreshes.iter().any(|r| {
12334        let kind = r.get("kind").and_then(Value::as_str).unwrap_or("");
12335        let action = r.get("action").and_then(Value::as_str).unwrap_or("");
12336        let status = r.get("status").and_then(Value::as_str).unwrap_or("");
12337        kind == "daemon"
12338            && action == "refreshed"
12339            && matches!(
12340                status,
12341                "loaded" | "enabled" | "active" | "registered" | "running"
12342            )
12343    });
12344    let spawned = if supervisor_will_spawn {
12345        // Defer to launchd / systemd / Task Scheduler. Pidfile reads
12346        // below still report the eventual supervisor child's state.
12347        None
12348    } else {
12349        Some(crate::ensure_up::ensure_daemon_running()?)
12350    };
12351
12352    // 5b. v0.13.2: session-scoped — no sibling respawn. `ensure_daemon_running`
12353    // above already respawned THIS session's daemon; sibling sessions were
12354    // spared (never killed), so there is nothing to respawn for them. Each
12355    // refreshes itself on its own `wire upgrade`.
12356    let session_respawns: Vec<Value> = Vec::new();
12357
12358    let new_record = crate::ensure_up::read_pid_record("daemon");
12359    let new_pid = new_record.pid();
12360    let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
12361        Some(d.version.clone())
12362    } else {
12363        None
12364    };
12365
12366    // 5c. v0.14.2: --restart-mcp also signals host-pinned `wire mcp` server
12367    // subprocesses to restart on the new binary. Per
12368    // `feedback_wire_upgrade_skips_mcp_servers`: macOS mmap + harness-pinned
12369    // MCP subprocesses mean sister Claude / Copilot CLI sessions stay on
12370    // pre-upgrade MCP code until each session explicitly `/mcp` reconnects.
12371    // Killing the MCP child closes its stdio; the MCP host (Claude Code /
12372    // Claude.app / Copilot CLI) auto-respawns it via its own restart
12373    // logic — picking up the new on-disk binary.
12374    //
12375    // Cross-session impact: kills EVERY `wire mcp` subprocess found, not
12376    // just this session's. There is no per-session MCP pidfile registry
12377    // (these procs are host-spawned). Operators opting in via the flag
12378    // accept the brief MCP-tool-unavailable window while hosts respawn.
12379    //
12380    // Same graceful-then-force-kill pattern as the daemon kill loop above —
12381    // taskkill /F is load-bearing on Windows for windowless subprocs.
12382    let killed_mcp: Vec<u32> = if restart_mcp && !mcp_pids.is_empty() {
12383        for pid in &mcp_pids {
12384            let _ = crate::platform::kill_process(*pid, false);
12385        }
12386        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
12387        while std::time::Instant::now() < deadline && mcp_pids.iter().any(|p| process_alive_pid(*p))
12388        {
12389            std::thread::sleep(std::time::Duration::from_millis(50));
12390        }
12391        for pid in &mcp_pids {
12392            if process_alive_pid(*pid) {
12393                let _ = crate::platform::kill_process(*pid, true);
12394            }
12395        }
12396        mcp_pids
12397            .iter()
12398            .copied()
12399            .filter(|p| !process_alive_pid(*p))
12400            .collect()
12401    } else {
12402        Vec::new()
12403    };
12404
12405    if as_json {
12406        println!(
12407            "{}",
12408            serde_json::to_string(&json!({
12409                "killed": killed,
12410                "found_daemons": daemon_pids,
12411                "spared_relay_servers": relay_pids,
12412                // v0.14.x: same surface as `--check` — JSON consumers
12413                // get the stale-MCP-server pid list so they can drive
12414                // operator UX (e.g., a tab-restart prompt). With
12415                // --restart-mcp (v0.14.2), `killed_mcp_server_pids`
12416                // carries what the upgrade itself signaled; the host
12417                // (Claude Code / Claude.app / Copilot CLI) respawns
12418                // them on the new binary. Without the flag, the procs
12419                // were NEVER candidates for the kill set and the
12420                // `stale_mcp_warning` is the human-readable nudge.
12421                "stale_mcp_server_pids": mcp_pids,
12422                "killed_mcp_server_pids": killed_mcp,
12423                "restart_mcp_requested": restart_mcp,
12424                "stale_mcp_warning": if mcp_pids.is_empty() || restart_mcp {
12425                    Value::Null
12426                } else {
12427                    json!(format!(
12428                        "{} `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)",
12429                        mcp_pids.len()
12430                    ))
12431                },
12432                "service_refreshes": service_refreshes,
12433                "spawned_fresh_daemon": spawned,
12434                "new_pid": new_pid,
12435                "new_version": new_version,
12436                "cli_version": cli_version,
12437                "session_respawns": session_respawns,
12438                "stale_children_killed": stale_children_killed,
12439                "path_binaries": path_dupes,
12440                "path_binaries_detail": path_binaries_detail,
12441                "path_warning": path_warning,
12442            }))?
12443        );
12444    } else {
12445        if killed.is_empty() {
12446            println!("wire upgrade: no stale wire processes running");
12447        } else {
12448            let killed_list = killed
12449                .iter()
12450                .map(|p| p.to_string())
12451                .collect::<Vec<_>>()
12452                .join(", ");
12453            // Session-scoped: report what was actually killed, and that the
12454            // shared relay-server was SPARED (not killed) — the old wording
12455            // lumped the spared relay into the killed count and read like it
12456            // had been terminated (glossy-magnolia nit).
12457            if relay_pids.is_empty() {
12458                println!(
12459                    "wire upgrade: killed {} daemon(s) [{killed_list}]",
12460                    killed.len()
12461                );
12462            } else {
12463                let relay_list = relay_pids
12464                    .iter()
12465                    .map(|p| p.to_string())
12466                    .collect::<Vec<_>>()
12467                    .join(", ");
12468                println!(
12469                    "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
12470                    killed.len(),
12471                    relay_pids.len()
12472                );
12473            }
12474        }
12475        if !stale_children_killed.is_empty() {
12476            let cli_v = env!("CARGO_PKG_VERSION");
12477            println!(
12478                "wire upgrade: refreshed {} stale-binary session daemon(s) (supervisor respawns on v{cli_v} on next 10s poll):",
12479                stale_children_killed.len()
12480            );
12481            for entry in &stale_children_killed {
12482                let name = entry.get("session").and_then(Value::as_str).unwrap_or("?");
12483                let pid = entry.get("pid").and_then(Value::as_u64).unwrap_or(0);
12484                let prev = entry
12485                    .get("prev_version")
12486                    .and_then(Value::as_str)
12487                    .unwrap_or("?");
12488                println!("                    - {name} (pid {pid}, was v{prev})");
12489            }
12490        }
12491        if !service_refreshes.is_empty() {
12492            println!(
12493                "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
12494                service_refreshes.len()
12495            );
12496            for r in &service_refreshes {
12497                let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
12498                let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
12499                let status = r.get("status").and_then(Value::as_str).unwrap_or("");
12500                let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
12501                if action == "refreshed" {
12502                    println!("                    - {kind}: {action} ({status}, {platform})");
12503                } else {
12504                    let err = r.get("error").and_then(Value::as_str).unwrap_or("");
12505                    println!("                    - {kind}: {action} ({err})");
12506                }
12507            }
12508        }
12509        match spawned {
12510            Some(true) => println!(
12511                "wire upgrade: spawned fresh daemon (pid {} v{})",
12512                new_pid
12513                    .map(|p| p.to_string())
12514                    .unwrap_or_else(|| "?".to_string()),
12515                new_version.as_deref().unwrap_or(&cli_version),
12516            ),
12517            Some(false) => {
12518                println!("wire upgrade: daemon was already running on current binary");
12519            }
12520            // v0.14.2 (#170 follow-up): Daemon service refresh
12521            // succeeded → launchd / systemd / Task Scheduler will
12522            // (re)start the `--all-sessions` supervisor on the new
12523            // binary, which spawns this session's child within its
12524            // next registry poll (default 10s). No foreground spawn
12525            // needed.
12526            None => println!(
12527                "wire upgrade: daemon refresh deferred to {} supervisor (will spawn within 10s)",
12528                if cfg!(target_os = "macos") {
12529                    "launchd"
12530                } else if cfg!(target_os = "linux") {
12531                    "systemd"
12532                } else if cfg!(target_os = "windows") {
12533                    "Task Scheduler"
12534                } else {
12535                    "OS"
12536                }
12537            ),
12538        }
12539        if !session_respawns.is_empty() {
12540            println!(
12541                "wire upgrade: refreshed {} session daemon(s):",
12542                session_respawns.len()
12543            );
12544            for r in &session_respawns {
12545                let h = r["session_home"].as_str().unwrap_or("?");
12546                let s = r["status"].as_str().unwrap_or("?");
12547                let label = std::path::Path::new(h)
12548                    .file_name()
12549                    .map(|f| f.to_string_lossy().into_owned())
12550                    .unwrap_or_else(|| h.to_string());
12551                println!("  {label:<24} {s}");
12552            }
12553        }
12554        if let Some(msg) = &path_warning {
12555            eprintln!("wire upgrade: {msg}");
12556        }
12557        // v0.14.x: surface MCP-server subprocess status. Without
12558        // --restart-mcp, warn the operator that sister Claude tabs
12559        // keep running pre-upgrade code until each one explicitly
12560        // `/mcp` reconnects — the "fix shipped but my sister session
12561        // still shows the old behavior" support-ping pattern that
12562        // surfaced this gap. With --restart-mcp (v0.14.2), report
12563        // what we signaled so the operator sees the brief
12564        // MCP-tool-unavailable window is by design.
12565        if restart_mcp {
12566            if !killed_mcp.is_empty() {
12567                let p: Vec<String> = killed_mcp.iter().map(|p| p.to_string()).collect();
12568                println!(
12569                    "wire upgrade: killed {} `wire mcp` server subprocess(es) [{}]; host (Claude Code / Claude.app / Copilot CLI) will respawn on the new binary.",
12570                    killed_mcp.len(),
12571                    p.join(", ")
12572                );
12573            } else if mcp_pids.is_empty() {
12574                // --restart-mcp was set but no MCP servers were running.
12575                // Common when the operator runs `wire upgrade` from a
12576                // shell with no Claude / Copilot session attached.
12577                println!(
12578                    "wire upgrade: --restart-mcp set, but no `wire mcp` server subprocesses were running."
12579                );
12580            } else {
12581                // Asked to restart but none of them actually died — the
12582                // operator should investigate (likely a permission
12583                // issue or a sibling-user pid that wire can't signal).
12584                let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12585                eprintln!(
12586                    "wire upgrade: WARNING — --restart-mcp requested but {} `wire mcp` subprocess(es) [{}] survived signaling. Check process ownership / OS permissions.",
12587                    mcp_pids.len(),
12588                    p.join(", ")
12589                );
12590            }
12591        } else if !mcp_pids.is_empty() {
12592            let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12593            eprintln!(
12594                "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.",
12595                mcp_pids.len(),
12596                p.join(", ")
12597            );
12598        }
12599    }
12600    Ok(())
12601}
12602
12603/// v0.9.1: should this command emit JSON by default?
12604///
12605/// - `explicit=true` → operator passed `--json`, always JSON.
12606/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
12607///   captured output parses cleanly without operators remembering to
12608///   append `--json`. Mirrors `gh`, `kubectl`, etc.
12609/// - interactive TTY → human format (false).
12610/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
12611///   that parsed the human text by accident).
12612fn json_default(explicit: bool) -> bool {
12613    if explicit {
12614        return true;
12615    }
12616    if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
12617        return false;
12618    }
12619    use std::io::IsTerminal;
12620    !std::io::stdout().is_terminal()
12621}
12622
12623fn process_alive_pid(pid: u32) -> bool {
12624    // v0.7.3: delegate to the cross-platform helper. See
12625    // `platform::process_alive` for the per-OS dispatch — Windows now
12626    // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
12627    // gave a hard-coded false on Windows pre-v0.7.3.
12628    crate::platform::process_alive(pid)
12629}
12630
12631// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
12632
12633/// Iterative Levenshtein distance between two strings, case-insensitive.
12634/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
12635/// resolves against (typically <30 chars).
12636fn levenshtein_ci(a: &str, b: &str) -> usize {
12637    let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
12638    let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
12639    let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
12640    let (m, n) = (a.len(), b.len());
12641    if m == 0 {
12642        return n;
12643    }
12644    let mut prev: Vec<usize> = (0..=m).collect();
12645    let mut curr = vec![0usize; m + 1];
12646    for j in 1..=n {
12647        curr[0] = j;
12648        for i in 1..=m {
12649            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
12650            curr[i] = std::cmp::min(
12651                std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
12652                prev[i - 1] + cost,
12653            );
12654        }
12655        std::mem::swap(&mut prev, &mut curr);
12656    }
12657    prev[m]
12658}
12659
12660/// Return up to `max_results` names from `pool` whose edit distance to
12661/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
12662/// "did you mean" suggestions on resolution miss.
12663pub fn closest_candidates(
12664    needle: &str,
12665    pool: &[String],
12666    max_distance: usize,
12667    max_results: usize,
12668) -> Vec<String> {
12669    let mut scored: Vec<(usize, &String)> = pool
12670        .iter()
12671        .map(|c| (levenshtein_ci(needle, c), c))
12672        .filter(|(d, _)| *d <= max_distance)
12673        .collect();
12674    scored.sort_by_key(|(d, _)| *d);
12675    scored
12676        .into_iter()
12677        .take(max_results)
12678        .map(|(_, c)| c.clone())
12679        .collect()
12680}
12681
12682/// Collect every name that `resolve_name_to_target` would currently
12683/// match: pinned-peer handles, pinned-peer character nicknames, sister
12684/// session names, sister character nicknames, sister handles. Used for
12685/// the `did_you_mean` pool on resolution miss.
12686fn known_local_names() -> Vec<String> {
12687    let mut names: Vec<String> = Vec::new();
12688    if let Ok(trust) = config::read_trust() {
12689        // (debug eprintln removed; left bug-trail in commit message)
12690        // trust.agents is an object keyed by handle, NOT an array —
12691        // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
12692        // the object's keys (which ARE the handles) plus each entry's
12693        // did for the DID-derived character nickname.
12694        if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
12695            for (handle, agent) in agents {
12696                names.push(handle.clone());
12697                if let Some(did) = agent.get("did").and_then(Value::as_str) {
12698                    let ch = crate::character::Character::from_did(did);
12699                    names.push(ch.nickname);
12700                }
12701            }
12702        }
12703    }
12704    if let Ok(sessions) = crate::session::list_sessions() {
12705        for s in sessions {
12706            names.push(s.name.clone());
12707            if let Some(h) = &s.handle {
12708                names.push(h.clone());
12709            }
12710            if let Some(ch) = &s.character {
12711                names.push(ch.nickname.clone());
12712            }
12713        }
12714    }
12715    names.sort();
12716    names.dedup();
12717    names
12718}
12719
12720// ---------- doctor (single-command diagnostic) ----------
12721
12722/// One DoctorCheck = one verdict on one health dimension.
12723#[derive(Clone, Debug, serde::Serialize)]
12724pub struct DoctorCheck {
12725    /// Short stable identifier (`daemon`, `relay`, `pair_rejections`, ...).
12726    /// Stable across versions for tooling consumption.
12727    pub id: String,
12728    /// PASS / WARN / FAIL.
12729    pub status: String,
12730    /// One-line human summary.
12731    pub detail: String,
12732    /// Optional remediation hint shown after the failing line.
12733    #[serde(skip_serializing_if = "Option::is_none")]
12734    pub fix: Option<String>,
12735}
12736
12737impl DoctorCheck {
12738    fn pass(id: &str, detail: impl Into<String>) -> Self {
12739        Self {
12740            id: id.into(),
12741            status: "PASS".into(),
12742            detail: detail.into(),
12743            fix: None,
12744        }
12745    }
12746    fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12747        Self {
12748            id: id.into(),
12749            status: "WARN".into(),
12750            detail: detail.into(),
12751            fix: Some(fix.into()),
12752        }
12753    }
12754    fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12755        Self {
12756            id: id.into(),
12757            status: "FAIL".into(),
12758            detail: detail.into(),
12759            fix: Some(fix.into()),
12760        }
12761    }
12762}
12763
12764/// `wire doctor` — single-command diagnostic for the silent-fail classes
12765/// 0.5.11 ships fixes for. Surfaces what each fix produces (P0.1 cursor
12766/// blocks, P0.2 pair-rejection logs, P0.4 daemon version mismatch, etc.)
12767/// so operators don't have to know where each lives.
12768fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
12769    let checks: Vec<DoctorCheck> = vec![
12770        check_daemon_health(),
12771        check_daemon_pid_consistency(),
12772        check_relay_reachable(),
12773        check_pair_rejections(recent_rejections),
12774        check_cursor_progress(),
12775        check_peer_staleness(7),
12776        check_and_heal_self_userinfo_endpoints(),
12777        check_stale_inbound_pairs(),
12778    ];
12779
12780    let fails = checks.iter().filter(|c| c.status == "FAIL").count();
12781    let warns = checks.iter().filter(|c| c.status == "WARN").count();
12782
12783    if as_json {
12784        println!(
12785            "{}",
12786            serde_json::to_string(&json!({
12787                "checks": checks,
12788                "fail_count": fails,
12789                "warn_count": warns,
12790                "ok": fails == 0,
12791            }))?
12792        );
12793    } else {
12794        println!("wire doctor — {} checks", checks.len());
12795        for c in &checks {
12796            let bullet = match c.status.as_str() {
12797                "PASS" => "✓",
12798                "WARN" => "!",
12799                "FAIL" => "✗",
12800                _ => "?",
12801            };
12802            println!("  {bullet} [{}] {}: {}", c.status, c.id, c.detail);
12803            if let Some(fix) = &c.fix {
12804                println!("      fix: {fix}");
12805            }
12806        }
12807        println!();
12808        if fails == 0 && warns == 0 {
12809            println!("ALL GREEN");
12810        } else {
12811            println!("{fails} FAIL, {warns} WARN");
12812        }
12813    }
12814
12815    if fails > 0 {
12816        std::process::exit(1);
12817    }
12818    Ok(())
12819}
12820
12821/// Check: daemon running, exactly one instance, no orphans.
12822///
12823/// Today's debug surfaced PID 54017 (old-binary wire daemon running for 4
12824/// days, advancing cursor without pinning). `wire status` lied about it.
12825/// `wire doctor` must catch THIS class: multiple daemons running, OR
12826/// pid-file claims daemon down while a process is actually up.
12827fn check_daemon_health() -> DoctorCheck {
12828    // v0.5.13 (issue #2 bug A): doctor PASSed on orphan-only state while
12829    // `wire status` reported DOWN, disagreeing for 25 min. v0.5.19 (#2
12830    // hardening): every surface routes through ensure_up::daemon_liveness
12831    // so they share one view of the world. No more parallel liveness
12832    // logic to drift out of sync.
12833    let snap = crate::ensure_up::daemon_liveness();
12834    let pgrep_pids = &snap.pgrep_pids;
12835    let pidfile_pid = snap.pidfile_pid;
12836    let pidfile_alive = snap.pidfile_alive;
12837    let orphan_pids = &snap.orphan_pids;
12838
12839    let fmt_pids = |xs: &[u32]| -> String {
12840        xs.iter()
12841            .map(|p| p.to_string())
12842            .collect::<Vec<_>>()
12843            .join(", ")
12844    };
12845
12846    match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
12847        (0, _, _) => DoctorCheck::fail(
12848            "daemon",
12849            "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
12850            "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
12851        ),
12852        // Single daemon AND it matches the pidfile → healthy.
12853        (1, true, true) => DoctorCheck::pass(
12854            "daemon",
12855            format!(
12856                "one daemon running (pid {}, matches pidfile)",
12857                pgrep_pids[0]
12858            ),
12859        ),
12860        // Pidfile is alive but pgrep ALSO sees orphan processes.
12861        (n, true, false) => DoctorCheck::fail(
12862            "daemon",
12863            format!(
12864                "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
12865                 The orphans race the relay cursor — they advance past events your current binary can't process. \
12866                 (Issue #2 exact class.)",
12867                fmt_pids(pgrep_pids),
12868                pidfile_pid.unwrap(),
12869                fmt_pids(orphan_pids),
12870            ),
12871            "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
12872        ),
12873        // Pidfile is dead but processes ARE running → all are orphans.
12874        (n, false, _) => DoctorCheck::fail(
12875            "daemon",
12876            format!(
12877                "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
12878                 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
12879                 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
12880                fmt_pids(pgrep_pids),
12881                match pidfile_pid {
12882                    Some(p) => format!("claims pid {p} which is dead"),
12883                    None => "is missing".to_string(),
12884                },
12885            ),
12886            "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
12887        ),
12888        // v0.14.2 (#170 supervisor follow-up): the
12889        // `(n>1, true, orphan_pids.is_empty())` case is the
12890        // legitimate `wire daemon --all-sessions` supervisor topology
12891        // — supervisor + N session children, all accounted for via
12892        // their per-session pidfiles + the central supervisor.pid.
12893        // Pre-fix this fell through to the legacy "Multiple daemons
12894        // race the relay cursor" warning with a destructive
12895        // `pkill -f "wire daemon"; wire daemon &` recommendation
12896        // that would WIPE the working supervisor + every session
12897        // child. Operators on the #170 path saw it on every
12898        // `wire doctor`.
12899        (n, true, true) => {
12900            // Probe: is one of these pids the supervisor?
12901            let supervisor_pid: Option<u32> = crate::session::sessions_root()
12902                .ok()
12903                .map(|root| root.join("supervisor.pid"))
12904                .filter(|p| p.exists())
12905                .and_then(|p| std::fs::read_to_string(p).ok())
12906                .and_then(|s| s.trim().parse::<u32>().ok())
12907                .filter(|p| crate::ensure_up::pid_is_alive(*p));
12908            if let Some(sup) = supervisor_pid
12909                && pgrep_pids.contains(&sup)
12910            {
12911                let child_count = n.saturating_sub(1);
12912                DoctorCheck::pass(
12913                    "daemon",
12914                    format!(
12915                        "supervisor (pid {sup}) + {child_count} session child daemon(s) — legitimate #170 `--all-sessions` topology, no orphans"
12916                    ),
12917                )
12918            } else {
12919                DoctorCheck::warn(
12920                    "daemon",
12921                    format!(
12922                        "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
12923                        fmt_pids(pgrep_pids)
12924                    ),
12925                    "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
12926                )
12927            }
12928        }
12929    }
12930}
12931
12932/// Check: structured pidfile matches running daemon. Spark's P0.4 5th
12933/// check. Surfaces version mismatch (daemon running old binary text in
12934/// memory under a current symlink — today's exact bug class), schema
12935/// drift (future format bumps), and identity contamination (daemon's
12936/// recorded DID doesn't match this box's configured DID).
12937///
12938/// v0.5.19 (#2 hardening): also surfaces stale pidfiles — a well-formed
12939/// JSON pid record whose recorded `pid` is no longer a live OS process.
12940/// Pre-hardening this check PASSed in that state (it only validated
12941/// content, not liveness), letting `wire status: DOWN` and
12942/// `wire doctor: PASS` disagree for 25 min in incident #2.
12943fn check_daemon_pid_consistency() -> DoctorCheck {
12944    let snap = crate::ensure_up::daemon_liveness();
12945    match &snap.record {
12946        crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
12947            "daemon_pid_consistency",
12948            "no daemon.pid yet — fresh box or daemon never started",
12949        ),
12950        crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
12951            "daemon_pid_consistency",
12952            format!("daemon.pid is corrupt: {reason}"),
12953            "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
12954        ),
12955        crate::ensure_up::PidRecord::Json(d) => {
12956            // v0.5.19 liveness gate: if the recorded pid is dead, the
12957            // pidfile is stale and the rest of the content drift checks
12958            // are moot — `wire upgrade` is the answer regardless.
12959            if !snap.pidfile_alive {
12960                return DoctorCheck::warn(
12961                    "daemon_pid_consistency",
12962                    format!(
12963                        "daemon.pid records pid {pid} (v{version}) but that process is not running — \
12964                         pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
12965                         silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
12966                        pid = d.pid,
12967                        version = d.version,
12968                    ),
12969                    "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
12970                     (kills any orphan daemon advancing the cursor without coordination)",
12971                );
12972            }
12973            let mut issues: Vec<String> = Vec::new();
12974            if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
12975                issues.push(format!(
12976                    "schema={} (expected {})",
12977                    d.schema,
12978                    crate::ensure_up::DAEMON_PID_SCHEMA
12979                ));
12980            }
12981            let cli_version = env!("CARGO_PKG_VERSION");
12982            if d.version != cli_version {
12983                issues.push(format!("version daemon={} cli={cli_version}", d.version));
12984            }
12985            if !std::path::Path::new(&d.bin_path).exists() {
12986                issues.push(format!("bin_path {} missing on disk", d.bin_path));
12987            }
12988            // Cross-check DID + relay against current config (best-effort).
12989            if let Ok(card) = config::read_agent_card()
12990                && let Some(current_did) = card.get("did").and_then(Value::as_str)
12991                && let Some(recorded_did) = &d.did
12992                && recorded_did != current_did
12993            {
12994                issues.push(format!(
12995                    "did daemon={recorded_did} config={current_did} — identity drift"
12996                ));
12997            }
12998            if let Ok(state) = config::read_relay_state()
12999                && let Some(current_relay) = state
13000                    .get("self")
13001                    .and_then(|s| s.get("relay_url"))
13002                    .and_then(Value::as_str)
13003                && let Some(recorded_relay) = &d.relay_url
13004                && recorded_relay != current_relay
13005            {
13006                issues.push(format!(
13007                    "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
13008                ));
13009            }
13010            if issues.is_empty() {
13011                DoctorCheck::pass(
13012                    "daemon_pid_consistency",
13013                    format!(
13014                        "daemon v{} bound to {} as {}",
13015                        d.version,
13016                        d.relay_url.as_deref().unwrap_or("?"),
13017                        d.did.as_deref().unwrap_or("?")
13018                    ),
13019                )
13020            } else {
13021                DoctorCheck::warn(
13022                    "daemon_pid_consistency",
13023                    format!("daemon pidfile drift: {}", issues.join("; ")),
13024                    "`wire upgrade` to atomically restart daemon with current config".to_string(),
13025                )
13026            }
13027        }
13028    }
13029}
13030
13031/// Check: bound relay's /healthz returns 200.
13032fn check_relay_reachable() -> DoctorCheck {
13033    let state = match config::read_relay_state() {
13034        Ok(s) => s,
13035        Err(e) => {
13036            return DoctorCheck::fail(
13037                "relay",
13038                format!("could not read relay state: {e}"),
13039                "run `wire up <handle>@<relay>` to bootstrap",
13040            );
13041        }
13042    };
13043    let url = state
13044        .get("self")
13045        .and_then(|s| s.get("relay_url"))
13046        .and_then(Value::as_str)
13047        .unwrap_or("");
13048    if url.is_empty() {
13049        return DoctorCheck::warn(
13050            "relay",
13051            "no relay bound — wire send/pull will not work",
13052            "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
13053        );
13054    }
13055    let client = crate::relay_client::RelayClient::new(url);
13056    match client.check_healthz() {
13057        Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
13058        Err(e) => DoctorCheck::fail(
13059            "relay",
13060            format!("{url} unreachable: {e}"),
13061            format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
13062        ),
13063    }
13064}
13065
13066/// Check: count recent entries in pair-rejected.jsonl (P0.2 output). Every
13067/// entry there is a silent failure that, pre-0.5.11, would have left the
13068/// operator wondering why pairing didn't complete.
13069fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
13070    let path = match config::state_dir() {
13071        Ok(d) => d.join("pair-rejected.jsonl"),
13072        Err(e) => {
13073            return DoctorCheck::warn(
13074                "pair_rejections",
13075                format!("could not resolve state dir: {e}"),
13076                "set WIRE_HOME or fix XDG_STATE_HOME",
13077            );
13078        }
13079    };
13080    if !path.exists() {
13081        return DoctorCheck::pass(
13082            "pair_rejections",
13083            "no pair-rejected.jsonl — no recorded pair failures",
13084        );
13085    }
13086    let body = match std::fs::read_to_string(&path) {
13087        Ok(b) => b,
13088        Err(e) => {
13089            return DoctorCheck::warn(
13090                "pair_rejections",
13091                format!("could not read {path:?}: {e}"),
13092                "check file permissions",
13093            );
13094        }
13095    };
13096    let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
13097    if lines.is_empty() {
13098        return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
13099    }
13100    let total = lines.len();
13101    let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
13102    let mut summary: Vec<String> = Vec::new();
13103    for line in &recent {
13104        if let Ok(rec) = serde_json::from_str::<Value>(line) {
13105            let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
13106            let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
13107            summary.push(format!("{peer}/{code}"));
13108        }
13109    }
13110    DoctorCheck::warn(
13111        "pair_rejections",
13112        format!(
13113            "{total} pair failures recorded. recent: [{}]",
13114            summary.join(", ")
13115        ),
13116        format!(
13117            "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
13118        ),
13119    )
13120}
13121
13122/// Check: cursor isn't stuck. We can't tell without polling — but we can
13123/// report the current cursor position so operators see if it changes.
13124/// Real "stuck" detection needs two pulls separated in time; defer that
13125/// behaviour to a `wire doctor --watch` mode.
13126///
13127/// Heal stale userinfo from this agent's own published relay endpoints.
13128///
13129/// Failure mode this check closes:
13130///   PR #61 added a guard at the WRITE side that prevents NEW userinfo-
13131///   bearing endpoints (`https://<handle>@<host>`) from ever being
13132///   persisted or published. But operators who ran a pre-#61 `wire up
13133///   <handle>@<relay>` already had the malformed endpoint baked into
13134///   their on-disk `self.endpoints[]` AND their signed agent-card AND
13135///   their phonebook entry. The fix prevented the bleeding; it didn't
13136///   heal the wound. Symptoms still visible:
13137///     - Every inbound POST to the malformed endpoint (pair_drop_ack,
13138///       messages) gets a Cloudflare 400 ("missing Bearer token" /
13139///       bare 400). Peers running pre-#62 wire can't deliver to us at
13140///       all (the failover from #62 lets newer peers walk past the
13141///       bad first endpoint to a clean one if both are published —
13142///       but two-endpoint operators still get a 400 for every event
13143///       on their FIRST attempt, and operators with only the
13144///       malformed endpoint are unreachable).
13145///     - `wire pull` from our own malformed slot 400s on every cycle
13146///       (the operator sees a stderr error line every poll).
13147///     - Surfaced concretely when swift-harbor ↔ slate-lotus paired
13148///       2026-05-27: slate-lotus's pair_drop_ack 400'd; my own pulls
13149///       400'd; bilateral handshake couldn't complete via the bad
13150///       endpoint.
13151///
13152/// This is a healable failure mode — the same `strip_relay_url_userinfo`
13153/// logic from #61 can be applied to existing on-disk state. We do it
13154/// inside `wire doctor` (rather than a separate `wire heal` command)
13155/// because:
13156///   1. `wire doctor` is the canonical "what's wrong + fix it" surface
13157///      operators already know to run when something looks off.
13158///   2. The mutation is unambiguously correct — userinfo on a self-
13159///      published relay endpoint has zero legitimate cases (the
13160///      one-name rule means the handle is DID-derived, never URL
13161///      userinfo).
13162///   3. Auto-heal is consistent with what `wire bind-relay https://...`
13163///      / `wire claim` already do at the WRITE side under #61 —
13164///      this just extends the same guard to read-side cleanup.
13165///
13166/// What this check does:
13167///   - Reads `relay.json` and inspects `self.endpoints[]` plus the
13168///     legacy top-level `self.relay_url`/`slot_id`/`slot_token` triple.
13169///   - If any endpoint's `relay_url` contains userinfo, removes that
13170///     endpoint from the array AND (if the legacy top-level was the
13171///     malformed one) promotes the first clean endpoint's coords to
13172///     the legacy slots.
13173///   - Atomically writes back via `write_relay_state` (full lock +
13174///     tmp+rename, same path every other writer uses).
13175///   - Reports PASS if nothing needed healing, WARN if healing happened
13176///     (with the list of stripped URLs + a remediation pointer to
13177///     `wire claim <persona>` for re-publishing the agent-card to the
13178///     phonebook).
13179///
13180/// Re-claim is NOT auto-run here: the doctor check is read-state-bound,
13181/// and `wire claim` requires a clean agent-card resign + network
13182/// round-trip + persona arg. Operators get the explicit next step in
13183/// the WARN fix text. Two-step is the right friction: heal silently,
13184/// claim explicitly.
13185fn check_and_heal_self_userinfo_endpoints() -> DoctorCheck {
13186    let mut state = match config::read_relay_state() {
13187        Ok(s) => s,
13188        Err(_) => {
13189            return DoctorCheck::pass(
13190                "self-userinfo-endpoints",
13191                "no relay state yet — nothing published to heal".to_string(),
13192            );
13193        }
13194    };
13195    let self_block = match state.get_mut("self").and_then(Value::as_object_mut) {
13196        Some(s) => s,
13197        None => {
13198            return DoctorCheck::pass(
13199                "self-userinfo-endpoints",
13200                "no self block in relay state — nothing published to heal".to_string(),
13201            );
13202        }
13203    };
13204
13205    let mut stripped: Vec<String> = Vec::new();
13206    let mut clean_seed: Option<(String, String, String)> = None;
13207
13208    if let Some(endpoints) = self_block
13209        .get_mut("endpoints")
13210        .and_then(Value::as_array_mut)
13211    {
13212        endpoints.retain(|ep| {
13213            let url = ep.get("relay_url").and_then(Value::as_str).unwrap_or("");
13214            // Reuse the exact same authority-only userinfo detection as
13215            // #61's assert_relay_url_clean_for_publish so any future
13216            // change to that authority parse stays in lockstep.
13217            if assert_relay_url_clean_for_publish(url).is_err() {
13218                stripped.push(url.to_string());
13219                false
13220            } else {
13221                if clean_seed.is_none() {
13222                    clean_seed = Some((
13223                        url.to_string(),
13224                        ep.get("slot_id")
13225                            .and_then(Value::as_str)
13226                            .unwrap_or("")
13227                            .to_string(),
13228                        ep.get("slot_token")
13229                            .and_then(Value::as_str)
13230                            .unwrap_or("")
13231                            .to_string(),
13232                    ));
13233                }
13234                true
13235            }
13236        });
13237    }
13238
13239    // Heal the legacy top-level relay_url/slot_id/slot_token triple if it
13240    // was the malformed one. Without this, v0.5.16-era readers (and the
13241    // pair_drop_ack path that falls back to legacy fields) still pick up
13242    // the userinfo URL even after we cleaned endpoints[].
13243    let mut legacy_healed = false;
13244    let legacy_url = self_block
13245        .get("relay_url")
13246        .and_then(Value::as_str)
13247        .unwrap_or("")
13248        .to_string();
13249    if !legacy_url.is_empty() && assert_relay_url_clean_for_publish(&legacy_url).is_err() {
13250        if let Some((url, sid, tok)) = &clean_seed {
13251            self_block.insert("relay_url".to_string(), Value::String(url.clone()));
13252            self_block.insert("slot_id".to_string(), Value::String(sid.clone()));
13253            self_block.insert("slot_token".to_string(), Value::String(tok.clone()));
13254            legacy_healed = true;
13255            stripped.push(format!("(legacy top-level) {legacy_url}"));
13256        } else {
13257            // No clean endpoint exists to promote — the operator only
13258            // has malformed endpoints. We can't auto-heal this safely
13259            // (would leave them with no inbox); surface as WARN with
13260            // explicit re-bind instructions and DON'T mutate.
13261            return DoctorCheck::warn(
13262                "self-userinfo-endpoints",
13263                format!(
13264                    "your published endpoint is malformed (`{legacy_url}` — handle as URL \
13265                     userinfo, the bug PR #61 prevents going forward) AND no clean endpoint \
13266                     exists to fall back to. Inbound POSTs to this endpoint 4xx; bilateral \
13267                     pairing can't complete."
13268                ),
13269                "Bind a clean federation slot first, then re-run doctor to heal: \
13270                 `wire bind-relay https://wireup.net` (or your own relay). The bind \
13271                 adds a clean endpoint additively; the next `wire doctor` run then \
13272                 strips the malformed one safely. Finally re-publish your card with \
13273                 `wire claim <your-persona>` so the phonebook serves the clean shape."
13274                    .to_string(),
13275            );
13276        }
13277    }
13278
13279    if stripped.is_empty() && !legacy_healed {
13280        return DoctorCheck::pass(
13281            "self-userinfo-endpoints",
13282            "no malformed endpoints in self-state".to_string(),
13283        );
13284    }
13285
13286    // Persist the healed state. Best-effort: if the write fails, the
13287    // operator still sees the WARN and can run `wire claim` to re-publish;
13288    // they keep the malformed entry on disk until the next doctor cycle.
13289    if let Err(e) = config::write_relay_state(&state) {
13290        return DoctorCheck::warn(
13291            "self-userinfo-endpoints",
13292            format!(
13293                "detected {} malformed userinfo-bearing endpoint(s) in self-state but \
13294                 failed to persist the heal: {e:#}. Found: {}",
13295                stripped.len(),
13296                stripped.join(", ")
13297            ),
13298            "re-run `wire doctor` — likely a transient lock contention".to_string(),
13299        );
13300    }
13301
13302    DoctorCheck::warn(
13303        "self-userinfo-endpoints",
13304        format!(
13305            "healed {} malformed endpoint(s) in self-state on disk: {}. \
13306             These were the `https://<handle>@<host>` shape that PR #61 prevents \
13307             at the write side but couldn't retroactively scrub from existing \
13308             operators. relay.json is now clean.",
13309            stripped.len(),
13310            stripped.join(", ")
13311        ),
13312        "re-publish your agent-card to the phonebook so peers resolve to the \
13313         clean endpoint: `wire claim <your-persona>` (find your persona with \
13314         `wire whoami`)."
13315            .to_string(),
13316    )
13317}
13318
13319/// v0.14.3: surface pre-#171 stale pending_inbound records for
13320/// peers already at VERIFIED+ tier. The record itself is benign
13321/// (operator can clear with `wire reject <handle>`) but until
13322/// cleared it keeps surfacing in `wire status --json` as
13323/// `stale_inbound_handles`, which leaks into automation. Doctor is
13324/// the right place to surface low-priority hygiene — operators
13325/// scan it intentionally instead of seeing it on every status
13326/// call.
13327fn check_stale_inbound_pairs() -> DoctorCheck {
13328    let pinned_verified: std::collections::HashSet<String> = config::read_trust()
13329        .ok()
13330        .and_then(|t| t.get("agents").and_then(Value::as_object).cloned())
13331        .map(|agents| {
13332            agents
13333                .into_iter()
13334                .filter_map(|(h, a)| {
13335                    let tier = a.get("tier").and_then(Value::as_str).unwrap_or("");
13336                    if matches!(tier, "VERIFIED" | "ORG_VERIFIED" | "ATTESTED") {
13337                        Some(h)
13338                    } else {
13339                        None
13340                    }
13341                })
13342                .collect()
13343        })
13344        .unwrap_or_default();
13345    let stale: Vec<String> = crate::pending_inbound_pair::list_pending_inbound()
13346        .unwrap_or_default()
13347        .into_iter()
13348        .filter(|p| pinned_verified.contains(&p.peer_handle))
13349        .map(|p| p.peer_handle)
13350        .collect();
13351    if stale.is_empty() {
13352        return DoctorCheck::pass(
13353            "stale-inbound-pairs",
13354            "no pre-#171 leftover pending_inbound records for VERIFIED peers",
13355        );
13356    }
13357    let n = stale.len();
13358    let list = stale.join(", ");
13359    let fix_list = stale
13360        .iter()
13361        .map(|h| format!("wire reject {h}"))
13362        .collect::<Vec<_>>()
13363        .join(" && ");
13364    DoctorCheck::warn(
13365        "stale-inbound-pairs",
13366        format!(
13367            "{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`."
13368        ),
13369        format!("clear with `{fix_list}`"),
13370    )
13371}
13372
13373fn check_peer_staleness(max_silent_days: u64) -> DoctorCheck {
13374    let state = match config::read_relay_state() {
13375        Ok(s) => s,
13376        Err(_) => {
13377            return DoctorCheck::pass(
13378                "peer-staleness",
13379                "no relay state yet — nothing pinned to check".to_string(),
13380            );
13381        }
13382    };
13383    let peers = match state.get("peers").and_then(Value::as_object) {
13384        Some(p) => p,
13385        None => {
13386            return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
13387        }
13388    };
13389    if peers.is_empty() {
13390        return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
13391    }
13392    let inbox_dir = match config::inbox_dir() {
13393        Ok(d) => d,
13394        Err(_) => {
13395            return DoctorCheck::warn(
13396                "peer-staleness",
13397                "could not resolve inbox dir; skipping peer-staleness check".to_string(),
13398                "check `wire status` for state-dir resolution".to_string(),
13399            );
13400        }
13401    };
13402    let threshold_secs = max_silent_days * 24 * 60 * 60;
13403    let threshold = std::time::Duration::from_secs(threshold_secs);
13404    let now = std::time::SystemTime::now();
13405    // v0.14.3 (#14): prefer the daemon-written
13406    // `peers[<peer>].last_inbound_event_at` (RFC3339) over inbox
13407    // file mtime — mtime is fragile (backup/restore/cp/touch all
13408    // break it; FAT32 has 2s resolution etc.) and the daemon-side
13409    // field is the load-bearing sender-side staleness signal.
13410    // Falls back to mtime when the field is absent (pre-v0.14.3
13411    // sessions, or never-received-anything peers).
13412    let now_unix = now
13413        .duration_since(std::time::UNIX_EPOCH)
13414        .map(|d| d.as_secs() as i64)
13415        .unwrap_or(0);
13416    let mut stale: Vec<(String, u64, &'static str)> = Vec::new();
13417    for (peer, info) in peers {
13418        // v0.14.3 first-pass: the daemon-written field.
13419        let daemon_signal_ts = info
13420            .get("last_inbound_event_at")
13421            .and_then(Value::as_str)
13422            .and_then(|s| {
13423                time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
13424            })
13425            .map(|odt| odt.unix_timestamp());
13426        if let Some(ts) = daemon_signal_ts {
13427            let age = now_unix.saturating_sub(ts) as u64;
13428            if age > threshold_secs {
13429                stale.push((peer.clone(), age / (24 * 60 * 60), "silent"));
13430            }
13431            continue;
13432        }
13433        // Fallback: inbox file mtime (pre-v0.14.3 or never-pulled peer).
13434        let path = inbox_dir.join(format!("{peer}.jsonl"));
13435        let (age_days, kind) = match std::fs::metadata(&path) {
13436            Ok(meta) => match meta
13437                .modified()
13438                .ok()
13439                .and_then(|m| now.duration_since(m).ok())
13440            {
13441                Some(d) if d > threshold => (d.as_secs() / (24 * 60 * 60), "silent"),
13442                Some(_) => continue, // fresh — not stale
13443                None => (0, "unknown-mtime"),
13444            },
13445            Err(_) => (max_silent_days + 1, "no-inbox-file"),
13446        };
13447        stale.push((peer.clone(), age_days, kind));
13448    }
13449    if stale.is_empty() {
13450        return DoctorCheck::pass(
13451            "peer-staleness",
13452            format!(
13453                "all {} pinned peer(s) have inbox traffic within the last {max_silent_days} day(s)",
13454                peers.len()
13455            ),
13456        );
13457    }
13458    let detail = stale
13459        .iter()
13460        .map(|(p, d, k)| match *k {
13461            "no-inbox-file" => format!("{p} (no inbox file)"),
13462            "unknown-mtime" => format!("{p} (unknown last-event time)"),
13463            _ => format!("{p} ({d}d silent)"),
13464        })
13465        .collect::<Vec<_>>()
13466        .join(", ");
13467    DoctorCheck::warn(
13468        "peer-staleness",
13469        format!(
13470            "{} pinned peer(s) silent for >{max_silent_days}d: {detail}. \
13471             If the peer re-bound their relay slot, our pin is now stale — \
13472             we push successfully to a dead slot and they never see us \
13473             (asymmetric failure, both sides report green).",
13474            stale.len()
13475        ),
13476        "re-pair with `wire add <peer>@<relay>` to refresh the slot. \
13477         Once issue #15 lands, this also auto-resolves on 410 Gone."
13478            .to_string(),
13479    )
13480}
13481
13482fn check_cursor_progress() -> DoctorCheck {
13483    let state = match config::read_relay_state() {
13484        Ok(s) => s,
13485        Err(e) => {
13486            return DoctorCheck::warn(
13487                "cursor",
13488                format!("could not read relay state: {e}"),
13489                "check ~/Library/Application Support/wire/relay.json",
13490            );
13491        }
13492    };
13493    let cursor = state
13494        .get("self")
13495        .and_then(|s| s.get("last_pulled_event_id"))
13496        .and_then(Value::as_str)
13497        .map(|s| s.chars().take(16).collect::<String>())
13498        .unwrap_or_else(|| "<none>".to_string());
13499    DoctorCheck::pass(
13500        "cursor",
13501        format!(
13502            "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
13503        ),
13504    )
13505}
13506
13507#[cfg(test)]
13508mod doctor_tests {
13509    use super::*;
13510
13511    #[test]
13512    fn doctor_check_constructors_set_status_correctly() {
13513        // Silent-fail-prevention rule: pass/warn/fail must be visibly
13514        // distinguishable to operators. If any constructor lets the wrong
13515        // status through, `wire doctor` lies and we're back to today's
13516        // 30-minute debug.
13517        let p = DoctorCheck::pass("x", "ok");
13518        assert_eq!(p.status, "PASS");
13519        assert_eq!(p.fix, None);
13520
13521        let w = DoctorCheck::warn("x", "watch out", "do this");
13522        assert_eq!(w.status, "WARN");
13523        assert_eq!(w.fix, Some("do this".to_string()));
13524
13525        let f = DoctorCheck::fail("x", "broken", "fix it");
13526        assert_eq!(f.status, "FAIL");
13527        assert_eq!(f.fix, Some("fix it".to_string()));
13528    }
13529
13530    #[test]
13531    fn check_pair_rejections_no_file_is_pass() {
13532        // Fresh-box case: no pair-rejected.jsonl yet. Must NOT report this
13533        // as a problem.
13534        config::test_support::with_temp_home(|| {
13535            config::ensure_dirs().unwrap();
13536            let c = check_pair_rejections(5);
13537            assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
13538        });
13539    }
13540
13541    #[test]
13542    fn check_pair_rejections_with_entries_warns() {
13543        // Existence of rejections is itself a signal — even if each entry
13544        // is a "known good failure," the operator wants to know they
13545        // happened.
13546        config::test_support::with_temp_home(|| {
13547            config::ensure_dirs().unwrap();
13548            crate::pair_invite::record_pair_rejection(
13549                "willard",
13550                "pair_drop_ack_send_failed",
13551                "POST 502",
13552            );
13553            let c = check_pair_rejections(5);
13554            assert_eq!(c.status, "WARN");
13555            assert!(c.detail.contains("1 pair failures"));
13556            assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
13557        });
13558    }
13559
13560    #[test]
13561    fn check_peer_staleness_no_peers_is_pass() {
13562        // Fresh box / no pin yet: must NOT report this as a problem
13563        // (nothing to be stale about).
13564        config::test_support::with_temp_home(|| {
13565            config::ensure_dirs().unwrap();
13566            let c = check_peer_staleness(7);
13567            assert_eq!(c.status, "PASS", "no peers should be PASS, got {c:?}");
13568        });
13569    }
13570
13571    #[test]
13572    fn check_peer_staleness_pinned_with_no_inbox_file_warns() {
13573        // Issue #14 asymmetric-stale-pin: peer is pinned but we've NEVER
13574        // received an event from them (no inbox file at all). That's
13575        // exactly the "we pushed N events, got 0 back" smell the WARN is
13576        // designed to catch.
13577        config::test_support::with_temp_home(|| {
13578            config::ensure_dirs().unwrap();
13579            // Seed a pinned peer with no corresponding inbox file.
13580            let mut state = json!({
13581                "peers": {
13582                    "stale-peer": {
13583                        "relay_url": "https://wireup.net",
13584                        "slot_id": "deadslot",
13585                        "slot_token": "tok",
13586                    }
13587                }
13588            });
13589            state["self"] = json!({});
13590            config::write_relay_state(&state).unwrap();
13591
13592            let c = check_peer_staleness(7);
13593            assert_eq!(
13594                c.status, "WARN",
13595                "pinned peer with no inbox file must surface: {c:?}"
13596            );
13597            assert!(
13598                c.detail.contains("stale-peer"),
13599                "WARN must name the silent peer so the operator can act: {}",
13600                c.detail
13601            );
13602            assert!(
13603                c.detail.contains("asymmetric")
13604                    || c.detail.contains("stale")
13605                    || c.detail.contains("dead slot"),
13606                "WARN must surface the failure-mode language so the operator \
13607                 finds the diagnosis without re-tracing: {}",
13608                c.detail
13609            );
13610            assert!(
13611                c.fix
13612                    .as_ref()
13613                    .is_some_and(|f| f.contains("wire add") && f.contains("#15")),
13614                "fix pointer must reference both the manual re-pair AND the \
13615                 follow-up issue (#15) that will automate this: {:?}",
13616                c.fix
13617            );
13618        });
13619    }
13620
13621    #[test]
13622    fn check_peer_staleness_pinned_with_fresh_inbox_is_pass() {
13623        // Negative case: pinned peer with a recent inbox event must NOT
13624        // be reported. This prevents the false-positive that would otherwise
13625        // make operators ignore the WARN.
13626        config::test_support::with_temp_home(|| {
13627            config::ensure_dirs().unwrap();
13628            let mut state = json!({
13629                "peers": {
13630                    "active-peer": {
13631                        "relay_url": "https://wireup.net",
13632                        "slot_id": "freshslot",
13633                        "slot_token": "tok",
13634                    }
13635                }
13636            });
13637            state["self"] = json!({});
13638            config::write_relay_state(&state).unwrap();
13639
13640            let inbox = config::inbox_dir().unwrap();
13641            std::fs::create_dir_all(&inbox).unwrap();
13642            std::fs::write(
13643                inbox.join("active-peer.jsonl"),
13644                "{\"event_id\":\"recent\"}\n",
13645            )
13646            .unwrap();
13647
13648            let c = check_peer_staleness(7);
13649            assert_eq!(c.status, "PASS", "fresh inbox should not warn: {c:?}");
13650        });
13651    }
13652
13653    #[test]
13654    fn check_peer_staleness_daemon_field_overrides_mtime() {
13655        // v0.14.3 (#14): when peers[<p>].last_inbound_event_at is
13656        // present, that signal trumps file mtime. Even with a
13657        // fresh inbox file mtime, an OLD daemon-written timestamp
13658        // must trigger the WARN — backup-restore should not mask
13659        // the real silence.
13660        config::test_support::with_temp_home(|| {
13661            config::ensure_dirs().unwrap();
13662            let mut state = json!({
13663                "peers": {
13664                    "ghost-peer": {
13665                        "relay_url": "https://wireup.net",
13666                        "slot_id": "ghostslot",
13667                        "slot_token": "tok",
13668                        "last_inbound_event_at": "2026-05-01T00:00:00Z",
13669                    }
13670                }
13671            });
13672            state["self"] = json!({});
13673            config::write_relay_state(&state).unwrap();
13674            // Fresh inbox mtime — would PASS via the fallback.
13675            let inbox = config::inbox_dir().unwrap();
13676            std::fs::create_dir_all(&inbox).unwrap();
13677            std::fs::write(inbox.join("ghost-peer.jsonl"), "{\"event_id\":\"x\"}\n").unwrap();
13678            let c = check_peer_staleness(7);
13679            assert_eq!(
13680                c.status, "WARN",
13681                "daemon-field staleness must override fresh mtime: {c:?}"
13682            );
13683            assert!(c.detail.contains("ghost-peer"), "got: {}", c.detail);
13684        });
13685    }
13686
13687    #[test]
13688    fn check_peer_staleness_daemon_field_fresh_overrides_old_mtime() {
13689        // Mirror case: a recent daemon-written timestamp must
13690        // PASS even with an old inbox file mtime. Backup-restore
13691        // case in reverse — operator restored an old inbox file
13692        // but pulled fresh events since.
13693        config::test_support::with_temp_home(|| {
13694            config::ensure_dirs().unwrap();
13695            // Stamp NOW-ish via OffsetDateTime so we don't drift.
13696            let now = time::OffsetDateTime::now_utc()
13697                .format(&time::format_description::well_known::Rfc3339)
13698                .unwrap();
13699            let mut state = json!({
13700                "peers": {
13701                    "active-peer": {
13702                        "relay_url": "https://wireup.net",
13703                        "slot_id": "freshslot",
13704                        "slot_token": "tok",
13705                        "last_inbound_event_at": now,
13706                    }
13707                }
13708            });
13709            state["self"] = json!({});
13710            config::write_relay_state(&state).unwrap();
13711            // Old inbox file mtime — would FAIL via the fallback.
13712            // We skip making it old (today's mtime works since the
13713            // field-driven path runs first and short-circuits).
13714            let c = check_peer_staleness(7);
13715            assert_eq!(
13716                c.status, "PASS",
13717                "recent daemon-field stamp must PASS regardless of mtime: {c:?}"
13718            );
13719        });
13720    }
13721
13722    #[test]
13723    fn check_self_userinfo_no_state_is_pass() {
13724        // Fresh box (no relay.json yet) must NOT WARN — there's nothing
13725        // published to heal, and treating a missing file as a problem
13726        // would scare every new operator on first `wire doctor` run.
13727        config::test_support::with_temp_home(|| {
13728            // Don't even call ensure_dirs — simulate truly fresh state.
13729            let c = check_and_heal_self_userinfo_endpoints();
13730            assert_eq!(c.status, "PASS", "no state should be PASS, got {c:?}");
13731        });
13732    }
13733
13734    #[test]
13735    fn check_self_userinfo_clean_state_is_pass_no_mutation() {
13736        // Negative case: clean self.endpoints[] must not trigger a heal,
13737        // must not mutate relay.json. Prevents the false-positive that
13738        // would make operators distrust the doctor.
13739        config::test_support::with_temp_home(|| {
13740            config::ensure_dirs().unwrap();
13741            let state = json!({
13742                "self": {
13743                    "endpoints": [
13744                        {
13745                            "relay_url": "https://wireup.net",
13746                            "scope": "Federation",
13747                            "slot_id": "abc",
13748                            "slot_token": "tok"
13749                        }
13750                    ],
13751                    "relay_url": "https://wireup.net",
13752                    "slot_id": "abc",
13753                    "slot_token": "tok"
13754                },
13755                "peers": {}
13756            });
13757            config::write_relay_state(&state).unwrap();
13758
13759            let c = check_and_heal_self_userinfo_endpoints();
13760            assert_eq!(c.status, "PASS", "clean state should be PASS: {c:?}");
13761
13762            // Verify state is byte-identical (no spurious write).
13763            let after = config::read_relay_state().unwrap();
13764            assert_eq!(after, state, "PASS path must NOT mutate relay.json");
13765        });
13766    }
13767
13768    #[test]
13769    fn check_self_userinfo_heals_malformed_endpoint_and_promotes_clean() {
13770        // THE regression case (swift-harbor / slate-lotus pairing 2026-05-27):
13771        // relay.json has a malformed first endpoint from before #61 AND a
13772        // clean second endpoint from a later `wire bind-relay`. The check
13773        // must (a) strip the malformed one, (b) promote the clean one's
13774        // coords to the legacy top-level triple, (c) write back, (d) emit
13775        // a WARN with the stripped URL + `wire claim` remediation pointer.
13776        config::test_support::with_temp_home(|| {
13777            config::ensure_dirs().unwrap();
13778            let state = json!({
13779                "self": {
13780                    "endpoints": [
13781                        {
13782                            "relay_url": "https://copilot-agent@wireup.net",
13783                            "scope": "Federation",
13784                            "slot_id": "stale-id",
13785                            "slot_token": "stale-token"
13786                        },
13787                        {
13788                            "relay_url": "https://wireup.net",
13789                            "scope": "Federation",
13790                            "slot_id": "clean-id",
13791                            "slot_token": "clean-token"
13792                        }
13793                    ],
13794                    "relay_url": "https://copilot-agent@wireup.net",
13795                    "slot_id": "stale-id",
13796                    "slot_token": "stale-token"
13797                },
13798                "peers": {}
13799            });
13800            config::write_relay_state(&state).unwrap();
13801
13802            let c = check_and_heal_self_userinfo_endpoints();
13803            assert_eq!(c.status, "WARN", "heal should report WARN: {c:?}");
13804            assert!(
13805                c.detail.contains("healed") && c.detail.contains("copilot-agent@wireup.net"),
13806                "WARN must name the stripped URL so the operator sees what changed: {}",
13807                c.detail
13808            );
13809            assert!(
13810                c.fix.as_ref().is_some_and(|f| f.contains("wire claim")),
13811                "fix must point at re-publishing the agent-card so the phonebook entry \
13812                 matches the healed state on disk: {:?}",
13813                c.fix
13814            );
13815
13816            // Verify the file on disk is healed:
13817            //   - endpoints[] contains ONLY the clean entry.
13818            //   - legacy top-level fields promoted from the clean entry.
13819            let after = config::read_relay_state().unwrap();
13820            let endpoints = after["self"]["endpoints"].as_array().unwrap();
13821            assert_eq!(endpoints.len(), 1, "malformed endpoint must be removed");
13822            assert_eq!(endpoints[0]["relay_url"], "https://wireup.net");
13823            assert_eq!(after["self"]["relay_url"], "https://wireup.net");
13824            assert_eq!(after["self"]["slot_id"], "clean-id");
13825            assert_eq!(after["self"]["slot_token"], "clean-token");
13826        });
13827    }
13828
13829    #[test]
13830    fn check_self_userinfo_no_clean_fallback_warns_without_mutating() {
13831        // Edge: operator only has the malformed endpoint, no clean fallback
13832        // to promote. Auto-healing would leave them with NO inbox slot at
13833        // all — strictly worse than the malformed shape (peers can at least
13834        // try the bad endpoint). Check must surface a WARN with explicit
13835        // re-bind instructions and DO NOT touch the state.
13836        config::test_support::with_temp_home(|| {
13837            config::ensure_dirs().unwrap();
13838            let state = json!({
13839                "self": {
13840                    "endpoints": [
13841                        {
13842                            "relay_url": "https://copilot-agent@wireup.net",
13843                            "scope": "Federation",
13844                            "slot_id": "stale-id",
13845                            "slot_token": "stale-token"
13846                        }
13847                    ],
13848                    "relay_url": "https://copilot-agent@wireup.net",
13849                    "slot_id": "stale-id",
13850                    "slot_token": "stale-token"
13851                },
13852                "peers": {}
13853            });
13854            config::write_relay_state(&state).unwrap();
13855
13856            let c = check_and_heal_self_userinfo_endpoints();
13857            assert_eq!(c.status, "WARN");
13858            assert!(
13859                c.fix
13860                    .as_ref()
13861                    .is_some_and(|f| f.contains("wire bind-relay") && f.contains("wire claim")),
13862                "no-clean-fallback fix must require BOTH a clean bind AND a re-claim: {:?}",
13863                c.fix
13864            );
13865
13866            // CRITICAL: state must NOT be mutated (would leave operator with
13867            // no inbox slot). Verify byte-identical.
13868            let after = config::read_relay_state().unwrap();
13869            assert_eq!(
13870                after, state,
13871                "no-clean-fallback path must NOT mutate state (would strand operator)"
13872            );
13873        });
13874    }
13875}
13876
13877// ---------- up megacommand (full bootstrap) ----------
13878
13879/// `wire up <nick@relay-host>` — single command from fresh box to ready-to-
13880/// pair. Composes the steps that today's onboarding walks operators through
13881/// one by one (init / bind-relay / claim / background daemon / arm monitor
13882/// recipe). Idempotent: every step checks current state and skips if done.
13883///
13884/// Argument parsing accepts:
13885///   - `<nick>@<relay-host>` — explicit relay
13886///   - `<nick>`              — defaults to wireup.net (the configured
13887///     public relay)
13888fn cmd_up(
13889    relay_arg: Option<&str>,
13890    name: Option<&str>,
13891    with_local: Option<&str>,
13892    no_local: bool,
13893    as_json: bool,
13894) -> Result<()> {
13895    // No nick to parse — your handle is your DID-derived persona (one-name
13896    // rule). The optional arg is only which relay to bind/claim on. Accepts
13897    // `@host`, bare `host`, or a full URL; defaults to the public relay.
13898    let relay_url = match relay_arg {
13899        Some(r) => {
13900            let r = r.trim_start_matches('@');
13901            if r.starts_with("http://") || r.starts_with("https://") {
13902                r.to_string()
13903            } else {
13904                format!("https://{r}")
13905            }
13906        }
13907        None => crate::pair_invite::DEFAULT_RELAY.to_string(),
13908    };
13909
13910    // Strip any URL userinfo (`<handle>@<host>`) before doing any state-
13911    // mutating work — otherwise the malformed endpoint gets persisted in
13912    // `relay_state` AND published in the signed agent-card, where every
13913    // inbound POST to it 4xxes. Mirrors `cmd_up`'s already-bound branch,
13914    // which has always ignored the userinfo on the "keeping existing
13915    // binding" warning path.
13916    let relay_url = strip_relay_url_userinfo(&relay_url);
13917
13918    let mut report: Vec<(String, String)> = Vec::new();
13919    let mut step = |stage: &str, detail: String| {
13920        report.push((stage.to_string(), detail.clone()));
13921        if !as_json {
13922            eprintln!("wire up: {stage} — {detail}");
13923        }
13924    };
13925
13926    // 1. init (or note existing identity). No typed name — cmd_init(None)
13927    // generates the persona from the freshly-minted keypair (one-name rule).
13928    if config::is_initialized()? {
13929        step("init", "already initialized".to_string());
13930    } else {
13931        cmd_init(
13932            None,
13933            name,
13934            Some(&relay_url),
13935            false,
13936            /* as_json */ false,
13937        )?;
13938        step("init", format!("created identity bound to {relay_url}"));
13939    }
13940
13941    // Canonical persona handle — the one name we claim and are addressed by.
13942    let canonical = {
13943        let card = config::read_agent_card()?;
13944        let did = card.get("did").and_then(Value::as_str).unwrap_or("");
13945        crate::agent_card::display_handle_from_did(did).to_string()
13946    };
13947    step("identity", format!("persona is `{canonical}`"));
13948
13949    // 2. Ensure relay binding matches. cmd_init with --relay binds it; if
13950    // already initialized we may need to bind to the requested relay
13951    // separately (operator switched relays).
13952    let relay_state = config::read_relay_state()?;
13953    let bound_relay = relay_state
13954        .get("self")
13955        .and_then(|s| s.get("relay_url"))
13956        .and_then(Value::as_str)
13957        .unwrap_or("")
13958        .to_string();
13959    if bound_relay.is_empty() {
13960        // Identity exists but never bound to a relay — bind now.
13961        // Fresh box (no pinned peers yet) — migrate_pinned irrelevant.
13962        // Pass `false` so the safety check kicks in if state was non-empty.
13963        cmd_bind_relay(
13964            &relay_url, /* scope */ None, // infer from URL (federation for wireup.net)
13965            /* replace */ false, /* migrate_pinned */ false, /* as_json */ false,
13966        )?;
13967        step("bind-relay", format!("bound to {relay_url}"));
13968    } else if bound_relay != relay_url {
13969        step(
13970            "bind-relay",
13971            format!(
13972                "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
13973                 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
13974            ),
13975        );
13976    } else {
13977        step("bind-relay", format!("already bound to {bound_relay}"));
13978    }
13979
13980    // 3. Claim nick on the relay's handle directory. Idempotent — same-DID
13981    // re-claims are accepted by the relay.
13982    match cmd_claim(
13983        &canonical,
13984        Some(&relay_url),
13985        None,
13986        /* hidden */ false,
13987        /* as_json */ false,
13988    ) {
13989        Ok(()) => step(
13990            "claim",
13991            format!("{canonical}@{} claimed", strip_proto(&relay_url)),
13992        ),
13993        Err(e) => step(
13994            "claim",
13995            format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
13996        ),
13997    }
13998
13999    // 3b. Opportunistic local dual-slot (additive). Gives same-box sister
14000    // sessions sub-millisecond loopback routing alongside the federation
14001    // slot. Local relays carry no handle directory — nothing to claim
14002    // there; sister discovery is via `wire session list-local`.
14003    if no_local {
14004        step("local-slot", "skipped (--no-local)".to_string());
14005    } else {
14006        let local_url = with_local
14007            .unwrap_or("http://127.0.0.1:8771")
14008            .trim_end_matches('/');
14009        let already_local = crate::endpoints::self_endpoints(
14010            &config::read_relay_state().unwrap_or_else(|_| json!({})),
14011        )
14012        .iter()
14013        .any(|e| e.relay_url == local_url);
14014        if relay_url.trim_end_matches('/') == local_url || already_local {
14015            step("local-slot", "already covered".to_string());
14016        } else if crate::relay_client::RelayClient::new(local_url)
14017            .check_healthz()
14018            .is_ok()
14019        {
14020            match cmd_bind_relay(
14021                local_url,
14022                Some("local"),
14023                /* replace */ false,
14024                /* migrate_pinned */ false,
14025                /* as_json */ false,
14026            ) {
14027                Ok(()) => step(
14028                    "local-slot",
14029                    format!("dual-bound local relay {local_url} for sister routing"),
14030                ),
14031                Err(e) => step("local-slot", format!("skipped local relay: {e}")),
14032            }
14033        } else {
14034            step(
14035                "local-slot",
14036                format!(
14037                    "no local relay reachable at {local_url} — federation only \
14038                     (sisters resolve via session-list)"
14039                ),
14040            );
14041        }
14042    }
14043
14044    // 4. Background daemon — must be running for pull/push/ack to flow.
14045    match crate::ensure_up::ensure_daemon_running() {
14046        Ok(true) => step("daemon", "started fresh background daemon".to_string()),
14047        Ok(false) => step("daemon", "already running".to_string()),
14048        Err(e) => step(
14049            "daemon",
14050            format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
14051        ),
14052    }
14053
14054    // 5. Final summary — point operator at the next commands.
14055    let summary =
14056        "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
14057         `wire monitor` to watch incoming events."
14058            .to_string();
14059    step("ready", summary.clone());
14060
14061    if as_json {
14062        let steps_json: Vec<_> = report
14063            .iter()
14064            .map(|(k, v)| json!({"stage": k, "detail": v}))
14065            .collect();
14066        println!(
14067            "{}",
14068            serde_json::to_string(&json!({
14069                "nick": canonical,
14070                "relay": relay_url,
14071                "steps": steps_json,
14072            }))?
14073        );
14074    }
14075    Ok(())
14076}
14077
14078/// Strip http:// or https:// prefix for display in `wire up` step output.
14079fn strip_proto(url: &str) -> String {
14080    url.trim_start_matches("https://")
14081        .trim_start_matches("http://")
14082        .to_string()
14083}
14084
14085/// Strip URL userinfo (`https://<userinfo>@<host>...`) from a relay URL,
14086/// warning to stderr if any was stripped. Returns the cleaned URL.
14087///
14088/// Bug 1 this fixes: `wire up <handle>@<relay>` and `wire bind-relay
14089/// <handle>@<relay>` previously prepended `https://` to the literal arg,
14090/// recording and publishing the endpoint as `https://<handle>@<relay>` —
14091/// handle parsed as URL userinfo. Every inbound event POST to that
14092/// endpoint (pair_drop_ack, messages) gets a 4xx (Cloudflare 400 on
14093/// wireup.net) because the upstream rejects the userinfo on plain
14094/// GETs/POSTs. Bilateral pairing can't complete; messages sit
14095/// undelivered. Also surfaced cosmetically (Bug 3) as a doubled-handle
14096/// echo at the claim step (`<nick>@<nick>@<host>`) because `strip_proto`
14097/// left the userinfo in.
14098///
14099/// Behavior: strip-and-warn rather than hard-reject. In v0.11+ the handle
14100/// is DID-derived (one-name rule), so the userinfo isn't *needed* — but
14101/// `<handle>@<relay>` is literally the wire dial-address format
14102/// (`wire dial coral-weasel@wireup.net`), so an operator who types
14103/// `wire up <handle>@<relay>` is making a natural-by-analogy mistake, not
14104/// a hostile request. Mirrors `cmd_up`'s already-bound branch, which has
14105/// always ignored the userinfo prefix when keeping an existing clean
14106/// slot. The hard invariant either way: a userinfo-bearing URL must
14107/// never reach `self.endpoints[]` or the published agent-card.
14108/// Self-pair guard (issue #30, explicit "Optional" ask).
14109///
14110/// Refuses to proceed when the resolved peer DID matches our own DID. Two
14111/// ways this fires:
14112///
14113///   1. The operator literally dialed their own handle by mistake.
14114///   2. Two terminals / agents that should be DISTINCT collapsed onto one
14115///      wire identity — either because v0.13's session-key resolution
14116///      didn't reach the wire process (env var not propagated; see #29 and
14117///      the Windows symptoms in #30) or because both terminals share a
14118///      WIRE_HOME without setting WIRE_SESSION_ID.
14119///
14120/// Pre-guard, case (2) silently produced a pair_drop targeting our own
14121/// slot — bilateral handshake could never complete and the operator could
14122/// only see "pending forever" with no diagnostic. The guard makes the
14123/// failure mode debuggable instead of silent by surfacing the exact DID
14124/// collision and pointing at the `wire whoami` / `WIRE_SESSION_ID`
14125/// diagnostic that the v0.13.5 session-key adapter introduced.
14126///
14127/// Companion to the lightweight nickname-match guard at the top of
14128/// `cmd_add` (which catches the literal `wire add <our-nick>@<relay>`
14129/// case before WebFinger). This DID-level guard is the load-bearing one
14130/// because case (2) — two collapsed terminals with DIFFERENT typed
14131/// nicknames that BOTH resolve to the shared DID — can't be caught
14132/// without the post-resolution comparison.
14133/// Issue #69 follow-up to #15: predicate "does this error smell like a
14134/// 4xx slot rotation?" — used by `try_reresolve_peer_on_slot_4xx` to
14135/// decide whether to spend a whois RTT on a re-resolve.
14136///
14137/// Original #15 implementation used `last_err.contains("410") ||
14138/// last_err.contains("404")`, which false-triggers on any unrelated
14139/// substring with `"410"`/`"404"` in it — e.g. `"slot 4101 expired"`,
14140/// `"request_id=410abc..."`, `"received 4040 bytes"`. False-trigger cost
14141/// is a single wasted whois per push call per peer (rate-limited by
14142/// `already_tried`), but it muddies the doctor diagnostic by inserting
14143/// spurious "peer slot rotated" log lines.
14144///
14145/// This predicate gates on the status code appearing as a *whole token*
14146/// — preceded by start-of-string / space / colon / tab / newline AND
14147/// followed by end-of-string / space / colon / tab / newline. That
14148/// matches both real-world shapes:
14149///
14150/// - `reqwest::StatusCode` Display, via `relay_client.rs` line ~339
14151///   `format!("post_event failed: {status}: {detail}")` →
14152///   `"post_event failed: 410 Gone: <body>"` (token `"410"` is followed
14153///   by space).
14154/// - UDS bare-`u16` Display, via `relay_client.rs` line ~227
14155///   `format!("post_event (uds {socket_path}) failed: {status}: ...")` →
14156///   `"post_event (uds /tmp/...sock) failed: 410: <body>"` (token
14157///   `"410"` is followed by colon).
14158///
14159/// And rejects the false-positive shapes documented in
14160/// `error_smells_like_slot_4xx_tests` below.
14161pub fn error_smells_like_slot_4xx(last_err: &str) -> bool {
14162    fn is_token_boundary(b: u8) -> bool {
14163        matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
14164    }
14165    let bytes = last_err.as_bytes();
14166    for code in ["410", "404"] {
14167        let code_bytes = code.as_bytes();
14168        let mut search_from = 0usize;
14169        while let Some(rel) = last_err[search_from..].find(code) {
14170            let abs = search_from + rel;
14171            let end = abs + code_bytes.len();
14172            let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
14173            let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
14174            if before_ok && after_ok {
14175                return true;
14176            }
14177            // Step past this candidate to find the next occurrence; using
14178            // `+ 1` (rather than `+ code_bytes.len()`) keeps the scan
14179            // cheap and guarantees forward progress even on overlap.
14180            search_from = abs + 1;
14181        }
14182    }
14183    false
14184}
14185
14186/// Issue #15: detect a 4xx-shaped push failure that smells like "slot
14187/// rotated by peer" and update the peer's pin in place with the freshly
14188/// resolved slot from the relay's handle directory.
14189///
14190/// Returns:
14191/// - `Ok(true)` — peer's pin was rotated; caller should refresh
14192///   `peer_endpoints_in_priority_order(&state, ...)` and retry.
14193/// - `Ok(false)` — re-resolve completed but the slot id was unchanged
14194///   (false-alarm 4xx, e.g. throttling); caller should NOT retry.
14195/// - `Err(e)` — re-resolve itself failed (network down, relay 5xx,
14196///   handle no longer claimed, etc.); caller should fall through to the
14197///   existing "skipped" path.
14198///
14199/// Only triggers when:
14200///   - The error string carries a 4xx slot-rotation status token (`410`/`404`)
14201///     as a *whole token* — preceded by start/space/colon/tab/newline and
14202///     followed by end/space/colon/tab/newline. This matches both the
14203///     `reqwest::StatusCode` Display shape (`": 410 Gone"`) and the UDS
14204///     bare-`u16` shape (`": 410:"`) emitted by `post_event` in
14205///     `src/relay_client.rs`, while rejecting substring false-positives
14206///     like `"slot 4101 expired"` or `"request_id=410abc..."`. See
14207///     `error_smells_like_slot_4xx` below.
14208///   - The peer has a pinned `relay_url` we can parse a handle@domain from.
14209///   - The caller hasn't already re-resolved this peer in the current push
14210///     call (caller's responsibility — pass `already_tried` from a set kept
14211///     in the outer per-peer loop). One whois per peer per push call,
14212///     exactly the rate limit the issue specifies.
14213///
14214/// Updates `state.peers[peer_handle]` in place (rotates the federation
14215/// endpoint's slot_id + slot_token to the fresh resolve), and emits a
14216/// stderr WARN so the operator can see the rotation event in their
14217/// terminal alongside the unrelated `wire push` output. Caller is
14218/// responsible for persisting `state` back to disk via
14219/// `config::write_relay_state` after all per-peer re-resolves settle.
14220fn try_reresolve_peer_on_slot_4xx(
14221    state: &mut Value,
14222    peer_handle: &str,
14223    last_err: &str,
14224    already_tried: &std::collections::HashSet<String>,
14225) -> Result<bool> {
14226    if !error_smells_like_slot_4xx(last_err) {
14227        // Not the slot-rotation shape. Don't waste a whois on this.
14228        return Ok(false);
14229    }
14230    if already_tried.contains(peer_handle) {
14231        // Rate limit: at most one whois per peer per push call.
14232        return Ok(false);
14233    }
14234    // Find the peer's pinned federation endpoint to re-resolve against.
14235    let peer_entry = state
14236        .get("peers")
14237        .and_then(|p| p.get(peer_handle))
14238        .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
14239    let peer_relay = peer_entry
14240        .get("endpoints")
14241        .and_then(Value::as_array)
14242        .and_then(|arr| {
14243            arr.iter().find(|e| {
14244                e.get("scope").and_then(Value::as_str) == Some("federation")
14245                    || e.get("scope").and_then(Value::as_str) == Some("Federation")
14246            })
14247        })
14248        .and_then(|e| e.get("relay_url").and_then(Value::as_str))
14249        .or_else(|| peer_entry.get("relay_url").and_then(Value::as_str))
14250        .ok_or_else(|| {
14251            anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
14252        })?
14253        .to_string();
14254    // Strip scheme + path to get the relay domain. Same shape parse used by
14255    // pair_profile::resolve_handle's input contract.
14256    let domain = peer_relay
14257        .trim_start_matches("https://")
14258        .trim_start_matches("http://")
14259        .split('/')
14260        .next()
14261        .unwrap_or(&peer_relay)
14262        .to_string();
14263    let handle = crate::pair_profile::Handle {
14264        nick: peer_handle.to_string(),
14265        domain,
14266    };
14267    let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
14268    let new_slot_id = resolved
14269        .get("slot_id")
14270        .and_then(Value::as_str)
14271        .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
14272        .to_string();
14273    // Compare against the currently-pinned federation slot.
14274    let peers = state
14275        .get_mut("peers")
14276        .and_then(Value::as_object_mut)
14277        .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
14278    let peer_entry = peers
14279        .get_mut(peer_handle)
14280        .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
14281    let current_slot_id = peer_entry
14282        .get("endpoints")
14283        .and_then(Value::as_array)
14284        .and_then(|arr| {
14285            arr.iter().find(|e| {
14286                let scope = e.get("scope").and_then(Value::as_str);
14287                scope == Some("federation") || scope == Some("Federation")
14288            })
14289        })
14290        .and_then(|e| e.get("slot_id").and_then(Value::as_str))
14291        .unwrap_or("")
14292        .to_string();
14293    if current_slot_id == new_slot_id {
14294        // Same slot — the 4xx was something else (rate limit, server burp).
14295        return Ok(false);
14296    }
14297    // Rotate in place. We update slot_id but DROP the slot_token: only the
14298    // peer's freshly-issued slot_token (which arrives via a new pair_drop_ack)
14299    // is valid. Sending against the new slot without a fresh token gets 401,
14300    // so the operator will see one more "skipped: 401" and the next pair
14301    // cycle (or a manual `wire add <peer>@<relay>` per the doctor #14 fix)
14302    // refreshes the token. This is the same trade-off the issue spells out:
14303    // auto-rotation closes the slot mismatch; token refresh still needs the
14304    // bilateral pair gate.
14305    if let Some(endpoints) = peer_entry
14306        .get_mut("endpoints")
14307        .and_then(Value::as_array_mut)
14308    {
14309        for ep in endpoints.iter_mut() {
14310            let scope = ep.get("scope").and_then(Value::as_str);
14311            if scope == Some("federation") || scope == Some("Federation") {
14312                ep["slot_id"] = Value::String(new_slot_id.clone());
14313                ep["slot_token"] = Value::String(String::new());
14314            }
14315        }
14316    }
14317    // Also update the legacy top-level fields for v0.5.16-era readers (the
14318    // same back-compat surface pair_drop_ack uses).
14319    peer_entry["slot_id"] = Value::String(new_slot_id.clone());
14320    peer_entry["slot_token"] = Value::String(String::new());
14321    eprintln!(
14322        "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
14323         now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
14324         {peer_handle}@<relay>` to refresh the slot_token."
14325    );
14326    Ok(true)
14327}
14328
14329fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
14330    if our_did == peer_did {
14331        bail!(
14332            "refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
14333             DID. Two terminals can collapse onto one wire identity when the per-\
14334             session key isn't reaching the wire process (issue #30 / #29).\n\n\
14335             Diagnose:\n  \
14336             • `wire whoami` in each terminal — DIDs MUST differ.\n  \
14337             • `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
14338             (PowerShell) — must be set + distinct per session.\n\n\
14339             Force distinct identities before relaunching the agent:\n  \
14340             • bash/zsh:   `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n  \
14341             • PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
14342        );
14343    }
14344    Ok(())
14345}
14346
14347fn strip_relay_url_userinfo(url: &str) -> String {
14348    // Locate the authority segment: everything after `://` (or the whole
14349    // string if there is no scheme yet), up to the first `/`, `?`, or `#`.
14350    let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
14351    let rest = &url[authority_start..];
14352    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
14353    let authority = &rest[..authority_end];
14354
14355    let Some(at_pos) = authority.find('@') else {
14356        return url.to_string();
14357    };
14358
14359    let userinfo = &authority[..at_pos];
14360    let host = &authority[at_pos + 1..];
14361    let scheme = &url[..authority_start];
14362    let tail = &rest[authority_end..];
14363    let cleaned = format!("{scheme}{host}{tail}");
14364
14365    eprintln!(
14366        "wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
14367         in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
14368         is just the bare relay. Binding to `{cleaned}` instead."
14369    );
14370
14371    cleaned
14372}
14373
14374/// Hard assertion that a URL about to be persisted to `relay_state` /
14375/// published in the signed agent-card carries no userinfo. The
14376/// `strip_relay_url_userinfo` filter at every public entry point already
14377/// removes it; this is the belt-and-suspenders check at the actual mutation
14378/// site — a future code path that bypasses the entry filter must NOT be
14379/// able to leak a malformed endpoint into a signed card or the persisted
14380/// relay state.
14381fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
14382    let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
14383    let rest = &url[authority_start..];
14384    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
14385    let authority = &rest[..authority_end];
14386    if authority.contains('@') {
14387        bail!(
14388            "internal invariant violated: relay URL `{url}` still carries userinfo at \
14389             the persist/publish boundary — `strip_relay_url_userinfo` must be called \
14390             before this point. Refusing to publish a malformed endpoint."
14391        );
14392    }
14393    Ok(())
14394}
14395
14396fn cmd_claim(
14397    nick: &str,
14398    relay_override: Option<&str>,
14399    public_url: Option<&str>,
14400    hidden: bool,
14401    as_json: bool,
14402) -> Result<()> {
14403    // `wire claim` is the one-step bootstrap: auto-init + auto-allocate slot
14404    // + claim handle. Operator should never have to run init/bind-relay first.
14405    let (_did, relay_url, slot_id, slot_token) =
14406        crate::pair_invite::ensure_self_with_relay(relay_override)?;
14407    let card = config::read_agent_card()?;
14408
14409    // v0.13.1 one-name enforcement: the handle you claim in the phonebook
14410    // MUST equal your DID-derived persona, so the directory entry can never
14411    // drift from your agent-card handle. A typed nick that differs is ignored
14412    // (mirrors how `wire init` coerces the typed name). This closes the
14413    // claim-path reopening of the v0.11 "two names" footgun — before this,
14414    // `wire claim coffee-ghost` published coffee-ghost@relay -> your DID while
14415    // your card said e.g. outback-sandpiper. The typed `nick` arg is now
14416    // vestigial, exactly like the one `wire init` / `wire up` already accept.
14417    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
14418    let canonical = crate::agent_card::display_handle_from_did(did).to_string();
14419    if !canonical.is_empty() && nick != canonical && !as_json {
14420        eprintln!(
14421            "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
14422        );
14423    }
14424    let nick = if canonical.is_empty() {
14425        nick
14426    } else {
14427        canonical.as_str()
14428    };
14429    if !crate::pair_profile::is_valid_nick(nick) {
14430        bail!(
14431            "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
14432        );
14433    }
14434
14435    let client = crate::relay_client::RelayClient::new(&relay_url);
14436    // v0.5.19 (#9.1): forward the `discoverable` flag. None for default
14437    // (back-compat); Some(false) for `--hidden`. Relays older than
14438    // v0.5.19 ignore the field, so this is safe to always send.
14439    let discoverable = if hidden { Some(false) } else { None };
14440    let resp =
14441        client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
14442
14443    if as_json {
14444        println!(
14445            "{}",
14446            serde_json::to_string(&json!({
14447                "nick": nick,
14448                "relay": relay_url,
14449                "response": resp,
14450            }))?
14451        );
14452    } else {
14453        // Best-effort: derive the public domain from the relay URL. If
14454        // operator passed --public-url that's the canonical address; else
14455        // the relay URL itself. Falls back to a placeholder if both miss.
14456        let domain = public_url
14457            .unwrap_or(&relay_url)
14458            .trim_start_matches("https://")
14459            .trim_start_matches("http://")
14460            .trim_end_matches('/')
14461            .split('/')
14462            .next()
14463            .unwrap_or("<this-relay-domain>")
14464            .to_string();
14465        println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
14466        println!("verify with: wire whois {nick}@{domain}");
14467    }
14468    Ok(())
14469}
14470
14471fn cmd_profile(action: ProfileAction) -> Result<()> {
14472    match action {
14473        ProfileAction::Set { field, value, json } => {
14474            // Try parsing the value as JSON; if that fails, treat it as a
14475            // bare string. Lets operators pass either `42` or `"hello"` or
14476            // `["rust","late-night"]` without quoting hell.
14477            let parsed: Value =
14478                serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
14479            let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
14480            let published = republish_card_to_phonebook();
14481            if json {
14482                println!(
14483                    "{}",
14484                    serde_json::to_string(&json!({
14485                        "field": field,
14486                        "profile": new_profile,
14487                        "published_to": published,
14488                    }))?
14489                );
14490            } else {
14491                println!("profile.{field} set");
14492                print_profile_publish_result(&published);
14493            }
14494        }
14495        ProfileAction::Get { json } => return cmd_whois(None, json, None),
14496        ProfileAction::Clear { field, json } => {
14497            let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
14498            let published = republish_card_to_phonebook();
14499            if json {
14500                println!(
14501                    "{}",
14502                    serde_json::to_string(&json!({
14503                        "field": field,
14504                        "cleared": true,
14505                        "profile": new_profile,
14506                        "published_to": published,
14507                    }))?
14508                );
14509            } else {
14510                println!("profile.{field} cleared");
14511                print_profile_publish_result(&published);
14512            }
14513        }
14514    }
14515    Ok(())
14516}
14517
14518/// Best-effort: re-publish the (freshly re-signed) agent-card to every relay
14519/// this identity already holds a federation slot on, so a `wire profile`
14520/// edit reaches the public phonebook immediately instead of waiting for the
14521/// next `wire up`. Silent no-op when the identity holds no federation slot
14522/// (offline / local-only). `discoverable: None` makes the relay PRESERVE the
14523/// prior setting, so a `--hidden` agent stays hidden across the re-claim.
14524/// Returns the relay URLs the card was published to.
14525fn republish_card_to_phonebook() -> Vec<String> {
14526    let Ok(card) = config::read_agent_card() else {
14527        return Vec::new();
14528    };
14529    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
14530    let persona = crate::agent_card::display_handle_from_did(did).to_string();
14531    if persona.is_empty() {
14532        return Vec::new();
14533    }
14534    let Ok(state) = config::read_relay_state() else {
14535        return Vec::new();
14536    };
14537    let mut published = Vec::new();
14538    for ep in crate::endpoints::self_endpoints(&state) {
14539        if ep.scope != crate::endpoints::EndpointScope::Federation
14540            || ep.slot_id.is_empty()
14541            || ep.slot_token.is_empty()
14542        {
14543            continue;
14544        }
14545        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
14546        if client
14547            .handle_claim_v2(&persona, &ep.slot_id, &ep.slot_token, None, &card, None)
14548            .is_ok()
14549        {
14550            published.push(ep.relay_url.clone());
14551        }
14552    }
14553    published
14554}
14555
14556fn print_profile_publish_result(published: &[String]) {
14557    if published.is_empty() {
14558        println!(
14559            "  (local only — not bound to a federation relay; run `wire up` to publish to the phonebook)"
14560        );
14561    } else {
14562        println!("  published to phonebook: {}", published.join(", "));
14563    }
14564}
14565
14566// ---------- setup — one-shot MCP host registration ----------
14567
14568fn cmd_setup(apply: bool) -> Result<()> {
14569    use crate::adapters::harness::HARNESS_ADAPTERS;
14570    use std::path::PathBuf;
14571
14572    // v0.14.x: no `env` mapping. Per-session identity for Claude Code is
14573    // resolved by `crate::session::resolve_session_key`, which reads
14574    // `WIRE_SESSION_ID` then falls back to `CLAUDE_CODE_SESSION_ID`. Current
14575    // Claude Code (verified 2026-05) propagates `CLAUDE_CODE_SESSION_ID`
14576    // into every MCP subprocess by default, so the historical mapping was
14577    // redundant and triggered a misleading MCP Config Diagnostics warning.
14578    let entry = json!({
14579        "command": "wire",
14580        "args": ["mcp"]
14581    });
14582    let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
14583
14584    // v0.14.2 (#92 category 1): per-host detection + upsert logic lives
14585    // in `adapters::harness::HARNESS_ADAPTERS`. Adding a new harness is
14586    // one struct entry there + one test. This loop is the only consumer.
14587    let mut targets: Vec<(&str, PathBuf)> = Vec::new();
14588    for adapter in HARNESS_ADAPTERS {
14589        for path in (adapter.paths_fn)() {
14590            targets.push((adapter.name, path));
14591        }
14592    }
14593
14594    println!("wire setup\n");
14595    println!("MCP server snippet (add this to your client's mcpServers):");
14596    println!();
14597    println!("{entry_pretty}");
14598    println!();
14599
14600    if !apply {
14601        println!("Probable MCP host config locations on this machine:");
14602        for (name, path) in &targets {
14603            let marker = if path.exists() {
14604                "✓ found"
14605            } else {
14606                "  (would create)"
14607            };
14608            println!("  {marker:14}  {name}: {}", path.display());
14609        }
14610        println!();
14611        println!("Run `wire setup --apply` to merge wire into each config above.");
14612        println!(
14613            "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
14614        );
14615        return Ok(());
14616    }
14617
14618    let mut modified: Vec<String> = Vec::new();
14619    let mut skipped: Vec<String> = Vec::new();
14620    for adapter in HARNESS_ADAPTERS {
14621        for path in (adapter.paths_fn)() {
14622            match (adapter.upsert_fn)(&path, "wire", &entry) {
14623                Ok(true) => {
14624                    modified.push(format!("✓ {} ({})", adapter.name, path.display()));
14625                }
14626                Ok(false) => skipped.push(format!(
14627                    "  {} ({}): already configured",
14628                    adapter.name,
14629                    path.display()
14630                )),
14631                Err(e) => skipped.push(format!("✗ {} ({}): {e}", adapter.name, path.display())),
14632            }
14633        }
14634    }
14635    if !modified.is_empty() {
14636        println!("Modified:");
14637        for line in &modified {
14638            println!("  {line}");
14639        }
14640        println!();
14641        println!("Restart the app(s) above to load wire MCP.");
14642    }
14643    if !skipped.is_empty() {
14644        println!();
14645        println!("Skipped:");
14646        for line in &skipped {
14647            println!("  {line}");
14648        }
14649    }
14650    Ok(())
14651}
14652
14653// v0.14.2 (#92 cat 1): `fn upsert_mcp_entry` retired. Its three
14654// shape-dispatching branches (standard / vscode / opencode) moved into
14655// per-shape `upsert_*` fns in `adapters::harness`. Adding a new shape
14656// = one new upsert fn + one registry entry, instead of editing this
14657// switch statement.
14658
14659// ---------- setup --statusline ----------
14660
14661/// Bundled Claude Code statusLine renderer (persona emoji + nickname + cwd,
14662/// pidfile+tasklist liveness). Embedded at compile time; written to the
14663/// Claude config dir on `wire setup --statusline --apply`.
14664const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
14665
14666/// `wire setup --statusline [--apply] [--remove]` — install/remove a Claude
14667/// Code statusLine that renders this session's wire persona. Honors
14668/// `$CLAUDE_CONFIG_DIR` (default `~/.claude`). Writes the renderer script and
14669/// merges a `statusLine` block into settings.json, preserving existing keys
14670/// and refusing to clobber a settings.json that exists but isn't valid JSON.
14671fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
14672    use std::path::PathBuf;
14673    let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
14674        .map(PathBuf::from)
14675        .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
14676        .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
14677    let settings_path = cfg_dir.join("settings.json");
14678    let script_path = cfg_dir.join("wire-statusline.sh");
14679    // Resolve the shell invocation. On Windows a bare `bash` resolves to
14680    // System32\bash.exe (WSL) — wrong environment, Windows paths invalid,
14681    // statusline breaks — so we emit the absolute git-bash path. On Unix a
14682    // bare `bash <script>` is correct. Script path is quoted for spaces.
14683    let (command, command_warn) = statusline_command(&script_path);
14684
14685    println!("wire setup --statusline\n");
14686    println!("Claude config dir: {}", cfg_dir.display());
14687    println!("  renderer:  {}", script_path.display());
14688    println!("  settings:  {}", settings_path.display());
14689    if let Some(w) = &command_warn {
14690        println!("  ⚠ {w}");
14691    }
14692    println!();
14693
14694    if remove {
14695        if !apply {
14696            println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
14697            println!("Run `wire setup --statusline --remove --apply` to do it.");
14698            return Ok(());
14699        }
14700        let dropped = remove_statusline_entry(&settings_path)?;
14701        let script_gone = if script_path.exists() {
14702            std::fs::remove_file(&script_path).is_ok()
14703        } else {
14704            false
14705        };
14706        println!(
14707            "Removed: statusLine key {} · renderer {}",
14708            if dropped { "dropped" } else { "absent" },
14709            if script_gone { "deleted" } else { "absent" }
14710        );
14711        return Ok(());
14712    }
14713
14714    if !apply {
14715        println!("Would write the renderer above and merge into settings.json:");
14716        println!();
14717        println!("  \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
14718        println!();
14719        println!("Resulting statusline:  ● <emoji> <nickname> · <cwd>");
14720        println!("Run `wire setup --statusline --apply` to install.");
14721        println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
14722        return Ok(());
14723    }
14724
14725    if let Some(parent) = script_path.parent() {
14726        std::fs::create_dir_all(parent).context("creating Claude config dir")?;
14727    }
14728    std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
14729    #[cfg(unix)]
14730    {
14731        use std::os::unix::fs::PermissionsExt;
14732        if let Ok(meta) = std::fs::metadata(&script_path) {
14733            let mut perms = meta.permissions();
14734            perms.set_mode(0o755);
14735            let _ = std::fs::set_permissions(&script_path, perms);
14736        }
14737    }
14738    let changed = upsert_statusline_entry(&settings_path, &command)?;
14739    println!("✓ renderer written: {}", script_path.display());
14740    if changed {
14741        println!("✓ merged statusLine into: {}", settings_path.display());
14742    } else {
14743        println!(
14744            "  settings.json already configured: {}",
14745            settings_path.display()
14746        );
14747    }
14748    println!();
14749    println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
14750    Ok(())
14751}
14752
14753/// Merge a `statusLine` command block into a Claude settings.json, preserving
14754/// all other keys. Returns Ok(true) if changed. Refuses to clobber a file that
14755/// exists but is not valid JSON.
14756fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
14757    let mut cfg: Value = if path.exists() {
14758        let body = std::fs::read_to_string(path).context("reading settings.json")?;
14759        if body.trim().is_empty() {
14760            json!({})
14761        } else {
14762            serde_json::from_str(&body).context(
14763                "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
14764            )?
14765        }
14766    } else {
14767        json!({})
14768    };
14769    if !cfg.is_object() {
14770        bail!("settings.json root is not a JSON object — refusing to clobber");
14771    }
14772    let desired = json!({"type": "command", "command": command});
14773    let root = cfg.as_object_mut().unwrap();
14774    if root.get("statusLine") == Some(&desired) {
14775        return Ok(false);
14776    }
14777    root.insert("statusLine".to_string(), desired);
14778    if let Some(parent) = path.parent()
14779        && !parent.as_os_str().is_empty()
14780    {
14781        std::fs::create_dir_all(parent).context("creating parent dir")?;
14782    }
14783    let out = serde_json::to_string_pretty(&cfg)? + "\n";
14784    std::fs::write(path, out).context("writing settings.json")?;
14785    Ok(true)
14786}
14787
14788/// Drop the `statusLine` key from settings.json. Ok(true) if a key was removed,
14789/// Ok(false) if file/key absent. Refuses to edit invalid JSON.
14790fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
14791    if !path.exists() {
14792        return Ok(false);
14793    }
14794    let body = std::fs::read_to_string(path).context("reading settings.json")?;
14795    if body.trim().is_empty() {
14796        return Ok(false);
14797    }
14798    let mut cfg: Value = serde_json::from_str(&body)
14799        .context("settings.json is not valid JSON — refusing to edit")?;
14800    let Some(root) = cfg.as_object_mut() else {
14801        return Ok(false);
14802    };
14803    if root.remove("statusLine").is_none() {
14804        return Ok(false);
14805    }
14806    let out = serde_json::to_string_pretty(&cfg)? + "\n";
14807    std::fs::write(path, out).context("writing settings.json")?;
14808    Ok(true)
14809}
14810
14811/// Build the `statusLine.command` string for this platform. Returns the
14812/// command plus an optional warning to surface to the operator.
14813fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
14814    #[cfg(windows)]
14815    {
14816        match resolve_git_bash() {
14817            Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
14818            None => (
14819                format!("bash \"{}\"", script_path.display()),
14820                Some(
14821                    "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
14822                     WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
14823                     Windows or set statusLine.command to your git-bash bash.exe path."
14824                        .to_string(),
14825                ),
14826            ),
14827        }
14828    }
14829    #[cfg(unix)]
14830    {
14831        (format!("bash \"{}\"", script_path.display()), None)
14832    }
14833}
14834
14835/// Locate the git-bash `bash.exe` on Windows, avoiding the WSL launcher at
14836/// `System32\bash.exe`. Claude Code's statusLine command needs the real
14837/// git-bash so the renderer runs in a POSIX-ish env with valid paths.
14838#[cfg(windows)]
14839fn resolve_git_bash() -> Option<String> {
14840    use std::path::PathBuf;
14841    // 1. `where.exe bash` — take the first hit that is NOT under System32
14842    //    (that one is the WSL `bash.exe` launcher).
14843    if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
14844        && out.status.success()
14845    {
14846        for line in String::from_utf8_lossy(&out.stdout).lines() {
14847            let p = line.trim();
14848            if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
14849                return Some(p.to_string());
14850            }
14851        }
14852    }
14853    // 2. Common Git-for-Windows install locations.
14854    let candidates = [
14855        std::env::var("ProgramFiles")
14856            .ok()
14857            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14858        std::env::var("ProgramFiles(x86)")
14859            .ok()
14860            .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14861        std::env::var("LocalAppData")
14862            .ok()
14863            .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
14864    ];
14865    candidates
14866        .into_iter()
14867        .flatten()
14868        .find(|c| PathBuf::from(c).exists())
14869}
14870
14871#[cfg(test)]
14872mod scan_jsonl_dir_tests {
14873    use super::*;
14874
14875    #[test]
14876    fn scan_jsonl_dir_excludes_pushed_audit_files() {
14877        // Pre-fix `wire status` reported `outbox.events` as the sum of
14878        // both the live outbox files AND the audit-only `*.pushed.jsonl`
14879        // lifecycle logs. On a long-running operator's box that turned
14880        // "11 events queued" into "71811 events queued" — confusing
14881        // and load-bearing-wrong for the silent-send detection class.
14882        let dir = tempfile::tempdir().unwrap();
14883        // Live outbox: one peer, 2 events.
14884        std::fs::write(
14885            dir.path().join("alice.jsonl"),
14886            "{\"event_id\":\"a\"}\n{\"event_id\":\"b\"}\n",
14887        )
14888        .unwrap();
14889        // Audit log: one peer, 100 events. Must NOT count.
14890        let many: String = (0..100)
14891            .map(|i| format!("{{\"event_id\":\"x{i}\",\"ts\":\"...\"}}\n"))
14892            .collect();
14893        std::fs::write(dir.path().join("alice.pushed.jsonl"), many).unwrap();
14894        let result = scan_jsonl_dir(dir.path()).unwrap();
14895        assert_eq!(
14896            result["events"], 2,
14897            "events count must include only live outbox lines, not pushed-log audit lines"
14898        );
14899        assert_eq!(
14900            result["files"], 1,
14901            "files count must reflect 1 live outbox file (the .pushed.jsonl audit log doesn't count as a queued-events surface)"
14902        );
14903    }
14904
14905    #[test]
14906    fn scan_jsonl_dir_zero_when_only_pushed_log_present() {
14907        // Edge case: a peer who's drained their queue still has an
14908        // append-only `<peer>.pushed.jsonl` file but no `<peer>.jsonl`.
14909        // Should report zero events, zero files — there's no pending
14910        // outbox work.
14911        let dir = tempfile::tempdir().unwrap();
14912        std::fs::write(
14913            dir.path().join("alice.pushed.jsonl"),
14914            "{\"event_id\":\"a\"}\n",
14915        )
14916        .unwrap();
14917        let result = scan_jsonl_dir(dir.path()).unwrap();
14918        assert_eq!(result["events"], 0);
14919        assert_eq!(result["files"], 0);
14920    }
14921
14922    #[test]
14923    fn scan_jsonl_dir_returns_zero_for_missing_dir() {
14924        let result = scan_jsonl_dir(std::path::Path::new("/nonexistent")).unwrap();
14925        assert_eq!(result["events"], 0);
14926        assert_eq!(result["files"], 0);
14927    }
14928}
14929
14930// v0.14.2 (#92 cat 1): setup_tests module retired. Coverage migrated
14931// to `adapters::harness::tests` — each shape gets its own dedicated
14932// test alongside the upsert fn that implements it.
14933
14934#[cfg(test)]
14935mod statusline_tests {
14936    use super::*;
14937
14938    #[test]
14939    fn statusline_merge_preserves_keys_and_is_idempotent() {
14940        let dir = tempfile::tempdir().unwrap();
14941        let path = dir.path().join("settings.json");
14942        std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
14943        // First merge changes the file but keeps existing keys.
14944        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14945        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14946        assert_eq!(v["theme"], "dark");
14947        assert_eq!(v["model"], "opus");
14948        assert_eq!(v["statusLine"]["type"], "command");
14949        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14950        // Identical re-merge = no change.
14951        assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14952        // Remove drops ONLY statusLine.
14953        assert!(remove_statusline_entry(&path).unwrap());
14954        let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14955        assert_eq!(v2["theme"], "dark");
14956        assert!(v2.get("statusLine").is_none());
14957        // Remove again = no-op.
14958        assert!(!remove_statusline_entry(&path).unwrap());
14959    }
14960
14961    #[test]
14962    fn statusline_merge_refuses_to_clobber_invalid_json() {
14963        let dir = tempfile::tempdir().unwrap();
14964        let path = dir.path().join("settings.json");
14965        std::fs::write(&path, "this is not json {").unwrap();
14966        let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
14967        assert!(
14968            format!("{err:#}").contains("not valid JSON"),
14969            "err: {err:#}"
14970        );
14971        // File left untouched.
14972        assert_eq!(
14973            std::fs::read_to_string(&path).unwrap(),
14974            "this is not json {"
14975        );
14976    }
14977
14978    #[test]
14979    fn statusline_creates_settings_when_absent() {
14980        let dir = tempfile::tempdir().unwrap();
14981        let path = dir.path().join("settings.json");
14982        assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14983        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14984        assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14985    }
14986}
14987
14988// ---------- notify (Goal 2) ----------
14989
14990fn cmd_notify(
14991    interval_secs: u64,
14992    peer_filter: Option<&str>,
14993    once: bool,
14994    as_json: bool,
14995) -> Result<()> {
14996    use crate::inbox_watch::InboxWatcher;
14997    let cursor_path = config::state_dir()?.join("notify.cursor");
14998    let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
14999    // v0.13.x identity work: a long-running notify loop racing another
15000    // wire process on the same inbox cursor silently drops toasts.
15001    // Skipped under `--once` (single sweep, no cursor ownership).
15002    if !once {
15003        crate::session::warn_on_identity_collision(std::process::id(), "notify");
15004    }
15005
15006    let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
15007        let events = watcher.poll()?;
15008        for ev in events {
15009            if let Some(p) = peer_filter
15010                && ev.peer != p
15011            {
15012                continue;
15013            }
15014            if as_json {
15015                println!("{}", serde_json::to_string(&ev)?);
15016            } else {
15017                os_notify_inbox_event(&ev);
15018            }
15019        }
15020        watcher.save_cursors(&cursor_path)?;
15021        Ok(())
15022    };
15023
15024    if once {
15025        return sweep(&mut watcher);
15026    }
15027
15028    let interval = std::time::Duration::from_secs(interval_secs.max(1));
15029    loop {
15030        if let Err(e) = sweep(&mut watcher) {
15031            eprintln!("wire notify: sweep error: {e}");
15032        }
15033        std::thread::sleep(interval);
15034    }
15035}
15036
15037fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
15038    let who = persona_label(&ev.peer);
15039    let title = if ev.verified {
15040        format!("wire ← {who}")
15041    } else {
15042        format!("wire ← {who} (UNVERIFIED)")
15043    };
15044    let body = format!("{}: {}", ev.kind, ev.body_preview);
15045    // Issue #81: dedup by (peer, event_id) so that overlapping monitor
15046    // sweeps / restarts with a torn cursor don't fire the same toast over
15047    // and over. `event_id` may be empty for pre-v0.5 legacy events; fall
15048    // back to the body preview in that case so the key still varies per
15049    // event rather than collapsing every keyless event into one entry.
15050    let id = if ev.event_id.is_empty() {
15051        ev.body_preview.as_str()
15052    } else {
15053        ev.event_id.as_str()
15054    };
15055    let dedup_key = format!("inbox:{}:{}", ev.peer, id);
15056    crate::os_notify::toast_dedup(&dedup_key, &title, &body);
15057}
15058
15059#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
15060fn os_toast(title: &str, body: &str) {
15061    eprintln!("[wire notify] {title}\n  {body}");
15062}
15063
15064// Integration tests for the CLI live in `tests/cli.rs` (cargo's tests/ dir).
15065
15066#[cfg(test)]
15067mod relay_url_tests {
15068    use super::*;
15069
15070    #[test]
15071    fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
15072        // Bug 1: `wire up <handle>@<relay>` and `wire bind-relay
15073        // <handle>@<relay>` previously persisted/published the endpoint as
15074        // `https://<handle>@<relay>` — handle stuck in URL userinfo. Every
15075        // inbound event POST to that endpoint 4xxed (Cloudflare 400 on
15076        // wireup.net); bilateral pairing couldn't complete.
15077        //
15078        // Strip+warn (not hard-reject): mirrors cmd_up's already-bound
15079        // branch, which has always ignored the userinfo on the "keeping
15080        // existing binding" warning path. `<handle>@<relay>` is also
15081        // literally the wire dial-address format — natural by analogy.
15082
15083        assert_eq!(
15084            strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
15085            "https://wireup.net",
15086            "https URL with handle userinfo is stripped to the bare host"
15087        );
15088        assert_eq!(
15089            strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
15090            "http://127.0.0.1:8771",
15091            "http + port + userinfo is stripped, port preserved"
15092        );
15093        // user:password@host — both halves of userinfo are dropped.
15094        assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
15095        // Authority with port + userinfo.
15096        assert_eq!(
15097            strip_relay_url_userinfo("https://nick@host:8443"),
15098            "https://host:8443"
15099        );
15100        // Schemeless `<handle>@<host>` — strips correctly. (cmd_up's
15101        // bare-host normalize prepends https:// before calling, but the
15102        // function is robust to either input.)
15103        assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
15104        // Path / query / fragment AFTER the authority are preserved.
15105        assert_eq!(
15106            strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
15107            "https://wireup.net/v1/events?x=1#frag"
15108        );
15109    }
15110
15111    #[test]
15112    fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
15113        // Bare host (https / http, with and without port, with path / query).
15114        for ok in [
15115            "https://wireup.net",
15116            "http://wireup.net",
15117            "http://127.0.0.1:8771",
15118            "https://relay.example.com:9443/v1/wire",
15119            "https://wireup.net/?env=prod",
15120            // Path / query containing `@` is fine — it's not in the authority.
15121            "https://wireup.net/users/me@example.com",
15122            "https://wireup.net/?to=me@example.com",
15123            // Fragment with @ — fine.
15124            "https://wireup.net/#contact@me",
15125            // IPv6 literal (no @ in authority).
15126            "http://[::1]:8771",
15127            // Schemeless bare host — also fine.
15128            "wireup.net",
15129            "wireup.net:8443",
15130        ] {
15131            assert_eq!(
15132                strip_relay_url_userinfo(ok),
15133                ok,
15134                "clean URL `{ok}` must pass through unchanged"
15135            );
15136        }
15137    }
15138
15139    #[test]
15140    fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
15141        // Belt-and-suspenders: even if a future code path bypasses
15142        // strip_relay_url_userinfo at the entry, the persist/publish
15143        // boundary must refuse a userinfo URL. This is the second line
15144        // of defense that keeps a malformed endpoint out of the SIGNED
15145        // agent-card and the persisted relay_state.
15146        assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
15147        assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
15148        assert!(
15149            assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
15150        );
15151
15152        let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
15153            .unwrap_err()
15154            .to_string();
15155        assert!(
15156            err.contains("invariant violated"),
15157            "persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
15158        );
15159        assert!(
15160            err.contains("strip_relay_url_userinfo"),
15161            "error must name the upstream filter so the caller can audit the bypass: {err}"
15162        );
15163        // user:password@host is just as bad — userinfo is userinfo.
15164        assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
15165        // Authority with port + userinfo.
15166        assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
15167    }
15168
15169    #[test]
15170    fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
15171        // Bug 3 (cosmetic): `wire up <handle>@<relay>` echoed `claimed
15172        // <nick>@<nick>@<relay>` because strip_proto left the userinfo in.
15173        // With Bug 1's strip+warn in cmd_up, the claim step receives a
15174        // bare host — strip_proto returns `<host>` and the echo is
15175        // `<nick>@<host>` exactly once. Verified end-to-end here:
15176        let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
15177        assert_eq!(after_strip, "https://wireup.net");
15178        assert_eq!(strip_proto(&after_strip), "wireup.net");
15179        // And the doubled-echo failure mode that motivated the fix:
15180        assert!(
15181            strip_proto("https://nick@wireup.net").contains('@'),
15182            "strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
15183        );
15184    }
15185}
15186
15187#[cfg(test)]
15188mod self_pair_guard_tests {
15189    use super::*;
15190
15191    #[test]
15192    fn reject_self_pair_after_resolution_blocks_matching_dids() {
15193        // Issue #30 (explicit "Optional" ask): when both terminals collapse
15194        // onto one wire identity (a v0.13-era WIRE_SESSION_ID propagation
15195        // gap or a shared WIRE_HOME), the resolved peer DID matches the
15196        // local DID and pair_drop silently goes nowhere. Guard surfaces
15197        // it as a refusable error with the diagnostic remediation path.
15198
15199        let err = reject_self_pair_after_resolution(
15200            "did:wire:winter-bay-4092b577",
15201            "did:wire:winter-bay-4092b577",
15202        )
15203        .unwrap_err()
15204        .to_string();
15205        assert!(
15206            err.contains("refusing to self-pair"),
15207            "must explicitly refuse, not silently bail: {err}"
15208        );
15209        assert!(
15210            err.contains("did:wire:winter-bay-4092b577"),
15211            "must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
15212        );
15213        assert!(
15214            err.contains("issue #30") || err.contains("issue #29"),
15215            "must point at the tracking issue so historical context is one search away: {err}"
15216        );
15217        // Remediation must be copy-paste ready — both POSIX and PowerShell
15218        // (the failure mode is Windows-prevalent per #30).
15219        assert!(
15220            err.contains("WIRE_SESSION_ID"),
15221            "remediation must name the env var operators set: {err}"
15222        );
15223        assert!(
15224            err.contains("uuidgen") || err.contains("NewGuid"),
15225            "remediation must include a concrete command to mint a unique id: {err}"
15226        );
15227    }
15228
15229    #[test]
15230    fn reject_self_pair_after_resolution_allows_distinct_dids() {
15231        // Sanity: the guard must not fire for any normal pair attempt
15232        // between two distinct identities. Cover the common shapes:
15233        // adjective-noun personas (post-v0.11), bare keypair hashes, and
15234        // mixed-case DIDs that happen to share a prefix.
15235        reject_self_pair_after_resolution(
15236            "did:wire:winter-bay-4092b577",
15237            "did:wire:cedar-bayou-0616dc6c",
15238        )
15239        .unwrap();
15240        reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
15241            .unwrap();
15242        // Same persona prefix, different suffix-hash → distinct DIDs (the
15243        // suffix is the load-bearing identifier). Must NOT trigger the
15244        // guard.
15245        reject_self_pair_after_resolution(
15246            "did:wire:noble-canyon-deadbeef",
15247            "did:wire:noble-canyon-cafef00d",
15248        )
15249        .unwrap();
15250    }
15251}
15252
15253#[cfg(test)]
15254mod slot_reresolve_tests {
15255    use super::*;
15256
15257    /// Issue #15: the gating logic of try_reresolve_peer_on_slot_4xx
15258    /// must short-circuit BEFORE any network call when the error shape
15259    /// doesn't smell like slot rotation, when the peer was already
15260    /// re-resolved this push, or when there's no peer entry to work
15261    /// against. Three of those four short-circuit paths are testable
15262    /// without a mock relay; the fourth (the actual whois + slot
15263    /// comparison) requires either a live test server or a mock
15264    /// transport, so it's covered manually via the failover_tests
15265    /// helper + integration check in a separate PR.
15266    ///
15267    /// What these tests pin:
15268    ///   - 200/500/timeout-shape errors do NOT trigger a re-resolve
15269    ///     (avoids wasted whois RTTs and churn in steady-state).
15270    ///   - Same peer twice in one push call only attempts re-resolve
15271    ///     once (rate limit the issue specifies).
15272    ///   - Missing peer entry surfaces as an explicit error, NOT a
15273    ///     silent skip (operator can see the malformed state).
15274    ///   - Peer with no federation endpoint surfaces as an explicit
15275    ///     error (you can't re-resolve a slot you can't address).
15276
15277    #[test]
15278    fn try_reresolve_skips_when_error_is_not_4xx_shape() {
15279        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
15280        let already = std::collections::HashSet::new();
15281        // 200 OK shouldn't ever land in this path, but sanity check the
15282        // negative filter: any error string without "404"/"410" is a no-op.
15283        let res =
15284            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
15285                .unwrap();
15286        assert!(!res, "502 must NOT trigger a re-resolve");
15287
15288        let res =
15289            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
15290                .unwrap();
15291        assert!(!res, "transport errors must NOT trigger a re-resolve");
15292
15293        let res = try_reresolve_peer_on_slot_4xx(
15294            &mut state,
15295            "some-peer",
15296            "post failed: 401 Unauthorized",
15297            &already,
15298        )
15299        .unwrap();
15300        assert!(
15301            !res,
15302            "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
15303        );
15304    }
15305
15306    #[test]
15307    fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
15308        // The issue's rate limit: "at most one whois per peer per push call."
15309        // Caller tracks via `already_tried`; helper must honor it BEFORE
15310        // attempting any I/O (otherwise a bad-state peer would burn a
15311        // network call per event in the outbox).
15312        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
15313        let mut already = std::collections::HashSet::new();
15314        already.insert("some-peer".to_string());
15315        let res = try_reresolve_peer_on_slot_4xx(
15316            &mut state,
15317            "some-peer",
15318            "post failed: 410 Gone",
15319            &already,
15320        )
15321        .unwrap();
15322        assert!(
15323            !res,
15324            "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
15325        );
15326    }
15327
15328    #[test]
15329    fn try_reresolve_errors_when_peer_missing_from_state() {
15330        // Surface state corruption explicitly rather than silently
15331        // returning Ok(false). If a peer disappeared from relay_state
15332        // mid-loop the operator needs to see it.
15333        let mut state = json!({"peers": {}});
15334        let already = std::collections::HashSet::new();
15335        let err = try_reresolve_peer_on_slot_4xx(
15336            &mut state,
15337            "missing-peer",
15338            "post failed: 410 Gone",
15339            &already,
15340        )
15341        .unwrap_err()
15342        .to_string();
15343        assert!(
15344            err.contains("missing-peer") && err.contains("not in relay_state"),
15345            "missing-peer error must name the peer + the failure: {err}"
15346        );
15347    }
15348
15349    #[test]
15350    fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
15351        // A peer with only local-scope endpoints (UDS / 127.0.0.1) has
15352        // no relay domain to whois against. Helper must surface this as
15353        // an actionable error, not a silent skip — the operator's
15354        // remediation is "pair via federation" or "you're on the same
15355        // box, the slot can't be 410'd by a peer who controls the
15356        // socket."
15357        let mut state = json!({
15358            "peers": {
15359                "local-only": {
15360                    "endpoints": [
15361                        {
15362                            "scope": "Local",
15363                            "relay_url": "http://127.0.0.1:8771",
15364                            "slot_id": "loc",
15365                            "slot_token": "tok"
15366                        }
15367                    ]
15368                }
15369            }
15370        });
15371        let already = std::collections::HashSet::new();
15372        let err = try_reresolve_peer_on_slot_4xx(
15373            &mut state,
15374            "local-only",
15375            "post failed: 410 Gone",
15376            &already,
15377        )
15378        .unwrap_err()
15379        .to_string();
15380        assert!(
15381            err.contains("federation endpoint"),
15382            "no-federation error must name the problem: {err}"
15383        );
15384    }
15385
15386    /// Issue #69: pin the word-boundary behavior of
15387    /// `error_smells_like_slot_4xx`. Prior implementation used a bare
15388    /// `contains("410") || contains("404")` substring match, which
15389    /// false-triggered on any unrelated error string containing those
15390    /// digits — e.g. slot ids that happen to start with `410`, request
15391    /// IDs, byte counts, etc.  Each false-positive cost a wasted whois
15392    /// per peer per push and a misleading "peer slot rotated" log line.
15393    ///
15394    /// These tests pin three classes:
15395    ///   - Real reqwest StatusCode Display shapes (`": 410 Gone"`,
15396    ///     `": 404 Not Found"`) trigger.
15397    ///   - Real UDS bare-`u16` shapes (`": 410:"`, `": 404:"`) trigger.
15398    ///   - Substring lookalikes (`"slot 4101 expired"`,
15399    ///     `"request_id=410abc"`, `"received 4040 bytes"`,
15400    ///     `"event 0x4104"`) do NOT trigger.
15401    #[test]
15402    fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
15403        // reqwest::StatusCode Display is "<u16> <reason>", embedded in
15404        // the post_event failure format string as "...failed: <status>: <detail>".
15405        assert!(error_smells_like_slot_4xx(
15406            "post_event failed: 410 Gone: slot rotated by peer"
15407        ));
15408        assert!(error_smells_like_slot_4xx(
15409            "post_event failed: 404 Not Found: handle no longer claimed"
15410        ));
15411    }
15412
15413    #[test]
15414    fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
15415        // UDS path formats status as a bare u16, so the shape is
15416        // "...failed: 410: <detail>" with the status flanked by spaces
15417        // and colons (no reason phrase).
15418        assert!(error_smells_like_slot_4xx(
15419            "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
15420        ));
15421        assert!(error_smells_like_slot_4xx(
15422            "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
15423        ));
15424    }
15425
15426    #[test]
15427    fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
15428        // The bug being fixed: the prior `contains("410")` predicate
15429        // matched ALL of these, burning a whois RTT and emitting a
15430        // spurious "peer slot rotated" log line each time.
15431        let false_positives = [
15432            "push aborted: slot 4101 expired",
15433            "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
15434            "post_event failed: 500: received 4040 bytes, expected envelope",
15435            "post_event failed: 500: event 0x4104 malformed",
15436            "post_event failed: 503: backlog=4102 entries pending",
15437            // 4044 is "received bytes" or anything containing 404 mid-token.
15438            "post_event failed: 500: tx_id=4044beef",
15439            // pure digit substrings inside identifiers / hashes:
15440            "post_event failed: 500: hash=abc410def",
15441        ];
15442        for case in false_positives {
15443            assert!(
15444                !error_smells_like_slot_4xx(case),
15445                "must NOT trigger re-resolve on substring lookalike: {case:?}"
15446            );
15447        }
15448    }
15449
15450    #[test]
15451    fn error_smells_like_slot_4xx_handles_edge_positions() {
15452        // Token at start of string (no preceding char).
15453        assert!(error_smells_like_slot_4xx("410 Gone"));
15454        assert!(error_smells_like_slot_4xx("404 Not Found"));
15455        // Token at end of string (no trailing char).
15456        assert!(error_smells_like_slot_4xx("got 410"));
15457        assert!(error_smells_like_slot_4xx("got 404"));
15458        // Tab and newline as separators (logs sometimes carry these).
15459        assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
15460        assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
15461        // Pure digit-only input that IS the code — token at start AND end.
15462        assert!(error_smells_like_slot_4xx("410"));
15463        assert!(error_smells_like_slot_4xx("404"));
15464        // Empty / no-match.
15465        assert!(!error_smells_like_slot_4xx(""));
15466        assert!(!error_smells_like_slot_4xx("no relevant status"));
15467        // 411-414, 401-403, 405-409 must NOT trigger (only 410/404 are
15468        // the slot-rotation shape per issue #15).
15469        assert!(!error_smells_like_slot_4xx(
15470            "post_event failed: 401 Unauthorized"
15471        ));
15472        assert!(!error_smells_like_slot_4xx(
15473            "post_event failed: 403 Forbidden"
15474        ));
15475        assert!(!error_smells_like_slot_4xx(
15476            "post_event failed: 411 Length Required"
15477        ));
15478    }
15479}
15480
15481// v0.14: tests for op-claims surfacing on operator read verbs.
15482// Pure-over-Value helper; no I/O, no filesystem fixtures needed.
15483#[cfg(test)]
15484mod op_claims_surfacing_tests {
15485    use super::*;
15486
15487    #[test]
15488    fn op_claims_extracts_present_non_null_fields() {
15489        let card = json!({
15490            "did": "did:wire:foo-deadbeef",
15491            "handle": "foo",
15492            "op_did": "did:wire:op:foo-aaaa",
15493            "op_pubkey": "PKB64==",
15494            "op_cert": "SIGB64==",
15495            "org_memberships": [{"org_did": "did:wire:org:slancha-bbbb"}],
15496            "schema_version": "v3.2",
15497        });
15498        let claims = op_claims_from_card(&card);
15499        assert_eq!(claims.len(), 5);
15500        assert_eq!(
15501            claims.get("op_did").and_then(Value::as_str),
15502            Some("did:wire:op:foo-aaaa")
15503        );
15504        assert!(
15505            claims
15506                .get("org_memberships")
15507                .and_then(Value::as_array)
15508                .is_some()
15509        );
15510    }
15511
15512    #[test]
15513    fn op_claims_empty_on_pre_v014_card() {
15514        // A pre-v0.14 card has none of the inline op_* fields. The
15515        // helper must return an EMPTY map so older peers surface
15516        // identically on every read verb (no `null`-spam in JSON,
15517        // no new lines in human output).
15518        let card = json!({
15519            "did": "did:wire:bar-cafebabe",
15520            "handle": "bar",
15521            "capabilities": ["wire/v3.1"],
15522        });
15523        assert!(op_claims_from_card(&card).is_empty());
15524    }
15525
15526    #[test]
15527    fn op_claims_skips_explicit_null_fields() {
15528        // Defensive: a card where republish has serialized op_did as
15529        // `null` (e.g., post-unenroll rebuild) must not surface a
15530        // `null` field — operators read absence to mean "not enrolled".
15531        let card = json!({
15532            "did": "did:wire:baz-12341234",
15533            "op_did": Value::Null,
15534            "org_memberships": Value::Null,
15535            "schema_version": "v3.2",
15536        });
15537        let claims = op_claims_from_card(&card);
15538        assert_eq!(claims.len(), 1);
15539        assert!(claims.get("op_did").is_none());
15540        assert!(claims.get("org_memberships").is_none());
15541        assert_eq!(
15542            claims.get("schema_version").and_then(Value::as_str),
15543            Some("v3.2")
15544        );
15545    }
15546}
15547
15548#[cfg(test)]
15549mod enroll_add_membership_tests {
15550    use super::*;
15551    use crate::enroll::issue_member_cert;
15552    use crate::signing::{b64encode, generate_keypair};
15553
15554    fn seed_op() -> ([u8; 32], [u8; 32], String) {
15555        let (sk, pk) = generate_keypair();
15556        crate::config::write_op_key(&sk).unwrap();
15557        crate::config::write_op_handle("opfoo").unwrap();
15558        let op_did = crate::agent_card::did_for_op("opfoo", &pk);
15559        (sk, pk, op_did)
15560    }
15561
15562    #[test]
15563    fn add_membership_happy_path_stores_and_is_idempotent() {
15564        config::test_support::with_temp_home(|| {
15565            config::ensure_dirs().unwrap();
15566            let (_op_sk, _op_pk, op_did) = seed_op();
15567            let (org_sk, org_pk) = generate_keypair();
15568            let org_did = crate::agent_card::did_for_org("acme", &org_pk);
15569            let cert = issue_member_cert(&org_sk, &op_did).unwrap();
15570            let bundle = json!({
15571                "org_did": org_did,
15572                "org_pubkey": b64encode(&org_pk),
15573                "member_cert": cert,
15574            })
15575            .to_string();
15576            cmd_enroll_add_membership(Some(bundle.clone()), None, None, None, true).unwrap();
15577            let stored = config::read_memberships().unwrap();
15578            assert_eq!(stored.len(), 1);
15579            assert_eq!(
15580                stored[0].get("org_did").and_then(Value::as_str),
15581                Some(org_did.as_str())
15582            );
15583            // Idempotent: re-running with the same org_did replaces, not duplicates.
15584            cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap();
15585            assert_eq!(config::read_memberships().unwrap().len(), 1);
15586        });
15587    }
15588
15589    #[test]
15590    fn add_membership_rejects_cert_for_wrong_op_did() {
15591        config::test_support::with_temp_home(|| {
15592            config::ensure_dirs().unwrap();
15593            let (_op_sk, _op_pk, _op_did) = seed_op();
15594            let (org_sk, org_pk) = generate_keypair();
15595            let org_did = crate::agent_card::did_for_org("acme", &org_pk);
15596            // Cert signed for a DIFFERENT op_did. Verify must refuse.
15597            let other_did = "did:wire:op:ghost-deadbeefdeadbeefdeadbeefdeadbeef";
15598            let cert = issue_member_cert(&org_sk, other_did).unwrap();
15599            let bundle = json!({
15600                "org_did": org_did,
15601                "org_pubkey": b64encode(&org_pk),
15602                "member_cert": cert,
15603            })
15604            .to_string();
15605            let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
15606            assert!(
15607                err.to_string().contains("verification failed"),
15608                "got: {err:#}"
15609            );
15610            // And nothing landed on disk.
15611            assert!(config::read_memberships().unwrap().is_empty());
15612        });
15613    }
15614
15615    #[test]
15616    fn add_membership_rejects_when_not_enrolled() {
15617        config::test_support::with_temp_home(|| {
15618            config::ensure_dirs().unwrap();
15619            // No op key written → we don't know our own op_did → refuse.
15620            let (org_sk, org_pk) = generate_keypair();
15621            let org_did = crate::agent_card::did_for_org("acme", &org_pk);
15622            let cert = issue_member_cert(&org_sk, "did:wire:op:anybody-aaaa").unwrap();
15623            let bundle = json!({
15624                "org_did": org_did,
15625                "org_pubkey": b64encode(&org_pk),
15626                "member_cert": cert,
15627            })
15628            .to_string();
15629            let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
15630            assert!(err.to_string().contains("not enrolled"), "got: {err:#}");
15631        });
15632    }
15633
15634    #[test]
15635    fn add_membership_rejects_malformed_org_did() {
15636        config::test_support::with_temp_home(|| {
15637            config::ensure_dirs().unwrap();
15638            let _ = seed_op();
15639            let bundle = json!({
15640                "org_did": "did:wire:not-an-org",
15641                "org_pubkey": "AAAA",
15642                "member_cert": "AAAA",
15643            })
15644            .to_string();
15645            let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
15646            assert!(
15647                err.to_string().contains("not a valid organization DID"),
15648                "got: {err:#}"
15649            );
15650        });
15651    }
15652}