Commit Graph
36 Commits
Author SHA1 Message Date
naps62-yoloandClaude Opus 4.8 3517e4e145 fix(maestro): recover() re-advertises sessions under current daemon_id (NAP-264)
ci / rust (push) Successful in 6m46s
`SessionManager::recover()` re-inserted each sidecar's `Session` verbatim,
keeping the persisted `daemon_id`. If the operator renamed the daemon between
starts, recovered sessions were re-advertised under the old id and became
unroutable. Overwrite `daemon_id` with the daemon's current id on recover,
warning when the sidecar's id differs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:51:18 +00:00
naps62-yoloandClaude Opus 4.8 8b4cdfd650 fix(maestro): tolerate unknown WS frame types instead of dropping the connection (NAP-269)
ci / rust (push) Successful in 14m9s
`dispatch_text` (registry) and `handle_inbound` (daemon) treated any
frame that didn't deserialize into a known `DaemonOutbound`/`DaemonInbound`
variant as fatal: the registry returned `false` (breaking the handler
loop and deregistering the daemon, wiping its catalog), and the daemon
propagated the serde error out of the read loop. A newer peer emitting a
not-yet-merged variant would therefore get stuck in a tight
disconnect/reconnect loop.

- Add a `#[serde(other)] Unknown` catch-all to both enums so unknown
  `type` tags decode instead of erroring.
- Log-and-skip the `Unknown` variant on both sides.
- Also log-and-skip genuinely malformed frames rather than tearing down
  the connection, so one bad frame can't wipe a daemon's catalog.

