acpmux: a guest's prompt waits for the owner
HOLD: superseded by #28859 (carries all of this plus the ratchet fixes and both Astra P1 fixes). Do not land this PR.
Leo (2026-10-28), on T3 Code's "Share with guest": "we want to be having, be able to do this kind of flow for CMux multiplayer ... we can work on the agent chat multiplayer." In that flow the owner shares a chat by link, "each message they send waits for you", and the owner approves each one: Approve sends it to the agent, Decline drops it.
This is the first PR of the plan in manaflow-ai/cmuxterm-hq#4766 (section 4, "First PR to build"). It changes acpmux only: no UI and no proxy change.
What it does
- Guest author.
_meta.acpmux.authoralso acceptsguest:<opaque>ids, for a link guest who is on no tailnet and has no account. It also acceptsapproval: "owner". Any otherapprovalvalue getsauthor.value_refused. The prompt'sperson_messageprovenance keepsapproval: "owner", and the hold is keyed on that field, not on the id prefix. - The hold. A prompt whose author carries
approval: "owner"always goes into the queue, even when nothing is running. Itsqueuedrecord hasawaitingOwner: true, revision: 0, and itsprompt_acceptedsaysawaitingOwner.- It is never steered in and never stops the running turn, whatever
steer,steerNoworinterruptit asks for (prompt_unless_held). - Every path that takes prompts out of the queue passes it by (
QueuedPrompt::aside): turn order, the digest of agent messages, the busy check of a retry or continuation, and a restart.
- It is never steered in and never stops the running turn, whatever
_acpmux/queue_approve {sessionId, promptId, revision}. The prompt waits no more and runs in its place in the queue as an ordinary queued prompt, never as a steer. The log recordsqueue_approved {promptId, turnId, revision, approvedAt}.- If
revisionis not the current one, the request getsqueue.stale(data.revisionis the current one). The owner can only approve the words they saw.
- If
_acpmux/queue_decline {sessionId, promptId}. The prompt leaves the queue and never runs.- Its waiting
session/promptgets an error (-32000) withdata {reason: "queue.declined", promptId, turnId}. - The log records
queue_declined {promptId, turnId, revision, declinedAt}, thenqueue_removed {reason: "declined"}.
- Its waiting
- Owner only.
- A request with any
_meta.acpmux.authorgetsqueue.owner_only. - From Web, approve and decline count as controlling the chat (
remote_guard.rs, the same check as a Web prompt). - Like a prompt, both are held to the Full-access machine (
full_access_hold). - Approve, decline and the author's remove are decided under the queue lock. A later answer to a prompt that no longer waits gets
queue.not_found(-32002) withdata.decided(approved,declinedorremoved) once the log says which.
- A request with any
- The guest's own changes.
- Edit still works. It keeps the prompt held at
revision + 1, and thequeue_updatedrecord carriesawaitingOwnerandrevision. - Remove still works.
- Move, send now and interrupt get
queue.awaiting_owner. - An edit after approval gets
queue.approved. - The owner editing the prompt still gets
queue.not_author.
- Edit still works. It keeps the prompt held at
- Cap. One author can have at most 16 prompts waiting for the owner. The 17th gets
queue.too_many_heldbefore it is recorded. - Resend. A resend of the same prompt id while the prompt is held waits on the first one through the prompt ledger, and no second copy is queued.
- Restart. The waiting state is rebuilt from the log alone:
queued {awaitingOwner, revision}, then edits, thenqueue_approvedfor that revision,queue_declined, or a removal. - Surface.
QueuedPromptSummary(session summaryqueue) and_acpmux/composersexposeawaitingOwnerandrevision.- New records
queue_approvedandqueue_declinedare transcript kinds. - New capability
queue-approval-v1and featurequeueApproval. - Updated:
docs/acpmux-schema.json, the README method list,spec/acpmux-protocol.json, and the TS types regenerated withbun scripts/acpmux-protocol-codegen.ts.
_acpmux/queue_removemoved unchanged intoserver/requests/queued.rs, next to the two new methods. That keepsrequests.rsfrom growing.
Not touched: the files of cmux#26707 (claude_stdio/steer_batch.rs, hub/steer_end.rs). The cmux host share proxy (agent_proxy.rs) only lets through a fixed list of agent-session-* commands, so a teammate cannot reach the new methods. Stamping approval: "owner" for an asker role comes in the next PR (plan item 2).
Risk
risk:high (labelled). Queue scheduling now skips prompts that wait for the owner, the same way it skips held ones. For a prompt with no approval, nothing changes: the new paths only run when the provenance carries approval: "owner", which only a stamped author sets today. The approval methods sit behind owner-only, Web-control and Full-access checks.
Tests
tests/hub_integration/prompt_approval.rs:
- A held prompt asked as a steer and interrupt still waits while the owner's own prompt runs past it. A resend of its id is a duplicate. Approve at its revision runs it under its author, not as a steer. An authored approve gets
queue.owner_only, and a stale revision getsqueue.stale. - Decline answers
queue.declined, and the turn never starts. - The guest's edit keeps the prompt held at a new revision. Move, send now and interrupt get
queue.awaiting_owner, the owner's edit getsqueue.not_author, an edit after approval getsqueue.approved, and a late answer getsqueue.not_foundwithdecided. The 17th held prompt getsqueue.too_many_held. - A restart between an edit and the approve keeps the new revision held, and the owner's prompt still runs past it.
racing_edits_are_logged_in_revision_order: two clients edit the held prompt 20 times each, concurrently. The log'squeue_updatedrecords name revisions 1 to 40 in log order. An edit is now logged under the queue lock that gave it its revision, asqueue_approvedandqueue_declinedalready were (review BLOCK at f86a8487: an edit logged after the lock let a slower, older edit land last, and a restart then paired a later approval with other words).a_restart_never_rolls_an_edit_back_to_an_older_revision: a log that holds revision 1's edit after revision 2's (as an older daemon could write it) restores revision 2's words. An approval of revision 1 getsqueue.stale, and approving revision 2 runs "second rewrite".
Ran on the merged head with cargo test -p acpmux --test hub_integration -- prompt_approval: 14 passed. cargo test -p acpmux --lib protocol (the IR check): 9 passed. Red on the tests-only commit d890b18e (the fix left out): a_restart_never_rolls_an_edit_back_to_an_older_revision FAILED, left: (true, 1) right: (true, 2), the restart restored the older revision. racing_edits_are_logged_in_revision_order passed there too: the race needs an unlucky interleaving and did not hit in 20 rounds, so it guards the order without proving the old bug.
A second base merge (234b0a8d) resolved conflicts with #27523 (inbox digest), #27660 (queue remove fromAgentsOnly) and #27685 (Stop holds agent messages):
- A queued prompt's wait loop keeps base's digest answer (
gone_reply) and the owner's decline (gone_or_declined). A prompt that still waits for the owner (take_queuedāOk(None)) frees its turn and waits on. queue_remove's newfromAgentsOnlymoved intoserver/requests/queued.rswith it.- An inbox digest never takes a prompt that waits for the owner (
digestible). logged_prompt's new-incarnation arm also clears the digest a requeued copy left.cargo clippy -p acpmux --tests -D warningsis clean. This VM's test runs hit its own TMPDIR/home permission checks (private-store refusals), so the merged head's acpmux tests are CI's.
The first base merge resolved conflicts with #27519 (_acpmux/queue_text) and #27536 (a carried turn queues behind it). Both methods stay registered, and base's two new enqueue calls take the held-prompt cap's error like the others.
Also: unit tests in author.rs, and the IR and capability tests. Results are in the cmux-ci receipt below.
No capture: nothing on screen changes. The only webview file touched is the generated protocol method list (webviews/src/agent-session/acpmux/protocol/generated/methods.ts, two new _acpmux/queue_approve / queue_decline entries); the Approve / Decline card is #26742.
Changelog
none
Agent session: agent session
š¤ Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Adds owner approval for prompts that link guests send in acpmux, the first step toward the guest chat flow in manaflow-ai/cmuxterm-hq#4766. A prompt whose author carries approval: "owner" queues and waits for the owner: it is never steered in, never stops the running turn, and the queue runs past it. Prompts without that flag behave exactly as before.
Changes
_acpmux/queue_approveruns the prompt in queue order at the revision the owner saw; stale revisions getqueue.stale;_acpmux/queue_declinedrops it and its writer answersqueue.declined. Both are owner-only, hold to Web control and Full-access rules, and the approved prompt runs under the approver's rules too (stricter control, trust gate, Full-access machine), recorded inqueue_approvedand restored on restart.- Every queued, approved, declined and edited record is logged under the queue lock that changed the queue, so a concurrent decline or edit cannot be lost to a restart; approve and decline bind to the specific queued prompt by its turn id, and
queue_approved/queue_declinedare indexed transcript kinds. - Guests can edit (bumps the revision, still waits) or remove their prompt; move, send now and interrupt get
queue.awaiting_owner. At most 16 prompts wait per author (queue.too_many_held); a restart rebuilds the state from the log (a slower, older edit never rolls back a newer revision), a waiting prompt wakes no child and holds no notice back, and summaries exposeawaitingOwnerandrevision. - A held prompt and the owner's answer go only to the peer that listed
queueApprovalat handshake; an older peer getsqueue.approval_unsupportedand nothing is forwarded. A chat with a held prompt cannot move (transfer.held_prompts), checked against every bundle log segment.