Expose the daemon-side spawn data (#24 recents, #25 gitroot, #22 presets) to
the web, mirroring the NAP-329 ListDir broker plumbing end to end so it works
across federated hosts.
Wire (maestro-protocol): `RecentDir`/`RecentDirs`/`GitRoot` payloads;
`DaemonInbound::RecentDirs`/`GitRoot` requests + `ForwardRecentDirs`/
`ForwardGitRoot` relay pairs + their `*Result` replies on both enums. All
forward-compatible via the existing `RpcResult` + `#[serde(other)] Unknown`.
Relay (broker + registry + registry_client): home daemon → registry → owning
daemon → back, with the same fail-fast-while-disconnected + RAII pending-entry
cleanup discipline as ListDir. The owning daemon answers RecentDirs from its
spawn history and GitRoot by resolving the path against its home dir
(shared `dirlist::resolve`) then walking up via `gitroot::find_root`.
Daemon HTTP (maestro-daemon):
- GET /api/daemons/:id/recent-dirs → RecentDirs
- GET /api/daemons/:id/gitroot?path= → GitRoot
- Presets ride GET /api/web/info (the documented DaemonInfo sibling) so the
form needs no extra round-trip; resolved once at startup from [spawn].presets
(a bad preset list now refuses startup).
Tests: protocol round-trip + forward-compat for the new variants; broker
forward round-trips + fail-fast; HTTP handler tests (self recents/gitroot,
presets advertisement, peer 503 without a registry link).
Closes#26
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `spawn_history` daemon module backing the spawn form's "recent dirs"
picker. Sidecars are deleted on kill, so they can't be the source — use an
append-only JSONL log at `<state_dir>/spawn-history.jsonl`, one record per
successful spawn (cwd, command, name, worktree_branch, at).
- Write: `SessionManager::create` appends after a successful spawn,
best-effort — a log-write failure only warns, never fails the spawn.
- Read: `recent_dirs()` folds newest-first, dedups by cwd, drops entries past
the recency cap, caps the result count.
- Prune: opportunistic `compact()` from `append` once the file passes a size
threshold; atomic tmp+rename rewrite.
- Config: `[daemon].spawn_history_days` (default 30, `0` disables). History is
wired into the manager at daemon startup.
Time-dependent logic is factored into `*_as_of(now)` helpers for
deterministic unit tests. Adds module unit tests (append/dedup/recency
prune/compaction/disabled/malformed/cap), a config resolution test, and
real-tmux integration tests proving create→append→read end to end.
Closes#24
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Android the soft keyboard overlaid the layout viewport and the browser
auto-scrolled the document on focus, dragging the shell (and its special-key
bar) to the top of the screen and forcing a manual scroll.
- index.html: interactive-widget=resizes-content so Chrome resizes the layout
viewport instead of overlaying the keyboard, killing the auto-scroll.
- viewport.ts: mirror visualViewport.offsetTop into --app-top for iOS / older
browsers that ignore interactive-widget and still scroll under the keyboard.
- styles.css: translateY(--app-top) re-pins #app to the visible region.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an optional `[spawn]` table to the unified config with a `presets` list
of `{ id, label, command }` tiles for the spawn form, defined in shared
config so every binary sees the same list (web via daemon advertisement in
#26, TUI later).
`SpawnSection::resolve` returns the presets to show in order: configured
`presets` if present (replacing the built-ins), else the `claude`/`opencode`/
`zsh` built-ins; then the implicit free-text `custom` tile is always
appended. Validates id non-empty/unique/not-reserved and command non-empty.
Closes#22
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Add a pure `gitroot::find_root` resolver that walks up from a path to the
nearest ancestor holding a `.git` entry (directory or linked-worktree file)
and returns that repo toplevel, else None. Mirrors the `dirlist` module
shape: filesystem-only, no `git` subprocess, unit-tested in isolation.
Drives the spawn form's worktree section (#21): infer `base_repo` from the
chosen working dir so a worktree spawn needs only a branch. HTTP/wire
exposure lands in #26.
Closes#25
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review feedback on the touch key bar:
- Pare to essentials: Esc, Tab, Ctrl, Alt + cursor keys (drop PgUp/PgDn
and the symbol keys).
- Two compact Termux-style rows in a 5-col grid; cursor keys as a
real-keyboard inverted-T with Home/End filling the top corners:
Esc Tab Home ↑ End
Ctrl Alt ← ↓ →
- Add Alt as a second sticky one-shot modifier; Ctrl+Alt compose, and
both promote cursor keys to the modified CSI form (\x1b[1;<n><final>).
- Explicit press-flash animation: the pointerdown preventDefault (kept so
the terminal stays focused) suppresses native :active feedback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On touch devices the soft keyboard omits the keys terminals depend on
(Esc, Tab, Ctrl, arrows, Home/End, PgUp/PgDn). Add a Termux-style
scrollable key bar to the attach view that injects the right xterm
sequences into the existing PTY input path.
- Ctrl is a one-shot sticky modifier: arm it and the next character —
tapped here or typed on the soft keyboard — is sent as its control
code, so Ctrl+C and friends work on mobile.
- Taps preventDefault on pointerdown so the terminal keeps focus and the
keyboard stays open.
- Bar is revealed only on coarse-pointer devices; a physical keyboard
already has these keys.
- Shell height now tracks the visual viewport (installViewportSizing) so
the bar lands above the soft keyboard instead of behind it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Set tracker: gitea in .claude/linear.json so /work and /yolo use this
repo's Gitea issues instead of Linear. Drops the Linear org/team/project
fields (now unused).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live-review iteration on the browser SPA, driven by crit feedback.
Sidebar / session tree
- Compact single-line leaf rows; status shown as a faint "alive" row wash
instead of per-row dots (exited rows dim + keep the "exited" badge).
- Host is now the first level of nesting (host → label path → session); the
inline @daemon is dropped from rows.
- Flush, borderless search input spanning the sidebar; drag-to-resize grip on
the sidebar edge (width persisted to localStorage); narrower default.
- Per-row clone action (opens the spawn modal prefilled); right-pointing
chevron when collapsed.
Chrome / layout
- Buttons swapped to a bracket terminal style ([ + Spawn ]); ghost/icon drop
the brackets. Old filled styles removed.
- Squared corners globally (radius tokens) and tighter, denser spacing.
- Session tally + daemon chips moved into the centered app header.
- Shorter control height; shared --bar-h aligns the search and attach bars.
Attach / terminal
- Useful attach title (name · command @ host) over a bare UUID.
- Robust fit: compute columns from the viewport's scrollbar-excluded
clientWidth (fixes mobile under-fit and desktop overflow); ResizeObserver +
font-load refit; full-bleed terminal on mobile.
Spawn / empty states
- Spawn modal accepts a prefill (clone); real "+ Spawn" buttons in welcome and
no-sessions empty states.
Dev
- New ?kit design-kit page (kit.ts/kit.css, DEV-only) showcasing components.
- Richer ?mock fixture with deeper label nesting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The peer (T2) attach path had a per-pair access-control layer
(`[daemon.policy].allow_inbound`): a target-side allow-list of daemon ids
permitted to broker-attach, enforced inside JWT verification, hot-reloadable via
SIGHUP / `POST /api/policy/reload`, with a `maestro policy` CLI. On a flat-trust
perimeter (the operator's VPN/firewall is the boundary for which hosts connect)
that granularity is unused, and deny-by-default made it config friction —
cross-daemon attach silently failed until every peer was listed.
Remove the allow-list; keep the registry-minted pair-attach JWT and ALL its
binding checks (iss=registry, aud=target, sid=session, exp). The firewall gates
host->port reachability; it does not stop a reachable host from attaching to a
session it wasn't handed or replaying a token elsewhere — that's the JWT's job
and it stays. Only the `sub in allow_inbound` check is dropped.
Removed: DaemonPolicySection / [daemon.policy].allow_inbound (config + daemon),
policy.rs (SIGHUP listener + GET/POST /api/policy), the `maestro policy` CLI,
PairAttachVerifier's allow-list field/reload/snapshot + JwtError::NotAllowed,
and the e2e broker_rejects_when_target_doesnt_allow_inbound test. The NAP-265
pre-upgrade-502 path stays covered by broker_attach_returns_502_on_perimeter_mismatch.
Also dropped the unit's ExecReload=kill -HUP: with no SIGHUP handler left,
default SIGHUP disposition is terminate, so wiring `systemctl reload` to it would
kill the daemon. Re-introducing a per-pair gate later is a verify-time change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A registry-driven CreateSession/KillSession was reported to the registry twice:
the RPC reply (applied eagerly in create_session/kill_session) AND the async
SessionAdded/SessionRemoved the daemon emits on its own SessionManager stream.
Both landed, so the registry's catalog write had to be idempotent to absorb the
duplicate — masking bugs where the async event never arrives and setting a trap
for any future non-idempotent event (counters, partial updates).
Drop the duplicate at the source: the daemon parks the session id of an
RPC-initiated create/kill in a per-connection RpcCovered set and swallows the
matching async event (consumed exactly once) before it reaches the registry.
Spontaneous changes — sessions created via the daemon's local HTTP API, or
reaped on their own — are not parked and still forward, since the registry has
no other way to learn about them. A lag resync clears the set (the snapshot is
full truth). The registry's eager apply is now the authoritative single write,
not a race-bandaid; idempotency is no longer a correctness invariant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote `COOKIE_NAME` and a robust `extract_cookie` (iterates every `;`-pair,
so it works on both `Cookie:` and `Set-Cookie:` headers regardless of position)
into `maestro-protocol`. The registry re-exports them; the CLI drops its weaker
`extract_cookie_value` (which only inspected the first pair) and its duplicate
`COOKIE_NAME`. Both ends of the wire now agree on one definition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
D: the four independent `= 256` broadcast-channel constants (session events,
local event bus, PTY fan-out, registry events) now derive from one shared
`maestro_config::BroadcastTuning::DEFAULT`, so the coincidence is explicit and
there's a home for per-deployment tuning later.
E: `kill()` and `reap_dead_session()` hand-rolled the same FIFO/sidecar/
worktree/broadcast teardown, differing only in whether tmux still had a session
to kill. Extracted into `cleanup_record(inner, id, record, kill_tmux)`. kill's
sidecar-delete is now best-effort (the session is already out of the
authoritative map), matching the reaper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `parse_bind_addr`, `default_state_dir(subdir)`, and an `InvalidBindAddr`
variant to `maestro-config`, and route the daemon, registry, and web config
loaders through them. Each crate's hand-rolled `InvalidBindAddr` variant and
duplicated XDG state-dir resolution are gone; their loaders now `?` the shared
helper, which converts into their existing transparent `Inner` variant.
Also adds the shared `BroadcastTuning` struct (used by the next commit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Answers the six NAP-331 questions and proposes an 8-ticket impl breakdown.
Grounds decisions in existing infra: DaemonInfo.version is already fanned out
by the registry, [autoupdate] fits the sectioned config, and NAP-330's
KillMode=process already gives restart-without-session-loss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the full-screen route-swap SPA with a persistent two-pane layout:
- Global top bar (brand + Spawn + theme) above a sidebar (search + status +
session tree, highlights the active session) and a content pane (welcome or
the attached terminal). Selecting a row swaps only the content pane; the
sidebar and its store subscription persist.
- New `connections.ts` ConnectionPool: attach connections (terminal + WS) are
decoupled from the UI — navigating away parks a session (keeps it streaming)
rather than tearing it down, so switching back is instant with scrollback
intact. LRU-bounded (MAX_LIVE=6); explicit `⏻` disconnect distinct from nav.
- `attach.ts` → mountable `createAttachConnection`; `catalog.ts` exposes
`renderTopbar` + `renderSidebar`; `main.ts` is the shell + hash router.
- Responsive: desktop side-by-side; mobile single-pane (list ↔ terminal).
Single session viewed at a time (split panes deferred).
Typecheck + production build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The generated `maestro.service` used systemd's default
`KillMode=control-group`, so `systemctl restart` (any update/auto-update)
SIGKILLed the daemon's whole cgroup — including the tmux server and every
`maestro-<uuid>` session. recover() then found nothing, silently destroying
all live sessions and their processes on every restart.
Emit `KillMode=process` in the daemon unit so only the daemon's MainPID is
signalled; the tmux server survives and recover() re-adopts the sessions on
the next start. (The web unit spawns no tmux, so it's left unchanged.)
Tests assert the daemon unit carries KillMode=process and the web unit does
not. INSTALL.md notes that updating keeps sessions, and that units written
before this default need a refresh.
Verified live on a host: with KillMode=process a restart leaves the session's
PID unchanged (recovered=1); without it, recovered=0 and the process is gone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI runs as root, where the 0o500 read-only sidecar dir doesn't block the
write the test relies on failing — so it reported a spurious failure and
has reddened main since commit 149. Probe writability and skip when root;
non-root runs still assert the rollback.
Rewrite styles.css as an OKLCH design-system: cool-slate neutrals with a
single committed signal-green for "alive" (running status, primary action,
focus, selection). Monospace becomes the UI typeface; dark is default with a
full light theme via tokens, OS-following with a persisted header toggle.
- catalog: pulsing wordmark, status-bar tally (N sessions / M live) + daemon
chips, color-independent session status (pulsing dot for running, hollow
ring + "exited" badge for dead), reveal-on-hover kill, three teaching
empty states.
- attach: state pill (live/closed/error), framed dark xterm w/ green cursor.
- spawn: bottom-sheet/dialog form, mono inputs with green focus rings.
- a11y: AA contrast both themes, visible focus, prefers-reduced-motion paths,
status never color-only.
- theme.ts: dark/light toggle + persistence; mock.ts: DEV-only review fixture
(?mock / ?mock&spawn / ?theme=), tree-shaken from production builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the web UI's strategic register (product) and the visual design
system (OKLCH token palette, monospace-first type, dark+light theming) for
the impeccable workflow. Supersedes the transient .impeccable.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`cargo fmt --all -- --check` was already failing on `main` (CI run 149) due to
unformatted code in broker.rs and http.rs; e2e.rs carried the same drift. This
applies rustfmt to those three so the branch passes the fmt gate. No behavior
change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two web spawn QoL improvements plus the wire support they need:
- Command field now prefills `claude` instead of an empty box.
- Working directory prefills the selected daemon's home dir, updating on
daemon change unless the user has typed their own value.
The daemon resolves its `$HOME` via `dirs::home_dir()` at startup and
advertises it end-to-end: a new `home_dir` field on `DaemonOutbound::Hello`
flows through the registry's `register` into `DaemonInfo` (served on
`/api/daemons` and `/api/events`). The field is `#[serde(default)]`, so older
daemons that omit it deserialize to an empty string — backward compatible, and
the UI simply skips prefill when it's empty.
Hierarchical `/`-separated labels in the web sidebar already worked
(web/src/catalog.ts); no change there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 501 path is gone; update the stale "rejects cross-daemon create" / "broker
is attach-only" module docs to describe the brokered create/kill RPCs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The home daemon now brokers cross-daemon create/kill itself, so the CLI only
falls back to the legacy registry HTTP path when the home daemon is
*unreachable* — not on 501/404. Removes the now-dead `is_remote_op_unsupported`
/ `is_not_found` / `has_status` predicates.
Adds an e2e (real tmux, two daemons + registry): POST to alpha with
daemon_id=beta brokers the create to beta and returns beta's session; a kill
via alpha reaches beta. Both verified against the registry catalog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The home daemon's `POST /api/sessions` (peer `daemon_id`) and
`DELETE /api/sessions/:id` (peer-owned) now broker the op to the owning daemon
over the registry control link rather than returning 501/404. This fixes
cross-daemon spawn/kill for *all* local clients — notably maestro-web, which
talks only to the local daemon and had no registry fallback.
- broker: `BrokerHandle::{forward_create,forward_kill}` + completion handlers,
mirroring `resolve_attach` (fail-fast while disconnected, RAII pending
cleanup, RESOLVE_TIMEOUT).
- registry_client: route `Forward{Create,Kill}Result` back to the broker.
- local_api: create forwards on peer `daemon_id`; kill looks up the owner in
the catalog mirror and forwards (only a truly-unknown session is 404).
`broker_error_response` maps BrokerError → HTTP (503/504/502/404/...).
- cli: thread the broker into `LocalApiState`.
Tests: broker round-trip / fail-fast / registry-err propagation; updated the
local_api integration test (peer create now 503s without a registry link
instead of 501, proving it brokers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the home-daemon → registry control-link RPC that brokers a cross-daemon
session create/kill without putting any session bytes on the registry: the
owning daemon still does the tmux spawn, the registry just relays request and
reply.
- protocol: `DaemonOutbound::{ForwardCreate,ForwardKill}` +
`DaemonInbound::{ForwardCreateResult,ForwardKillResult}` (+ round-trip tests).
- registry: `Registry::{forward_create,forward_kill}` reuse the existing
`create_session`/`kill_session` R→D machinery and route the reply back to the
home daemon; transport faults map to stable `ErrorInfo` codes. Wired into the
`WS /daemon` dispatch.
Next: daemon-side broker handle + local_api wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Dockerfile still built `-p maestro-registry` and copied
`target/release/maestro-registry`, but NAP-245 retired the per-binary crates —
`maestro-registry` is now a library, so the build produced no such binary and
the `cp` failed, breaking every Dokploy deploy.
Build the umbrella `maestro` binary with only the `registry` feature
(`-p maestro --no-default-features --features registry`), copy
`target/release/maestro`, and run its `registry` subcommand via ENTRYPOINT.
Also drops the now-vestigial web-builder stage (the registry no longer embeds
the web bundle — that moved to the separate maestro-web binary).
Validated locally: docker build succeeds and the image boots, binds
0.0.0.0:8080, and serves HTTP 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`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>
Every `SessionManager` tmux call shelled out via blocking
`std::process::Command::output()` on the tokio worker — including `send_keys`
on the per-keystroke hot path. Under concurrent typing the small worker pool
stalls on `tmux` fork+waits (tens of ms on a loaded host), starving HTTP
handlers, the registry WS, the liveness watcher, and PTY readers.
Wrap each call site (create/kill/recover/liveness/send_keys/resize/pipe_pane)
in `tokio::task::spawn_blocking` via a small `tmux_blocking` helper, so the
blocking fork+wait runs on the blocking pool and never parks an async worker.
The `Tmux` API stays synchronous (lower blast radius than an async migration;
its tests are unchanged). Full daemon suite + send_keys/broker e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`(perimeter_id, private_addr)` was carried as two `Option<String>`s with an
unwritten "both Some or both None" invariant, enforced ad-hoc at every reader
and silently violable (`with_perimeter(Some, None)`).
Collapse it to `Option<PerimeterCandidate>` (reusing the existing protocol
type, so it flows unconverted from Heartbeat → registry → AttachCandidates):
- protocol: `Heartbeat { perimeter: Option<PerimeterCandidate> }` (empty still
serializes to the legacy `{"type":"Heartbeat"}` shape).
- registry: `DaemonEntry.perimeter`, `update_perimeter`, `perimeter_for` lose
the defensive AND-check; `build_attach_candidates` uses it directly.
- daemon: `RegistryClient.with_perimeter(Option<PerimeterCandidate>)`; the
both-or-neither rule now lives once, in `DaemonConfig::perimeter()`, which
warns and drops a half-configured pair instead of advertising it.
Unblocked by NAP-270's coverage; broker T2 + perimeter-mismatch e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The asymmetric-VLAN security boundary and the broker's RPC error branches had
no tests, so a regression that leaked a JWT across perimeters or dialed across
them would ship undetected. Adds the required coverage:
registry (state.rs unit):
- perimeter_for_requires_both_id_and_addr
- resolve_attach_refuses_self_target
- resolve_attach_returns_unknown_session_for_orphan_id
daemon broker (broker.rs unit):
- broker_resolve_attach_propagates_registry_err
- broker_resolve_attach_times_out_when_no_reply (paused-clock, instant)
daemon e2e:
- broker_attach_returns_502_on_perimeter_mismatch (two daemons, different
perimeters → structured 502, never dials across)
Enables tokio `test-util` in maestro-daemon dev-deps for the paused-clock test.
Unblocks NAP-272.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`broker_attach_ws` upgraded the client WS first and only then dialed the peer
inside `pump_broker_ws`; a dial failure (peer down, perimeter rejection, peer
verifier refusing the pair JWT — all reported pre-upgrade) just logged and
closed the socket, indistinguishable from a clean session end.
Move the `connect_async` ahead of `on_upgrade`: on failure return a structured
`502 peer_unreachable` (matching the other pre-upgrade error paths); on success
hand the connected upstream to `pump_broker_ws`.
Updated the allow_inbound-reject e2e to assert the handshake now fails with 502
instead of an opaque post-upgrade close.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`BrokerHandle::resolve_attach` queued ResolveAttach frames with
`out_tx.send().await`. While the registry was disconnected nothing drained the
bounded channel, so after 64 queued frames every caller blocked on the send —
HTTP `/api/attach/:id` requests for peer sessions hung instead of erroring.
Track link state with an `AtomicBool` the registry-client flips on connect /
disconnect: `resolve_attach` returns `NoControlLink` immediately while down, and
uses a non-blocking `try_send` so a back-pressured-but-connected channel can't
block either.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cookie and pair-attach JWTs are signed with the same shared secret. Neither
verifier bound the token's purpose, so a token minted for one could in principle
validate as the other.
- Add a canonical `REGISTRY_ISSUER` to maestro-protocol; registry re-exports it.
- Auth cookies now carry `iss = maestro-registry` + a dedicated `aud =
maestro-cookie`; `verify_cookie` binds both, so a pair-attach JWT (daemon-id
audience) is rejected.
- `PairAttachVerifier::verify` now binds `iss = REGISTRY_ISSUER`, rejecting any
token not minted by the registry.
Tests: pair-verifier rejects a foreign issuer; verify_cookie rejects a
pair-attach JWT. Existing cookie + broker e2e flows unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create() ran worktree-add → tmux spawn → sidecar write with no unwinding. A
failure in a later step leaked earlier ones — worst case a sidecar write
failure (disk full / EIO / permission) left a ghost `maestro-<uuid>` tmux
session running with no in-memory record, silently adopted with empty metadata
on the next daemon restart.
Each step now unwinds the ones before it: bridge/tmux failures remove the
worktree (+ FIFO), and a sidecar-write failure kills the just-spawned tmux
session before removing the FIFO + worktree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`proxy_http` buffered request bodies with `to_bytes(.., usize::MAX)` and
materialized full upstream responses with `.bytes()`. Anyone reachable on the
bind address could POST a multi-GB body (the cookie middleware admits any
signed cookie) and OOM the process; a large upstream reply did the same.
Cap buffered request bodies at 8 MiB and return 413 on overflow; stream the
upstream response straight through via `bytes_stream()` → `Body::from_stream`
so it's never fully held in RAM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`BrokerHandle::resolve_attach` (daemon) and `Registry::create_session` /
`kill_session` (registry) insert a pending oneshot into a map, then await
the reply. If the awaiting future is cancelled — e.g. an axum handler
dropped because the browser closed the tab or aborted the XHR — the entry
was never removed, leaking one map slot per cancelled request and growing
unboundedly under UI-driven retry traffic.
Introduce a small RAII `PendingGuard` that removes the entry on drop, so
cleanup happens on every exit path including cancellation. Because a sync
`Drop` can't lock a tokio mutex, move the pending maps to
`std::sync::Mutex` (only ever locked for a tiny insert/remove, never held
across an await): broker `pending`, and the registry's `pending_create` /
`pending_kill` lifted out of the async-locked `State`. Removal is
idempotent, so the happy path (reply already taken by `complete_*`) and
the guard's drop don't conflict; `await_rpc` no longer needs its manual
timeout cleanup.
Tested: cancelling an in-flight `resolve_attach` (broker) and
`kill_session` (registry) leaves the pending map empty.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Registry::deregister` removed the catalog entry unconditionally. If a daemon's
old WS handler hit heartbeat timeout *after* the daemon had reconnected on a
new socket, the stale handler's deregister wiped the freshly-installed live
entry and broadcast DaemonRemoved + SessionRemoved for all its sessions.
`register` now mints a per-connection fencing token (uuid) stored on the
DaemonEntry and returned to the handler, which holds it for the connection's
life. `deregister(daemon_id, token)` only tears down the entry when the stored
token matches, so a superseded handler's late deregister is a no-op.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`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>
`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>
Drives the real `maestro` binary: one test points `MAESTRO_HOME_DAEMON_URL` at
a live daemon with no registry configured (proves the catalog read comes from
the home daemon), the other points it at a dead port and asserts the CLI warns
and falls back to the registry while still listing the session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`maestro list/spawn/kill/attach` now target the home daemon's local API
(`[cli].home_daemon_url`, default http://127.0.0.1:7070) instead of the
registry directly. The daemon brokers transport selection and `allow_inbound`
enforcement.
- list reads the daemon's catalog mirror (own ∪ peer sessions)
- spawn/kill hit the daemon's local session endpoints
- attach opens the daemon's `WS /api/attach/:id` broker (shared pump loop
via the new `run_attach_url` / `broker_attach_url`)
Compat fallback to the legacy registry path (HTTP + direct WS) with a stderr
warning when the home daemon is unreachable. Cross-daemon spawn (501) and kill
(404) — not yet brokered locally — also fall back so they keep working.
Adds `[cli].home_daemon_url` config + `--home-daemon-url`/`MAESTRO_HOME_DAEMON_URL`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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.
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.
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.
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>
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>
- `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.
`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.
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).
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.
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>
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.
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.
Compiling maestro-web now requires the frontend toolchain to build the
embedded SPA bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
`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).
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.
- `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.
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.
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.
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`.
build.rs runs pnpm install+build before include_dir! embeds web/dist, so
any install method (cargo install --git, --path, wizard) ships a real
bundle instead of the placeholder. Hard-fail when pnpm/node missing;
wizard prompts abort/skip; CI gains the node+pnpm toolchain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spec covers the new `crates/maestro-tui/` crate gated on a `tui` feature of
the umbrella binary: ratatui + vt100, sidebar/tab/pane components, configurable
keymap (defaults are defaults), first-class mouse, vim-buffer mental model.
Goals + non-goals; v1 ships tabs-only, v1.5 enables splits on the same layout
tree. Talks only to the local daemon's `/api/*` surface — peer sessions reach
us via the catalog mirror, attach hops are broker-mediated transparently.
`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.
- 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).
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`.
`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>
`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>
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>
`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>
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>
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>
`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>