RegistryClient gains a with_perimeter() setter that captures both fields
from DaemonConfig and ships them in every Heartbeat. NAP-247 (broker T2)
will consume these on the registry side to populate AttachCandidates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new optional fields in [daemon]: perimeter_id (e.g. "wg-naps62") and
private_addr (e.g. "10.0.0.7:7070"). Env overrides via
MAESTRO_DAEMON_PERIMETER_ID and MAESTRO_DAEMON_PRIVATE_ADDR. Both flow
through DaemonConfig but the daemon doesn't act on them yet — heartbeat
wiring lands next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iss/sub/aud/sid/exp claims for the broker-mediated attach flow. "Pair-scoped"
means the credential identifies which home daemon (sub) may attach to which
target daemon (aud) for which session (sid) — a stolen token only works
between the two daemons it was issued for.
Existing JwtClaims stays as a compat path; NAP-243 wires the new claims into
the daemon's verifier and the registry's issuer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Convert DaemonOutbound::Heartbeat from unit to struct variant with two
optional fields advertising the daemon's T2 reachability. Old payloads
({"type":"Heartbeat"} with no fields) still parse — both fields default
to None — and a new daemon that doesn't populate them serializes back to
the same minimal shape.
Update the daemon's heartbeat ticker and the registry's match arm to the
new shape. Population from daemon config + registry persistence lands
with the broker logic in NAP-243.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the wire types for broker-mediated attach: PerimeterCandidate (T2 hint),
RelayCandidate (T3 marker, shape TBD via NAP-238), and AttachCandidates as
the registry's reply to ResolveAttach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared section is the implicit global scope, so its fields use
the bare `MAESTRO_<FIELD>` env-var spelling. Only `[daemon]` and
`[registry]` sections get section-prefixed env vars.
Renames:
- MAESTRO_SHARED_TOKEN → MAESTRO_TOKEN
- MAESTRO_SHARED_REGISTRY_URL → MAESTRO_REGISTRY_URL
`MAESTRO_DAEMON_*` and `MAESTRO_REGISTRY_BIND_ADDR` are unchanged.
This restores the canonical spec name (`MAESTRO_TOKEN`) that operators
already use in their deployment scripts — NAP-233 had renamed it to
`MAESTRO_SHARED_TOKEN`, which silently broke daemon auth against
existing registry deployments still setting `MAESTRO_TOKEN` in env.
Constants renamed alongside: `ENV_SHARED_TOKEN` → `ENV_TOKEN`,
`ENV_SHARED_REGISTRY_URL` → `ENV_REGISTRY_URL`. `EnvOverrides` fields
shed their `shared_` prefix to match.
Touches: daemon/cli/registry crates, e2e harness, design spec.
- Installer: check for a `[daemon]` section in the unified file (rather
than the existence of the now-defunct `~/.config/maestrod/config.toml`)
to decide whether to skip the configure prompt on re-runs. The
configure prompt + maestrod_config_path() helper now reference
`~/.config/maestro/config.toml`.
- docs/INSTALL.md: mention the unified file and the single-TOML shape.
- spec: update §Registry / §Daemon / §Auth to call out
`[registry].bind_addr`, `[daemon].bind_addr`, `[shared].registry_url`,
and `[shared].token` instead of the old per-app files.
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`.
Introduces the shared on-disk schema that all binaries (CLI, daemon,
registry) will load from `~/.config/maestro/config.toml`. Sections:
[shared] registry_url, token — consumed by 2+ binaries
[cli] cookie
[daemon] daemon_id, hostname, daemon_url, bind_addr, state_dir, worktree_root
[registry] bind_addr
Exposes:
- MaestroConfigFile { shared, cli, daemon, registry } with strict load,
load-or-default, atomic save, and per-section empty-table suppression.
- default_config_path() / resolve_config_path() honoring MAESTRO_CONFIG.
- derive_daemon_ws_url() for the daemon's scheme-swap + /daemon suffix
off the shared http(s):// base URL.
- ConfigError::MissingFile with a hard-cutover message pointing the user
at `maestrod configure` (legacy paths are no longer read).
Consumers wired in follow-up commits.
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).
When a session terminates (the user runs `exit`, tmux is killed, or
`maestro kill` is called) the daemon's per-session broadcast channel
closes and `serve_attach` breaks out of its loop. The socket was then
dropped without a Close frame, so the client's `stream.next()` would
hang and the terminal stayed stuck in raw mode.
- daemon: send `Message::Close(None)` before returning from
`serve_attach` whenever the session itself ended (broadcast closed,
or session was killed between auth and subscribe).
- client: track remote-close, restore the TTY first, then print
`[maestro] session ended` so the message lands in cooked mode.
- tests: cover the kill path and the liveness-watcher reap path on the
daemon side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the daemon-unit step, the wizard now offers a second user unit
(`maestro-web.service`) that runs `maestro-registry` on 127.0.0.1:8888
so the web UI is reachable in a browser locally.
Decisions:
- The unit runs `maestro-registry` directly — the web bundle is already
embedded in that binary, so a separate `maestro-web` binary would be
pure cost. `Description=` and the unit filename make the user-facing
framing clear.
- `Wants=maestrod.service` + `After=` (not `Requires=`): the registry
process doesn't actually need the daemon to start; the web UI just
has nothing to show without one. Soft dependency expresses that.
- Default loopback bind is `127.0.0.1:8888` to make it obvious which
port is the local UI vs a separately deployed registry on `:8080`.
Wizard surface:
- New prompt (default *no*) after the daemon-systemd step.
- New flags: `--no-web`, `--web-bind <addr>`, `--web-exec-start <path>`.
- Skipped automatically when systemd is absent or `--no-systemd` is set.
- The re-run/update path mirrors the daemon flow: refresh the unit,
daemon-reload, restart instead of start.
- `maestro-registry` is only `cargo install`ed when the user opts in,
keeping the no-web install footprint unchanged.
docs/INSTALL.md gets the new flags + a full sample unit body.
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).
The daemon never noticed when a session's command exited and tmux tore
the session down — the in-memory `SessionManager` map plus the registry
catalog kept showing it as `Running`, and a follow-up `maestro attach`
would hang on an empty broadcast.
Adds a per-host background task that polls `tmux list-sessions` every
2s, diffs against the in-memory set, and reaps anything we still track
but tmux has lost: drops the record, deletes the sidecar + FIFO,
cleans up its worktree if requested, and emits `SessionEvent::Removed`
so the registry purges its catalog.
The task holds a `Weak<Inner>` so it exits cleanly once the last
`SessionManager` clone is dropped. Skips the tmux subprocess when the
in-memory map is empty.
The wizard now treats existing state as "we're updating, not installing":
- If `~/.config/maestrod/config.toml` exists, skip the configure prompt with
a clear notice. Sets `configured = true` so the systemd start logic still
makes sense.
- If the systemd user unit already exists, refresh the file in place, skip
the enable prompt (it's either already enabled or the user said no last
time), and ask to `restart` instead of `start` — so the new binary
actually takes effect.
Adds `SystemctlAction::Restart` and a test for it. Same `./install.sh` works
for both first install and subsequent updates; no `--update` flag.
Refs NAP-225.
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.
The daemon's outbound registry client uses `connect_async`, which
errors with "TLS support not compiled in" against `wss://` URLs unless
the crate is built with a TLS feature. CLI got `rustls-tls-webpki-roots`
in NAP-216; daemon needs the same so it can talk to a TLS-terminated
registry like maestro-registry.n62.casa.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`configure::run` printed the "wrote" line whenever stdin was a TTY,
and `main::configure` printed it again unconditionally — so an
interactive run printed it twice. Library now stays silent; the binary
is the single owner of that confirmation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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. Add a node:20 stage to the registry Dockerfile that runs
`pnpm install && pnpm build` against `web/`, then copies the
resulting `dist/` into the Rust builder so rust-embed picks it up.
Single-binary deploy still — no separate static-asset volume.
`.dockerignore` keeps `web/node_modules` and `web/dist` out of the
build context (the node stage produces them fresh) while letting the
sources through.
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>
Lets `maestro-registry` run in container deployments (e.g. Dokploy)
without mounting a writable config volume.
- `MAESTRO_TOKEN`: when set, bypasses `registry.toml` entirely (no
read, no write). The container needs no persistent volume just to
seed the shared secret — pass it as a platform secret instead.
- `MAESTRO_REGISTRY_BIND_ADDR`: overrides the file's `bind_addr`.
Lets the container bind `0.0.0.0:$PORT` without rewriting TOML.
- New `load_config(path, env_token, env_bind_addr)` in `token.rs`
centralises the env > file > default precedence. Takes explicit
`Option`s so tests don't touch process-global env.
- `TokenError::InvalidBindAddr` carries the raw input + parse error
so misconfigured deploys log a useful diagnostic.
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>
`tokio::fs::File` routes every `.read()` through `spawn_blocking`. On
an idle FIFO opened r+w, that thread blocks in `read()` forever — and
when the test runtime tries to shut down, the blocking-pool thread is
stuck on the syscall and the test binary hangs (which is exactly what
just took out CI run 11).
Switch to `std::fs::File` opened `O_RDWR | O_NONBLOCK` wrapped in
`tokio::io::unix::AsyncFd`. Reads now drive off epoll readiness with
`try_io`; no blocking-pool threads, no hung shutdown.
`Drop` still aborts the task so per-test cleanup is clean.
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.
Introduces the maestro-protocol crate (NAP-211): the shared serde
types that the daemon, registry, and clients all serialize across
WebSocket boundaries. Covers:
- Session, SessionStatus
- CreateSessionRequest with optional WorktreeSpec
- RpcResult<T> reply envelope
- DaemonOutbound (Hello, snapshot, add/remove/update,
Heartbeat, request results)
- DaemonInbound (Ack, Error, CreateSession, KillSession)
- ClientControl text frames (Resize, Ping, Pong, Error)
Schema mirrors the wire-protocol tables in the design spec. Unit
tests round-trip every variant and assert tag/discriminator
stability so future renames break loudly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>