Tested: unknown/malformed frames keep the connection alive (registry
handler), and unknown `type` decodes to `Unknown` (protocol round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:30:36 +00:00
naps62-yoloandClaude Opus 4.8 2bfab13b58 fix(maestro): heal daemon→registry catalog desync that left dead sessions listed (NAP-291)
ci / rust (push) Successful in 9m41s
The daemon→registry uplink could leave a session in the registry's catalog
after the daemon had already dropped it from its authoritative map, so
`maestro list` / the web UI kept showing it while DELETE returned 404
("no such session on this daemon"). It only healed on the next reconnect.

Two defects in `registry_client::connect_once`, both fixed here:

A. Subscribe-after-snapshot race: the uplink took `manager.snapshot()` and
   then subscribed to `manager.events()`. A reap/kill firing in that window
   was in neither — the registry kept the dead session. Now subscribe before
   snapshotting, matching `local_api::pump_events` (which documents the same
   ordering rule for the browser/CLI event WS).

B. Lagged events were silently dropped (`continue`). If the 256-deep event
   broadcast overflowed, a dropped `SessionRemoved` was never relayed and the
   registry stayed stale until reconnect. The events arm is now extracted into
   `forward_session_event`, which on `Lagged` re-sends a full `SessionsSnapshot`
   — the registry applies it by replacing the daemon's set, healing immediately.

Adds deterministic unit tests for the lag-resync, the Removed mapping, and the
closed-stream break. Existing integration tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:36:19 +00:00
naps62-yolo ade0d49096 chore(maestro): retarget installer, e2e harness, and docs at unified maestro binary (NAP-245)
- Installer: one `cargo install maestro` instead of two; ExecStart becomes
  `~/.cargo/bin/maestro daemon`; user unit renamed `maestrod.service` →
  `maestro.service`; configure step is `maestro daemon configure`.
- E2E harness: both `Daemon` and `Registry` subprocess wrappers shell out to
  the unified `maestro` binary with the appropriate subcommand.
- State dir: `~/.local/state/maestrod` → `~/.local/state/maestro`.
- Docs: CLAUDE.md repo layout, design spec repo layout + "Daemon (`maestro daemon`)"
  heading, INSTALL.md (full rewrite of CLI references + new thin-builds section).
2026-05-27 10:43:51 +00:00
naps62-yolo 35482118b1 feat(maestro): unify maestro/maestrod/maestro-registry into single binary (NAP-245)
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`.
2026-05-27 10:35:41 +00:00
naps62-yolo 8e857e9fe7 Merge branch 'linear/nap-237-daemon-policy-hot-reload-sighup-maestro-policy-reload'
ci / rust (push) Successful in 4m46s
2026-05-27 09:53:28 +00:00
naps62-yoloandClaude Opus 4.7 f69c7a9eee feat(maestro): hot-reload [daemon.policy] via SIGHUP + maestro policy CLI (NAP-237)
ci / rust (push) Failing after 2m51s
`PairAttachVerifier` now stores `allow_inbound` behind `Arc<RwLock<_>>` so
clones share the same set. Two triggers re-read the unified config file
and swap it atomically:

- SIGHUP — `systemctl --user reload maestrod`, or any `kill -HUP`. The
  installer's user unit now sets `ExecReload=/bin/kill -HUP $MAINPID`.
- `POST /api/policy/reload` — used by `maestro policy reload`. The CLI
  reads `[daemon].bind_addr` from the unified config (default 7070) and
  POSTs to `http://127.0.0.1:<port>`. No PID lookup required.

Also adds `maestro policy show [--json]` and `GET /api/policy` for
"did my edit get picked up?" debugging. A broken mid-edit config logs
an error and leaves the previously-effective list in place rather than
locking everyone out.

In-flight attaches keep their existing connection — revocation requires
killing the session, as spec'd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:53:19 +00:00
naps62-yoloandClaude Opus 4.7 7814ca99de fix(maestro): chain pipe-pane into new-session so initial output isn't lost (NAP-253)
ci / rust (push) Failing after 1m45s
`SessionManager::create` used to call `tmux new-session` (which exec's the
user's command immediately), run an async `PtyBridge::start_with_replay`
(mkfifo + open + spawn reader), then issue a *separate* `tmux pipe-pane`.
Any output the child wrote between exec and that third tmux subprocess
landing went into the pane but never into the FIFO. Hit in NAP-252
dogfood: `bash -c 'echo X; exec bash'` sessions showed only the trailing
prompt, never the X.

Two changes:

1. New `Tmux::new_session_piped(id, cwd, cmd, sink)` chains
   `; pipe-pane -t maestro-<id> <sink>` into the same tmux command argv
   as `new-session`. Within a tmux command chain, `pipe-pane` is
   processed immediately after `new-session` returns — before the child
   has finished `execve` — so the FIFO is wired up before the first
   write.
2. `SessionManager::create` now builds the FIFO + reader task **before**
   spawning the session, then calls `new_session_piped` instead of the
   two-step `new_session` + `pipe_pane`.

`Tmux::pipe_pane` and the standalone `Tmux::new_session` stay around —
`recover()` (NAP-228) still needs a standalone pipe-pane re-attach, and
some integration tests want a bare session without a pipe.

Tests:
- Unit: argv assertion that the chained `pipe-pane` follows `new-session`
  when a sink is supplied.
- Integration: `create_captures_initial_output_in_replay_buffer` spawns
  `sh -c 'echo NAP_253_FIRST_LINE_MARKER; exec sleep 30'` and polls
  `subscribe_pty_with_replay` until the marker shows up in the replay
  buffer (or fails after 5s). Fails before the fix, passes after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:49:43 +00:00
naps62-yoloandClaude Opus 4.7 08f5bbc045 test(maestro): fix borrow-after-partial-move in recover regression test
ci / rust (push) Successful in 2m51s
Capture sidecar_dir/fifo_dir/tmux/worktree_root before `drop(fx.manager)`
so the post-drop builder calls don't need to reborrow the partially-moved
fixture. Pure compile fix — same scenario as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:43:15 +00:00
naps62-yoloandClaude Opus 4.7 d23ee6a8b2 fix(maestro): re-pipe pane on recover so attach sees output (NAP-228)
`Tmux::pipe_pane` used `tmux pipe-pane -o`, which tmux documents as "only
open a new pipe if no previous pipe exists." When `maestrod` restarts and
`SessionManager::recover` re-issues `pipe-pane` against the adopted tmux
session, the previous daemon process's pipe to a now-orphan FIFO is
still active — so `-o` makes the call a no-op. The revived manager's
PtyBridge reads from a fresh FIFO that tmux never feeds, and attach
clients see input land in the pane but no output flow back.

Drop the `-o`. tmux closes any existing pipe before opening the new one,
which is what `pipe_pane` callers always wanted: `create` runs on a
fresh session (nothing to clobber), `recover` actively needs the
clobber.

Test: `recover_re_pipes_pane_so_output_reaches_subscribers` creates a
bash session under one manager, drops it, recovers under a second
manager, sends keys, and asserts the echo bytes reach a fresh
subscriber within 5s. Fails before the fix (stale pipe), passes after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:43:15 +00:00
naps62-yoloandClaude Opus 4.7 1c5b6c615c feat(maestro): default daemon TMUX_TMPDIR to $XDG_RUNTIME_DIR (NAP-234)
ci / rust (push) Failing after 2m13s
Plain `tmux ls` in the user's shell now sees maestro-managed sessions
without the `TMUX_TMPDIR=/tmp` workaround, because most shells already
set `TMUX_TMPDIR=$XDG_RUNTIME_DIR` and the daemon now matches.

Precedence: env `MAESTRO_DAEMON_TMUX_TMPDIR` > `[daemon].tmux_tmpdir` >
`dirs::runtime_dir()` > None (tmux's own `/tmp` default). Set
`tmux_tmpdir = "/tmp"` in config.toml if the daemon must outlive the
user's login without linger enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:37:33 +00:00
naps62-yoloandClaude Opus 4.7 b1aa2e6eab fix(maestro): chain default-terminal into new-session instead of pre-set
ci / rust (push) Successful in 2m59s
CI e2e tests failed with "tmux exited with code 1: server exited
unexpectedly" because the startup `start-server \; set-option -g
default-terminal` call left an empty server that tmux's default
`exit-empty on` killed before the first `new-session` could attach.

Move the `set-option -g default-terminal tmux-256color` into the same
tmux command as `new-session` (chained with `;`) so a single tmux
invocation creates the server, sets the option, and spawns the pane.
The pane reads `default-terminal` at creation time, so chaining before
`new-session` still applies to the very first session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:56:29 +00:00
naps62-yoloandClaude Opus 4.7 080f961238 feat(maestro): daemon spawns tmux sessions with 256-color TERM (NAP-252)
ci / rust (push) Failing after 3m7s
`maestrod` running from a stripped systemd env leaves the spawned shell
without `TERM`/`COLORTERM`, and the pane process inherits tmux's
`default-terminal` (which defaults to `screen`, 8 colors). Browser
attaches via xterm.js render ANSI sequences as plain text.

Fix:
- `Tmux::new_session` now passes `-e TERM=xterm-256color -e
  COLORTERM=truecolor` to `tmux new-session`.
- New `Tmux::set_option_global` chains `start-server \; set-option -g`
  so it works before any session exists; the daemon calls it on startup
  to set `default-terminal=tmux-256color`.

Operators wanting 24-bit color should add
`set-option -ga terminal-overrides ",xterm-256color:RGB"` to their
own tmux config — left out of the daemon since that's operator territory.

Tests:
- Unit: `new_session_args` returns the expected argv including the
  `-e TERM=…` / `COLORTERM=…` pair.
- Integration: spawn a session under an isolated tmux server, set
  `default-terminal`, run `printenv` + `tput colors`, assert the pane
  sees `TERM=tmux-256color`, `COLORTERM=truecolor`, and 256 colors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:50:56 +00:00
naps62-yoloandClaude Opus 4.7 3bd793f394 feat(maestro): replay PTY scrollback on attach (NAP-229)
ci / rust (push) Failing after 7m57s
Each PtyBridge keeps a per-session ring buffer (Mutex<VecDeque<u8>>,
default 64 KB, override via `[daemon] pty_replay_bytes`). The reader
task appends each chunk while holding the buffer lock and broadcasts in
the same critical section, so `PtyBridge::subscribe_with_replay` —
which snapshots the buffer and subscribes to the broadcast under that
same lock — produces a snapshot that's a true prefix of subsequent live
bytes. No duplicates, no gaps at the seam.

`SessionManager` exposes the new `subscribe_pty_with_replay`, and
`attach::serve_local` sends the snapshot as a single binary frame
before pumping the live receiver. End result: attaching to an idle
bash session now shows the prompt immediately; reattaching after
running a build sees the last screenful instead of a blank terminal.

Adds `[daemon] pty_replay_bytes` to the unified config (None ⇒
default; Some(0) ⇒ disabled). E2E test exercises the full path: client
A drives output through, disconnects, client B sees the prior output
on connect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:56:23 +00:00
naps62-yoloandClaude Opus 4.7 a671e15114 feat(maestro): daemon WS /api/events for live catalog updates (NAP-248)
ci / rust (push) Failing after 7m28s
New module `local_events::LocalEventBus` (broadcast<RegistryEvent>) with
two publishers:

* `spawn_session_publisher` — translates SessionManager's own session
  events into the matching `RegistryEvent` variants (with daemon_id
  filled in for the SessionRemoved case).
* `CatalogMirror::apply` — re-publishes peer events after applying them
  to the local mirror, so subscribers see the post-apply state.

New `WS /api/events` route on the daemon: subscribes to the bus, sends
an initial `Snapshot` built from manager.snapshot() ∪ catalog state,
then forwards live deltas. `Lagged` follows the registry's convention
(client resyncs via REST).

Revert the REST-seed hack in web/src/events.ts — the WS endpoint now
exists, so `CatalogStore::start()` can go back to just `connect()`.

E2E: subscribe to a daemon's events WS, expect Snapshot then a
SessionAdded after POSTing /api/sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:01:08 +00:00
naps62-yoloandClaude Opus 4.7 cebb2d74b1 feat(maestro): daemon broker T2 + pair-JWT verifier + allow_inbound (NAP-247)
* 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>
2026-05-26 20:32:29 +00:00
naps62-yoloandClaude Opus 4.7 1f6a12e439 feat(maestro): add [daemon.policy] allow_inbound to config (NAP-247)
New [daemon.policy] section with allow_inbound: Vec<String>. Default empty
(deny all peer attaches). The daemon plumbs it through DaemonConfig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:32:06 +00:00
naps62-yoloandClaude Opus 4.7 2bf6f95506 feat(maestro): daemon CatalogMirror + own∪peer HTTP responses (NAP-246)
New module crates/maestro-daemon/src/catalog_mirror.rs: in-memory mirror of
the global catalog (peer daemons + their sessions), updated from the
`DaemonInbound::CatalogEvent` frames the registry now fans out.

`local_api` consumes it: `GET /api/sessions` returns the union of the
daemon's own sessions and the mirrored peers; `GET /api/daemons` returns
self + connected peers. The web UI sees the same catalog from any home
daemon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:18:25 +00:00
naps62-yoloandClaude Opus 4.7 b0be488547 feat(maestro): daemon local-view HTTP API + broker WS self-short-circuit (NAP-243)
ci / rust (push) Failing after 10m36s
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>
2026-05-26 18:05:30 +00:00
naps62-yoloandClaude Opus 4.7 360f564f72 feat(maestro): advertise perimeter_id + private_addr in heartbeats (NAP-243)
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>
2026-05-26 18:00:57 +00:00
naps62-yoloandClaude Opus 4.7 e0e9a98c04 feat(maestro): add perimeter_id + private_addr to daemon config (NAP-243)
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>
2026-05-26 18:00:12 +00:00
naps62-yoloandClaude Opus 4.7 9a8109559b feat(maestro): extend Heartbeat with perimeter_id + private_addr (NAP-242)
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>
2026-05-26 17:46:05 +00:00
naps62-yolo 094cd1ffce fix(maestro): drop SHARED_ prefix from [shared] env vars
ci / rust (pull_request) Successful in 4m13s
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.
2026-05-26 13:24:29 +00:00
naps62-yolo 90127e0ed3 feat(maestro): wire maestro-daemon to unified config (NAP-233)
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.
2026-05-26 09:31:15 +00:00
naps62-yoloandClaude Opus 4.7 c1b4fe0bb6 fix(maestro): make attach exit cleanly when session ends (NAP-226)
ci / rust (push) Successful in 2m15s
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>
2026-05-26 08:49:00 +00:00
naps62-yolo a2ffb0ad33 feat(maestro): reap dead tmux sessions via liveness watcher (NAP-227)
ci / rust (push) Successful in 1m37s
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.
2026-05-26 07:26:42 +00:00
naps62-yoloandClaude Opus 4.7 966716bc4d fix(maestro): enable rustls in daemon tokio-tungstenite for wss://
ci / rust (push) Successful in 2m38s
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>
2026-05-25 10:38:06 +00:00
naps62-yoloandClaude Opus 4.7 0c9148e1c7 fix(maestro): drop duplicate wrote ... line from maestrod configure
ci / rust (push) Successful in 1m33s
`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>
2026-05-25 10:20:00 +00:00
naps62-yoloandClaude Opus 4.7 4b9acc88de feat(maestro): daemon config defaults, env overrides, maestrod configure
ci / rust (push) Successful in 1m43s
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>
2026-05-25 10:15:06 +00:00
naps62-yoloandClaude Opus 4.7 18bd385a2a feat(maestro): registry HTTP API, cookie auth, JWT issuance, events WS
ci / rust (push) Successful in 48s
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>
2026-05-24 17:08:41 +00:00
naps62-yolo 499cffe397 fix(maestro): use AsyncFd for FIFO PTY reader (non-blocking)
ci / rust (push) Successful in 40s
`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.
2026-05-24 16:47:44 +00:00
naps62-yolo 2b84783968 fix(maestro): drop stale wait_for_async import in attach test too
ci / rust (push) Has been cancelled
2026-05-24 16:17:28 +00:00
naps62-yolo fb2a0b1e6a fix(maestro): drop stale wait_for_async import in session_manager test
ci / rust (push) Failing after 1m14s
2026-05-24 16:10:18 +00:00
naps62-yolo 0624ad1257 style(maestro): apply cargo fmt
ci / rust (push) Failing after 1m15s
2026-05-24 15:53:35 +00:00
naps62-yolo ab9a5bf017 feat(maestro): wire daemon to registry + per-session WS stream
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.
2026-05-24 15:22:36 +00:00
naps62-yolo 946aa384a0 feat(maestro): add daemon tmux/sidecar/worktree primitives
ci / rust (push) Failing after 3s
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.
2026-05-24 13:02:41 +00:00