This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/docs/STRUCTURED-VIEW.md
T
Miguel Palhas 1e30b9ade1
ci / rust (push) Successful in 8m43s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 4m21s
Merge remote-tracking branch 'origin/main' into timbre
2026-09-07 10:24:22 +01:00

28 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 provider adapter process is the ACP agent. It translates the provider's events 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).

Choosing the view

view is fixed at spawn and never changes. Every client picks it the same way, from the resolved command alone (maestro_protocol::display::supports_structured_view): a command that resolves to Claude Code or Codex spawns Structured by default, anything else spawns Terminal. The operator can override the default — the web and TUI spawn forms show the checkbox ticked and let it be unticked, and maestro spawn takes --terminal (or --structured, which sends the request even for a command the CLI does not recognise and lets the daemon refuse it). Once the operator works the toggle, a later command change never re-ticks it under them; it can still be forced off when the new command has no adapter.

The daemon is the last word: a Structured request for a command with no adapter is refused there, whichever client sent it.

The adapter

The resolved structured command selects a provider adapter. Its binary is resolved by walking the session's PATH when the spawn request sets one, or the daemon's PATH otherwise, and taking the first executable with that name.

Command Adapter binary Install command Provider detail
claude claude-code-acp npm install -g --prefix ~/.local @zed-industries/claude-code-acp Needs a logged-in Claude Code.
codex codex-acp npm install -g --prefix ~/.local @agentclientprotocol/codex-acp Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration.

The selected install command is baked into AcpError::AdapterMissing. When an adapter cannot be found, spawn still creates a Dead session with that command as activity_detail (see docs/INSTALL.md for the systemd PATH caveat). Provider authentication failures surface through the adapter's stderr tail in the same field.

The handshake is bounded by HANDSHAKE_TIMEOUT (60s) — long enough for a slow machine to boot an adapter and its provider process, 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 Providers read the session workspace through their own tool surface.
fs.writeTextFile false Providers write the session workspace through their own tool surface.
terminal false Providers run commands through their own tool surface.

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 neither supported adapter needs 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.

Codex and Claude differ in what they emit, not in this lifecycle. Codex maps shell commands, file changes, MCP calls, web search, image generation/view, reasoning, plans, and token usage into standard ACP chunks and tool calls; those fold into the same eight rows. Codex's available_commands_update and session_info_update, like equivalent Claude updates, remain opaque Notice rows. Its session/load capability is required for daemon recovery just as it is for Claude.

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 The prompt accepted by Maestro's structured prompt endpoint, persisted before ACP dispatch. An adapter user_message_chunk is also supported for imported ACP history. user:<uuid> for a daemon-recorded prompt; user:<message_id> or user:<seq> for an ACP chunk.
AgentText An agent_message_chunk — the agent's visible reply, streamed. agent:<message_id>, or agent:<seq> from the first chunk when no id is supplied. Later chunks for the same id—or consecutive anonymous text chunks—are appended into data.content.text in place. An empty anonymous chunk starts a fresh row, matching the turn marker emitted by adapters that omit message ids.
AgentThought An agent_thought_chunk — the model's reasoning stream. thought:<message_id> / first anonymous 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 ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged maestroTurnMeta notice as the durable footer for a completed, cancelled, or failed turn. notice:<seq> for ACP updates; turn_meta:<uuid> for a turn footer.

