The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`,
a module constant. Rebinding one left the other on `ctrl-a`, so the two
escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`)
moved on one client and not the other.
`GET /api/info` now carries `leader`. The web binds that when it arrives and
rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command
bar render the configured chord; an absent or empty field — an older daemon,
or an unreachable `/api/info` — leaves it on `ctrl-a`.
Chose the existing `[tui].leader` served as-is over a new shared key: one
knob, no precedence rules between two spellings, and no migration for
operators who already set it. The name is a wart — it is the leader for every
client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md`
say so.
Spawn was `s`/`S` in the TUI and `n`/`N` in the web client; the TUI now
uses `n`/`N` too. The web sidebar gains `c`, `r` and `x` — collapse,
rename, kill — which the TUI already bound.
docs/SIDEBAR.md now carries the shared table, so a new binding has one
place to land on both clients.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The browser reached the daemon through maestro-web, a second binary that
served the SPA and reverse-proxied /api/* — two TCP connections and a
WebSocket frame re-encode on every keystroke and every byte of PTY output.
On one machine that hop buys nothing.
The daemon now embeds the bundle and serves it off its own listener, behind
a default-on `web` feature. `--no-default-features` builds without pnpm or
node; MAESTRO_WEB_DIST still takes a prebuilt bundle.
The router's fallback answers an unmatched /api/* with 404 rather than the
SPA shell, so a client expecting JSON gets a status it can read instead of
HTML.
Removes maestro-web and the [web] config section. An existing [web] block
is ignored, not rejected. The installer deletes a leftover
maestro-web.service on upgrade.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the army-green dark/light palettes with neutral graphite and
adds ice-cyan, periwinkle, amber-phosphor, ember and orchid presets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`s` (OpenSpawnModal, was NewDraftPane) puts the form up as a centered
overlay instead of taking over the pane. The attached session stays
attached behind it and comes back untouched on Escape; the modal owns
every key while it is up, so `x` and `j` typed into a field no longer
reach the keymap.
Tab::pane is a session id again and PaneRef is gone. The form lives on
App next to the rename buffer, so its loads no longer address a tab
index that could not identify them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`r` opens a prompt pre-filled with the session's own name — empty when it
has none, so Enter cannot pin the command as the name. Enter saves, Escape
cancels, and an emptied field clears the name.
Refs #125
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Solarized (dark/light), Catppuccin Mocha, GitHub (dark/light),
Tokyo Night, Gruvbox, Nord, Dracula. Colours auto-tuned from the
canonical seeds to clear the palette contrast floors; tests now
assert every RGB preset, not just dark/light.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ships `maestro`, `maestro-web` and the SPA bundle as flake packages, plus a
`services.maestro` Home Manager module that runs the daemon and the web UI as
systemd user services.
`maestro-web`'s build.rs now honours MAESTRO_WEB_DIST and hands the resolved
bundle path to `include_dir!` through MAESTRO_WEB_BUNDLE_DIR. Without it the
Nix build would have to run `pnpm install` mid-compile, which the sandbox has
no network for. Unset, the pnpm path is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They described the registry/peer-to-peer design and were kept only for
their superseded banners. CLAUDE.md is the current reference. Repoints
the doc comments that cited them, and fixes the maestro-web README,
which still named MAESTRO_REGISTRY as the Vite proxy override
(MAESTRO_DAEMON_URL since the refactor).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewrites CLAUDE.md as the current-architecture reference, drops the
registry from INSTALL/PRODUCT/DESIGN and the web README, deletes the
registry Dockerfile, and marks the dated design specs superseded where
they describe the registry, JWT auth, or cross-host attach.
The installer loses its configure step — every config field now has a
default, so a daemon starts with no config file at all.
Cross-host (T2-brokered) attach fails when no shared perimeter is
configured: the daemon rejects the upgrade with a structured 502
(no_perimeter_candidate / no_local_perimeter / perimeter_mismatch /
peer_unreachable), but maestro-web's WS proxy had already upgraded the
browser socket and merely dropped it on the upstream failure — the
attach pane flashed and vanished with no reason shown.
- maestro-web proxy: on a failed upstream handshake, relay the daemon's
error message to the browser as an application close frame (4502 +
reason) instead of a bare drop; parse the structured JSON error body.
- web attach.ts: on a 4000+ close with a reason, show it in the pane
header and terminal body and keep the pane up, rather than auto-closing.
- daemon: WARN at startup when no perimeter_id/private_addr is configured,
since cross-host attach is then unavailable.
- docs/INSTALL.md: document the required matching perimeter_id + per-host
private_addr for cross-host attach.
Closes#39
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>
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>
- `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.
- 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).
`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>
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>
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>
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>
- Installer: check for a `[daemon]` section in the unified file (rather
than the existence of the now-defunct `~/.config/maestrod/config.toml`)
to decide whether to skip the configure prompt on re-runs. The
configure prompt + maestrod_config_path() helper now reference
`~/.config/maestro/config.toml`.
- docs/INSTALL.md: mention the unified file and the single-TOML shape.
- spec: update §Registry / §Daemon / §Auth to call out
`[registry].bind_addr`, `[daemon].bind_addr`, `[shared].registry_url`,
and `[shared].token` instead of the old per-app files.
Earlier today I picked option 1 from NAP-231's open question (run
`maestro-registry` locally on a different port). That was wrong: the
user-facing framing is "local web UI", and option 2 — a thin front-end
that serves the web bundle and reverse-proxies `/api/*` to whatever
registry the daemon is already registered with — is what actually
delivers that.
What the new crate does:
- `crates/maestro-web` ships a single binary `maestro-web`.
- It embeds the web bundle the same way `maestro-registry` does
(rust-embed against `web/dist/`).
- Mounts the bundle at `/` and a reverse proxy at `/api/*` (catch-all).
- HTTP proxy via `reqwest` — forwards method, headers (minus
hop-by-hop), body, and passes upstream response headers (including
`Set-Cookie`) back to the browser.
- WS proxy upgrades both sides and shuttles frames with
`tokio::select!`. Cookies on the initial `WebSocket` upgrade headers
are forwarded so the registry sees the same browser session.
- Reads `MAESTRO_REGISTRY` (upstream URL) and `MAESTRO_WEB_BIND_ADDR`
(default `127.0.0.1:8888`) from env.
- An integration test (`tests/proxy.rs`) stands up a mock upstream,
proxies an HTTP request with cookies through, and round-trips a text
WebSocket frame to verify the WS path.
Installer changes:
- Wizard now `cargo install`s `maestro-web` (not `maestro-registry`)
when the user opts into the web service.
- Unit template switched: `ExecStart=%h/.cargo/bin/maestro-web` plus
`Environment=MAESTRO_REGISTRY=<upstream>` +
`Environment=MAESTRO_WEB_BIND_ADDR=<bind>`.
- New `--web-registry <URL>` flag. Default in interactive mode: read
`registry_url` straight out of `~/.config/maestrod/config.toml`, so
the proxy lines up with what the daemon is using without further
prompting. Non-interactive runs without either of those error out
rather than guess.
Docs:
- `docs/INSTALL.md` updated unit body + flag table.
- `CLAUDE.md` repo-layout block now lists `maestro-web`.
148 tests pass workspace-wide (9 new in maestro-web, including the
proxy integration test; 15 in installer).
After the daemon-unit step, the wizard now offers a second user unit
(`maestro-web.service`) that runs `maestro-registry` on 127.0.0.1:8888
so the web UI is reachable in a browser locally.
Decisions:
- The unit runs `maestro-registry` directly — the web bundle is already
embedded in that binary, so a separate `maestro-web` binary would be
pure cost. `Description=` and the unit filename make the user-facing
framing clear.
- `Wants=maestrod.service` + `After=` (not `Requires=`): the registry
process doesn't actually need the daemon to start; the web UI just
has nothing to show without one. Soft dependency expresses that.
- Default loopback bind is `127.0.0.1:8888` to make it obvious which
port is the local UI vs a separately deployed registry on `:8080`.
Wizard surface:
- New prompt (default *no*) after the daemon-systemd step.
- New flags: `--no-web`, `--web-bind <addr>`, `--web-exec-start <path>`.
- Skipped automatically when systemd is absent or `--no-systemd` is set.
- The re-run/update path mirrors the daemon flow: refresh the unit,
daemon-reload, restart instead of start.
- `maestro-registry` is only `cargo install`ed when the user opts in,
keeping the no-web install footprint unchanged.
docs/INSTALL.md gets the new flags + a full sample unit body.
The wizard now treats existing state as "we're updating, not installing":
- If `~/.config/maestrod/config.toml` exists, skip the configure prompt with
a clear notice. Sets `configured = true` so the systemd start logic still
makes sense.
- If the systemd user unit already exists, refresh the file in place, skip
the enable prompt (it's either already enabled or the user said no last
time), and ask to `restart` instead of `start` — so the new binary
actually takes effect.
Adds `SystemctlAction::Restart` and a test for it. Same `./install.sh` works
for both first install and subsequent updates; no `--update` flag.
Refs NAP-225.
`curl | sh` only works for public repos — gitea hides private repos with a
404 to unauthenticated clients. Lead with downloading `install.sh` via the
signed-in web UI (or `git show origin/main:install.sh`) and running it
locally. Note that the inner `cargo install --git` still uses whatever git
credentials the host already has, and document `MAESTRO_REPO_URL=ssh://...`
for SSH-only setups.
Refs NAP-225.
Single command for both first install and subsequent updates:
curl -sSf https://git.naps.pt/naps62/maestro/raw/branch/main/install.sh | sh
Builds `maestro-installer` into a temp `--root`, runs the wizard, cleans up —
so only `maestrod` and `maestro` persist in `~/.cargo/bin`. Re-running updates
in place via cargo install's overwrite semantics. Wizard flags pass through
with `sh -s --`. Docs updated to lead with the one-liner.
Refs NAP-225.
New `maestro-installer` crate exposing the `maestro-install` binary. Bootstrap
flow: `cargo install --git ... maestro-installer && maestro-install` runs
`cargo install` for `maestro-daemon` and `maestro-cli`, optionally drives
`maestrod configure`, and on systemd hosts writes a user unit at
`~/.config/systemd/user/maestrod.service` plus reload/enable/start. Pure
builders (cargo install args, systemctl args, unit body) live in the lib and
are unit-tested; main.rs is the prompt glue.
Refs NAP-225.