Compare commits

...
Author SHA1 Message Date
Miguel Palhas 24ed1ac002 docs(maestro): fix two more factual errors in structured docs
INSTALL.md conflated event-log replay with ACP session/load resume.
STRUCTURED-VIEW.md overstated death-on-append-failure: only notification
appends kill the session, approval-row append failures just cancel the
pending approval.
2026-09-05 03:49:59 +01:00
Miguel PalhasandClaude Sonnet 5 64741a027e docs(maestro): fix five more factual errors in structured-session docs
Corrects recovery-as-two-operations, tmux-only survival on restart,
replay's durable-vs-live-state gap, snapshot-vs-replay purpose, and the
three-file persistence count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:45:26 +01:00
Miguel Palhas cdd4213ff5 docs(maestro): fix three factual errors in STRUCTURED-VIEW.md
- replay: since=0 returns the first page (still limit-bounded), not the
  whole log; clients page forward from the last returned cursor
- PR_SET_PDEATHSIG is Linux-only; other platforms depend on the
  terminate() ladder running to completion
- approval nonce is minted before cancelling the prior pending approval,
  not after
2026-09-05 03:38:32 +01:00
Miguel Palhas f8973b3802 docs(maestro): write STRUCTURED-VIEW.md
Contracts the eight transcript row kinds, daemon-vs-client decisions, the
transcript/reduced-state projections and their cursor/replay semantics, the
ACP handshake and declined capabilities, the adapter install command, and
the approval nonce rules (generation, live-only placement, secrecy from the
agent and durable history, single-use/duplicate resolution) and bounded
prompt/cancel liveness.

Amends RESTART.md to state plainly that a structured session's agent is a
daemon child, not tmux-backed: a restart ends the active turn, the
transcript survives in the event log, and pending approvals/nonces do not.