Notice covers known-but-unspecialized SessionUpdate variants: ones the agent-client-protocol dependency's schema already deserializes (it currently folds available_commands_update and session_info_update there) but the projection's match doesn't fold into one of the other seven kinds. It is not a wire-level forward-compatibility seam: SessionUpdate is an internally-tagged enum with no unknown-variant fallback, so a session/update whose sessionUpdate tag this build's dependency has never heard of — including a variant gated behind an unstable ACP feature this build doesn't enable, which is compiled out of the enum entirely — fails to deserialize before the projection ever runs, rather than becoming a Notice. A genuinely new ACP variant needs the agent-client-protocol dependency upgraded (and, typically, the projection's match taught about it) before it can appear on this transcript at all — see projection.rs's test asserting that an unrecognised sessionUpdate tag is a deserialization error, not a Notice. A client that does not know what to do with a Notice can still render it as an opaque, collapsed line — that remains 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 / .started_at are derived, not renderer-computed). Ending a turn appends a durable maestroTurnMeta footer with its elapsed time, outcome, model, and context-window usage/delta. ACP does not expose provider billing-token counts, so these are explicitly context tokens.
  • 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 the dependency decodes but nobody has taught the projection to special-case (falls back to Notice rather than erroring). A variant the dependency itself can't decode is a deserialization error, not a Notice — see The eight transcript row kinds.
  • 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, and a floor presentation for Notice. Plan is the exception: both clients omit it from the transcript; the TUI may show the current plan in its status line, while the web client deliberately has no stats or plan rail.
  • The web client groups two or more tool rows after the same User row into one collapsed turn activity block. Opening it reveals independently expandable tool calls, including any raw command, input, output, and diff content the adapter supplied.
  • The web client's user, agent, thought, tool-detail, and notice text blocks copy their complete source text when clicked or keyboard-activated.
  • The web client consumes available_commands_update notices as the live source for composer / autocomplete at any whitespace-delimited token under the caret. Completing a command replaces only that token. The client omits those state-only notices, along with session_info_update, from the visible transcript.
  • How live responding, thinking, tool activity, elapsed time, and context usage are drawn. The web client keeps these in a compact strip above the composer and renders each durable turn footer inline with the transcript.
  • 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 User and Approval rows share a cursor sequence with raw ACP JSON without pretending to be ACP traffic; the envelope also carries durable Turn footers). 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), and a restored Approval row carries no pending nonce, because the nonce never reached disk (see 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_row) 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.
  • 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

The web composer accepts up to ten files through its attachment picker, file drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes a UTF-8 .txt attachment. Shorter pastes stay in the composer. Attachments can be removed before sending; failed sends retain the draft and attachments. Pastes also stay inline when all ten attachment slots are occupied or the text exceeds the 20 MiB file limit.

On send, POST /api/sessions/:id/structured/attachments?name=<filename> uploads each file as raw bytes (20 MiB maximum) and returns its absolute path. Only live structured sessions accept uploads. Filenames are limited to 180 bytes and cannot contain directory separators or control characters. Files get unique names and private file permissions in <id>.attachments beside the session sidecar. They persist across daemon restarts; removing a composer chip does not delete uploaded data. There is currently no automatic attachment cleanup.

The prompt includes each filename and host path so the adapter can read it through its own file tools, including images when the provider supports them. These references are persisted in the ordinary user transcript row. Attachment bytes are not embedded in ACP messages or transcript history.

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 is appended as a durable User row before it claims the turn and dispatches to ACP, so the transcript never depends on an adapter echoing the operator's input. If that append fails, the prompt is not dispatched. 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": "user"|"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. An append that fails while the daemon is running — a write_all that errors after only a prefix reached disk, or a flush that errors after a complete write — is rolled back to the exact byte length the file was at before the call, so last_seq and the file's line count never disagree and the same log handle can be appended to again immediately (#184). If that rollback itself cannot restore the prior length, the append is fatal: the log's consistency can no longer be promised, so the caller kills the session instead of appending to it again.
  • <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: as long as the log rolled back cleanly, it only cancels the pending approval and rolls the unpersisted Approval row back out of the live transcript, without marking the session dead — a snapshot taken after the failure must never show a row durable replay could not reproduce (#184). If the rollback itself did not restore the log's prior length, this narrower path does not apply either: the session is marked dead the same as any other append failure, since the log can no longer promise last_seq matches its file.) 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). Success flips the session back to Running/Idle; 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.