Commit Graph
50 Commits
Author SHA1 Message Date
Miguel Palhas e9ede49256 feat(maestro): render structured TUI sessions 2026-09-05 07:19:10 +01:00
Miguel Palhas 5a861976d7 test(maestro): cover signal shutdown drain 2026-09-05 00:07:19 +01:00
Miguel Palhas b1718559df feat(maestro): spawn a claude-code-acp child per structured session (#134)
A Structured spawn gets no tmux pane: the daemon starts the adapter in
the session cwd with the session env (including MAESTRO_SESSION_ID and
MAESTRO_URL), runs the handshake, and records the ACP session id on the
sidecar. An adapter that cannot come up still yields a session, Dead
with activity Error and the reason as activity_detail, so a missing
adapter reads as the install command in the sidebar rather than as a
session that never appeared.

The liveness pass checks a structured session's child instead of the
tmux list. kill sends session/cancel, waits, then SIGTERMs through the
ACP handle. SessionManager::shutdown ends every adapter concurrently
and marks those sessions Dead on disk. take_acp_updates hands #135 the
raw session/update stream once, with earlier notifications still queued.
2026-09-04 22:02:39 +01:00
Miguel Palhas 540a3025b1 feat(maestro): add maestro-acp, the ACP client per structured session (#134)
New crate wrapping agent-client-protocol 2.1 in the client role. spawn()
resolves claude-code-acp on the PATH the child would run with, starts it
in the session cwd with the session env, runs initialize + session/new,
and returns an AcpSession holding the connection, the ACP session id,
and an unbounded channel of raw session/update notifications for #135.

initialize declares fs.readTextFile, fs.writeTextFile and terminal as
false so the adapter uses Claude Code's own tools. request_permission is
a stub that declines until #139.

terminate() sends session/cancel, waits, SIGTERMs, then SIGKILLs. The
child stays in the daemon's process group and gets SIGTERM on daemon
death via PR_SET_PDEATHSIG. CLAUDECODE is stripped from the child env:
the adapter refuses to start when it believes it is nested in another
Claude Code session, which is how a daemon launched from one behaves.

Tests run against tests/fake_acp_agent.py, a JSON-RPC-over-stdio stand-in
that logs what it receives; real_adapter_handshake is #[ignore]d.
2026-09-04 20:13:30 +01:00
Miguel PalhasandClaude Opus 5 4fc90aabfb feat(maestro): serve the web UI from the daemon
The browser reached the daemon through maestro-web, a second binary that
served the SPA and reverse-proxied /api/* — two TCP connections and a
WebSocket frame re-encode on every keystroke and every byte of PTY output.
On one machine that hop buys nothing.

The daemon now embeds the bundle and serves it off its own listener, behind
a default-on `web` feature. `--no-default-features` builds without pnpm or
node; MAESTRO_WEB_DIST still takes a prebuilt bundle.

The router's fallback answers an unmatched /api/* with 404 rather than the
SPA shell, so a client expecting JSON gets a status it can read instead of
HTML.

Removes maestro-web and the [web] config section. An existing [web] block
is ignored, not rejected. The installer deletes a leftover
maestro-web.service on upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:23:18 +01:00
Miguel Palhas 1b289a0450 feat(maestro): daemon auto-installs claude-code hook on first start 2026-08-24 22:25:54 +01:00
Miguel Palhas 3c5bb51e88 feat(maestro): add maestro hooks install subcommand 2026-08-24 22:22:49 +01:00
Miguel Palhas eb605a3d05 feat(maestro): ship claude-code activity hook + idempotent installer 2026-08-24 22:18:23 +01:00
Miguel Palhas 7dcb87cd6e feat(maestro): project-list helpers for the tui draft picker (#64) 2026-08-23 21:46:54 +01:00
Miguel Palhas a970ba89c2 feat(maestro): GET /api/names serves name batches (#53) 2026-08-23 21:13:46 +01:00
Miguel Palhas 17796c1ffa feat(maestro): maestro-names crate with musical wordlists (#53) 2026-08-23 21:11:06 +01:00
Miguel PalhasandClaude Opus 5 30bd9d9042 test(maestro): one mock daemon instead of three answers
ci / web (pull_request) Successful in 29s
ci / rust (pull_request) Successful in 3m17s
ci / web (push) Successful in 30s
ci / rust (push) Successful in 2m39s
There were three answers to "how do I test against a daemon", and the CLI
— the thinnest client — had none, so its formatting and error handling
were exercised by compiling and forking the real binary plus tmux.

Adds crates/maestro-testing with a MockDaemon that serves the real /api/*
surface from maestro-protocol types, backed by a Vec<Session>. It replaces
two ad-hoc stubs: maestro-web's, which returned [{"id":"session-1"}] —
not a valid Session — and maestro-tui's scripted socket with a hardcoded
timeline.

maestro-cli gets its first tests against a daemon: six cases covering the
catalog, that DaemonInfo carries the version and presets its old private
copy dropped, that a structured failure surfaces the daemon's own message,
and that connection-refused stays distinguishable from an HTTP rejection.

maestro-web's proxy suite gains an end-to-end check that an upstream
rejection reaches the browser as a 4502 close carrying the daemon's
message — upstream_close_reason had only unit tests.

e2e keeps the tmux-shaped tests. cli_list_reads_from_daemon still runs the
shipped binary as a subprocess but now against MockDaemon, and
tui_integration.rs is deleted: the real daemon's /api/events contract is
covered by maestro-daemon/tests/local_api.rs and the client reducer by
maestro-tui/tests/catalog_ws.rs. The e2e suite drops from ~32s to ~20s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:51:22 +01:00
Miguel PalhasandClaude Opus 5 ef3d314296 refactor(maestro): finish moving the e2e harness off raw reqwest
ci / rust (pull_request) Successful in 3m11s
ci / rust (push) Successful in 1m27s
Restores the harness's deliberate 5s per-request budget, which the shared
client's 30s default had silently replaced, and converts the last two
hand-rolled daemon calls in e2e.rs (recent-dirs, gitroot). Drops reqwest
from maestro-e2e entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:22:35 +01:00
Miguel PalhasandClaude Opus 5 148f97fa71 chore(maestro): drop the unused tokio dep from the client feature
ci / rust (pull_request) Successful in 5m29s
reqwest brings its own runtime requirement; maestro-protocol never names
tokio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:17:46 +01:00
Miguel PalhasandClaude Opus 5 39cfa50d68 fix(maestro): keep the TUI's two request budgets separate
ci / rust (pull_request) Successful in 5m9s
Collapsing the health probe and the app client into one lost both
policies: the probe's 500ms was a total request timeout, not a connect
timeout, and the app's calls had 5s rather than the client default 30s.
A daemon that accepts TCP and then stalls held the terminal for 30s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:16:30 +01:00
Miguel PalhasandClaude Opus 5 02356f84ab refactor(maestro): route every client through the shared DaemonClient
ci / rust (pull_request) Successful in 3m0s
The CLI, TUI and end-to-end harness each carried their own client: three
reqwest builders with three timeout policies, three copies of the
success-or-body check, and route strings at every call site. Two of them
discarded the daemon's structured error and reported only a status code.

Deletes `maestro-cli/src/http.rs`, both `ws_base` copies in the TUI, the
CLI's partial `DaemonInfo` (it dropped `version` and `presets`), and the
e2e harness's `check_ok`. `maestro-web` gains a protocol dependency and
stops building the error envelope with a format string and reading it back
as untyped JSON; the daemon and the proxy now register routes from the same
constants.

`maestro_web::router` returns `Result` — an unusable daemon URL fails at
startup instead of at first connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:11:43 +01:00
Miguel PalhasandClaude Opus 5 01a1073943 feat(maestro): own the /api surface in maestro-protocol
The crate held the wire types but not the wire surface, so every consumer
re-derived routes, URL construction and the error envelope.

Adds `routes` (path constants, axum patterns, and a single fallible
`ws_base`), the `ApiError` envelope, `DaemonInfo`, and a `DaemonClient`
behind an optional `client` feature. `SpawnPreset` moves here because it
rides `GET /api/info`; `maestro-config` re-exports it so the on-disk
schema and the payload stay one type.

`ws_base` now rejects a base with no recognised scheme. The five previous
copies disagreed — the CLI errored, the web proxy trusted it, the TUI
passed it through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:11:37 +01:00
Miguel PalhasandClaude Opus 5 bf7def5cf1 chore(maestro): drop dead maestro-names crate
Nothing in the workspace depended on it — session handles are generated
client-side in web/src/names.ts, which carried its own copy of the
wordlists. Also drops two unused deps: rust-embed (no crate used it;
maestro-web embeds via include_dir) and urlencoding in maestro-cli.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:49:30 +01:00
Miguel Palhas 6790f41652 refactor(maestro): point CLI and TUI at the local daemon only
CLI drops the registry fallback, cookie auth, set-registry, and the
--daemon selector; every command hits [cli].daemon_url. TUI drops the
daemon tree from the sidebar and the daemon field from the spawn modal.
GET /api/web/info becomes GET /api/info.
2026-08-22 10:07:12 +01:00
Miguel Palhas d006f4a7bd refactor(maestro): drop registry, p2p, and auth from daemon core
Maestro is now single-host: one daemon owns the machine's sessions and
clients talk to it directly. Removes the registry crate, the daemon's
registry client/broker/catalog mirror, JWT attach auth, cookie auth, and
the perimeter (T2) transport config.

The daemon API is now unauthenticated on its bind address, same trust
model as tmux. Session and CreateSessionRequest lose daemon_id;
RegistryEvent becomes CatalogEvent with no daemon variants; attach moves
to WS /api/attach/:id with no token.
2026-08-22 10:00:24 +01:00
naps62-yoloandClaude Opus 4.8 ff33b43d44 feat(maestro): orchestra-themed session-name generator (#23)
New shared `maestro-names` crate producing `adjective-instrument` session
handles drawn from the orchestra pit — musical tempo/dynamics/articulation
markings × instruments and forms (e.g. `allegro-cello`, `crescendo-oboe`,
`staccato-viola`), tying handles to the app's conductor metaphor.

- Pure generation takes an injected RNG; no thread_rng/clock in pure paths,
  so seeded tests are reproducible (repo invariant).
- `generate_unique` prefers the bare name, appending an incrementing numeric
  suffix (`-2`, `-3`, …) only on collision via an injected `is_taken`.
- `generate_thread` is the one impure convenience wrapper for runtime callers.
- Wordlists are public so the web UI can mirror them for instant reroll; the
  Rust generator stays the source of truth.

Closes #23. Part of #21 (spawn dialog rework).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:39:34 +00:00
naps62-yoloandClaude Opus 4.8 4a008684f6 refactor(maestro): dedup CLI JWT percent-encoder onto urlencoding crate (NAP-273 part A)
ci / rust (push) Failing after 5m42s
`maestro-cli` carried a hand-rolled `urlencoding()` for the attach-token query
param while `maestro-daemon` already used the `urlencoding` crate for the same
wire field. Two encoders for one field is exactly the drift that only surfaces
in production — collapse to the crate (`urlencoding = "2"`), deleting the local
copy. The unreserved set is identical, so the encoded output is unchanged
(existing `attach_url` tests pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:27:31 +00:00
naps62-yolo f27adf5aba feat(maestro-tui): status bar + focus highlight + j/k sidebar nav (NAP-287)
ci / rust (push) Successful in 15m39s
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.
2026-05-29 09:01:36 +00:00
naps62-yolo c599fcfdec test(maestro-tui): integration tests against real daemon subprocess (NAP-282)
ci / rust (push) Failing after 5m1s
`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.
2026-05-28 22:37:22 +00:00
naps62-yolo 5a17de4489 feat(maestro-tui): App event loop + lifecycle wiring (NAP-281)
ci / rust (push) Failing after 5m4s
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).
2026-05-28 22:30:45 +00:00
naps62-yolo 953d170dae feat(maestro-tui): ratatui widgets + render orchestration (NAP-280)
ci / rust (push) Failing after 4m44s
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.
2026-05-28 22:24:06 +00:00
naps62-yolo 888a00d177 feat(maestro-tui): SessionTask + SessionEmulators + ResizeTracker (NAP-279)
ci / rust (push) Failing after 5m11s
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.
2026-05-28 22:15:49 +00:00
naps62-yolo 0f3770f711 feat(maestro-tui): CatalogStore over WS /api/events (NAP-278)
ci / rust (push) Failing after 5m5s
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.
2026-05-28 22:08:06 +00:00
naps62-yolo 31e1b73193 feat(maestro-tui): TuiConfig with shipped defaults + override merge (NAP-277)
ci / rust (push) Successful in 15m8s
`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).
2026-05-28 19:52:41 +00:00
naps62-yolo c99f23c705 feat(maestro): scaffold maestro-tui crate + maestro tui subcommand (NAP-276)
ci / rust (push) Successful in 15m43s
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`.
2026-05-28 19:24:34 +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-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 98dbf3d06d feat(maestro): add [web] section to unified config (NAP-241)
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>
2026-05-26 18:23:04 +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-yolo ed5743cd50 feat(maestro): wire maestro-registry to unified config (NAP-233)
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.
2026-05-26 09:34:13 +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-yolo efaa8bfe13 feat(maestro): wire maestro-cli to unified config (NAP-233)
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`.
2026-05-26 09:24:51 +00:00
naps62-yolo 7409cd6ad8 Revert "fix(maestro): maestro-web is now a proxy, not a local registry (NAP-231)"
This reverts commit 3b6dd50c1d.
2026-05-26 09:05:38 +00:00
naps62-yolo 3b6dd50c1d fix(maestro): maestro-web is now a proxy, not a local registry (NAP-231)
ci / rust (push) Successful in 4m36s
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).
2026-05-26 08:59:27 +00:00
naps62-yolo 91f252b4e2 feat(maestro): add maestro-e2e cross-process test harness (NAP-217)
ci / rust (push) Successful in 2m8s
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).
2026-05-26 07:38:19 +00:00
naps62-yolo 2da41cc720 feat(maestro): add maestro-install wizard (cargo install + systemd)
ci / rust (push) Successful in 1m35s
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.
2026-05-25 14:30:03 +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 077e9e958e feat(maestro): embed and serve web bundle from registry
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>
2026-05-25 09:57:26 +00:00
naps62-yoloandClaude Opus 4.7 b68e39a9e5 feat(maestro): maestro-cli — list/spawn/attach/kill
ci / rust (push) Successful in 1m7s
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>
2026-05-25 09:49:46 +00:00
naps62-yoloandClaude Opus 4.7 3022ee2c62 feat(maestro): add maestro-registry binary entry point
ci / rust (push) Successful in 1m0s
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>
2026-05-24 17:28:20 +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 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 c721b5ec62 feat(maestro): add registry daemon-side WS handler and state
ci / rust (push) Failing after 6s
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.
2026-05-24 13:42:00 +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
naps62-yoloandClaude Opus 4.7 de8db03a18 chore(maestro): scaffold cargo workspace and CI
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>
2026-05-24 12:35:08 +00:00