Refs #137
2026-09-05 03:32:42 +01:00
Miguel Palhas 1e2c0f0a38 Merge structured-view/139-permission-requests into blitz/structured-view 2026-09-05 03:14:19 +01:00
Miguel Palhas 6fa7dc63e4 fix: serialize structured prompt dispatch 2026-09-05 03:11:16 +01:00
Miguel Palhas 5c0a4c4640 fix: bind approvals to prompt turns 2026-09-05 02:57:29 +01:00
Miguel Palhas 8773671afd fix(daemon): unify approval replay log 2026-09-05 02:44:59 +01:00
Miguel Palhas d9815d86c6 feat(daemon): resolve ACP approvals 2026-09-05 02:32:01 +01:00
Miguel Palhas 13009d0f0e Merge structured-view/138-structured-api into blitz/structured-view 2026-09-05 02:16:00 +01:00
Miguel Palhas bbae09bc48 fix(daemon): reject dead structured sessions 2026-09-05 02:13:55 +01:00
Miguel Palhas c7c294996e fix(daemon): remove unused turn token 2026-09-05 02:07:07 +01:00
Miguel Palhas 3c638b08de fix: clarify structured prompt failures 2026-09-05 02:05:06 +01:00
Miguel Palhas be23a2b539 fix: harden structured replay 2026-09-05 01:56:07 +01:00
Miguel Palhas b98f5cb3e5 feat(maestro): serve structured session API 2026-09-05 01:46:21 +01:00
Miguel Palhas 0f5576c77a Merge structured-view/136-transcript-projection into blitz/structured-view 2026-09-05 01:31:55 +01:00
Miguel Palhas 0292f373f7 test(daemon): cover projection lifecycle 2026-09-05 01:31:20 +01:00
Miguel Palhas eea337ce36 fix(daemon): scope active tools to turn 2026-09-05 01:30:16 +01:00
Miguel Palhas fd3ea64949 fix(daemon): reconcile transcript projections 2026-09-05 01:26:12 +01:00
Miguel Palhas 046c110460 feat(maestro): project ACP transcript state 2026-09-05 01:13:39 +01:00
Miguel Palhas 6eb65f1057 Merge structured-view/183-dismissal-race into blitz/structured-view 2026-09-05 00:57:46 +01:00
Miguel Palhas 3a68c1dd2f fix(maestro): preserve dismissed recovery cleanup 2026-09-05 00:57:36 +01:00
Miguel Palhas fde1954eac Merge structured-view/182-natural-exit-race into blitz/structured-view 2026-09-05 00:48:22 +01:00
Miguel Palhas 25435c34b2 fix(maestro): classify exited ACP children 2026-09-05 00:47:51 +01:00
Miguel Palhas 4a956e9c38 Merge structured-view/181-recovery-provenance into blitz/structured-view 2026-09-05 00:36:19 +01:00
Miguel Palhas 2a49814572 fix(maestro): mark graceful ACP recovery 2026-09-05 00:35:50 +01:00
Miguel Palhas d1aa56bab3 Merge structured-view/180-shutdown-create into blitz/structured-view 2026-09-05 00:26:07 +01:00
Miguel Palhas cf322746a7 fix(maestro): serialize structured shutdown 2026-09-05 00:22:59 +01:00
Miguel Palhas b25e733539 Merge structured-view/179-shutdown-drain into blitz/structured-view 2026-09-05 00:11:13 +01:00
Miguel Palhas 5a861976d7 test(maestro): cover signal shutdown drain 2026-09-05 00:07:19 +01:00
Miguel Palhas bfe4965081 fix(maestro): stop ACP before WS drain 2026-09-05 00:07:19 +01:00
Miguel Palhas 9d7ed8c071 fix(maestro): serialize shutdown finalization 2026-09-05 00:07:19 +01:00
Miguel Palhas 88a40c1ea7 Merge structured-view/177-restart-worktree into blitz/structured-view 2026-09-04 23:58:43 +01:00
Miguel Palhas 7049b5f1c0 docs(maestro): document structured restart recovery 2026-09-04 23:53:20 +01:00
Miguel Palhas c277ad630c fix(maestro): retain worktree on ACP restart 2026-09-04 23:53:20 +01:00
Miguel Palhas fc9c62d46b test(maestro): cover structured restart worktree 2026-09-04 23:53:20 +01:00
Miguel Palhas 3fe560d67e Merge structured-view/178-pid-safe into blitz/structured-view 2026-09-04 23:46:32 +01:00
Miguel Palhas b2b33535ae fix(maestro): own ACP child signals 2026-09-04 23:42:17 +01:00
Miguel Palhas 4ea6ddcaee Merge structured-view/135-event-log into blitz/structured-view 2026-09-04 23:30:15 +01:00
Miguel Palhas e9142bb027 fix(maestro): close ACP replay seams
Serialize replay reads with event appends, acquire the live receiver under that lock, and drain the event pump during shutdown. Recovery now removes its FIFO when opening the log fails.
2026-09-04 23:25:46 +01:00
Miguel Palhas d11098a478 feat(maestro): persist live ACP events
Own the ACP update receiver inside SessionManager, append each notification before broadcasting it, and recover structured sidecars by loading their stored ACP session id without duplicating replayed history.
2026-09-04 23:20:48 +01:00
Miguel Palhas 61776b4e0a fix(maestro): preserve post-load events
Switch replay suppression off inside the ordered session/load response callback, before the dispatcher can deliver the next live notification.
2026-09-04 23:18:59 +01:00
Miguel Palhas eabd21bccf feat(maestro): load existing ACP sessions
Resume a stored ACP session through session/load and discard the history replayed during the load handshake so the durable event log remains the single transcript source.
2026-09-04 23:13:45 +01:00
Miguel PalhasandClaude Fable 5.1 fd54d4f833 feat(maestro): add the per-session event log to SidecarStore (#135)
<sidecar_dir>/<id>.events.jsonl holds one session/update notification
per line; seq is the line's 1-based position. open_events returns an
append handle held for the session's lifetime, and terminates a
partial last line first so a hard kill mid-write never glues the next
event onto the fragment. read_events takes a Since (forward, exclusive)
or Before (backward, exclusive, None for the tail) cursor with a limit;
unparseable lines keep their seq and are skipped. delete removes the
log with the sidecar; load_all ignores .jsonl.

Each append is a single write_all to the kernel, no fsync: a killed
daemon loses nothing already written, and an fsync per streamed chunk
is the wrong trade for a log that is not meant to survive power loss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 22:14:33 +01:00
Miguel Palhas 78b5abb0cf Merge structured-view/134-acp-subprocess into blitz/structured-view
Closes #134
2026-09-04 22:06:46 +01:00
Miguel Palhas 6d8b49b75f docs(maestro): document the claude-code-acp install (#134) 2026-09-04 22:03:23 +01:00
Miguel Palhas 6c20a99d58 feat(maestro): end adapter children on daemon shutdown (#134)
SIGTERM now counts as a shutdown alongside ctrl-c: systemd stops the
unit with SIGTERM, and with KillMode=process nothing else would reach
the claude-code-acp children. After the listener drains the manager
terminates every adapter before the process exits.
2026-09-04 22:03:23 +01:00
Miguel Palhas b1718559df feat(maestro): spawn a claude-code-acp child per structured session (#134)
A Structured spawn gets no tmux pane: the daemon starts the adapter in
the session cwd with the session env (including MAESTRO_SESSION_ID and
MAESTRO_URL), runs the handshake, and records the ACP session id on the
sidecar. An adapter that cannot come up still yields a session, Dead
with activity Error and the reason as activity_detail, so a missing
adapter reads as the install command in the sidebar rather than as a
session that never appeared.

The liveness pass checks a structured session's child instead of the
tmux list. kill sends session/cancel, waits, then SIGTERMs through the
ACP handle. SessionManager::shutdown ends every adapter concurrently
and marks those sessions Dead on disk. take_acp_updates hands #135 the
raw session/update stream once, with earlier notifications still queued.
2026-09-04 22:02:39 +01:00
Miguel Palhas 540a3025b1 feat(maestro): add maestro-acp, the ACP client per structured session (#134)
New crate wrapping agent-client-protocol 2.1 in the client role. spawn()
resolves claude-code-acp on the PATH the child would run with, starts it
in the session cwd with the session env, runs initialize + session/new,
and returns an AcpSession holding the connection, the ACP session id,
and an unbounded channel of raw session/update notifications for #135.

initialize declares fs.readTextFile, fs.writeTextFile and terminal as
false so the adapter uses Claude Code's own tools. request_permission is
a stub that declines until #139.

terminate() sends session/cancel, waits, SIGTERMs, then SIGKILLs. The
child stays in the daemon's process group and gets SIGTERM on daemon
death via PR_SET_PDEATHSIG. CLAUDECODE is stripped from the child env:
the adapter refuses to start when it believes it is nested in another
Claude Code session, which is how a daemon launched from one behaves.

Tests run against tests/fake_acp_agent.py, a JSON-RPC-over-stdio stand-in
that logs what it receives; real_adapter_handshake is #[ignore]d.
2026-09-04 20:13:30 +01:00
Miguel Palhas c0da16658a feat(maestro): add acp_session_id to the session sidecar (#134)
A Structured session's adapter hands back an ACP session id from
session/new; a respawn will pass it to session/load (#135). This is a
new field rather than #161's agent_session_id: that one is the Claude
Code conversation id the activity hook reports and `claude --resume`
takes, minted by Claude Code itself. The ACP id is minted by the
adapter, only exists for Structured sessions, and is what the adapter
takes back. Overloading one field with two ids from two processes
would leave a respawn unable to tell which kind it holds.
2026-09-04 20:06:12 +01:00
Miguel Palhas eb4655a9d2 test(maestro): update collision tests for branch suffixing
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.
2026-09-04 20:02:20 +01:00
Miguel Palhas c76059ec66 Merge structured-view/133-session-view into blitz/structured-view
Closes #133
2026-09-04 19:57:18 +01:00
Miguel Palhas f02168ffd5 feat(maestro): add SessionView to the protocol and sidecar
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.
2026-09-04 19:41:29 +01:00
Miguel PalhasandClaude Sonnet 5 f0c272e0b1 feat(maestro): copy terminal selection to clipboard
ci / web (push) Successful in 28s
ci / rust (push) Failing after 2m6s
ci / flake (push) Successful in 2m14s
xterm.js has no built-in copy-on-select; mirror onSelectionChange to
navigator.clipboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:33:24 +01:00
Miguel Palhas 624f6f19c8 Merge remote-tracking branch 'origin/fermata'
ci / web (push) Failing after 13s
ci / flake (push) Successful in 3m12s
ci / rust (push) Failing after 3m26s
2026-09-04 13:32:05 +01:00
Miguel Palhas 08161ae042 Merge remote-tracking branch 'origin/main' into fermata 2026-09-04 13:30:19 +01:00
Miguel PalhasandClaude Sonnet 5 1e3c7b294d fix(maestro): send plain Escape to the pane, move focus.toggle behind the leader
Escape unfocusing the pane made it impossible to send a literal Esc into
the attached session. Removes pane.focusList and its Escape binding, and
rebinds focus.toggle from ctrl-shift-h (claimed by the browser's History
shortcut) to leader-h, consistent with the other leader escape hatches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:30:14 +01:00
Miguel PalhasandClaude Sonnet 5 49ab7e9c81 fix(maestro): suffix branch name on new-branch worktree collision
ci / rust (push) Failing after 3m26s
ci / web (push) Successful in 43s
ci / flake (push) Successful in 3m54s
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>
2026-09-04 13:29:49 +01:00
Miguel PalhasandClaude Sonnet 5 0cc25d7e1c fix(maestro): equalize split widths after spawning a session
ci / rust (push) Successful in 9m12s
ci / web (push) Successful in 33s
ci / flake (push) Successful in 2m37s
splitLeaf always halved just the target pane, so repeated splits left
one pane at 50% and the rest squeezed into shrinking fractions instead
of equal columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:03 +01:00
Miguel PalhasandClaude Opus 5 706942ddd7 feat(maestro): install activity hooks for codex too
ci / web (push) Successful in 51s
ci / flake (push) Successful in 3m52s
ci / rust (push) Successful in 6m47s
The hook installer only ever wrote ~/.claude/settings.json, so a codex
session reported no activity and never posted its first prompt — leaving
it on the daemon's generated handle with no haiku name.

Adds a Target enum (claude-code, codex) over the hook file's location and
event set. One script and one marker serve both; codex swaps Notification
for PermissionRequest and has no StopFailure, so it never reports Error.
Both agents spell the prompt event UserPromptSubmit and put the text in
the payload's `prompt`, so autoname needs no change.

Codex will not run a hook it has not been told to trust; `maestro hooks
install` says so, and the approval stays the operator's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 10:44:14 +01:00
Miguel Palhas 52877abe29 fix(maestro): focus the kill button in kill dialogs
ci / web (push) Successful in 37s
ci / flake (push) Successful in 2m50s
ci / rust (push) Successful in 5m46s
Enter now confirms the kill the operator just asked for; Escape and the scrim still cancel.
2026-09-04 08:24:32 +01:00
Miguel Palhas 43fc3a3735 Merge remote-tracking branch 'origin/main' into sarabande
ci / web (push) Successful in 41s
ci / flake (push) Successful in 4m45s
ci / rust (push) Successful in 8m58s
2026-09-04 08:18:02 +01:00
Miguel Palhas ff1ffb377c fix(maestro): leave fast-forward spawns unnamed in the web client
The web N path sent the generated handle as the session name as well as
the worktree branch, so the session was born named and never got its
Haiku name from its first prompt. The TUI already spawns unnamed (#131);
the handle now only seeds the branch.
2026-09-04 08:17:14 +01:00
Miguel Palhas ba74d3515b fix(maestro): keep sidebar accent on the picked session while previewing
ci / rust (push) Successful in 10m9s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 2m56s
A preview moved the active tab's focus, and the sidebar took that as the
focused session, so hovering another row dropped the accent from the row the
operator had actually opened. Report the pre-preview focus instead.
2026-09-04 08:15:35 +01:00
Miguel Palhas 8325bc6d9a Merge remote-tracking branch 'origin/main' into blitz/command-bar
ci / web (push) Successful in 37s
ci / flake (push) Successful in 2m52s
ci / rust (push) Successful in 5m44s
2026-09-03 22:58:41 +01:00
Miguel PalhasandClaude Opus 5 8f499dbee3 fix(maestro): arbitrate pane size across clients
ci / web (push) Successful in 52s
ci / flake (push) Successful in 2m35s
ci / rust (push) Successful in 5m57s
Every attached client pushed its own dimensions straight to
`tmux resize-window`, so two clients of different sizes resized the
shared window past each other on every report — 198 resizes in 90
seconds on a four-session daemon. Each flip repaints a full-screen agent
at a new height and orphans the rows it drew at the old one, leaving a
stale input box the operator types into and reads as a stuck pane.

The window now goes to the smallest attached client per axis, tmux's own
rule for multiple clients, and only when that size actually changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:28:40 +01:00
Miguel Palhas 511fed408c feat(maestro): confirm every kill in the web client
The TUI has always raised `KillConfirm` before `x` kills anything; the web
sent `DELETE /api/sessions/:id` on the first press and only prompted when the
daemon refused. The command bar puts both behind the same three keystrokes,
so the difference now reads as a bug (#160).

The web follows the TUI: a kill is destructive and unattended agents live in
these sessions. `killDialog.ts` grows a confirmation that shares the force
prompt's shell, names the blockers the card already knows about, and keeps the
focus on Cancel. The guarded refusal still opens its own dialog behind it.
2026-09-03 22:08:06 +01:00
Miguel Palhas ba02b11f13 feat(maestro): serve the configured leader to the web client
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.
2026-09-03 18:33:01 +01:00
Miguel PalhasandClaude Opus 5 59ede15d2b feat(maestro): open the bar from the sidebar search field
There is no ctrl-k on a phone and the web UI is mobile-first, so the field
the operator already reaches for becomes the touch entry point: focusing or
tapping it dispatches OPEN_COMMAND_BAR_EVENT and the shell opens the bar with
an empty query. The field is readonly and blurs itself first, so no keyboard
rises over it and the bar can be closed without reopening it.

This replaces the inline substring filter — one search affordance in the
header, not two. buildTree loses its filter argument, and the tree's "No
matches" empty state goes with it; the bar's own no-results state took over.
The filter's name-and-directory matching is now covered against the bar.

The bar's phone-width sheet moves to the spawn modal's 599px break, with
thumb-sized rows and a field that clears the zoom-on-focus threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:21:31 +01:00
Miguel PalhasandClaude Sonnet 5 331039df11 fix(maestro): leader twice sends one literal leader byte
ctrl-a is consumed as the leader on every press, so it never reaches the
shell and readline's beginning-of-line was gone with no way back — unlike
Ctrl-K, which already has an escape hatch. Fix it the way tmux does:
pressing the leader twice sends the literal leader byte to the focused
pane and clears the pending chord, on both web and TUI, built from the
configured leader so a rebound leader carries it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:14:11 +01:00
Miguel Palhas 151438094e Merge remote-tracking branch 'origin/blitz/command-bar' into command-bar/158-literal-leader 2026-09-03 17:59:51 +01:00
Miguel Palhas c1c283c0f8 feat(maestro): give readline's kill-to-end-of-line back via the leader
ctrl-k now opens the command bar in both modes (#153), taking readline's
kill-to-end-of-line away from every pane. Leader then ctrl-k — ctrl-a
ctrl-k by default — sends the literal 0x0B byte to the focused pane
instead, built from the configured leader so rebinding it rebinds the
chord too.

Chording a second binding off the leader exposed a gap in ChordState:
a key bound both alone and as a chord prefix (ctrl-a alone, and ctrl-a
ctrl-k) would get stuck waiting on the next keystroke, discarding its
own meaning the moment that keystroke didn't continue the chord. Feed
now resolves the held prefix to its own binding when that happens and
hands the breaking key back via take_replay for the caller to process
fresh — otherwise, pressing leader then any other key would never
leave Insert mode again.
2026-09-03 17:55:50 +01:00
Miguel PalhasandClaude Opus 5 1b260b5c20 feat(maestro): drill into a session's actions with tab
`tab` on a highlighted session opens its action page inside the command
bar: kill, rename, spawn similar, still fuzzy-searchable, with the
overlay titled after the session. Escape or backspace at an empty query
returns to the result list with the previous query intact.

Each action raises the flow the TUI already has — `KillConfirm`, the
rename prompt pre-filled, and the spawn form, now seeded from that
session's directory and command rather than the focused one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:35:24 +01:00
Miguel Palhas 3eb1d7ddbe feat(maestro): drill into a session's action page with tab
Tab on a highlighted session in the web command bar opens that session's
action page in the same overlay: kill, rename, spawn similar, still fuzzy
searchable and headed by the session's name. Escape, or backspace at an
empty query, returns to the result list with the previous query intact.

Each action raises the flow the card already has. The two that lived
inside the sidebar get an event apiece — the confirm-and-blockers kill and
the inline rename prompt — so the bar picks the action and nothing more;
spawn similar reuses the existing SPAWN_SIMILAR_EVENT.
2026-09-03 17:27:13 +01:00
Miguel Palhas 9d6b69cc49 feat(maestro): add the TUI command bar's > and ~ sigils
`>` narrows to commands from the live keymap for the current mode, matched
on the action's help_label only — the chord is displayed, never matched, so
results cannot depend on a user's rebinds. Rows dedupe by action, not by
chord: the default map binds both `enter` and `tab` to AttachHighlighted,
and AttachHighlighted and ToggleAttachHighlighted share a label because they
are the same match arm.

`~` narrows to recent dirs plus the project list. Enter takes the
fast-forward path pinned to that directory, with no recency fallback, so a
directory outside the project scan opens the pre-filled form rather than
spawning elsewhere.

Both fall out of parsing the query line, as in web/src/commandBar.ts: a
sigil is only a sigil in first position, and backspacing past one is just
the query becoming mixed again.
2026-09-03 17:17:27 +01:00
Miguel PalhasandClaude Opus 5 b2cbb23fe2 feat(maestro): open the bar with ctrl-k over a focused pane
The global listener already ran in the capture phase, but deferred to
isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats
that textarea as not a typing target, while real form fields — the spawn
form's inputs — keep their own Ctrl-K.

Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused
pane through the leader chord the command set already resolves, so
readline's kill-to-end-of-line stays reachable. The pool grows a
sendToFocused for it, over a new AttachConnection.sendInput.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:03:19 +01:00
Miguel Palhas f8b434dd1d fix(maestro): keep accent fill when previewing active row
ci / web (push) Successful in 40s
ci / flake (push) Successful in 3m34s
ci / rust (push) Successful in 6m44s
Hovering the session already in the pane put the preview tint over its accent fill, so the row that is on screen looked unpicked. Tier 5 now skips `.is-active`.
2026-09-03 16:34:58 +01:00
88 changed files with 11557 additions and 683 deletions
+6
View File
@@ -92,6 +92,12 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
the two matchers must agree on, and the fixture table they are tested against.
Read it before changing either bar or either matcher.
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
run as a daemon child over ACP instead of in a tmux pane) — the eight
transcript row kinds, the two projections and their cursor/replay semantics,
the handshake and declined capabilities, and the approval nonce rules. Read
it before changing anything that renders a structured session.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
Generated
+747 -57
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -10,6 +10,7 @@ repository = "https://git.naps.pt/naps62/maestro"
publish = false
[workspace.dependencies]
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
axum = { version = "0.7", features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] }
@@ -17,6 +18,7 @@ crossterm = { version = "0.28", features = ["event-stream"] }
dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2"
maestro-acp = { path = "crates/maestro-acp" }
maestro-config = { path = "crates/maestro-config" }
# `default-features = false` so each consumer opts into `haiku-autoname`
# through its own feature of the same name (#131); a workspace entry that
@@ -38,6 +40,7 @@ tempfile = "3"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
tokio-tungstenite = "0.21"
tokio-util = { version = "0.7", features = ["compat"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "maestro-acp"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "ACP client role: one claude-code-acp child per structured session"
[dependencies]
agent-client-protocol.workspace = true
libc.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
+721
View File
@@ -0,0 +1,721 @@
//! ACP client role for the daemon (#134): one `claude-code-acp` child per
//! structured session.
//!
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the
//! adapter as a child process with stdio pipes, in the session's `cwd` with
//! the session's env, runs the `initialize` + `session/new` handshake, and
//! hands back an [`AcpSession`] that owns the connection for the life of the
//! session.
//!
//! **The handshake declares almost nothing.** `initialize` reports
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the
//! daemon to act on its behalf — exactly what already happens in the
//! terminal view. `session/request_permission` is the one client method that
//! gets a real implementation, in #139; here it is a stub that declines.
//!
//! Every `session/update` notification the adapter sends lands on an
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
//! is dropped while nobody reads: #135 takes the receiver and persists each
//! notification to the per-session event log before broadcasting it.
//!
//! A respawn after a daemon restart sets [`SpawnConfig::load_session`] and
//! the handshake runs `session/load` with the stored id instead of
//! `session/new`. The history the agent streams back while that request is
//! in flight is *not* delivered: the daemon's event log already holds it,
//! and replaying it would double every line.
//!
//! The child is a child of the daemon, full stop. It shares the daemon's
//! process group, is marked to receive `SIGTERM` when the daemon dies
//! (`PR_SET_PDEATHSIG` on Linux), and [`AcpSession::terminate`] is what both
//! `DELETE /api/sessions/:id` and daemon shutdown call: `session/cancel`, a
//! short wait, then `SIGTERM`, then `SIGKILL` as the last resort.
#![deny(missing_debug_implementations)]
use std::collections::{HashMap, VecDeque};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, Implementation,
InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId,
SessionNotification,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client};
use tokio::io::AsyncBufReadExt;
use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema;
/// The adapter binary, resolved on `PATH`.
pub const ADAPTER_BIN: &str = "claude-code-acp";
/// How to install the adapter when it is missing. Named in the error so an
/// operator staring at a failed session knows what to run.
pub const INSTALL_COMMAND: &str =
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
/// How long `initialize` + `session/new` may take before the spawn is
/// declared failed. The real adapter boots a node process and the Claude
/// Code SDK behind it; a slow machine takes seconds, never a minute.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
/// sending `SIGTERM`. Cancel is a courtesy that lets the adapter finish the
/// turn it is on; the signal is what actually ends it.
pub const CANCEL_GRACE: Duration = Duration::from_millis(500);
/// How long [`AcpSession::terminate`] waits after `SIGTERM` before `SIGKILL`.
pub const TERM_GRACE: Duration = Duration::from_secs(2);
/// Bytes of the child's stderr kept for the failure message.
const STDERR_TAIL_BYTES: usize = 4096;
/// What [`spawn`] needs to start one adapter.
#[derive(Debug, Clone)]
pub struct SpawnConfig {
/// Working directory of the child and `cwd` of `session/new`.
pub cwd: PathBuf,
/// Environment laid over the daemon's own for the child. A `PATH` in
/// here is also what the adapter is resolved against, since that is the
/// `PATH` the child would see.
pub env: HashMap<String, String>,
/// Upper bound on the handshake; [`HANDSHAKE_TIMEOUT`] by default.
pub handshake_timeout: Duration,
/// An existing ACP session id to resume with `session/load` (#135)
/// instead of opening a fresh one. The adapter must advertise
/// `loadSession`; the handshake fails otherwise.
pub load_session: Option<String>,
}
impl SpawnConfig {
pub fn new(cwd: impl Into<PathBuf>, env: HashMap<String, String>) -> Self {
Self {
cwd: cwd.into(),
env,
handshake_timeout: HANDSHAKE_TIMEOUT,
load_session: None,
}
}
/// Resume `session_id` with `session/load` instead of `session/new`.
pub fn load_session(mut self, session_id: impl Into<String>) -> Self {
self.load_session = Some(session_id.into());
self
}
}
/// A blocked ACP permission request delivered to the daemon.
#[derive(Debug)]
pub struct PermissionRequest {
/// The prompt turn that was active when ACP delivered this callback.
pub turn: u64,
pub request: RequestPermissionRequest,
pub response: oneshot::Sender<RequestPermissionOutcome>,
}
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command
/// so the failed session is actionable rather than silently dead.
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")]
AdapterMissing,
#[error("failed to start {ADAPTER_BIN}: {0}")]
Spawn(#[source] std::io::Error),
/// The adapter answered the handshake with an error.
#[error("ACP handshake failed: {0}")]
Handshake(String),
/// The adapter went away before the handshake finished. Carries the
/// tail of its stderr, which is where a missing Claude login shows up.
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")]
ExitedEarly { status: String, stderr: String },
#[error("ACP handshake timed out after {0:?}")]
HandshakeTimeout(Duration),
}
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
/// session env's `PATH` when it sets one, the daemon's own otherwise.
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> {
let path: OsString = match env.get("PATH") {
Some(p) => OsString::from(p),
None => std::env::var_os("PATH")?,
};
std::env::split_paths(&path)
.filter(|dir| !dir.as_os_str().is_empty())
.map(|dir| dir.join(ADAPTER_BIN))
.find(|candidate| is_executable(candidate))
}
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
enum Command {
Cancel,
Prompt {
text: String,
turn: u64,
done: oneshot::Sender<Result<(), String>>,
},
Close,
}
enum ChildCommand {
Signal(libc::c_int),
Reap(oneshot::Sender<bool>),
}
#[derive(Debug, Default)]
struct PermissionTurnState {
active: Option<u64>,
}
impl PermissionTurnState {
fn begin(&mut self, turn: u64) {
self.active = Some(turn);
}
fn cancel_active(&mut self) {
self.active = None;
}
fn finish(&mut self, turn: u64) {
if self.active == Some(turn) {
self.active = None;
}
}
fn callback_turn(&self) -> Option<u64> {
self.active
}
}
/// Why the handshake did not produce a session id.
enum ReadyError {
/// The adapter answered with an error.
Handshake(String),
/// The transport closed under the request: the child is gone.
Closed,
}
fn ready_error(e: agent_client_protocol::Error) -> ReadyError {
if agent_client_protocol::is_incoming_transport_closed(&e) {
ReadyError::Closed
} else {
ReadyError::Handshake(e.to_string())
}
}
/// A live ACP connection to one adapter child. Dropping it sends `SIGTERM`
/// if the child is still running; [`AcpSession::terminate`] is the polite
/// version.
#[derive(Debug)]
pub struct AcpSession {
session_id: String,
pid: u32,
commands: mpsc::UnboundedSender<Command>,
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
exit: watch::Receiver<Option<ExitStatus>>,
}
impl AcpSession {
/// The id `session/new` returned. What a respawn passes to
/// `session/load` (#135).
pub fn session_id(&self) -> &str {
&self.session_id
}
/// The adapter's pid.
pub fn pid(&self) -> u32 {
self.pid
}
/// Whether the child has been reaped.
pub fn has_exited(&self) -> bool {
self.exit.borrow().is_some()
}
/// Reap the child if it has already exited.
pub async fn reap_if_exited(&self) -> bool {
if self.has_exited() {
return true;
}
let (sent, received) = oneshot::channel();
if self.child_commands.send(ChildCommand::Reap(sent)).is_err() {
return self.has_exited();
}
received.await.unwrap_or_else(|_| self.has_exited())
}
/// Wait for the child to exit.
pub async fn wait(&self) -> Option<ExitStatus> {
wait_exit(&self.exit).await
}
/// The stream of raw `session/update` notifications, handed out once.
/// Notifications that arrived before the take are still queued on it.
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
self.updates.lock().expect("updates mutex").take()
}
pub fn take_permission_requests(&self) -> Option<mpsc::UnboundedReceiver<PermissionRequest>> {
self.permission_requests
.lock()
.expect("permission requests")
.take()
}
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
/// child is already gone, which is the outcome cancel was after.
pub fn cancel(&self) {
let _ = self.commands.send(Command::Cancel);
}
/// Send a user prompt and receive completion once the agent ends the turn.
pub fn prompt(
&self,
text: String,
turn: u64,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::Prompt {
text,
turn,
done: done_tx,
})
.map_err(|_| "ACP connection is closed".to_string())?;
Ok(done_rx)
}
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
self.cancel();
if tokio::time::timeout(cancel_grace, self.wait())
.await
.is_ok()
{
return;
}
self.signal(libc::SIGTERM);
if tokio::time::timeout(term_grace, self.wait()).await.is_ok() {
return;
}
tracing::warn!(
pid = self.pid,
"{ADAPTER_BIN} ignored SIGTERM; sending SIGKILL"
);
self.signal(libc::SIGKILL);
let _ = self.commands.send(Command::Close);
self.wait().await;
}
fn signal(&self, sig: libc::c_int) {
let _ = self.child_commands.send(ChildCommand::Signal(sig));
}
}
impl Drop for AcpSession {
fn drop(&mut self) {
self.signal(libc::SIGTERM);
let _ = self.commands.send(Command::Close);
}
}
/// Start the adapter for one session and run the handshake.
///
/// Fails without a running child: on any error the process is already
/// signalled, so a failed session never leaves a stray adapter behind.
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?;
let mut cmd = tokio::process::Command::new(&adapter);
cmd.current_dir(&config.cwd)
.envs(&config.env)
// Claude Code refuses to start when it thinks it is nested inside
// another Claude Code session; a daemon launched from one would
// otherwise never get a structured session up.
.env_remove("CLAUDECODE")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// If the daemon is dropped without a graceful shutdown the runtime
// still tears the task holding this `Child` down, and that kills it.
.kill_on_drop(true);
#[cfg(target_os = "linux")]
{
// Same process group as the daemon (no setpgid), and SIGTERM when the
// daemon dies however it dies: an agent that outlives its daemon is
// exactly the orphan #134 rules out.
// SAFETY: prctl is async-signal-safe and touches no shared state.
unsafe {
cmd.pre_exec(|| {
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
Ok(())
});
}
}
let mut child = cmd.spawn().map_err(AcpError::Spawn)?;
let pid = child.id().expect("child pid is known before wait");
let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
let (exit_tx, exit_rx) = watch::channel(None);
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
let (ready_tx, ready_rx) = oneshot::channel();
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
tokio::spawn(run_connection(
transport,
config.cwd.clone(),
config.load_session.clone(),
updates_tx,
permission_tx,
commands_rx,
ready_tx,
));
let outcome = tokio::time::timeout(config.handshake_timeout, ready_rx).await;
let failure = match outcome {
Ok(Ok(Ok(session_id))) => {
return Ok(AcpSession {
session_id,
pid,
commands: commands_tx,
child_commands: child_commands_tx,
updates: Mutex::new(Some(updates_rx)),
permission_requests: Mutex::new(Some(permission_rx)),
exit: exit_rx,
});
}
Ok(Ok(Err(ReadyError::Handshake(message)))) => AcpError::Handshake(message),
Ok(Ok(Err(ReadyError::Closed))) | Ok(Err(_)) => {
// The connection ended before the handshake did: the child went
// away. Reap it so the status is real, then report.
let status = wait_exit(&exit_rx).await;
let stderr = {
let mut tail = stderr_tail.lock().expect("stderr tail");
tail.make_contiguous();
String::from_utf8_lossy(tail.as_slices().0)
.trim()
.to_string()
};
AcpError::ExitedEarly {
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
stderr: if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
},
}
}
Err(_) => AcpError::HandshakeTimeout(config.handshake_timeout),
};
// No half-started adapter survives a failed spawn.
let _ = commands_tx.send(Command::Close);
let _ = child_commands_tx.send(ChildCommand::Signal(libc::SIGTERM));
if tokio::time::timeout(TERM_GRACE, wait_exit(&exit_rx))
.await
.is_err()
{
let _ = child_commands_tx.send(ChildCommand::Signal(libc::SIGKILL));
wait_exit(&exit_rx).await;
}
Err(failure)
}
async fn wait_exit(exit: &watch::Receiver<Option<ExitStatus>>) -> Option<ExitStatus> {
let mut exit = exit.clone();
let status = exit.wait_for(|s| s.is_some()).await.ok()?;
*status
}
async fn supervise_child(
mut child: tokio::process::Child,
pid: u32,
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
exit_tx: watch::Sender<Option<ExitStatus>>,
) {
loop {
tokio::select! {
status = child.wait() => {
let status = status.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(status);
return;
}
command = commands.recv() => match command {
Some(ChildCommand::Signal(sig)) => {
// This task alone owns `child`. It cannot reap the child
// between receiving this command and sending the signal,
// so its PID cannot be reused in that window.
unsafe { libc::kill(pid as libc::pid_t, sig) };
}
Some(ChildCommand::Reap(done)) => match child.try_wait() {
Ok(Some(status)) => {
let _ = done.send(true);
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(Some(status));
return;
}
Ok(None) => {
let _ = done.send(false);
}
Err(error) => {
tracing::warn!(pid, %error, "could not reap {ADAPTER_BIN}");
let _ = done.send(false);
}
},
None => {
// The session vanished without a command receiver. This
// task still owns the unreaped child, so this cannot hit
// a reused PID.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
let status = child.wait().await.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(status);
return;
}
},
}
}
}
/// The capabilities the daemon declares: none of the filesystem or terminal
/// surface, so the adapter falls back to Claude Code's own tools.
fn client_capabilities() -> ClientCapabilities {
ClientCapabilities::new()
.fs(FileSystemCapabilities::new()
.read_text_file(false)
.write_text_file(false))
.terminal(false)
}
async fn run_connection<T>(
transport: T,
cwd: PathBuf,
load_session: Option<String>,
updates_tx: mpsc::UnboundedSender<SessionNotification>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
) where
T: agent_client_protocol::ConnectTo<Client> + 'static,
{
// Set while `session/load` is in flight. Notification handlers run in
// the dispatch loop, in arrival order, so everything the agent streams
// before its load response sees the flag up and is dropped: that is the
// history the event log already holds (#135).
let replaying = Arc::new(AtomicBool::new(false));
let replaying_seen = replaying.clone();
// ACP callbacks have no turn id. The daemon never queues a later prompt
// until a cancelled prompt settles, so this active turn is unambiguous.
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
let permission_turn_seen = permission_turn.clone();
let result = Client
.builder()
.name("maestro")
.on_receive_notification(
async move |notification: SessionNotification, _cx| {
if replaying_seen.load(Ordering::SeqCst) {
tracing::trace!("dropping replayed notification during session/load");
return Ok(());
}
// A dropped receiver only means nobody wants updates any more.
let _ = updates_tx.send(notification);
Ok(())
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
async move |request: RequestPermissionRequest, responder, _cx| {
let turn = permission_turn_seen
.lock()
.expect("permission turn state")
.callback_turn();
let Some(turn) = turn else {
return responder.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Cancelled,
));
};
let (response, received) = oneshot::channel();
if permission_requests
.send(PermissionRequest {
turn,
request,
response,
})
.is_err()
{
return responder.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Cancelled,
));
}
let outcome = received.await.unwrap_or(RequestPermissionOutcome::Cancelled);
responder.respond(RequestPermissionResponse::new(outcome))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
let init = InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities())
.client_info(Implementation::new("maestro", env!("CARGO_PKG_VERSION")));
let initialized = match cx.send_request(init).block_task().await {
Ok(r) => r,
Err(e) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
};
let session_id = match load_session {
Some(existing) => {
if !initialized.agent_capabilities.load_session {
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}"
))));
return Ok(());
}
replaying.store(true, Ordering::SeqCst);
let (loaded_tx, loaded_rx) = oneshot::channel();
let replaying_done = replaying.clone();
if let Err(e) = cx
.send_request(LoadSessionRequest::new(existing.clone(), cwd))
.on_receiving_result(move |result| {
replaying_done.store(false, Ordering::SeqCst);
async move {
let _ = loaded_tx.send(result);
Ok(())
}
})
{
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
match loaded_rx.await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
Err(_) => {
let _ = ready_tx.send(Err(ReadyError::Handshake(
"session/load response was not delivered".into(),
)));
return Ok(());
}
}
existing
}
None => match cx
.send_request(NewSessionRequest::new(cwd))
.block_task()
.await
{
Ok(created) => created.session_id.0.to_string(),
Err(e) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
},
};
let _ = ready_tx.send(Ok(session_id.clone()));
loop {
tokio::select! {
cmd = commands_rx.recv() => match cmd {
Some(Command::Cancel) => {
permission_turn
.lock()
.expect("permission turn state")
.cancel_active();
let _ = cx.send_notification(CancelNotification::new(
SessionId::new(session_id.as_str()),
));
}
Some(Command::Prompt { text, turn, done }) => {
permission_turn
.lock()
.expect("permission turn state")
.begin(turn);
let request = PromptRequest::new(
SessionId::new(session_id.as_str()),
vec![ContentBlock::from(text)],
);
let done = Arc::new(Mutex::new(Some(done)));
let callback_done = done.clone();
let callback_turn = permission_turn.clone();
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
let callback_done = callback_done.clone();
let callback_turn = callback_turn.clone();
async move {
callback_turn
.lock()
.expect("permission turn state")
.finish(turn);
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string()));
}
Ok(())
}
}) {
permission_turn
.lock()
.expect("permission turn state")
.finish(turn);
if let Some(done) = done.lock().expect("prompt sender").take() {
let _ = done.send(Err(error.to_string()));
}
}
}
Some(Command::Close) | None => break,
},
_ = cx.incoming_closed() => break,
}
}
Ok(())
})
.await;
if let Err(e) = result {
tracing::debug!(error = %e, "ACP connection ended with an error");
}
}
async fn drain_stderr(
stderr: tokio::process::ChildStderr,
pid: u32,
tail: Arc<Mutex<VecDeque<u8>>>,
) {
let mut lines = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
let mut tail = tail.lock().expect("stderr tail");
tail.extend(line.as_bytes());
tail.push_back(b'\n');
while tail.len() > STDERR_TAIL_BYTES {
tail.pop_front();
}
tail.make_contiguous();
}
}
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""A deterministic stand-in for `claude-code-acp` (#134).
Speaks just enough JSON-RPC over stdio for the daemon's handshake:
answers `initialize` and `session/new`, logs every method it receives, and
exits on SIGTERM. Tests point `PATH` at a directory holding this file under
the adapter's name, so the real adapter (which needs Claude auth and is
slow) never runs in the deterministic suite.
Environment knobs, all optional:
FAKE_ACP_LOG append one line per event to this file:
start pid=<pid> cwd=<cwd>
env <NAME>=<value> (for FAKE_ACP_ECHO_ENV)
caps <json of clientCapabilities>
recv <method>
signal SIGTERM
FAKE_ACP_ECHO_ENV name of one env var to log at start
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
`session/new`
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
so the SIGTERM path is exercised)
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
FAKE_ACP_CANCEL_SETTLES=1 complete the outstanding prompt on cancel
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
notifications ("replayed <i>") before answering,
the way a real agent streams history back
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
FAKE_ACP_DELAY_PROMPT_MS=n wait before answering `session/prompt`
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
JSON-RPC error
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
callback until a later prompt
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
counting from 1) and logs `emit tick <n>`, so a test can make the agent
speak on demand (#135).
"""
import json
import os
import signal
import sys
import time
LOG = os.environ.get("FAKE_ACP_LOG")
def log(line):
if not LOG:
return
with open(LOG, "a") as f:
f.write(line + "\n")
f.flush()
def send(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def on_sigterm(_signum, _frame):
log("signal SIGTERM")
sys.exit(0)
signal.signal(signal.SIGTERM, on_sigterm)
session_id = f"fake-{os.getpid()}"
ticks = 0
prompt_failures = 0
pending_prompt_id = None
pending_permission_id = None
delayed_permission = False
def chunk(text):
return {"jsonrpc": "2.0", "method": "session/update", "params": {
"sessionId": session_id,
"update": {"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text}},
}}
def on_sigusr1(_signum, _frame):
global ticks
ticks += 1
send(chunk(f"tick {ticks}"))
log(f"emit tick {ticks}")
signal.signal(signal.SIGUSR1, on_sigusr1)
log(f"start pid={os.getpid()} cwd={os.getcwd()}")
echo = os.environ.get("FAKE_ACP_ECHO_ENV")
if echo:
log(f"env {echo}={os.environ.get(echo, '')}")
for raw in sys.stdin:
raw = raw.strip()
if not raw:
continue
msg = json.loads(raw)
method = msg.get("method")
if method is None:
# A response to something we sent; nothing to do.
if msg.get("id") == pending_permission_id:
log("permission response " + json.dumps(msg.get("result"), sort_keys=True))
send({"jsonrpc": "2.0", "id": pending_prompt_id,
"result": {"stopReason": "end_turn"}})
pending_prompt_id = None
pending_permission_id = None
continue
log(f"recv {method}")
rid = msg.get("id")
params = msg.get("params") or {}
if method == "initialize":
log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True))
if os.environ.get("FAKE_ACP_INIT_FAIL") == "1":
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake init failure"}})
continue
send({"jsonrpc": "2.0", "id": rid, "result": {
"protocolVersion": params.get("protocolVersion", 1),
"agentCapabilities": {
"loadSession": os.environ.get("FAKE_ACP_NO_LOAD") != "1"},
"agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"},
"authMethods": [],
}})
elif method == "session/new":
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
send(chunk("hello from fake"))
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
sys.exit(0)
elif method == "session/load":
session_id = params.get("sessionId")
log(f"recv session/load sessionId={session_id}")
if os.environ.get("FAKE_ACP_LOAD_FAIL") == "1":
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake load failure"}})
continue
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
send(chunk(f"replayed {i + 1}"))
send({"jsonrpc": "2.0", "id": rid, "result": {}})
send(chunk("hello after load"))
elif method == "session/cancel":
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
send(chunk("final on cancel"))
if (os.environ.get("FAKE_ACP_CANCEL_SETTLES") == "1"
and pending_prompt_id is not None):
send({"jsonrpc": "2.0", "id": pending_prompt_id,
"result": {"stopReason": "cancelled"}})
pending_prompt_id = None
pending_permission_id = None
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
sys.exit(0)
elif method == "session/prompt":
if os.environ.get("FAKE_ACP_PERMISSION_AFTER_CANCEL") == "1":
if pending_prompt_id is None:
pending_prompt_id = rid
delayed_permission = True
continue
if delayed_permission:
delayed_permission = False
pending_permission_id = "permission-1"
send({"jsonrpc": "2.0", "id": pending_permission_id,
"method": "session/request_permission", "params": {
"sessionId": session_id,
"toolCall": {"toolCallId": "tool-1", "fields": {}},
"options": [
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
]
}})
continue
if os.environ.get("FAKE_ACP_REQUEST_PERMISSION") == "1":
pending_prompt_id = rid
pending_permission_id = "permission-1"
send({"jsonrpc": "2.0", "id": pending_permission_id,
"method": "session/request_permission", "params": {
"sessionId": session_id,
"toolCall": {"toolCallId": "tool-1", "fields": {}},
"options": [
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "session", "name": "Allow session", "kind": "allow_always"},
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
]
}})
continue
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
prompt_failures += 1
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake prompt failure"}})
else:
send({"jsonrpc": "2.0", "id": rid,
"result": {"stopReason": "end_turn"}})
elif rid is not None:
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32601, "message": f"method not found: {method}"}})
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
while True:
signal.pause()
+394
View File
@@ -0,0 +1,394 @@
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
//! one ignored test against the real `claude-code-acp`.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use tempfile::TempDir;
fn fake_agent() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fake_acp_agent.py")
}
/// A temp `bin/` holding the fake agent under the adapter's name, plus a
/// log file the agent appends to. `PATH` for the spawn is just that dir.
struct Fake {
dir: TempDir,
}
impl Fake {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
Self { dir }
}
fn log_path(&self) -> PathBuf {
self.dir.path().join("agent.log")
}
fn log(&self) -> String {
std::fs::read_to_string(self.log_path()).unwrap_or_default()
}
/// The fake's `bin/` first, so it shadows any real adapter, then the
/// process `PATH` so the script's `python3` still resolves.
fn env(&self) -> HashMap<String, String> {
let path = format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
HashMap::from([
("PATH".to_string(), path),
(
"FAKE_ACP_LOG".to_string(),
self.log_path().to_string_lossy().into_owned(),
),
])
}
fn config(&self, cwd: &Path) -> SpawnConfig {
let mut config = SpawnConfig::new(cwd, self.env());
config.handshake_timeout = Duration::from_secs(10);
config
}
async fn wait_for_log(&self, needle: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if self.log().contains(needle) {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("log never contained {needle:?}:\n{}", self.log());
}
fn pid(&self) -> u32 {
self.log()
.lines()
.find_map(|line| line.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|pid| pid.parse().ok())
.expect("pid in log")
}
async fn wait_for_exit(&self) {
let pid = self.pid();
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} still alive");
}
}
fn pid_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
&& std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|s| !s.contains(") Z "))
.unwrap_or(false)
}
#[tokio::test]
async fn spawn_runs_adapter_in_cwd_with_env_and_restrictive_caps() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
config
.env
.insert("MAESTRO_SESSION_ID".into(), "sess-123".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
assert!(session.session_id().starts_with("fake-"));
assert!(pid_alive(session.pid()));
let log = fake.log();
let cwd_canon = cwd.path().canonicalize().unwrap();
assert!(
log.contains(&format!("cwd={}", cwd_canon.display())),
"child cwd missing from log:\n{log}"
);
assert!(log.contains("env MAESTRO_SESSION_ID=sess-123"), "{log}");
assert!(log.contains("recv initialize"), "{log}");
assert!(log.contains("recv session/new"), "{log}");
let caps = log
.lines()
.find_map(|l| l.strip_prefix("caps "))
.expect("caps line");
let caps: serde_json::Value = serde_json::from_str(caps).unwrap();
assert_eq!(caps["fs"]["readTextFile"], false);
assert_eq!(caps["fs"]["writeTextFile"], false);
assert_eq!(caps["terminal"], false);
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn missing_adapter_names_the_install_command() {
let empty = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let env = HashMap::from([(
"PATH".to_string(),
empty.path().to_string_lossy().into_owned(),
)]);
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await
.expect_err("no adapter on PATH");
assert!(matches!(err, AcpError::AdapterMissing));
let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{msg}");
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
}
#[tokio::test]
async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()))
.await
.expect("spawn");
let mut updates = session.take_updates().expect("first take");
assert!(session.take_updates().is_none(), "handed out once");
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("update within 5s")
.expect("channel open");
assert_eq!(first.session_id.0.as_ref(), session.session_id());
match first.update {
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
ContentBlock::Text(t) => assert_eq!(t.text, "hello from fake"),
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()))
.await
.expect("spawn");
let pid = session.pid();
session
.terminate(Duration::from_millis(200), Duration::from_secs(2))
.await;
assert!(session.has_exited());
fake.wait_for_log("signal SIGTERM").await;
let log = fake.log();
let cancel = log.find("recv session/cancel").expect("cancel logged");
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
assert!(!pid_alive(pid), "child {pid} still alive");
}
#[tokio::test]
async fn terminate_is_satisfied_by_an_exit_on_cancel() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
session
.terminate(Duration::from_secs(2), Duration::from_secs(2))
.await;
assert!(session.has_exited());
let log = fake.log();
assert!(log.contains("recv session/cancel"), "{log}");
assert!(!log.contains("signal SIGTERM"), "cancel was enough:\n{log}");
}
#[tokio::test]
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("fake init failure"), "{err}");
// Closing the connection gives the fake EOF on stdin; the SIGTERM that
// follows may find it already gone. Either way it must not survive.
let pid: u32 = fake
.log()
.lines()
.find_map(|l| l.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|p| p.parse().ok())
.expect("pid in log");
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} outlived the failed spawn");
}
#[tokio::test]
async fn early_exit_is_observed_after_a_successful_handshake() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
let status = tokio::time::timeout(Duration::from_secs(5), session.wait())
.await
.expect("exit within 5s")
.expect("status");
assert!(status.success(), "{status}");
assert!(session.has_exited());
}
#[tokio::test]
async fn child_exit_before_handshake_reports_exited_early() {
// A "adapter" that dies before answering anything.
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let script = bin.join(ADAPTER_BIN);
std::fs::write(&script, "#!/bin/sh\necho 'not logged in' >&2\nexit 3\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let cwd = TempDir::new().unwrap();
let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]);
let mut config = SpawnConfig::new(cwd.path(), env);
config.handshake_timeout = Duration::from_secs(10);
let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err {
AcpError::ExitedEarly { status, stderr } => {
assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}");
}
other => panic!("unexpected {other:?}"),
}
}
#[tokio::test]
async fn drop_terminates_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_IGNORE_EOF".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
drop(session);
fake.wait_for_log("signal SIGTERM").await;
fake.wait_for_exit().await;
}
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
#[tokio::test]
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
assert_eq!(session.session_id(), "fake-old");
let log = fake.log();
assert!(
log.contains("recv session/load sessionId=fake-old"),
"{log}"
);
assert!(!log.contains("recv session/new"), "{log}");
// The three replayed chunks never reach the receiver: the event log
// already holds them. The first notification after the response does.
let mut updates = session.take_updates().expect("updates");
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("update within 5s")
.expect("channel open");
assert_eq!(chunk_text(&first), "hello after load");
// Nothing queued behind it either.
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(updates.try_recv().is_err(), "replay leaked through");
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn load_session_requires_the_load_capability() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config)
.await
.expect_err("no loadSession");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("loadSession"), "{err}");
assert!(!fake.log().contains("recv session/load"), "{}", fake.log());
}
#[tokio::test]
async fn load_session_error_fails_the_spawn() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("fake load failure"), "{err}");
}
/// End to end against the installed `claude-code-acp`. Needs Claude auth,
/// takes seconds, so it is opt-in: `cargo test -p maestro-acp -- --ignored`.
#[tokio::test]
#[ignore]
async fn real_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()))
.await
.expect("real claude-code-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
+23 -11
View File
@@ -96,20 +96,32 @@ pub async fn dispatch(
}
/// `maestro hooks install` (#106): write the shipped hook script under the
/// user's data dir and register it in their Claude Code settings. Idempotent
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
/// user's data dir and register it in every agent's hook file. Idempotent and
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
/// agent failing is reported and does not stop the others.
#[allow(clippy::result_large_err)]
fn run_hooks_install() -> Result<(), CliError> {
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
let changed = maestro_hooks::install_for_home(&home)?;
if changed {
println!(
"installed claude-code activity hook: {} → {}",
maestro_hooks::script_path(&home).display(),
maestro_hooks::settings_path(&home).display()
);
} else {
println!("claude-code activity hook already up to date");
let script = maestro_hooks::script_path(&home);
for (target, result) in maestro_hooks::install_for_home(&home) {
let agent = target.label();
let settings = target.settings_path(&home);
match result {
Ok(true) => {
println!(
"installed {agent} activity hook: {} → {}",
script.display(),
settings.display()
);
if target == maestro_hooks::Target::Codex {
// Codex will not run a hook it has not been told to
// trust, and that approval is the operator's to give.
println!(" codex will ask you to trust this hook the next time it starts");
}
}
Ok(false) => println!("{agent} activity hook already up to date"),
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
}
}
Ok(())
}
+3
View File
@@ -51,6 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
};
let body = CreateSessionRequest {
view: Default::default(),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
@@ -236,6 +237,7 @@ mod tests {
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(id),
cwd: PathBuf::from(cwd),
command: command.into(),
@@ -252,6 +254,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
@@ -91,6 +91,7 @@ async fn spawn_round_trips_through_the_daemon() {
let created = mock
.client()
.create_session(&maestro_protocol::CreateSessionRequest {
view: Default::default(),
cwd: "/home/mock".into(),
command: "bash".into(),
env: Default::default(),
+14
View File
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
/// Leader key both clients use when `[tui].leader` is unset (#159). Canonical
/// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey`
/// both produce.
pub const DEFAULT_LEADER: &str = "ctrl-a";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("io: {0}")]
@@ -152,6 +157,15 @@ pub struct TuiSection {
pub keymap_insert: BTreeMap<String, String>,
}
impl TuiSection {
/// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for
/// history, but read by every client: the daemon serves it on
/// `GET /api/info` so the web follows the same knob (#159).
pub fn leader(&self) -> &str {
self.leader.as_deref().unwrap_or(DEFAULT_LEADER)
}
}
/// Palette presets supported by the TUI's `[tui].theme` setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiTheme {
+2
View File
@@ -28,6 +28,7 @@ dirs.workspace = true
futures-util.workspace = true
include_dir = { workspace = true, optional = true }
libc.workspace = true
maestro-acp.workspace = true
mime_guess = { workspace = true, optional = true }
maestro-config.workspace = true
maestro-hooks = { workspace = true, default-features = false }
@@ -42,6 +43,7 @@ tracing-subscriber.workspace = true
uuid.workspace = true
[dev-dependencies]
libc.workspace = true
reqwest.workspace = true
tokio-tungstenite.workspace = true
urlencoding = "2"
+9 -1
View File
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
) -> Result<(), axum::Error> {
let (mut sink, mut stream) = socket.split();
// This client's claim on the shared window size. Registered up front and
// released on every exit below, so a socket that dies mid-session stops
// constraining the size for the clients still attached.
let viewport = manager.attach_viewport();
// NAP-229: send buffered scrollback up-front so the client sees the last
// screenful immediately instead of a blank terminal. The replay lock the
// bridge held during snapshot+subscribe guarantees no byte appears both
// here and on the live receiver, and none is dropped at the seam.
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
manager.release_viewport(id, viewport).await;
return Ok(());
}
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl {
ClientControl::Resize { cols, rows } => {
let _ = manager.resize(id, cols, rows).await;
let _ = manager.set_viewport(id, viewport, cols, rows).await;
}
ClientControl::Ping => {
let pong = serde_json::to_string(&ClientControl::Pong)
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
}
}
manager.release_viewport(id, viewport).await;
if session_ended {
let _ = sink.send(Message::Close(None)).await;
}
+34 -14
View File
@@ -51,17 +51,23 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
// First-start hook auto-install (#106). Best-effort, before serving: a
// session spawned right away should already carry the hook.
if config.hooks_auto_install {
match crate::hooks::auto_install(&config.home_dir) {
crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!("installed claude-code activity hook (#106)");
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
let agent = target.label();
match outcome {
crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!(agent, "installed activity hook (#106)");
}
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!(agent, "updated activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!(
agent,
"hooks auto-install failed; run `maestro hooks install` manually"
);
}
_ => {}
}
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!("updated claude-code activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
}
_ => {}
}
}
// No worktree dir to pre-create: without an override the worktrees live
@@ -100,20 +106,34 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
projects.start_background_refresh();
let router = local_api::router(local_api::state_for(
manager,
manager.clone(),
config.hostname.clone(),
config.home_dir.clone(),
config.spawn_presets.clone(),
projects,
config.leader.clone(),
));
let listener = TcpListener::bind(config.bind_addr).await?;
let bound = listener.local_addr()?;
tracing::info!(addr = %bound, "API listening");
let shutdown = async {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("ctrl-c received — shutting down");
// Ctrl-C at a terminal or SIGTERM from systemd: either one is a
// shutdown, and both must reach the adapter children below (#134). The
// unit's `KillMode=process` signals only the daemon, so nothing else
// would end them.
let shutdown_manager = manager.clone();
let shutdown = async move {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received — shutting down"),
_ = sigterm.recv() => tracing::info!("SIGTERM received — shutting down"),
}
// Axum waits for upgraded WebSockets to drain. Structured children
// must be settled before that wait, while the signal path still owns
// the shutdown order (#179).
shutdown_manager.shutdown().await;
};
axum::serve(listener, router)
+29
View File
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
/// start when none is present (#106): `[hooks].auto_install`, default
/// `true`.
pub hooks_auto_install: bool,
/// Canonical leader key advertised to clients in `GET /api/info` (#159):
/// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon
/// never binds it itself; it serves it so the web client and the TUI read
/// one knob.
pub leader: String,
}
impl DaemonConfig {
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
project_roots,
project_scan_depth,
hooks_auto_install: file.hooks.auto_install(),
leader: file.tui.leader().to_string(),
})
}
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
mod tests {
use super::*;
/// #159: the leader the operator set for the TUI is what the daemon
/// advertises, so rebinding it once moves both clients' chord.
#[test]
fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.leader, "ctrl-a");
std::fs::write(
&path,
r#"
[tui]
leader = "ctrl-b"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.leader, "ctrl-b");
}
#[test]
fn hooks_auto_install_defaults_true_and_is_opt_out() {
let dir = tempfile::tempdir().unwrap();
+107 -37
View File
@@ -1,37 +1,53 @@
//! First-start hook auto-install (#106).
//!
//! The daemon runs [`auto_install`] once at startup: unless the user's
//! `~/.claude/settings.json` already carries exactly what this build ships
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
//! same installer `maestro hooks install` uses writes it. That covers
//! upgrades as well as first starts: an install from a maestro that had fewer
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
//! a failure is warned about and never blocks startup. The same global hook
//! serves every Claude Code session on the machine, so nothing here targets
//! repositories or worktrees.
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
//! file already carries exactly what this build ships (and `[hooks]
//! .auto_install` hasn't opted out — checked by the caller), the same
//! installer `maestro hooks install` uses writes it. That covers upgrades as
//! well as first starts: an install from a maestro that had fewer events —
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
//! settings — is rewritten rather than left half-wired. Best-effort: a
//! failure is warned about and never blocks startup, and one target failing
//! does not stop the others. The same global hook serves every session on the
//! machine, so nothing here targets repositories or worktrees.
use std::path::Path;
/// Install the Claude Code activity hook for `home_dir` unless the current
/// one is already there or the home dir is unresolvable (`""`). Returns what
/// happened; an older install being brought up to date is
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
use maestro_hooks::Target;
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
/// any whose install is already current. Returns what happened per target —
/// an older install being brought up to date is
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
Target::ALL
.iter()
.map(|&target| (target, auto_install_target(target, home_dir)))
.collect()
}
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
if home_dir.is_empty() {
return AutoInstallOutcome::Skipped;
}
let settings = maestro_hooks::settings_path(Path::new(home_dir));
let script = maestro_hooks::script_path(Path::new(home_dir));
if maestro_hooks::is_current(&settings, &script) {
let home = Path::new(home_dir);
let settings = target.settings_path(home);
let script = maestro_hooks::script_path(home);
if maestro_hooks::is_current(target, &settings, &script) {
return AutoInstallOutcome::AlreadyPresent;
}
let stale = maestro_hooks::is_installed(&settings);
match maestro_hooks::install_for_home(Path::new(home_dir)) {
match maestro_hooks::install_target(target, home) {
Ok(_) if stale => AutoInstallOutcome::Upgraded,
Ok(_) => AutoInstallOutcome::Installed,
Err(e) => {
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
tracing::warn!(
target = target.label(),
error = %e,
"hooks auto-install failed (non-fatal)"
);
AutoInstallOutcome::Failed
}
}
@@ -52,15 +68,50 @@ pub enum AutoInstallOutcome {
mod tests {
use super::*;
/// The outcome recorded for one target.
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
outcomes
.iter()
.find(|(t, _)| *t == target)
.map(|(_, o)| *o)
.expect("every target is reported")
}
/// Everything happens under a temp `$HOME`; the developer's own
/// `~/.claude/settings.json` is never read or written.
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
/// written.
#[test]
fn installs_when_settings_carry_no_maestro_hook() {
let dir = tempfile::tempdir().unwrap();
let outcome = auto_install(dir.path().to_str().unwrap());
assert_eq!(outcome, AutoInstallOutcome::Installed);
let settings = dir.path().join(".claude/settings.json");
assert!(maestro_hooks::is_installed(&settings));
let outcomes = auto_install(dir.path().to_str().unwrap());
for target in Target::ALL {
assert_eq!(
outcome(&outcomes, target),
AutoInstallOutcome::Installed,
"{}",
target.label()
);
assert!(maestro_hooks::is_installed(
&target.settings_path(dir.path())
));
}
}
/// Every agent maestro knows about is hooked, not just Claude Code: a
/// codex session reports activity and its first prompt the same way.
#[test]
fn codex_gets_its_own_hook_file() {
let dir = tempfile::tempdir().unwrap();
auto_install(dir.path().to_str().unwrap());
let hooks = dir.path().join(".codex/hooks.json");
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
let events = root["hooks"].as_object().unwrap();
assert!(events.contains_key("PermissionRequest"));
assert!(
!events.contains_key("Notification"),
"codex has no Notification"
);
}
#[test]
@@ -69,14 +120,22 @@ mod tests {
let home = dir.path().to_str().unwrap();
auto_install(home);
// A current install is left exactly as it is.
let settings = dir.path().join(".claude/settings.json");
let before = std::fs::read_to_string(&settings).unwrap();
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
assert_eq!(
before,
std::fs::read_to_string(&settings).unwrap(),
"already-installed settings must not be rewritten"
);
let before: Vec<String> = Target::ALL
.iter()
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
.collect();
let outcomes = auto_install(home);
for (i, target) in Target::ALL.iter().enumerate() {
assert_eq!(
outcome(&outcomes, *target),
AutoInstallOutcome::AlreadyPresent
);
assert_eq!(
before[i],
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
"already-installed settings must not be rewritten"
);
}
}
/// An install from an older maestro — present, but missing an event this
@@ -87,7 +146,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
auto_install(home);
let settings = dir.path().join(".claude/settings.json");
let settings = Target::ClaudeCode.settings_path(dir.path());
let script = maestro_hooks::script_path(dir.path());
let mut root: serde_json::Value =
@@ -96,12 +155,23 @@ mod tests {
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
assert!(maestro_hooks::is_current(&settings, &script));
let outcomes = auto_install(home);
assert_eq!(
outcome(&outcomes, Target::ClaudeCode),
AutoInstallOutcome::Upgraded
);
assert!(maestro_hooks::is_current(
Target::ClaudeCode,
&settings,
&script
));
}
#[test]
fn unresolvable_home_is_skipped() {
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
let outcomes = auto_install("");
for target in Target::ALL {
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
}
}
}
+2
View File
@@ -21,6 +21,7 @@ pub mod hooks;
pub mod local_api;
pub mod panes;
pub mod proc;
pub mod projection;
pub mod projects;
pub mod pty;
pub mod release;
@@ -30,6 +31,7 @@ pub mod spawn_history;
#[cfg(feature = "web")]
pub mod static_bundle;
pub mod tmux;
pub mod viewport;
pub mod worktree;
pub mod worktree_probe;
+209 -3
View File
@@ -21,12 +21,13 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
PROTOCOL_VERSION,
StructuredApprovalDecision, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::session::SessionManager;
use crate::session::{QueueAcpPrompt, SessionManager};
use crate::sidecar::EventCursor;
#[derive(Debug, Clone)]
pub struct LocalApiState {
@@ -39,6 +40,10 @@ pub struct LocalApiState {
pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>,
/// Configured leader key (`[tui].leader`) advertised in `GET /api/info`
/// (#159), so the web client binds the chord the operator configured
/// rather than its own default.
pub leader: Arc<String>,
}
pub fn router(state: LocalApiState) -> Router {
@@ -60,7 +65,15 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::NAMES, get(names))
.route(routes::INFO, get(daemon_info))
.route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws));
.route(routes::patterns::ATTACH, any(attach_ws))
.route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(
routes::patterns::STRUCTURED_APPROVAL,
post(structured_approval),
);
#[cfg(feature = "haiku-autoname")]
{
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
@@ -166,6 +179,7 @@ fn session_error_status(code: &str) -> StatusCode {
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
}
}
@@ -219,6 +233,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
presets: (*s.presets).clone(),
build: crate::release::build_info(),
instance: crate::release::instance_id().to_string(),
leader: (*s.leader).clone(),
})
}
@@ -363,6 +378,126 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response()
}
#[derive(Debug, Deserialize)]
struct ReplayQuery {
since: Option<u64>,
before: Option<u64>,
limit: Option<usize>,
}
const MAX_STRUCTURED_REPLAY_LIMIT: usize = 500;
async fn structured_replay(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<ReplayQuery>,
) -> Response {
if query.since.is_some() && query.before.is_some() {
return error_response(
StatusCode::BAD_REQUEST,
"invalid_cursor",
"use either since or before, not both",
);
}
let cursor = match query.before {
Some(before) => EventCursor::Before(Some(before)),
None => EventCursor::Since(query.since.unwrap_or(0)),
};
match s
.manager
.read_acp_projection_replay(
id,
cursor,
query
.limit
.unwrap_or(100)
.clamp(1, MAX_STRUCTURED_REPLAY_LIMIT),
)
.await
{
Ok(Some(events)) => Json(events).into_response(),
// A terminal session, or a structured session whose adapter is no
// longer live, cannot provide a coherent replay continuation.
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_prompt(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
Ok(QueueAcpPrompt::InFlight) => error_response(
StatusCode::CONFLICT,
"turn_in_flight",
"the structured session already has an active turn",
),
Ok(QueueAcpPrompt::Unavailable) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Ok(QueueAcpPrompt::Failed(message)) => {
error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message)
}
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
match s.manager.cancel_acp_turn(id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_approval(
State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>,
Json(decision): Json<StructuredApprovalDecision>,
) -> Response {
let decision = match decision {
StructuredApprovalDecision::AllowOnce => crate::session::ApprovalDecision::AllowOnce,
StructuredApprovalDecision::AllowForSession => {
crate::session::ApprovalDecision::AllowForSession
}
StructuredApprovalDecision::Deny => crate::session::ApprovalDecision::Deny,
};
match s.manager.resolve_acp_approval(id, &nonce, decision).await {
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
/// Convenience constructor — pulls the bits `LocalApiState` needs from
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
/// scanner starts with its cache cold; wire
@@ -374,6 +509,7 @@ pub fn state_for(
home_dir: impl Into<String>,
presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>,
leader: impl Into<String>,
) -> LocalApiState {
LocalApiState {
manager,
@@ -381,6 +517,7 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
projects,
leader: Arc::new(leader.into()),
}
}
@@ -411,6 +548,75 @@ async fn attach_ws(
}))
}
#[allow(clippy::result_large_err)]
async fn structured_ws(
Path(id): Path<Uuid>,
State(s): State<LocalApiState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
let Some((snapshot, state, rx)) = s
.manager
.subscribe_acp_projection(id, EventCursor::Since(0))
.await
.map_err(|e| {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
})?
else {
return Err(error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
));
};
Ok(ws.on_upgrade(move |socket| async move {
let _ = pump_structured(socket, snapshot, state, rx).await;
}))
}
async fn pump_structured(
socket: axum::extract::ws::WebSocket,
snapshot: maestro_protocol::TranscriptSnapshot,
state: maestro_protocol::ReducedState,
mut rx: tokio::sync::broadcast::Receiver<crate::projection::ProjectionUpdate>,
) -> Result<(), axum::Error> {
use axum::extract::ws::Message;
use futures_util::{SinkExt, StreamExt};
let (mut sink, mut stream) = socket.split();
for event in [
StructuredEvent::TranscriptSnapshot { snapshot },
StructuredEvent::ReducedState { state },
] {
if sink
.send(Message::Text(
serde_json::to_string(&event).expect("structured event encode"),
))
.await
.is_err()
{
return Ok(());
}
}
loop {
tokio::select! {
update = rx.recv() => match update {
Ok(update) => {
let event = StructuredEvent::ProjectionUpdate { transcript: update.transcript, state: update.state };
if sink.send(Message::Text(serde_json::to_string(&event).expect("structured event encode"))).await.is_err() { break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
},
frame = stream.next() => match frame {
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {},
},
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
// ---------------------------------------------------------------------------
+754
View File
@@ -0,0 +1,754 @@
//! Fold persisted ACP notifications into client-neutral transcript and state.
use std::collections::{HashMap, HashSet};
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
use maestro_protocol::{
ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, TranscriptSnapshot,
};
use serde_json::Value;
/// A transcript delta paired with the state after the same ACP notification.
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectionUpdate {
pub transcript: Option<TranscriptDelta>,
pub state: ReducedState,
}
/// A prompt lifecycle transition supplied by the prompt transport (#138).
///
/// ACP notifications do not delimit turns, so replaying them must not infer
/// an in-flight prompt from an agent chunk.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnLifecycle {
Begin,
Finish,
Cancel,
Failure,
}
/// The one-pass ACP fold used for both persisted replay and live events.
#[derive(Debug, Default)]
pub struct Projection {
rows: Vec<TranscriptRow>,
row_index: HashMap<String, usize>,
tools: HashMap<String, ToolCall>,
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
state: ReducedState,
active_turn: Option<u64>,
next_turn: u64,
turn_tool_calls: HashSet<String>,
}
impl Projection {
/// Fold one durable event and return its client-visible changes.
pub fn apply(&mut self, seq: u64, notification: &SessionNotification) -> ProjectionUpdate {
let transcript = match &notification.update {
SessionUpdate::UserMessageChunk(chunk) => {
self.content(seq, chunk, TranscriptRowKind::User, "user")
}
SessionUpdate::AgentMessageChunk(chunk) => {
self.agent_text();
self.content(seq, chunk, TranscriptRowKind::AgentText, "agent")
}
SessionUpdate::AgentThoughtChunk(chunk) => {
self.agent_thought();
self.content(seq, chunk, TranscriptRowKind::AgentThought, "thought")
}
SessionUpdate::ToolCall(tool) => self.tool_call(tool),
SessionUpdate::ToolCallUpdate(update) => self.tool_update(update),
SessionUpdate::Plan(plan) => {
let data = to_value(plan);
self.state.plan = Some(data.clone());
Some(self.replace("plan".into(), TranscriptRowKind::Plan, data))
}
SessionUpdate::UsageUpdate(usage) => {
let value = to_value(usage);
self.state.usage = Some(value.clone());
self.state.context = Some(value);
None
}
SessionUpdate::CurrentModeUpdate(mode) => {
self.state.mode = Some(mode.current_mode_id.to_string());
None
}
SessionUpdate::ConfigOptionUpdate(options) => {
let value = to_value(options);
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
None
}
// ACP deliberately adds notification variants over time. Known but
// non-transcript updates become a generic notice instead of being
// dropped or forcing clients to understand ACP's enum.
_ => Some(self.append(
format!("notice:{seq}"),
TranscriptRowKind::Notice,
to_value(&notification.update),
)),
};
ProjectionUpdate {
transcript,
state: self.state.clone(),
}
}
/// The current transcript at `cursor`.
pub fn snapshot(&self, cursor: u64) -> TranscriptSnapshot {
TranscriptSnapshot {
cursor,
rows: self.rows.clone(),
}
}
/// The state folded over every event seen so far.
pub fn state(&self) -> ReducedState {
self.state.clone()
}
pub fn turn_active(&self) -> bool {
self.active_turn.is_some()
}
/// Start a prompt turn and return its monotonically increasing token.
pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) {
self.next_turn += 1;
let turn = self.next_turn;
self.active_turn = Some(turn);
self.state.turn.responding = true;
self.state.turn.thinking = true;
self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear();
(
turn,
ProjectionUpdate {
transcript: None,
state: self.state(),
},
)
}
/// End `turn` only when it remains the active prompt turn.
pub fn turn_lifecycle(
&mut self,
lifecycle: TurnLifecycle,
turn: u64,
) -> Option<ProjectionUpdate> {
if !matches!(
lifecycle,
TurnLifecycle::Finish | TurnLifecycle::Cancel | TurnLifecycle::Failure
) || self.active_turn != Some(turn)
{
return None;
}
self.active_turn = None;
self.state.turn.responding = false;
self.state.turn.thinking = false;
self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear();
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
pub fn active_turn(&self) -> Option<u64> {
self.active_turn
}
/// Add a daemon-originated permission row. The nonce belongs only in the
/// reduced state, which is the client control surface, never in ACP data.
pub fn request_approval(&mut self, nonce: String, request: Value) -> ProjectionUpdate {
let row = self.append(
format!("approval:{}", uuid::Uuid::new_v4()),
TranscriptRowKind::Approval,
request.clone(),
);
self.state.pending_approval = Some(serde_json::json!({
"nonce": nonce,
"request": request,
}));
ProjectionUpdate {
transcript: Some(row),
state: self.state(),
}
}
pub fn restore_approval(&mut self, row: TranscriptRow) -> ProjectionUpdate {
let transcript = if !self.row_index.contains_key(&row.id) {
self.row_index.insert(row.id.clone(), self.rows.len());
self.rows.push(row.clone());
Some(TranscriptDelta::Append { row })
} else {
None
};
ProjectionUpdate {
transcript,
state: self.state(),
}
}
/// Clear an approval only when it is still the one the client answered.
pub fn resolve_approval(&mut self, nonce: &str) -> Option<ProjectionUpdate> {
let pending = self.state.pending_approval.as_ref()?;
if pending.get("nonce").and_then(Value::as_str) != Some(nonce) {
return None;
}
self.state.pending_approval = None;
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
/// Terminal turn paths must not leave a recovered snapshot permanently
/// blocked on an ACP request whose transport no longer exists.
pub fn clear_approval(&mut self) -> Option<ProjectionUpdate> {
self.state.pending_approval.take()?;
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
fn content(
&mut self,
seq: u64,
chunk: &ContentChunk,
kind: TranscriptRowKind,
prefix: &str,
) -> Option<TranscriptDelta> {
let id = chunk
.message_id
.as_ref()
.map(|id| format!("{prefix}:{id}"))
.unwrap_or_else(|| format!("{prefix}:{seq}"));
let data = to_value(chunk);
if let Some(index) = self.row_index.get(&id).copied() {
let row = &mut self.rows[index];
append_text(&mut row.data, &data);
return Some(TranscriptDelta::Patch { row: row.clone() });
}
Some(self.append(id, kind, data))
}
fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> {
let id = format!("tool:{}", tool.tool_call_id);
let tool_id = tool.tool_call_id.to_string();
let mut canonical = tool.clone();
if let Some(updates) = self.pending_tool_updates.get(&tool_id) {
for fields in updates {
canonical.update(fields.clone());
}
}
let kind = if tool_has_diff(tool) {
TranscriptRowKind::ToolDiff
} else {
TranscriptRowKind::ToolCall
};
self.tools.insert(tool_id, canonical.clone());
self.track_tool_call(&canonical);
Some(self.replace(
id,
if tool_has_diff(&canonical) {
TranscriptRowKind::ToolDiff
} else {
kind
},
to_value(&canonical),
))
}
fn tool_update(
&mut self,
update: &maestro_acp::schema::ToolCallUpdate,
) -> Option<TranscriptDelta> {
let tool_id = update.tool_call_id.to_string();
let Some(tool) = self.tools.get_mut(&tool_id) else {
self.pending_tool_updates
.entry(tool_id)
.or_default()
.push(update.fields.clone());
return None;
};
tool.update(update.fields.clone());
self.pending_tool_updates
.entry(tool_id.clone())
.or_default()
.push(update.fields.clone());
let row_id = format!("tool:{tool_id}");
let index = *self
.row_index
.get(&row_id)
.expect("a tracked tool call always has a transcript row");
let row = {
let row = &mut self.rows[index];
row.kind = if tool_has_diff(tool) {
TranscriptRowKind::ToolDiff
} else {
TranscriptRowKind::ToolCall
};
row.data = to_value(tool);
row.clone()
};
self.sync_active_tool(&tool_id);
Some(TranscriptDelta::Patch { row })
}
fn append(&mut self, id: String, kind: TranscriptRowKind, data: Value) -> TranscriptDelta {
let row = TranscriptRow { id, kind, data };
self.row_index.insert(row.id.clone(), self.rows.len());
self.rows.push(row.clone());
TranscriptDelta::Append { row }
}
fn replace(&mut self, id: String, kind: TranscriptRowKind, data: Value) -> TranscriptDelta {
let Some(index) = self.row_index.get(&id).copied() else {
return self.append(id, kind, data);
};
let row = &mut self.rows[index];
row.kind = kind;
row.data = data;
TranscriptDelta::Patch { row: row.clone() }
}
fn agent_text(&mut self) {
if self.turn_active() {
self.state.turn.responding = true;
self.state.turn.thinking = false;
}
}
fn agent_thought(&mut self) {
if self.turn_active() {
self.state.turn.responding = true;
self.state.turn.thinking = true;
}
}
fn track_tool_call(&mut self, tool: &ToolCall) {
if self.turn_active() {
self.turn_tool_calls.insert(tool.tool_call_id.to_string());
self.sync_active_tool(&tool.tool_call_id.to_string());
}
}
fn sync_active_tool(&mut self, tool_id: &str) {
if !self.turn_active() || !self.turn_tool_calls.contains(tool_id) {
return;
}
let active = self.tools.get(tool_id).is_some_and(|tool| {
matches!(
tool.status,
maestro_acp::schema::ToolCallStatus::Pending
| maestro_acp::schema::ToolCallStatus::InProgress
)
});
let active_tool_calls = &mut self.state.turn.active_tool_calls;
if active {
if !active_tool_calls.iter().any(|id| id == tool_id) {
active_tool_calls.push(tool_id.to_owned());
active_tool_calls.sort();
}
} else {
active_tool_calls.retain(|id| id != tool_id);
}
}
}
fn to_value(value: &impl serde::Serialize) -> Value {
serde_json::to_value(value).expect("ACP schema values serialize")
}
fn tool_has_diff(tool: &ToolCall) -> bool {
tool.content
.iter()
.any(|content| matches!(content, maestro_acp::schema::ToolCallContent::Diff(_)))
}
fn append_text(existing: &mut Value, next: &Value) {
let Some(existing_text) = existing.pointer_mut("/content/text") else {
*existing = next.clone();
return;
};
let Some(next_text) = next.pointer("/content/text").and_then(Value::as_str) else {
*existing = next.clone();
return;
};
if let Some(text) = existing_text.as_str() {
*existing_text = Value::String(format!("{text}{next_text}"));
} else {
*existing = next.clone();
}
}
fn model_from_options(value: &Value) -> Option<String> {
value
.get("configOptions")?
.as_array()?
.iter()
.find(|option| {
option.get("id").and_then(Value::as_str) == Some("model")
|| option.get("category").and_then(Value::as_str) == Some("model")
})?
.get("currentValue")?
.as_str()
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use maestro_acp::schema::SessionNotification;
use maestro_protocol::{TranscriptDelta, TranscriptRowKind};
use super::{Projection, TurnLifecycle};
fn event(value: serde_json::Value) -> SessionNotification {
serde_json::from_value(serde_json::json!({
"sessionId": "acp-1",
"update": value,
}))
.unwrap()
}
#[test]
fn folds_content_tool_diff_plan_and_notice_rows() {
let mut projection = Projection::default();
let events = [
event(
serde_json::json!({"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}}),
),
event(
serde_json::json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}),
),
event(
serde_json::json!({"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"think"}}),
),
event(
serde_json::json!({"sessionUpdate":"tool_call","toolCallId":"read-1","title":"Read","kind":"read"}),
),
event(
serde_json::json!({"sessionUpdate":"tool_call","toolCallId":"edit-1","title":"Edit","content":[{"type":"diff","path":"x.rs","oldText":"a","newText":"b"}]}),
),
event(serde_json::json!({"sessionUpdate":"plan","entries":[]})),
event(
serde_json::json!({"sessionUpdate":"available_commands_update","availableCommands":[]}),
),
];
let kinds: Vec<_> = events
.iter()
.enumerate()
.filter_map(|(i, event)| projection.apply((i + 1) as u64, event).transcript)
.map(|delta| match delta {
TranscriptDelta::Append { row } => row.kind,
other => panic!("expected append, got {other:?}"),
})
.collect();
assert_eq!(
kinds,
[
TranscriptRowKind::User,
TranscriptRowKind::AgentText,
TranscriptRowKind::AgentThought,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolDiff,
TranscriptRowKind::Plan,
TranscriptRowKind::Notice,
]
);
}
#[test]
fn tool_update_patches_the_original_stable_row() {
let mut projection = Projection::default();
let first = projection.apply(
4,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"bash-7", "title":"Run"
})),
);
let updated = projection.apply(
5,
&event(serde_json::json!({
"sessionUpdate":"tool_call_update", "toolCallId":"bash-7", "status":"completed"
})),
);
let id = match first.transcript.unwrap() {
TranscriptDelta::Append { row } => row.id,
other => panic!("expected append, got {other:?}"),
};
match updated.transcript.unwrap() {
TranscriptDelta::Patch { row } => {
assert_eq!(row.id, id);
assert_eq!(row.data["status"], "completed");
}
other => panic!("expected patch, got {other:?}"),
}
assert_eq!(projection.snapshot(5).rows.len(), 1);
}
#[test]
fn duplicate_tool_calls_patch_one_stable_row() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"bash-7", "title":"Run"
})),
);
let duplicate = projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"bash-7", "title":"Run again"
})),
);
match duplicate.transcript.unwrap() {
TranscriptDelta::Patch { row } => {
assert_eq!(row.id, "tool:bash-7");
assert_eq!(row.data["title"], "Run again");
}
other => panic!("expected patch, got {other:?}"),
}
assert_eq!(projection.snapshot(2).rows.len(), 1);
}
#[test]
fn early_tool_update_is_reconciled_when_the_call_arrives() {
let mut projection = Projection::default();
let early = projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"tool_call_update", "toolCallId":"bash-7", "status":"completed"
})),
);
assert_eq!(early.transcript, None);
let call = projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"bash-7", "title":"Run", "status":"pending"
})),
);
match call.transcript.unwrap() {
TranscriptDelta::Append { row } => {
assert_eq!(row.id, "tool:bash-7");
assert_eq!(row.data["status"], "completed");
}
other => panic!("expected append, got {other:?}"),
}
assert!(projection.state().turn.active_tool_calls.is_empty());
}
#[test]
fn unknown_tool_stays_generic_and_keeps_its_raw_name() {
let mut projection = Projection::default();
let update = projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"custom-1", "title":"Custom",
"name":"vendor.custom", "kind":"future_kind"
})),
);
match update.transcript.unwrap() {
TranscriptDelta::Append { row } => {
assert_eq!(row.kind, TranscriptRowKind::ToolCall);
assert_eq!(row.data["name"], "vendor.custom");
}
other => panic!("expected generic tool row, got {other:?}"),
}
}
#[test]
fn folds_usage_mode_model_and_plan_into_whole_state() {
let mut projection = Projection::default();
for (seq, update) in [
serde_json::json!({"sessionUpdate":"usage_update","used":12,"size":200}),
serde_json::json!({"sessionUpdate":"current_mode_update","currentModeId":"plan"}),
serde_json::json!({"sessionUpdate":"config_option_update","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"opus","options":[]}]}),
serde_json::json!({"sessionUpdate":"plan","entries":[]}),
]
.into_iter()
.enumerate()
{
projection.apply((seq + 1) as u64, &event(update));
}
let state = projection.state();
assert_eq!(state.usage.as_ref().unwrap()["used"], 12);
assert_eq!(state.context.as_ref().unwrap()["size"], 200);
assert_eq!(state.mode.as_deref(), Some("plan"));
assert_eq!(state.model.as_deref(), Some("opus"));
assert_eq!(
state.plan.as_ref().unwrap()["entries"],
serde_json::json!([])
);
}
#[test]
fn plans_replace_one_stable_row_and_state() {
let mut projection = Projection::default();
let first = projection.apply(
1,
&event(serde_json::json!({"sessionUpdate":"plan","entries":[{"content":"first","priority":"high","status":"pending"}]})),
);
assert!(matches!(
first.transcript,
Some(TranscriptDelta::Append { .. })
));
let replacement = projection.apply(
2,
&event(serde_json::json!({"sessionUpdate":"plan","entries":[{"content":"second","priority":"high","status":"completed"}]})),
);
match replacement.transcript.unwrap() {
TranscriptDelta::Patch { row } => {
assert_eq!(row.id, "plan");
assert_eq!(row.data["entries"][0]["content"], "second");
}
other => panic!("expected patch, got {other:?}"),
}
assert_eq!(projection.snapshot(2).rows.len(), 1);
assert_eq!(
projection.state().plan.unwrap()["entries"][0]["content"],
"second"
);
}
#[test]
fn turn_lifecycle_gates_chunks_and_normalizes_terminal_states() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"replay"}})),
);
assert_eq!(projection.state().turn, Default::default());
let (_, _) = projection.begin_turn();
assert!(projection.state().turn.responding);
assert!(projection.state().turn.thinking);
projection.apply(
2,
&event(serde_json::json!({"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"think"}})),
);
assert!(projection.state().turn.thinking);
projection.apply(
3,
&event(serde_json::json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"answer"}})),
);
assert!(projection.state().turn.responding);
assert!(!projection.state().turn.thinking);
for terminal in [
TurnLifecycle::Finish,
TurnLifecycle::Cancel,
TurnLifecycle::Failure,
] {
let (turn, _) = projection.begin_turn();
projection.turn_lifecycle(terminal, turn);
assert!(!projection.state().turn.responding);
assert!(!projection.state().turn.thinking);
}
}
fn terminal_turn_drops_unfinished_tools(
terminal: TurnLifecycle,
unfinished_id: &str,
next_id: &str,
) {
let mut projection = Projection::default();
let (turn, _) = projection.begin_turn();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":unfinished_id,
"title":"Unfinished", "status":"in_progress"
})),
);
assert_eq!(projection.state().turn.active_tool_calls, [unfinished_id]);
projection.turn_lifecycle(terminal, turn);
assert!(projection.state().turn.active_tool_calls.is_empty());
assert_eq!(projection.snapshot(1).rows.len(), 1);
projection.begin_turn();
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":next_id,
"title":"Next", "status":"pending"
})),
);
assert_eq!(projection.state().turn.active_tool_calls, [next_id]);
}
#[test]
fn cancelled_turn_does_not_reactivate_unfinished_tools() {
terminal_turn_drops_unfinished_tools(TurnLifecycle::Cancel, "cancelled-1", "next-1");
}
#[test]
fn failed_turn_does_not_reactivate_unfinished_tools() {
terminal_turn_drops_unfinished_tools(TurnLifecycle::Failure, "failed-1", "next-1");
}
#[test]
fn approval_history_survives_pending_cleanup() {
let mut projection = Projection::default();
let update =
projection.request_approval("secret".into(), serde_json::json!({"tool":"bash"}));
let TranscriptDelta::Append { row } = update.transcript.unwrap() else {
panic!("approval row")
};
assert_eq!(row.kind, TranscriptRowKind::Approval);
assert!(row.data.get("nonce").is_none());
assert!(!row.id.contains("secret"));
let persisted =
serde_json::to_string(&crate::sidecar::PersistedEvent::Approval { row: row.clone() })
.unwrap();
assert!(!persisted.contains("secret"));
assert_eq!(
projection.state().pending_approval.unwrap()["nonce"],
"secret"
);
projection.clear_approval();
assert!(projection.state().pending_approval.is_none());
assert_eq!(projection.snapshot(0).rows, vec![row]);
}
#[test]
fn recovered_approval_is_historical_not_pending() {
let mut live = Projection::default();
let TranscriptDelta::Append { row } = live
.request_approval("daemon-nonce".into(), serde_json::json!({"tool":"bash"}))
.transcript
.unwrap()
else {
panic!("approval row")
};
let mut recovered = Projection::default();
let update = recovered.restore_approval(row.clone());
assert_eq!(
update.transcript,
Some(TranscriptDelta::Append { row: row.clone() })
);
assert!(update.state.pending_approval.is_none());
assert_eq!(recovered.snapshot(0).rows, vec![row]);
let replay = serde_json::to_string(&recovered.snapshot(1).rows).unwrap();
assert!(!replay.contains("daemon-nonce"));
}
#[test]
fn stale_terminal_turn_cannot_clear_newer_approval() {
let mut projection = Projection::default();
let (first, _) = projection.begin_turn();
projection.turn_lifecycle(TurnLifecycle::Cancel, first);
let (second, _) = projection.begin_turn();
projection.request_approval("second-nonce".into(), serde_json::json!({"tool":"bash"}));
assert!(projection
.turn_lifecycle(TurnLifecycle::Finish, first)
.is_none());
assert_eq!(projection.active_turn(), Some(second));
assert_eq!(
projection.state().pending_approval.unwrap()["nonce"],
"second-nonce"
);
}
}
File diff suppressed because it is too large Load Diff
+239 -7
View File
@@ -5,10 +5,23 @@
//! original `cwd`, command, env, metadata, etc. Files are deleted on
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still
//! advertised but with empty metadata (handled by the caller, not here).
//!
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
//! sidecar: every `session/update` notification and daemon approval row is one
//! JSON object, appended before it is broadcast. The log is the source of
//! truth for the transcript; `seq` is the line's 1-based position, so a
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
//! that does not parse — including the partial line a hard kill leaves at
//! the end — keeps its seq and is skipped on read. `delete` removes the log
//! with the sidecar; `load_all` never reads one as a sidecar.
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use maestro_protocol::Session;
use maestro_acp::schema::SessionNotification;
use maestro_protocol::{Session, TranscriptRow};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
@@ -38,6 +51,120 @@ impl SidecarStore {
self.dir.join(format!("{id}.json"))
}
/// Where the per-session event log lives (#135).
pub fn events_path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.events.jsonl"))
}
/// Durable evidence that graceful daemon shutdown ended this structured
/// session and the next daemon may resume it.
pub fn recovery_path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.recover"))
}
/// Mark `id` as eligible for one structured-session recovery attempt.
pub fn mark_for_recovery(&self, id: Uuid) -> Result<(), SidecarError> {
std::fs::create_dir_all(&self.dir)?;
std::fs::write(self.recovery_path_for(id), [])?;
Ok(())
}
/// Consume the graceful-shutdown recovery marker for `id`.
pub fn take_recovery_marker(&self, id: Uuid) -> Result<bool, SidecarError> {
match std::fs::remove_file(self.recovery_path_for(id)) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
/// Open the event log for `id` for appending, creating it (and the
/// directory) when missing. One handle per live session: the caller
/// keeps it for the session's lifetime rather than reopening per
/// notification.
///
/// A log whose last line has no newline — what a hard kill mid-write
/// leaves behind — is terminated first, so the next append starts a
/// fresh line instead of gluing onto the fragment. The fragment keeps
/// the seq of its line and reads as malformed.
pub fn open_events(&self, id: Uuid) -> Result<EventLog, SidecarError> {
std::fs::create_dir_all(&self.dir)?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(self.events_path_for(id))?;
let mut lines = 0u64;
let mut last = None;
let mut reader = BufReader::new(&mut file);
let mut buf = [0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
lines += buf[..n].iter().filter(|&&b| b == b'\n').count() as u64;
last = Some(buf[n - 1]);
}
if last.is_some_and(|b| b != b'\n') {
file.write_all(b"\n")?;
lines += 1;
}
file.seek(SeekFrom::End(0))?;
Ok(EventLog {
file,
last_seq: lines,
})
}
/// Read events from the log for `id`, at most `limit`, in ascending seq
/// order. A missing log is an empty log. Lines that fail to parse are
/// skipped but still consume a seq, so cursors stay stable.
pub fn read_events(
&self,
id: Uuid,
cursor: EventCursor,
limit: usize,
) -> Result<Vec<LoggedEvent>, SidecarError> {
let file = match File::open(self.events_path_for(id)) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
if limit == 0 {
return Ok(Vec::new());
}
let mut out: VecDeque<LoggedEvent> = VecDeque::new();
let mut seq = 0u64;
for line in BufReader::new(file).split(b'\n') {
let line = line?;
seq += 1;
let keep = match cursor {
EventCursor::Since(since) => seq > since,
EventCursor::Before(Some(before)) => seq < before,
EventCursor::Before(None) => true,
};
if !keep {
if matches!(cursor, EventCursor::Before(Some(_))) {
break;
}
continue;
}
let Ok(event) = PersistedEvent::decode(&line) else {
continue;
};
out.push_back(LoggedEvent { seq, event });
match cursor {
EventCursor::Since(_) if out.len() >= limit => break,
EventCursor::Before(_) if out.len() > limit => {
out.pop_front();
}
_ => {}
}
}
Ok(out.into())
}
/// Atomically write `session` to `<dir>/<id>.json`. Creates the directory
/// if it does not exist.
pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
@@ -59,13 +186,13 @@ impl SidecarStore {
}
}
/// Delete the sidecar for `id`. No-op if it does not exist.
/// Delete the sidecar, event log, and recovery marker for `id`. No-op for
/// whichever does not exist.
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
remove_if_present(&self.events_path_for(id))?;
remove_if_present(&self.dir.join(format!("{id}.approvals.jsonl")))?;
remove_if_present(&self.recovery_path_for(id))?;
remove_if_present(&self.path_for(id))
}
/// Read every sidecar JSON in the directory. Files that fail to parse are
@@ -81,6 +208,8 @@ impl SidecarStore {
for entry in entries {
let entry = entry?;
let path = entry.path();
// `.events.jsonl` logs (#135) sit in the same directory and are
// never sidecars.
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
@@ -95,3 +224,106 @@ impl SidecarStore {
Ok(out)
}
}
fn remove_if_present(path: &Path) -> Result<(), SidecarError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Which slice of a session's event log to read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventCursor {
/// Forward: events with `seq` strictly greater than the cursor, the
/// first `limit` of them. `Since(0)` is the whole log from the start.
/// What a client catching up after a reconnect asks for.
Since(u64),
/// Backward: events with `seq` strictly less than the cursor, the last
/// `limit` of them, still returned in ascending order. `None` means the
/// tail of the log, for a first paint. The next page passes the smallest
/// seq it received.
Before(Option<u64>),
}
/// One line of a session's event log.
#[derive(Debug, Clone, PartialEq)]
pub struct LoggedEvent {
/// 1-based line position in the log; the cursor for [`EventCursor`].
pub seq: u64,
pub event: PersistedEvent,
}
/// A single entry in the structured event history.
///
/// ACP notifications retain their original JSON representation, so existing
/// event logs remain readable. Daemon-generated rows use an envelope so they
/// can share the same cursor sequence without pretending to be ACP traffic.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
pub enum PersistedEvent {
Approval {
row: TranscriptRow,
},
#[serde(skip)]
Notification(Box<SessionNotification>),
}
impl PersistedEvent {
fn decode(line: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice::<SessionNotification>(line)
.map(|notification| Self::Notification(Box::new(notification)))
.or_else(|_| serde_json::from_slice(line))
}
}
impl From<SessionNotification> for PersistedEvent {
fn from(notification: SessionNotification) -> Self {
Self::Notification(Box::new(notification))
}
}
impl From<&SessionNotification> for PersistedEvent {
fn from(notification: &SessionNotification) -> Self {
Self::Notification(Box::new(notification.clone()))
}
}
impl From<&PersistedEvent> for PersistedEvent {
fn from(event: &PersistedEvent) -> Self {
event.clone()
}
}
/// An open, append-only handle on one session's event log. Obtained from
/// [`SidecarStore::open_events`]; held for the session's lifetime.
#[derive(Debug)]
pub struct EventLog {
file: File,
last_seq: u64,
}
impl EventLog {
/// The seq of the last line, `0` for an empty log.
pub fn last_seq(&self) -> u64 {
self.last_seq
}
/// Append one notification as a line and return its seq. The line is
/// handed to the kernel in a single write before this returns, so a
/// caller that broadcasts afterwards never announces an event the log
/// would not replay — short of the machine losing power.
pub fn append(&mut self, event: impl Into<PersistedEvent>) -> Result<u64, SidecarError> {
let event = event.into();
let mut line = match &event {
PersistedEvent::Notification(notification) => serde_json::to_vec(notification)?,
_ => serde_json::to_vec(&event)?,
};
line.push(b'\n');
self.file.write_all(&line)?;
self.file.flush()?;
self.last_seq += 1;
Ok(self.last_seq)
}
}
+186
View File
@@ -0,0 +1,186 @@
//! What size the shared tmux window should be, given every client watching it.
//!
//! A tmux window has one size, but a session can have several clients attached
//! at once — two browser panes, a browser and the TUI. Each reports the size
//! *it* can display, and applying whichever arrived last makes the window
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
//! repaint at a new height, and the rows it drew at the old height are never
//! cleared: the operator ends up looking at a stale input box, types into a
//! box that is not where the screen says it is, and concludes the pane is
//! stuck.
//!
//! So the window is sized to the smallest attached client on each axis — the
//! same rule tmux uses for its own multiple clients, and the only size every
//! client can render without truncation. The size last handed to tmux is
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
use std::collections::HashMap;
use uuid::Uuid;
/// One attached client within a session. Allocated by
/// [`crate::session::SessionManager`] and opaque to everything else.
pub type ViewportId = u64;
/// Per-session client sizes, and what tmux was last told.
#[derive(Debug, Default)]
pub struct Viewports {
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
applied: HashMap<Uuid, (u16, u16)>,
}
impl Viewports {
pub fn new() -> Self {
Self::default()
}
/// Record what `viewport` can display. Returns the size tmux must be set
/// to, or `None` when the window already has it.
pub fn set(
&mut self,
session: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Option<(u16, u16)> {
self.clients
.entry(session)
.or_default()
.insert(viewport, (cols, rows));
self.settle(session)
}
/// Drop a client that has detached. Returns the size tmux must be set to
/// now that it no longer constrains the window.
///
/// The last client leaving settles nothing: the window keeps the size it
/// has rather than snapping to some remembered default no one is watching.
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
let clients = self.clients.get_mut(&session)?;
clients.remove(&viewport);
if clients.is_empty() {
self.clients.remove(&session);
self.applied.remove(&session);
return None;
}
self.settle(session)
}
/// The smallest size every attached client can render, but only when it
/// differs from what tmux was last told.
///
/// A zero on either axis is a client that cannot display anything yet — a
/// pane still laying out, a hidden tab — and must not drag the shared
/// window down with it, so it is held but left out of the arbitration.
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
let clients = self.clients.get(&session)?;
let mut cols = u16::MAX;
let mut rows = u16::MAX;
for &(c, r) in clients.values() {
if c == 0 || r == 0 {
continue;
}
cols = cols.min(c);
rows = rows.min(r);
}
if cols == u16::MAX || rows == u16::MAX {
return None;
}
let target = (cols, rows);
if self.applied.get(&session) == Some(&target) {
return None;
}
self.applied.insert(session, target);
Some(target)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_report_settles_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
}
#[test]
fn a_report_that_changes_nothing_costs_no_resize() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.set(s, 1, 80, 24), None);
assert_eq!(v.set(s, 1, 80, 24), None);
}
/// The bug this module exists for: two clients of different sizes used to
/// resize the window past each other on every report.
#[test]
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
// Whatever order they re-report in, the answer stays the smaller one.
assert_eq!(v.set(s, 1, 53, 46), None);
assert_eq!(v.set(s, 2, 53, 20), None);
assert_eq!(v.set(s, 1, 53, 46), None);
}
#[test]
fn each_axis_is_arbitrated_on_its_own() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 200, 20);
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
}
#[test]
fn a_zero_sized_client_does_not_shrink_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(s, 2, 0, 0), None);
assert_eq!(v.set(s, 2, 0, 30), None);
}
#[test]
fn releasing_the_small_client_gives_the_window_back() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 46);
v.set(s, 2, 80, 20);
assert_eq!(v.release(s, 2), Some((80, 46)));
}
#[test]
fn releasing_the_last_client_settles_nothing() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 1), None);
// ...and the session is forgotten, so a fresh attach settles again.
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
}
#[test]
fn releasing_an_unknown_viewport_is_harmless() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.release(s, 7), None);
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 7), None);
}
#[test]
fn sessions_do_not_arbitrate_against_each_other() {
let mut v = Viewports::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
assert_eq!(v.set(a, 1, 80, 24), None);
}
}
+40 -12
View File
@@ -111,16 +111,33 @@ impl WorktreeManager {
// Clear registrations whose directory is gone first, so a
// previously-leaked entry doesn't push this branch to a suffix.
prune(base_repo)?;
// Anything occupying the target — a tree we left behind, one the
// operator made, an unrelated directory — is sidestepped with a
// numeric suffix on the directory; the branch stays whatever was
// requested (#77). The daemon never adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
let (branch, path) = if create_branch {
// Worktrees are commonly abandoned without deleting their branch,
// so a random handle can collide with one of its own past runs.
// Unlike the `add_existing` arm below, the suffix goes on the
// branch name itself — the path is derived from it anyway.
let mut candidate = branch.to_string();
let mut path = self.target_path(base_repo, &candidate);
let mut n = 1;
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
n += 1;
candidate = format!("{branch}-{n}");
path = self.target_path(base_repo, &candidate);
}
(candidate, path)
} else {
// Existing branch: the name must stay exact, so only the
// directory — anything occupying it, ours or the operator's — is
// sidestepped with a numeric suffix (#77). The daemon never
// adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
(branch.to_string(), path)
};
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
if let Some(parent) = path.parent() {
@@ -129,10 +146,10 @@ impl WorktreeManager {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch {
cmd.arg("-b").arg(branch).arg(&path);
cmd.arg("-b").arg(&branch).arg(&path);
} else {
// Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(branch);
cmd.arg(&path).arg(&branch);
}
let out = cmd.output()?;
check(out)?;
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
Some(candidate.to_path_buf())
}
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["show-ref", "--verify", "--quiet"])
.arg(format!("refs/heads/{branch}"))
.status()
.is_ok_and(|s| s.success())
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
+952
View File
@@ -0,0 +1,952 @@
//! Structured sessions (#134): a `claude-code-acp` child per session instead
//! of a tmux pane. Runs against the fake adapter in
//! `crates/maestro-acp/tests/fake_acp_agent.py`; nothing here needs Claude.
mod common;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND};
use maestro_daemon::projection::TurnLifecycle;
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{
ActivityState, CreateSessionRequest, SessionStatus, SessionView, TranscriptDelta,
TranscriptRowKind, WorktreeSpec,
};
use tempfile::TempDir;
fn fake_agent() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py")
}
/// The fake adapter installed as `claude-code-acp` in a temp `bin/`, with
/// the log file it appends to. `env()` is what a spawn request carries.
struct Fake {
dir: TempDir,
}
impl Fake {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
Self { dir }
}
fn log_path(&self) -> PathBuf {
self.dir.path().join("agent.log")
}
fn log(&self) -> String {
std::fs::read_to_string(self.log_path()).unwrap_or_default()
}
/// Fake `bin/` first so it shadows any real adapter, then the process
/// `PATH` so the script's `python3` still resolves.
fn env(&self) -> HashMap<String, String> {
let path = format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
HashMap::from([
("PATH".to_string(), path),
(
"FAKE_ACP_LOG".to_string(),
self.log_path().to_string_lossy().into_owned(),
),
])
}
/// The pid the fake logged at start, once it has.
fn pid(&self) -> u32 {
self.log()
.lines()
.filter_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
.next_back()
.expect("pid in log")
}
}
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
panic!("unexpected daemon event")
};
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.to_path_buf(),
command: "claude".into(),
env,
metadata: HashMap::new(),
worktree: None,
}
}
fn pid_alive(pid: u32) -> bool {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|s| !s.contains(") Z "))
.unwrap_or(false)
}
async fn wait_for_exit(pid: u32) {
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "pid {pid} still alive");
}
#[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
assert_eq!(session.view, SessionView::Structured);
assert_eq!(session.status, SessionStatus::Running);
let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}");
// The child ran in the session cwd with the session's env — including
// the identity the daemon injects, so the agent knows which session it
// is (#103).
let log = fake.log();
let cwd_canon = cwd.path().canonicalize().unwrap();
assert!(
log.contains(&format!("cwd={}", cwd_canon.display())),
"{log}"
);
assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
"{log}"
);
// No tmux pane was created for it: the agent is the daemon's child.
let panes = fx.tmux.tmux.list_sessions().expect("list");
assert!(
!panes.contains(&session.id),
"structured session got a tmux pane"
);
// The sidecar carries the id a respawn will pass to session/load (#135).
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", session.id))).unwrap(),
)
.unwrap();
assert_eq!(sidecar.acp_session_id.as_deref(), Some(acp_id.as_str()));
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| !events.is_empty())
})
.await;
// Live delivery happens only after the notification is durable.
let (replay, mut updates) = fx
.manager
.subscribe_acp_events_with_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("replay read")
.expect("event subscription");
assert_eq!(replay.len(), 1);
assert!(updates.try_recv().is_err(), "replay duplicated onto live");
unsafe {
libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1);
}
let live = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("an update within 5s")
.expect("broadcast open");
assert_eq!(event_text(&live), "tick 1");
let stored = fx
.manager
.read_acp_events(session.id, EventCursor::Since(live.seq - 1), 1)
.expect("read event logged before broadcast");
assert_eq!(stored, vec![live]);
fx.manager.kill(session.id, false).await.expect("kill");
assert!(!fx
.sidecar_dir()
.join(format!("{}.events.jsonl", session.id))
.exists());
}
#[tokio::test]
async fn projection_snapshot_and_live_stream_share_one_fold() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 1)
})
.await;
// The cursor does not truncate either projection: a reconnect needs the
// source row for future patches and whole reduced state from the session.
let (snapshot, state, mut live) = fx
.manager
.subscribe_acp_projection(session.id, EventCursor::Since(1))
.await
.expect("projection subscription")
.expect("structured session");
assert_eq!(snapshot.cursor, 1);
assert_eq!(snapshot.rows.len(), 1);
assert_eq!(snapshot.rows[0].kind, TranscriptRowKind::AgentText);
assert_eq!(state.turn, Default::default());
let maestro_daemon::session::BeginAcpTurn::Started(_, turn) = fx
.manager
.begin_acp_turn(session.id)
.await
.expect("begin lifecycle")
else {
panic!("structured turn should start");
};
let begin = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("begin lifecycle broadcast")
.expect("projection broadcast");
assert_eq!(begin.transcript, None);
assert!(begin.state.turn.responding);
assert!(begin.state.turn.thinking);
unsafe {
libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1);
}
let agent_update = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("live projection")
.expect("projection broadcast");
match agent_update.transcript {
Some(TranscriptDelta::Append { row }) => {
assert_eq!(row.kind, TranscriptRowKind::AgentText);
assert_eq!(row.data["content"]["text"], "tick 1");
}
other => panic!("expected one appended transcript row, got {other:?}"),
}
assert!(agent_update.state.turn.responding);
assert!(!agent_update.state.turn.thinking);
assert!(fx
.manager
.apply_acp_turn_lifecycle(session.id, TurnLifecycle::Finish, turn)
.await
.expect("finish lifecycle"));
let finish = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("finish lifecycle broadcast")
.expect("projection broadcast");
assert_eq!(finish.transcript, None);
assert_eq!(finish.state.turn, Default::default());
fx.manager.kill(session.id, false).await.expect("kill");
}
#[tokio::test]
async fn stale_turn_completion_cannot_end_the_next_turn() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.unwrap();
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
fx.manager.begin_acp_turn(session.id).await.unwrap()
else {
panic!("first turn should start");
};
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
let maestro_daemon::session::BeginAcpTurn::Started(_, second) =
fx.manager.begin_acp_turn(session.id).await.unwrap()
else {
panic!("second turn should start");
};
assert!(second > first);
assert!(!fx
.manager
.apply_acp_turn_lifecycle(session.id, TurnLifecycle::Finish, first)
.await
.unwrap());
assert!(matches!(
fx.manager.begin_acp_turn(session.id).await.unwrap(),
maestro_daemon::session::BeginAcpTurn::InFlight
));
assert!(fx
.manager
.apply_acp_turn_lifecycle(session.id, TurnLifecycle::Finish, second)
.await
.unwrap());
fx.manager.kill(session.id, false).await.unwrap();
}
#[tokio::test]
async fn cancelled_prompt_that_never_settles_fails_the_session() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_PERMISSION_AFTER_CANCEL".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.unwrap();
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "first".into())
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued
));
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
let second = fx
.manager
.queue_acp_prompt(session.id, "second".into())
.await
.unwrap();
assert!(
matches!(second, maestro_daemon::session::QueueAcpPrompt::Unavailable),
"{second:?}"
);
assert_eq!(
fx.manager.status(session.id).await,
Some(SessionStatus::Dead)
);
assert!(!fake.log().contains("permission response"));
}
#[tokio::test]
async fn cancellation_settlement_allows_the_next_prompt_permission() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
env.insert("FAKE_ACP_CANCEL_SETTLES".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.unwrap();
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "first".into())
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued
));
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "second".into())
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Queued
));
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let (_, state, _) = fx
.manager
.subscribe_acp_projection(session.id, EventCursor::Since(0))
.await
.unwrap()
.unwrap();
if let Some(pending) = state.pending_approval {
break pending["nonce"].as_str().unwrap().to_owned();
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(fx
.manager
.resolve_acp_approval(
session.id,
&nonce,
maestro_daemon::session::ApprovalDecision::AllowOnce,
)
.await
.unwrap()
.is_some());
wait_for_async(Duration::from_secs(2), || {
let log = fake.log();
async move { log.contains("permission response") }
})
.await;
fx.manager.kill(session.id, false).await.unwrap();
}
#[tokio::test]
async fn recovery_loads_the_acp_session_without_duplicating_history() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 1)
})
.await;
unsafe {
libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1);
}
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 2)
})
.await;
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
assert!(
fx.sidecar_dir()
.join(format!("{}.recover", session.id))
.exists(),
"graceful shutdown must record recovery provenance"
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(
revived.status(session.id).await,
Some(SessionStatus::Running)
);
let log = fake.log();
assert!(
log.contains(&format!("recv session/load sessionId={acp_id}")),
"{log}"
);
wait_for_async(Duration::from_secs(5), || async {
revived
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 3)
})
.await;
let (replay, mut live) = revived
.subscribe_acp_events_with_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("replay read")
.expect("recovered event subscription");
assert_eq!(replay.len(), 3);
assert!(live.try_recv().is_err(), "replay duplicated onto live");
let new_pid = fake.pid();
assert_ne!(new_pid, old_pid);
unsafe {
libc::kill(new_pid as libc::pid_t, libc::SIGUSR1);
}
let event = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("live event after recovery")
.expect("broadcast open");
assert_eq!(event.seq, 4);
assert_eq!(event_text(&event), "tick 1");
let stored = revived
.read_acp_events(session.id, EventCursor::Since(0), 10)
.expect("recovered log");
assert_eq!(stored.len(), 4, "session/load replay must not be appended");
assert_eq!(
stored.iter().map(event_text).collect::<Vec<_>>(),
["hello from fake", "tick 1", "hello after load", "tick 1"]
);
revived.kill(session.id, false).await.expect("kill");
}
#[tokio::test]
async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
let branch = "feature/restart";
let worktree = repo
.path()
.join("worktrees")
.join("feature")
.join("restart");
let session = fx
.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
env: fake.env(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: branch.into(),
create_branch: true,
}),
})
.await
.expect("create");
assert_eq!(session.cwd, worktree);
push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await;
assert!(
worktree.is_dir(),
"shutdown must not settle a pristine worktree"
);
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", session.id))).unwrap(),
)
.unwrap();
assert_eq!(
sidecar.worktree.as_ref().map(|wt| &wt.path),
Some(&worktree)
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(
revived.status(session.id).await,
Some(SessionStatus::Running)
);
assert!(fake.log().contains("recv session/load"), "{}", fake.log());
assert!(worktree.is_dir(), "recovery must retain the worktree");
revived.kill(session.id, false).await.expect("dismiss");
assert!(
!worktree.exists(),
"operator dismissal still cleans a pristine tree"
);
}
#[tokio::test]
async fn failed_recovery_cleans_its_fifo() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
let events_path = fx
.sidecar_dir()
.join(format!("{}.events.jsonl", session.id));
std::fs::remove_file(&events_path).unwrap();
std::fs::create_dir(&events_path).unwrap();
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert!(revived.recover().await.is_err());
assert!(!fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
let failed_pid = fake.pid();
assert_ne!(failed_pid, old_pid);
wait_for_exit(failed_pid).await;
}
#[tokio::test]
async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
let fx = DaemonFixture::new();
let empty = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let env = HashMap::from([(
"PATH".to_string(),
empty.path().to_string_lossy().into_owned(),
)]);
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("a failed session, not an error");
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
assert!(detail.contains(ADAPTER_BIN), "{detail}");
assert!(detail.contains(INSTALL_COMMAND), "{detail}");
assert_eq!(session.acp_session_id, None);
// It is a real session: listed, on disk, and dismissable like any other
// dead one.
assert!(fx.manager.has(session.id).await);
assert!(fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.exists());
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
}
#[tokio::test]
async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut events = fx.manager.events();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let pid = fake.pid();
assert!(pid_alive(pid));
fx.manager.kill(session.id, false).await.expect("kill");
wait_for_exit(pid).await;
let log = fake.log();
let cancel = log.find("recv session/cancel").expect("cancel logged");
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
assert!(!fx.manager.has(session.id).await);
assert!(!fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.exists());
let mut removed = false;
while let Ok(ev) = events.try_recv() {
if matches!(ev, maestro_protocol::CatalogEvent::SessionRemoved { id } if id == session.id) {
removed = true;
}
}
assert!(removed, "SessionRemoved never broadcast");
}
#[tokio::test]
async fn shutdown_reaps_every_adapter_child() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let a = Fake::new();
let b = Fake::new();
let sa = fx
.manager
.create(structured(cwd.path(), a.env()))
.await
.expect("create a");
let sb = fx
.manager
.create(structured(cwd.path(), b.env()))
.await
.expect("create b");
let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb));
fx.manager.shutdown().await;
wait_for_exit(pa).await;
wait_for_exit(pb).await;
assert!(a.log().contains("signal SIGTERM"), "{}", a.log());
assert!(b.log().contains("signal SIGTERM"), "{}", b.log());
// Still listed, now Dead: the sidecar is truthful for whoever reads it
// after the restart (#135).
assert_eq!(fx.manager.status(sa.id).await, Some(SessionStatus::Dead));
assert_eq!(fx.manager.status(sb.id).await, Some(SessionStatus::Dead));
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", sa.id))).unwrap(),
)
.unwrap();
assert_eq!(sidecar.status, SessionStatus::Dead);
}
#[tokio::test]
async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
let fx = DaemonFixture::new();
let dismissed = Fake::new();
let blocker = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = dismissed.env();
env.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
fx.manager
.create(structured(cwd.path(), blocker.env()))
.await
.expect("create blocker");
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
wait_for_async(Duration::from_secs(5), || async {
dismissed.log().contains("recv session/cancel")
})
.await;
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(
!fx.sidecar_dir()
.join(format!("{}.json", session.id))
.exists(),
"dismissal must finish before shutdown settles its stale snapshot"
);
shutdown.await.expect("shutdown task");
assert!(!fx.manager.has(session.id).await);
assert!(
!fx.sidecar_dir()
.join(format!("{}.recover", session.id))
.exists(),
"dismissal must leave no recovery marker"
);
assert!(
!fx.sidecar_dir()
.join(format!("{}.json", session.id))
.exists(),
"dismissal must leave no sidecar"
);
assert!(
!fx.sidecar_dir()
.join(format!("{}.events.jsonl", session.id))
.exists(),
"dismissal must leave no event log"
);
}
#[tokio::test]
async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_exit(fake.pid()).await;
fx.manager.shutdown().await;
assert!(
!fx.sidecar_dir()
.join(format!("{}.recover", session.id))
.exists(),
"a naturally exited adapter is not recoverable"
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(revived.status(session.id).await, Some(SessionStatus::Dead));
assert!(
!fake.log().contains("recv session/load"),
"a naturally dead adapter must not be resumed: {}",
fake.log()
);
}
#[tokio::test]
async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
let create_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!shutdown.is_finished(),
"shutdown returned before create finished"
);
let session = create.await.expect("create task").expect("create");
let pid = fake.pid();
shutdown.await.expect("shutdown task");
wait_for_exit(pid).await;
assert!(fake.log().contains("signal SIGTERM"), "{}", fake.log());
assert_eq!(
fx.manager.status(session.id).await,
Some(SessionStatus::Dead)
);
assert!(matches!(
fx.manager.create(structured(cwd.path(), fake.env())).await,
Err(maestro_daemon::SessionError::ShuttingDown)
));
// A repeated signal must not attempt to terminate the child again.
fx.manager.shutdown().await;
assert_eq!(fake.log().matches("signal SIGTERM").count(), 1);
}
#[tokio::test]
async fn shutdown_drains_final_notifications_to_the_log() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_EMIT_ON_CANCEL".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 1)
})
.await;
fx.manager.shutdown().await;
let stored = fx
.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.expect("event log after shutdown");
assert_eq!(
stored.iter().map(event_text).collect::<Vec<_>>(),
["hello from fake", "final on cancel"]
);
}
#[tokio::test]
async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux,
// is what notices for a structured session.
let pid = fake.pid();
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(fx.manager.has(session.id).await);
assert!(
!fx.sidecar_dir()
.join(format!("{}.recover", session.id))
.exists(),
"a naturally exited adapter is never marked for recovery"
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(revived.status(session.id).await, Some(SessionStatus::Dead));
assert!(
!fake.log().contains("recv session/load"),
"a naturally dead adapter must not be resumed: {}",
fake.log()
);
revived.kill(session.id, false).await.expect("dismiss");
assert!(!revived.has(session.id).await);
}
@@ -42,6 +42,7 @@ async fn fixture(ttl: Duration) -> Fixture {
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
+2
View File
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
"/home/test",
Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
@@ -74,6 +75,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
let s = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd_path,
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
+1
View File
@@ -50,6 +50,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
};
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
@@ -19,6 +19,7 @@ use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
+1
View File
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
"/home/test",
presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
+613 -1
View File
@@ -4,12 +4,16 @@ mod common;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::Path;
use std::time::Duration;
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::local_api;
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
use maestro_protocol::{
ApiError, CreateSessionRequest, Session, SessionStatus, SessionView, WorktreeSpec,
};
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -36,6 +40,14 @@ async fn spawn(hostname: &str) -> Harness {
async fn spawn_with(
hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
) -> Harness {
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
}
async fn spawn_with_leader(
hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
leader: &str,
) -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
@@ -48,6 +60,7 @@ async fn spawn_with(
"/home/test",
presets,
projects,
leader,
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
@@ -77,6 +90,563 @@ fn ws_url(addr: SocketAddr, path: &str) -> String {
format!("ws://{addr}{path}")
}
struct FakeAcp {
dir: TempDir,
}
impl FakeAcp {
fn new() -> Self {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let adapter = bin.join("claude-code-acp");
std::fs::copy(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py"),
&adapter,
)
.unwrap();
std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap();
Self { dir }
}
fn env(&self) -> HashMap<String, String> {
HashMap::from([
(
"PATH".into(),
format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap()
),
),
(
"FAKE_ACP_LOG".into(),
self.dir.path().join("agent.log").display().to_string(),
),
])
}
fn pid(&self) -> u32 {
std::fs::read_to_string(self.dir.path().join("agent.log"))
.unwrap_or_default()
.lines()
.filter_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
.next_back()
.expect("fake ACP started")
}
fn log(&self) -> String {
std::fs::read_to_string(self.dir.path().join("agent.log")).unwrap_or_default()
}
}
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
structured_session_with_env(h, fake.env()).await
}
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
h.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: "/tmp".into(),
command: "claude".into(),
env,
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap()
.id
}
#[tokio::test]
async fn structured_ws_sends_snapshot_then_live_projection() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
.await
.unwrap();
let first = ws.next().await.unwrap().unwrap().into_text().unwrap();
let second = ws.next().await.unwrap().unwrap().into_text().unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&first).unwrap()["type"],
"transcript_snapshot"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&second).unwrap()["type"],
"reduced_state"
);
let response = reqwest::Client::new()
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "hello"}))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
let live = tokio::time::timeout(Duration::from_secs(1), ws.next())
.await
.expect("projection update arrives on the subscribed stream")
.unwrap()
.unwrap()
.into_text()
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&live).unwrap()["type"],
"projection_update"
);
}
#[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "200".into());
let id = structured_session_with_env(&h, env).await;
let client = reqwest::Client::new();
let first = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "first"}))
.send()
.await
.unwrap();
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
let second = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "second"}))
.send()
.await
.unwrap();
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = second.json().await.unwrap();
assert_eq!(error.error.code, "turn_in_flight");
let cancel = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/cancel"),
))
.send()
.await
.unwrap();
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
let next = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "next"}))
.send()
.await
.unwrap();
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if fake.log().matches("recv session/prompt").count() == 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("both prompt commands reach ACP");
let log = fake.log();
let first_prompt = log.find("recv session/prompt").unwrap();
let cancel = log.find("recv session/cancel").unwrap();
let second_prompt = log.rfind("recv session/prompt").unwrap();
assert!(first_prompt < cancel && cancel < second_prompt, "{log}");
}
#[tokio::test]
async fn structured_approval_is_secret_single_use_and_race_safe() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let mut env = fake.env();
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
let id = structured_session_with_env(&h, env).await;
let client = reqwest::Client::new();
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
.await
.unwrap();
ws.next().await.unwrap().unwrap();
ws.next().await.unwrap().unwrap();
assert_eq!(
client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt")
))
.json(&serde_json::json!({"prompt":"needs permission"}))
.send()
.await
.unwrap()
.status(),
reqwest::StatusCode::NO_CONTENT
);
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let event: serde_json::Value =
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
.unwrap();
if let Some(nonce) = event["state"]["pending_approval"]["nonce"].as_str() {
assert_eq!(event["transcript"]["row"]["kind"], "approval");
assert!(event["transcript"]["row"]["data"].get("nonce").is_none());
break nonce.to_owned();
}
}
})
.await
.unwrap();
assert!(
!fake.log().contains(&nonce),
"daemon nonce leaked to ACP agent"
);
let path = format!("/api/sessions/{id}/structured/approvals/{nonce}");
let (a, b) = tokio::join!(
client
.post(url(h.addr, &path))
.json(&serde_json::json!("allow_once"))
.send(),
client
.post(url(h.addr, &path))
.json(&serde_json::json!("deny"))
.send(),
);
assert_eq!(a.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
assert_eq!(b.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
assert_eq!(
client
.post(url(h.addr, &path))
.json(&serde_json::json!("deny"))
.send()
.await
.unwrap()
.status(),
reqwest::StatusCode::NO_CONTENT
);
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if fake.log().contains("permission response") {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let log = fake.log();
assert!(
log.contains("\"optionId\": \"once\"") || log.contains("\"optionId\": \"deny\""),
"{log}"
);
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let event: serde_json::Value =
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
.unwrap();
if event["state"]["pending_approval"].is_null() {
break;
}
}
})
.await
.expect("resolved approval clears the reduced state");
assert_eq!(
client
.post(url(
h.addr,
&format!(
"/api/sessions/{}/structured/approvals/{nonce}",
Uuid::new_v4()
)
))
.json(&serde_json::json!("deny"))
.send()
.await
.unwrap()
.status(),
reqwest::StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn structured_prompt_failure_clears_the_turn() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let mut env = fake.env();
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
let id = structured_session_with_env(&h, env).await;
let client = reqwest::Client::new();
let path = format!("/api/sessions/{id}/structured/prompt");
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
.await
.unwrap();
ws.next().await.unwrap().unwrap();
ws.next().await.unwrap().unwrap();
let first = client
.post(url(h.addr, &path))
.json(&serde_json::json!({"prompt":"fails"}))
.send()
.await
.unwrap();
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
let mut saw_active_turn = false;
tokio::time::timeout(Duration::from_secs(1), async {
while let Some(Ok(message)) = ws.next().await {
let event: serde_json::Value =
serde_json::from_str(&message.into_text().unwrap()).unwrap();
let turn = &event["state"]["turn"];
if turn["responding"] == true {
saw_active_turn = true;
}
if saw_active_turn && turn["responding"] == false && turn["thinking"] == false {
return;
}
}
panic!("structured websocket closed before the failed turn cleared");
})
.await
.expect("failed turn clears promptly");
let second = client
.post(url(h.addr, &path))
.json(&serde_json::json!({"prompt":"available"}))
.send()
.await
.unwrap();
assert_eq!(second.status(), reqwest::StatusCode::NO_CONTENT);
assert!(second.bytes().await.unwrap().is_empty());
}
#[tokio::test]
async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
let h = spawn("naps-laptop").await;
let client = reqwest::Client::new();
let prompt = |id| {
client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "hello"}))
};
let unknown = prompt(Uuid::new_v4()).send().await.unwrap();
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
let error: ApiError = unknown.json().await.unwrap();
assert_eq!(error.error.code, "not_found");
let terminal = h
.manager
.create(CreateSessionRequest {
view: SessionView::Terminal,
cwd: "/tmp".into(),
command: "sh".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let non_structured = prompt(terminal.id).send().await.unwrap();
assert_eq!(non_structured.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = non_structured.json().await.unwrap();
assert_eq!(error.error.code, "not_structured");
let fake = FakeAcp::new();
let dead = structured_session(&h, &fake).await;
h.manager.shutdown().await;
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
let unavailable = prompt(dead).send().await.unwrap();
assert_eq!(unavailable.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = unavailable.json().await.unwrap();
assert_eq!(error.error.code, "not_structured");
}
#[tokio::test]
async fn structured_replay_honors_cursors_and_limit() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let events: Vec<serde_json::Value> = client
.get(url(
h.addr,
&format!("/api/sessions/{id}/structured/replay?since=0&limit=1"),
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
if !events.is_empty() {
return events;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0]["cursor"], 1);
assert!(events[0].get("notification").is_none());
let invalid = client
.get(url(
h.addr,
&format!("/api/sessions/{id}/structured/replay?since=0&before=2"),
))
.send()
.await
.unwrap();
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn structured_replay_pages_projected_events_in_cursor_order() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
unsafe { libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1) };
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let events: Vec<serde_json::Value> = client
.get(url(
h.addr,
&format!("/api/sessions/{id}/structured/replay?before=99&limit=2"),
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
if events.len() == 2 {
return events;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(
events
.iter()
.map(|event| event["cursor"].as_u64().unwrap())
.collect::<Vec<_>>(),
vec![1, 2]
);
let boundary: Vec<serde_json::Value> = client
.get(url(
h.addr,
&format!("/api/sessions/{id}/structured/replay?before=2&limit=10"),
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(boundary.len(), 1);
assert_eq!(boundary[0]["cursor"], 1);
}
#[tokio::test]
async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
let h = spawn("naps-laptop").await;
let client = reqwest::Client::new();
let unknown = client
.get(url(
h.addr,
&format!("/api/sessions/{}/structured/replay", Uuid::new_v4()),
))
.send()
.await
.unwrap();
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
let terminal = h
.manager
.create(CreateSessionRequest {
view: SessionView::Terminal,
cwd: "/tmp".into(),
command: "sh".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let response = client
.get(url(
h.addr,
&format!("/api/sessions/{}/structured/replay", terminal.id),
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let fake = FakeAcp::new();
let dead = structured_session(&h, &fake).await;
h.manager.shutdown().await;
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
let response = client
.get(url(
h.addr,
&format!("/api/sessions/{dead}/structured/replay"),
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = response.json().await.unwrap();
assert_eq!(error.error.code, "not_structured");
}
#[tokio::test]
async fn structured_ws_rejects_a_dead_session() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
h.manager.shutdown().await;
assert_eq!(h.manager.status(id).await, Some(SessionStatus::Dead));
let err = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
.await
.expect_err("dead structured session must not upgrade");
let message = err.to_string();
assert!(
message.contains("409") || message.to_lowercase().contains("conflict"),
"{message}"
);
}
#[tokio::test]
async fn info_returns_daemon_identity() {
let h = spawn("naps-laptop").await;
@@ -91,6 +661,38 @@ async fn info_returns_daemon_identity() {
assert!(resp["version"].is_string());
}
/// #159: the web client reads its leader off `/api/info` instead of a
/// hardcoded constant, so the payload has to carry the configured one — not
/// the default dressed up as one.
#[tokio::test]
async fn info_advertises_the_configured_leader() {
let h = spawn_with_leader(
"naps-laptop",
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
"ctrl-b",
)
.await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["leader"], "ctrl-b");
}
#[tokio::test]
async fn info_reports_the_default_leader_when_unconfigured() {
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["leader"], "ctrl-a");
}
#[tokio::test]
async fn info_reports_build_and_instance() {
// The status indicator needs a release to name and a process to compare
@@ -133,6 +735,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
assert!(empty.is_empty());
let req = CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -349,6 +952,7 @@ async fn patch_session_renames_it() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -391,6 +995,7 @@ async fn post_focus_stamps_last_focused_at() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -424,6 +1029,7 @@ async fn delete_owned_session_kills_it() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -445,6 +1051,7 @@ async fn delete_owned_session_kills_it() {
async fn spawn_via_manager(h: &Harness) -> Uuid {
h.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -554,6 +1161,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -606,6 +1214,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -639,6 +1248,7 @@ async fn attach_ws_streams_pty_bytes() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -707,6 +1317,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
let created = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -751,6 +1362,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
h.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
+1
View File
@@ -51,6 +51,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
};
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
@@ -64,6 +64,7 @@ impl Fx {
fn request(&self, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: self.tmp.path().to_path_buf(),
command: command.into(),
env: HashMap::new(),
@@ -555,3 +556,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
Some(&"http://127.0.0.1:7070".to_string())
);
}
/// Two clients attached to one session used to resize the shared window past
/// each other on every report they made. Every flip repaints a full-screen
/// agent at a new height and orphans the rows it drew at the old one, which
/// reads to the operator as a pane that has stopped taking input.
#[tokio::test]
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
// redraw loop do. Neither report changes the arbitrated size.
for _ in 0..5 {
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
}
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(
resizes,
vec![(53, 46), (53, 20)],
"the window was resized more than the arbitrated size actually changed"
);
}
/// The smaller client leaving hands the window back to the one still attached.
#[tokio::test]
async fn releasing_a_client_resizes_to_what_is_left() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
fx.manager.release_viewport(s.id, tui).await;
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
}
@@ -22,6 +22,7 @@ async fn create_then_kill_round_trip() {
let session = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -63,6 +64,7 @@ async fn create_appends_to_spawn_history() {
let session = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -92,6 +94,7 @@ async fn create_without_history_logs_nothing() {
let cwd = TempDir::new().unwrap();
fx.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -114,6 +117,7 @@ async fn recover_adopts_existing_tmux_sessions() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -153,6 +157,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// The shell prints the marker before doing anything else and
// then sleeps so the session stays alive. Before NAP-253 this
@@ -200,6 +205,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// Print a line, then switch to the alternate screen the way a
// full-screen program does on startup. Only the second marker is
@@ -258,6 +264,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// Alternate screen, then button-event tracking (?1002) with SGR
// coordinates (?1006) — what tmux and agent TUIs actually request.
@@ -311,6 +318,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -373,6 +381,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -423,6 +432,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -488,6 +498,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
fx.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -580,6 +591,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -617,6 +629,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -680,6 +693,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -729,6 +743,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -774,6 +789,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -814,6 +830,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -855,6 +872,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -916,6 +934,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -976,6 +995,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1010,6 +1030,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let first = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1027,6 +1048,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let second = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: inner_worktree.clone(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1065,6 +1087,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1103,6 +1126,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1128,6 +1152,7 @@ async fn set_focused_true_stamps_last_focused_at() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1176,6 +1201,7 @@ async fn attaching_stamps_last_focused_at() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
+284 -2
View File
@@ -6,8 +6,8 @@ use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use maestro_daemon::sidecar::SidecarStore;
use maestro_protocol::{Session, SessionStatus};
use maestro_daemon::sidecar::{PersistedEvent, SidecarStore};
use maestro_protocol::{Session, SessionStatus, SessionView, TranscriptRow, TranscriptRowKind};
use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
@@ -32,6 +32,8 @@ fn sample(id: Uuid) -> Session {
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
view: SessionView::Terminal,
}
}
@@ -66,15 +68,30 @@ fn delete_removes_file_and_is_idempotent() {
let s = sample(id);
store.write(&s).unwrap();
store.mark_for_recovery(id).unwrap();
assert!(store.path_for(id).exists());
assert!(store.recovery_path_for(id).exists());
store.delete(id).unwrap();
assert!(!store.path_for(id).exists());
assert!(!store.recovery_path_for(id).exists());
// second delete is a no-op
store.delete(id).unwrap();
}
#[test]
fn recovery_marker_is_consumed_once() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
assert!(!store.take_recovery_marker(id).unwrap());
store.mark_for_recovery(id).unwrap();
assert!(store.take_recovery_marker(id).unwrap());
assert!(!store.take_recovery_marker(id).unwrap());
}
#[test]
fn load_all_recovers_every_sidecar_and_skips_garbage() {
let dir = TempDir::new().unwrap();
@@ -103,6 +120,37 @@ fn load_all_returns_empty_when_directory_missing() {
assert!(store.load_all().unwrap().is_empty());
}
#[test]
fn sidecar_missing_view_key_reads_as_terminal() {
// A pre-#133 sidecar on disk has no `view` field at all.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut value = serde_json::to_value(sample(id)).unwrap();
value.as_object_mut().unwrap().remove("view");
std::fs::write(store.path_for(id), serde_json::to_vec(&value).unwrap()).unwrap();
let loaded = store.read(id).expect("read").expect("present");
assert_eq!(loaded.view, SessionView::Terminal);
}
#[test]
fn sidecar_structured_view_round_trips() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut s = sample(id);
s.view = SessionView::Structured;
store.write(&s).expect("write");
let loaded = store.read(id).expect("read");
assert_eq!(
loaded.as_ref().map(|s| s.view),
Some(SessionView::Structured)
);
}
#[test]
fn write_creates_parent_directory() {
let dir = TempDir::new().unwrap();
@@ -113,3 +161,237 @@ fn write_creates_parent_directory() {
assert!(nested.exists());
assert!(store.path_for(s.id).exists());
}
// ---- per-session event log (#135) ----
use maestro_acp::schema::{ContentBlock, SessionNotification, SessionUpdate, TextContent};
use maestro_daemon::sidecar::EventCursor;
fn note(text: &str) -> SessionNotification {
SessionNotification::new(
"acp-1",
SessionUpdate::AgentMessageChunk(maestro_acp::schema::ContentChunk::new(
ContentBlock::Text(TextContent::new(text)),
)),
)
}
fn text_of(n: &SessionNotification) -> &str {
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => &t.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
panic!("expected ACP notification")
};
text_of(notification)
}
#[test]
fn append_assigns_line_position_as_seq_and_reads_back() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).expect("open");
assert_eq!(log.last_seq(), 0);
assert_eq!(log.append(note("a")).unwrap(), 1);
assert_eq!(log.append(note("b")).unwrap(), 2);
assert_eq!(log.append(note("c")).unwrap(), 3);
assert_eq!(log.last_seq(), 3);
let path = store.events_path_for(id);
assert!(path.ends_with(format!("{id}.events.jsonl")), "{path:?}");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3, "one object per line:\n{raw}");
for line in raw.lines() {
let v: Value = serde_json::from_str(line).expect("each line is one JSON object");
assert_eq!(v["sessionId"], "acp-1");
}
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
assert_eq!(
all.iter().map(event_text).collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
}
#[test]
fn since_reads_forward_and_before_reads_backward_with_limit() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
for i in 1..=6 {
log.append(note(&format!("n{i}"))).unwrap();
}
// `since` is exclusive: what a client that already holds seq 2 needs.
let fwd = store.read_events(id, EventCursor::Since(2), 3).unwrap();
assert_eq!(fwd.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4, 5]);
// Past the end: nothing, not an error.
assert!(store
.read_events(id, EventCursor::Since(6), 3)
.unwrap()
.is_empty());
// `before` is exclusive and pages backward: the `limit` events closest
// to the cursor, still in ascending seq order.
let back = store
.read_events(id, EventCursor::Before(Some(5)), 2)
.unwrap();
assert_eq!(back.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4]);
// No cursor: the tail of the log, for a first paint.
let tail = store.read_events(id, EventCursor::Before(None), 2).unwrap();
assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![5, 6]);
// Paging past the start runs out cleanly.
let start = store
.read_events(id, EventCursor::Before(Some(2)), 10)
.unwrap();
assert_eq!(start.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
assert!(store
.read_events(id, EventCursor::Before(Some(1)), 10)
.unwrap()
.is_empty());
}
#[test]
fn missing_log_reads_as_empty() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
assert!(store
.read_events(id, EventCursor::Since(0), 10)
.unwrap()
.is_empty());
assert!(store
.read_events(id, EventCursor::Before(None), 10)
.unwrap()
.is_empty());
}
#[test]
fn malformed_lines_are_skipped_but_keep_their_seq() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
log.append(note("first")).unwrap();
drop(log);
// Garbage in the middle and a line that is JSON but not a notification.
let path = store.events_path_for(id);
let mut raw = std::fs::read_to_string(&path).unwrap();
raw.push_str("{not json\n");
raw.push_str("{\"unrelated\": true}\n");
std::fs::write(&path, raw).unwrap();
let mut log = store.open_events(id).unwrap();
assert_eq!(log.last_seq(), 3);
assert_eq!(log.append(note("fourth")).unwrap(), 4);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
assert_eq!(event_text(&all[1]), "fourth");
}
#[test]
fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
// A hard kill mid-write leaves a partial line with no newline.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
log.append(note("whole")).unwrap();
drop(log);
let path = store.events_path_for(id);
let mut raw = std::fs::read(&path).unwrap();
raw.extend_from_slice(br#"{"sessionId":"acp-1","update":{"sessionUpd"#);
std::fs::write(&path, &raw).unwrap();
// Reads: the partial line is skipped, nothing else is lost.
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
// Reopening terminates the partial line so the next append does not
// glue onto it; the partial keeps a seq of its own.
let mut log = store.open_events(id).unwrap();
assert_eq!(log.last_seq(), 2);
assert_eq!(log.append(note("after")).unwrap(), 3);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
assert_eq!(event_text(&all[1]), "after");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3, "{raw}");
}
#[test]
fn delete_removes_the_event_log_and_load_all_ignores_it() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
store.write(&sample(id)).unwrap();
let mut log = store.open_events(id).unwrap();
log.append(note("x")).unwrap();
drop(log);
let legacy_approvals = dir.path().join(format!("{id}.approvals.jsonl"));
std::fs::write(&legacy_approvals, b"obsolete").unwrap();
assert!(store.events_path_for(id).exists());
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1, "the .jsonl must not be read as a sidecar");
store.delete(id).unwrap();
assert!(!store.path_for(id).exists());
assert!(!store.events_path_for(id).exists());
assert!(!legacy_approvals.exists());
// Idempotent with the log already gone.
store.delete(id).unwrap();
}
#[test]
fn approval_rows_share_event_cursors_with_legacy_acp_lines() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let legacy = serde_json::to_vec(&note("before")).unwrap();
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(store.events_path_for(id), [legacy, b"\n".to_vec()].concat()).unwrap();
let mut log = store.open_events(id).unwrap();
assert_eq!(
log.append(PersistedEvent::Approval {
row: TranscriptRow {
id: "approval:secret".into(),
kind: TranscriptRowKind::Approval,
data: serde_json::json!({"tool": "bash"}),
},
})
.unwrap(),
2
);
assert_eq!(log.append(note("after")).unwrap(), 3);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(
all.iter().map(|event| event.seq).collect::<Vec<_>>(),
[1, 2, 3]
);
assert!(matches!(all[1].event, PersistedEvent::Approval { .. }));
assert_eq!(event_text(&all[0]), "before");
assert_eq!(event_text(&all[2]), "after");
let since = store.read_events(id, EventCursor::Since(1), 1).unwrap();
assert_eq!(since[0].seq, 2);
let before = store
.read_events(id, EventCursor::Before(Some(3)), 1)
.unwrap();
assert_eq!(before[0].seq, 2);
}
+1
View File
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
"/home/test",
maestro_config::SpawnSection::default().resolve().unwrap(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
+6 -4
View File
@@ -405,8 +405,10 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
);
}
/// 49ab7e9: a new-branch add onto a name that already exists suffixes the
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md.
#[test]
fn add_fails_when_branch_already_exists() {
fn add_new_suffixes_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
@@ -423,9 +425,9 @@ fn add_fails_when_branch_already_exists() {
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
let path = mgr.add_new(&repo, "feat/dup").unwrap();
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
assert!(path.is_dir());
}
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
+1
View File
@@ -13,6 +13,7 @@ path = "src/lib.rs"
[dependencies]
chrono.workspace = true
futures-util.workspace = true
libc.workspace = true
maestro-protocol = { workspace = true, features = ["client"] }
maestro-testing.workspace = true
serde.workspace = true
+8
View File
@@ -87,6 +87,14 @@ impl Daemon {
}
}
/// Send the daemon the same SIGTERM systemd uses, without waiting for its
/// graceful HTTP shutdown to finish.
pub fn send_sigterm(&self) {
let child = self.child.as_ref().expect("daemon is running");
let result = unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGTERM) };
assert_eq!(result, 0, "send SIGTERM to daemon");
}
/// Re-spawn the daemon on the same port + state. The previous tmux
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
/// its sessions.
+180 -4
View File
@@ -15,7 +15,7 @@ use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use maestro_e2e::util::wait_for_async;
use maestro_e2e::{Daemon, TestClient};
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use maestro_protocol::{CreateSessionRequest, SessionStatus, SessionView, WorktreeSpec};
use tempfile::TempDir;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -27,6 +27,7 @@ async fn bring_up() -> (Daemon, TestClient) {
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
@@ -35,6 +36,61 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
}
}
struct FakeAcp {
_dir: TempDir,
env: HashMap<String, String>,
}
fn fake_acp(log: &std::path::Path) -> FakeAcp {
let dir = tempfile::tempdir().expect("fake adapter dir");
let adapter = dir.path().join("claude-code-acp");
std::fs::copy(
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../maestro-acp/tests/fake_acp_agent.py"
),
&adapter,
)
.expect("copy fake adapter");
let mut permissions = std::fs::metadata(&adapter)
.expect("adapter metadata")
.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&adapter, permissions).expect("make fake adapter executable");
let env = HashMap::from([
(
"PATH".into(),
format!(
"{}:{}",
dir.path().display(),
std::env::var("PATH").unwrap_or_default()
),
),
("FAKE_ACP_LOG".into(), log.to_string_lossy().into_owned()),
]);
FakeAcp { _dir: dir, env }
}
fn fake_acp_pid(log: &std::path::Path) -> Option<u32> {
std::fs::read_to_string(log).ok()?.lines().find_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
}
fn pid_alive(pid: u32) -> bool {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|stat| !stat.contains(") Z "))
.unwrap_or(false)
}
/// A worktree spec built through its wire shape. The daemon and clients ship
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
/// object carries only the fields the lifecycle actually honours.
@@ -49,6 +105,7 @@ fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool)
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: base.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -143,6 +200,7 @@ async fn kill_removes_clean_worktree() {
init_repo(base.path());
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -196,6 +254,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
.join("unsaved");
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -254,6 +313,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -323,8 +383,11 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
client.kill_session(s.id).await.expect("kill");
}
/// #66: a second spawn onto a branch that already has a worktree is rejected
/// with a structured worktree error; the first worktree stays intact.
/// #66: a second spawn onto an *existing* branch that already has a worktree
/// is rejected with a structured worktree error; the first worktree stays
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9
/// suffixes the new branch name instead, see
/// `colliding_branch_name_gets_suffixed_too`.)
#[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() {
let (_daemon, client) = bring_up().await;
@@ -337,7 +400,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
.await
.expect("first spawn");
let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.spawn_session(&worktree_req(base.path(), "feature/dup", false))
.await;
match dup {
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
@@ -497,6 +560,57 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
);
}
/// A random handle can reuse the name of a branch left behind by an earlier,
/// already-removed worktree — `git worktree add -b` refuses outright in that
/// case, even though the directory is free. The daemon suffixes the branch
/// itself (not just the directory) and both land on `-2`.
#[tokio::test(flavor = "multi_thread")]
async fn colliding_branch_name_gets_suffixed_too() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// No worktree directory in the way, but the branch already exists.
run_git(&["branch", "handle"], base.path());
let s = client
.spawn_session(&worktree_req(base.path(), "handle", true))
.await
.expect("spawn onto a taken branch name");
let suffixed = base.path().join("worktrees").join("handle-2");
assert_eq!(
s.cwd, suffixed,
"the directory must follow the suffixed branch name"
);
assert!(suffixed.is_dir());
let registrations = git_worktree_list(base.path());
assert!(
registrations.iter().any(|l| l.contains("handle-2")),
"the -2 branch's worktree must be registered: {registrations:?}"
);
let branches = String::from_utf8(
std::process::Command::new("git")
.arg("-C")
.arg(base.path())
.args(["branch", "--list"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
branches.contains("handle-2"),
"the branch itself must be named handle-2: {branches}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
/// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone.
@@ -703,6 +817,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let _dirty = client
.spawn_session(&CreateSessionRequest {
view: Default::default(),
cwd: dirty_repo.path().to_path_buf(),
command: "sh -c 'echo unsaved > scratch.txt'".into(),
env: HashMap::new(),
@@ -751,6 +866,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
@@ -934,6 +1050,66 @@ async fn daemon_restart_recovers_session() {
client.kill_session(s.id).await.expect("kill");
}
#[tokio::test(flavor = "multi_thread")]
async fn sigterm_stops_structured_child_before_events_ws_drains() {
let (mut daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let log = cwd.path().join("fake-acp.log");
let fake = fake_acp(&log);
let session = client
.spawn_session(&CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.path().to_path_buf(),
command: "claude".into(),
env: fake.env.clone(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("spawn structured session");
let ws_url = daemon.client().events_url().expect("events URL");
let (_events_ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.expect("events WS handshake");
wait_for_async("fake ACP starts", Duration::from_secs(5), || {
let log = log.clone();
async move { fake_acp_pid(&log).is_some() }
})
.await;
let pid = fake_acp_pid(&log).expect("fake ACP pid");
daemon.send_sigterm();
wait_for_async(
"fake ACP exits after SIGTERM",
Duration::from_secs(5),
|| async { !pid_alive(pid) },
)
.await;
let sidecar_path = daemon
.state_dir()
.join("sessions")
.join(format!("{}.json", session.id));
wait_for_async(
"dead structured session sidecar",
Duration::from_secs(5),
|| {
let sidecar_path = sidecar_path.clone();
async move {
std::fs::read(&sidecar_path)
.ok()
.and_then(|json| {
serde_json::from_slice::<maestro_protocol::Session>(&json).ok()
})
.is_some_and(|saved| saved.status == SessionStatus::Dead)
}
},
)
.await;
daemon.stop();
}
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_spawns_each_get_unique_ids() {
let (_daemon, client) = bring_up().await;
+142 -55
View File
@@ -1,4 +1,4 @@
//! Claude Code activity hook (#106).
//! Agent activity hook (#106).
//!
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
//! the daemon's first-start auto-install:
@@ -6,28 +6,40 @@
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
//! posts an [`ActivityState`](maestro-protocol) to
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
//! global hook runs in every Claude Code session on the machine, so it exits
//! global hook runs in every agent session on the machine, so it exits
//! 0 and silently when either #103 env var is unset, and it never lets a
//! daemon failure escape (short curl timeout, status ignored).
//! * [`install`] — idempotent registration of that script in
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
//! in the file is preserved verbatim.
//! * [`install`] — idempotent registration of that script in one [`Target`]'s
//! hook file. Only entries maestro itself wrote (identified by
//! [`HOOK_MARKER`]) are touched; every other hook and every other field in
//! the file is preserved verbatim.
//!
//! One script serves every [`Target`]: Claude Code and Codex read the same
//! `{"hooks": {Event: [{"hooks": [{"type": "command", "command": …}]}]}}`
//! shape and hand the hook the same JSON on stdin. Only the file's location
//! and the event set differ.
use std::path::Path;
/// Marker substring every maestro-installed hook command carries. The
/// installer rewrites or removes exactly these entries — nothing else in the
/// user's settings file.
///
/// It still says `claude` although the script now serves every [`Target`]:
/// the marker is how an install from an older maestro is recognised, and
/// renaming it would orphan every entry already on disk.
pub const HOOK_MARKER: &str = "maestro-claude-hook";
/// File name of the installed script (carries [`HOOK_MARKER`]).
/// File name of the installed script (carries [`HOOK_MARKER`]). One script
/// for every [`Target`] — see the note on [`HOOK_MARKER`] for the name.
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
/// Hook events installed only with the `haiku-autoname` feature (#131).
/// `UserPromptSubmit` carries the prompt the user just submitted; the script
/// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
/// still-provisionally-named session after its first one.
/// Hook events installed only with the `haiku-autoname` feature (#131), for
/// every [`Target`] — Claude Code and Codex both spell the event
/// `UserPromptSubmit` and both put the submitted text in the payload's
/// `prompt`. The script forwards the whole payload to
/// `/api/sessions/:id/prompt`, which names a still-provisionally-named
/// session after its first one.
#[cfg(feature = "haiku-autoname")]
pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")];
@@ -43,7 +55,7 @@ pub const PROMPT_EVENTS: [(&str, &str); 0] = [];
/// Claude Code's own name for "the turn ended on an API error"; there is no
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
/// `Error`.
pub const HOOK_EVENTS: [(&str, &str); 5] = [
pub const CLAUDE_EVENTS: [(&str, &str); 5] = [
// Tool starting: the agent is demonstrably producing work, even if the
// tool itself runs for minutes.
("PreToolUse", "Active"),
@@ -58,17 +70,86 @@ pub const HOOK_EVENTS: [(&str, &str); 5] = [
("StopFailure", "Error"),
];
/// Codex hook event → `ActivityState` spelling posted to the daemon.
///
/// Codex's event set is close to Claude Code's but not identical: a pending
/// approval arrives as `PermissionRequest` rather than `Notification`, and
/// there is no `StopFailure`, so a codex session never reports `Error`.
pub const CODEX_EVENTS: [(&str, &str); 4] = [
("PreToolUse", "Active"),
("PostToolUse", "Active"),
// Codex is blocked on the operator approving a tool call.
("PermissionRequest", "AwaitingInput"),
("Stop", "AwaitingInput"),
];
/// An agent whose hook file maestro writes into.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// `~/.claude/settings.json`.
ClaudeCode,
/// `~/.codex/hooks.json`. Codex asks the operator to trust a hook it has
/// not seen before, so an install here does nothing until they approve it
/// once inside codex — maestro never writes that approval itself.
Codex,
}
impl Target {
/// Every target an install covers. Both files are written whether or not
/// the agent is on the machine: an unused hook file costs nothing, and
/// the alternative is a session spawned after the daemon started missing
/// the hook because its agent arrived later.
pub const ALL: [Target; 2] = [Target::ClaudeCode, Target::Codex];
/// How the target is named in logs and CLI output.
pub fn label(self) -> &'static str {
match self {
Target::ClaudeCode => "claude-code",
Target::Codex => "codex",
}
}
/// The file this target's hook entries are registered in, under `home`.
pub fn settings_path(self, home: &Path) -> std::path::PathBuf {
match self {
Target::ClaudeCode => home.join(".claude/settings.json"),
Target::Codex => home.join(".codex/hooks.json"),
}
}
/// The activity events this target emits.
pub fn activity_events(self) -> &'static [(&'static str, &'static str)] {
match self {
Target::ClaudeCode => &CLAUDE_EVENTS,
Target::Codex => &CODEX_EVENTS,
}
}
/// Every event installed for this target: its activity events plus, with
/// `haiku-autoname` on, the prompt event both agents spell the same way.
pub fn events(self) -> impl Iterator<Item = (&'static str, &'static str)> {
self.activity_events()
.iter()
.chain(PROMPT_EVENTS.iter())
.copied()
}
}
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
# Installed by maestro (#106): report Claude Code activity to its daemon.
# Exits silently unless running inside a session maestro spawned (#103 env).
# Installed by maestro (#106): report agent activity to its daemon.
# One script for every agent maestro installs hooks into; the state to post
# is chosen by the caller, so the two event sets differ only in what is
# registered where. Exits silently unless running inside a session maestro
# spawned (#103 env).
# Event -> ActivityState mapping:
# PreToolUse -> Active (tool started; agent is working)
# PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (permission / idle notice)
# Stop -> AwaitingInput (turn ended; waiting on the user)
# StopFailure -> Error (turn ended on an API error)
# Prompt -> the payload is forwarded to the prompt route (#131)
# PreToolUse -> Active (tool started; agent is working)
# PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (claude code: permission / idle)
# PermissionRequest -> AwaitingInput (codex: waiting on an approval)
# Stop -> AwaitingInput (turn ended; waiting on the user)
# StopFailure -> Error (claude code: turn ended on an error)
# Prompt -> the payload is forwarded to the prompt route (#131)
state="$1"
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
[ -n "$MAESTRO_URL" ] || exit 0
@@ -115,34 +196,29 @@ pub fn script_path(home: &Path) -> std::path::PathBuf {
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
}
/// The user-level Claude Code settings file for a given home directory.
pub fn settings_path(home: &Path) -> std::path::PathBuf {
home.join(".claude/settings.json")
}
/// Whether `settings_path` already carries a maestro hook (#106). Presence
/// only: an install from an older release answers `true` even when it is
/// missing events this build ships — see [`is_current`].
/// Whether the file at `settings_path` already carries a maestro hook
/// (#106). Presence only: an install from an older release answers `true`
/// even when it is missing events this build ships — see [`is_current`].
pub fn is_installed(settings_path: &Path) -> bool {
settings_json(settings_path)
.map(|root| find_maestro_commands(&root).next().is_some())
.unwrap_or(false)
}
/// Whether what is on disk is what this build ships: the script at
/// `script_path` byte-for-byte, and exactly one settings entry per event in
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
/// Whether what is on disk is what this build ships for `target`: the script
/// at `script_path` byte-for-byte, and exactly one settings entry per one of
/// the target's [`Target::events`], pointing at that script.
///
/// This is the upgrade question. A maestro that gains an event — #131 added
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
/// existing user with a present but incomplete install, which
/// [`is_installed`] cannot tell apart from a current one.
pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
/// This is the upgrade question, asked per [`Target`]. A maestro that gains
/// an event — #131 added `UserPromptSubmit` long after #106 had installed
/// itself — leaves every existing user with a present but incomplete
/// install, which [`is_installed`] cannot tell apart from a current one.
pub fn is_current(target: Target, settings_path: &Path, script_path: &Path) -> bool {
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
return false;
}
settings_json(settings_path)
.map(|root| maestro_entries(&root) == expected_entries(script_path))
.map(|root| maestro_entries(&root) == expected_entries(target, script_path))
.unwrap_or(false)
}
@@ -173,14 +249,13 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
found
}
/// What [`install`] writes for `script_path`, in the same shape.
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
let mut want: Vec<(String, String)> = HOOK_EVENTS
.iter()
.chain(PROMPT_EVENTS.iter())
/// What [`install`] writes for `target` and `script_path`, in the same shape.
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> {
let mut want: Vec<(String, String)> = target
.events()
.map(|(event, state)| {
(
(*event).to_string(),
event.to_string(),
format!("{} {state}", script_path.display()),
)
})
@@ -207,28 +282,40 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
Ok(())
}
/// Install the full hook setup for `home`: write the script under
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
/// Install the full hook setup for one target under `home`: write the script
/// under `.local/share/maestro/` and register it in the target's hook file.
/// Idempotent; returns whether anything changed on disk.
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
pub fn install_target(target: Target, home: &Path) -> Result<bool, HooksError> {
let script = script_path(home);
let settings = settings_path(home);
let settings = target.settings_path(home);
// A script left over from an older release counts as a change even when
// the settings entries pointing at it are already right.
let changed = !is_current(&settings, &script);
let changed = !is_current(target, &settings, &script);
write_script(&script)?;
install(&settings, &script)?;
install(target, &settings, &script)?;
Ok(changed)
}
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
/// settings file at `settings_path`. Idempotent: existing maestro entries are
/// updated in place wherever they sit, stale ones removed, duplicates folded;
/// every non-maestro entry and every unrelated top-level field survives.
/// Install for every [`Target::ALL`], reporting each one separately. One
/// target failing — an unwritable file, unparseable JSON — never stops the
/// others: an agent whose hook could not be written is one agent without
/// activity reporting, not a failed install.
pub fn install_for_home(home: &Path) -> Vec<(Target, Result<bool, HooksError>)> {
Target::ALL
.iter()
.map(|&target| (target, install_target(target, home)))
.collect()
}
/// Register the hook command for every one of `target`'s [`Target::events`]
/// in the settings file at `settings_path`. Idempotent: existing maestro
/// entries are updated in place wherever they sit, stale ones removed,
/// duplicates folded; every non-maestro entry and every unrelated top-level
/// field survives.
///
/// Never inspects or writes anywhere but `settings_path` — callers pass the
/// user's real home or a temp one in tests, never a repository.
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
let mut root = settings_json(settings_path)?;
// Strip every maestro entry wherever it currently sits, so an older
@@ -236,7 +323,7 @@ pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksErro
strip_maestro_entries(&mut root);
let hooks = ensure_object(&mut root, "hooks");
for (event, state) in HOOK_EVENTS.iter().chain(PROMPT_EVENTS.iter()).copied() {
for (event, state) in target.events() {
let groups = ensure_array(hooks, event);
let mut group = serde_json::Map::new();
group.insert(
+136 -32
View File
@@ -1,15 +1,24 @@
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
//! and the settings-file installer. Everything happens under a temp `$HOME`;
//! the developer's own `~/.claude/settings.json` is never touched.
//! the developer's own `~/.claude/settings.json` and `~/.codex/hooks.json`
//! are never touched.
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use super::*;
/// How many commands one install writes: every activity event, plus the
/// prompt event when `haiku-autoname` is on (#131).
const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
/// How many commands one install writes for a target: its activity events,
/// plus the prompt event when `haiku-autoname` is on (#131).
fn installed_events(target: Target) -> usize {
target.events().count()
}
/// The Claude Code hook file under a temp home. Most installer tests only
/// need one target, and this is the one they were written against.
fn settings_path(home: &Path) -> std::path::PathBuf {
Target::ClaudeCode.settings_path(home)
}
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
let mut cmd = Command::new("/bin/sh");
@@ -70,7 +79,7 @@ fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a
/// exit 0, no output, stdin drained.
#[test]
fn script_exits_silently_without_maestro_env() {
for (event, _) in HOOK_EVENTS {
for (event, _) in CLAUDE_EVENTS {
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
@@ -103,7 +112,7 @@ fn script_posts_mapped_state_to_the_activity_endpoint() {
/// Every event maps to the state the contract specifies.
#[test]
fn event_state_mapping_matches_the_contract() {
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
let map: std::collections::HashMap<&str, &str> = CLAUDE_EVENTS.into_iter().collect();
assert_eq!(map["PreToolUse"], "Active");
assert_eq!(map["PostToolUse"], "Active");
assert_eq!(map["Notification"], "AwaitingInput");
@@ -203,10 +212,10 @@ fn fresh_install_registers_every_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
write_script(&script_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let cmds = installed_commands(&settings_path(home));
assert_eq!(cmds.len(), INSTALLED_EVENTS);
assert_eq!(cmds.len(), installed_events(Target::ClaudeCode));
let joined = cmds.join("\n");
assert_eq!(joined.matches("Active").count(), 2);
assert!(joined.contains("Error"));
@@ -221,15 +230,15 @@ fn fresh_install_registers_every_event() {
fn install_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let once = std::fs::read_to_string(settings_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
assert_eq!(once, twice);
assert_eq!(
installed_commands(&settings_path(home)).len(),
INSTALLED_EVENTS
installed_events(Target::ClaudeCode)
);
}
@@ -257,12 +266,12 @@ fn install_updates_an_older_maestro_hook_in_place() {
.path()
.join(".local/share/maestro")
.join(SCRIPT_FILE_NAME);
install(&settings, &new_script).unwrap();
install(Target::ClaudeCode, &settings, &new_script).unwrap();
let cmds = installed_commands(&settings);
assert_eq!(
cmds.len(),
INSTALLED_EVENTS,
installed_events(Target::ClaudeCode),
"old entry replaced, not kept: {cmds:?}"
);
assert!(!cmds.iter().any(|c| c == old_script));
@@ -298,7 +307,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
.path()
.join(".local/share/maestro")
.join(SCRIPT_FILE_NAME);
install(&settings, &new_script).unwrap();
install(Target::ClaudeCode, &settings, &new_script).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
@@ -311,7 +320,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
let cmds = installed_commands(&settings);
assert_eq!(
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
INSTALLED_EVENTS,
installed_events(Target::ClaudeCode),
"{cmds:?}"
);
}
@@ -321,9 +330,15 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
fn is_installed_tracks_reality() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(!is_installed(&settings_path(home)));
install_for_home(home).unwrap();
assert!(is_installed(&settings_path(home)));
for target in Target::ALL {
assert!(!is_installed(&target.settings_path(home)));
}
for (_, result) in install_for_home(home) {
result.unwrap();
}
for target in Target::ALL {
assert!(is_installed(&target.settings_path(home)));
}
}
/// The full home-level install leaves the script executable.
@@ -331,7 +346,9 @@ fn is_installed_tracks_reality() {
fn install_for_home_writes_an_executable_script() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
for (_, result) in install_for_home(home) {
result.unwrap();
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -350,7 +367,7 @@ fn install_for_home_writes_an_executable_script() {
fn install_registers_the_prompt_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
@@ -367,7 +384,7 @@ fn install_registers_the_prompt_event() {
fn install_registers_no_prompt_event_without_the_feature() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
@@ -436,8 +453,12 @@ fn the_script_posts_a_prompt_payload_unchanged() {
fn is_current_rejects_an_install_missing_an_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
assert!(is_current(&settings_path(home), &script_path(home)));
install_target(Target::ClaudeCode, home).unwrap();
assert!(is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
// Drop one event, as an older install would have left the file.
let mut root: serde_json::Value =
@@ -450,7 +471,11 @@ fn is_current_rejects_an_install_missing_an_event() {
.unwrap();
assert!(is_installed(&settings_path(home)));
assert!(!is_current(&settings_path(home), &script_path(home)));
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
}
/// A settings file that is complete but points at a script from an older
@@ -459,9 +484,13 @@ fn is_current_rejects_an_install_missing_an_event() {
fn is_current_rejects_a_stale_script() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
install_target(Target::ClaudeCode, home).unwrap();
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(!is_current(&settings_path(home), &script_path(home)));
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
}
/// Nothing installed at all is not current.
@@ -469,18 +498,93 @@ fn is_current_rejects_a_stale_script() {
fn is_current_rejects_a_missing_install() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(!is_current(&settings_path(home), &script_path(home)));
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
}
/// `install_for_home` reports a stale script as a change, even when the
/// `install_target` reports a stale script as a change, even when the
/// settings entries pointing at it already read correctly.
#[test]
fn install_for_home_reports_a_stale_script_as_a_change() {
fn install_target_reports_a_stale_script_as_a_change() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(install_for_home(home).unwrap());
assert!(!install_for_home(home).unwrap());
assert!(install_target(Target::ClaudeCode, home).unwrap());
assert!(!install_target(Target::ClaudeCode, home).unwrap());
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(install_for_home(home).unwrap());
assert!(install_target(Target::ClaudeCode, home).unwrap());
}
/// Codex is hooked in its own file, with its own event set: the two agents
/// share the script but not the events, so neither file carries the other's
/// spellings (#131 follow-up — a codex session was never named because
/// nothing installed its `UserPromptSubmit` hook).
#[test]
fn each_target_gets_its_own_file_and_events() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
for (_, result) in install_for_home(home) {
result.unwrap();
}
let claude = events_in(&Target::ClaudeCode.settings_path(home));
let codex = events_in(&Target::Codex.settings_path(home));
assert_eq!(
Target::Codex.settings_path(home),
home.join(".codex/hooks.json")
);
// Claude Code's failure signal has no codex equivalent, and codex's
// approval notice is spelled differently.
assert!(claude.contains(&"StopFailure".to_string()));
assert!(claude.contains(&"Notification".to_string()));
assert!(!codex.contains(&"StopFailure".to_string()));
assert!(!codex.contains(&"Notification".to_string()));
assert!(codex.contains(&"PermissionRequest".to_string()));
// Both report work and both report a finished turn.
for event in ["PreToolUse", "PostToolUse", "Stop"] {
assert!(claude.contains(&event.to_string()), "claude {event}");
assert!(codex.contains(&event.to_string()), "codex {event}");
}
assert_eq!(
installed_commands(&Target::Codex.settings_path(home)).len(),
installed_events(Target::Codex)
);
}
/// #131: a codex session names itself the same way a Claude Code one does —
/// same event spelling, same script, same `Prompt` argument.
#[cfg(feature = "haiku-autoname")]
#[test]
fn codex_reports_its_first_prompt_too() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_target(Target::Codex, home).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(Target::Codex.settings_path(home)).unwrap())
.unwrap();
let cmd = root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.to_string();
assert!(cmd.ends_with(" Prompt"), "{cmd}");
assert!(cmd.contains(SCRIPT_FILE_NAME));
}
/// Every event name a settings file registers a maestro hook under.
fn events_in(settings: &Path) -> Vec<String> {
serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(settings).unwrap())
.unwrap()
.get("hooks")
.and_then(|h| h.as_object())
.into_iter()
.flatten()
.map(|(event, _)| event.clone())
.collect()
}
+2
View File
@@ -268,6 +268,7 @@ mod tests {
fn session(cwd: &str) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(1),
cwd: PathBuf::from(cwd),
command: "bash".into(),
@@ -284,6 +285,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
+235
View File
@@ -116,6 +116,33 @@ pub struct Session {
/// sorts a never-focused session after every session that has one.
#[serde(default)]
pub last_focused_at: Option<DateTime<Utc>>,
/// Whether this session's process is a plain terminal (PTY bytes through
/// tmux) or a structured agent driven over ACP rows (#133). Fixed at
/// spawn time and never changes for the life of the session. Defaults to
/// `Terminal` on the wire so every existing client and payload from a
/// daemon that predates the field keeps working untouched.
#[serde(default)]
pub view: SessionView,
/// The ACP session id the `claude-code-acp` adapter handed back from
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
/// one, and only once its handshake succeeded; a later respawn passes it
/// to `session/load` so the conversation continues (#135). Distinct from
/// the hook-reported Claude Code conversation id (#161): that one is what
/// `claude --resume` takes, this one is what the ACP adapter takes, and
/// the two are minted by different processes with different lifetimes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>,
}
/// A session's process shape (#133): a plain terminal, or a structured agent
/// driven over ACP rows. Chosen at spawn and immutable for the session's
/// life. `Terminal` is the default — every sidecar and `CreateSessionRequest`
/// written before this field existed meant a terminal session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SessionView {
#[default]
Terminal,
Structured,
}
/// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -352,6 +379,11 @@ pub struct CreateSessionRequest {
pub metadata: HashMap<String, Value>,
#[serde(default)]
pub worktree: Option<WorktreeSpec>,
/// The process shape to spawn (#133). Defaulted so every existing client
/// and `curl` against the API keeps requesting a `Terminal` session
/// untouched.
#[serde(default)]
pub view: SessionView,
}
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
@@ -554,6 +586,12 @@ pub struct DaemonInfo {
/// network blip, even when the build did not change.
#[serde(default)]
pub instance: String,
/// Canonical leader key the operator configured (`[tui].leader`, default
/// `ctrl-a`), served so the web client binds the same chord the TUI does
/// instead of its own hardcoded constant (#159). Empty from a daemon too
/// old to serve it, which clients read as "use your default".
#[serde(default)]
pub leader: String,
}
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
@@ -717,6 +755,132 @@ pub enum CatalogEvent {
},
}
// ---------------------------------------------------------------------------
// Structured-session projections
// ---------------------------------------------------------------------------
/// One client-neutral item in a structured session transcript.
///
/// `data` preserves the ACP payload without committing clients to an ACP
/// dependency. Rendering choices, including markdown and tool-card treatment,
/// stay with the client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TranscriptRow {
/// Stable within one session, so later deltas can address this row.
pub id: String,
pub kind: TranscriptRowKind,
pub data: Value,
}
/// The semantic shape of a [`TranscriptRow`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptRowKind {
User,
AgentText,
AgentThought,
ToolCall,
ToolDiff,
Approval,
Plan,
Notice,
}
/// A complete transcript at a durable event-log position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TranscriptSnapshot {
pub cursor: u64,
#[serde(default)]
pub rows: Vec<TranscriptRow>,
}
/// One durable, client-neutral replay step from a structured session.
///
/// Results are ordered by increasing `cursor`. `since` returns the first
/// matching steps; `before` returns the final matching steps, still increasing.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StructuredReplayEvent {
/// Durable ACP-log position represented by this projected step.
pub cursor: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript: Option<TranscriptDelta>,
pub state: ReducedState,
}
/// A change to a transcript row.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TranscriptDelta {
Append { row: TranscriptRow },
Patch { row: TranscriptRow },
Remove { id: String },
}
/// Per-turn indicators derived from the ACP stream.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnState {
#[serde(default)]
pub responding: bool,
#[serde(default)]
pub thinking: bool,
#[serde(default)]
pub active_tool_calls: Vec<String>,
}
/// A whole-session view derived from ACP notifications.
///
/// Values whose shape ACP owns are carried as JSON so this wire protocol stays
/// forward-compatible when ACP adds fields or variants.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ReducedState {
#[serde(default)]
pub turn: TurnState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_approval: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
/// Server messages sent over a structured-session WebSocket.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StructuredEvent {
TranscriptSnapshot {
snapshot: TranscriptSnapshot,
},
ReducedState {
state: ReducedState,
},
ProjectionUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
transcript: Option<TranscriptDelta>,
state: ReducedState,
},
}
/// HTTP body for `POST /api/sessions/:id/structured/prompt`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StructuredPromptRequest {
pub prompt: String,
}
/// Operator decision for a pending ACP permission request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StructuredApprovalDecision {
AllowOnce,
AllowForSession,
Deny,
}
// ---------------------------------------------------------------------------
// Client ↔ Daemon control messages (text frames on the attach WS)
// ---------------------------------------------------------------------------
@@ -856,6 +1020,8 @@ mod tests {
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
last_focused_at: None,
view: SessionView::Terminal,
acp_session_id: None,
}
}
@@ -1067,6 +1233,7 @@ mod tests {
branch: "feat/foo".into(),
create_branch: true,
}),
view: SessionView::Terminal,
};
assert_eq!(req, round_trip(&req));
}
@@ -1081,6 +1248,74 @@ mod tests {
assert!(req.worktree.is_none());
assert!(req.env.is_empty());
assert!(req.metadata.is_empty());
assert_eq!(req.view, SessionView::Terminal);
}
#[test]
fn session_view_defaults_to_terminal_on_legacy_payload() {
// A pre-#133 daemon or client omits `view`; it must deserialize to
// `Terminal` rather than failing, and `Structured` round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.view, SessionView::Terminal);
let mut structured = sample_session();
structured.view = SessionView::Structured;
assert_eq!(structured, round_trip(&structured));
}
#[test]
fn structured_projection_types_round_trip_and_default() {
let row = TranscriptRow {
id: "tool:read-1".into(),
kind: TranscriptRowKind::ToolCall,
data: serde_json::json!({"name": "vendor.read", "title": "Read"}),
};
let snapshot = TranscriptSnapshot {
cursor: 7,
rows: vec![row.clone()],
};
assert_eq!(snapshot, round_trip(&snapshot));
assert_eq!(
TranscriptDelta::Patch { row: row.clone() },
round_trip(&TranscriptDelta::Patch { row })
);
let state: ReducedState = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(state, ReducedState::default());
assert_eq!(
serde_json::to_value(TranscriptRowKind::AgentThought).unwrap(),
serde_json::json!("agent_thought")
);
}
#[test]
fn acp_session_id_defaults_to_none_and_round_trips() {
// A payload from before #134 carries no `acp_session_id`; it must
// read as `None`, stay off the wire while unset, and round-trip
// once the adapter has handed one back.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.acp_session_id, None);
let serialized = serde_json::to_value(&s).unwrap();
assert!(serialized.get("acp_session_id").is_none());
let mut structured = sample_session();
structured.view = SessionView::Structured;
structured.acp_session_id = Some("sess_01HZX".into());
assert_eq!(structured, round_trip(&structured));
}
/// A client that predates the new/existing discriminator sends no
+66
View File
@@ -71,6 +71,31 @@ pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}")
}
/// `WS /api/structured/:id` — transcript/state snapshots and live deltas.
pub fn structured(id: Uuid) -> String {
format!("{API_PREFIX}/structured/{id}")
}
/// `GET /api/sessions/:id/structured/replay` — cursor-bounded ACP history.
pub fn structured_replay(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/replay")
}
/// `POST /api/sessions/:id/structured/prompt` — send an ACP prompt.
pub fn structured_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/prompt")
}
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel")
}
/// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission.
pub fn structured_approval(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
}
/// Axum-style patterns for the two parameterised routes, so the server
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
@@ -80,6 +105,11 @@ pub mod patterns {
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
pub const ATTACH: &str = "/api/attach/:id";
pub const STRUCTURED: &str = "/api/structured/:id";
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
}
/// A daemon base URL that could not be turned into a WebSocket base.
@@ -206,6 +236,22 @@ mod tests {
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
structured(id()),
"/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
structured_replay(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/replay"
);
assert_eq!(
structured_prompt(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/prompt"
);
assert_eq!(
structured_cancel(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/cancel"
);
assert_eq!(
attach_url("https://laptop.lan:7070", id()).unwrap(),
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -240,6 +286,22 @@ mod tests {
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::STRUCTURED.trim_end_matches(":id"),
structured(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::STRUCTURED_REPLAY.trim_end_matches(":id/structured/replay"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::STRUCTURED_PROMPT.trim_end_matches(":id/structured/prompt"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::STRUCTURED_CANCEL.trim_end_matches(":id/structured/cancel"),
session(id()).trim_end_matches(&id().to_string())
);
}
#[test]
@@ -258,5 +320,9 @@ mod tests {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
}
assert!(attach(id()).starts_with(API_PREFIX));
assert!(structured(id()).starts_with(API_PREFIX));
assert!(structured_replay(id()).starts_with(API_PREFIX));
assert!(structured_prompt(id()).starts_with(API_PREFIX));
assert!(structured_cancel(id()).starts_with(API_PREFIX));
}
}
+2
View File
@@ -31,6 +31,7 @@ use uuid::Uuid;
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
Session {
view: Default::default(),
id,
cwd: cwd.into(),
command: command.into(),
@@ -47,6 +48,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
+27
View File
@@ -56,6 +56,14 @@ pub enum Action {
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
/// over the sessions, ranked by the shared matcher.
OpenCommandBar,
/// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B`
/// byte to the focused pane instead of opening the command bar, giving
/// readline's kill-to-end-of-line back.
SendLiteralCtrlK,
/// Escape hatch (#158): the leader pressed twice sends one literal
/// leader byte to the focused pane and drops the pending chord, the way
/// tmux resolves a doubled prefix key.
SendLiteralLeader,
ToggleSidebar,
ShowHelp,
Quit,
@@ -98,6 +106,8 @@ impl Action {
Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
Action::OpenCommandBar => "Go to a session".into(),
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
Action::SendLiteralLeader => "Send literal leader key to session".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(),
Action::Quit => "Quit Maestro".into(),
@@ -125,6 +135,8 @@ impl Action {
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
@@ -256,11 +268,26 @@ mod tests {
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session");
}
/// The escape hatch (#156) is an ordinary action like any other.
#[test]
fn send_literal_ctrl_k_round_trips() {
assert_eq!(
Action::parse("SendLiteralCtrlK").unwrap(),
Action::SendLiteralCtrlK
);
assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK");
assert_eq!(
Action::SendLiteralCtrlK.help_label(),
"Send literal Ctrl-K to session"
);
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::OpenCommandBar,
Action::SendLiteralCtrlK,
Action::RenameHighlighted,
Action::Quit,
Action::OpenSpawnModal,
+666 -58
View File
@@ -23,7 +23,7 @@ use uuid::Uuid;
use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::command_bar::{BarOutcome, BarTarget, CommandBar};
use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId};
use crate::config::TuiConfig;
use crate::daemon_link::DaemonLink;
use crate::draft::{DraftInputResult, DraftPane};
@@ -168,6 +168,14 @@ pub enum Effect {
/// Fast-forward spawn (#67): resolve every default from the daemon and
/// spawn with no form; unresolvable defaults open a pre-filled form.
FastForward,
/// The same fast path pinned to one directory — the command bar's `~`
/// (#154). Nothing may override the directory the operator named.
FastForwardIn(PathBuf),
/// Fetch the command bar's `~` candidates: recent dirs and the project
/// list. `bar` guards a load landing in a bar that was reopened since.
LoadBarDirectories {
bar: u64,
},
/// The form's target changed: probe `/api/gitroot` so the worktree
/// section knows its base repo (#65). `seq` guards stale answers.
ProbeGitRoot {
@@ -298,6 +306,7 @@ impl App {
return Ok(true);
}
}
self.chord.take_replay();
return Ok(false);
}
@@ -317,32 +326,46 @@ impl App {
return Ok(false);
}
let known = self.cfg.keymap.known_bindings(self.state.mode);
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
ChordResult::Resolved(canon) => {
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
if matches!(act, Some(Action::Quit)) {
return Ok(true);
// A key bumped out of an abandoned chord (#156) is replayed as its
// own keystroke, against the mode dispatch may have just changed.
let mut current_key = key;
let mut result = self.chord.feed(
&current_key,
&self.cfg.keymap.known_bindings(self.state.mode),
);
loop {
let canon = match result {
ChordResult::Pending => return Ok(false),
ChordResult::Resolved(canon) => canon,
};
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
if matches!(act, Some(Action::Quit)) {
return Ok(true);
}
let pty_owns_keys =
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane);
match act {
// `Noop` suppresses a binding, and a key with no binding in
// Insert mode is the session's — so a key bound to `Noop`
// reaches the pty too. That is the escape hatch for taking
// a default binding back.
Some(Action::Noop) | None if pty_owns_keys => {
let effects = self.forward_to_session(&current_key).into_iter().collect();
self.perform(effects).await;
}
let pty_owns_keys =
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane);
match act {
// `Noop` suppresses a binding, and a key with no binding in
// Insert mode is the session's — so a key bound to `Noop`
// reaches the pty too. That is the escape hatch for taking
// a default binding back.
Some(Action::Noop) | None if pty_owns_keys => {
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
}
Some(act) => {
let effects = self.dispatch(act);
self.perform(effects).await;
}
None => {}
Some(act) => {
let effects = self.dispatch(act);
self.perform(effects).await;
}
Ok(false)
None => {}
}
match self.chord.take_replay() {
Some(replay_key) => {
let known = self.cfg.keymap.known_bindings(self.state.mode);
result = self.chord.feed(&replay_key, &known);
current_key = replay_key;
}
None => return Ok(false),
}
}
}
@@ -386,6 +409,8 @@ impl App {
Effect::Kill(id) => self.submit_kill(id).await,
Effect::LoadDraft => self.spawn_load_draft(),
Effect::FastForward => self.spawn_fast_forward(),
Effect::FastForwardIn(dir) => self.spawn_fast_forward_in(dir),
Effect::LoadBarDirectories { bar } => self.spawn_load_bar_directories(bar),
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
Effect::LoadBranches => self.spawn_load_branches(),
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
@@ -459,6 +484,35 @@ impl App {
self.dispatch(Action::AttachHighlighted),
)
}
// The bar picks the action and hands off; it never
// reimplements one.
BarOutcome::Open(BarTarget::Command(action)) => {
self.close_command_bar();
(OverlayDecision::Consumed, self.dispatch(action))
}
BarOutcome::Open(BarTarget::Directory(dir)) => {
self.close_command_bar();
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
}
// The action page raises the flow this client already has
// — for kill that is `KillConfirm`, which the TUI puts in
// front of every kill (#160). The bar names the target;
// it never reimplements the flow.
BarOutcome::Open(BarTarget::SessionAction { session, action }) => {
self.close_command_bar();
let effects = match action {
SessionActionId::Kill => {
self.confirm_kill(session);
Vec::new()
}
SessionActionId::Rename => {
self.prompt_rename(session);
Vec::new()
}
SessionActionId::SpawnSimilar => self.open_spawn_similar(session),
};
(OverlayDecision::Consumed, effects)
}
}
}
Some(OverlayKind::Help) => {
@@ -506,7 +560,17 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenCommandBar => self.open_command_bar(),
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
// Escape hatch (#156): the literal byte, not the bar.
Action::SendLiteralCtrlK => {
effects.extend(self.forward_to_session(&InputKey::ctrl_char('k')));
}
// Escape hatch (#158): the leader's own byte, built from the
// configured leader rather than a hardcoded key.
Action::SendLiteralLeader => {
let leader = self.cfg.leader.clone();
effects.extend(self.forward_to_session(&leader));
}
Action::OpenSpawnModal => {
self.open_spawn_modal();
effects.push(Effect::LoadDraft);
@@ -535,24 +599,12 @@ impl App {
}
Action::KillHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
self.state.show_overlay(OverlayKind::KillConfirm(sid));
self.confirm_kill(sid);
}
}
Action::RenameHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
// Pre-fill with the custom name only: a session that has
// none starts empty rather than with its command already
// typed in, which Enter would then pin as the name.
self.rename_input = TextInput::with_value(
self.catalog
.snapshot()
.sessions
.iter()
.find(|s| s.id == sid)
.and_then(crate::sidebar::custom_name_for)
.unwrap_or_default(),
);
self.state.show_overlay(OverlayKind::Rename(sid));
self.prompt_rename(sid);
}
}
}
@@ -628,12 +680,21 @@ impl App {
/// while a session is previewed discards the preview first, like any
/// other real change (`docs/PREVIEW.md`); focus and mode are left alone,
/// so closing gives the keyboard back to whatever held it.
fn open_command_bar(&mut self) {
fn open_command_bar(&mut self) -> Vec<Effect> {
let snapshot = self.catalog.snapshot();
self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir));
// The keymap for the mode the bar was opened from, which is the one
// the operator would be typing into behind it. Same source the help
// overlay reads, so the two can never disagree.
let keymap = self.cfg.keymap.entries(self.state.mode);
let bar = CommandBar::new(&snapshot.sessions, &keymap, &self.home_dir);
let id = bar.id();
self.command_bar = Some(bar);
self.pending_preview = None;
self.clear_preview();
self.state.show_overlay(OverlayKind::CommandBar);
// `~`'s candidates are a network round trip; the bar opens without
// them and fills in when they land.
vec![Effect::LoadBarDirectories { bar: id }]
}
/// Take the bar down, dropping its query. Every exit goes through here so
@@ -829,6 +890,30 @@ impl App {
Some(None)
}
/// Raise the kill confirmation for one session (`docs/WORKTREES.md`).
/// Shared by the keymap's own kill and the command bar's action page
/// (#155), which picks the action and hands off rather than reimplementing
/// it; the blockers the dialog names are read at render time.
fn confirm_kill(&mut self, sid: Uuid) {
self.state.show_overlay(OverlayKind::KillConfirm(sid));
}
/// Raise the rename prompt for one session, pre-filled with its custom
/// name only: a session that has none starts empty rather than with its
/// command already typed in, which Enter would then pin as the name.
fn prompt_rename(&mut self, sid: Uuid) {
self.rename_input = TextInput::with_value(
self.catalog
.snapshot()
.sessions
.iter()
.find(|s| s.id == sid)
.and_then(crate::sidebar::custom_name_for)
.unwrap_or_default(),
);
self.state.show_overlay(OverlayKind::Rename(sid));
}
/// Put the spawn form up with a fresh draft behind it, preselecting the
/// project of the session the operator was last on — so `n` then Enter
/// spawns another session there, the way "spawn similar" would. A
@@ -842,6 +927,30 @@ impl App {
self.state.open_spawn_modal();
}
/// "Spawn similar" (#155): the spawn form seeded from one session's
/// directory and command, rather than from whatever the operator was last
/// looking at. Same base-repository rule as the plain seed, and the name
/// is left out on purpose — a clone is a new session and takes its own
/// generated handle, like `web/src/spawnSimilar.ts`.
fn open_spawn_similar(&mut self, sid: Uuid) -> Vec<Effect> {
let session = self
.catalog
.snapshot()
.sessions
.iter()
.find(|s| s.id == sid)
.cloned();
self.open_spawn_modal();
if let (Some(session), Some(draft)) = (session, self.draft.as_mut()) {
draft.seed_cwd = Some(match &session.worktree {
Some(wt) => wt.base_repo.clone(),
None => session.cwd.clone(),
});
draft.seed_command = (!session.command.is_empty()).then(|| session.command.clone());
}
vec![Effect::LoadDraft]
}
/// The project of the session the operator was last on — its base
/// repository for a worktree session, its cwd otherwise. Shared by the
/// plain spawn modal seed and fast-forward's project pick (#67 review):
@@ -990,18 +1099,27 @@ impl App {
projects,
names,
focused_cwd,
pinned,
} = update
{
self.fast_forward_spawn(info, projects, names, focused_cwd)
self.fast_forward_spawn(info, projects, names, focused_cwd, pinned)
.await;
return;
}
// Addressed to the bar, not to a form: a load that outlived its bar is
// dropped rather than filling in the next one.
if let DraftUpdate::BarDirectories { bar, paths } = update {
if let Some(open) = self.command_bar.as_mut().filter(|b| b.id() == bar) {
open.set_directories(&paths);
}
return;
}
let want = match &update {
DraftUpdate::Data { draft, .. }
| DraftUpdate::GitRoot { draft, .. }
| DraftUpdate::Branches { draft, .. } => *draft,
// Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } => unreachable!(),
// Handled above; they target no particular draft.
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => unreachable!(),
};
// Match by identity: dismissing the form and opening another while a
// load is in flight must not let the old answer land in the new form.
@@ -1036,7 +1154,9 @@ impl App {
DraftUpdate::Branches { cwd, branches, .. } => {
draft.set_branches(&cwd, branches);
}
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => {
unreachable!()
}
}
if follow_up_branches {
self.spawn_load_branches();
@@ -1064,16 +1184,52 @@ impl App {
projects: projects.ok(),
names,
focused_cwd,
pinned: None,
});
});
}
/// The same fast path, pinned to the directory the command bar's `~` row
/// named (#154). Off the loop for the same reason as [`Self::
/// spawn_fast_forward`]: a cold `/api/projects` walks all of `$HOME`.
fn spawn_fast_forward_in(&self, dir: PathBuf) {
let (daemon, http, base, tx) = self.loader_handles();
tokio::spawn(async move {
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
let _ = tx.send(DraftUpdate::FastForward {
info: info.ok(),
projects: projects.ok(),
names,
focused_cwd: None,
pinned: Some(dir),
});
});
}
/// Fetch `~`'s candidates: the spawn form's own two sources, recent dirs
/// (newest first) ahead of the project list. Either failing is a shorter
/// list, not an error state.
fn spawn_load_bar_directories(&self, bar: u64) {
let daemon = self.daemon.clone();
let tx = self.draft_tx.clone();
tokio::spawn(async move {
let (recents, projects) = tokio::join!(daemon.recent_dirs(), daemon.list_projects());
let mut paths: Vec<PathBuf> = recents
.map(|r| r.dirs.into_iter().map(|d| d.cwd).collect())
.unwrap_or_default();
paths.extend(projects.unwrap_or_default().into_iter().map(|p| p.path));
let _ = tx.send(DraftUpdate::BarDirectories { bar, paths });
});
}
async fn fast_forward_spawn(
&mut self,
info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
pinned: Option<PathBuf>,
) {
// Keep the home dir: without it the pre-filled draft renders
// unabbreviated paths and "Other directory…" seeds `/` instead of
@@ -1087,13 +1243,21 @@ impl App {
_ => Vec::new(),
};
let discovered = projects.unwrap_or_default();
let resolved = crate::fast_forward::resolve(
&discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
);
// A pinned directory is the operator's own pick, so it takes no
// fallback: a directory outside the project scan resolves no project,
// which is an unresolvable default and opens the form seeded with it.
let resolved = match pinned.as_deref() {
Some(dir) => {
crate::fast_forward::resolve_at(dir, &discovered, &presets, names.first().cloned())
}
None => crate::fast_forward::resolve(
&discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
),
};
if let Some(req) = resolved.build_request() {
self.submit_spawn(req).await;
return;
@@ -1105,8 +1269,10 @@ impl App {
};
draft.home_dir = home_dir;
// The fast path resolved its own project; its pick wins over the
// focused session's, so the seed never lands on top of it.
draft.seed_cwd = None;
// focused session's, so the seed never lands on top of it. A pinned
// directory is the exception: it is the seed, whether or not the scan
// knew it as a project.
draft.seed_cwd = pinned;
if !presets.is_empty() {
draft.set_presets(presets);
}
@@ -1463,7 +1629,12 @@ enum DraftUpdate {
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
/// The command bar pinned the spawn to this directory (#154): nothing
/// — not the focused session, not the recency ranking — may move it.
pinned: Option<PathBuf>,
},
/// The command bar's `~` candidates arrived (#154).
BarDirectories { bar: u64, paths: Vec<PathBuf> },
}
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
@@ -1711,6 +1882,7 @@ mod tests {
sessions: ids
.iter()
.map(|id| Session {
view: Default::default(),
id: *id,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -1727,6 +1899,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
})
.collect(),
});
@@ -1892,6 +2065,7 @@ mod tests {
let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -1908,6 +2082,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}],
});
app.dispatch(Action::SidebarNext);
@@ -1919,6 +2094,7 @@ mod tests {
use chrono::Utc;
use maestro_protocol::{Session, SessionStatus};
Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: cwd.into(),
@@ -1935,6 +2111,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
@@ -2222,6 +2399,102 @@ mod tests {
assert_eq!(req["worktree"]["create_branch"], json!(true));
}
/// The command bar's `~` pins the spawn: the directory the operator named
/// wins over the recency ranking that plain fast-forward would follow.
#[tokio::test]
async fn a_pinned_directory_spawns_there_not_in_the_freshest_project() {
use axum::routing::{get, post};
use axum::Json;
use serde_json::json;
use std::sync::{Arc, Mutex};
async fn info() -> Json<serde_json::Value> {
Json(json!({
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
}))
}
async fn projects() -> Json<serde_json::Value> {
Json(json!([
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"},
{"path": "/repos/pinned", "name": "pinned", "base_repo": "/repos/pinned",
"last_activity": (chrono::Utc::now() - chrono::TimeDelta::days(3)).to_rfc3339(),
"last_command": "zsh"}
]))
}
async fn names() -> Json<serde_json::Value> {
Json(json!({"names": ["crimson-otter"]}))
}
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
let seen = posted.clone();
async fn spawn_session(
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
seen.lock().unwrap().push(body);
Json(json!({
"id": uuid::Uuid::from_u128(78).to_string(),
"cwd": "/repos/pinned", "command": "zsh",
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
}))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::PROJECTS, get(projects))
.route(maestro_protocol::routes::NAMES, get(names))
.route(
maestro_protocol::routes::SESSIONS,
post(spawn_session).with_state(seen),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
app.perform(vec![Effect::FastForwardIn(PathBuf::from("/repos/pinned"))])
.await;
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
.await
.expect("load completed")
.expect("channel open");
app.apply_draft_update(update).await;
let posts = posted.lock().unwrap();
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
assert_eq!(posts[0]["cwd"], json!("/repos/pinned"));
assert_eq!(posts[0]["command"], json!("zsh"));
}
/// A directory the project scan does not know resolves no project, which
/// is an unresolvable default: the form opens seeded with it rather than
/// the spawn silently landing somewhere else.
#[tokio::test]
async fn a_pinned_directory_outside_the_scan_opens_the_prefilled_form() {
let mut app = make_app();
app.fast_forward_spawn(
None,
Some(vec![maestro_protocol::Project {
path: PathBuf::from("/repos/fresh"),
name: "fresh".into(),
is_worktree: false,
base_repo: None,
last_activity: Some(Utc::now()),
last_command: Some("aider".into()),
}]),
vec!["crimson-otter".into()],
None,
Some(PathBuf::from("/elsewhere")),
)
.await;
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
let draft = app.draft.as_ref().expect("form is up");
// The seed is dropped by the picker when no project matches it, so
// the operator picks by hand — but nothing spawned in `/repos/fresh`.
assert_ne!(draft.picked_cwd, Some(PathBuf::from("/repos/fresh")));
}
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
/// switching which session the pane shows, and leaving the pane back to
/// the sidebar. A sidebar highlight alone must report nothing.
@@ -2510,6 +2783,7 @@ mod tests {
);
app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest {
view: Default::default(),
cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(),
env: Default::default(),
@@ -2670,6 +2944,55 @@ mod tests {
CtEvent::Key(crossterm::event::KeyEvent::new(code, mods))
}
fn ctrl_key_event(c: char) -> CtEvent {
key_event(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::CONTROL,
)
}
/// Drive a sequence of key events through an app in Insert mode with a
/// session attached, and report the app afterwards plus every chunk of
/// bytes that reached that session, in order.
async fn drive_insert_sequence(
section: &TuiSection,
events: &[CtEvent],
) -> (App, Vec<Vec<u8>>) {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
let sid = Uuid::from_u128(41);
let mut app = App::new(
TuiConfig::load(section).unwrap(),
CatalogStore::default(),
DaemonClient::new("http://127.0.0.1:7070"),
DaemonLink::default(),
);
let (tx, mut rx) = mpsc::channel(8);
app.emulators.insert(
sid,
SessionHandle {
tx,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
},
);
app.state.attach_to_focused(sid);
app.state.mode = Mode::Insert;
app.state.focus = Focus::Pane;
for ev in events {
app.handle_event(ev.clone()).await.unwrap();
}
let mut sent = Vec::new();
while let Ok(SessionInput::Bytes(bytes)) = rx.try_recv() {
sent.push(bytes);
}
(app, sent)
}
/// Escape belongs to the session: full-screen apps (vim, Claude Code) read
/// it constantly, so no Insert-mode default may swallow it. The leader is
/// the way back to the sidebar.
@@ -2688,19 +3011,120 @@ mod tests {
/// `Noop` suppresses a binding, and in Insert mode a key with no binding is
/// the session's — so binding a key to `Noop` hands it to the pty.
///
/// Uses `ctrl-x`, not the leader: the leader is also a chord prefix
/// (#156), so a single fed keystroke on it resolves as `Pending`, not
/// `Resolved` — a different mechanism, covered separately below.
#[tokio::test]
async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
let mut section = TuiSection::default();
section.keymap_insert.insert("ctrl-a".into(), "Noop".into());
section.keymap_insert.insert("ctrl-x".into(), "Noop".into());
let sent = bytes_sent_for(
&section,
key_event(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::CONTROL,
),
)
.await;
assert_eq!(sent, Some(vec![0x01]));
assert_eq!(sent, Some(vec![0x18]));
}
/// Leader then `ctrl-k` sends the literal byte and never opens the bar
/// (#156) — the default leader, `ctrl-a`.
#[tokio::test]
async fn leader_then_ctrl_k_sends_the_literal_byte_and_does_not_open_the_bar() {
let (app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[ctrl_key_event('a'), ctrl_key_event('k')],
)
.await;
assert_eq!(sent, vec![vec![0x0b]]);
assert_eq!(app.state.overlay(), None);
assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left");
}
/// The escape hatch is built from the configured leader, not a hardcoded
/// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#156).
#[tokio::test]
async fn a_rebound_leader_still_reaches_the_literal_ctrl_k_escape() {
let section = TuiSection {
leader: Some("ctrl-b".into()),
..Default::default()
};
let (app, sent) =
drive_insert_sequence(&section, &[ctrl_key_event('b'), ctrl_key_event('k')]).await;
assert_eq!(sent, vec![vec![0x0b]]);
assert_eq!(app.state.overlay(), None);
}
/// Leader alone, followed by an ordinary key, still leaves Insert mode —
/// the escape hatch chord doesn't cost the leader its existing meaning
/// (#156).
#[tokio::test]
async fn leader_alone_still_leaves_insert_when_followed_by_another_key() {
let (app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[
ctrl_key_event('a'),
key_event(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::NONE,
),
],
)
.await;
assert!(
sent.is_empty(),
"no bytes for the leader chord itself: {sent:?}"
);
assert_eq!(app.state.mode, Mode::Normal, "leader still leaves Insert");
}
/// Leader pressed twice sends one literal leader byte, tmux-style, and
/// clears the pending chord rather than leaving Insert (#158) — the
/// default leader, `ctrl-a`.
#[tokio::test]
async fn leader_twice_sends_the_literal_leader_byte_and_stays_in_insert() {
let (app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[ctrl_key_event('a'), ctrl_key_event('a')],
)
.await;
assert_eq!(sent, vec![vec![0x01]]);
assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left");
}
/// The escape hatch is built from the configured leader, not a hardcoded
/// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#158).
#[tokio::test]
async fn a_rebound_leader_still_reaches_the_literal_leader_escape() {
let section = TuiSection {
leader: Some("ctrl-b".into()),
..Default::default()
};
let (app, sent) =
drive_insert_sequence(&section, &[ctrl_key_event('b'), ctrl_key_event('b')]).await;
assert_eq!(sent, vec![vec![0x02]]);
assert_eq!(app.state.mode, Mode::Insert);
}
/// After the literal-leader escape fires, the chord is gone — a further
/// leader press starts a fresh chord rather than resolving as a
/// three-deep sequence (#158).
#[tokio::test]
async fn leader_twice_then_ctrl_k_is_a_fresh_chord() {
let (_app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[
ctrl_key_event('a'),
ctrl_key_event('a'),
ctrl_key_event('a'),
ctrl_key_event('k'),
],
)
.await;
assert_eq!(sent, vec![vec![0x01], vec![0x0b]]);
}
#[test]
@@ -2778,6 +3202,7 @@ mod tests {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
view: Default::default(),
id: Uuid::from_u128(n),
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -2797,6 +3222,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: focused,
acp_session_id: None,
};
let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -2850,12 +3276,115 @@ mod tests {
.iter()
.map(|r| match r.target {
BarTarget::Session(id) => id,
ref other => panic!("expected a session row, got {other:?}"),
})
.collect::<Vec<_>>(),
vec![bravo, alpha]
);
}
/// `~`'s candidates are a network round trip, so opening asks for them
/// rather than blocking the keystroke on one.
#[test]
fn opening_the_bar_asks_for_its_directories() {
let mut app = make_app();
let effects = app.dispatch(Action::OpenCommandBar);
let bar = app.command_bar.as_ref().unwrap().id();
assert_eq!(effects, vec![Effect::LoadBarDirectories { bar }]);
}
fn type_into_bar(app: &mut App, text: &str) {
for c in text.chars() {
app.handle_overlay_input(&InputKey::Char {
c,
ctrl: false,
alt: false,
shift: false,
});
}
}
/// `>` lists the live keymap for the current mode, matched on the label
/// alone, and Enter hands the action to the ordinary dispatcher.
#[test]
fn enter_on_a_command_row_dispatches_the_action() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
type_into_bar(&mut app, ">defaults");
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
assert_eq!(row.name, "Create session with defaults");
assert_eq!(
row.target,
BarTarget::Command(Action::FastForwardSpawn),
"chords: {:?}",
row.chords
);
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::FastForward]);
assert!(app.state.overlay().is_none());
assert!(app.command_bar.is_none());
}
/// The row shows the chord bound to it, and both chords when the map binds
/// two — but the chord is never what matched.
#[test]
fn a_command_row_carries_its_chords() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
type_into_bar(&mut app, ">open selected");
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
assert_eq!(row.name, "Open selected item");
assert!(
row.chords.iter().any(|c| c == "enter"),
"chords: {:?}",
row.chords
);
assert!(
row.chords.len() > 1,
"the default map binds more than one chord to it: {:?}",
row.chords
);
}
#[test]
fn enter_on_a_directory_row_fast_forward_spawns_there() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
app.command_bar
.as_mut()
.unwrap()
.set_directories(&[PathBuf::from("/repos/api")]);
type_into_bar(&mut app, "~api");
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(
effects,
vec![Effect::FastForwardIn(PathBuf::from("/repos/api"))]
);
assert!(app.state.overlay().is_none());
}
/// A load that outlived its bar fills in nothing: the reopened bar has its
/// own identity.
#[tokio::test]
async fn a_stale_directory_load_is_dropped() {
let mut app = make_app();
app.dispatch(Action::OpenCommandBar);
let stale = app.command_bar.as_ref().unwrap().id();
app.handle_overlay_input(&InputKey::Esc);
app.dispatch(Action::OpenCommandBar);
app.apply_draft_update(DraftUpdate::BarDirectories {
bar: stale,
paths: vec![PathBuf::from("/repos/api")],
})
.await;
type_into_bar(&mut app, "~");
assert!(app.command_bar.as_ref().unwrap().rows().is_empty());
}
/// Opening from Insert mode leaves the keyboard where it was, so closing
/// gives it back — the bar is a view over the app, not a place in it.
#[test]
@@ -2955,6 +3484,7 @@ mod tests {
let sid = Uuid::from_u128(7);
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -2972,6 +3502,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}],
});
app.dispatch(Action::KillHighlighted);
@@ -2991,6 +3522,7 @@ mod tests {
};
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -3007,6 +3539,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}],
});
}
@@ -3089,4 +3622,79 @@ mod tests {
}]
);
}
// ---- the session action page (#155) ----
/// Drill into the second row's session, then pick an action by name.
fn drill_and_pick(app: &mut App, action: &str) -> (OverlayDecision, Vec<Effect>) {
app.dispatch(Action::OpenCommandBar);
app.handle_overlay_input(&InputKey::Down);
app.handle_overlay_input(&InputKey::Tab);
type_into_bar(app, action);
app.handle_overlay_input(&InputKey::Enter)
}
/// The bar picks the action and hands off: kill goes through the confirm
/// this client puts in front of every kill, not straight to the daemon.
#[test]
fn the_action_page_raises_the_existing_kill_confirm() {
let mut app = make_app();
let (alpha, _bravo) = seed_two_sessions(&mut app);
let (decision, effects) = drill_and_pick(&mut app, "kill");
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty(), "the confirm is the whole effect");
assert_eq!(app.state.overlay(), Some(&OverlayKind::KillConfirm(alpha)));
assert!(app.command_bar.is_none());
}
#[test]
fn the_action_page_raises_the_existing_rename_prompt_prefilled() {
let mut app = make_app();
let (alpha, _bravo) = seed_two_sessions(&mut app);
let (decision, effects) = drill_and_pick(&mut app, "rename");
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert_eq!(app.state.overlay(), Some(&OverlayKind::Rename(alpha)));
assert_eq!(app.rename_input.value(), "alpha");
assert!(app.command_bar.is_none());
}
/// Spawn similar opens the spawn form seeded from that session's directory
/// and command — not from whatever the operator was last looking at.
#[test]
fn the_action_page_raises_the_spawn_form_seeded_from_the_session() {
let mut app = make_app();
seed_two_sessions(&mut app);
let (decision, effects) = drill_and_pick(&mut app, "spawn");
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::LoadDraft]);
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
let draft = app.draft.as_ref().unwrap();
assert_eq!(draft.seed_cwd, Some(std::path::PathBuf::from("/")));
assert_eq!(draft.seed_command.as_deref(), Some("bash"));
assert!(app.command_bar.is_none());
}
/// Backing out is free: the result-list query survives the round trip.
#[test]
fn escape_on_the_action_page_returns_to_the_result_list() {
let mut app = make_app();
seed_two_sessions(&mut app);
app.dispatch(Action::OpenCommandBar);
type_into_bar(&mut app, "alp");
app.handle_overlay_input(&InputKey::Tab);
assert!(app.command_bar.as_ref().unwrap().on_action_page());
let (decision, _) = app.handle_overlay_input(&InputKey::Esc);
assert_eq!(decision, OverlayDecision::Consumed);
let bar = app.command_bar.as_ref().expect("escape backs out, not out");
assert!(!bar.on_action_page());
assert_eq!(bar.query(), "alp");
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
// A second escape is the way out again.
app.handle_overlay_input(&InputKey::Esc);
assert!(app.command_bar.is_none());
assert!(app.state.overlay().is_none());
}
}
+2
View File
@@ -207,6 +207,7 @@ mod tests {
fn session(id: u128, command: &str) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(id),
command: command.into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -223,6 +224,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
File diff suppressed because it is too large Load Diff
+74 -1
View File
@@ -114,7 +114,7 @@ impl TuiConfig {
TuiTheme::Nord => Palette::NORD,
TuiTheme::Dracula => Palette::DRACULA,
};
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
let leader_str = section.leader();
let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
@@ -146,6 +146,25 @@ impl TuiConfig {
keymap
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
.expect("leader canonical form is valid by construction");
// Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from
// the same leader spec so rebinding leader rebinds this chord too.
keymap
.set(
Mode::Insert,
&format!("{} ctrl-k", leader.canonical()),
"SendLiteralCtrlK",
)
.expect("leader canonical form is valid by construction");
// Leader, leader → the literal-leader escape hatch (#158), tmux-style:
// pressing it twice sends the leader byte itself. Built from the same
// leader spec, so rebinding leader rebinds this chord too.
keymap
.set(
Mode::Insert,
&format!("{} {}", leader.canonical(), leader.canonical()),
"SendLiteralLeader",
)
.expect("leader canonical form is valid by construction");
for (input, action) in &section.keymap_normal {
keymap.set(Mode::Normal, input, action).map_err(|source| {
@@ -278,6 +297,60 @@ mod tests {
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
}
/// The literal-Ctrl-K escape hatch (#156) is chorded off the default
/// leader, `ctrl-a ctrl-k` — not the command bar.
#[test]
fn default_leader_escapes_a_literal_ctrl_k() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"),
Some(Action::SendLiteralCtrlK),
);
}
/// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not
/// just the Insert escape — proving the chord is built from the
/// configured leader rather than a hardcoded `ctrl-a`.
#[test]
fn rebinding_leader_moves_the_literal_ctrl_k_chord() {
let mut section = TuiSection::default();
section.leader = Some("ctrl-b".into());
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"),
Some(Action::SendLiteralCtrlK),
);
// The old leader's chord is gone along with its Insert escape.
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None);
}
/// The literal-leader escape hatch (#158) is chorded off the leader
/// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution.
#[test]
fn default_leader_escapes_a_literal_leader() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"),
Some(Action::SendLiteralLeader),
);
}
/// Rebinding the leader must rebind the literal-leader chord with it,
/// not just the Insert escape and the Ctrl-K chord — proving it too is
/// built from the configured leader rather than a hardcoded `ctrl-a`.
#[test]
fn rebinding_leader_moves_the_literal_leader_chord() {
let mut section = TuiSection::default();
section.leader = Some("ctrl-b".into());
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-b"),
Some(Action::SendLiteralLeader),
);
// The old leader's chord is gone along with the rest of its bindings.
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), None);
}
#[test]
fn user_normal_override_replaces_default() {
let mut section = TuiSection::default();
+1
View File
@@ -250,6 +250,7 @@ mod tests {
dirty: false,
},
instance: instance.into(),
leader: maestro_config::DEFAULT_LEADER.into(),
}
}
+69
View File
@@ -228,6 +228,10 @@ pub struct DraftPane {
/// respawns there without touching the picker. Consumed by the first
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
pub seed_cwd: Option<PathBuf>,
/// Command to preselect once the preset list lands — "spawn similar"'s
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
/// `None` once applied or when nothing seeded it.
pub seed_command: Option<String>,
/// The repository picked in step one ([`group_key`] of its main box),
/// `None` until a box is picked. Looked up fresh in `groups` rather than
/// cached, so a project-list refresh can't leave it stale.
@@ -291,6 +295,7 @@ impl Default for DraftPane {
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
presets,
selected_preset: 0,
seed_command: None,
custom_command: crate::text_input::TextInput::default(),
groups: Vec::new(),
filter: crate::text_input::TextInput::default(),
@@ -342,6 +347,27 @@ impl DraftPane {
.iter()
.position(|p| !p.command.is_empty())
.unwrap_or(0);
self.apply_seed_command();
}
/// Land a seeded command on the tile that runs it: its own preset when the
/// daemon offers one, otherwise the custom escape hatch with the command
/// typed in.
fn apply_seed_command(&mut self) {
let Some(command) = self.seed_command.take() else {
return;
};
match self.presets.iter().position(|p| p.command == command) {
Some(index) => self.selected_preset = index,
None => {
self.selected_preset = self
.presets
.iter()
.position(|p| p.command.is_empty())
.unwrap_or(self.selected_preset);
self.custom_command = crate::text_input::TextInput::with_value(command);
}
}
}
/// Replace the project snapshot: sort fresh-first (#54), group by
@@ -668,6 +694,7 @@ impl DraftPane {
}
let worktree = self.build_worktree()?;
Ok(CreateSessionRequest {
view: Default::default(),
cwd,
command: command.to_string(),
env: Default::default(),
@@ -1688,6 +1715,48 @@ mod tests {
assert_eq!(draft.presets.last().unwrap().id, "custom");
}
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
/// the tile that runs it when the daemon offers one.
#[test]
fn a_seeded_command_selects_its_own_preset() {
let mut draft = DraftPane {
seed_command: Some("aider".into()),
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
},
]);
assert_eq!(draft.command(), "aider");
assert!(!draft.is_custom());
assert_eq!(draft.seed_command, None, "consumed by the first load");
}
/// A command no preset runs falls back to the custom tile with the command
/// typed in, so the clone still spawns what it cloned.
#[test]
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
let mut draft = DraftPane {
seed_command: Some("bash -l".into()),
..DraftPane::default()
};
draft.set_presets(vec![SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
}]);
assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l");
}
#[test]
fn left_right_cycles_presets_wrapping_into_custom() {
let mut draft = DraftPane {
+68
View File
@@ -49,6 +49,33 @@ pub fn resolve(
.into_iter()
.next()
});
defaults(project, presets, handle)
}
/// Resolve the defaults for a spawn pinned to one directory — the command
/// bar's `~` (#154), where the operator named the directory and nothing may
/// override it. Unlike [`resolve`] there is no recency fallback: a directory
/// the project scan does not know leaves `project` `None`, which is an
/// unresolvable default, so the keystroke opens the pre-filled form instead of
/// spawning somewhere else.
pub fn resolve_at(
dir: &std::path::Path,
projects: &[Project],
presets: &[SpawnPreset],
handle: Option<String>,
) -> FastForwardDefaults {
let project = projects.iter().find(|p| p.path == dir).cloned();
defaults(project, presets, handle)
}
/// The project's last command, or the first non-custom preset, wrapped up with
/// the rest of the defaults. Shared by both resolvers so the two entry points
/// can never settle on different commands.
fn defaults(
project: Option<Project>,
presets: &[SpawnPreset],
handle: Option<String>,
) -> FastForwardDefaults {
let command = project
.as_ref()
.and_then(|p| p.last_command.clone())
@@ -81,6 +108,7 @@ impl FastForwardDefaults {
return None;
}
Some(CreateSessionRequest {
view: Default::default(),
cwd: project.path.clone(),
command: command.to_string(),
env: Default::default(),
@@ -159,6 +187,46 @@ mod tests {
}
}
/// The command bar's `~` names the directory; nothing may override it.
#[test]
fn resolve_at_pins_the_directory_over_the_recency_ranking() {
let projects = vec![
project("/repos/fresh", 0, Some("aider")),
project("/repos/pinned", 30, Some("zsh")),
];
let resolved = resolve_at(
std::path::Path::new("/repos/pinned"),
&projects,
&[preset("claude", "claude")],
Some("otter".into()),
);
assert_eq!(
resolved.project.as_ref().map(|p| p.path.clone()),
Some(PathBuf::from("/repos/pinned"))
);
assert_eq!(resolved.command.as_deref(), Some("zsh"));
let req = resolved.build_request().expect("every default resolved");
assert_eq!(req.cwd, PathBuf::from("/repos/pinned"));
}
/// A directory the scan does not know resolves no project, so there is no
/// request to build — the caller opens the pre-filled form instead of
/// spawning somewhere else.
#[test]
fn resolve_at_takes_no_fallback_for_an_unknown_directory() {
let projects = vec![project("/repos/fresh", 0, Some("aider"))];
let resolved = resolve_at(
std::path::Path::new("/elsewhere"),
&projects,
&[preset("claude", "claude")],
Some("otter".into()),
);
assert_eq!(resolved.project, None);
// The preset still resolves; the missing project is what blocks it.
assert_eq!(resolved.command.as_deref(), Some("claude"));
assert_eq!(resolved.build_request(), None);
}
#[test]
fn resolves_every_default_from_the_most_recent_project() {
let projects = vec![
+70 -4
View File
@@ -387,6 +387,9 @@ pub struct ChordState {
held: Vec<String>,
last_press: Option<Instant>,
timeout: Duration,
/// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting
/// to be replayed as a fresh keystroke by the caller.
replay: Option<InputKey>,
}
impl ChordState {
@@ -395,6 +398,7 @@ impl ChordState {
held: Vec::new(),
last_press: None,
timeout,
replay: None,
}
}
@@ -404,6 +408,13 @@ impl ChordState {
/// binding; wait for the next key.
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
/// up in the keymap. The held buffer is cleared.
///
/// A held prefix that is itself directly bound (e.g. a leader key bound
/// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves
/// to that binding the moment the next key fails to extend it, rather
/// than swallowing the held key and losing its meaning — the new key
/// never belonged to the chord and is handed back via [`Self::take_replay`]
/// so the caller can process it as its own keystroke.
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
if let Some(t) = self.last_press {
if t.elapsed() > self.timeout {
@@ -428,11 +439,31 @@ impl ChordState {
if is_prefix {
self.held.push(key_s);
ChordResult::Pending
} else {
self.held.clear();
ChordResult::Resolved(candidate)
return ChordResult::Pending;
}
if known_bindings.contains(&candidate) {
self.held.clear();
return ChordResult::Resolved(candidate);
}
if !self.held.is_empty() {
let held_str = self.held.join(" ");
self.held.clear();
if known_bindings.contains(&held_str) {
self.replay = Some(key.clone());
return ChordResult::Resolved(held_str);
}
}
ChordResult::Resolved(candidate)
}
/// Take the key bumped out of an abandoned chord by the last [`Self::feed`]
/// call, if any. The caller feeds it through again as a brand new
/// keystroke.
pub fn take_replay(&mut self) -> Option<InputKey> {
self.replay.take()
}
}
@@ -733,6 +764,41 @@ mod tests {
assert_eq!(r, ChordResult::Resolved("g x".into()));
}
/// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and
/// `ctrl-a ctrl-k`) must still fire its own binding when the next key
/// doesn't complete the chord — the held key's meaning isn't lost, and
/// the new key comes back via `take_replay` for the caller to process on
/// its own (#156).
#[test]
fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
.into_iter()
.collect();
let r = chord.feed(&InputKey::ctrl_char('a'), &known);
assert_eq!(r, ChordResult::Pending);
let r = chord.feed(&InputKey::char('j'), &known);
assert_eq!(r, ChordResult::Resolved("ctrl-a".into()));
assert_eq!(chord.take_replay(), Some(InputKey::char('j')));
}
/// The full chord still resolves normally when the next key completes
/// it — no replay, no early fire of the shorter binding.
#[test]
fn a_completed_chord_off_a_bound_prefix_has_no_replay() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
.into_iter()
.collect();
chord.feed(&InputKey::ctrl_char('a'), &known);
let r = chord.feed(&InputKey::ctrl_char('k'), &known);
assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into()));
assert_eq!(chord.take_replay(), None);
}
#[test]
fn chord_state_lone_key_resolves_immediately() {
let mut chord = ChordState::new(Duration::from_secs(10));
+4
View File
@@ -484,6 +484,7 @@ mod tests {
let mut metadata = HashMap::new();
metadata.insert("name".into(), serde_json::Value::String(name.into()));
Session {
view: Default::default(),
id: Uuid::from_u128(id),
command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -500,6 +501,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
@@ -1250,6 +1252,7 @@ mod tests {
#[test]
fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session {
view: Default::default(),
id: Uuid::from_u128(1),
command: "vim".into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -1266,6 +1269,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
};
let sb = Sidebar {
sessions: vec![s],
+21 -8
View File
@@ -45,8 +45,21 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
`preventDefault`s it, since selecting all of a terminal pane does nothing
useful.
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
none of them needs one.
One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
`GET /api/info`, and the web binds that instead of its own constant (#159). A
daemon too old to send the field, or an unreachable `/api/info`, leaves the web
on `ctrl-a`. The section is named `[tui]` for history — it is the leader for
every client, not the TUI's alone.
The leader itself needs the same kind of way back: holding it consumes every
press, on both clients, even before anything is bound behind it. Pressing it
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
focused pane and clears the pending chord, the way tmux resolves a doubled
prefix key. This chord is also built from the configured leader, so a
rebound leader carries its own escape hatch with it.
Only Ctrl-K and the leader itself get an escape hatch. No other control
character is intercepted, so none of them needs one.
### Focus round-trip
@@ -145,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
ambiguous.
- Escape, or backspace at an empty query, returns to the result list **with the
previous query intact**. Drilling in and backing out is free.
- Each action raises the flow it already has: kill goes through the existing
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the
existing prompt pre-filled with the session's own name
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the
session's directory and command. The bar picks the action; it never
reimplements one.
- Each action raises the flow it already has: kill through the confirmation
both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
(`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
seeded from the session's directory and command. The bar picks the action; it
never reimplements one.
No actions beyond those three, and no action across more than one session.
+41 -5
View File
@@ -18,10 +18,14 @@ wizard, and cleans up — only the unified `maestro` binary ends up persisted in
**Updating is the same command.** Re-running `./install.sh` detects an existing
systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead
of `start`. No `--update` flag needed. **The restart keeps your sessions:** the unit sets
of `start`. No `--update` flag needed. **The restart keeps your terminal sessions:** the unit sets
`KillMode=process`, so systemd signals only the daemon — the tmux server (and
every session) survives, and the new daemon re-adopts them on start. A unit
written before this default lacks the line; re-run `./install.sh` to refresh it.
every tmux-backed session) survives, and the new daemon re-adopts them on
start. Structured sessions (an agent run as a daemon child over ACP) are not
tmux panes, so shutdown ends their active turn; the transcript survives and
the next daemon attempts one resume (`docs/RESTART.md#structured-sessions`). A
unit written before this default lacks the line; re-run `./install.sh` to
refresh it.
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
uses your already-configured git credentials for this host (HTTPS helper or
@@ -94,7 +98,10 @@ running a local daemon, for a host that only talks to another machine's.
The daemon unit carries the same `KillMode=process` as the wizard-written one,
so `systemctl --user restart maestro-daemon` leaves the tmux server and every
session alive.
terminal session alive. Structured sessions end their active turn on
shutdown and resume once on the next daemon: ACP state comes back via
`session/load`, while the event log only replays Maestro's own transcript
projection (`docs/RESTART.md#structured-sessions`).
## Thin builds for single-role hosts
@@ -136,7 +143,8 @@ cover every v1 action — only override what you want to change:
```toml
[tui]
theme = "dark" # see the theme list below
leader = "ctrl-a" # one byte that escapes Insert mode
leader = "ctrl-a" # one byte that escapes Insert mode; the web reads
# the same setting off /api/info
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
[tui.keymap.normal]
@@ -187,6 +195,34 @@ An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`.
## Structured sessions: `claude-code-acp`
A session spawned with `view: Structured` (#134) runs Claude Code as a child
of the daemon over the Agent Client Protocol instead of in a tmux pane. The
daemon resolves the adapter binary, `claude-code-acp`, on its `PATH` at spawn
time. Install it once per host:
```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
```
That puts `claude-code-acp` in `~/.local/bin`, which must be on the daemon's
`PATH` (a systemd user unit does not read your shell profile; set
`Environment=PATH=…` in the unit or install somewhere already on it). The
adapter needs a logged-in Claude Code (`claude /login`).
When the adapter is missing the spawn still creates the session, marked dead
with the install command above as its activity detail, so the sidebar says
what to run. The adapter is a child of the daemon: it ends with a `DELETE
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
itself. A daemon restart ends an in-flight structured turn, but preserves its
worktree, session record, and event-log transcript. The replacement daemon
starts a new adapter, resumes it with `session/load`, and replays the
transcript. Terminal sessions keep surviving restarts through tmux. See
`docs/STRUCTURED-VIEW.md` for the full contract — row kinds, projections, the
handshake, and the approval and recovery rules — and `docs/RESTART.md` for
what a client observes across the restart.
## The browser UI
The daemon serves the SPA off its own listener. Open
+2 -1
View File
@@ -46,7 +46,8 @@ Web client (`--preview` and friends in `web/src/styles.css`):
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge — see the tier ladder in
+39 -4
View File
@@ -2,10 +2,16 @@
Maestro is upgraded in place: the operator reinstalls the binary and systemd
restarts the daemon under every browser tab and TUI that is attached to it.
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
lose their sockets. This document is the contract for what a client does about
it — what the daemon publishes, how a client tells a restart from a blip, and
what the user sees while it happens.
tmux keeps **terminal** sessions alive across that (see `docs/INSTALL.md`),
but clients lose their sockets either way. This document is the contract for
what a client does about it — what the daemon publishes, how a client tells a
restart from a blip, and what the user sees while it happens.
A **structured** session (`docs/STRUCTURED-VIEW.md`) has no tmux pane behind
it to survive on: its agent is a child of the daemon process itself, so a
restart takes it down with the daemon. See
[Structured sessions](#structured-sessions) below for exactly what that
costs and what it doesn't.
## What the daemon publishes
@@ -68,6 +74,35 @@ resolves, so a blocked client is never a trapped one. It clears itself the
moment a probe succeeds — there is no "retry" button, because the client is
already retrying.
## Structured sessions
Everything above describes a **client's** view of a restart, and it holds for
a structured session exactly as written — the same three verdicts, the same
status indicator, the same overlay. What differs is what survives on the
**daemon** side, because a structured session's agent is a daemon child, not
a tmux pane:
- A restart **ends the active turn**. There is no adapter process left to
finish it, and no tmux pane keeping it alive underneath.
- The **transcript survives**, in the per-session event log
(`docs/STRUCTURED-VIEW.md#persistence-recovery-and-dead-sessions`) — a
graceful shutdown marks the session for one resume attempt. Recovery is
two separate operations, not one: the next daemon calls `session/load` so
the adapter restores its own ACP session, but whatever history the
adapter emits during that call is discarded, not replayed into anything —
the daemon already has that history on disk. Separately, the daemon
rebuilds its own projection by folding the persisted event log back
through it. An ungraceful death (a crash, `kill -9`) leaves no resume
marker, and the session stays dead permanently.
- **Pending approvals and their nonces do not survive.** Both live only in
the old daemon's memory; any ACP permission request still mid-flight when
the daemon exits is answered `Cancelled` on the way down, and a resumed
session starts with no pending approval at all.
`docs/STRUCTURED-VIEW.md` is the full contract for all of this — the event
log's shape, the recovery marker, and exactly which kind of session death is
resumable.
## Timing
- **Grace window** — 1200 ms between the socket dropping and the overlay
+25 -5
View File
@@ -189,7 +189,7 @@ furthest down wins the paint.
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
| Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) |
The web ladder is written as one ordered run of equal-specificity rules in
`styles.css`, every qualifier inside `:where()`, so the order they appear in
@@ -250,6 +250,10 @@ Two rulings specific to a reported `Error` (#114):
process changes do not clear it — only the agent itself may withdraw what
the agent itself reported. Re-reporting `Error` extends the TTL rather than
stacking reports.
- **Only Claude Code reports `Error`.** Codex has no counterpart to
`StopFailure` in its hook event set, so a codex turn that ends on an API
error reports `AwaitingInput` and its card carries no error mark. A card
without the mark means "nothing reported a failure", not "nothing failed".
If a client receives an `ActivityState` variant it does not know, it must not
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
@@ -303,9 +307,10 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
same chord back): an attached terminal owns every plain keystroke. The TUI uses
`ctrl-w h`.
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
by default — and the same chord back): an attached terminal owns every plain
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
uses `ctrl-w h`.
Everything else is the same key on both clients:
@@ -316,7 +321,7 @@ Everything else is the same key on both clients:
| `Tab` | hand the keyboard to the pane |
| `c` | collapse or expand the highlighted folder |
| `r` | rename the highlighted session |
| `x` | kill the highlighted session |
| `x` | kill the highlighted session (confirms) |
| `n` | open the spawn form |
| `N` | spawn with every default resolved |
| `?` | show the shortcut overlay |
@@ -324,3 +329,18 @@ Everything else is the same key on both clients:
The TUI binds these through its keymap, so a user can rebind any of them; the
web client's are fixed. A binding added to one client belongs in this table and
in both help overlays.
## Killing a session
**Every kill confirms first, in both clients** (#160). A kill is destructive
and unattended agents live in these sessions, so neither client sends
`DELETE /api/sessions/:id` off a single press. The TUI raises
`OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its
`✕` button and the command bar's action page both use. Cancel holds the focus
in the web's dialog, so nothing kills on a stray Enter.
The confirmation names what the card last saw in the way, when anything was —
the daemon will refuse an unforced kill over such a worktree, and saying so
before the attempt is cheaper than a refusal. What comes back from that refusal
is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns
what a forced kill leaves behind.
+359
View File
@@ -0,0 +1,359 @@
# STRUCTURED-VIEW.md — the structured session contract
The contract for a structured session (`view: Structured`, #133): an agent run
as a daemon child over the Agent Client Protocol (ACP) instead of in a tmux
pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
read this before changing anything that renders a structured session — both
the web and TUI renderers are written against it.
It covers the eight transcript row kinds, what the daemon decides versus what
a client decides, the two projections and their delta/cursor semantics, the
ACP handshake and the capabilities it declines, the adapter's install
command, and the approval nonce rules. **Non-goal: the detached runner.** It
does not exist yet, and nothing here describes it.
## What a structured session is
A terminal session's agent runs inside a tmux pane; a structured session's
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
daemon is the ACP **client**; the adapter process, `claude-code-acp`, is the
ACP **agent** — it wraps Claude Code's own SDK and translates the model's
tool calls into ACP `session/update` notifications instead of drawing them to
a pane. `crates/maestro-acp` owns the daemon's side of that connection;
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two
projections below.
The child is a real child of the daemon process: same process group, and on
Linux marked with `PR_SET_PDEATHSIG` so it dies with the daemon even if the
daemon exits without cleaning up; other platforms have no such signal and
depend on an orderly shutdown/drop to terminate it. The ordinary path on
every platform is `AcpSession::terminate` (`session/cancel`, a short grace,
`SIGTERM`, then `SIGKILL`), called from both `DELETE /api/sessions/:id` and
daemon shutdown.
`docs/WORKTREES.md`'s kill-guard rule — an unforced kill refused while the
session's worktree holds unsaved work — applies to a structured session
exactly as it does to a terminal one; only what gets terminated differs (an
ACP handle instead of a tmux pane).
## The adapter
`ADAPTER_BIN` (`claude-code-acp`) is resolved by walking `PATH` — the
session's own `PATH` env var if the spawn request set one, the daemon's
otherwise — and taking the first executable file named `claude-code-acp` on
it. Install it once per host:
```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
```
That is `maestro_acp::INSTALL_COMMAND`, and it is not just documentation: it
is baked into `AcpError::AdapterMissing`'s message. When the adapter cannot
be found, spawn still creates the session — **Dead**, with that install
command as its `activity_detail` — so the sidebar names the fix instead of
the session silently never appearing (see `docs/INSTALL.md` for the full
install writeup, including the systemd `PATH` caveat). The adapter also needs
a logged-in Claude Code (`claude /login`); a missing login surfaces the same
way, via the adapter's own stderr tail in the failure message.
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
slow machine to boot a node process and the Claude Code SDK behind it, never
long enough to hang a spawn indefinitely. Any handshake failure — missing
adapter, early exit, a protocol error, or the timeout — leaves no orphaned
process: `spawn` closes and reaps the child itself before returning the
error.
## The ACP handshake
`initialize` is where the daemon states what kind of client it is. It
declares, deliberately, almost nothing:
| Capability | Declared | Why |
| --- | --- | --- |
| `fs.readTextFile` | `false` | The adapter uses Claude Code's own Read tool instead of asking the daemon to read a file on its behalf — the same tool a terminal-view Claude Code session uses. |
| `fs.writeTextFile` | `false` | Same reasoning, for Write. |
| `terminal` | `false` | The adapter runs Claude Code's own Bash tool rather than asking the daemon to open a terminal for it. |
Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because `claude-code-acp` has
never asked for it, not because it was weighed and refused.
The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below.
Before #139 it was a stub that declined every request; that stub is gone now
that a live implementation exists.
`session/new` opens a fresh ACP session on every ordinary spawn. A respawn
after a daemon restart instead sets `SpawnConfig::load_session(id)` and runs
`session/load` with the ACP session id the original handshake returned
(`Session::acp_session_id` on the wire) — the adapter must advertise
`agent_capabilities.load_session`, or the handshake fails outright naming
that. Whatever history the adapter streams back while `session/load` is in
flight is dropped on arrival, deliberately: the daemon's own event log
already holds that history, and forwarding it too would duplicate every row
in the projections below.
## The eight transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
transcript is written in. Every row has a stable `id` (so later deltas can
address it), a `kind`, and a `data: Value` that preserves the underlying ACP
payload — clients depend on ACP's *shape* without depending on ACP as a
crate.
| Kind | Carries | Row identity |
| --- | --- | --- |
| `User` | An echoed `user_message_chunk` — the prompt text as ACP saw it. | `user:<message_id>`, or `user:<seq>` when the chunk carries no id. |
| `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>` / `agent:<seq>`. Later chunks for the same id are appended into `data.content.text` in place, not appended as new rows. |
| `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / `thought:<seq>`, coalesced the same way as `AgentText`. |
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
| `Notice` | The raw JSON of any `session/update` variant the projection does not special-case. | `notice:<seq>`. |
`Notice` is the projection's forward-compatibility seam: ACP adds
notification variants over time, and an unrecognised one becomes a generic
row instead of being silently dropped or making the daemon reject the whole
stream. A client that does not know what to do with a `Notice` can render it
as an opaque, collapsed line — that is a reasonable floor, not a client bug.
Rendering is entirely a client concern. The projection's job stops at
folding ACP into these eight shapes; markdown formatting, diff rendering,
tool-card treatment, and how a `Notice` is presented are all renderer
choices, not daemon ones.
## Daemon decisions vs. client decisions
**The daemon decides:**
- Which ACP `session/update` becomes which row kind, and under what id — the
fold in `projection.rs` is the one place this logic lives, so the web and
TUI renderers can never disagree about it.
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
or a failure ends it (`TurnState.responding` / `.thinking` /
`.active_tool_calls` are derived, not renderer-computed).
- Approval nonce minting, secrecy, and the single-active-approval rule (see
below) — a client never sees an ACP permission request before the daemon
has already turned it into an `Approval` row and a nonce.
- What happens to an ACP update variant nobody has taught the daemon about
yet (falls back to `Notice` rather than erroring).
- Persistence and replay: what is written to the event log, in what order,
and how a restart resumes it.
**A client decides:**
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
diff view for `ToolDiff`, a tool card for `ToolCall`, a checklist for
`Plan`, and a floor presentation for `Notice`.
- How `responding` vs. `thinking` are distinguished visually, if at all.
- How the operator is asked to resolve an `Approval` row — the daemon only
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
never the UI that solicits it.
- Scrolling, collapsing, and every other presentation-only behaviour.
## The two projections
`Projection` (daemon-only) is folded once from the durable ACP stream and
exposes two independent views, both defined in `maestro-protocol`:
- **`TranscriptSnapshot { cursor, rows }`** — the complete ordered list of
transcript rows built so far, plus the log position (`cursor`) it reflects.
It is intentionally always the *whole* transcript, not a windowed one: a
tool row patched much later must still find its original entry.
- **`ReducedState`** — one whole-session snapshot folded over every event
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
always full-history, never a delta — a client that only wants "is a turn
active right now" never has to replay anything to answer that.
Changes to the transcript travel as `TranscriptDelta`, tagged `type`:
`Append { row }` (a new row), `Patch { row }` (an existing row, addressed by
`id`, replaced wholesale), and `Remove { id }`. `Remove` is defined on the
wire for forward compatibility; nothing in the current projection emits it —
every row, once appended, is only ever patched or left alone.
### Cursor and replay semantics
The event log's own line position is the cursor: 1-based, shared by ACP
notifications and daemon-generated rows alike (`sidecar.rs`'s
`PersistedEvent` envelope is what lets an `Approval` row share a cursor
sequence with raw ACP JSON without pretending to be ACP traffic). Two ways to
read it:
- **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and
a `ReducedState` covering the whole log as it stands, then streams
`ProjectionUpdate { transcript, state }` for everything that happens next.
There is no client-supplied resume cursor on this socket: every connection
gets the full snapshot, not a gap-filled continuation, so a client never
needs the replay endpoint just to obtain the current transcript. Use
replay instead for cursor-bounded pagination through the durable log —
history from before this connection, fetched a page at a time.
- **`GET /api/sessions/:id/structured/replay`** — cursor-bounded pagination
over the durable log, returning one `StructuredReplayEvent` (`cursor`,
`transcript`, `state`) per matching line. `?since=<n>` returns the first
page after `n` (`since=0` selects from the beginning, i.e. the first
page); a client pages forward from there using the cursor of the last
event returned. `?before=<n>` returns the page immediately before `n`,
still in ascending order — passing both is a `400`. `?limit` defaults to
100 and is clamped to 500. Each call re-folds
the entire log from scratch to compute the state at every returned cursor,
so a page reflects exactly the *durable* state at its cursor, not an
approximation of it — at the cost of redoing the whole fold per request.
That durable state is not always identical to what a live client saw at
that moment, though: turn lifecycle transitions are broadcast live without
a durable event backing them (see [Prompt and cancel](#prompt-and-cancel-bounded-liveness)),
and a restored `Approval` row carries no pending nonce, because the nonce
never reached disk (see [Approvals](#approvals)) — replay is exact and
reconstructible, not a perfect record of everything the live view showed.
A session that is not a live structured session (terminal, or a dead
adapter) answers `409 not_structured` from either route rather than a
coherent-looking but stale reply.
## Approvals
An ACP `session/request_permission` callback becomes an `Approval` row plus a
pending decision, entirely on the daemon side:
- **Generation.** The daemon mints the nonce itself — a fresh
`Uuid::new_v4()` string per request — rather than using anything ACP's own
request carries. ACP permission callbacks have no request id of their own
that would double as one.
- **Live-only placement.** The nonce lives in exactly one place: the
in-memory `pending_approvals` map keyed by nonce, and its mirror in
`ReducedState.pending_approval.nonce` on the live wire. It is never part of
the `Approval` row's `data` — the row is `RequestPermissionRequest`
serialized *without* the nonce.
- **Secrecy from the agent and from durable history.** The adapter itself
never learns the nonce — it is a daemon-to-client secret, never sent back
over the ACP connection. It is likewise never written to the event log:
restoring an `Approval` row from disk (`Projection::restore_approval`)
produces a historical row with no `pending_approval` in state, because the
nonce that would make it "pending" never made it to disk in the first
place. A restart therefore cannot resume a pending approval — see
[Persistence](#persistence-recovery-and-dead-sessions).
- **One live approval at a time.** A new permission request mints its own
nonce, then cancels whatever was already pending (`cancel_pending_approvals`)
before installing itself as the new pending approval — ACP serializes
requests through one connection, so this only ever fires if an adapter
nonetheless overlaps two. Either way, the prior pending request is
cancelled/cleared before the new one is installed, so the newest request
wins.
- **Single-use and duplicate resolution.**
`POST /api/sessions/:id/structured/approvals/:nonce` **always answers
`204`** for a known, live structured session — whether the nonce is the
one currently pending, one already consumed by an earlier answer, or one
that never existed. Only an unknown or non-structured *session* answers
otherwise (`404` / `409`). This is deliberate: two clients racing to answer the same
approval must not turn a harmless duplicate click into a client-visible
error. Whichever `POST` reaches the daemon first removes the nonce from
`pending_approvals` and is the one whose decision actually reaches the
adapter; every later `POST` for that nonce is a no-op `204`.
- **Cancellation vs. denial.** A turn cancel or a session shutdown resolves
any pending approval with ACP's `Cancelled` outcome, not a `Deny` decision
— the adapter should read those as "the operation was withdrawn," not "the
operator refused it."
- **Decision mapping.** `AllowOnce` and `AllowForSession` select the ACP
option of kind `AllowOnce` / `AllowAlways` from the request's own option
list. `Deny` prefers a one-shot refusal (`RejectOnce`) and falls back to a
permanent one (`RejectAlways`) only when that is the sole rejection option
offered; if neither is present, it answers `Cancelled`.
## Prompt and cancel: bounded liveness
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
against each other through one per-session `operation` lock
(`AcpEventStream::operation`), because ACP permission callbacks carry no
per-request id — the daemon's own notion of "the active turn" is the only
thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn."
- A prompt claims the turn and dispatches it to ACP as one operation. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing — one active turn per session, never a hidden queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
prompt claiming "the active turn" while the old one is still outstanding
would make the next permission callback ambiguous between the two.
- So a cancelled-but-still-dispatched turn must **settle within
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
resolves) or that grace period runs out. If it settles in time, the next
prompt proceeds normally. If it does not, the daemon does not guess: it
marks the whole structured session dead (`fail_cancelled_acp_turn`) rather
than risk a callback resolving against the wrong turn. This is the bounded
half of "bounded liveness" — a structured session's prompt/cancel dance
either resolves promptly or the session is declared unusable, never left
in an indefinite ambiguous state.
## Persistence, recovery, and dead sessions
A structured session's durable state is three files in the sidecar
directory: the sidecar itself (`<id>.json`), plus two next to it:
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
ACP `SessionNotification` (kept in its original JSON so old logs stay
readable) or a `{"maestroEvent": "approval", "row": …}` envelope for a
daemon-generated row. Both share the same 1-based line-position cursor. A
line a hard kill left half-written is terminated before the next append, so
it keeps its position and is skipped as malformed rather than corrupting
what follows.
- **`<id>.recover`** — an empty marker file. Its only meaning is "the daemon
that wrote this shut down gracefully while this session was running, and
the next daemon may attempt to resume it." It is consumed (deleted) the
moment a recovery attempt reads it — one recovery attempt per graceful
shutdown, never a standing flag.
Three ways a structured session ends up `Dead`, each with different
persistence consequences:
1. **The adapter exits, or a notification append to the event log fails,
while the daemon keeps running.** (An approval-row append failure is
narrower: it cancels the pending approval and lets the session continue,
without marking it dead.) `mark_session_dead` applies the same
natural-exit worktree rule
as a terminal session's own pane exiting (`docs/WORKTREES.md`): a pristine
worktree is removed, one holding unsaved work is left on disk. No recovery
marker is written. This death is permanent — a later daemon restart's
`recover()` finds the sidecar, sees no marker, and leaves the session
`Dead` without attempting to resume it.
2. **A cancelled turn fails to settle within `CANCEL_GRACE`.** Same
consequence as (1): the session is marked dead through the same path, no
marker, no future resumption.
3. **Graceful daemon shutdown**, while the session was still `Running`.
`mark_session_dead_after_shutdown` marks the session dead **without**
applying the worktree exit rule — the tree is kept exactly as it is,
pristine or not, because there is nothing "natural" about this exit — and
writes the `.recover` marker. The sidecar on disk therefore says `Dead`,
but is eligible for resumption.
Recovery, on the next daemon's `recover()`: for every `Structured` sidecar
not already adopted, the marker is consumed. When one was present and the
sidecar carries a stored `acp_session_id`, the daemon spawns a fresh adapter
with `SpawnConfig::load_session(acp_session_id)`, replays the
entire durable event log into a new `Projection` (rebuilding the transcript
and reduced state exactly as they were, with any restored `Approval` row
landing as history — never as a re-armed pending approval, since its nonce
never reached disk), and only then reopens the log for further appends. What
the adapter streams back while its own `session/load` is in flight is
discarded rather than appended, so the replayed history is not duplicated
(see [The ACP handshake](#the-acp-handshake)). Success flips the session back
to `Running`/`Active`; failure (no stored `acp_session_id`, or the handshake
itself failing) leaves it `Dead` with the failure reason as
`activity_detail` — the same "tell the operator what to fix" pattern as a
missing adapter binary.
Pending approvals and their nonces never survive any of this. They live only
in the in-memory `pending_approvals` map and the live `ReducedState`, both
gone the instant the daemon that held them exits. Whatever ACP permission
request was mid-flight when the old daemon died was already answered
`Cancelled` on that daemon's own shutdown path, so a resumed session always
starts with no pending approval.
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
now that structured sessions exist alongside terminal ones.
+12 -4
View File
@@ -35,10 +35,18 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
back to a short hash of the branch.
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
a free one. The suffix names the **directory only**. The branch is whatever was
requested; a second worktree of the same branch is not what happens here, because
git refuses to check the same branch out twice and the branch picker already
marks branches that are checked out elsewhere.
a free one.
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
itself, and the directory follows since it is derived from the branch — a
random handle can otherwise reuse the name of a branch some earlier, already
removed worktree left behind, and `git worktree add -b` refuses outright even
though the directory is free.
For an *existing* branch (`create_branch: false`) the name must stay exact, so
only the directory is suffixed — a second worktree of the same branch is not
what happens here, because git refuses to check the same branch out twice and
the branch picker already marks branches that are checked out elsewhere.
### The `worktree_root` override
+14 -20
View File
@@ -4,8 +4,6 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -14,15 +12,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
@@ -50,6 +39,10 @@ export interface AttachConnection {
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
focusTerminal(): void;
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
* handling — the leader's literal Ctrl-K escape hatch
* (docs/COMMAND-BAR.md). */
sendInput(data: string): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
@@ -217,15 +210,6 @@ export function createAttachConnection(
}
}
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
@@ -466,6 +450,12 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
@@ -506,6 +496,9 @@ export function createAttachConnection(
focusTerminal() {
term.focus();
},
// The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take.
sendInput,
dispose() {
if (disposed) return;
disposed = true;
@@ -518,6 +511,7 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
+139
View File
@@ -0,0 +1,139 @@
// @vitest-environment happy-dom
//
// The sidebar's half of the command bar's action page (#151): the bar picks
// kill or rename and raises the flow the card already has, so what the page
// runs is what the ✕ button and the `r` key run.
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
});
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
mounted?.dispose();
mounted = null;
document.body.replaceChildren();
});
function session(overrides: Partial<Session> = {}): Session {
return {
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: { name: "amber-falcon" },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
function mount(s: Session): HTMLElement {
const handle = renderSidebar(storeWith([s]));
mounted = handle;
document.body.appendChild(handle.el);
return handle.el;
}
function raise(name: string, s: Session): void {
document.dispatchEvent(new CustomEvent(name, { detail: s }));
}
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("kill from the action page", () => {
it("goes through the same confirmation the sidebar raises (#160)", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const s = session();
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
"Kill amber-falcon",
);
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
window.fetch = ((url: string) => {
if (String(url).includes("force=true")) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const s = session({ blocked_work: { untracked_files: 1 } });
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
expect(dialog?.textContent).toContain("1 untracked file");
});
});
describe("rename from the action page", () => {
it("expands the folder hiding the card, since the prompt lives in the card", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
folder?.click();
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
raise(RENAME_SESSION_EVENT, s);
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
});
it("raises the card's own prompt, pre-filled with the session's name", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
raise(RENAME_SESSION_EVENT, s);
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
expect(input?.value).toBe("amber-falcon");
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
});
});
+40 -1
View File
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
});
it("kills straight away, without a first confirmation", async () => {
it("confirms before an unguarded kill (#160)", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("kills nothing when the confirmation is cancelled (#160)", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
await flush();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
});
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000008",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
expect(dialog?.textContent).toContain("refused unless it is forced");
});
it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
+115 -44
View File
@@ -1,12 +1,13 @@
import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js";
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js";
import { CommandSet, defineCommands } from "./commands.js";
import { CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
import {
abbreviateCwd,
abbreviateFolderName,
blockerStats,
customSessionName,
@@ -16,6 +17,7 @@ import {
sessionBlockers,
isProvisionalName,
sessionName,
sessionTreePath,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
@@ -37,19 +39,35 @@ export function requestOpenSpawn(): void {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
}
/** Ask the shell to open the command bar with an empty query. Dispatched by
* the sidebar's search field, which is the touch entry point to the bar
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
* Handled in main.ts, which owns the session snapshot the bar needs. */
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
export function requestOpenCommandBar(): void {
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
}
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
* what Tab in the sidebar list does. Handled in main.ts, which owns the
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** Ask the sidebar to kill a session — the command bar's action page picking
* `kill` (#151). Detail is the session. Handled here, so the kill goes
* through the same confirm-and-blockers flow the card's ✕ does
* (`docs/WORKTREES.md`); the bar never reimplements it. */
export const KILL_SESSION_EVENT = "catalog-kill-session";
/** Ask the sidebar to rename a session — the command bar's action page picking
* `rename` (#151). Detail is the session. The prompt is the card's own inline
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
export const RENAME_SESSION_EVENT = "catalog-rename-session";
/** A session row was clicked (issue #133). The row's `href` drives normal
* hash navigation, but a split/drag can move focus without ever touching
* `location.hash` — so a click back onto a session whose hash still
@@ -208,7 +226,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
void requestKill(cur, sessionName(cur));
requestKill(cur, sessionName(cur));
});
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
@@ -226,7 +244,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
{
label: "Kill session",
danger: true,
onSelect: () => void requestKill(cur, sessionName(cur)),
onSelect: () => requestKill(cur, sessionName(cur)),
},
]);
});
@@ -271,26 +289,33 @@ function treeSignature(node: TreeNode): string {
}
/**
* Kill on the first click (#81): the daemon is the one that decides whether a
* session may go, and it refuses over a worktree holding unsaved work. Only
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
* a forced kill. Anything else surfaces as an alert.
* Kill behind a confirmation (#160): a kill is destructive and unattended
* agents live in these sessions, so the web asks first, the way the TUI always
* has. The daemon decides the rest — it refuses a kill over a worktree holding
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
* a forced kill. Anything else surfaces in the dialog that asked.
*/
async function requestKill(s: Session, name: string): Promise<void> {
try {
await killSession(s.id);
} catch (e) {
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
openForceKillDialog({
name,
blockers: e.unsavedWork ?? sessionBlockers(s),
reason: e.detail,
onForce: () => killSession(s.id, true),
});
return;
}
alert(`Failed to kill: ${(e as Error).message}`);
}
function requestKill(s: Session, name: string): void {
openKillConfirmDialog({
name,
blockers: sessionBlockers(s),
onKill: async () => {
try {
await killSession(s.id);
} catch (e) {
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
openForceKillDialog({
name,
blockers: e.unsavedWork ?? sessionBlockers(s),
reason: e.detail,
onForce: () => killSession(s.id, true),
});
return;
}
throw e;
}
},
});
}
/**
@@ -348,16 +373,12 @@ function beginRename(row: HTMLElement, s: Session): void {
input.select();
}
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
// The tree has one empty state: no sessions at all. The old "No matches" one
// belonged to the inline substring filter, which the command bar replaced
// (#157) — the bar's own no-results state took over with it.
function renderEmpty(onSpawn: () => void): HTMLElement {
const el = document.createElement("div");
el.className = "empty";
if (filtering) {
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌕</div>
<div class="empty-title">No matches</div>
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
return el;
}
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
@@ -428,12 +449,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
// renders with absolute paths until it lands.
let homeDir: string | undefined;
// The field never holds a query: it is the touch entry point to the command
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
// on-screen keyboard over a field nothing is typed into.
const searchHeader = document.createElement("header");
searchHeader.className = "sidebar-search";
searchHeader.innerHTML = `
<input
type="search"
class="search"
readonly
placeholder="Search sessions…"
aria-label="Search sessions"
autocomplete="off"
@@ -542,11 +567,23 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
// Touch entry (#157): the field is a button that looks like a field. It
// never holds a query — focusing or tapping it hands the keyboard straight
// to the bar's own input, which is also why it blurs first: leaving the
// focus here would make the bar impossible to close without reopening it.
const search = searchHeader.querySelector(".search") as HTMLInputElement;
let filter = "";
search.addEventListener("input", () => {
filter = search.value;
refresh();
const openBar = () => {
search.value = "";
search.blur();
requestOpenCommandBar();
};
search.addEventListener("focus", openBar);
// A tap can land without a focus event (iOS Safari on a blurred field), so
// the pointer path opens the bar too; the blur above keeps the two from
// stacking two overlays.
search.addEventListener("pointerdown", (e) => {
e.preventDefault();
openBar();
});
let highlights: SidebarHighlights = { open: new Set(), focused: null };
@@ -685,7 +722,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
},
"list.kill": () => {
const hit = highlightedSession();
if (hit) void requestKill(hit.session, sessionName(hit.session));
if (hit) requestKill(hit.session, sessionName(hit.session));
},
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
}),
@@ -703,8 +740,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
if (renamingId !== null) return;
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, homeDir, filter);
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
const tree = buildTree(sessions, homeDir);
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
if (signature === renderedSignature) {
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
@@ -715,9 +752,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
renderedSignature = signature;
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
);
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
return;
}
if (tree.kind === "folder") {
@@ -745,6 +780,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
};
document.addEventListener("catalog-rerender", rerenderHandler);
// The command bar's action page (#151) picks an action and hands it here:
// both flows are the ones the card already has, reached by session rather
// than by row.
const killHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
requestKill(s, sessionName(s));
};
const renameHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
const row = rowForSession(s);
if (row) beginRename(row, s);
};
document.addEventListener(KILL_SESSION_EVENT, killHandler);
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
/** The card a session is rendered as, expanding whatever folder hides it
* first: the rename prompt lives inside the card, so a collapsed folder
* would otherwise swallow the request. */
function rowForSession(s: Session): HTMLElement | null {
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
const found = find();
if (found) return found;
// Folder paths are the abbreviated directory's segments joined back up
// (see buildTree), so the session's own path is normalised the same way
// before it is compared against them.
const cwd = sessionTreePath(s);
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
for (const folder of [...collapsed]) {
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
}
rerenderHandler();
return find();
}
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
// multi-view controller can seed the spawn modal with the session's dir/command.
@@ -794,6 +863,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
// refusing to refresh.
renamingId = null;
document.removeEventListener("catalog-rerender", rerenderHandler);
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
unsubscribe();
},
};
+215 -1
View File
@@ -1,7 +1,12 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { SESSION_SELECT_EVENT } from "./catalog.js";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import {
barGroups,
barRows,
@@ -15,6 +20,7 @@ import {
orderSessionsForBar,
parseBarQuery,
sessionBarCandidates,
sessionActionCandidates,
sessionCandidate,
SPAWN_DIRECTORY_EVENT,
} from "./commandBar.js";
@@ -507,3 +513,211 @@ describe("the sigils in the overlay", () => {
expect(seen).toEqual(["/home/me/dev/api"]);
});
});
describe("the focus round-trip over a pane (#152)", () => {
function terminalTextarea(): HTMLTextAreaElement {
const ta = document.createElement("textarea");
ta.className = "xterm-helper-textarea";
document.body.appendChild(ta);
return ta;
}
it("hands the keyboard back to the terminal that had it", () => {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
press("Escape");
expect(document.activeElement).toBe(ta);
});
it("reports no focus change to the daemon, opening or closing", () => {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
try {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
press("Escape");
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
String(url).includes("/focus"),
);
expect(focusCalls).toEqual([]);
} finally {
vi.unstubAllGlobals();
}
});
});
describe("the session action page (#151)", () => {
const sessions = [
session("api", {
metadata: { name: "api" },
current_cwd: "/home/me/dev/api",
last_focused_at: "2026-01-01T11:00:00Z",
}),
session("worker", {
metadata: { name: "worker" },
current_cwd: "/home/me/dev/worker",
last_focused_at: "2026-01-01T10:00:00Z",
}),
];
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
return openCommandBar({
sessions,
homeDir: "/home/me",
commands: [],
onOpenSession: () => {},
...over,
});
}
function headerText(): string | undefined {
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
}
it("lists kill, rename and spawn similar for the highlighted session", () => {
open();
press("Tab");
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
expect(activeName()).toBe("Kill session");
});
it("names the session being acted on", () => {
open();
press("ArrowDown");
press("Tab");
expect(headerText()).toContain("worker");
});
it("drops the kind headers, since the page is one kind", () => {
open();
press("Tab");
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("stays fuzzy-searchable over the three actions", () => {
open();
press("Tab");
type("ren");
expect(rowNames()).toEqual(["Rename session"]);
type("spwn");
expect(rowNames()).toEqual(["Spawn similar"]);
type("zzz");
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
});
it("does nothing on a command row", () => {
openCommandBar({
sessions,
homeDir: "/home/me",
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
onOpenSession: () => {},
});
type(">");
const before = rowNames();
press("Tab");
expect(rowNames()).toEqual(before);
expect(headerText()).toBeUndefined();
});
it("does nothing on a directory row", async () => {
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
type("~");
await Promise.resolve();
press("Tab");
expect(rowNames()).toEqual(["~/dev/api"]);
expect(headerText()).toBeUndefined();
});
it("returns to the result list on Escape, with the previous query intact", () => {
open();
type("wor");
press("Tab");
expect(headerText()).toContain("worker");
press("Escape");
expect(isCommandBarOpen()).toBe(true);
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("returns to the result list on backspace at an empty query", () => {
open();
type("wor");
press("Tab");
type("");
press("Backspace");
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("keeps backspace ordinary while the page's query has text", () => {
open();
press("Tab");
type("ren");
press("Backspace");
expect(headerText()).toContain("api");
expect(rowNames()).toEqual(["Rename session"]);
});
it("closes the bar when the page's Escape has already been used", () => {
open();
press("Tab");
press("Escape");
press("Escape");
expect(isCommandBarOpen()).toBe(false);
});
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
const seen: string[] = [];
for (const [hook, key] of [
["onKillSession", "Kill session"],
["onRenameSession", "Rename session"],
["onSpawnSimilar", "Spawn similar"],
] as const) {
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
type("wor");
press("Tab");
type(key);
press("Enter");
expect(isCommandBarOpen()).toBe(false);
}
expect(seen).toEqual([
"onKillSession:worker",
"onRenameSession:worker",
"onSpawnSimilar:worker",
]);
});
it("hands each action to the flow it already has, by default", () => {
const seen: string[] = [];
const listeners: Array<[string, EventListener]> = [
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
];
for (const [name, fn] of listeners) document.addEventListener(name, fn);
try {
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
open();
press("Tab");
type(action);
press("Enter");
}
} finally {
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
}
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
});
it("offers no action beyond the three", () => {
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
"kill",
"rename",
"spawn-similar",
]);
});
});
+152 -10
View File
@@ -2,11 +2,16 @@
// list, opened from the sidebar, closed with Escape.
//
// Three kinds live here: sessions and commands, ranked together by default,
// and directories, which need the `~` sigil. The action page and the
// terminal-focus binding land in #151-#152.
// and directories, which need the `~` sigil. `tab` on a session drills into
// its action page (#151), which is the same overlay over three actions.
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
import { SESSION_SELECT_EVENT } from "./catalog.js";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import { keyCaps, publishedCommands, type Command } from "./commands.js";
import {
matchAndRank,
@@ -30,7 +35,13 @@ export type CommandBarCandidate = CommandCandidate & { command: Command };
* ranks; `path` is the one a spawn is made in. */
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
/** One row of a session's action page. It is a `command` to the matcher — a
* single `label` field, ranked exactly like a registry command — and carries
* the action it stands for instead of a registry entry. */
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
export type BarCandidate =
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
export type BarResult = MatchResult<BarCandidate>;
/** Raised when Enter lands on a directory row. The bar picks the directory; the
@@ -73,6 +84,40 @@ export function parseBarQuery(raw: string): BarQuery {
return { sigil: null, query: raw };
}
// ---- the session action page ----
/** The actions a session's action page offers, and no others: the bar picks
* one and hands off to the flow that already implements it
* (docs/COMMAND-BAR.md, "Drill-in"). */
export type SessionActionId = "kill" | "rename" | "spawn-similar";
export interface SessionAction {
id: SessionActionId;
label: string;
}
export const SESSION_ACTIONS: readonly SessionAction[] = [
{ id: "kill", label: "Kill session" },
{ id: "rename", label: "Rename session" },
{ id: "spawn-similar", label: "Spawn similar" },
];
/** The action page's candidates, in the order above — which is the order an
* empty query lists them in, since the matcher keeps input order. */
export function sessionActionCandidates(): ActionBarCandidate[] {
return SESSION_ACTIONS.map((action) => ({
kind: "command",
action,
fields: { label: action.label },
}));
}
/** Narrow a `command` candidate to an action row. Both share the matcher's
* `command` kind, so what separates them is what they carry. */
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
return candidate.kind === "command" && "action" in candidate;
}
// ---- candidates ----
function focusedAt(s: Session): number | null {
@@ -306,6 +351,8 @@ function renderRow(result: BarResult, index: number): HTMLElement {
if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") {
row.dataset.commandId = candidate.command.id;
} else {
@@ -322,7 +369,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
// The chord is teaching material, not a match target: it is rendered from
// the live registry entry and never fed to the matcher.
if (candidate.kind === "command") {
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
const keys = document.createElement("span");
keys.className = "cmdbar-row-keys";
for (const cap of keyCaps(candidate.command.keys[0])) {
@@ -374,6 +421,13 @@ export interface CommandBarOptions {
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
* spawn. */
onSpawnDirectory?: (path: string) => void;
/** The action page's three actions (#151). Each defaults to raising the
* event the flow that already implements it listens on — the sidebar's
* confirm-and-blockers kill, its inline rename prompt, the spawn form
* seeded from the session. The bar picks the action and nothing more. */
onKillSession?: (session: Session) => void;
onRenameSession?: (session: Session) => void;
onSpawnSimilar?: (session: Session) => void;
}
interface OpenBar {
@@ -406,6 +460,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
opts.onSpawnDirectory ??
((path: string) =>
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
const sessionEvent =
(name: string) =>
(session: Session): void => {
document.dispatchEvent(new CustomEvent(name, { detail: session }));
};
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
const listEntries =
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
@@ -450,6 +512,13 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
let rows: BarResult[] = [];
let highlighted = 0;
// The action page (#151). `page` is the session being acted on, `stashed`
// the result-list query it was drilled into from — backing out restores it,
// so the round trip costs the operator nothing.
let page: Session | null = null;
let stashed = "";
let header: HTMLElement | null = null;
// `~`'s candidates: the recents-and-projects list, fetched once, plus
// whatever `/api/dirs` has filled in for the directory being typed.
let directories: DirectoryBarCandidate[] = [];
@@ -516,6 +585,7 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
* list is mixed — sessions and commands together — except on an empty query,
* where the bar is a session switcher. */
function candidatesFor(parsed: BarQuery): BarCandidate[] {
if (page) return sessionActionCandidates();
switch (parsed.sigil) {
case ">":
return [...commandCandidates];
@@ -547,9 +617,27 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
}
/** The line naming the session being acted on, so the target is never
* ambiguous. Present only while the action page is up. */
function paintHeader(): void {
header?.remove();
header = null;
if (!page) return;
header = document.createElement("div");
header.className = "cmdbar-page-header";
header.setAttribute("role", "presentation");
header.textContent = `Actions for "${sessionName(page)}"`;
panel.insertBefore(header, results);
}
function render(): void {
const parsed = parseBarQuery(input.value);
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
// On the action page the query line is over the three actions, so there is
// no sigil to read: `>` and `~` are ordinary characters there.
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
input.placeholder = page
? `Act on ${sessionName(page)}…`
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
paintHeader();
rows = barRows(parsed.query, candidatesFor(parsed));
// The highlight starts on the globally best row every time the list
// changes: an old index would point at a different result.
@@ -567,8 +655,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
let index = 0;
for (const group of barGroups(rows)) {
// While a sigil is in force there is one kind, so the header is dropped.
if (parsed.sigil === null) {
// While a sigil is in force there is one kind, so the header is
// dropped — and the action page is one kind too.
if (parsed.sigil === null && !page) {
const header = document.createElement("div");
header.className = "cmdbar-group-title";
header.setAttribute("role", "presentation");
@@ -593,10 +682,50 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
paintHighlight();
}
/** `tab`: drill into the highlighted session's action page. No effect on any
* other kind, and none on the page itself, which has no session rows. */
function drillIn(): void {
const candidate = rows[highlighted]?.candidate;
if (page || candidate?.kind !== "session") return;
page = candidate.session;
stashed = input.value;
input.value = "";
render();
}
/** Escape, or backspace at an empty query: back to the result list with the
* query the drill-in was made from. */
function leavePage(): void {
page = null;
input.value = stashed;
stashed = "";
render();
}
function runAction(action: SessionAction, session: Session): void {
close();
switch (action.id) {
case "kill":
killSession(session);
return;
case "rename":
renameSession(session);
return;
case "spawn-similar":
spawnSimilar(session);
return;
}
}
function activate(index: number): void {
const result = rows[index];
if (!result) return;
const candidate = result.candidate;
if (isActionCandidate(candidate)) {
// The page cannot be up without a session to act on.
if (page) runAction(candidate.action, page);
return;
}
close();
switch (candidate.kind) {
case "session":
@@ -615,7 +744,20 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
switch (e.key) {
case "Escape":
e.preventDefault();
close();
// On an action page Escape is one level up, not the way out.
if (page) leavePage();
else close();
return;
case "Tab":
e.preventDefault();
drillIn();
return;
case "Backspace":
// Ordinary editing, except at an empty query on the action page.
if (page && input.value === "") {
e.preventDefault();
leavePage();
}
return;
case "Enter":
e.preventDefault();
+178 -14
View File
@@ -4,12 +4,17 @@ import { describe, expect, it, vi } from "vitest";
import {
COMMAND_SPECS,
CommandSet,
commandSpecs,
DEFAULT_LEADER,
canonicalizeKey,
controlByte,
defineCommands,
isBarTypingTarget,
isTerminalTextarea,
isTypingTarget,
keyCaps,
publishedCommands,
type Command,
} from "./commands.js";
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
@@ -128,31 +133,28 @@ describe("CommandSet", () => {
expect(run).toHaveBeenCalledTimes(2);
});
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
const plain = vi.fn();
const chorded = vi.fn();
const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
{ leader: DEFAULT_LEADER },
);
const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled();
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
// left for the pane's shell/app instead.
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
pressOn(set, terminal, "a", { ctrlKey: true });
expect(pressOn(set, terminal, "h")).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1);
});
it("keeps pane commands live inside the terminal's textarea", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
it("leaves a plain Escape for the pane instead of binding it", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
const e = key("Escape");
Object.defineProperty(e, "target", { value: textarea });
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
// xterm is told to swallow by the return value, not by the browser default.
expect(e.defaultPrevented).toBe(false);
expect(pressOn(set, textarea, "Escape")).toBe(false);
});
});
@@ -161,6 +163,38 @@ describe("the leader", () => {
expect(DEFAULT_LEADER).toBe("ctrl-a");
});
// #159: the leader is the operator's, served on `/api/info`. Every chord
// behind it is built from it, so a rebound leader carries its escape hatches
// with it instead of leaving them on a stale ctrl-a.
it("builds the chords behind it from the configured leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
// Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
});
it("dispatches a configured leader's chord and not the default's", () => {
const literalCtrlK = vi.fn();
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
leader: "ctrl-b",
});
set.handle(key("b", { ctrlKey: true }));
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
expect(literalCtrlK).toHaveBeenCalledTimes(1);
// The old leader is an ordinary key again: it holds nothing, and the chord
// that used to follow it runs nothing.
set.handle(key("a", { ctrlKey: true }));
set.handle(key("k", { ctrlKey: true }));
expect(literalCtrlK).toHaveBeenCalledTimes(1);
});
it("sends the configured leader's own byte for the doubled chord", () => {
expect(controlByte("ctrl-b")).toBe("\x02");
});
it("holds the leader as a partial chord instead of selecting the page", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
leader: DEFAULT_LEADER,
@@ -234,3 +268,133 @@ describe("isTypingTarget", () => {
expect(isTypingTarget(null)).toBe(false);
});
});
describe("isTerminalTextarea", () => {
it("recognises xterm's offscreen textarea, and nothing else", () => {
expect(isTerminalTextarea(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(true);
expect(isTerminalTextarea(el("<textarea></textarea>"))).toBe(false);
expect(isTerminalTextarea(el("<input>"))).toBe(false);
expect(isTerminalTextarea(null)).toBe(false);
});
});
describe("isBarTypingTarget", () => {
it("holds the bar's keys out of real form fields", () => {
expect(isBarTypingTarget(el("<input>"))).toBe(true);
expect(isBarTypingTarget(el("<textarea></textarea>"))).toBe(true);
});
it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => {
expect(isBarTypingTarget(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(false);
expect(isBarTypingTarget(el("<button></button>"))).toBe(false);
expect(isBarTypingTarget(null)).toBe(false);
});
});
describe("ctrl-k over a focused pane (#152)", () => {
const terminal = () => el('<textarea class="xterm-helper-textarea"></textarea>');
/** The global listener's routing: the bar's set only sees a key that is not
* aimed at a real form field. */
function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) {
if (isBarTypingTarget(target)) return false;
return pressOn(set, target, k, init);
}
it("opens the bar when the terminal has the keyboard", () => {
const open = vi.fn();
const set = new CommandSet(defineCommands({ "bar.open": open }));
expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true);
expect(open).toHaveBeenCalledTimes(1);
});
it("leaves the spawn form's inputs their own Ctrl-K", () => {
const open = vi.fn();
const set = new CommandSet(defineCommands({ "bar.open": open }));
expect(route(set, el("<input>"), "k", { ctrlKey: true })).toBe(false);
expect(open).not.toHaveBeenCalled();
});
it("sends the literal 0x0B after the leader, without opening the bar", () => {
const open = vi.fn();
const literal = vi.fn();
const bar = new CommandSet(defineCommands({ "bar.open": open }));
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), {
leader: DEFAULT_LEADER,
});
const target = terminal();
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
const e = key("k", { ctrlKey: true });
Object.defineProperty(e, "target", { value: target });
expect(set.handle(e)).toBe(true);
expect(literal).toHaveBeenCalledTimes(1);
// The leader's set consumed the key, so the bar's set never sees it.
expect(open).not.toHaveBeenCalled();
expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise
expect(open).toHaveBeenCalledTimes(1);
});
it("binds the escape hatch behind the default leader", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK");
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]);
});
});
describe("leader pressed twice (#158)", () => {
it("sends one literal leader byte and clears the pending chord", () => {
const literal = vi.fn();
const run = vi.fn();
const set = new CommandSet(
defineCommands({ "pane.literalLeader": literal, "spawn.open": run }),
{ leader: DEFAULT_LEADER },
);
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
expect(literal).toHaveBeenCalledTimes(1);
// The chord is cleared, not still held: the next key is ordinary again.
expect(set.handle(key("n"))).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
});
it("binds the escape hatch behind the default leader", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalLeader");
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ${DEFAULT_LEADER}`]);
});
// Not just the default: the chord is resolved from whatever leader this
// set was constructed with, so a rebound leader carries the escape hatch
// with it — the same proof #156 wrote for the TUI. The web has no config
// for this yet (#159), but the mechanism must not be hardcoded to ctrl-a.
it("follows a rebound leader, not just the default", () => {
const literal = vi.fn();
const rebound: Command = {
id: "pane.literalLeader",
label: "Send a literal leader key to the pane",
group: "Pane",
scope: "global",
keys: ["ctrl-b ctrl-b"],
run: literal,
};
const set = new CommandSet([rebound], { leader: "ctrl-b" });
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
expect(literal).toHaveBeenCalledTimes(1);
// The old leader no longer does anything special.
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(false);
});
});
describe("controlByte", () => {
it("computes the raw control byte from a plain ctrl chord", () => {
expect(controlByte("ctrl-a")).toBe("\x01");
expect(controlByte("ctrl-k")).toBe("\x0b");
});
it("rejects anything that isn't a single plain ctrl chord", () => {
expect(() => controlByte("ctrl-shift-a")).toThrow();
expect(() => controlByte("a")).toThrow();
expect(() => controlByte("ctrl-a ctrl-a")).toThrow();
});
});
+67 -20
View File
@@ -32,9 +32,26 @@ export interface Command extends CommandSpec {
run: () => void;
}
/** Every command the web client has. The single list this issue exists to
* create: labels, chords and grouping live here and nowhere else. */
export const COMMAND_SPECS: readonly CommandSpec[] = [
/** Leader used when the daemon does not name one — an older daemon, or an
* unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a`
* is select-all in a browser, so a set that owns the leader
* `preventDefault`s it. */
export const DEFAULT_LEADER = "ctrl-a";
/** Every command the web client has, for one leader. The single list: labels,
* chords and grouping live here and nowhere else. Chords behind the leader are
* built from it (#159), so the configured leader — served on `/api/info` as
* `leader` — is what the overlay renders and the sets dispatch. */
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
return SPECS.map(({ leaderKeys, ...spec }) =>
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
);
}
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
const SPECS: readonly SpecSource[] = [
{
id: "spawn.open",
label: "Open the spawn form",
@@ -56,11 +73,28 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
keys: ["ctrl-k"],
scope: "global",
},
{
id: "pane.literalCtrlK",
label: "Send a literal Ctrl-K to the pane",
group: "Pane",
keys: [`${DEFAULT_LEADER} ctrl-k`],
leaderKeys: (leader) => [`${leader} ctrl-k`],
scope: "global",
},
{
id: "pane.literalLeader",
label: "Send a literal leader key to the pane",
group: "Pane",
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
leaderKeys: (leader) => [`${leader} ${leader}`],
scope: "global",
},
{
id: "focus.toggle",
label: "Move the keyboard between the session list and the pane",
group: "Session list",
keys: ["ctrl-shift-h"],
keys: [`${DEFAULT_LEADER} h`],
leaderKeys: (leader) => [`${leader} h`],
scope: "global",
},
{
@@ -112,13 +146,6 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
keys: ["tab"],
scope: "list",
},
{
id: "pane.focusList",
label: "Hand the keyboard back to the session list, from the pane",
group: "Session list",
keys: ["esc"],
scope: "pane",
},
{
id: "help.toggle",
label: "Show or hide this help",
@@ -142,13 +169,20 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
},
];
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s]));
/** The command list under the default leader. Iteration order everywhere else
* (the help overlay, the bar) comes from here. */
export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs();
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
* test failure rather than a key that silently does nothing. */
export function defineCommands(handlers: Record<string, () => void>): Command[] {
* test failure rather than a key that silently does nothing. `leader` picks
* the chords behind the leader; omit it for a set that has none. */
export function defineCommands(
handlers: Record<string, () => void>,
leader: string = DEFAULT_LEADER,
): Command[] {
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
return Object.entries(handlers).map(([id, run]) => {
const spec = SPEC_BY_ID.get(id);
const spec = byId.get(id);
if (!spec) throw new Error(`unknown command id: ${id}`);
return { ...spec, run };
});
@@ -216,6 +250,15 @@ export function canonicalizeKey(e: KeyboardEvent): string | null {
return out + e.key.toLowerCase();
}
/** The raw control byte for a canonical `ctrl-<letter>` chord — `ctrl-a` is
* `0x01` — mirroring the TUI's `encode_key`. Built from the chord string
* itself so a different leader sends its own byte, not a hardcoded one. */
export function controlByte(chord: string): string {
const m = /^ctrl-([a-z])$/.exec(chord);
if (!m) throw new Error(`not a plain ctrl chord: ${chord}`);
return String.fromCharCode(m[1].charCodeAt(0) - 96);
}
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
/** True when a chord carries a modifier or more than one key. Plain single-key
@@ -241,11 +284,19 @@ export function isTypingTarget(target: EventTarget | null): boolean {
/** xterm's offscreen textarea. It is a typing target like any other, but it is
* also where the leader has to work — a pane is exactly where the operator
* needs the escape hatch (docs/COMMAND-BAR.md). */
function isTerminalTextarea(target: EventTarget | null): boolean {
export function isTerminalTextarea(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
}
/** True where the command bar's own keys must stay out of the way: a real form
* field, and not the pane. `ctrl-k` is intercepted ahead of xterm
* (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while
* the spawn form's inputs keep their own Ctrl-K. */
export function isBarTypingTarget(target: EventTarget | null): boolean {
return isTypingTarget(target) && !isTerminalTextarea(target);
}
// ---- display ----
const CAPS: Record<string, string> = {
@@ -292,10 +343,6 @@ export function keyCaps(chord: string): string[] {
// ---- dispatch ----
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
* in a browser, so a set that owns the leader `preventDefault`s it. */
export const DEFAULT_LEADER = "ctrl-a";
/** How long a partial chord waits for its next key — the TUI's
* `chord_timeout_ms` default. */
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
+40
View File
@@ -3,6 +3,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
// the pool reaches the focused connection with them.
const sent: { id: string; data: string }[] = [];
vi.mock("./attach.js", () => ({
createAttachConnection: (id: string) => ({
sessionId: id,
el: document.createElement("div"),
onMount() {},
onUnmount() {},
focusTerminal() {},
sendInput(data: string) {
sent.push({ id, data });
},
dispose() {},
}),
}));
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -85,3 +102,26 @@ describe("ConnectionPool.setFocus", () => {
await Promise.resolve();
});
});
describe("ConnectionPool.sendToFocused", () => {
it("types the bytes into the focused session's PTY", () => {
mockFetch();
sent.length = 0;
const pool = new ConnectionPool();
const pane = document.createElement("div");
pool.mount(A, pane);
pool.setFocus(A);
expect(pool.sendToFocused("\x0b")).toBe(true);
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
});
it("is a no-op with nothing focused", () => {
mockFetch();
sent.length = 0;
const pool = new ConnectionPool();
expect(pool.sendToFocused("\x0b")).toBe(false);
expect(sent).toEqual([]);
});
});
+12
View File
@@ -159,6 +159,18 @@ export class ConnectionPool {
this.cache.get(this.focusedId)?.focusTerminal();
}
/** Type raw bytes into the focused session's PTY. Returns false when there is
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
* is a no-op over the welcome pane rather than an error
* (docs/COMMAND-BAR.md). */
sendToFocused(data: string): boolean {
if (this.focusedId === null) return false;
const conn = this.cache.get(this.focusedId);
if (!conn) return false;
conn.sendInput(data);
return true;
}
/** Fully disconnect + forget a session. */
close(id: Uuid): void {
const conn = this.cache.get(id);
+17 -4
View File
@@ -34,12 +34,14 @@ describe("resolveFastForward (#67)", () => {
const res = resolveFastForward(projects, presets, "amber-falcon");
expect(res.project?.path).toBe("/repos/fresh");
expect(res.command).toBe("aider");
expect(res.name).toBe("amber-falcon");
expect(res.handle).toBe("amber-falcon");
const req = buildFastForwardRequest(res)!;
expect(req.cwd).toBe("/repos/fresh");
expect(req.command).toBe("aider");
expect(req.metadata).toEqual({ name: "amber-falcon" });
// No name (#131): the daemon names the session after its first prompt,
// and a name here would collide with the branch handle.
expect(req.metadata).toBeUndefined();
expect(req.worktree).toEqual({
base_repo: "/repos/fresh",
branch: "amber-falcon",
@@ -88,9 +90,9 @@ describe("resolveFastForward (#67)", () => {
expect(buildFastForwardRequest(res)).toBeNull();
});
it("downgrades to a draft when the generated name is missing", () => {
it("downgrades to a draft when the generated handle is missing", () => {
const res = resolveFastForward([project({ last_command: "claude" })], presets);
expect(res.name).toBeUndefined();
expect(res.handle).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull();
});
@@ -108,6 +110,17 @@ describe("resolveFastForward (#67)", () => {
});
});
it("seeds the handle as the branch and leaves the name field empty (#131)", () => {
const res = resolveFastForward(
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
presets,
"amber-falcon",
);
const seed = fastForwardSeed(res);
expect(seed.name).toBe("");
expect(seed.worktreeBranch).toBe("amber-falcon");
});
it("leaves the worktree default alone when not even a project resolved", () => {
// Not `false`: the form defaults the toggle on and a false would stick,
// so the fallback draft would disagree with a normally-opened draft and
+19 -13
View File
@@ -10,19 +10,21 @@ import { sortProjects } from "./projectList.js";
export interface FastForwardResolution {
project?: Project;
command?: string;
name?: string;
/** A generated handle for the worktree branch (#131). Not a session name:
* the fast path names no session. */
handle?: string;
}
/** The defaults: the currently focused session's project when it's still a
* known project, else the most recent one (fresh-first, same ordering the
* picker shows) — so `N` lands where the operator currently is, matching
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
* falls back to the first non-custom preset, and the name is freshly
* generated when the daemon supplied one. */
* falls back to the first non-custom preset, and the branch handle is
* freshly generated when the daemon supplied one. */
export function resolveFastForward(
projects: Project[],
presets: SpawnPreset[],
name?: string,
handle?: string,
focusedCwd?: string,
): FastForwardResolution {
const project =
@@ -30,41 +32,45 @@ export function resolveFastForward(
const lastCommand = project?.last_command?.trim();
const command =
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
return { project, command, name: name || undefined };
return { project, command, handle: handle || undefined };
}
/** The immediate spawn request — worktree on, new branch named after the
* generated session name — or `null` when any default is missing. */
* generated handle — or `null` when any default is missing.
*
* The request carries no name (#131): the daemon assigns a provisional one
* and replaces it once the session sees its first prompt. A name here would
* also collide with the branch handle's own session. */
export function buildFastForwardRequest(
res: FastForwardResolution,
): CreateSessionRequest | null {
const { project, command, name } = res;
if (!project || !command?.trim() || !name) return null;
const { project, command, handle } = res;
if (!project || !command?.trim() || !handle) return null;
return {
cwd: project.path,
command,
metadata: { name },
worktree: {
base_repo: project.base_repo ?? project.path,
branch: name,
branch: handle,
create_branch: true,
},
};
}
/** Seed for the fallback spawn form: whatever did resolve goes into the
* form; the rest stays with its own defaults. */
* form; the rest stays with its own defaults. The name field stays empty
* (#131) — the handle seeds the branch, not the session name. */
export function fastForwardSeed(res: FastForwardResolution) {
return {
cwd: res.project?.path ?? "",
command: res.command ?? "",
name: res.name ?? "",
name: "",
// Left undefined rather than false when nothing resolved: the form
// defaults the toggle on, and `false` would stick, so the fallback form
// would disagree with both a normally-opened form and the TUI's fast path
// (#67 review).
worktreeEnabled: res.project ? true : undefined,
worktreeBranch:
res.project && res.name ? res.name : undefined,
res.project && res.handle ? res.handle : undefined,
};
}
+28 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { keyCaps, publishedCommands } from "./commands.js";
import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
beforeEach(() => {
@@ -44,6 +44,33 @@ describe("help overlay", () => {
.toEqual(publishedCommands().map((c) => c.id));
});
// #159: the leader comes from the daemon, so the overlay must render the
// configured chord — a hardcoded `Ctrl A` is the visible half of the bug.
it("renders the configured leader's chord, not a hardcoded Ctrl A", () => {
const bound = defineCommands(
{ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} },
"ctrl-b",
);
publishCommands(bound);
try {
openHelp();
const keysOf = (id: string) =>
[
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
.querySelectorAll("kbd"),
]
.map((k) => k.textContent)
.join(" ");
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B");
} finally {
// Leave the registry as the rest of the suite expects it.
publishCommands(
defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }),
);
}
});
it("opens on ? and closes on a second ?", () => {
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
closeForceKillDialog,
openForceKillDialog,
openKillConfirmDialog,
} from "./killDialog.js";
describe("kill dialogs", () => {
beforeEach(() => {
closeForceKillDialog();
document.body.innerHTML = "";
});
it("focuses the kill button, so Enter confirms", () => {
const el = openKillConfirmDialog({
name: "alpha",
blockers: null,
onKill: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
});
it("focuses the force button too", () => {
const el = openForceKillDialog({
name: "alpha",
blockers: null,
reason: "worktree is dirty.",
onForce: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
});
});
+84 -23
View File
@@ -1,14 +1,44 @@
/**
* Forced-kill prompt (#81).
* The two kill prompts (#81, #160).
*
* The daemon refuses to kill a session whose worktree still holds uncommitted
* or unpushed work. `confirm()` can only ask the same question twice, so the
* refusal gets its own dialog: it names what is in the way and offers the
* override the daemon's `force=true` already implements. A kill the daemon
* accepts never reaches here — there is nothing to warn about.
* A kill is destructive and unattended agents live in these sessions, so both
* clients ask before one goes through — the TUI's `KillConfirm`, and
* `openKillConfirmDialog` here (#160). Where the session's worktree still
* holds uncommitted or unpushed work, the confirmation says so up front, the
* same way the TUI's does.
*
* The daemon decides the rest: it refuses a kill over such a worktree, and
* that refusal needs an answer the confirmation cannot give — a forced kill.
* `openForceKillDialog` is that second prompt. A kill the daemon accepts never
* reaches it.
*/
import { formatInTheWay, type InTheWay } from "./types.js";
/** What both prompts need: a target to name and a button that acts on it. */
interface KillDialogSpec {
/** Session name, as the sidebar shows it. */
name: string;
/** Sentence under the title. */
body: string;
/** Label on the acting button. */
confirmLabel: string;
/** Extra class on the acting button, so tests and styles can find it. */
confirmClass: string;
/** Run it. A rejection is rendered in the dialog, which stays open so the
* press can be retried. */
onConfirm: () => Promise<void>;
}
export interface KillConfirmPrompt {
name: string;
/** What the card last saw in the way, when anything was. Shown before the
* attempt, so "this will be refused" is not a surprise. */
blockers: InTheWay | null;
/** Run the plain kill. Opening a further dialog from here is allowed: the
* confirmation only closes itself if it is still the one on screen. */
onKill: () => Promise<void>;
}
export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */
name: string;
@@ -30,9 +60,9 @@ export function closeForceKillDialog(): void {
open = null;
}
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
* can never stack two dialogs. */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
/** Mount one of the prompts. Opening while one is up replaces it, so the
* confirmation and a refusal that follows it can never stack. */
function mount(p: KillDialogSpec): HTMLElement {
closeForceKillDialog();
const el = document.createElement("div");
@@ -50,9 +80,7 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
const body = document.createElement("p");
body.className = "kill-dialog-body";
body.textContent = p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
body.textContent = p.body;
const error = document.createElement("p");
error.className = "kill-dialog-error";
@@ -65,22 +93,27 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog);
const force = document.createElement("button");
force.type = "button";
force.className = "btn btn-danger kill-dialog-force";
force.textContent = "Force kill";
force.addEventListener("click", () => {
force.disabled = true;
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = `btn btn-danger ${p.confirmClass}`;
confirm.textContent = p.confirmLabel;
confirm.addEventListener("click", () => {
confirm.disabled = true;
void p
.onForce()
.then(closeForceKillDialog)
.onConfirm()
.then(() => {
// A handler that opened the refusal prompt has already replaced this
// dialog; closing here would take that one down with it.
if (open?.el === el) closeForceKillDialog();
})
.catch((e: unknown) => {
force.disabled = false;
if (open?.el !== el) return;
confirm.disabled = false;
error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`;
});
});
actions.append(cancel, force);
actions.append(cancel, confirm);
panel.append(title, body, error, actions);
@@ -99,6 +132,34 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
};
window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown };
cancel.focus();
// The acting button takes the focus in both prompts: the operator already
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
confirm.focus();
return el;
}
/** Ask before a kill the daemon has not seen yet (#160). */
export function openKillConfirmDialog(p: KillConfirmPrompt): HTMLElement {
return mount({
name: p.name,
body: p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. The kill will be refused unless it is forced.`
: "The session and everything running in it go away. Its worktree stays on disk.",
confirmLabel: "Kill",
confirmClass: "kill-dialog-kill",
onConfirm: p.onKill,
});
}
/** Offer the override after the daemon refuses a guarded kill (#81). */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
return mount({
name: p.name,
body: p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`,
confirmLabel: "Force kill",
confirmClass: "kill-dialog-force",
onConfirm: p.onForce,
});
}
+18
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
emptyLayout,
equalizeRatios,
leaves,
locateSession,
openSessions,
@@ -44,6 +45,23 @@ describe("splitLeaf", () => {
});
});
describe("equalizeRatios", () => {
it("resets nested splits to equal width, not just the newest one", () => {
const tab = tabWithOneLeaf("s1");
const first = leaves(tab.root)[0].id;
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
const second = leaves(tab.root)[1].id;
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
equalizeRatios(tab.root);
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
expect(root.ratio).toBeCloseTo(1 / 3);
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
expect(inner.ratio).toBeCloseTo(1 / 2);
});
});
describe("removeLeaf", () => {
it("collapses the parent split into the surviving sibling", () => {
const tab = tabWithOneLeaf("s1");
+11
View File
@@ -118,6 +118,17 @@ export function removeLeaf(tab: Tab, leafId: string): void {
tab.root = tab.root ? rec(tab.root) : null;
}
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
export function addTab(layout: Layout): Tab {
const tab: Tab = {
id: freshId("t"),
+85 -49
View File
@@ -7,7 +7,7 @@ import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen } from "./help.js";
import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js";
import { CommandSet, DEFAULT_LEADER, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
@@ -35,7 +35,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
@@ -86,9 +86,15 @@ const store = new CatalogStore();
// Absolute paths are a fine fallback until it lands.
let homeDir: string | undefined;
void getDaemonInfo()
.then((info) => (homeDir = info.home_dir))
.then((info) => {
homeDir = info.home_dir;
// The leader is the operator's, not this module's (#159): a daemon that
// names one rebinds the chords hanging off it, and the `?` overlay and the
// command bar re-render from the rebuilt set.
if (info.leader) useLeader(info.leader);
})
.catch(() => {
/* absolute paths are a fine fallback */
/* absolute paths and the default leader are fine fallbacks */
});
// The daemon's identity and liveness: names the release in the sidebar's status
@@ -349,14 +355,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
}
async function requestFastForward(): Promise<void> {
const [discovered, name] = await Promise.all([
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
const req = buildFastForwardRequest(resolved);
if (req) {
try {
@@ -383,7 +389,7 @@ async function requestFastForward(): Promise<void> {
// one outside any repo spawns without a worktree, since there is no repo to cut
// a branch from. Anything still unresolved opens the pre-filled form.
async function requestSpawnInDirectory(path: string): Promise<void> {
const [discovered, name] = await Promise.all([
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
@@ -392,7 +398,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
const presets = await spawnPresets();
const known = discovered.find((p) => p.path === path);
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
const resolved = resolveFastForward([project], presets, name, path);
const resolved = resolveFastForward([project], presets, handle, path);
const req = buildFastForwardRequest(resolved);
if (req) {
const inRepo =
@@ -424,7 +430,7 @@ document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
// attached terminal must never be able to trap the focus keys.
//
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
// keys, so `isTypingTarget` keeps them out of form fields and the terminal's
// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's
// hidden textarea.
const spawnKeys = new CommandSet(
defineCommands({
@@ -433,41 +439,74 @@ const spawnKeys = new CommandSet(
}),
);
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it.
// Leader-h moves the keyboard between the session list and the pane, the web
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
// is already claimed by the browser (history), so nothing unmodified — or
// modified with a common browser chord — could get the focus back out of it.
//
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the
// key as a partial chord and takes select-all off it, so the escape hatch
// docs/COMMAND-BAR.md describes has somewhere to land (#149).
const focusKeys = new CommandSet(
defineCommands({
"focus.toggle": () => {
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
}
},
}),
{ leader: DEFAULT_LEADER },
);
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off
// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
// (#149).
let currentLeader = DEFAULT_LEADER;
function buildFocusKeys(leader: string): CommandSet {
return new CommandSet(
defineCommands(
{
"focus.toggle": () => {
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
}
},
// The escape hatch: binding ctrl-k takes readline's
// kill-to-end-of-line away from every pane, and leader ctrl-k gives it
// back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
// this set, ahead of the bar's, so the chord never opens the bar.
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
// The leader itself, pressed twice, tmux-style: sends one literal
// leader byte and clears the pending chord instead of resolving as a
// dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
// hardcoded byte, so a rebound leader sends its own.
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
},
leader,
),
{ leader },
);
}
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
* same command ids replaces them, so the help overlay and the command bar —
* both of which render the published registry when they open — show the
* configured chord rather than the default. */
function useLeader(leader: string): void {
if (leader === currentLeader) return;
currentLeader = leader;
focusKeys = buildFocusKeys(leader);
}
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
// like any other, so the `?` overlay lists it without a second list.
const barKeys = new CommandSet(
defineCommands({
"bar.open": () => {
// Opening while a session is previewed discards the preview first, like
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
schedulePreview.cancel();
mv?.previewSession(null);
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
},
}),
);
const openBar = () => {
// Opening while a session is previewed discards the preview first, like
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
schedulePreview.cancel();
mv?.previewSession(null);
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
};
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
// phone, so the field the operator already reaches for opens the same bar.
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
// Capture phase: an attached xterm swallows every keystroke it is given, so the
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
@@ -479,17 +518,18 @@ window.addEventListener(
if (focusKeys.handle(e)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) return;
// Opening the bar over a focused terminal is #152; until then the pane's
// hidden textarea keeps its Ctrl+K, like any other typing target.
if (!isTypingTarget(e.target) && barKeys.handle(e)) return;
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
// seen ahead of xterm and opens the bar (#152). A real form field — the
// spawn form's inputs — keeps its own Ctrl+K.
if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return;
spawnKeys.handle(e);
},
true,
);
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
// above) hands it back the other way.
document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs.
@@ -497,10 +537,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
if (previewed) focusSession(previewed);
pool.focusTerminal();
});
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
+21 -3
View File
@@ -10,6 +10,7 @@ import {
activeTab,
addTab,
closeTab,
equalizeRatios,
findLeaf,
freshId,
type Dir,
@@ -215,6 +216,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return fl?.sessionId ?? null;
}
/** The session the operator actually picked — what `focusedLeafSession()`
* would return with the preview undone. A preview moves the active tab and
* its focus, but it is a view: the accent stays on the row the operator
* chose until they choose another (docs/PREVIEW.md). */
function realFocusedSession(): string | null {
const p = preview;
if (!p) return focusedLeafSession();
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
const leafId =
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
const fl = leafId ? findLeaf(tab.root, leafId) : null;
return fl?.sessionId ?? null;
}
/** Update focus styling + pool focus + sidebar highlights without rebuilding
* the DOM (cheap path for click-to-focus). */
function syncFocus(): void {
@@ -233,7 +248,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: realOpenSessions(),
focused: focusedLeafSession(),
focused: realFocusedSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
@@ -443,6 +458,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render();
}
@@ -528,6 +544,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
target.sessionId = sessionId;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
}
render();
opts.onOpenSessionsChange?.();
@@ -608,7 +625,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
{ focus: !preview && !quietRender },
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
sidebar.setPreview(preview?.sessionId ?? null);
}
@@ -801,6 +818,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render();
}
@@ -817,7 +835,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
openSessions: realOpenSessions,
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
focusedSessionId: realFocusedSession,
previewSession,
previewedSessionId: () => preview?.sessionId ?? null,
};
+33 -1
View File
@@ -47,12 +47,20 @@ function fakePool(): Spy {
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null };
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
const rowHighlights: { open: Set<string>; focused: string | null } = {
open: new Set(),
focused: null,
};
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setHighlights(h) {
rowHighlights.open = h.open;
rowHighlights.focused = h.focused;
},
setPreview(id: string | null) {
previewRow.id = id;
},
@@ -95,6 +103,8 @@ describe("sidebar preview", () => {
stubFetch();
document.body.replaceChildren();
previewRow.id = null;
rowHighlights.open = new Set();
rowHighlights.focused = null;
});
afterEach(() => {
@@ -134,6 +144,28 @@ describe("sidebar preview", () => {
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
});
it("leaves the accent on the session the pane actually holds", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(rowHighlights.focused).toBe("s2");
// Walking past other rows never moves the accent off what the operator
// picked: only a real open does (docs/PREVIEW.md).
mv.previewSession("s1");
expect(rowHighlights.focused).toBe("s2");
expect(mv.focusedSessionId()).toBe("s2");
mv.previewSession("s9");
expect(rowHighlights.focused).toBe("s2");
mv.previewSession(null);
expect(rowHighlights.focused).toBe("s2");
mv.openSession("s1");
expect(rowHighlights.focused).toBe("s1");
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
+3 -1
View File
@@ -143,7 +143,7 @@ describe("sidebar keyboard navigation", () => {
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
it("x kills the highlighted session", async () => {
it("x kills the highlighted session, once confirmed (#160)", async () => {
const { el } = twoFolders();
const killed: string[] = [];
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
@@ -153,6 +153,8 @@ describe("sidebar keyboard navigation", () => {
press(tree(el), "j");
press(tree(el), "x");
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
});
+135
View File
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom
//
// The sidebar's search field is the touch entry point to the command bar
// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the
// shell to open the bar with an empty query, instead of filtering the tree.
import { beforeAll, describe, expect, it } from "vitest";
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
import { sessionBarCandidates } from "./commandBar.js";
import { matchAndRank } from "./matcher.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
function session(id: string, cwd: string): Session {
return {
id,
name: id,
cwd,
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
} as Session;
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
const handle = renderSidebar(storeWith(sessions));
document.body.appendChild(handle.el);
const search = handle.el.querySelector<HTMLInputElement>(".search")!;
const opens: number[] = [];
const listener = () => opens.push(1);
document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener);
return {
handle,
search,
opens,
dispose: () => {
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, listener);
handle.dispose();
handle.el.remove();
},
};
}
describe("sidebar search field opens the command bar (#157)", () => {
it("asks the shell to open the bar when the field is focused", () => {
const t = mount();
t.search.dispatchEvent(new FocusEvent("focus"));
expect(t.opens).toHaveLength(1);
t.dispose();
});
it("opens on a tap, where a focus event may never arrive", () => {
const t = mount();
t.search.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(t.opens).toHaveLength(1);
t.dispose();
});
it("does not trap the keyboard in the field: it gives focus up on open", () => {
const t = mount();
t.search.focus();
expect(document.activeElement).not.toBe(t.search);
t.dispose();
});
it("opens the bar with an empty query — the field never holds one", () => {
const t = mount();
t.search.focus();
expect(t.search.value).toBe("");
t.dispose();
});
it("is readonly: no on-screen keyboard over a field nothing is typed into", () => {
const t = mount();
expect(t.search.readOnly).toBe(true);
t.dispose();
});
it("no longer filters the tree in place", () => {
const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]);
t.search.value = "alpha";
t.search.dispatchEvent(new Event("input"));
expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2);
t.dispose();
});
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
const t = mount([]);
expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet");
t.dispose();
});
});
// The inline filter matched a session on its name and its directory, case- and
// whitespace-insensitively. That behaviour did not go away with the field — it
// moved into the bar, which ranks the same two fields (docs/COMMAND-BAR.md).
describe("what the retired inline filter did, the bar now does", () => {
it("matches a session on both its name and its directory", () => {
const sessions = [session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")];
const candidates = sessionBarCandidates(sessions, "/home/n");
const byName = matchAndRank("alpha", candidates).map((r) => r.candidate.session.id);
expect(byName).toEqual(["alpha"]);
const byDir = matchAndRank("beta", candidates).map((r) => r.candidate.session.id);
expect(byDir).toEqual(["beta"]);
});
it("ignores case and surrounding whitespace, and shows nothing on no match", () => {
const candidates = sessionBarCandidates([session("alpha", "/home/n/Alpha")], "/home/n");
expect(matchAndRank(" ALPHA ", candidates)).toHaveLength(1);
expect(matchAndRank("zzz", candidates)).toHaveLength(0);
});
});
+30 -4
View File
@@ -1202,8 +1202,10 @@ textarea {
}
/* 5. The row the content pane is previewing right now. Last, so previewing a
session that is already open still reads as provisional. */
.tree-leaf:where(.is-previewing) {
session that is already open still reads as provisional — except the session
already mounted in the pane (tier 4). Previewing that one changes nothing on
screen, so its accent stays put instead of blinking out under the pointer. */
.tree-leaf:where(.is-previewing):where(:not(.is-active)) {
background: var(--preview-tint);
}
@@ -1472,6 +1474,15 @@ input.leaf-rename:focus {
padding: var(--sp-2) 0;
}
/* The action page's header (#151): names the session being acted on, so the
target of kill/rename/spawn-similar is never ambiguous. */
.cmdbar-page-header {
padding: var(--sp-2) var(--sp-4);
border-bottom: 1px solid var(--border);
font-size: var(--fs-12);
color: var(--muted);
}
.cmdbar-group-title {
padding: var(--sp-2) var(--sp-4) var(--sp-1);
font-size: var(--fs-11);
@@ -1549,7 +1560,10 @@ input.leaf-rename:focus {
}
/* Phone: the bottom-sheet treatment the spawn modal already uses. */
@media (max-width: 600px) {
/* Bottom sheet at phone width, the same treatment (and the same 599px break)
the spawn modal uses: the bar is reached by thumb here, through the sidebar's
search field (#157), so it rides the bottom of the screen. */
@media (max-width: 599px) {
.cmdbar-overlay {
grid-template-rows: 1fr auto;
padding: 0;
@@ -1558,9 +1572,21 @@ input.leaf-rename:focus {
.cmdbar-panel {
grid-row: 2;
width: 100%;
max-height: 70vh;
max-height: 70dvh;
border-radius: var(--r-3) var(--r-3) 0 0;
border-bottom: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Thumb-sized rows and a field that clears the on-screen keyboard's
zoom-on-focus threshold (16px). */
.cmdbar-input {
font-size: var(--fs-17);
min-height: 44px;
}
.cmdbar-row {
min-height: 44px;
}
}
+2 -26
View File
@@ -26,7 +26,6 @@ describe("buildTree", () => {
const root = buildTree(
[session("a", "/home/n/work"), session("b", "/home/n/work")],
"/home/n",
"",
) as TreeFolder;
expect(names(root)).toEqual(["~/work"]);
@@ -35,7 +34,7 @@ describe("buildTree", () => {
});
it("collapses a linear chain into one folder", () => {
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n", "") as TreeFolder;
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n") as TreeFolder;
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
});
@@ -43,7 +42,6 @@ describe("buildTree", () => {
const root = buildTree(
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
"/home/n",
"",
) as TreeFolder;
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
@@ -53,7 +51,6 @@ describe("buildTree", () => {
const root = buildTree(
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
"/home/n",
"",
) as TreeFolder;
expect(names(root)).toEqual(["~/git"]);
@@ -65,30 +62,11 @@ describe("buildTree", () => {
const root = buildTree(
[session("loose", ""), session("nested", "/home/n/work")],
"/home/n",
"",
) as TreeFolder;
expect(root.children[0].kind).toBe("folder");
expect(root.children[1].kind).toBe("leaf");
});
it("filters on both session name and directory", () => {
const sessions = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
const byDir = buildTree(sessions, "/home/n", "alpha") as TreeFolder;
expect(names(byDir)).toEqual(["~/alpha"]);
const byCommand = buildTree(sessions, "/home/n", "claude") as TreeFolder;
expect(names(byCommand)).toEqual(["~/alpha"]);
const noMatch = buildTree(sessions, "/home/n", "zzz") as TreeFolder;
expect(noMatch.children).toEqual([]);
});
it("ignores case and surrounding whitespace in the filter", () => {
const root = buildTree([session("a", "/home/n/Alpha")], "/home/n", " ALPHA ") as TreeFolder;
expect(root.children).toHaveLength(1);
});
});
describe("mergeLinear", () => {
@@ -127,7 +105,6 @@ describe("worktree sessions group under their repository (#99)", () => {
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
@@ -147,7 +124,6 @@ describe("worktree sessions group under their repository (#99)", () => {
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
@@ -157,7 +133,7 @@ describe("worktree sessions group under their repository (#99)", () => {
});
it("leaves a session without a worktree where it is", () => {
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n") as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/arr"]);
});
});
+1 -6
View File
@@ -25,19 +25,14 @@ export function compareNodes(a: TreeNode, b: TreeNode): number {
return an.localeCompare(bn);
}
export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
export function buildTree(sessions: Session[], home: string | undefined): TreeNode {
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
// Grouped by repository, so a worktree session lands under its repo
// rather than in a folder of its own (#99).
const cwd = sessionTreePath(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
if (lc) {
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
if (!haystack.includes(lc)) continue;
}
const parts = dir ? dir.split("/").filter(Boolean) : [];
let cursor = root;
for (const part of parts) {
+8
View File
@@ -56,6 +56,10 @@ export interface Session {
* how the command bar's empty query sorts a never-focused session after every
* session that has one (`docs/COMMAND-BAR.md`). */
last_focused_at?: IsoTimestamp | null;
/** The ACP session id the `claude-code-acp` adapter returned from
* `session/new` (#134). Only a `Structured` session whose handshake
* succeeded has one; a respawn passes it to `session/load` (#135). */
acp_session_id?: string | null;
}
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
@@ -98,6 +102,10 @@ export interface DaemonInfo {
* value across a reconnect means the daemon restarted. Absent on older
* daemons, which the restart flow treats as "cannot tell". */
instance?: string;
/** Canonical leader key the operator configured (`[tui].leader`, default
* `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or
* empty on an older daemon; the client then keeps its own default. */
leader?: string;
}
/** Build provenance of the running daemon: what the status indicator names in