Files
Jules Lasne 945e431a64 Cockpit in the TUI (native ratatui view + CLI verbs + cross-machine) (#1114)
* refactor(cockpit): extract wire types into protocol.rs; fix replay lost semantics

Foundation commit for the TUI cockpit view (issue #1018). Moves the
HTTP / WebSocket wire types out of the server module so daemon, web
frontend, CLI cockpit verbs, and the upcoming TUI cockpit view all
import a single source of truth. A rename in one place now breaks
every consumer at compile time instead of going silently divergent.

Also fixes the hardcoded `lost: false` in /cockpit/replay: the event
store now exposes lowest_seq(), and the endpoint reports
lost = since < lowest_seq - 1 so a client returning after a long
absence learns its history was truncated and can reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cockpit): add daemon client (HTTP + WS + discovery + auto-spawn)

Lays the substrate for the CLI cockpit verbs and the TUI cockpit view
to come. Talks to an `aoe serve` daemon over the existing per-session
cockpit REST + WebSocket surface; one shared `DaemonEndpoint` type
backs both layers and the wire-type re-use from `cockpit::protocol`
guarantees client and server can't drift on JSON shape.

- discovery: AOE_DAEMON_URL env override first, then local serve.url
  paired with a live serve.pid. Loopback alternates preferred over
  tunnel addresses for same-box clients. Token extracted to a separate
  field so it travels as `Authorization: Bearer` (HTTP) or `?token=`
  (WS) but never gets logged.
- http: typed client per cockpit verb (replay, context_primer, prompt,
  cancel, resolve_approval) mapped 1:1 to the per-session REST routes.
  401 -> Unauthorized, 403 read-only -> ReadOnly, 404 -> SessionNotFound
  for clean error UX.
- ws: tokio-tungstenite stream parsing CockpitBroadcastFrame and the
  `{"kind":"lagged"}` sentinel, plus a shutdown channel and url
  sanitiser so the token is never logged.
- daemon_manager: ensure_daemon() that auto-spawns a loopback-only
  long-lived `aoe serve` if neither AOE_DAEMON_URL nor a live local
  daemon are found. AOE_DAEMON_URL set -> never auto-spawn, fail loud.

tokio-tungstenite added as an optional dep folded into the `serve`
feature alongside the rest of the cockpit surface. Both `cockpit/`
and `server/` are already serve-gated, so keeping the client there is
the smaller change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): aoe cockpit {history,status,prompt,approve,cancel,tail}

Wires the daemon client into actual user-facing CLI verbs so the
client surface added in 51ac75f doesn't sit dead. Auto-spawns a
loopback daemon on first invocation, then talks to it over HTTP/WS:

- history <id> [--since N] [--json]: dump persisted transcript.
- status <id> [--json]: highest/lowest seq + lost flag + daemon source.
- prompt <id> <text|->: send a prompt (`-` reads from stdin).
- approve <id> <nonce> [--always|--deny]: resolve a pending approval.
- cancel <id>: cancel the in-flight prompt.
- tail <id> [--since N]: stream broadcast frames to stdout as JSON.

ReplayResponse gains a `lowest_seq: Option<u64>` field so `status` can
show the retention floor. Default-`None` on the `#[serde(default)]`
attribute keeps the wire shape backwards-compatible with any client
that pinned the old shape (frontend uses serde_json, treats unknown
fields as harmless).

`attach` verb deferred to commit 4 alongside the TUI cockpit view it
opens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tui): native cockpit view

Renders a cockpit session inside the TUI instead of toasting users
toward the web dashboard. Mirrors the activity semantics of
web/src/hooks/useCockpit.ts in a flat-row Rust reducer; talks to the
local daemon via the cockpit::client added in 51ac75f.

- src/tui/cockpit_view/reducer.rs: pure CockpitBroadcastFrame ->
  ActivityRow accumulator. Groups consecutive AgentMessageChunk events
  into one assistant row; flushes the buffer on any non-chunk event so
  intervening tool calls don't bleed into the wrong message turn.
  Mutates tool rows in place when ToolCallCompleted lands; resolves
  approvals by nonce lookup. Dedupes against duplicate seqs from the
  replay-vs-live overlap.
- src/tui/cockpit_view/input.rs: focus model with three regions
  (composer / transcript / approval). Composer captures every key
  including `a`/`A`/`d`, so typing "always allow" with a pending
  approval can never silently resolve it (regression test pins this).
- src/tui/cockpit_view/render.rs: three-pane layout (transcript /
  status banner / composer). Tool cards render as one-liners with
  truncated args + content preview; rich diff / image previews are
  deferred to followups, accessed via `o` to open the web view.
- src/tui/cockpit_view/state.rs: owned view state (transcript,
  composer textarea, focus, ws handle, toast banner).
