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>