Commit Graph
88 Commits
Author SHA1 Message Date
naps62-yoloandClaude Opus 4.8 3b60f67332 fix(maestro): give CLI reqwest clients connect + request timeouts (NAP-262)
ci / rust (push) Successful in 9m49s
The `RegistryClient` and the bare `reqwest::Client::new()` calls in
`policy.rs` built clients with no timeouts, so `maestro list` / `maestro
policy {show,reload}` would hang forever against a registry/daemon that
is TCP-alive but not making forward progress (SIGSTOP, swap thrash,
deadlock behind slow disk).

Centralize construction in `http::build_client()`, which applies a 5s
connect timeout and a 30s request timeout, and route every call site
through it. Covered by a test that points the client at a peer which
accepts the connection but never responds and asserts the request times
out instead of hanging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:22:58 +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 39dddb17da feat(maestro-tui): trim chrome + walk sidebar in visual order (NAP-289)
ci / rust (push) Successful in 8m18s
Dogfood-driven layout fixes:

- `j`/`k` now walk the same daemon-then-sessions order the sidebar emits
  (`visible_sidebar_order`), so the highlight follows what the user sees
  instead of jumping by Uuid order across daemons.
- Tab bar collapses from 3 rows of box border to 1 row of plain text +
  1 row of horizontal rule. The rule doubles as the pane's focus indicator
  (cyan top border when `focus = Pane`, default otherwise). Saves ~4 rows
  above the pane before any content shows.
- Sidebar drops the full box border in favour of a single right-edge
  vertical rule (also cyan when focused). The "daemons" header now lives
  inline as a bold first line instead of in the block title.

Visible content area grows by ~2 cols + ~4 rows on the typical layout
without losing focus / panel-boundary affordances.

Workspace clippy + 85 unit tests passing.
2026-05-29 10:10:56 +00:00
naps62-yolo f208aa441d feat(maestro-tui): attach lands in Insert + real help + blinking cursors (NAP-288)
ci / rust (push) Successful in 8m45s
Three dogfood-driven UX fixes:

1. `AppState::attach_to_focused` now sets `mode = Mode::Insert` along with
   `focus = Focus::Pane`, so pressing Enter on a sidebar row drops the user
   straight into typing the remote shell. The leader (default `ctrl-a`)
   returns to Normal — same surface as tmux's prefix key.

2. `Keymap::entries(mode)` exposes the live binding table; `App::dispatch
   (Action::ShowHelp)` (and `App::new`) rebuild `HelpOverlay::lines` from
   it. Users see the actual Normal + Insert bindings + the leader hint,
   not just "? to close".

3. Visible blinking cursor in both surfaces that accept text:
   - `Pane::render` calls `Frame::set_cursor_position` at the vt100 cursor
     position when the pane is focused (and the screen hasn't hidden the
     cursor itself). Terminals do the blink.
   - `SpawnModal::render` places the cursor at the end of whichever text
     field is currently focused (cwd / command / label). Daemon dropdown
     and the Submit button still rely on the REVERSED style.

Workspace clippy + 85 unit tests passing.
2026-05-29 09:19:30 +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-yoloandClaude Opus 4.8 1cafa2c2af fix(maestro): install maestro without the registry feature
ci / rust (push) Successful in 8m13s
The installer's cargo install of the umbrella maestro crate built the
default feature set (cli+daemon+registry+tui), forcing every host to
compile the registry — which a host neither needs nor runs (the registry
is a separate, centrally-deployed service). Install with
--no-default-features --features cli,daemon,tui instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:42:43 +00:00
naps62-yoloandClaude Opus 4.8 212ebbf120 fix(maestro): stop embedding web bundle in the registry
The registry serves no UI — that is maestro-web's job (connection-
orchestration spec §"UI hosting"). The rust-embed of web/dist was
legacy from NAP-218, before maestro-web was extracted (NAP-241), and it
made the registry (and thus the umbrella maestro binary) fail to compile
whenever web/dist was absent — e.g. a fresh cargo install --git after
the tracked .gitkeep was dropped. Remove web_assets.rs, the rust-embed
dep, and the / + /assets routes; the registry is now JSON/WS API only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:40:23 +00:00
naps62-yolo 278c81d2bc feat(maestro-tui): wire --config flag + document tui in INSTALL.md (NAP-283)
ci / rust (push) Failing after 5m13s
- `RunConfig` grows a `config_path: Option<PathBuf>` field, plumbed from the
  umbrella binary's global `--config` flag through to `MaestroConfigFile`
  loading. Explicit `--config <path>` wins over `$MAESTRO_CONFIG` over the
  platform default; a missing file still falls back to shipped defaults.
- `docs/INSTALL.md` gains a "terminal UI" section: that `maestro tui` ships
  in the default feature set, how to opt out (`--no-default-features
  --features cli,daemon`), the `MAESTRO_DAEMON_URL` override, and a worked
  example of `[tui]` / `[tui.keymap.normal]` config pointing at the
  full action vocabulary in the design spec.
- Adds a thin-build line for `--features cli` (CLI-only client) and lists
  `tui` in the default-feature set.