- src/tui/cockpit_view/mod.rs: async orchestrator. Calls
  ensure_daemon, hydrates via /replay, opens WS, runs a tokio::select!
  over key events + ws frames + a redraw ticker, and reconnects on
  ws drop using the last seq.
- src/tui/app.rs: new `Action::OpenCockpit(SessionId)` variant
  (serve-feature-gated) plus `pending_cockpit_open` slot the sync
  action handler uses to hand control back to the async loop, which
  then borrows event_stream + terminal for the cockpit run.
- src/tui/home/input.rs: replace the "open the web dashboard" toast
  with Action::OpenCockpit when serve is built in; keep the toast as
  fallback for non-serve builds.
- src/tui/home/render.rs: rename `[web]` badge to `[cockpit]` now
  that the TUI renders the session natively.
- src/cockpit/protocol.rs: derive PartialEq+Eq on
  ApprovalDecisionWire so the input dispatcher's Intent enum can
  derive PartialEq.

Help-screen keybinds, the `aoe cockpit attach` CLI verb, and the
focus-isolation e2e regression are deferred to a follow-up commit so
this diff stays scoped to "wire up the view."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): add aoe cockpit attach <id>

Jumps straight into the TUI cockpit view for a known session id
without going through the home screen. The intended pairing is
`AOE_DAEMON_URL=https://remote.tld AOE_DAEMON_TOKEN=... aoe cockpit
attach <id>`: attach to a remote cockpit session from a developer
machine that doesn't have a local serve daemon (and shouldn't auto-
spawn one — `ensure_daemon` short-circuits under the env override).

cockpit_view::run_standalone takes care of the alternate-screen
terminal setup that the parent TUI normally owns: enable_raw_mode,
EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture; then
the shared `run()` loop drives the view; then the same teardown
sequence in reverse. Uses the empire theme since standalone attach
doesn't load the home view's saved theme preference.

Help-screen keybinds for the cockpit view are intentionally NOT added
to the home help overlay — they only apply while the cockpit view is
focused, and the home dialog is already at 41/42 lines of available
height. The cockpit view's status banner already surfaces a
focus-specific help hint inline, and docs/cockpit.md (commit 7) gets
the full table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tui): cross-machine remote-cockpit picker

Closes the cross-machine half of #1018. When `AOE_DAEMON_URL` is set
(via env var or the new `--daemon-url` flag on `aoe`), the TUI swaps
the local home view for a remote-cockpit picker that talks to the
named daemon via HTTPS instead of reading the local FS.

The remote view is intentionally separate from `HomeView` rather than
a `SessionSource`-trait abstraction over it: HomeView is deeply
local-coupled (tmux PTYs, on-disk Storage, container/host terminal
mode, per-session profile management), and the remote surface only
needs the small slice of "list cockpit sessions, open one, refresh."
Trying to share the same struct would push tmux abstractions through
a layer that has no notion of tmux. Two scoped surfaces are simpler
than one over-generalised one.

What the remote picker does:

- Calls `GET /api/sessions` against the daemon, filters to
  `cockpit_mode = true` (tmux PTYs aren't reachable cross-machine
  without SSH).
- Renders a selectable list (j/k navigate, Enter opens, r refreshes,
  q/Esc exits).
- On Enter, hands off to `cockpit_view::run_for_endpoint`, a new
  variant of the cockpit run loop that takes a pre-discovered
  endpoint instead of auto-spawning. The user explicitly chose a
  remote daemon; silently auto-spawning a local one would attach to
  the wrong universe.

Local-only operations (tmux attach, edit file, session stop, file
diff) are absent rather than disabled: they aren't reachable on this
machine, so showing them grayed out would mislead. The web dashboard
remains the long-tail surface for remote management.

`--daemon-url` is wired as a clap arg with `env = "AOE_DAEMON_URL"`,
so the flag and env var are unified. When the flag is passed, main.rs
mirrors the value back into the env so the same discovery code path
the CLI cockpit verbs use also resolves it.

The full `SessionSource` trait abstraction over `HomeView` from the
original 4-PR plan is intentionally deferred: the remote picker
already meets the acceptance criteria ("AOE_DAEMON_URL=... aoe opens
a TUI that shows the remote session list, all cockpit operations
work transparently"), and the broader refactor would touch every
HomeView call site without much functional gain. Tracked as a
followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cockpit): document TUI cockpit view + CLI verbs + cross-machine

Replaces the "cockpit is web-dashboard only" framing in docs/cockpit.md
with the actual TUI surface, the keybind table (composer / transcript /
approval focus model, plus the focus-isolation guarantee), the
cross-machine attach workflow via AOE_DAEMON_URL or `aoe cockpit
attach`, and the full CLI verb catalogue.

