A new attach replayed the tail of the raw byte buffer. That tail starts
mid-stream, long after a full-screen program set the modes, colours and
cursor position that give those bytes meaning, so a browser refresh
painted garbage over every agent TUI until something forced a repaint —
which is why opening the same session in the TUI (a resize) fixed it.
Ask tmux for the pane's current screen instead, and fall back to the byte
buffer when there is no pane left to capture, as for a dead session.
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>
The no-retained-store test asserts a negative — the tree survives — so
waiting on that negative proves nothing: settle could still remove the
tree one instant after the assertion passes. mark_session_dead now flips
a per-record settled flag once the exit rule has been applied or refused,
exposed as SessionManager::is_settled; the test waits on it before
asserting.
Refs #115
Two races. First, the watcher updates the snapshot and emits
SessionUpdated as two steps, so the drain could run between them: the
legitimate breakdown emission then arrived inside the quiet window.
Subscribe before the watcher starts and consume through the breakdown
event so the window begins after the last real change.
Second, the assertion treated any SessionUpdated as a failed
suppression, but the activity pass may legitimately announce an
Active→Idle transition in that window (ACTIVE_WINDOW is 3s), carrying
today's blocked_work unchanged. Only a changed indicator counts as a
re-emit now.
Fixes#113
Dead status is observable a moment before the settle step removes the
worktree, so the assertions raced the removal under parallel load. Wait
for the tree to be gone and the store to stay empty, same as the
retained-store wait cfebfd4 added to the guarded sibling.
Fixes#113
Dead status can be observed before the settle step's store write
lands, so the test's store read raced the write under parallel load.
Wait for the store to have the entry before asserting on it, same as
the settle-state wait a few lines below.
Fixes#111
The sidebar nested a worktree session under the worktree, so a repo grew
a <repo>/worktrees/<branch> folder holding one session of the same name,
and the repo stopped being where you look for the work.
The daemon recorded whatever base_repo the request named, which is the
worktree itself when spawning from inside one; it now records the
resolved main checkout. The web tree groups by that instead of the cwd.
The TUI sidebar is a flat list with no directory grouping, so it needs
no mirror.
Closes#99
run_blocked_work_pass rides the liveness tick but probes on a slower
cadence (DEFAULT_BLOCKED_WORK_INTERVAL, 15s): each probe is three git
subprocesses per session. Sessions without a worktree are filtered out
before any probe, so their indicator costs nothing. A failed probe keeps
the previous value; settled exits clear it.
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.
Nothing has honoured WorktreeSpec.cleanup_on_kill since #56, and after
#79 and #81 survival is decided by what is in a worktree, not by what
the spawn asked for. A field that lies is worse than a missing one.
Removes the field from the protocol, the --cleanup-on-kill flag from
the CLI, and the tests asserting its default.
When the liveness watcher finds the tmux session gone, it now settles
the worktree through the same probe_worktree/record_retention helpers
the kill uses: pristine trees are removed, anything else is retained as
exited-with-unsaved-work before anything is torn down. The dead session
drops its worktree reference once settled, so dismissing it neither
refuses nor retains twice. If the probe or store write fails, nothing
is destroyed and the tree stays attached to the record.
WORKTREES.md: written down the settle-then-drop and nothing-destroyed-
when-unrecordable rules alongside the existing exit rule.
--force overrides the kill refusal and nothing else (#81). The guarded
worktree is recorded in the retained store with reason force-killed —
never removed — and the outcome names what was kept so a client can say
where the work went. A tree whose guard is clear never needed the
override; force removes it as usual.
The store write happens before teardown, so a failed retention leaves
the session running and every artifact intact. DELETE /api/sessions/:id
takes ?force=true and answers 200 with KillOutcome; the unforced kill
keeps its 204.
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.
Each was a typed field that type-checked on both sides and carried
nothing across.
SessionStatus::Dead was never constructed by the daemon, yet the TUI, CLI
and SPA all render it and DESIGN.md specifies the exited badge. Sessions
were removed on death, never marked. They are now marked Dead and kept
until the operator dismisses them, with the PtyBridge sealed rather than
dropped: attached clients still get a Close frame (NAP-226), and the
replay buffer survives so a later attach can read what a crashed agent
printed. Previously that output was destroyed. Dead sessions are
read-only and do not survive a daemon restart.
CreateSessionRequest.env was accepted, stored, echoed back and dropped —
new_session_piped had no env parameter. It now reaches tmux as sorted
`-e KEY=VAL` flags. A key containing `=` or NUL is rejected, since tmux
would parse it as a different assignment than the caller wrote.
PROTOCOL_VERSION was written by the daemon and read by nobody. Adds
`maestro_protocol::is_compatible`, compared on the major component only;
`maestro list` shows the daemon version and the TUI warns on mismatch.
Also stops mapping every non-404 session error to 502: an invalid env key
is a client mistake and now answers 400.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A background task, a second broadcast channel and a whole module existed
to remap SessionEvent onto CatalogEvent one variant at a time.
SessionManager now emits CatalogEvent directly.
Deletes local_events.rs, the publisher task, the LocalEventBus channel,
enum SessionEvent, LocalApiState::events, and the duplicate capacity
constant — events_capacity was declared twice for one logical stream.
Lag semantics improve. There were two lag points: the publisher falling
behind the manager, which broadcast one Lagged to every client, and each
WS subscriber falling behind the bus. Only the per-subscriber one is left,
which pump_events already handled — a slow client now makes only itself
resync.
Adds the first daemon-level tests for WS /api/events: the Snapshot-then-
deltas contract, and that a late subscriber gets a Snapshot of current
state. Both were described only in a comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SessionManager held a concrete Tmux, so its decisions — spawn rollback
ordering, liveness diffing, activity change-detection — could only be
reached by forking a real tmux server. Inner now holds an Arc<dyn Panes>;
Tmux is the production adapter, FakePanes the one tests drive.
Arc<dyn Panes> rather than a type parameter: a generic would spread
through LocalApiState, attach and cli, and one vtable hop is free next to
a fork+exec.
Adds tests/session_decisions.rs — six cases, no subprocess, 0.24s.
Replaces the chmod-0o500 rollback test, which detected root and returned
early, so on CI it asserted nothing; the failure is now forced by a
regular file sitting where the sidecar directory's parent must be, which
no uid can work around. It also asserts the rollback *order*, not just
the end state.
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 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>
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.
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>
`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>
`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>
The daemon never noticed when a session's command exited and tmux tore
the session down — the in-memory `SessionManager` map plus the registry
catalog kept showing it as `Running`, and a follow-up `maestro attach`
would hang on an empty broadcast.
Adds a per-host background task that polls `tmux list-sessions` every
2s, diffs against the in-memory set, and reaps anything we still track
but tmux has lost: drops the record, deletes the sidecar + FIFO,
cleans up its worktree if requested, and emits `SessionEvent::Removed`
so the registry purges its catalog.
The task holds a `Weak<Inner>` so it exits cleanly once the last
`SessionManager` clone is dropped. Skips the tmux subprocess when the
in-memory map is empty.
Completes the headless daemon stack for NAP-214.
- `config`: daemon TOML config (daemon_id, registry_url, bind_addr,
token, state_dir, worktree_root) with sensible XDG defaults.
- `jwt`: HS256 verifier; the daemon validates per-attach JWTs locally
with no callback to the registry. Also exposes `issue()` for tests.
- `pty::PtyBridge`: per-session FIFO + tokio broadcast pump. The FIFO is
opened r+w so it never sees zero writers — stays alive across
`pipe-pane` re-issues. Helper `pipe_pane_command()` builds the
`cat >> <fifo>` shell line.
- `session::SessionManager`: authoritative live-session store. Creates
worktrees (when requested), spawns tmux, mkfifo + pipe-pane, writes
sidecar; tears it all down on kill. Emits diff events on a
broadcast for the registry client. `recover()` adopts surviving
`maestro-*` tmux sessions on startup and re-pipes them.
- `registry_client::RegistryClient`: persistent outbound WS to the
registry with exponential backoff. Sends Hello → snapshot, drives a
select loop that pumps inbound CreateSession/KillSession RPCs into
the manager (replying with results), emits SessionAdded/Removed/
Updated diffs from the manager, and heartbeats every 10s.
- `attach`: axum router mounting `WS /sessions/:id/stream?token=<jwt>`.
Binary frames carry PTY bytes either way (subscribe to broadcast →
send to client; receive from client → tmux send-keys -l). Text
frames carry `ClientControl::{Resize,Ping,Pong}`.
- `bin/maestrod`: ties everything together, loads config from
`MAESTROD_CONFIG` env or `~/.config/maestrod/config.toml`,
recovers, spawns the registry client, binds the WS server.
Also adds `JwtClaims` to `maestro-protocol` so the daemon and the
future registry JWT issuer (NAP-215) agree on the wire shape.
Integration tests cover session manager round-trip + recovery + PTY
broadcast; attach endpoint JWT (bad/wrong-session/wrong-daemon),
PTY round-trip through real tmux, multi-attach broadcast, ping/pong;
registry client registration + snapshot + diff forwarding +
CreateSession RPC round-trip.
Closes NAP-214.