First-use UX fix: the TUI rendered the daemon list but felt frozen because
mode and focus were invisible, and the sidebar had no nav keys, so
`Enter`/`AttachHighlighted` had nothing to target.
- `render::draw` now reserves the bottom row for a status bar that always
shows `[NORMAL]`/`[INSERT]` (cyan/yellow chip), the focused panel
(sidebar / pane), and the most useful key hints (`j/k: nav • enter:
attach • s: spawn • ?: help • ^Wq: quit`). Status bar is never
eclipsed by overlays.
- `Sidebar::render_with_focus` and the pane block now draw their border
in cyan when focused, in the default colour otherwise.
- New `Action::SidebarNext` / `SidebarPrev`, bound to `j`/`k` and
`down`/`up` in the shipped Normal-mode defaults. Dispatch walks the
catalog's session list, calling `AppState::move_sidebar_highlight`
(wraps both ends).
Workspace clippy + 85 unit tests + 3 integration tests green.
`crates/maestro-e2e/tests/tui_integration.rs` adds three integration tests
that spin up a real registry + daemon via the existing maestro-e2e harness
and drive `CatalogStore` against the daemon's `/api/*` surface:
- `catalog_bootstrap_against_real_daemon` — WS connect + initial Snapshot
carries the self-daemon.
- `post_session_propagates_through_events_ws` — POST /api/sessions →
catalog WS surfaces SessionAdded within 2 s.
- `delete_session_propagates_removal` — DELETE /api/sessions/:id →
catalog WS surfaces SessionRemoved within 2 s.
These exercise the real wire protocol (subprocess + tmux + axum + tungstenite)
without going through the full ratatui frontend, complementing the unit-test
TestBackend assertions on widgets.
The full App-driven e2e (input injection feeding the dispatch loop, asserting
vt100 contents) would need `App::run` to be backend-generic; deferring as a
follow-up since the integration layer here already proves the wire protocol
round-trip the NAP-282 acceptance asked for.
Stitches the pieces from NAP-276..280 into a working app:
- `app.rs` — `App` owns config, catalog, emulators, state, modal, chord
resolver, region map, last-sent resize cache, and the reqwest client.
`run()` races crossterm events, catalog change notifies, and a 30 Hz
heartbeat. `redraw()` builds `RenderInputs`, calls `render::draw`,
refreshes `RegionMap`, then runs per-session min-size resize propagation
to the daemon.
- `dispatch(Action)` — Action → state mutation. `AttachHighlighted` spawns
a real `SessionTask` if none exists for the session. `OpenSpawnModal`
rebuilds the modal against the current daemon list. `KillHighlighted`
opens a confirm overlay.
- `handle_overlay_input` — intercepts keystrokes while spawn modal / kill
confirm / help is open. Spawn `Submit` fires `POST /api/sessions`.
Kill `y` fires `DELETE /api/sessions/:id` and drops the local handle.
- Insert-mode passthrough: characters (with ctrl/alt mapping), arrows
(CSI sequences), Enter/Tab/Backspace/Esc become binary bytes on the
active session's WS sink.
- `lib::run` — installs the panic hook, runs a 3×500ms `/api/web/info`
probe, loads `[tui]` from `MaestroConfigFile`, spawns
`CatalogStore::run_events_loop`, waits up to 2s for the bootstrap
Snapshot, enters the terminal guard, and drives `App::run`.
90 tests passing (5 new app dispatch unit tests + the existing widget +
catalog + session_task + emulators + pure-logic suites).
Adds the full visual layer:
- `state.rs` — `AppState` (tabs/focus/mode/sidebar visibility/overlay).
v1 keeps each tab to one optional session; the split tree is v1.5.
- `terminal.rs` — `TerminalGuard` (raw mode + alt screen + mouse capture
with RAII restore) and an idempotent panic hook that restores the
terminal before re-panicking.
- `sidebar.rs` — daemon → session tree with ●/○/✗ glyphs. Pulls a label
out of `metadata.label` (the protocol's shape), falls back to the
command.
- `tabs.rs` — top-row TabBar with active highlight.
- `pane.rs` — `vt100::Screen` → ratatui `Buffer` cell-by-cell, with full
fg/bg/bold/italic/underline/reverse translation. `PaneStatus` round-trips
from `SessionState`.
- `overlays.rs` — SpawnModal (5-field form with rotation), KillConfirm,
HelpOverlay, Toast. All centered popups with `Clear` underlay.
- `render.rs` — `draw(f, RenderInputs)` composes everything; returns a
`RegionMap` whose `region_at(col, row)` resolves a mouse coordinate to
a tagged Region (modal eclipses the rest).
22 new unit tests bringing the crate total to 80 passing. Tests use
content-based assertions over `ratatui::backend::TestBackend` output rather
than file-based goldens — less brittle to whitespace shifts while still
covering the visible content.
Per-attached-session task:
- Owns one WS to `WS /api/attach/:id` and a `vt100::Parser`.
- Binary frames → `parser.process(bytes)`. Text frames → `ClientControl::Ping`
is replied to as Pong; other text frames are ignored in v1.
- `SessionInput::Bytes(b)` becomes a binary frame; `Resize { cols, rows }`
resizes the parser AND sends `ClientControl::Resize` on the wire.
- WS drop → `Reconnecting` with exponential backoff (1s → 30s, ±25% jitter).
Parser screen is **not** reset across reconnects, so the user keeps seeing
the last-known frame instead of a blank.
- `SessionHandle::end()` transitions the task to `Ended` and stops it.
SessionEmulators:
- `(SessionId → SessionHandle)` registry.
- Owns a `ResizeTracker` whose `set_pane_size(sid, pane_id, (cols, rows))`
and `session_size(sid) -> Option<(cols, rows)>` compute the per-dimension
minimum across all panes bound to the same session — the value the
dispatcher feeds to the daemon.
Tests:
- 7 unit tests for `ResizeTracker` covering per-dim min, cross-session
isolation, and pane/session drop semantics.
- 2 unit tests for `SessionState` transitions.
- 1 axum-WS round-trip integration test asserting `"hello"` lands in the
vt100 screen within 500ms.
CatalogStore is the TUI's view of the daemons-and-sessions tree, backed by
`WS /api/events` on the local daemon. The daemon's WS opens with a Snapshot
frame, so that's bootstrap; subsequent frames are deltas. No separate REST
round-trip needed.
Surface:
- `apply_event` — pure, used by tests directly and by the WS loop.
- `snapshot()` — cheap O(d+s) read returning sorted Vec views + monotonic
version for cheap render staleness checks.
- `wait_for_initial_snapshot` / `changed` — render-loop wakeups.
- `run_events_loop(ws_url)` — long-running task with exponential backoff
(1s → 30s, ±25% jitter). Reconnect re-sends a fresh Snapshot, which
reconciles state without server-side replay.
8 unit tests + 1 axum WS round-trip integration test passing.
`TuiConfig::load(&TuiSection)` builds the runtime config:
1. Apply shipped default Normal-mode bindings (cover every v1 action).
2. Insert mode binds the leader → `LeaveInsert` (built from the leader spec
so rebinding leader moves the Insert escape automatically).
3. Apply user overrides from `[tui.keymap.normal]` / `[tui.keymap.insert]`
over the defaults, with the same validation path — bad TOML entries
surface as `BadKeymap { mode, input, action, source }`.
Also adjusts the existing `MaestroConfigFile` round-trip test to include the
new `tui` field.
8 new unit tests passing (49 total for tui + config).
New `crates/maestro-tui/` library behind a `tui` cargo feature on the umbrella
binary (default-on). Ships a clap-derive `TuiArgs` and a `run()` stub that
returns `NotImplemented` — exercises the full dispatch path so phases 2+ can
fill in the catalog, session liveness, widgets, and event loop without
touching scaffolding.
Acceptance:
- `cargo build -p maestro --features tui` succeeds
- `maestro tui` prints "maestro: not implemented" and exits 1
- `cargo test -p maestro-tui` passes (2 clap-parse tests)
- `cargo fmt --check` + `cargo clippy --all-targets --features tui -- -D warnings` clean
Phase 1 of the Maestro TUI epic (NAP-275); plan in
`docs/superpowers/plans/2026-05-28-maestro-tui.md`.
New `crates/maestro/` umbrella binary hosts the CLI, daemon, and registry
behind Cargo features `cli`, `daemon`, `registry` (default = all three).
Library crates keep their structure; per-binary `main.rs` files are removed
and their dispatch logic is exposed as public functions:
- `maestro_cli::dispatch(command, config, registry_url)`
- `maestro_daemon::cli::dispatch(config, cmd)` (with public `DaemonCmd`/`ConfigureArgs`)
- `maestro_registry::run()`
Subcommand surface:
- `maestro list / spawn / attach / kill / set-registry / policy` (unchanged)
- `maestro daemon [run|configure]` (was `maestrod`)
- `maestro registry` (was `maestro-registry`)
Hard cutover — old binary names are not preserved.
Verified: default-feature build, plus `--no-default-features --features {cli|daemon|registry}`
each build clean with `-D warnings`.
* New PairAttachVerifier (crates/maestro-daemon/src/jwt.rs): checks
signature, aud == self, exp > now, and sub ∈ allow_inbound. Default
deny — empty allow_inbound rejects every cross-daemon attach.
* /sessions/:id/stream tries the legacy verifier first, falls back to
the pair verifier on mismatch. Compat with existing direct-attach.
* New BrokerHandle (crates/maestro-daemon/src/broker.rs): shared between
the registry-client task and HTTP handlers; calls ResolveAttach over
the control link and correlates the reply via a pending map.
* RegistryClient now owns a BrokerHandle, drains its outbound mpsc
alongside session events / heartbeats, and routes
ResolveAttachResult back into the pending map. First heartbeat fires
immediately so the registry has the daemon's perimeter info before
the first ResolveAttach.
* /api/attach/🆔 self-owned → local PTY; peer-owned (per catalog
mirror) → ResolveAttach + T2 dial + bidirectional frame proxy.
Returns structured errors for missing local perimeter, peer on a
different perimeter, etc. T1/T3 stay stubbed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WebSection holds bind_addr, daemon_url, and dev_proxy_target — all optional
so a partial file is still valid. Bumps the workspace deps with include_dir,
mime_guess, bytes; adds `stream` to reqwest features for proxying bodies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New module crates/maestro-daemon/src/local_api.rs serving:
* GET /api/sessions — own sessions
* GET /api/daemons — self only (v1; peer mirror is NAP-246)
* GET /api/web/info — daemon identity (browser header data)
* POST /api/sessions — create on self; 501 for cross-daemon (NAP-247)
* DELETE /api/sessions/:id — kill on self; 404 for unknown
* WS /api/attach/:id — delegate to existing PTY subscribe when self-owned,
404 otherwise (cross-daemon broker is NAP-247)
The new routes share bind_addr with the legacy /sessions/:id/stream WS
endpoint, which keeps working unchanged. The shared serve loop is exposed
as attach::serve_local so the broker WS can reuse the PTY pump.
Trust model documented at the top of the module: no JWT, no cookie on
the new routes — same trust as tmux being one-user-wide. Operators who
bind a perimeter address opt into one-segment-wide trust.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Registry now reads `[shared].token` and `[registry].bind_addr` from the
unified file via maestro-config. First-run token generation does a
read-modify-write so `[shared]` / `[cli]` / `[daemon]` sections that
already exist on a co-located host stay intact.
Env-var renames:
- MAESTRO_TOKEN → MAESTRO_SHARED_TOKEN
- MAESTRO_REGISTRY_CONFIG → MAESTRO_CONFIG
`MAESTRO_REGISTRY_BIND_ADDR` is unchanged. The startup banner now points
the operator at `MAESTRO_SHARED_TOKEN` when announcing a freshly
generated token.
e2e harness updated to use the new env var; auth.rs doc-comment updated
to reflect the new env-var name.
Daemon now loads `[shared].registry_url`, `[shared].token`, and the
`[daemon]` section from the unified file via maestro-config. The
registry-WS endpoint is derived from `[shared].registry_url` (scheme
swap + `/daemon`), so operators configure a single base URL instead of
duplicating the daemon-specific endpoint.
Env-var renames (all per the new `MAESTRO_<SECTION>_<FIELD>` convention):
- MAESTROD_REGISTRY_URL → MAESTRO_SHARED_REGISTRY_URL (and the value is
now an http(s) base, not the ws daemon endpoint)
- MAESTROD_DAEMON_URL → MAESTRO_DAEMON_URL
- MAESTROD_DAEMON_ID → MAESTRO_DAEMON_ID
- MAESTROD_BIND_ADDR → MAESTRO_DAEMON_BIND_ADDR
- MAESTROD_CONFIG → MAESTRO_CONFIG
- MAESTRO_TOKEN → MAESTRO_SHARED_TOKEN
`maestrod configure` now writes the unified file with read-modify-write
so any existing `[cli]` / `[registry]` sections on the same host are
preserved. The interactive prompts reflect the new shape: registry URL
is asked for as a base URL, and the token prompt names the new env var.
Missing-file behaviour is hard cutover: if the config doesn't exist and
not every required field comes from env, the daemon errors with a
message pointing at `maestrod configure` and explicitly noting that the
legacy paths (~/.config/maestrod/config.toml) are no longer read.
e2e harness updated to match: `Daemon::spawn` takes the registry's HTTP
base, and the spawned subprocess sees the renamed env vars.
CLI now reads `[shared].registry_url` and `[cli].cookie` from the unified
file via maestro-config instead of `~/.config/maestro/cli.toml`. The
two persistence paths are now distinct:
- `maestro set-registry <url>` → writes `[shared].registry_url`
- captured Set-Cookie on first contact → writes `[cli].cookie`
Both go through read-modify-write so they preserve any `[daemon]` /
`[registry]` content already in the file.
Env-var renames on the CLI flag layer:
- `--registry-url` reads `MAESTRO_SHARED_REGISTRY_URL` (was `MAESTRO_REGISTRY_URL`)
- `--config` reads `MAESTRO_CONFIG` (was `MAESTRO_CLI_CONFIG`)
Default config path is now `~/.config/maestro/config.toml`.
Earlier today I picked option 1 from NAP-231's open question (run
`maestro-registry` locally on a different port). That was wrong: the
user-facing framing is "local web UI", and option 2 — a thin front-end
that serves the web bundle and reverse-proxies `/api/*` to whatever
registry the daemon is already registered with — is what actually
delivers that.
What the new crate does:
- `crates/maestro-web` ships a single binary `maestro-web`.
- It embeds the web bundle the same way `maestro-registry` does
(rust-embed against `web/dist/`).
- Mounts the bundle at `/` and a reverse proxy at `/api/*` (catch-all).
- HTTP proxy via `reqwest` — forwards method, headers (minus
hop-by-hop), body, and passes upstream response headers (including
`Set-Cookie`) back to the browser.
- WS proxy upgrades both sides and shuttles frames with
`tokio::select!`. Cookies on the initial `WebSocket` upgrade headers
are forwarded so the registry sees the same browser session.
- Reads `MAESTRO_REGISTRY` (upstream URL) and `MAESTRO_WEB_BIND_ADDR`
(default `127.0.0.1:8888`) from env.
- An integration test (`tests/proxy.rs`) stands up a mock upstream,
proxies an HTTP request with cookies through, and round-trips a text
WebSocket frame to verify the WS path.
Installer changes:
- Wizard now `cargo install`s `maestro-web` (not `maestro-registry`)
when the user opts into the web service.
- Unit template switched: `ExecStart=%h/.cargo/bin/maestro-web` plus
`Environment=MAESTRO_REGISTRY=<upstream>` +
`Environment=MAESTRO_WEB_BIND_ADDR=<bind>`.
- New `--web-registry <URL>` flag. Default in interactive mode: read
`registry_url` straight out of `~/.config/maestrod/config.toml`, so
the proxy lines up with what the daemon is using without further
prompting. Non-interactive runs without either of those error out
rather than guess.
Docs:
- `docs/INSTALL.md` updated unit body + flag table.
- `CLAUDE.md` repo-layout block now lists `maestro-web`.
148 tests pass workspace-wide (9 new in maestro-web, including the
proxy integration test; 15 in installer).
New `crates/maestro-e2e` exercises the full headless stack — registry,
two daemons, real HTTP+WS — as actual subprocesses.
Harness pieces:
- `Registry::spawn` and `Daemon::spawn` launch the real built binaries
with `env_clear()` plus an isolated `$HOME`, `XDG_CONFIG_HOME`,
`XDG_STATE_HOME`, and (for daemons) `TMUX_TMPDIR`. Drop guards kill
the child and clean the temp dirs.
- `Daemon::stop` + `Daemon::restart` swap the process without losing
the state dirs, so the recovery test exercises a real tmux
re-adoption.
- A `build::workspace_target_bin` helper re-invokes cargo from the
test process to build `maestrod` / `maestro-registry` on demand
(cargo only ships libs as path-dep of e2e by default).
- `TestClient` wraps reqwest + tokio-tungstenite for the public
HTTP+WS surface — list/spawn/kill/connect-info/attach + events WS.
Scenarios covered (`tests/e2e.rs`):
- list sessions from two daemons through one registry
- multi-attach: two WS clients see the same PTY output
- worktree with `cleanup_on_kill: true` is removed after kill
- daemon restart: catalog drops then repopulates from `recover()`
- concurrent spawns all get distinct ids
All five pass in ~0.7s in parallel; cleanup confirmed (no leftover
processes or temp dirs).
New `maestro-installer` crate exposing the `maestro-install` binary. Bootstrap
flow: `cargo install --git ... maestro-installer && maestro-install` runs
`cargo install` for `maestro-daemon` and `maestro-cli`, optionally drives
`maestrod configure`, and on systemd hosts writes a user unit at
`~/.config/systemd/user/maestrod.service` plus reload/enable/start. Pure
builders (cargo install args, systemctl args, unit body) live in the lib and
are unit-tested; main.rs is the prompt glue.
Refs NAP-225.
Closes NAP-224. Daemon onboarding now mirrors the registry's NAP-221 /
NAP-222 ergonomics: a fresh host needs three values, not eight.
Config (`crates/maestro-daemon/src/config.rs`):
- Split into `DaemonConfigFile` (on-disk, every field `Option<>`) and
`DaemonConfig` (resolved, all fields present).
- `load_config(path, EnvOverrides)` resolves env > file > default and
returns clear `Missing { field, env, path }` errors for the three
fields that genuinely require a human: `registry_url`, `daemon_url`,
`token`.
- Defaults: `bind_addr = 127.0.0.1:7070`; `state_dir` /
`worktree_root` from XDG; `hostname` from `gethostname(2)`;
`daemon_id` defaults to `hostname`.
- Treats a missing config file as empty so an env-only deploy works.
- Atomic file save (tmp + rename) for `configure` to share.
Env vars (mirrors NAP-222):
- `MAESTRO_TOKEN`
- `MAESTROD_REGISTRY_URL`, `MAESTROD_BIND_ADDR`,
`MAESTROD_DAEMON_URL`, `MAESTROD_DAEMON_ID`
- `MAESTROD_CONFIG` for the file path
`maestrod configure` (`configure.rs` + clap in `main.rs`):
- Interactive bootstrap when stdin is a TTY: prompts for the three
required fields, defaults the rest.
- Non-interactive mode (`--non-interactive` or non-TTY stdin) takes
values from flags only; errors out listing the first missing one.
- Refuses to overwrite an existing config without `--force`.
- Writes via the same atomic-save path used by config-file edits.
`maestrod` grew a clap CLI: `run` (default) and `configure` subcommands,
both honour `--config` / `$MAESTROD_CONFIG`.
Verified end-to-end:
- `maestrod configure --non-interactive --registry-url ... --daemon-url
... --token ...` writes a valid file and exits 0.
- `--non-interactive` without all required flags errors with the
missing field name.
- Existing file + no `--force` is rejected and the file is untouched.
- Workspace `cargo test`: 115 passed (was 97; +18 new daemon tests
across config + configure).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NAP-218. The registry's `GET /` now serves the built web bundle from
`web/dist/` (embedded into the binary at compile time via rust-embed)
instead of the NAP-215 placeholder, and `GET /assets/*` serves the
hashed JS/CSS chunks Vite produces.
When `web/dist` is empty (fresh checkout without `pnpm build`), a
small fallback page tells the operator to run the JS build. That
keeps `cargo run -p maestro-registry` usable without a node
toolchain installed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes NAP-216. Thin client for the registry HTTP API + per-session
daemon WebSocket. Becomes the headless test harness for NAP-217 and
unblocks the demo-by-script path before the web client lands (NAP-218).
Crate layout (`crates/maestro-cli/`):
- `cli.rs` — clap-derive parser. Commands: `list`, `spawn`, `attach`,
`kill`, `set-registry`. Global `--registry-url` (env
`MAESTRO_REGISTRY_URL`), `--config`, repeatable `-v`.
- `config.rs` — `CliConfig` persisted at `~/.config/maestro/cli.toml`,
storing the default registry URL + the session cookie issued by the
registry's cookie middleware.
- `http.rs` — `RegistryClient` wrapping `reqwest`. Attaches the
persisted cookie, captures `Set-Cookie` responses (first-contact
bootstrap and rotation), atomically writes the new value back to
the config file.
- `sessions.rs` — `list/spawn/kill` + connect-info fetch. `list` prints
a per-daemon grouped table; `--json` for scripting.
- `attach.rs` — raw-TTY WS proxy. Termios cfmakeraw with an RAII guard
for restore-on-drop, SIGWINCH → `ClientControl::Resize` text frame,
configurable detach key (default Ctrl-q; accepts `^X` form), echoes
`Ping`/`Pong` for keepalive symmetry with the daemon side.
Workspace deps added: clap (derive+env), reqwest (rustls+cookies),
nix (term+signal).
Smoke-tested against the live registry at maestro-registry.n62.casa:
`maestro list` issued + persisted the cookie and printed the empty
catalog cleanly. 19 new unit tests (parser, config round-trip,
table-printing, cookie extraction, attach URL/detach-key parsing).
Full workspace `cargo test`: 97 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes NAP-221. Adds the `maestro-registry` bin so the registry can be
spawned as a child process (required by the NAP-217 E2E harness) and
run as a long-lived service.
- New `crates/maestro-registry/src/main.rs` with a `#[tokio::main]`
entry point: loads config via `ensure_token` (override path via
`MAESTRO_REGISTRY_CONFIG`), binds the configured `bind_addr`, serves
the production router, and shuts down cleanly on Ctrl-C. Mirrors the
`maestrod` wiring (EnvFilter tracing, ExitCode reporting).
- `[lib]` + `[[bin]]` sections in `crates/maestro-registry/Cargo.toml`
so the crate exposes both surfaces (same pattern as `maestro-daemon`).
- `RegistryConfig` gains a `bind_addr: SocketAddr` field with
`serde(default)` to default to `127.0.0.1:8080` per spec §Registry.
Existing TOML files without the field still load.
- `default_config_path` re-exported from the crate root for the bin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the client-facing surface on the registry (NAP-215). Endpoints:
- GET /api/daemons — list connected daemons (with daemon_url)
- GET /api/sessions — flat session catalog
- POST /api/sessions — forwards CreateSession to chosen daemon, 504 on
10s daemon timeout
- DELETE /api/sessions/:id — forwards KillSession
- GET /api/sessions/:id/connect-info — returns {daemon_url, jwt} with a
5min HS256-signed attach JWT
- WS /api/events — snapshot + delta broadcast of catalog and presence
- GET / placeholder served until NAP-218 web bundle lands
Cookie auth middleware mints a long-lived httpOnly JWT cookie on first
request and reissues silently on missing/tampered cookies. Per spec the
perimeter is the operator's network — the cookie is a second factor.
Protocol additions: daemon_url on Hello, DaemonInfo, ConnectInfo,
RegistryEvent. Daemon config + RegistryClient now carry daemon_url.
Registry state now exposes an event broadcast channel (subscribe gives
a snapshot + tokio::sync::broadcast::Receiver) and helper lookups
daemon_for_session / daemon_url.
Tests: 11 reqwest-driven HTTP integration tests cover every endpoint,
the cookie flow, 504 timeout path, worktree forwarding, and events WS
propagation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the headless daemon stack for NAP-214.
- `config`: daemon TOML config (daemon_id, registry_url, bind_addr,
token, state_dir, worktree_root) with sensible XDG defaults.
- `jwt`: HS256 verifier; the daemon validates per-attach JWTs locally
with no callback to the registry. Also exposes `issue()` for tests.
- `pty::PtyBridge`: per-session FIFO + tokio broadcast pump. The FIFO is
opened r+w so it never sees zero writers — stays alive across
`pipe-pane` re-issues. Helper `pipe_pane_command()` builds the
`cat >> <fifo>` shell line.
- `session::SessionManager`: authoritative live-session store. Creates
worktrees (when requested), spawns tmux, mkfifo + pipe-pane, writes
sidecar; tears it all down on kill. Emits diff events on a
broadcast for the registry client. `recover()` adopts surviving
`maestro-*` tmux sessions on startup and re-pipes them.
- `registry_client::RegistryClient`: persistent outbound WS to the
registry with exponential backoff. Sends Hello → snapshot, drives a
select loop that pumps inbound CreateSession/KillSession RPCs into
the manager (replying with results), emits SessionAdded/Removed/
Updated diffs from the manager, and heartbeats every 10s.
- `attach`: axum router mounting `WS /sessions/:id/stream?token=<jwt>`.
Binary frames carry PTY bytes either way (subscribe to broadcast →
send to client; receive from client → tmux send-keys -l). Text
frames carry `ClientControl::{Resize,Ping,Pong}`.
- `bin/maestrod`: ties everything together, loads config from
`MAESTROD_CONFIG` env or `~/.config/maestrod/config.toml`,
recovers, spawns the registry client, binds the WS server.
Also adds `JwtClaims` to `maestro-protocol` so the daemon and the
future registry JWT issuer (NAP-215) agree on the wire shape.
Integration tests cover session manager round-trip + recovery + PTY
broadcast; attach endpoint JWT (bad/wrong-session/wrong-daemon),
PTY round-trip through real tmux, multi-attach broadcast, ping/pong;
registry client registration + snapshot + diff forwarding +
CreateSession RPC round-trip.
Closes NAP-214.
Registry-side primitives for the daemon↔registry conversation. No
client-facing HTTP yet (that's NAP-215).
- `state::Registry`: in-memory store of connected daemons + a session
catalog keyed by (daemon_id, session_id). Tracks pending CreateSession
/ KillSession RPCs so a single daemon connection can multiplex them.
- `handler::handle_socket`: per-connection loop that authenticates the
Hello, applies snapshot/diff/heartbeat frames, and resolves matching
*Result frames. Heartbeat watchdog is a `tokio::time::timeout` around
the read side — no separate janitor task.
- `server::router_with_timeout`: axum router mounting `WS /daemon`. The
timeout knob lets tests verify the 30s drop path in milliseconds.
- `token::ensure_token`: first-run bootstrap that generates a 256-bit
hex token, prints it once to stderr, and persists it to
`~/.config/maestro/registry.toml`.
- Outbound `create_session` / `kill_session` exposed as the internal
API NAP-215's HTTP handlers will call.
Integration tests use a real axum server bound to `:0` and drive it via
`tokio-tungstenite`, covering: bad-token reject, snapshot accept, diff
apply (add/update/remove), heartbeat timeout dropping the daemon,
re-registration replacing the catalog, and a full CreateSession RPC
round-trip.
Closes NAP-213.
Local primitives for `maestrod`, no network code yet:
- `tmux`: subprocess wrapper around new-session, kill-session,
list-sessions (filtered to maestro-*), send-keys -l, pipe-pane -o,
resize-window, kill-server.
- `sidecar`: atomic JSON metadata store keyed by session UUID, with
load_all for daemon restart recovery.
- `worktree`: `git worktree add -b` with collision-proof generated paths
and `git worktree remove --force` for cleanup_on_kill.
Integration tests use real tmux (isolated via unique socket + temp
TMUX_TMPDIR) and real git (tmpdir repo).
Closes NAP-212.
Adds the empty Cargo workspace (resolver = 2, members = crates/*),
a stable rust-toolchain pin with rustfmt + clippy, and a Gitea
Actions pipeline that runs cargo fmt --check, clippy -D warnings,
and cargo test on every push/PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>