49ab7e9 made a new-branch worktree add suffix the branch name instead of
failing, and documented it, but left two tests asserting the old hard
rejection. The rejection contract (#66) still holds for an existing
branch that is already checked out, so that test now exercises that
case.
Adds `SessionView { Terminal, Structured }` to maestro-protocol, a
defaulted `view` field on `Session` and `CreateSessionRequest`, and
sidecar round-trip tests for both a legacy payload with no `view` key
and a `Structured` session. Nothing reads the field yet; every other
struct literal across the workspace picks up `Default::default()`
(Terminal) to keep compiling.
Gitea #133.
git worktree add -b fails outright when the branch already exists, even
if the target directory is free. A random fast-forward handle commonly
collides with a branch an earlier, already-removed worktree left behind.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds POST /api/sessions/:id/input and a CLI command on top of it, so a
script (or another agent) can hand an instruction to a running session
without attaching to it.
The text goes in as one write and the Enter follows 150ms later: agents
that run a full-screen prompt coalesce a burst of input into a paste, and
a newline inside that burst becomes a line break instead of sending the
message. `--no-enter` skips the Enter and leaves the text in the prompt.
The session argument takes an ID, a unique ID prefix, or a session name;
names win over prefixes. A session whose pane has exited is refused (409)
rather than silently accepted — unlike a stray keystroke on an attach
socket, a send that goes nowhere is worth hearing about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the retained-worktree store end to end: the daemon's state-dir
store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the
TUI overlay behind `w`, and the web `#/worktrees` view and header link.
The kill guard stays. A forced kill and a dirty natural exit now leave
the worktree on disk untouched instead of parking it in a store, so
removing one is an ordinary `git worktree remove`. A pristine tree is
still removed on exit. DELETE /api/sessions/:id always answers 204.
The header's debug button also goes; Ctrl+Shift+D and `?hud` still
toggle the HUD.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves the e2e conflict in favour of #88's helper, rewritten as a
struct literal now that cleanup_on_kill is gone — #88 had built the
spec through serde_json to dodge the field, which cost compile-time
checking for no reason once the field no longer exists.
Closes#88
Every scenario from the contract, against a real daemon, tmux and git:
collision suffixes with both trees working, foreign directories
sidestepped without adoption, the guarded kill and its refusal
breakdown, save-then-kill succeeding, --force retaining the tree,
natural exits settling dirty and pristine worktrees differently, the
retained list with reasons, reclaim keeping the branch, and the store
surviving a daemon restart.
Worktree specs are built through their wire shape so the tests carry no
reference to WorktreeSpec.cleanup_on_kill, which #87 is removing.
Three in session_manager.rs arrived with #82 after #87 branched; four in
maestro-e2e were fenced off because #88 owns that crate. Removing the
field without them left the workspace uncompilable.
The flag overrides the kill refusal (#81): the session ends, the guarded
worktree is retained, and the CLI prints where the work went — path,
branch, force-killed — instead of leaving the operator to wonder.
WORKTREES.md: write down that a forced kill of a tree whose guard is
clear removes it as usual — force changes what happens to blocked work,
not ordinary cleanup.
Collapses the two probes #79 and #80 grew in parallel into one:
worktree_probe::in_the_way is the single predicate behind the kill
refusal, the retained list, and the natural-exit classifier to come.
Keeps #79's in-worktree resolution (detached HEAD, missing directory)
and #80's InTheWay payload, and drops the duplicate UnsavedWork type.
Closes#79
The guard moves off the worktree and onto the kill. DELETE
/api/sessions/:id now runs the shared unsaved_work probe before tearing
anything down and answers 409 kill_guarded with the breakdown on the
wire, so a client can say "3 uncommitted files, 2 unpushed commits".
The session keeps running and the tree is untouched.
WorktreeManager::remove is unconditional now — git itself still refuses
a dirty tree as a last line of defense.
The existing-branch picker could never succeed in either client: both
treated the main checkout's HEAD as available, and since that is usually
`main` it became the default pick — git then refuses the second checkout.
A TUI test had baked the bug in by asserting the selection lands on the
`is_head` entry. Both pickers now treat "checked out anywhere" as held.
Worktree paths were still not injective. Flattening `/` and `.` to `-`
made `feat/x`, `feat.x` and `feat-x` share one directory, and the
override root keyed on the repo basename alone, so `~/a/maestro` and
`~/b/maestro` collided. A branch name is already a valid relative path —
git stores it as refs/heads/feat/example — so it is used verbatim and
nests under `worktrees/feat/`; the override root gains a digest of the
full repo path. Git forbids holding both `feat` and `feat/x`, so the
nesting cannot collide for valid refs.
Also:
- Recovered sessions dropped their worktree association, so after a
restart a kill cleaned up nothing and the leftover directory blocked
that branch forever. It is persisted on Session now.
- The nesting guard compared git's resolved paths against an unresolved
one, so it silently passed on any symlinked repo path — and that guard
is what stands between a kill and an inner tree's uncommitted work.
- Draft loads ran on the TUI event loop: /api/projects on a cold cache
walks all of $HOME, freezing keys, frames and attached panes for up to
the request timeout. They are detached tasks posting to a channel now.
- Fast-forward in the web client created the session and never mounted
it, so `N` looked like a dead key while the TUI attached.
- The fast-forward fallback seed forced the worktree toggle off, against
both a normal draft and the TUI.
- The projects background refresh honoured the TTL, so two of every three
ticks were no-ops and requests paid for the rescan instead.
There were three answers to "how do I test against a daemon", and the CLI
— the thinnest client — had none, so its formatting and error handling
were exercised by compiling and forking the real binary plus tmux.
Adds crates/maestro-testing with a MockDaemon that serves the real /api/*
surface from maestro-protocol types, backed by a Vec<Session>. It replaces
two ad-hoc stubs: maestro-web's, which returned [{"id":"session-1"}] —
not a valid Session — and maestro-tui's scripted socket with a hardcoded
timeline.
maestro-cli gets its first tests against a daemon: six cases covering the
catalog, that DaemonInfo carries the version and presets its old private
copy dropped, that a structured failure surfaces the daemon's own message,
and that connection-refused stays distinguishable from an HTTP rejection.
maestro-web's proxy suite gains an end-to-end check that an upstream
rejection reaches the browser as a 4502 close carrying the daemon's
message — upstream_close_reason had only unit tests.
e2e keeps the tmux-shaped tests. cli_list_reads_from_daemon still runs the
shipped binary as a subprocess but now against MockDaemon, and
tui_integration.rs is deleted: the real daemon's /api/events contract is
covered by maestro-daemon/tests/local_api.rs and the client reducer by
maestro-tui/tests/catalog_ws.rs. The e2e suite drops from ~32s to ~20s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the harness's deliberate 5s per-request budget, which the shared
client's 30s default had silently replaced, and converts the last two
hand-rolled daemon calls in e2e.rs (recent-dirs, gitroot). Drops reqwest
from maestro-e2e entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI, TUI and end-to-end harness each carried their own client: three
reqwest builders with three timeout policies, three copies of the
success-or-body check, and route strings at every call site. Two of them
discarded the daemon's structured error and reported only a status code.
Deletes `maestro-cli/src/http.rs`, both `ws_base` copies in the TUI, the
CLI's partial `DaemonInfo` (it dropped `version` and `presets`), and the
e2e harness's `check_ok`. `maestro-web` gains a protocol dependency and
stops building the error envelope with a format string and reading it back
as untyped JSON; the daemon and the proxy now register routes from the same
constants.
`maestro_web::router` returns `Result` — an unusable daemon URL fails at
startup instead of at first connect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Maestro is now single-host: one daemon owns the machine's sessions and
clients talk to it directly. Removes the registry crate, the daemon's
registry client/broker/catalog mirror, JWT attach auth, cookie auth, and
the perimeter (T2) transport config.
The daemon API is now unauthenticated on its bind address, same trust
model as tmux. Session and CreateSessionRequest lose daemon_id;
RegistryEvent becomes CatalogEvent with no daemon variants; attach moves
to WS /api/attach/:id with no token.
Add an orthogonal activity dimension to sessions, inferred entirely from
structured tmux/`/proc` state — no PTY byte-stream parsing, no work on the
attach hot path.
Protocol: `ActivityState` enum (Active/Idle/AwaitingInput) + three additive,
serde-defaulted `Session` fields (`foreground`, `activity`, `last_activity_at`)
so the snapshot/diff flow stays forward-compatible with older daemons.
Detection (piggybacks the existing ~2s liveness tick, no new timer):
- foreground command via `tmux list-panes -F '#{pane_pid} #{pane_current_command}
#{pane_tty}'`;
- quiescence from a per-`PtyBridge` last-read `Instant`, bumped in `run_reader`;
- AwaitingInput via best-effort `/proc` walk to the deepest descendant: state
`S` + a non-timer `wchan` + an stdio fd on the pane tty. (`/proc/pid/syscall`
is ptrace-gated and unreadable for own sessions, so `wchan` distinguishes a
blocking read from a plain `sleep`.)
- combine: recent output → Active; else input-blocked → AwaitingInput; else
Idle. Emit `SessionUpdated` only on (activity, foreground) change — no diff
storms at steady state.
`maestro list` gains an ACTIVITY column and shows the live foreground. Pure
combine/parse logic is unit-tested; an e2e asserts `cat` → AwaitingInput and
`sleep` → Idle with correct foreground against real tmux.
Closes#18Closes#8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Web: rename the per-row clone affordance to "Spawn similar" — it reopens the
revamped spawn form pre-seeded from the session's host, dir, command (matched
back to a preset by the form), and label, but with a freshly generated name
since it's a new session. Built entirely on the existing prefill machinery; the
custom event is renamed `catalog-spawn-similar`.
e2e (maestro-e2e): against real tmux + git, spawn into a git repo dir and assert
(1) the daemon's recent-dirs endpoint surfaces that dir with its command — what
#27's recents picker reads on the next form open — proving the durable
spawn-history write, and (2) gitroot infers the enclosing repo as the worktree
base_repo.
Closes#28
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>
`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 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 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>
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>
`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.
- 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 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>
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>
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>
The shared section is the implicit global scope, so its fields use
the bare `MAESTRO_<FIELD>` env-var spelling. Only `[daemon]` and
`[registry]` sections get section-prefixed env vars.
Renames:
- MAESTRO_SHARED_TOKEN → MAESTRO_TOKEN
- MAESTRO_SHARED_REGISTRY_URL → MAESTRO_REGISTRY_URL
`MAESTRO_DAEMON_*` and `MAESTRO_REGISTRY_BIND_ADDR` are unchanged.
This restores the canonical spec name (`MAESTRO_TOKEN`) that operators
already use in their deployment scripts — NAP-233 had renamed it to
`MAESTRO_SHARED_TOKEN`, which silently broke daemon auth against
existing registry deployments still setting `MAESTRO_TOKEN` in env.
Constants renamed alongside: `ENV_SHARED_TOKEN` → `ENV_TOKEN`,
`ENV_SHARED_REGISTRY_URL` → `ENV_REGISTRY_URL`. `EnvOverrides` fields
shed their `shared_` prefix to match.
Touches: daemon/cli/registry crates, e2e harness, design spec.
Registry now reads `[shared].token` and `[registry].bind_addr` from the
unified file via maestro-config. First-run token generation does a
read-modify-write so `[shared]` / `[cli]` / `[daemon]` sections that
already exist on a co-located host stay intact.
Env-var renames:
- MAESTRO_TOKEN → MAESTRO_SHARED_TOKEN
- MAESTRO_REGISTRY_CONFIG → MAESTRO_CONFIG
`MAESTRO_REGISTRY_BIND_ADDR` is unchanged. The startup banner now points
the operator at `MAESTRO_SHARED_TOKEN` when announcing a freshly
generated token.
e2e harness updated to use the new env var; auth.rs doc-comment updated
to reflect the new env-var name.
Daemon now loads `[shared].registry_url`, `[shared].token`, and the
`[daemon]` section from the unified file via maestro-config. The
registry-WS endpoint is derived from `[shared].registry_url` (scheme
swap + `/daemon`), so operators configure a single base URL instead of
duplicating the daemon-specific endpoint.
Env-var renames (all per the new `MAESTRO_<SECTION>_<FIELD>` convention):
- MAESTROD_REGISTRY_URL → MAESTRO_SHARED_REGISTRY_URL (and the value is
now an http(s) base, not the ws daemon endpoint)
- MAESTROD_DAEMON_URL → MAESTRO_DAEMON_URL
- MAESTROD_DAEMON_ID → MAESTRO_DAEMON_ID
- MAESTROD_BIND_ADDR → MAESTRO_DAEMON_BIND_ADDR
- MAESTROD_CONFIG → MAESTRO_CONFIG
- MAESTRO_TOKEN → MAESTRO_SHARED_TOKEN
`maestrod configure` now writes the unified file with read-modify-write
so any existing `[cli]` / `[registry]` sections on the same host are
preserved. The interactive prompts reflect the new shape: registry URL
is asked for as a base URL, and the token prompt names the new env var.
Missing-file behaviour is hard cutover: if the config doesn't exist and
not every required field comes from env, the daemon errors with a
message pointing at `maestrod configure` and explicitly noting that the
legacy paths (~/.config/maestrod/config.toml) are no longer read.
e2e harness updated to match: `Daemon::spawn` takes the registry's HTTP
base, and the spawned subprocess sees the renamed env vars.
New `crates/maestro-e2e` exercises the full headless stack — registry,
two daemons, real HTTP+WS — as actual subprocesses.
Harness pieces:
- `Registry::spawn` and `Daemon::spawn` launch the real built binaries
with `env_clear()` plus an isolated `$HOME`, `XDG_CONFIG_HOME`,
`XDG_STATE_HOME`, and (for daemons) `TMUX_TMPDIR`. Drop guards kill
the child and clean the temp dirs.
- `Daemon::stop` + `Daemon::restart` swap the process without losing
the state dirs, so the recovery test exercises a real tmux
re-adoption.
- A `build::workspace_target_bin` helper re-invokes cargo from the
test process to build `maestrod` / `maestro-registry` on demand
(cargo only ships libs as path-dep of e2e by default).
- `TestClient` wraps reqwest + tokio-tungstenite for the public
HTTP+WS surface — list/spawn/kill/connect-info/attach + events WS.
Scenarios covered (`tests/e2e.rs`):
- list sessions from two daemons through one registry
- multi-attach: two WS clients see the same PTY output
- worktree with `cleanup_on_kill: true` is removed after kill
- daemon restart: catalog drops then repopulates from `recover()`
- concurrent spawns all get distinct ids
All five pass in ~0.7s in parallel; cleanup confirmed (no leftover
processes or temp dirs).