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
20 KiB
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, marked
with PR_SET_PDEATHSIG so it dies with the daemon, and ended by
AcpSession::terminate (session/cancel, a short grace, SIGTERM, then
SIGKILL) 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:
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 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/updatebecomes which row kind, and under what id — the fold inprojection.rsis 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_callsare 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
Approvalrow and a nonce. - What happens to an ACP update variant nobody has taught the daemon about
yet (falls back to
Noticerather 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 forToolDiff, a tool card forToolCall, a checklist forPlan, and a floor presentation forNotice. - How
respondingvs.thinkingare distinguished visually, if at all. - How the operator is asked to resolve an
Approvalrow — 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, andmode. LikeTranscriptSnapshot, 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 aTranscriptSnapshotand aReducedStatecovering the whole log as it stands, then streamsProjectionUpdate { 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. A client that needs history from before it was connected uses the replay endpoint below, not this socket.GET /api/sessions/:id/structured/replay— cursor-bounded pagination over the durable log, returning oneStructuredReplayEvent(cursor,transcript,state) per matching line.?since=<n>returns the first page aftern(since=0is the whole log);?before=<n>returns the page immediately beforen, still in ascending order — passing both is a400.?limitdefaults 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 state at its cursor, not an approximation — at the cost of redoing the whole fold per request. A session that is not a live structured session (terminal, or a dead adapter) answers409 not_structuredfrom 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_approvalsmap keyed by nonce, and its mirror inReducedState.pending_approval.nonceon the live wire. It is never part of theApprovalrow'sdata— the row isRequestPermissionRequestserialized 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
Approvalrow from disk (Projection::restore_approval) produces a historical row with nopending_approvalin 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. - One live approval at a time. A new permission request cancels whatever
was already pending (
cancel_pending_approvals) before minting its own nonce — ACP serializes requests through one connection, so this only ever fires if an adapter nonetheless overlaps two, and the newest one wins. - Single-use and duplicate resolution.
POST /api/sessions/:id/structured/approvals/:noncealways answers204for 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. WhicheverPOSTreaches the daemon first removes the nonce frompending_approvalsand is the one whose decision actually reaches the adapter; every laterPOSTfor that nonce is a no-op204. - Cancellation vs. denial. A turn cancel or a session shutdown resolves
any pending approval with ACP's
Cancelledoutcome, not aDenydecision — the adapter should read those as "the operation was withdrawn," not "the operator refused it." - Decision mapping.
AllowOnceandAllowForSessionselect the ACP option of kindAllowOnce/AllowAlwaysfrom the request's own option list.Denyprefers 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 answersCancelled.
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_flightrather than queuing — one active turn per session, never a hidden queue of prompts. - Cancel sends
session/cancel, clears any pending approval asCancelled, 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 next to its sidecar
(<id>.json) in the sidecar directory:
<id>.events.jsonl— the unified event log. Each line is either a raw ACPSessionNotification(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:
- The adapter exits, or the log append fails, while the daemon keeps
running.
mark_session_deadapplies 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'srecover()finds the sidecar, sees no marker, and leaves the sessionDeadwithout attempting to resume it. - 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. - Graceful daemon shutdown, while the session was still
Running.mark_session_dead_after_shutdownmarks 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.recovermarker. The sidecar on disk therefore saysDead, 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). 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.