Regenerates docs/cli/reference.md via `cargo xtask gen-docs` so the
new clap surface (`aoe --daemon-url`, the cockpit verbs, the attach
flag) is pulled into the canonical reference (CI enforces this stays
in sync).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(serve): add `aoe serve --status` for daemon introspection

Prints PID, mode, primary + alternate URLs, and log path when the
daemon is up; exits non-zero with an actionable hint otherwise.
Mirrors `aoe url`'s patterns (bail-when-down, no token redaction)
since both run under the same local-only security boundary.

Wired through clap with `conflicts_with_all = ["stop","daemon","remote"]`
so misuse fails early.

* feat(serve): clearer error when daemon already running

Previously: "Stop it first with `aoe serve --stop`." which hid the
fact that the existing daemon is probably what the user wants. Now
suggests `--status`, `aoe url`, and `--stop` so the discovery path
is obvious without `--help` or docs.

* feat(client): pre-flight health-check + env-aware `aoe serve --status`

Two fixes for AOE_DAEMON_URL UX:

1. `ensure_daemon` now health-checks the env override endpoint
   before returning, so `aoe cockpit *` against an unreachable URL
   fails loud ("AOE_DAEMON_URL is set but the daemon at that URL is
   unreachable") instead of bubbling up a raw reqwest transport
   stack from each verb.

2. `aoe serve --status` now follows AOE_DAEMON_URL: when set, it
   pings the remote endpoint and reports reachability + token state
   instead of inspecting the local `serve.pid` file. Same friendly
   error path when the remote is down.

Adds `HttpClient::health_check()` (GET /api/sessions) for both. The
endpoint is cheap, authenticated, and separates "host down"
(transport error) from "auth misconfigured" (401) at the call site.

Doc note in cockpit.md spelling out the retarget so users can
predict the behavior without reading the source.

* fix(cockpit-view): auto-scroll past wrapped agent-message chunks

Transcript scroll was clamped against `lines.len()` (logical line
count). Streaming AgentMessageChunk events grow text *within* a
single Line, which the Paragraph then wraps to multiple visual
rows — the logical count stayed constant, so
`scroll_offset = u16::MAX` (stick-to-bottom) clipped short of the
newest chunk. Tool calls didn't show the bug because each call
pushes whole new Line entries.

Switch to `visual_line_count(lines, width)`: per-line display width
divided by available columns, rounded up, summed. Approximate (no
account for tabs/control chars) but accurate for the streaming-text
case that matters. Unit-pinned with a regression test.

* fix(cockpit): address PR review (UTF-8 panic, em-dashes, error fidelity)

- render: replace byte-slice truncation with char-safe truncate_chars so
  tool args/content with multi-byte codepoints at the cutoff don't panic
  the TUI. Regression tests pin the boundary cases.
- client/ws: real WsError::Parse variant instead of fabricating an
  InvalidOpcode protocol error; the toast now carries the actual reason.
- client/daemon_manager: distinguish EnvOverrideUnauthorized from
  EnvOverrideUnreachable so a wrong AOE_DAEMON_TOKEN shows the right
  message; cockpit_view renders both. NoExecutable no longer absorbs
  log-file IO failures (new LogFile variant).
- style: sweep em-dashes from PR-added Rust comments, status-banner
  string literals, and docs/cockpit.md per project rule. xtask post-
  processes clap_markdown's hard-coded em-dash bullet separator so
  docs/cli/reference.md regenerates without them and future CLI flags
  inherit the rule automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cockpit): apply remaining PR review polish

- cli/serve: --status now conflicts with --no-auth, --read-only,
  --passphrase, --port, --tunnel-name, --no-tailscale, --tunnel-url,
  --open in addition to --stop/--daemon/--remote, so a misuse like
  `aoe serve --status --port 9000` fails fast instead of silently
  accepting the extras.
- client/ws: WsHandle::shutdown awaits the reader task's graceful close
  with a 200ms budget before falling back to abort(), so a Close frame
  actually reaches the daemon on clean exits.
- client/http: list_sessions<T>() reuses the shared auth/header
  plumbing for GET /api/sessions. The remote-home picker drops its
  bespoke reqwest::Client and goes through HttpClient like every other
  cockpit verb does.
- cockpit_view: reconnect_with_backoff replaces the one-shot WS
  reconnect with 250/500/1000ms attempts so a 2-second daemon bounce
  recovers without paging the user.
- cockpit_view: composer height magic numbers become
  COMPOSER_BORDER_ROWS + COMPOSER_MAX_CONTENT_ROWS.
- cockpit_view/reducer: tracing::debug! when a frame is dropped against
  last_seq, so a true reordering shows up in logs without spamming on
  the normal replay/live overlap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cockpit): require a running daemon; remove auto-spawn

