diff --git a/ANTI_FEATURES.md b/ANTI_FEATURES.md index d523606..498f638 100644 --- a/ANTI_FEATURES.md +++ b/ANTI_FEATURES.md @@ -88,6 +88,18 @@ Self-hosted relay defaults to plain HTTP on localhost; operator chooses ingress. Foreground-first. `wire daemonize` opt-in. Won't change. +## 21. No cross-agent memory store + +wire is transport, not a memory backend. Agents bring their own memory (a vault, a DB, whatever). We won't ship a "structured cross-agent memory" API — the thing BAND (band.ai) gates behind Enterprise and leaves off its own real-time bus, absent from its own core model. A message bus that also owns your agents' memory is two products; we are one. Won't change. (Deliberate divergence — verified against BAND's shipped API, 2026-07-04.) + +## 22. No task boards / kanban / workflow orchestration + +No per-room task board, no assignments, no goal tracker. Coordinating *what work happens* is the agent's job (or its orchestrator's); wire coordinates *messages between identities*. BAND ships a beta kanban; that's orchestration, a different lane (see #8). Won't change. + +## 23. No governance-UI / policy-engine product + +We won't build an approval-queue, a pre-action pause/kill console, or a policy engine as a product surface. wire's honest governance is the **signed, verifiable audit trail it already emits** (every envelope Ed25519-signed; see #11) — a stronger evidentiary claim than a self-reported event log, and it needs no dashboard to be true. Surfacing that audit queryably is fine; building "governance" theater on spec is not. Won't change pre-1.0. + --- OSS projects die from scope creep faster than from any other cause. This list is the maintainer's pre-commitment device. Feature requests that violate any item: closed with link to this file. diff --git a/docs/design/2026-07-04-observability-and-open-band.md b/docs/design/2026-07-04-observability-and-open-band.md new file mode 100644 index 0000000..800583d --- /dev/null +++ b/docs/design/2026-07-04-observability-and-open-band.md @@ -0,0 +1,218 @@ +# Design — wire observability (`wire dash` + Mission Control) & the "open BAND" position + +Date: 2026-07-04 · Status: DRAFT (design, pre-implementation) · Branch: `observability-open-band` + +## Origin + +Prompted by looking at [band.ai](https://www.band.ai/) and asking "should we build an open-source +clone of this dashboard?" Research reframed it twice: + +1. **band.ai (BAND) is not a dashboard — it is a direct competitor to wire.** It is + "enterprise interaction infrastructure for multi-agent systems": persistent agent identity, + shared rooms, @mention routing, consent-based discovery, cross-agent memory, unified audit + trail, governance. That is wire's thesis. Closed/proprietary, **$17M raised** (Sierra Ventures, + Hetz, Team8), enterprise-security founders (Sygnia→Temasek, Ermetic→Tenable). Deploys + SaaS / private-cloud / edge. Cross-framework SDKs (LangGraph, CrewAI, OpenAI, Gemini, + Pydantic AI) + MCP + ACP. The dashboard is one thin surface on top. +2. **The generic "watch my agents" dashboard is already a saturated OSS category** — building + another from scratch is shelf-ware. Closest existing tools: + [Mission Control](https://github.com/builderz-labs/mission-control) (MIT, 1.8k★, already + auto-discovers `~/.claude/projects/` sessions, 31 panels), seunggabi/schmoli TUIs, + Claude-Code-Agent-Monitor, agent-deck, plus native `claude daemon status`. + +**Conclusion:** don't clone BAND's dashboard. Instead (a) ship a *wire-aware* observability pane +none of those tools have (DIDs / personas / sync / peers / relay), reusing an existing OSS UI +where it buys leverage, and (b) treat BAND as validation that wire should lean into being **the +open, decentralized BAND**. Three sub-projects, two buildable now, one strategic. + +## Local reality that motivates P1 + +On this Mac right now: **272 session dirs** under +`~/Library/Application Support/wire/sessions/by-key/`, **270 wire binaries** in `ps`, **264 live +daemons**. These are not dead husks — they are *abandoned-but-still-running* daemons, one per old +Claude Code session. There is no single pane that shows which wire identities exist, which have a +live daemon, and which are actually in use vs abandoned. That is the L1 pain `wire dash` kills. + +Scope confirmed with operator: all four layers wanted — +- **L1** local sessions (this box), **L2** fleet (Mac + Dell + GB10 + Air), + **L3** remote peers, **L4** relay health. +- Aggregation splits cleanly: the relay already sees every *synced* identity (L2/L3/L4); only a + filesystem walk sees local/abandoned state (L1). No existing surface gives all four. + +--- + +## P0 — shared core: `src/dash.rs` + +> **As-built (2026-07-04, branch `observability-open-band`):** shipped `DaemonState` is +> `Running{pid} | StalePid{pid} | None` (not `Live/Abandoned/Dead`) plus a separate +> `likely_idle: bool` = *running daemon ∧ no real peers*. Reason found in research: a running +> daemon heartbeat-syncs regardless of use, so **sync-age can't detect "abandoned" — peer-count +> is the honest signal.** Speculative `in_use`/`session_source` fields dropped; `cwd` is carried +> and rendered (the reap-decision hint). Sort = peers-desc → running → key. The struct below is +> the original plan; the code is the source of truth. + +The reusable mechanism both P1 (TUI) and P2 (MC adapter) consume. Build once. + +```rust +pub struct SessionSnapshot { + pub key: String, // by-key hash (session::SessionInfo) + pub handle: String, // agent-card.json + pub did: String, // did:wire:... + pub fingerprint: String, // did-fp + pub persona: Persona, // emoji + palette → TUI color + pub daemon: DaemonState, // Live{pid} | Abandoned{pid} | Dead{pid} | None + pub sync_age_s: Option,// relay.json / state + pub relay_url: String, // relay.json + pub peers: Vec, // trust.json (pinned peers) + pub pending_inbound: usize, + pub session_source: String, // claude-code | cli | named + pub in_use: bool, // recent sync OR live MCP-registered cwd + pub started_at: Option, +} + +pub struct DashReport { + pub sessions: Vec, + pub relays: Vec, // deduped by relay_url, one /healthz each +} + +pub fn collect(opts: CollectOpts) -> Result; +``` + +### Load-bearing constraints (a naive build dies at 270 sessions) + +- **Read-only, zero spawn, zero per-session network.** `collect()` reads on-disk state only. +- **Per-session liveness = cheap primitives already in-tree**, never a machine-wide scan per + session: `session::list_sessions()` → per `SessionInfo.home_dir` → + `session::session_daemon_pid(home)` → `platform::process_alive(pid)`. This is exactly the walk + `ensure_up::daemon_liveness()` already does internally to compute orphans (#170 / v0.13.2 A2) — + reuse/extend it, do not reinvent, and do not call the full `daemon_liveness()` once-per-session. +- **`DaemonState` classification:** + - `None` — no `daemon.pid`. + - `Dead{pid}` — pidfile present, `process_alive == false` (true husk). + - `Live{pid}` — pid alive **and** session is in use (recent sync / MCP-registered cwd). + - `Abandoned{pid}` — pid alive but session not in use (the ~260 case). +- **Relay health (L4) fetched once per distinct relay_url**, not per session. `--probe` (opt-in, + default off) adds a single relay probe per distinct peer for L3 liveness; off = fully offline. +- **`--json` emits `DashReport` verbatim** — the golden surface P2 and any external tool consume. + Add an anti-drift golden-JSON test (wire convention). + +Build-loop tier: **MEDIUM** (multi-file, reads live session store, no external mutation). + +--- + +## P1 — `wire dash` (CLI subcommand) + +Consumes `dash::collect()`. New `Command::Dash { watch, json, all, probe }` in `cli/mod.rs` +(enum at `src/cli/mod.rs:75`); impl in `src/cli/dash.rs` mirroring `src/cli/status.rs` patterns. + +- **Default one-shot table**, columns: + `persona · handle · did-fp · ●live/◐abandoned/○dead/–none · sync-age · peers · pending · source`. + Sort: in-use live first, then abandoned by sync-age, dead last. Abandoned/dead collapsed to a + count line; `--all` expands. Header line = relay health (L4) + fleet hint. +- **`--watch`** — 2s redraw. **Prefer crossterm/ANSI redraw over pulling `ratatui`** (wire's + minimal-dep ethos: pure rustls, no bloat). Interactivity (jump/kill) is explicitly phase 2. +- **`--json`** — emit `DashReport`. +- **L2 (fleet)** in v1 = "run `wire dash` on each box over SSH" — no new network surface. A + relay-side `/v1/my/slots` (key-authenticated, scoped to slots you can prove you own — NOT a + global enumeration, which would leak the social graph BAND-style) is the phase-2 upgrade for a + single phone-viewable fleet pane. Deliberately deferred until threat-modeled. +- **Husk reaping is OUT of P1.** wire *already has* a husk reaper (see host-decay engines). `wire + dash` **surfaces** abandoned/dead daemons and points at the existing reaper; it does **not** grow + a second destructive path. A `--reap` that kills processes would be HIGH-tier + hits the `wire + nuke` host-scoping trap — deferred. + +Build-loop tier: **MEDIUM**. In-situ test: run against the live 272-session store, confirm it +classifies live vs abandoned vs dead correctly and does not spawn/kill anything. + +--- + +## P2 — Mission Control adapter (read-only reporter) + +Not a new UI — a thin reporter reusing `dash::collect()`, so wire identities appear in MC's +existing 31-panel dashboard. Correction to an earlier framing: this pulls in **no** Next.js dep — +MC runs as a **separate process**; wire is only an HTTP client (`reqwest`, already in-tree). + +Confirmed MC contract: +- `POST /api/agents/register` — `Authorization: Bearer `, body `{ "name", "role" }`. +- `GET /api/tasks/queue?agent=` — task poll (**not used**; wire is not a task executor). +- Real-time via WebSocket + SSE (schema in MC's `openapi.json` — build agent must fetch/confirm). +- Default port **3000**. Agent fields: `id, name, status, session, tokens, cost, logs`. +- Generic-fallback adapter exists → wire maps onto it. + +Surface: `wire dash --report mission-control --mc-url --mc-key ` (loop mode). +Honest field mapping: + +| MC field | wire source | +|---|---| +| `name` | handle | +| `role` | `"wire-peer"` | +| `session` | key | +| `status` | `DaemonState` (live/abandoned/dead) | +| `logs` | inbox JSONL tail | +| `tokens` / `cost` | **null** — wire is not an LLM runner; do not fabricate | + +- **MC_API_KEY via env / 1Password (`op`), never committed.** Adapter is send-only (wire→MC); it + does not accept commands from MC (no task execution). If MC→wire control is ever wanted, that is + a separate, HIGH-tier design (agentic-action-safety). + +Build-loop tier: **MEDIUM**. In-situ test: run a local MC on :3000, confirm wire sessions register +and status updates land; verify no secret in the diff. + +--- + +## P3 — "the open BAND" (research-spec brief, NOT code) + +Strategic frame P1+P2 slot into. wire already ships BAND's expensive core (self-certifying DID +identity, consent pairing, rooms/groups, signed audit) — **open + decentralized**. The brief maps +the gaps and locks the stance: + +| Pillar | Gap vs BAND | Locked stance | +|---|---|---| +| Observability | none after P1+P2 | ✅ delivered by P1+P2 | +| Cross-framework reach | BAND ships N SDKs; wire = MCP/CLI | **MCP-as-universal-adapter** (operator-locked 2026-07-04). Every MCP-speaking framework (Claude Code, Cursor, LangGraph-via-MCP, …) gets wire free — one surface, not N SDKs. Brief evaluates where MCP coverage is thin; native SDKs only if a real gap is found. | +| Governance / audit | BAND's enterprise wedge | dash is the seed; roadmap adds a queryable audit log + policy hooks. Post-1.0 additive. | +| Cross-agent memory | BAND ships it | **Explicit NON-GOAL / anti-feature** (operator-locked 2026-07-04). wire = transport; agents bring their own memory (Soul). Record in `docs/ANTI_FEATURES.md` so it is a decision, not a gap. | +| Positioning | — | **"No server owns your agent graph."** BAND's private-cloud is still their platform; wire's relay is a bypassable blind mailbox (loopback / self-host). Lean into decentralized + offline + crypto-verifiable + AGPL. | + +Deliverable = a `research-spec` brief + a full BAND **deep-crawl dossier** (first pass done; a +`deep-crawl` completes it and lands in the dossier-cache). Trust-prior-tagged, KPI-gated. Runs in +**parallel** with the code (it is a document, no code dependency). + +Build-loop tier: research, not code — `research-spec` machinery (research log + persona review + +KPI gate). Opus. + +--- + +## Sequencing & build plan (Opus/Sonnet agents, per operator; no Fable) + +``` +P0 dash::collect() ──► P1 wire dash (Sonnet build, Opus/Sonnet review) + (serial root) └► P2 MC adapter (Sonnet build, review) +P3 open-BAND brief ── parallel ──► (Opus: research-spec + deep-crawl) +``` + +- **P0 is the serial root**; P1 and P2 both depend on it and are otherwise independent. +- **P3 is independent of all code** — dispatch it in parallel from the start. +- Each code component runs its own **build-loop** (research → plan → gate → develop → in-situ test + → gate). MEDIUM tier: 2–3 reviewers over the diff, one gate, self in-situ round-trip. +- Every code component lands with its caller in the same change (no shelf-ware): P0 lands *with* + P1 consuming it; P2 lands as a real subcommand flag. +- **Non-negotiable in-situ tests** run against the live session store / a local MC on :3000 — never + a mock-only pass. No externally-visible mutation is tested against real targets. + +## Files touched (anticipated) + +- **new** `src/dash.rs` (P0), `src/cli/dash.rs` (P1) +- **edit** `src/cli/mod.rs` (`Command::Dash` enum + dispatch) +- **new** MC reporter (in `src/cli/dash.rs` or `src/dash_report.rs`) (P2) +- **new** `docs/design/2026-07-04-observability-and-open-band.md` (this doc) +- **edit** `docs/ANTI_FEATURES.md` (memory-as-non-goal, from P3) +- **new** P3 brief + BAND dossier (docs / dossier-cache) +- tests: `tests/dash_collect_*.rs` (classification), golden-JSON anti-drift, MC-mapping unit + +## Open questions / deferred + +- Relay-side `/v1/my/slots` for the single-pane fleet view (L2) — deferred until threat-modeled + (must be key-scoped, must not enable social-graph enumeration). +- `wire dash --reap` (destructive husk cleanup) — deferred; use the existing reaper. +- MC→wire task control — out of scope; separate HIGH-tier design if ever wanted. diff --git a/docs/design/2026-07-04-open-band-positioning-brief.md b/docs/design/2026-07-04-open-band-positioning-brief.md new file mode 100644 index 0000000..fd7ac87 --- /dev/null +++ b/docs/design/2026-07-04-open-band-positioning-brief.md @@ -0,0 +1,120 @@ +# Brief — wire as "the open BAND": positioning + roadmap + +Date: 2026-07-04 · Status: DRAFT for ratification · Branch: `observability-open-band` +Companion: `docs/design/2026-07-04-observability-and-open-band.md` (the P1/P2 code) · +Primary source: `dotfiles-claude/dossiers/band-ai.md` (30-page deep-crawl of band.ai) + +## Thesis + +BAND (band.ai) raised **$17M seed** (Sierra/Hetz/Team8) to build exactly wire's thesis — +persistent agent identity, consent-gated discovery, shared rooms, coordination across frameworks — +as a **closed, centralized SaaS**. That is not a threat to flee; it is **category validation with a +structural opening**. wire already ships BAND's expensive core (identity + consent + rooms + signed +audit) on the axis BAND *cannot* pivot to without unbuilding itself: **decentralized, self-hostable, +offline-capable, cryptographically self-certifying, open-source.** The move is to name and own that +axis — **"no server owns your agent graph"** — and close the two feature gaps that actually matter +(reach + observability), while explicitly *declining* the gaps that are traps (hosted memory, task +boards, enterprise-governance theater). + +## The market moment (why now) + +- "Copilot era → workforce era": many agents per team, crossing org/vendor boundaries, needing + coordination + oversight. `[S, Team8, 2026, 72]` BAND, A2A (Google), ACP, MCP are all racing the + same seam. A funded, well-sold closed incumbent legitimizes the category and creates demand for + an **open** counterweight — the Langfuse-to-Datadog / Ollama-to-OpenAI pattern. +- wire's existing assets map 1:1 onto BAND's pitch, minus the server. This is a positioning + reframe more than a rebuild. + +## wire vs BAND — verified matrix + +All BAND facts from the 30-page primary-source crawl (`dossiers/band-ai.md`), `[P, 2026-07-04, 92]`. + +| Dimension | wire | BAND | Verdict | +|---|---|---|---| +| **Identity** | self-certifying DID + Ed25519, offline-verifiable, no account | account-issued API key + UUID; root of trust = human account; **zero crypto** | **wire wins** (hard) | +| **Encryption** | NIP-44 E2E (x25519 + ChaCha20-Poly1305), signed envelopes | none surfaced in 30 pages | **wire wins** (hard) | +| **Consent** | operator-gated bilateral accept | Contacts state machine, but `HUB_ROOM` allows LLM auto-accept + same-org auto-trust | **wire wins** (stricter) | +| **Decentralization** | self-host relay / loopback / offline; relay is a blind bypassable mailbox | coordination plane **SaaS-only** (app.band.ai); compute BYO but substrate centralized | **wire wins** (the wedge) | +| **License** | AGPL/Apache/MIT, OSS | closed/proprietary | **wire wins** | +| **Framework reach** | MCP + CLI (Claude-native) | **14 native SDK adapters** (LangGraph/CrewAI/OpenAI/Gemini/Pydantic/…, Py+TS) + A2A + ACP | **BAND wins** (the real gap) | +| **Rooms/routing** | group chat (shared-slot) | rooms + @mention routing, dynamic assembly, tool-gated sends | **parity / BAND richer** | +| **Memory** | none (punts to Soul) | structured records (no vectors), Enterprise-gated, off the real-time bus, absent from core-concepts | **contested → wire declines (see below)** | +| **Tasks/boards** | none | per-room kanban (beta) | **wire declines** | +| **Governance/audit** | signed envelopes + inbox JSONL | fragmented: no unified log, **no pre-action approval/pause/kill** | **both thin → greenfield** | +| **Observability** | none (pre-P1/P2) | dashboard (stream + cron) | **BAND wins today → P1/P2 closes it** | +| **Funding/GTM** | solo OSS | $17M + enterprise sales | **BAND wins** (not our game) | + +## Strategic recommendation + +**Own the decentralized/open axis; close reach + observability; decline the traps.** + +### Build (close the gaps that matter) +1. **Observability** — P1 `wire dash` + P2 Mission Control adapter (companion doc). Removes BAND's + only visible-surface lead. Ship first (in progress). +2. **Reach via MCP-as-universal-adapter** (locked). *Sharpened by the crawl:* BAND itself found MCP + insufficient — its MCP server **"cannot receive messages,"** relegated to automation. wire's + differentiator is that **wire made MCP a *live-participant* path** (daemon + monitor tail inbound + → the agent replies in-context). So the bet is not "MCP like BAND's"; it is "MCP done as BAND + *couldn't*." **Must-clear bar:** prove + document wire's MCP live-receive loop as first-class, and + validate coverage against the frameworks BAND lists (every MCP-speaking host gets wire free). Only + add a native SDK where a real framework has no MCP host (researched, not speculative). +3. **A2A interop adapter (evaluate).** BAND exposes peers as A2A endpoints. wire speaking A2A on the + *outside* (per the standing positioning-lock: "speak A2A on the outside, Future-A on the inside") + keeps wire interoperable with the BAND/Google/enterprise world without adopting their trust model. + Scope in a follow-up spec; not v1. + +### Steal (free wins from the crawl) +4. **Tool-gated sends / "raw LLM text is not a message."** BAND makes raw model output invisible in a + room — only an explicit send tool delivers. This is an anti-hallucination / anti-double-act design + that pairs with wire's `agentic-action-safety` posture. Cheap to adopt in wire's MCP tool contract; + worth a short design note. + +### Decline (name them anti-features, don't drift into them) +5. **Cross-agent memory — NON-GOAL** (locked). Vindicated by the crawl: BAND's own memory is a + vector-less, Enterprise-gated bolt-on, absent from its core model. wire = transport; agents bring + their own memory (Soul). Record in `docs/ANTI_FEATURES.md`. +6. **Task boards / kanban — NON-GOAL.** BAND's is beta; it is workflow-orchestration, not comms + substrate. Out of wire's lane. +7. **Enterprise governance UI — DEFER, don't chase the theater.** BAND's "governance" is passive + observability with no pre-action gate. wire's honest version = the signed, verifiable audit trail + it *already* has (every envelope Ed25519-signed) surfaced queryably — a *stronger* claim than + BAND's self-reported event log, and it falls out of P1's dash. Post-1.0 additive; don't build a + policy engine on spec. + +## The one-line positioning + +> **wire — the open agent network. No server owns your agent graph.** +> Self-certifying identity, end-to-end encryption, self-hostable or fully offline. The parts BAND +> charges enterprise money for, decentralized and open. + +## KPIs (falsifiable) + +- **Reach proof:** a documented, tested wire↔agent round-trip from ≥3 distinct MCP hosts (e.g. + Claude Code, Cursor, one LangGraph-via-MCP) — proving "MCP host ⇒ wire participant" without a + native SDK. Target: before claiming reach parity. *Fail if any host needs bespoke wire glue.* +- **Observability proof:** `wire dash` correctly classifies the live 272-session store (live vs + abandoned vs dead) with zero spawn/kill; MC adapter shows wire agents in MC's UI. *Fail if it + mutates state or misses husks.* +- **Positioning proof:** the landing + README lead with the decentralization wedge and a 60-second + "self-host / offline" demo BAND structurally cannot match. *Fail if the pitch is feature-parity + ("we have rooms too") instead of axis-difference.* +- **Interop proof (stretch):** wire peer reachable as an A2A endpoint from a non-wire A2A client. + +## Risks / honest counters + +- **Reach is the real gap and MCP may not cover every framework.** Mitigation: the KPI forces a + *proof*, not an assertion; native SDKs added only where a coverage hole is demonstrated. +- **BAND out-executes on GTM ($17M + enterprise sellers).** wire doesn't win enterprise sales; it + wins the developers + privacy/sovereignty buyers who will never accept a mandatory central broker. + Different ICP — don't fight on their field. +- **"Open BAND" invites feature-parity creep** (memory, boards, governance UI). Mitigation: the + Decline list above is a standing gate; each is an explicit anti-feature. +- **Changelog/maturity unverifiable** (their mirror hides it) — don't over-index on BAND's velocity; + compete on axis, not feature-race. `[flag: unverified]` + +## Next actions + +1. Ratify this brief (esp. the Decline list → `docs/ANTI_FEATURES.md` entries). +2. Proceed to P1/P2 code (companion doc) — observability first. +3. Spin a short follow-up spec for the MCP-live-receive reach proof + the A2A-outside adapter. +4. Feed the positioning one-liner to the landing/README refresh (separate change). diff --git a/docs/design/2026-07-05-retire-idle-identities.md b/docs/design/2026-07-05-retire-idle-identities.md new file mode 100644 index 0000000..da5df8a --- /dev/null +++ b/docs/design/2026-07-05-retire-idle-identities.md @@ -0,0 +1,129 @@ +# Plan (v2, post gate-1) — retire/revive idle identities + `wire dash --retire-idle` + +Date: 2026-07-05 · Tier: **HIGH** (kills daemon processes, touches the cross-cutting supervisor +lifecycle, shared-write to session state) · Branch: `observability-open-band` +Revised after a 4-persona plan review folded 5 BLOCKERs + 8 MAJORs (see end). + +## Problem (from research) + +`wire dash` surfaced **258 idle solo daemons**. They are NOT husks: each is a *real identity* +(`config/wire/private.key`) that syncs. The supervisor **deliberately keeps a daemon alive for +every real identity** so it can receive mail (`daemon_supervisor.rs:151-203`). So there is no way to +retire an identity you're done with, and killing its daemon is futile — the supervisor respawns it +within one poll (~10s). + +## Decisions (operator-ratified) + +- **Reversible:** retire = stop daemon + `.retired` marker; keep home/identity/slot. `wire revive` + undoes. Later `--purge-retired` reclaims disk. +- **Manual only:** `wire retire` / `wire revive` / `wire dash --retire-idle`. **No auto-retire.** +- **CLI-only, no MCP tool.** A destructive host-lifecycle action mirrors `nuke` (also CLI-only); + per `agentic-action-safety`, an agent must not kill another identity's daemon unsupervised. + +## Mechanism (reuses the supervisor reconcile loop; hardened per gate-1) + +1. **Marker:** `/state/wire/retired.json` = `{schema:"wire-retired-v1", retired_at, reason}`. + `is_retired(home)` is a **pure existence check** (`.exists()`, mirrors `fs_has_identity`) — never + content-dependent, so a torn write can't flip it to "not retired". Any body-parsing (for display) + **fails closed** (parse error ⇒ still treated as retired). +2. **`supervisor_eligible` — filter retired FIRST, above everything** (fixes B1+B2): + retired homes are removed at the very top of the function, **before** the `max_idle == None` + early-return AND **before** the `cwd.is_some()` and `has_identity` branches. So a retired home is + ineligible in every config (`WIRE_ALL_SESSIONS_MAX_IDLE_DAYS=0`, cwd-bound project, idle identity + — all of it). Injected `is_retired: H` probe (like the existing `has_identity`) for unit tests. + The supervisor's existing step-3 then kills its owned child + step-4 never respawns — for free. +3. **`retire` also kills the pid directly, graceful-then-force** (fixes B3): write marker, then + `platform::kill_process(pid, false)`; if still alive after a short grace, `kill_process(pid, true)` + — mirroring the `upgrade.rs` #2 fix (bare `taskkill /PID` without `/F` is a no-op for a headless + Windows daemon). Covers operator-spawned daemons (never in the supervisor's `children` map). + **Order is marker-first, then kill** (a unit test asserts this) — reverse it and the respawn race + returns. + +## Surface + +- `wire retire ` — resolve box-wide → guard → marker → kill → report. +- `wire revive ` — remove marker → supervisor respawns next poll. +- `wire dash --retire-idle [--older-than ] [--dry-run] [--force] [--json]` — bulk. +- `wire dash --retired` — list retired identities (the revive-discoverability answer, B5/M6). +- `wire dash` — retired identities **collapse into a count by default** (a new `retired` predicate, + NOT `likely_idle` which goes false once the daemon is dead); summary gains a `retired` tally; + `--all` / `--retired` reveal them. + +### Box-wide resolver (M5) + +`` resolves over `session::list_sessions()` (NOT the current-session trust resolver): +match if arg == handle, == fingerprint, or == session key/dir name (many idle homes never claimed a +handle → key/fp is the only address). **Zero match → error. Multiple match → error listing them.** + +## Hard safety guards (fixes B4, M1, M2, M3, M4) + +Selection for `--retire-idle` = daemon running ∧ 0 pinned peers ∧ **no pending inbound pair** +(`pending_inbound_pair` record, M1) ∧ last-active > threshold (default 7d) ∧ not already retired ∧ +**not the current identity**. + +- **Current identity by HOME PATH, fail closed** (B4): identify "me" by the effective home this + process resolves for itself (`WIRE_HOME`/session-key/cwd-detect/default chain — the same one + `detect_session_wire_home` uses), and exclude by **home_dir equality**, not by + `resolve_session_key()` (which is `None` on a bare terminal). If the current home cannot be + resolved at all, **refuse** the bulk sweep (fail closed), never proceed with an empty exclusion. +- **Paired = sacrosanct:** `peers.len() > 0` excluded unconditionally on every path. +- **`--force` = skip the typed confirmation ONLY** (M2). It never bypasses the paired/current/ + pending/recent guards, on either the single-target or bulk path. Force-retiring a paired identity + is out of scope. +- **Dry-run is the DEFAULT for bulk**; the real run **lists every victim handle** (M4, like + `nuke`), then demands a typed `retire` confirm. +- **Re-check guards per target at kill time** (M3, TOCTOU): if a candidate paired/became-current/ + got a pending request during the confirm latency, **drop it** (don't kill) and report the drop. +- Single-target `wire retire ` refuses a paired/current/pending/recent target (message explains + why); it does not have a guard-bypass — the current identity can never be retired by any flag. + +## Reversibility (verified against the relay, gate-1 reviewer 3) + +`relay_server.rs:20` — slot tokens **never expire**; `post_event` retains mail regardless of pulls; +the daemon's pull cursor lives in kept `relay.json` → **revive drains the full backlog, identity +identical.** The husk-reaper skips any home with `private.key` (`daemon_supervisor.rs:295-302`) → a +retired-but-identityful home is never swept. So retire is genuinely reversible. Caveat (MINOR): a +slot has a 64MB cap; a peer messaging a long-retired identity could eventually 413 — pre-existing, +surface it in the retired view later, not blocking. + +## Files + +- **new** `src/retire.rs` — marker read/write/remove, `is_retired`, `retire_session`/`revive_session` + (kill injected for test), the box-wide resolver. Unit-tested. +- **edit** `src/daemon_supervisor.rs` — `fs_is_retired` + retired-first filter in `supervisor_eligible` + + tests (retired ineligible with cwd, with identity, and under `max_idle=None`). +- **edit** `src/dash.rs` — `retired: bool` on `SessionSnapshot`; retired excluded from `likely_idle`; + a `retired` count. +- **edit** `src/cli/dash.rs` — retired collapse + count + `--retired` + `--retire-idle` path. +- **edit** `src/cli/mod.rs` + `src/cli/lifecycle.rs` — `Command::Retire`, `Command::Revive`, new + `Dash` flags; `cmd_retire`/`cmd_revive`/`cmd_retire_idle`. + +## Success criteria (runnable checks) + +- `cargo build` + `cargo test --lib` + `cargo fmt --check` + `cargo clippy -D warnings` green. +- Unit: `supervisor_eligible` returns false for a retired home **with cwd**, **with identity**, and + **under `max_idle=None`**; retire writes marker before kill; revive removes marker; bulk selection + excludes current(by home)/paired/pending-inbound/recent; resolver errors on zero + ambiguous; + dash render **collapses retired by default** + `--retired` reveals. +- **In-situ (HIGH):** retire ONE known throwaway idle identity (0 peers, not current) → daemon dies + AND stays dead across >1 supervisor poll (~20s, generous for backoff, M-minor); `wire dash` shows + it retired (collapsed) + `--retired` lists it; `revive` brings it back; current session + all + paired identities untouched; pid-count drops by exactly 1 (no cascade). Reversible round-trip + proven before the operator touches the 258. + +## Explicitly NOT doing / follow-ups + +- No auto-retire; no hard-delete; no slot release; no MCP tool. +- Not bulk-retiring the 258 here — operator runs `--retire-idle` after seeing the round-trip. +- `--purge-retired` (reclaim disk; dash still walks retired homes until then) — follow-up. +- **Root-cause follow-up (both critics):** a Claude `SessionEnd` hook that self-retires its own + identity iff 0 pinned peers at session end — stops the pile growing at the source, no false-positive + on paired identities. Log after the manual tool ships. + +## Gate-1 findings folded + +BLOCKERs: B1 retired-check-before-cwd-branch · B2 retired-above-max_idle-early-return · B3 Windows +graceful-then-force kill · B4 current-by-home-path-fail-closed · B5 retired-collapse-in-dash. +MAJORs: pending-inbound exclusion · unified `--force` · TOCTOU re-check · name victims · box-wide +resolver · `--retired` list · MCP-exclusion declared · slot-cap note. MINORs: pure `.exists()` +marker fail-closed · marker-before-kill test · backoff timing note · `--json`/`--watch` handling. diff --git a/src/cli/dash.rs b/src/cli/dash.rs new file mode 100644 index 0000000..21321d8 --- /dev/null +++ b/src/cli/dash.rs @@ -0,0 +1,735 @@ +//! `wire dash` — one pane for every wire identity on this box. +//! +//! Renders [`crate::dash::collect`]: each session's persona, daemon liveness, +//! pinned peers, relay binding, and sync recency. Paired sessions float to the +//! top; the idle solo-daemon throwaways collapse into a count (`--all` expands). +//! Read-only — never spawns or kills a daemon. + +use crate::character::sanitize_display_text; +use crate::dash::{self, CollectOpts, DaemonState, SessionSnapshot}; +use anyhow::Result; +use std::io::{IsTerminal, Write}; +use std::time::Duration; + +/// Visible width of the name column (emoji + handle). Emoji display width is +/// terminal-dependent (usually 2); we pad on Rust char count, so rows with an +/// emoji may sit ~1 column wider than plain rows — acceptable for a glance tool. +const NAME_W: usize = 24; +/// Visible width of the daemon cell — every label (`● live` / `○ husk` / +/// `· none`) is exactly 6 chars, matching the `DAEMON` header. +const DAEMON_W: usize = 6; +/// Fingerprints are 8 hex chars; give the column one space of breathing room. +const FP_W: usize = 9; +const CWD_MAX: usize = 30; + +/// Write a frame to stdout, exiting cleanly on a broken pipe. `wire dash` is +/// meant to be piped (`head`, `jq`, the Mission Control reporter, an SSH tail); +/// the default `print!`/`println!` macros PANIC on EPIPE, so a downstream reader +/// closing early would crash wire with a backtrace. Exit 0 instead. +fn emit(text: &str) { + let mut out = std::io::stdout().lock(); + let r = out.write_all(text.as_bytes()).and_then(|()| out.flush()); + if let Err(e) = r + && e.kind() == std::io::ErrorKind::BrokenPipe + { + std::process::exit(0); + } +} + +/// Parsed `wire dash` flags. +pub struct DashArgs { + pub watch: bool, + pub json: bool, + pub all: bool, + pub probe: bool, + pub retired: bool, + pub retire_idle: bool, + pub older_than: Option, + pub dry_run: bool, + pub force: bool, +} + +/// A warning banner when this shell's wire identity is split — the env resolves +/// a different identity than Claude Code's live session (a stale wire process, +/// usually an MCP server, answering as the wrong identity). Empty when healthy. +fn split_banner(color: bool) -> String { + let Some(s) = crate::session::detect_identity_split() else { + return String::new(); + }; + let headline = match (s.env_handle.as_deref(), s.live_handle.as_deref()) { + (Some(env), Some(live)) => { + format!("this shell operates as {env} but your live Claude session is {live}") + } + _ => format!( + "the wire identity this shell resolves (via {}) doesn't match your live Claude session", + s.env_source + ), + }; + let body = format!( + "⚠ identity split — {headline}.\n A stale wire process (usually an MCP server) is bound to the wrong identity. Fix: reconnect it (/mcp) or restart this session.\n\n" + ); + if color { + format!("\x1b[33m{body}\x1b[0m") + } else { + body + } +} + +pub fn cmd_dash(args: DashArgs) -> Result<()> { + if args.retire_idle { + return cmd_retire_idle( + args.older_than.unwrap_or(7), + args.dry_run, + args.force, + args.json, + ); + } + let opts = CollectOpts { + probe_relays: args.probe, + }; + let color = std::io::stdout().is_terminal(); + if args.watch { + // Watch is the outer loop: each tick emits JSON (one compact object) + // or the table, so `--watch --json` streams and `--watch | pipe` keeps + // looping instead of silently printing once. + loop { + let report = dash::collect(&opts)?; + let mut frame = String::from("\x1b[2J\x1b[H"); // clear + home + if args.json { + frame.push_str(&serde_json::to_string(&report)?); + frame.push('\n'); + } else { + frame.push_str(&split_banner(color)); + frame.push_str(&render(&report, args.all, args.retired, color)); + } + emit(&frame); + std::thread::sleep(Duration::from_secs(2)); + } + } + let report = dash::collect(&opts)?; + let frame = if args.json { + format!("{}\n", serde_json::to_string_pretty(&report)?) + } else { + format!( + "{}{}", + split_banner(color), + render(&report, args.all, args.retired, color) + ) + }; + emit(&frame); + Ok(()) +} + +fn fmt_age(s: Option) -> String { + match s { + None => "—".to_string(), + Some(s) if s < 60 => format!("{s}s"), + Some(s) if s < 3600 => format!("{}m", s / 60), + Some(s) if s < 86400 => format!("{}h", s / 3600), + Some(s) => format!("{}d", s / 86400), + } +} + +/// A fixed-width daemon cell: pad the *plain* label first, then wrap it in +/// color so the visible width stays `DAEMON_W` (wrapping an already-padded +/// ANSI string with `{: String { + let (label, code) = match d { + DaemonState::Running { .. } => ("● live", "32"), // green + DaemonState::StalePid { .. } => ("○ husk", "31"), // red + DaemonState::None => ("· none", "2"), // dim + }; + let padded = format!("{label: String { + if color { + format!("\x1b[2m{text}\x1b[0m") + } else { + text.to_string() + } +} + +/// Display name for a session: sanitized `handle` (or nickname, or key). +fn display_name(s: &SessionSnapshot) -> String { + let raw = s + .handle + .as_deref() + .or(s.nickname.as_deref()) + .unwrap_or(&s.key); + sanitize_display_text(raw) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let head: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{head}…") + } +} + +/// The retired marker cell, fixed to `DAEMON_W` visible chars (magenta). +fn retired_cell(color: bool) -> String { + let label = "◌ retd"; + let padded = format!("{label: String { + let total = report.sessions.len(); + let running = report + .sessions + .iter() + .filter(|s| s.daemon.is_running()) + .count(); + let paired = report + .sessions + .iter() + .filter(|s| !s.peers.is_empty()) + .count(); + let idle = report.sessions.iter().filter(|s| s.likely_idle).count(); + let retired_n = report.sessions.iter().filter(|s| s.retired).count(); + let husks = report + .sessions + .iter() + .filter(|s| matches!(s.daemon, DaemonState::StalePid { .. })) + .count(); + + let mut out = String::new(); + out.push_str(&format!( + "wire dash — {total} identities · {running} running · {paired} paired · {idle} idle · {retired_n} retired · {husks} husks\n" + )); + for r in &report.relays { + let health = if r.unprobed { + dim("(unprobed — --probe for health)", color) + } else if r.ok { + let s = r.status.map(|c| c.to_string()).unwrap_or_default(); + if color { + format!("\x1b[32m[{s} ok]\x1b[0m") + } else { + format!("[{s} ok]") + } + } else { + let s = r + .status + .map(|c| c.to_string()) + .unwrap_or_else(|| "unreachable".into()); + if color { + format!("\x1b[31m[{s}]\x1b[0m") + } else { + format!("[{s}]") + } + }; + out.push_str(&format!("relay {} {health}\n", r.url)); + } + out.push('\n'); + + // Header row (padded to the same widths the data rows use). + out.push_str(&dim( + &format!( + "{:5} {:>6} {}\n", + "IDENTITY", "DAEMON", "FP", "PEERS", "SYNC", "RELAY / CWD" + ), + color, + )); + + let mut hidden_idle = 0usize; + let mut hidden_retired = 0usize; + let mut shown = 0usize; + for s in &report.sessions { + // Visibility: `--retired` shows only retired; otherwise idle solo + // daemons AND retired identities collapse unless `--all`. + let show = if retired_only { + s.retired + } else if all { + true + } else { + !s.likely_idle && !s.retired + }; + if !show { + if !retired_only { + if s.retired { + hidden_retired += 1; + } else if s.likely_idle { + hidden_idle += 1; + } + } + continue; + } + shown += 1; + let name = display_name(s); + let emoji = s.emoji.as_deref().unwrap_or("·"); + let plain_name = format!("{emoji} {name}"); + let name_pad = NAME_W.saturating_sub(plain_name.chars().count()); + // Colorize just the name; pad with plain spaces after (color must not + // affect the computed column width). + let name_col = if color && let Some(c) = s.ansi256_primary { + format!( + "{emoji} \x1b[38;5;{c}m{name}\x1b[0m{}", + " ".repeat(name_pad) + ) + } else { + format!("{plain_name}{}", " ".repeat(name_pad)) + }; + let fp = s.fingerprint.as_deref().unwrap_or("—"); + let peers = s.peers.len(); + let sync = fmt_age(s.last_sync_age_s); + let relay = s.relay_url.as_deref().unwrap_or("—"); + // cwd is the reap-decision signal — which project an idle/retired + // daemon belongs to. Dim, truncated. + let cwd = s + .cwd + .as_deref() + .map(|c| format!(" {}", dim(&truncate(c, CWD_MAX), color))) + .unwrap_or_default(); + // Retired identities show a distinct marker, not their (stopped) daemon. + let daemon = if s.retired { + retired_cell(color) + } else { + daemon_cell(&s.daemon, color) + }; + out.push_str(&format!( + "{name_col} {daemon} {fp:5} {sync:>6} {relay}{cwd}\n" + )); + // Pinned peers under a paired session (sanitized — handle/tier are + // peer-published text that must not inject terminal escapes). + if !s.peers.is_empty() { + let list: Vec = s + .peers + .iter() + .map(|p| { + format!( + "{} ({})", + sanitize_display_text(&p.handle), + sanitize_display_text(&p.tier) + ) + }) + .collect(); + out.push_str(&dim(&format!(" ↳ {}\n", list.join(", ")), color)); + } + } + if retired_only { + if shown == 0 { + out.push_str(&dim("no retired identities.\n", color)); + } + } else { + if hidden_idle > 0 { + let p = if hidden_idle == 1 { "" } else { "s" }; + out.push_str(&dim( + &format!( + "\n… {hidden_idle} idle solo daemon{p} hidden (--all to show · `wire dash --retire-idle` to clean up)\n" + ), + color, + )); + } + if hidden_retired > 0 { + let noun = if hidden_retired == 1 { + "identity" + } else { + "identities" + }; + out.push_str(&dim( + &format!( + "… {hidden_retired} retired {noun} hidden (--retired to list · `wire revive ` to restore)\n" + ), + color, + )); + } + } + out.push_str(&dim( + "run `wire dash` on each box for a fleet view.\n", + color, + )); + out +} + +/// `wire dash --retire-idle` — reversibly retire every idle solo daemon. +/// Selection: running daemon ∧ 0 pinned peers ∧ not the current identity ∧ no +/// pending inbound pair ∧ not already retired ∧ daemon running longer than the +/// cutoff. Dry-run unless confirmed; guards re-checked at kill time (TOCTOU). +fn cmd_retire_idle(older_than_days: u64, dry_run: bool, force: bool, json: bool) -> Result<()> { + use crate::retire; + use std::path::PathBuf; + // Fail closed: if we can't identify our own home, refuse — never risk + // retiring the identity the operator is using right now. + let current = retire::current_home().ok_or_else(|| { + anyhow::anyhow!( + "cannot resolve the current identity's home — refusing --retire-idle (would risk retiring the session you're using)" + ) + })?; + let cutoff_s = older_than_days.saturating_mul(86_400); + let is_current = |home: &std::path::Path| -> bool { + std::fs::canonicalize(home) + .map(|h| h == current) + .unwrap_or(false) + }; + + // Iterate session homes DIRECTLY — each SessionInfo carries its own + // home_dir. Deliberately NOT a name-keyed map: SessionInfo.name is the + // DID-derived handle, which can collide across ~270 identities (32-bit + // nickname space → birthday paradox), and a collision would collapse two + // homes and retire the wrong one. + struct Cand { + home: PathBuf, + label: String, + fp: String, + emoji: String, + cwd: Option, + did: Option, + handle: Option, + } + let sessions = crate::session::list_sessions()?; + let mut candidates: Vec = Vec::new(); + for si in &sessions { + let home = &si.home_dir; + if !si.daemon_running + || retire::is_retired(home) + || is_current(home) + || retire::has_pending_inbound(home) + || retire::identity_age_s(home) + .map(|a| a < cutoff_s) + .unwrap_or(true) + { + continue; + } + // Peer read last (a file read per session). + if !crate::dash::read_peers(home, si.did.as_deref(), si.handle.as_deref()).is_empty() { + continue; + } + candidates.push(Cand { + home: home.clone(), + label: si.handle.clone().unwrap_or_else(|| si.name.clone()), + fp: si + .did + .as_deref() + .and_then(crate::dash::fingerprint_from_did) + .unwrap_or_else(|| "—".into()), + emoji: si + .character + .as_ref() + .map(|c| c.emoji.clone()) + .unwrap_or_else(|| "·".into()), + cwd: si.cwd.clone(), + did: si.did.clone(), + handle: si.handle.clone(), + }); + } + + if candidates.is_empty() { + if json { + emit(&format!( + "{}\n", + serde_json::to_string_pretty( + &serde_json::json!({"retired":[],"note":"no idle identities matched"}) + )? + )); + } else { + eprintln!( + "no idle solo daemons to retire (identity older than {older_than_days}d, 0 peers, not current/paired/pending)." + ); + } + return Ok(()); + } + + // JSON dry-run: structured preview for automation. + if json && dry_run { + let list: Vec<_> = candidates + .iter() + .map(|c| serde_json::json!({"handle": c.handle, "fp": c.fp, "cwd": c.cwd})) + .collect(); + emit(&format!( + "{}\n", + serde_json::to_string_pretty( + &serde_json::json!({"would_retire": list, "count": candidates.len()}) + )? + )); + return Ok(()); + } + + // Name the victims before the confirm gate (like `wire nuke`). + eprintln!( + "wire dash --retire-idle would retire {} idle identit{}:", + candidates.len(), + if candidates.len() == 1 { "y" } else { "ies" } + ); + for c in &candidates { + eprintln!( + " {} {} {} {}", + c.emoji, + c.label, + c.fp, + c.cwd.as_deref().unwrap_or("") + ); + } + if dry_run { + eprintln!( + "\n(dry run — nothing retired. Re-run without --dry-run; `wire revive ` undoes any.)" + ); + return Ok(()); + } + + // Typed confirm, unless --force. --force skips the prompt ONLY — it never + // bypasses the selection guards above. + if !force { + use std::io::{IsTerminal, Write}; + if !std::io::stdin().is_terminal() { + anyhow::bail!( + "refusing to retire {} identities without a TTY confirmation (use --force for automation)", + candidates.len() + ); + } + eprint!( + "\nType `retire` to retire these {} identities (reversible): ", + candidates.len() + ); + let _ = std::io::stderr().flush(); + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + if line.trim() != "retire" { + eprintln!("aborted — nothing retired."); + return Ok(()); + } + } + + // Execute, re-checking guards per target at kill time (TOCTOU: a candidate + // could have paired / become current / gotten a pending request during the + // confirm latency). + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let mut retired: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); // a guard changed during confirm + let mut errored: Vec = Vec::new(); + let total = candidates.len(); + for (i, c) in candidates.iter().enumerate() { + let still_ok = !is_current(&c.home) + && !retire::has_pending_inbound(&c.home) + && !retire::is_retired(&c.home) + && crate::dash::read_peers(&c.home, c.did.as_deref(), c.handle.as_deref()).is_empty(); + if !still_ok { + skipped.push(c.label.clone()); + continue; + } + if !json { + eprintln!(" [{}/{total}] retiring {}…", i + 1, c.label); + } + match retire::retire_session( + &c.home, + "idle-sweep", + now_unix, + retire::stop_daemon_graceful_then_force, + ) { + Ok(_) => retired.push(c.label.clone()), + Err(e) => errored.push(format!("{}: {e}", c.label)), + } + } + + if json { + emit(&format!( + "{}\n", + serde_json::to_string_pretty( + &serde_json::json!({"retired": retired, "skipped": skipped, "errored": errored}) + )? + )); + } else { + let mut note = String::new(); + if !skipped.is_empty() { + note.push_str(&format!( + "skipped {} (changed during confirm). ", + skipped.len() + )); + } + if !errored.is_empty() { + note.push_str(&format!("{} errored. ", errored.len())); + } + eprintln!( + "\nretired {} idle identit{}. {note}`wire dash --retired` lists them; `wire revive ` restores one.", + retired.len(), + if retired.len() == 1 { "y" } else { "ies" } + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dash::{DashReport, PeerRow, RelayHealth}; + + fn snap(key: &str, running: bool, peers: usize) -> SessionSnapshot { + SessionSnapshot { + key: key.to_string(), + handle: Some(key.to_string()), + did: Some(format!("did:wire:{key}-deadbeef")), + fingerprint: Some("deadbeef".to_string()), + nickname: Some(key.to_string()), + emoji: Some("🦊".to_string()), + primary_hex: Some("#da60a3".to_string()), + ansi256_primary: Some(175), + daemon: if running { + DaemonState::Running { pid: 1 } + } else { + DaemonState::None + }, + daemon_version: Some("0.16.0".to_string()), + relay_url: Some("https://wireup.net".to_string()), + slot_id: Some("abc".to_string()), + last_sync_age_s: Some(5), + peers: (0..peers) + .map(|i| PeerRow { + handle: format!("peer{i}"), + did: format!("did:wire:peer{i}-0000"), + tier: "VERIFIED".to_string(), + }) + .collect(), + cwd: None, + likely_idle: running && peers == 0, + retired: false, + } + } + + #[test] + fn idle_solo_daemons_collapse_unless_all() { + let report = DashReport { + schema: dash::SCHEMA, + sessions: vec![snap("paired", true, 1), snap("idle", true, 0)], + relays: vec![RelayHealth { + url: "https://wireup.net".to_string(), + ok: false, + status: None, + unprobed: true, + }], + }; + let collapsed = render(&report, false, false, false); + assert!(collapsed.contains("paired"), "paired session always shown"); + assert!( + collapsed.contains("1 idle solo daemon hidden"), + "idle collapses (singular):\n{collapsed}" + ); + // The paired session is shown even collapsed; only the idle row hides. + assert!( + !collapsed.contains("🦊 idle"), + "idle row is hidden:\n{collapsed}" + ); + + let expanded = render(&report, true, false, false); + assert!( + !expanded.contains("hidden"), + "--all shows everything:\n{expanded}" + ); + assert!( + expanded.contains("↳ peer0 (VERIFIED)"), + "peer line under paired" + ); + } + + #[test] + fn retired_collapses_by_default_and_lists_with_retired_flag() { + let mut r = snap("gone", false, 0); // daemon stopped + r.retired = true; + r.likely_idle = false; + let report = DashReport { + schema: dash::SCHEMA, + sessions: vec![snap("live", true, 1), r], + relays: vec![], + }; + // Default view: retired row hidden (NOT via likely_idle, which is false + // once the daemon dies), counted in the summary + hidden note. + let def = render(&report, false, false, false); + assert!( + !def.contains("🦊 gone"), + "retired hidden by default:\n{def}" + ); + assert!( + def.contains("1 retired ·"), + "retired counted in summary:\n{def}" + ); + assert!( + def.contains("retired identity hidden"), + "retired hidden note:\n{def}" + ); + // --retired: only retired rows. + let only = render(&report, false, true, false); + assert!( + only.contains("🦊 gone"), + "retired shown under --retired:\n{only}" + ); + assert!( + !only.contains("🦊 live"), + "non-retired hidden under --retired:\n{only}" + ); + } + + #[test] + fn header_counts_are_accurate() { + let report = DashReport { + schema: dash::SCHEMA, + sessions: vec![snap("a", true, 2), snap("b", true, 0), snap("c", false, 0)], + relays: vec![], + }; + let out = render(&report, true, false, false); + assert!(out.contains("3 identities"), "{out}"); + assert!(out.contains("2 running"), "{out}"); + assert!(out.contains("1 paired"), "{out}"); + assert!(out.contains("1 idle"), "{out}"); + } + + #[test] + fn peer_handle_terminal_escape_is_stripped() { + let mut s = snap("victim", true, 0); + s.peers = vec![PeerRow { + handle: "evil\x1b[2Jhandle".to_string(), + did: "did:wire:evil-0000".to_string(), + tier: "VERIFIED".to_string(), + }]; + s.likely_idle = false; + let report = DashReport { + schema: dash::SCHEMA, + sessions: vec![s], + relays: vec![], + }; + let out = render(&report, true, false, true); + // The live screen-clear escape (ESC[2J) must not survive into output. + // sanitize_display_text strips the ESC byte; the inert "[2J" text may + // remain, which is harmless (no ESC = no terminal action). + assert!( + !out.contains("\x1b[2J"), + "injected ESC[2J must not survive:\n{out:?}" + ); + assert!(out.contains("evil"), "sanitized handle text still shown"); + assert!(out.contains("handle"), "sanitized handle text still shown"); + } + + #[test] + fn fmt_age_scales() { + assert_eq!(fmt_age(None), "—"); + assert_eq!(fmt_age(Some(5)), "5s"); + assert_eq!(fmt_age(Some(120)), "2m"); + assert_eq!(fmt_age(Some(7200)), "2h"); + assert_eq!(fmt_age(Some(172800)), "2d"); + } + + #[test] + fn cwd_rendered_when_present() { + let mut s = snap("proj", true, 1); + s.cwd = Some("/Users/x/Source/wire".to_string()); + let report = DashReport { + schema: dash::SCHEMA, + sessions: vec![s], + relays: vec![], + }; + let out = render(&report, false, false, false); + assert!(out.contains("/Users/x/Source/wire"), "cwd shown:\n{out}"); + } +} diff --git a/src/cli/lifecycle.rs b/src/cli/lifecycle.rs index 95b01d6..7be498f 100644 --- a/src/cli/lifecycle.rs +++ b/src/cli/lifecycle.rs @@ -287,3 +287,81 @@ fn scrub_shell_lines(warnings: &mut Vec) { } } } + +// ---------- retire / revive (reversible identity decommission) ---------- + +/// `wire retire ` — reversibly decommission an identity you're +/// done with: write a `.retired` marker (so the supervisor won't respawn) and +/// stop its daemon. Guards: never the current identity; a paired identity +/// (mesh member) needs `--force`. +pub(crate) fn cmd_retire(target: String, force: bool, as_json: bool) -> Result<()> { + let s = crate::retire::resolve_target(&target)?; + let home = s.home_dir.clone(); + let label = s.handle.clone().unwrap_or_else(|| s.name.clone()); + + // Hard refuse, no override: never retire the identity this process uses. + if crate::retire::is_current(&home) { + anyhow::bail!("'{label}' is the identity you're using right now — refusing to retire it"); + } + // Paired = your live mesh; require an explicit --force to retire a member. + let peers = crate::dash::read_peers(&home, s.did.as_deref(), s.handle.as_deref()); + if !peers.is_empty() && !force { + anyhow::bail!( + "'{label}' is paired with {} peer(s) — part of your mesh. Pass --force to retire it anyway.", + peers.len() + ); + } + + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let pid = crate::retire::retire_session( + &home, + "manual", + now_unix, + crate::retire::stop_daemon_graceful_then_force, + )?; + + if as_json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "retired": label, "key": s.name, "did": s.did, "stopped_pid": pid, + }))? + ); + } else { + match pid { + Some(p) => eprintln!( + "retired {label} — stopped daemon pid {p}. `wire revive {label}` brings it back." + ), + None => eprintln!( + "retired {label} — no daemon was running. `wire revive {label}` brings it back." + ), + } + } + Ok(()) +} + +/// `wire revive ` — undo a retire: remove the marker; the +/// supervisor respawns the daemon on its next poll. The identity, relay slot, +/// and any mail that arrived while retired are all intact. +pub(crate) fn cmd_revive(target: String, as_json: bool) -> Result<()> { + let s = crate::retire::resolve_target(&target)?; + let label = s.handle.clone().unwrap_or_else(|| s.name.clone()); + let was = crate::retire::is_retired(&s.home_dir); + crate::retire::revive_session(&s.home_dir)?; + if as_json { + println!( + "{}", + serde_json::to_string_pretty(&json!({"revived": label, "was_retired": was}))? + ); + } else if was { + eprintln!( + "revived {label} — the supervisor will respawn its daemon within a poll (~10-60s)." + ); + } else { + eprintln!("{label} was not retired — nothing to do."); + } + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b9500e5..f1d99d9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -18,6 +18,7 @@ use serde_json::{Value, json}; use crate::config; mod comms; +mod dash; mod demo; mod group; mod identity; @@ -126,6 +127,58 @@ pub enum Command { #[arg(long)] json: bool, }, + /// One pane for every wire identity on this box — daemon liveness, + /// pinned peers, relay binding, and sync recency. Read-only; paired + /// sessions float to the top, idle solo daemons collapse into a count. + Dash { + /// Live-refresh every 2s (Ctrl-C to exit). + #[arg(long)] + watch: bool, + /// Emit the full snapshot as JSON (the `wire-dash-v1` surface). + #[arg(long)] + json: bool, + /// Show idle solo daemons too (hidden by default). + #[arg(long)] + all: bool, + /// Probe each distinct relay's /healthz (one GET per relay). + #[arg(long)] + probe: bool, + /// List retired identities (otherwise collapsed/hidden). + #[arg(long)] + retired: bool, + /// Reversibly retire every idle solo daemon (0 peers, not current, + /// idle past the cutoff). Dry-run unless confirmed. + #[arg(long = "retire-idle")] + retire_idle: bool, + /// Idle cutoff in days for --retire-idle (default 7). + #[arg(long)] + older_than: Option, + /// Preview --retire-idle without retiring anything. + #[arg(long)] + dry_run: bool, + /// Skip the typed confirmation for --retire-idle (automation). + /// Never bypasses the current/paired/pending/recent guards. + #[arg(long)] + force: bool, + }, + /// Reversibly decommission an identity you're done with — stop its daemon + /// and stop the supervisor from respawning it. `wire revive` undoes it. + Retire { + /// Handle, fingerprint, or session key of the identity to retire. + target: String, + /// Retire even a paired (mesh-member) identity. + #[arg(long)] + force: bool, + #[arg(long)] + json: bool, + }, + /// Bring a retired identity back — the supervisor respawns its daemon. + Revive { + /// Handle, fingerprint, or session key of the identity to revive. + target: String, + #[arg(long)] + json: bool, + }, /// Emit a shell completion script to stdout. /// /// Pipe to your shell's completion dir to enable tab-completion of @@ -1849,6 +1902,33 @@ pub fn run() -> Result<()> { colored, } => identity::cmd_whoami(json_default(json), short, colored), Command::Peers { json } => comms::cmd_peers(json_default(json)), + Command::Dash { + watch, + json, + all, + probe, + retired, + retire_idle, + older_than, + dry_run, + force, + } => dash::cmd_dash(dash::DashArgs { + watch, + json: json_default(json), + all, + probe, + retired, + retire_idle, + older_than, + dry_run, + force, + }), + Command::Retire { + target, + force, + json, + } => lifecycle::cmd_retire(target, force, json_default(json)), + Command::Revive { target, json } => lifecycle::cmd_revive(target, json_default(json)), Command::Here { json } => comms::cmd_here(json_default(json)), Command::Demo { json } => demo::cmd_demo(json_default(json)), Command::Completions { shell } => { diff --git a/src/daemon_supervisor.rs b/src/daemon_supervisor.rs index 5c48a9a..3f37700 100644 --- a/src/daemon_supervisor.rs +++ b/src/daemon_supervisor.rs @@ -157,22 +157,41 @@ fn fs_has_identity(home: &Path) -> bool { .exists() } +/// True iff the session home has been retired (`state/wire/retired.json`). +/// A retired home is ineligible for a daemon regardless of cwd/identity/idle — +/// the supervisor kills any running child and never respawns. Pure existence +/// check (see [`crate::retire::is_retired`]). +fn fs_is_retired(home: &Path) -> bool { + crate::retire::is_retired(home) +} + /// Filter `list_sessions()` down to the sessions the supervisor should /// own a daemon for. A session is eligible iff it has a registry cwd /// binding OR it was active within `max_idle`. `max_idle == None` /// disables the filter (every session eligible). Pure: the activity /// probe is injected so this is unit-testable without touching disk. -fn supervisor_eligible( +fn supervisor_eligible( sessions: Vec, max_idle: Option, now: SystemTime, last_active: F, has_identity: G, + is_retired: H, ) -> Vec where F: Fn(&Path) -> Option, G: Fn(&Path) -> bool, + H: Fn(&Path) -> bool, { + // Retired homes are ineligible in EVERY configuration — filtered FIRST, + // ahead of the `max_idle == None` early-return and the cwd/identity keeps + // below. A retired identity's daemon must stop and never respawn; an + // `is_retired` check placed after either branch would let a cwd-bound or + // identity-ful retired home stay eligible and get respawned. + let sessions: Vec = sessions + .into_iter() + .filter(|s| !is_retired(&s.home_dir)) + .collect(); let Some(max_idle) = max_idle else { return sessions; }; @@ -440,6 +459,7 @@ pub fn run_supervisor(interval_secs: u64, as_json: bool) -> Result<()> { SystemTime::now(), fs_last_active, fs_has_identity, + fs_is_retired, ); if wanted.len() != total_sessions { eprintln!( @@ -738,6 +758,7 @@ pub fn read_supervisor_state() -> Result { SystemTime::now(), fs_last_active, fs_has_identity, + fs_is_retired, ) .into_iter() .map(|s| s.name) @@ -1135,6 +1156,7 @@ mod tests { now, |_| Some(ancient), |_| false, + |_| false, ); assert_eq!(out.len(), 1); assert_eq!(out[0].name, "wire"); @@ -1163,6 +1185,7 @@ mod tests { } }, |_| false, + |_| false, ); let names: Vec<_> = out.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["rosy-rook"]); @@ -1180,6 +1203,7 @@ mod tests { now, |_| None, |_| false, + |_| false, ); assert!(out.is_empty()); } @@ -1200,18 +1224,55 @@ mod tests { now, |_| None, // neither has ever synced |home| home.ends_with("real"), // only this one has a private.key + |_| false, ); let names: Vec<_> = out.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["real"]); } + #[test] + fn eligible_drops_retired_even_with_cwd_identity_or_no_cutoff() { + // The retire invariant: a `.retired` home is ineligible in EVERY + // configuration — with a cwd binding, with an identity, and even under + // `max_idle = None` (the spawn-for-all override). All three would + // otherwise keep it eligible; the retired filter must beat them all. + let now = SystemTime::now(); + let sessions = vec![ + mk_session("retired-proj", Some("/Users/p/proj")), // cwd-bound + mk_session("retired-id", None), // identity-ful + mk_session("live", Some("/Users/p/live")), + ]; + let retired = |home: &Path| home.to_string_lossy().contains("retired-"); + // With the 7-day cutoff. + let out = supervisor_eligible( + sessions.clone(), + Some(Duration::from_secs(7 * 86_400)), + now, + |_| Some(now), + |home| home.ends_with("retired-id"), // retired-id has identity + retired, + ); + assert_eq!( + out.iter().map(|s| s.name.as_str()).collect::>(), + vec!["live"], + "both retired homes dropped despite cwd/identity" + ); + // And under the max_idle=None spawn-for-all override. + let out2 = supervisor_eligible(sessions, None, now, |_| Some(now), |_| true, retired); + assert_eq!( + out2.iter().map(|s| s.name.as_str()).collect::>(), + vec!["live"], + "retired dropped even with max_idle=None" + ); + } + #[test] fn eligible_none_cutoff_keeps_everything() { // Override = 0 (max_idle None) restores legacy spawn-for-all. let now = SystemTime::now(); let ancient = now - Duration::from_secs(999 * 86_400); let sessions = vec![mk_session("husk", None), mk_session("agate-nimbus", None)]; - let out = supervisor_eligible(sessions, None, now, |_| Some(ancient), |_| false); + let out = supervisor_eligible(sessions, None, now, |_| Some(ancient), |_| false, |_| false); assert_eq!(out.len(), 2); } diff --git a/src/dash.rs b/src/dash.rs new file mode 100644 index 0000000..5dc7d16 --- /dev/null +++ b/src/dash.rs @@ -0,0 +1,504 @@ +//! dash — a read-only observability snapshot across every wire identity on +//! this machine. +//! +//! `collect()` walks the local session store (via [`crate::session::list_sessions`]) +//! and enriches each session with its daemon liveness, relay binding, pinned +//! peers, and sync recency. It powers `wire dash` (the terminal pane) and the +//! Mission Control reporter. +//! +//! Hard invariants (a naive aggregate over ~270 sessions dies without these): +//! - **Read-only, no spawn, no kill.** Never starts or stops a daemon. +//! - **No per-session network I/O.** Reads on-disk state only. Relay `/healthz` +//! is probed once per *distinct* relay, and only when explicitly asked +//! ([`CollectOpts::probe_relays`]). +//! - **Explicit paths, never the session-scoped config helpers.** +//! [`crate::config::read_trust`] / `read_relay_state` resolve against the +//! *current* session's home (WIRE_HOME / session-key context); using them in +//! a cross-session walk would read the same session 270 times. Every read +//! here is rooted at the session's own `home_dir`. + +use serde::Serialize; +use std::collections::BTreeSet; +use std::path::Path; +use std::time::Duration; + +/// Liveness of a session's sync daemon, derived from its `daemon.pid` file + +/// the pid-alive check [`crate::session::list_sessions`] already computes. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum DaemonState { + /// `daemon.pid` present and the recorded pid is a live process. + Running { pid: u32 }, + /// `daemon.pid` present but the process is gone (a true husk). + StalePid { pid: u32 }, + /// No `daemon.pid` file. + None, +} + +impl DaemonState { + pub fn is_running(&self) -> bool { + matches!(self, DaemonState::Running { .. }) + } +} + +/// One pinned peer of a session, read from its `trust.json`. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PeerRow { + pub handle: String, + pub did: String, + pub tier: String, +} + +/// A single wire identity on this box + its live-ish state. +#[derive(Debug, Clone, Serialize)] +pub struct SessionSnapshot { + /// Session key/name (the `by-key/` dir name, or a named session). + pub key: String, + pub handle: Option, + pub did: Option, + /// Short DID fingerprint (the trailing hex segment of the DID). + pub fingerprint: Option, + pub nickname: Option, + pub emoji: Option, + /// Primary persona color as `#rrggbb`, for UI/JSON consumers. + pub primary_hex: Option, + /// Primary persona color as an ANSI-256 index, for terminal glyph coloring. + pub ansi256_primary: Option, + pub daemon: DaemonState, + pub daemon_version: Option, + pub relay_url: Option, + pub slot_id: Option, + /// Seconds since this session's daemon last synced (mtime of + /// `state/wire/last_sync.json`). `None` if it never synced. + pub last_sync_age_s: Option, + pub peers: Vec, + pub cwd: Option, + /// A running daemon with no real pinned peers — the throwaway + /// Claude-session daemon pattern, a candidate for retire. Deliberately NOT + /// a "usage" claim: a live daemon heartbeat-syncs regardless of use, so + /// peers (not sync-age) are the honest signal. A retired identity is never + /// `likely_idle` (it's already handled). + pub likely_idle: bool, + /// This identity has been retired (`wire retire`) — a `.retired` marker is + /// present; the supervisor won't keep a daemon for it. + pub retired: bool, +} + +/// `/healthz` result for one distinct relay URL. +#[derive(Debug, Clone, Serialize)] +pub struct RelayHealth { + pub url: String, + pub ok: bool, + pub status: Option, + /// True when not probed (probe is opt-in); `ok`/`status` are then unknown. + pub unprobed: bool, +} + +/// The whole-machine snapshot — the golden surface `--json` emits and the +/// Mission Control reporter consumes. +#[derive(Debug, Clone, Serialize)] +pub struct DashReport { + pub schema: &'static str, + pub sessions: Vec, + pub relays: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct CollectOpts { + /// Probe each distinct relay's `/healthz` (one blocking GET per relay, + /// 2s timeout). Off by default: `dash` is a local pane; network is opt-in. + pub probe_relays: bool, +} + +pub const SCHEMA: &str = "wire-dash-v1"; + +/// Cap on how much of an on-disk state file we read. Normal trust.json / +/// relay.json / daemon.pid are well under 1 KB; this bounds the blast radius +/// of a single corrupt/hostile file across the ~270-session fan-out. +const MAX_STATE_FILE: u64 = 256 * 1024; + +/// Read at most [`MAX_STATE_FILE`] bytes of a file. `None` on any error. +fn read_capped(path: &Path) -> Option> { + use std::io::Read; + let f = std::fs::File::open(path).ok()?; + let mut buf = Vec::new(); + f.take(MAX_STATE_FILE).read_to_end(&mut buf).ok()?; + Some(buf) +} + +/// Extract the short fingerprint from a DID (`did:wire:terra-plain-e6511a52` +/// → `e6511a52`). The nickname may contain hyphens, so take the final segment. +/// `None` for a DID with no usable trailing segment. +pub fn fingerprint_from_did(did: &str) -> Option { + let tail = did.rsplit(':').next()?; // "terra-plain-e6511a52" + let fp = tail.rsplit('-').next().unwrap_or(""); + if fp.is_empty() { + return None; + } + Some(fp.chars().take(16).collect()) +} + +/// Read a session's pinned peers from `/config/wire/trust.json`, +/// excluding the session's own identity (`trust.json` lists self as an agent). +/// Self is matched on DID **or** handle: a corrupt self-entry missing its `did` +/// must still not surface the session as its own peer. +pub fn read_peers(home: &Path, own_did: Option<&str>, own_handle: Option<&str>) -> Vec { + let path = home.join("config").join("wire").join("trust.json"); + let Some(bytes) = read_capped(&path) else { + return Vec::new(); + }; + let Ok(v) = serde_json::from_slice::(&bytes) else { + return Vec::new(); + }; + let Some(agents) = v.get("agents").and_then(|a| a.as_object()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (handle, rec) in agents { + let did = rec.get("did").and_then(|d| d.as_str()).unwrap_or(""); + // Skip the self entry — trust.json always lists the owning identity. + // Match on either DID or handle so a `did`-less self entry is still + // excluded (else the session lists itself as a peer). + if own_did == Some(did) || own_handle == Some(handle.as_str()) { + continue; + } + out.push(PeerRow { + handle: handle.clone(), + did: did.to_string(), + tier: rec + .get("tier") + .and_then(|t| t.as_str()) + .unwrap_or("UNTRUSTED") + .to_string(), + }); + } + out.sort_by(|a, b| a.handle.cmp(&b.handle)); + out +} + +/// Read `/config/wire/relay.json` → `(relay_url, slot_id)`. +pub fn read_relay_binding(home: &Path) -> (Option, Option) { + let path = home.join("config").join("wire").join("relay.json"); + let Some(bytes) = read_capped(&path) else { + return (None, None); + }; + let Ok(v) = serde_json::from_slice::(&bytes) else { + return (None, None); + }; + let sf = v.get("self"); + let url = sf + .and_then(|s| s.get("relay_url")) + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + let slot = sf + .and_then(|s| s.get("slot_id")) + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + (url, slot) +} + +/// Seconds since `/state/wire/last_sync.json` was last written. +pub fn last_sync_age_s(home: &Path) -> Option { + let path = home.join("state").join("wire").join("last_sync.json"); + let mtime = std::fs::metadata(&path).ok()?.modified().ok()?; + mtime.elapsed().ok().map(|d| d.as_secs()) +} + +/// Read the daemon `version` from `/state/wire/daemon.pid`. +fn read_daemon_version(home: &Path) -> Option { + let path = home.join("state").join("wire").join("daemon.pid"); + let bytes = read_capped(&path)?; + let v = serde_json::from_slice::(&bytes).ok()?; + v.get("version") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()) +} + +/// Build the snapshot for one already-enumerated session. Pure w.r.t. the +/// network; only reads files under `si.home_dir`. +fn snapshot_one(si: &crate::session::SessionInfo) -> SessionSnapshot { + let home = &si.home_dir; + let pid = crate::session::session_daemon_pid(home); + let daemon = match (pid, si.daemon_running) { + (Some(pid), true) => DaemonState::Running { pid }, + (Some(pid), false) => DaemonState::StalePid { pid }, + (None, _) => DaemonState::None, + }; + let peers = read_peers(home, si.did.as_deref(), si.handle.as_deref()); + let (relay_url, slot_id) = read_relay_binding(home); + let (nickname, emoji, primary_hex, ansi256_primary) = match &si.character { + Some(c) => ( + Some(c.nickname.clone()), + Some(c.emoji.clone()), + Some(c.palette.primary_hex.clone()), + Some(c.palette.ansi256_primary), + ), + None => (None, None, None, None), + }; + let retired = crate::retire::is_retired(home); + let likely_idle = daemon.is_running() && peers.is_empty() && !retired; + SessionSnapshot { + key: si.name.clone(), + handle: si.handle.clone(), + fingerprint: si.did.as_deref().and_then(fingerprint_from_did), + did: si.did.clone(), + nickname, + emoji, + primary_hex, + ansi256_primary, + daemon, + daemon_version: read_daemon_version(home), + relay_url, + slot_id, + last_sync_age_s: last_sync_age_s(home), + peers, + cwd: si.cwd.clone(), + likely_idle, + retired, + } +} + +fn probe_relay(url: &str) -> RelayHealth { + let base = url.trim_end_matches('/'); + // Defensive: relay_url comes from an on-disk session file. Only probe + // http(s), and never follow redirects (a hostile relay could otherwise + // bounce the blind probe at an internal address). + if !(base.starts_with("http://") || base.starts_with("https://")) { + return RelayHealth { + url: url.to_string(), + ok: false, + status: None, + unprobed: false, + }; + } + let build = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .redirect(reqwest::redirect::Policy::none()) + .build(); + let Ok(client) = build else { + return RelayHealth { + url: url.to_string(), + ok: false, + status: None, + unprobed: false, + }; + }; + match client.get(format!("{base}/healthz")).send() { + Ok(r) => RelayHealth { + url: url.to_string(), + ok: r.status().is_success(), + status: Some(r.status().as_u16()), + unprobed: false, + }, + Err(_) => RelayHealth { + url: url.to_string(), + ok: false, + status: None, + unprobed: false, + }, + } +} + +/// Snapshot every wire identity on this box. Read-only; never spawns/kills a +/// daemon; does network I/O only when `opts.probe_relays` is set. +pub fn collect(opts: &CollectOpts) -> anyhow::Result { + let sessions = crate::session::list_sessions()?; + let mut snaps = Vec::with_capacity(sessions.len()); + let mut relay_urls: BTreeSet = BTreeSet::new(); + for si in &sessions { + let snap = snapshot_one(si); + if let Some(u) = &snap.relay_url { + relay_urls.insert(u.clone()); + } + snaps.push(snap); + } + // Sort: paired sessions first (most peers), then by daemon liveness, + // then name — so the wires that matter float to the top and the idle + // throwaways sink. + snaps.sort_by(|a, b| { + b.peers + .len() + .cmp(&a.peers.len()) + .then(b.daemon.is_running().cmp(&a.daemon.is_running())) + .then(a.key.cmp(&b.key)) + }); + let relays = relay_urls + .into_iter() + .map(|u| { + if opts.probe_relays { + probe_relay(&u) + } else { + RelayHealth { + url: u, + ok: false, + status: None, + unprobed: true, + } + } + }) + .collect(); + Ok(DashReport { + schema: SCHEMA, + sessions: snaps, + relays, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn fingerprint_extracts_trailing_hex() { + assert_eq!( + fingerprint_from_did("did:wire:terra-plain-e6511a52").as_deref(), + Some("e6511a52") + ); + // Multi-hyphen nickname must not confuse the parse. + assert_eq!( + fingerprint_from_did("did:wire:a-b-c-deadbeef").as_deref(), + Some("deadbeef") + ); + assert_eq!(fingerprint_from_did("garbage").as_deref(), Some("garbage")); + } + + #[test] + fn read_peers_excludes_self_and_reads_tier() { + let dir = tempfile::tempdir().unwrap(); + let cw = dir.path().join("config").join("wire"); + fs::create_dir_all(&cw).unwrap(); + fs::write( + cw.join("trust.json"), + r#"{"agents":{ + "terra-plain":{"did":"did:wire:terra-plain-e6511a52","tier":"ATTESTED"}, + "raven-kettle":{"did":"did:wire:raven-kettle-11112222","tier":"VERIFIED"} + },"version":1}"#, + ) + .unwrap(); + let peers = read_peers( + dir.path(), + Some("did:wire:terra-plain-e6511a52"), + Some("terra-plain"), + ); + assert_eq!(peers.len(), 1, "self must be excluded"); + assert_eq!(peers[0].handle, "raven-kettle"); + assert_eq!(peers[0].tier, "VERIFIED"); + } + + #[test] + fn read_peers_excludes_self_by_handle_when_did_missing() { + // A corrupt self-entry with no `did` must still be excluded via handle. + let dir = tempfile::tempdir().unwrap(); + let cw = dir.path().join("config").join("wire"); + fs::create_dir_all(&cw).unwrap(); + fs::write( + cw.join("trust.json"), + r#"{"agents":{ + "terra-plain":{"tier":"ATTESTED"}, + "raven-kettle":{"did":"did:wire:raven-kettle-11112222","tier":"VERIFIED"} + },"version":1}"#, + ) + .unwrap(); + let peers = read_peers( + dir.path(), + Some("did:wire:terra-plain-e6511a52"), + Some("terra-plain"), + ); + assert_eq!(peers.len(), 1, "did-less self entry excluded by handle"); + assert_eq!(peers[0].handle, "raven-kettle"); + } + + #[test] + fn read_peers_missing_file_is_empty() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_peers(dir.path(), None, None).is_empty()); + } + + #[test] + fn fingerprint_none_for_empty_tail() { + assert_eq!(fingerprint_from_did("did:wire:"), None); + assert_eq!(fingerprint_from_did(""), None); + } + + #[test] + fn dash_report_json_shape_is_stable() { + // Anti-drift: the wire-dash-v1 golden surface. If a field is renamed + // or dropped, this fails — external consumers (Mission Control adapter, + // any --json reader) depend on this shape. + let report = DashReport { + schema: SCHEMA, + sessions: vec![SessionSnapshot { + key: "k".into(), + handle: Some("h".into()), + did: Some("did:wire:h-deadbeef".into()), + fingerprint: Some("deadbeef".into()), + nickname: Some("h".into()), + emoji: Some("🦊".into()), + primary_hex: Some("#da60a3".into()), + ansi256_primary: Some(175), + daemon: DaemonState::StalePid { pid: 9 }, + daemon_version: Some("0.16.0".into()), + relay_url: Some("https://wireup.net".into()), + slot_id: Some("s".into()), + last_sync_age_s: Some(5), + peers: vec![], + cwd: Some("/tmp/x".into()), + likely_idle: false, + retired: false, + }], + relays: vec![], + }; + let v = serde_json::to_value(&report).unwrap(); + assert_eq!(v["schema"], "wire-dash-v1"); + let s = &v["sessions"][0]; + for key in [ + "key", + "handle", + "did", + "fingerprint", + "nickname", + "emoji", + "primary_hex", + "ansi256_primary", + "daemon", + "daemon_version", + "relay_url", + "slot_id", + "last_sync_age_s", + "peers", + "cwd", + "likely_idle", + "retired", + ] { + assert!(s.get(key).is_some(), "missing golden field: {key}"); + } + // StalePid must serialize as the tagged `stale_pid` a consumer keys on. + assert_eq!(s["daemon"]["state"], "stale_pid"); + assert_eq!(s["daemon"]["pid"], 9); + } + + #[test] + fn read_relay_binding_parses_self() { + let dir = tempfile::tempdir().unwrap(); + let cw = dir.path().join("config").join("wire"); + fs::create_dir_all(&cw).unwrap(); + fs::write( + cw.join("relay.json"), + r#"{"self":{"relay_url":"https://wireup.net","slot_id":"abc123"},"peers":{}}"#, + ) + .unwrap(); + let (url, slot) = read_relay_binding(dir.path()); + assert_eq!(url.as_deref(), Some("https://wireup.net")); + assert_eq!(slot.as_deref(), Some("abc123")); + } + + #[test] + fn daemon_state_serializes_with_tag() { + let j = serde_json::to_value(DaemonState::Running { pid: 42 }).unwrap(); + assert_eq!(j["state"], "running"); + assert_eq!(j["pid"], 42); + let n = serde_json::to_value(DaemonState::None).unwrap(); + assert_eq!(n["state"], "none"); + } +} diff --git a/src/lib.rs b/src/lib.rs index b933604..38da840 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod cli; pub mod config; pub mod daemon_stream; pub mod daemon_supervisor; +pub mod dash; pub mod diag; pub mod enc; pub mod endpoints; @@ -49,6 +50,7 @@ pub mod probe; pub mod pull; pub mod relay_client; pub mod relay_server; +pub mod retire; pub mod same_machine; pub mod send; pub mod service; diff --git a/src/retire.rs b/src/retire.rs new file mode 100644 index 0000000..b876ab1 --- /dev/null +++ b/src/retire.rs @@ -0,0 +1,284 @@ +//! retire — decommission a wire identity you're done with, reversibly. +//! +//! The daemon supervisor keeps a daemon alive for *every* real identity (one +//! with a `private.key`) so it can still receive mail — so an idle throwaway +//! identity's daemon can't just be killed: the supervisor respawns it within +//! one poll. "Retiring" writes a durable `.retired` marker that makes the +//! supervisor treat the home as ineligible (it kills the child and never +//! respawns — see `daemon_supervisor::supervisor_eligible`), then stops the +//! running daemon directly. +//! +//! Reversible by construction: the marker is the ONLY state change. The home, +//! identity keypair, relay slot, and pull cursor are all kept, so `revive` +//! (remove the marker) brings the identity back intact and it drains any mail +//! that arrived while retired (relay slots never expire; mail is retained). +//! +//! `is_retired` is a pure existence check so a torn write can never flip an +//! identity back to "not retired". CLI-only: an agent must not retire another +//! identity's daemon unsupervised (mirrors `wire nuke`). + +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +pub const MARKER_SCHEMA: &str = "wire-retired-v1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RetiredMarker { + pub schema: String, + pub retired_at_unix: u64, + #[serde(default)] + pub reason: String, +} + +/// `/state/wire/retired.json`. +pub fn marker_path(home: &Path) -> PathBuf { + home.join("state").join("wire").join("retired.json") +} + +/// Pure existence check — never parses the body, so a partial write can't +/// read as "not retired". The supervisor eligibility filter keys on this. +pub fn is_retired(home: &Path) -> bool { + marker_path(home).exists() +} + +/// Best-effort read of the marker body for display. A parse error is treated +/// as retired-with-unknown-details (fail closed), never as not-retired. +pub fn read_marker(home: &Path) -> Option { + let bytes = std::fs::read(marker_path(home)).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn write_marker(home: &Path, reason: &str, now_unix: u64) -> Result<()> { + let dir = home.join("state").join("wire"); + std::fs::create_dir_all(&dir)?; + let m = RetiredMarker { + schema: MARKER_SCHEMA.to_string(), + retired_at_unix: now_unix, + reason: reason.to_string(), + }; + let tmp = dir.join("retired.json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(&m)?)?; + std::fs::rename(&tmp, marker_path(home))?; // atomic + Ok(()) +} + +fn remove_marker(home: &Path) -> Result<()> { + let p = marker_path(home); + if p.exists() { + std::fs::remove_file(&p)?; + } + Ok(()) +} + +/// Retire a session home: write the marker FIRST (so the supervisor won't +/// respawn), then stop its daemon via the injected `stop` fn. Returns the pid +/// that was stopped, if any. Idempotent — re-retiring just rewrites the marker. +/// +/// Marker-before-kill is load-bearing: reverse it and the supervisor can +/// respawn the daemon in the window between kill and marker. +pub fn retire_session(home: &Path, reason: &str, now_unix: u64, stop: F) -> Result> +where + F: Fn(u32) -> bool, +{ + write_marker(home, reason, now_unix)?; + let pid = crate::session::session_daemon_pid(home); + if let Some(p) = pid { + stop(p); + } + Ok(pid) +} + +/// Bring a retired identity back: remove the marker; the supervisor respawns +/// its daemon on the next poll. No-op if not retired. +pub fn revive_session(home: &Path) -> Result<()> { + remove_marker(home) +} + +/// Stop a daemon by pid, graceful then force. Mirrors the `wire upgrade` fix: +/// a bare SIGTERM / `taskkill /PID` (no `/F`) is a no-op for a headless daemon +/// on Windows, so escalate to SIGKILL / `/F` if it's still alive after a grace. +pub fn stop_daemon_graceful_then_force(pid: u32) -> bool { + crate::platform::kill_process(pid, false); + for _ in 0..10 { + if !crate::platform::process_alive(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + crate::platform::kill_process(pid, true); + for _ in 0..6 { + if !crate::platform::process_alive(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + !crate::platform::process_alive(pid) +} + +/// The session home THIS process resolves for itself (honoring WIRE_HOME / +/// session-key). Used to guarantee we never retire the current identity — +/// compared by canonical home path, since `resolve_session_key()` is `None` +/// on a bare terminal. `None` if it can't be resolved (caller must fail closed). +pub fn current_home() -> Option { + let cfg = crate::config::config_dir().ok()?; // /config/wire + let home = cfg.parent()?.parent()?; // + // Fail closed: only claim a home we can confirm is a real identity home + // (has `config/wire/private.key`). Under a bare terminal with no WIRE_HOME + // set, `config_dir()` is only one level deep (`dirs_config/wire`), so + // `parent().parent()` walks PAST the config root into an unrelated ancestor + // — the private.key check rejects that bogus path and returns None (the + // caller then fails closed) instead of a plausible-looking wrong home. + if !home + .join("config") + .join("wire") + .join("private.key") + .exists() + { + return None; + } + Some(std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf())) +} + +/// True iff `home` is the current process's own identity home. +pub fn is_current(home: &Path) -> bool { + match current_home() { + Some(cur) => { + let h = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()); + h == cur + } + None => false, + } +} + +/// True iff the home has any pending inbound pair request awaiting `wire accept` +/// (`state/wire/pending-inbound-pairs/*.json`). Such a home has 0 pinned peers +/// but is NOT idle — a peer is actively trying to reach it — so the bulk sweep +/// must never retire it. +pub fn has_pending_inbound(home: &Path) -> bool { + let dir = home + .join("state") + .join("wire") + .join("pending-inbound-pairs"); + match std::fs::read_dir(&dir) { + Ok(mut entries) => entries.any(|e| { + e.ok() + .and_then(|e| e.path().extension().map(|x| x == "json")) + .unwrap_or(false) + }), + Err(_) => false, + } +} + +/// Seconds since this identity was created — the mtime of +/// `config/wire/private.key`, written exactly once at keygen and never +/// rewritten. This is the honest "how old is this throwaway" signal: unlike +/// `daemon.pid` (which resets to now on every supervisor respawn) or +/// `last_sync.json` (which a running daemon refreshes every heartbeat), the +/// key's mtime tracks the identity's actual age, so a freshly-created sibling +/// session is never swept. `None` if no key (not a real identity). +pub fn identity_age_s(home: &Path) -> Option { + let p = home.join("config").join("wire").join("private.key"); + let mtime = std::fs::metadata(&p).ok()?.modified().ok()?; + mtime.elapsed().ok().map(|d| d.as_secs()) +} + +/// Resolve `` to exactly one local session, box-wide +/// over `list_sessions()`. Many idle homes never claimed a handle, so the key +/// (by-key dir name) and fingerprint are also accepted. Errors on zero or +/// multiple matches — never guesses. +pub fn resolve_target(arg: &str) -> Result { + let a = arg.trim(); + if a.is_empty() { + bail!("empty identity — pass a handle, fingerprint, or session key"); + } + let sessions = crate::session::list_sessions()?; + let matched: Vec = sessions + .into_iter() + .filter(|s| { + s.handle.as_deref() == Some(a) + || s.name == a + || s.did + .as_deref() + .and_then(crate::dash::fingerprint_from_did) + .as_deref() + == Some(a) + }) + .collect(); + match matched.len() { + 0 => bail!("no wire identity matches '{a}' (try a handle, fingerprint, or `wire dash`)"), + 1 => Ok(matched.into_iter().next().unwrap()), + n => { + let names: Vec = matched + .iter() + .map(|s| s.handle.clone().unwrap_or_else(|| s.name.clone())) + .collect(); + bail!( + "'{a}' is ambiguous — {n} identities match: {}", + names.join(", ") + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marker_roundtrip_and_is_retired() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + assert!(!is_retired(home)); + write_marker(home, "test", 1_700_000_000).unwrap(); + assert!(is_retired(home), "marker present ⇒ retired"); + let m = read_marker(home).unwrap(); + assert_eq!(m.schema, MARKER_SCHEMA); + assert_eq!(m.retired_at_unix, 1_700_000_000); + assert_eq!(m.reason, "test"); + remove_marker(home).unwrap(); + assert!(!is_retired(home), "marker removed ⇒ not retired"); + } + + #[test] + fn is_retired_is_pure_existence_not_content() { + // A garbage (unparseable) marker still reads as retired — fail closed. + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + std::fs::create_dir_all(home.join("state").join("wire")).unwrap(); + std::fs::write(marker_path(home), b"{ this is not json").unwrap(); + assert!( + is_retired(home), + "corrupt marker must still read as retired" + ); + assert!( + read_marker(home).is_none(), + "corrupt body → None, but still retired" + ); + } + + #[test] + fn retire_writes_marker_before_kill() { + // The stop closure asserts the marker already exists when it runs. + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().to_path_buf(); + // No daemon.pid → stop never called, but marker still written. + let called = std::cell::Cell::new(false); + let pid = retire_session(&home, "r", 1, |_p| { + called.set(true); + true + }) + .unwrap(); + assert_eq!(pid, None, "no pid file ⇒ nothing to stop"); + assert!(!called.get()); + assert!(is_retired(&home), "marker written even with no daemon"); + } + + #[test] + fn revive_is_noop_when_not_retired() { + let dir = tempfile::tempdir().unwrap(); + revive_session(dir.path()).unwrap(); + assert!(!is_retired(dir.path())); + } +} diff --git a/src/session.rs b/src/session.rs index 98cffce..93716fa 100644 --- a/src/session.rs +++ b/src/session.rs @@ -892,6 +892,87 @@ fn valid_session_key(v: &str) -> bool { !v.is_empty() && !v.contains("${") } +/// A session-identity SPLIT: the ENV-derived session key disagrees with the +/// PID-file-derived one (Claude Code's authoritative live session). This is the +/// "displayed name ≠ operational name" bug — usually a long-lived wire process +/// (an MCP server) frozen to a stale session key while the live Claude session +/// moved on (e.g. across a resume), so the process answers as the wrong +/// identity. Both handles are resolved for a human-readable warning. +#[derive(Debug, Clone)] +pub struct IdentitySplit { + pub env_source: &'static str, + pub env_handle: Option, + pub live_handle: Option, +} + +/// The session key from the ENV alone (first valid host var), independent of +/// the PID-file adapter. Mirrors the env portion of [`resolve_session_key`]. +fn session_key_from_env() -> Option<(String, &'static str)> { + for (var, source) in [ + ("WIRE_SESSION_ID", "override"), + ("CLAUDE_CODE_SESSION_ID", "claude-code"), + ("CODEX_SESSION_ID", "codex-cli"), + ("COPILOT_AGENT_SESSION_ID", "copilot-cli"), + ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"), + ] { + if let Ok(v) = std::env::var(var) + && valid_session_key(&v) + { + return Some((v.trim().to_string(), source)); + } + } + None +} + +/// Two session keys conflict iff they sanitize to different by-key homes. +fn keys_conflict(env_key: &str, live_key: &str) -> bool { + sanitize_name(env_key) != sanitize_name(live_key) +} + +fn handle_for_key(key: &str) -> Option { + let home = session_home_for_key(&sanitize_name(key)).ok()?; + let card = home.join("config").join("wire").join("agent-card.json"); + read_card_identity(&card).1 +} + +/// Detect a session-identity split (see [`IdentitySplit`]). `None` in the +/// healthy case — env and PID-file agree, or either is absent. The PID-file +/// adapter walks this process's parent chain to the owning `claude` process, so +/// it reflects the LIVE session; a disagreement means the env is stale. +pub fn detect_identity_split() -> Option { + let (env_key, env_source) = session_key_from_env()?; + let live_key = claude_code_session_from_pidfile()?; + if !keys_conflict(&env_key, &live_key) { + return None; + } + Some(IdentitySplit { + env_source, + env_handle: handle_for_key(&env_key), + live_handle: handle_for_key(&live_key), + }) +} + +#[cfg(test)] +mod split_tests { + use super::{keys_conflict, valid_session_key}; + + #[test] + fn keys_conflict_only_on_different_homes() { + assert!(!keys_conflict("abc-123", "abc-123"), "same key ⇒ no split"); + assert!(keys_conflict("abc-123", "xyz-999"), "different key ⇒ split"); + // sanitize collapses equivalent forms → no false split. + assert!(!keys_conflict(" abc ", "abc")); + } + + #[test] + fn unexpanded_template_key_is_invalid() { + // The ${...} literal that caused the historical collision stays rejected, + // so it can never be one side of a (false) split. + assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}")); + assert!(valid_session_key("real-session-id")); + } +} + /// Recover the Claude Code session id from the per-session PID-file when it /// isn't available via the environment. Claude Code writes /// `~/.claude/sessions/.json` = `{"sessionId": "...", "cwd": "...", ...}`