- CLAUDE.md repo-layout block lists the new `maestro-tui` crate.

Closes the NAP-275 v1 epic.
2026-05-28 22:42:30 +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 50688c39b7 Merge branch 'linear/nap-280-tui-widgets'
ci / rust (push) Failing after 4m14s
2026-05-28 22:24:22 +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-yoloandClaude Opus 4.8 a429c69956 chore(maestro): drop obsolete web/dist/.gitkeep handling
ci / rust (push) Failing after 3m2s
build.rs now builds web/dist before include_dir! evaluates, so the
checked-in .gitkeep placeholder is no longer needed for a fresh checkout
to compile (verified by building with web/dist entirely absent). Ignore
dist/ wholesale and update the static_bundle doc comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:21:59 +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-yoloandClaude Opus 4.8 eeccc1ad5b feat(maestro): preflight pnpm + node at installer web step
The opt-in maestro-web step now checks for the frontend toolchain its
build.rs requires; missing tools prompt abort/skip (interactive) or skip
the refresh (non-interactive) instead of failing cargo with a raw panic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:02:11 +00:00
naps62-yoloandClaude Opus 4.8 c355f9bd2e fix(maestro): tie maestro-web recompile to web/dist + harden bundle build
Watch the embedded web/dist output (not just frontend inputs) so a
rebuilt bundle is actually re-embedded; assert pnpm build produced
web/dist/index.html; strengthen the embed test to require a vite-built
hashed asset reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:02:11 +00:00
naps62-yoloandClaude Opus 4.8 092a04857f feat(maestro): build maestro-web SPA bundle in build.rs
include_dir! now embeds a freshly built web/dist on every compile; pnpm +
node are hard build requirements. Fixes the placeholder served by
cargo install --git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:02:11 +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 231032aa85 feat(maestro-tui): Keymap table with input/action validation (NAP-277)
Two-level `(mode → input → action)` lookup. `set` validates each whitespace-
separated chord token via `InputKey::parse` and the action name via
`Action::parse`, so a bad TOML entry surfaces a precise diagnostic at TUI
startup instead of silently dropping a binding.

`known_bindings(mode)` feeds `ChordState` so a key only holds when it strictly
prefixes a real binding.

8 unit tests passing.
2026-05-28 19:50:41 +00:00
naps62-yolo 67870bb9e8 feat(maestro-tui): InputKey canonicalisation + ChordState (NAP-277)
- `InputKey` — hashable variant covering chars-with-modifiers, specials, and
  mouse events (kind + tagged region). `canonical()`/`parse()` round-trip the
  TOML form (`ctrl-w h`, `mouse:click(pane)`).
- `canonicalise()` — `crossterm::Event` → `InputKey`, with a region-resolver
  callback supplied by the renderer (NAP-280 will wire `RegionMap` to it).
- `ChordState` — small state machine that holds a key when it strictly
  prefixes a known binding, resolves otherwise. Drops stale held keys past
  `timeout`.

11 unit tests passing.
2026-05-28 19:49:59 +00:00
naps62-yolo a4252438cd feat(maestro-tui): Action enum with canonical-string parser (NAP-277)
Closed list of TUI verbs. The string form (`EnterInsert`, `FocusPane(Left)`,
`GotoTab(3)`) is what users write in `[tui.keymap.normal]`; the enum is what
the event loop matches on.

Parser surfaces `Unknown`, `BadArg`, and `Malformed` diagnostics so a bad
keymap entry is rejected at load time with a specific reason. Display round-
trips so the canonical form is stable.

9 unit tests passing.
2026-05-28 19:48:10 +00:00
naps62-yolo ed128ec200 feat(maestro): add [tui] config section + crossterm workspace dep (NAP-277)
Schema-only — adds `TuiSection` with `leader`, `chord_timeout_ms`, and per-mode
keymap override maps; pulls crossterm into the workspace deps so the next
commit's input layer can consume it.

Field set matches `docs/superpowers/specs/2026-05-28-maestro-tui-design.md` §3.9.
2026-05-28 19:47:05 +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 9ce6303293 refactor(maestro): wire --config flag through to maestro registry
ci / rust (push) Failing after 2m13s
ci / rust (pull_request) Successful in 4m54s
`registry::run` now accepts an `Option<PathBuf>` config override so the
umbrella binary's `--config` flag (and its clap-bound `MAESTRO_CONFIG`
env var) reach the registry, mirroring how the daemon already consumed
the override.
2026-05-27 10:45:16 +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 cc6e04ca5a feat(maestro): installer drops optional maestro-web systemd unit (NAP-249)
ci / rust (push) Failing after 7m18s
New helpers in maestro-installer::systemd:

* `render_web_unit` — same shape as the daemon unit, ordered after
  `maestrod.service` so the proxy doesn't 502 on startup.
* `user_web_unit_path` / `default_maestro_web_path` — under `~/.config/`
  and `~/.cargo/bin/` respectively.

New wizard step (NAP-241 deferred this; phone-dogfood on 2026-05-26
surfaced it): after the daemon+systemd flow, optionally
`cargo install maestro-web` and lay down
`~/.config/systemd/user/maestro-web.service`. Re-runs detect the existing
unit and refresh + restart silently.

New flags:

* `--no-web` — skip the prompt entirely (headless hosts).
* `--web-exec-start <path>` — override the unit's ExecStart.

Non-interactive (`--yes` / piped) skips the web step unless an existing
unit is being refreshed — operators opt in by re-running once
interactively, then subsequent unattended re-runs refresh automatically.

docs/INSTALL.md updated with the new flow + flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:02:30 +00:00
naps62-yoloandClaude Opus 4.7 53cc008e3b test(maestro): e2e for broker T2 + allow_inbound enforcement (NAP-247)
ci / rust (push) Successful in 7m28s
Two scenarios:

* broker_t2_attach_through_home_daemon: alpha + beta share perimeter
  "wg-test"; beta lists alpha in allow_inbound. Test attaches to beta's
  session via alpha's /api/attach/:id and verifies the PTY echoes back.
* broker_rejects_when_target_doesnt_allow_inbound: same setup but beta's
  allow_inbound is empty; the broker WS handshake succeeds at alpha
  (which has no reason to refuse) but beta drops the pair JWT, so the
  proxied stream closes without any PTY bytes flowing.

Extends maestro-e2e::Daemon with DaemonOptions and a spawn_at variant
that fixes the bind address, so the daemon's advertised private_addr
matches the port the broker will dial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:32:37 +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 698032d870 feat(maestro): registry pair-JWT issuer + ResolveAttach handler (NAP-247)
* DaemonEntry stores the daemon's most recent perimeter_id / private_addr
  from heartbeats, with `update_perimeter` and `perimeter_for` accessors.
* issue_pair_attach_jwt mints the iss/sub/aud/sid/exp pair claim shape
  (60s TTL) — verified by the target daemon.
* Registry::resolve_attach assembles AttachCandidates: T1 stays None
  (NAP-235 canceled), T2 carries the target's perimeter when known, T3
  stays None (NAP-238 pending), plus the pair JWT.
* WS handler routes the new DaemonOutbound::ResolveAttach to the new
  method and persists perimeter info on every Heartbeat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:32:18 +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 a701d537a3 test(maestro): e2e for daemon catalog mirror (NAP-246)
ci / rust (push) Successful in 8m4s
Spins up registry + alpha + beta, creates a session on beta, verifies
alpha's `/api/sessions` and `/api/daemons` reflect the peer state within
5 s of the create round-tripping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:18:31 +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 2e6b7ac356 feat(maestro): registry fan-out of catalog events to daemons (NAP-246)
Each daemon connection now also subscribes to the registry's event broadcast.
A per-connection task pumps peer-filtered events through the existing
outbound mpsc as `CatalogEvent` frames; the daemon side mirrors them
locally (NAP-246).

Two pre-existing race fixes that NAP-246's extra traffic exposed:

* `create_session` / `kill_session` now eagerly mirror Ok results into
  state.sessions (via the same `add_session` / `remove_session` calls the
  daemon's async `SessionAdded` / `SessionRemoved` will hit later — both
  are idempotent). HTTP callers no longer see a transient empty catalog
  immediately after a successful POST.
* Catalog frames flow through the same select! arm as RPC frames so the
  WS sink+stream coupling axum imposes doesn't stall the inbound reader.

Updates daemon_ws.rs tests to skip CatalogEvent frames when asserting on
RPC frames.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:18:18 +00:00
naps62-yoloandClaude Opus 4.7 c4c9c8a24f feat(maestro): DaemonInbound::CatalogEvent + RegistryEvent::for_peer (NAP-246)
New control-link message wrapping a registry event for daemons that want a
local catalog mirror. `RegistryEvent::for_peer(self_id)` strips events that
concern the recipient itself — daemons mirror peer state, not self.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:18:08 +00:00
naps62-yoloandClaude Opus 4.7 bc3237287f feat(maestro): maestro-web standalone binary + reverse-proxy (NAP-241)
New crate crates/maestro-web/: axum server that

* embeds the web/dist SPA bundle via include_dir! (graceful placeholder when
  pnpm build hasn't run yet — keeps fresh checkouts compiling)
* reverse-proxies /api/* HTTP to a co-located daemon (default
  http://127.0.0.1:7070) via reqwest, stripping hop-by-hop headers
* reverse-proxies /api/attach/:id and /api/events WebSockets via
  tokio_tungstenite::connect_async + a tokio task pumping both directions
* reads bind_addr / daemon_url / dev_proxy_target from the new [web]
  config section; env-var overrides MAESTRO_WEB_BIND_ADDR / _DAEMON_URL /
  _DEV_PROXY_TARGET

Integration tests spin up a mock daemon + maestro-web pair on random ports
and verify HTTP forward, SPA fallback, WS echo, and 502 on unreachable
daemon.

Adds a top-level Justfile (`just web` = pnpm install + pnpm build + cargo
build), crates/maestro-web/README.md (config + trust model + dev workflow),
and a docs/INSTALL.md section on optional web UI install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:23:14 +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-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