Opening a cockpit session from the TUI (or running any aoe cockpit
verb) used to auto-spawn a loopback aoe serve in the background if
one wasn't already running. That hid the choice between localhost,
Tailscale Funnel, and Cloudflare tunnel from the user, and left an
aoe serve process behind that they didn't ask for.

Now require_daemon() (renamed from ensure_daemon) returns
ManagerError::NoDaemonRunning with a multi-line actionable hint
pointing at:

  aoe serve --daemon                 (localhost only)
  aoe serve --daemon --remote        (Tailscale Funnel / Cloudflare)
  aoe serve --daemon --tunnel-name … (named Cloudflare Tunnel)

…and AOE_DAEMON_URL for attaching to an existing remote daemon.

The TUI renders the message in the cockpit error screen; the CLI
verbs print it to stderr and exit non-zero. setsid()-detached
spawn, deadline polling, log file management, and the related error
variants (NoExecutable, LogFile, SpawnFailedFast, SpawnTimeout) are
all gone. docs/cockpit.md updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): regen CLI docs, allow webpki-roots license, drop em-dash from xtask comment

Three small fixes for the CI failures on PR #1114:

- Docs check: the multi-line `--status` doc I added in cb95fbb wasn't
  reflected in docs/cli/reference.md (forgot to re-run `cargo xtask
  gen-docs`). Regenerated. Also dropped the now-stale "cockpit
  auto-spawn flow" phrase from the help text since auto-spawn was
  removed in 3dc14ef.
- Supply Chain: tokio-tungstenite's `rustls-tls-webpki-roots` feature
  pulls in `webpki-roots` (CDLA-Permissive-2.0). deny.toml already
  whitelists CDLA-Permissive-2.0 for the equivalent `webpki-root-certs`
  crate that reqwest uses; extend the same exception to `webpki-roots`.
- xtask: the comment explaining the em-dash strip itself contained a
  literal em-dash, contradicting its own rule. Rewrote with a Unicode
  escape so the source file has zero literal em-dash code points and
  the strip target is described unambiguously.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(xtask): drop em-dash strip in gen-docs

The project's no-em-dash rule is about human-authored docs and
comments, where the writer should have picked a comma or semicolon.
clap-markdown's bullet separator is a renderer choice applied
uniformly to every entry; there's no human author to nudge, and the
strip created a layer of indirection in xtask that future
contributors would have had to remember.

Reverts the strip introduced earlier in this branch; docs/cli/reference.md
goes back to clap-markdown's native output (209 em-dashes restored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): note em-dash exception for clap-generated CLI reference

The no-em-dash rule is about human-authored docs and comments.
docs/cli/reference.md is generated by `cargo xtask gen-docs` and
inherits clap-markdown's em-dash bullet separator uniformly; that's
a renderer choice, not prose, so leave it alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): generalize em-dash exception to all auto-generated content

Carving out a single file (docs/cli/reference.md) was too narrow:
the underlying principle is "human-authored prose only." Rephrase
so future auto-generated docs (changelogs, API refs, schemas) are
covered without needing another edit here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): trim the em-dash exception example

The rule stands on its own without the reference.md example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: njbrake <nathan@mozilla.ai>
Co-authored-by: Nathan Brake <33383515+njbrake@users.noreply.github.com>
2026-05-14 20:46:26 -04:00

64 lines
1.5 KiB
TOML

# https://embarkstudios.github.io/cargo-deny/
[graph]
all-features = false
no-default-features = false
# Vulnerability database checks
[advisories]
# Deny any crate with a known security vulnerability
version = 2
ignore = []
# License policy
[licenses]
confidence-threshold = 0.8
allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CC0-1.0",
"ISC",
"MIT",
"MIT-0",
"MPL-2.0",
"Unicode-3.0",
"Unlicense",
"Zlib",
]
exceptions = [
# UEFI-only crate, not linked into the final binary on Linux/macOS
{ allow = ["LGPL-2.1-or-later"], crate = "r-efi" },
# CDLA-Permissive-2.0 is a permissive data license. Both crates
# ship Mozilla's CA bundle under that license; the project tree
# ends up with whichever one the dependency happens to pull in
# (`reqwest` uses `webpki-root-certs`; `tokio-tungstenite` uses
# `webpki-roots`).
{ allow = ["CDLA-Permissive-2.0"], crate = "webpki-root-certs" },
{ allow = ["CDLA-Permissive-2.0"], crate = "webpki-roots" },
]
[licenses.private]
ignore = true
# Crate bans
[bans]
multiple-versions = "warn"
wildcards = "allow"
highlight = "all"
workspace-default-features = "allow"
external-default-features = "allow"
allow = []
deny = []
skip = []
skip-tree = []
# Source provenance
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []