acpmux: a guest's prompt waits for the owner

TE
5d ago
+2309-201

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.author also accepts guest:<opaque> ids, for a link guest who is on no tailnet and has no account. It also accepts approval: "owner". Any other approval value gets author.value_refused. The prompt's person_message provenance keeps approval: "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. Its queued record has awaitingOwner: true, revision: 0, and its prompt_accepted says awaitingOwner.
    • It is never steered in and never stops the running turn, whatever steer, steerNow or interrupt it 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.
  • _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 records queue_approved {promptId, turnId, revision, approvedAt}.
    • If revision is not the current one, the request gets queue.stale (data.revision is the current one). The owner can only approve the words they saw.
  • _acpmux/queue_decline {sessionId, promptId}. The prompt leaves the queue and never runs.
    • Its waiting session/prompt gets an error (-32000) with data {reason: "queue.declined", promptId, turnId}.
    • The log records queue_declined {promptId, turnId, revision, declinedAt}, then queue_removed {reason: "declined"}.
  • Owner only.
    • A request with any _meta.acpmux.author gets queue.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) with data.decided (approved, declined or removed) once the log says which.
  • The guest's own changes.
    • Edit still works. It keeps the prompt held at revision + 1, and the queue_updated record carries awaitingOwner and revision.
    • 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.
  • Cap. One author can have at most 16 prompts waiting for the owner. The 17th gets queue.too_many_held before 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, then queue_approved for that revision, queue_declined, or a removal.
  • Surface.
    • QueuedPromptSummary (session summary queue) and _acpmux/composers expose awaitingOwner and revision.
    • New records queue_approved and queue_declined are transcript kinds.
    • New capability queue-approval-v1 and feature queueApproval.
    • Updated: docs/acpmux-schema.json, the README method list, spec/acpmux-protocol.json, and the TS types regenerated with bun scripts/acpmux-protocol-codegen.ts.
  • _acpmux/queue_remove moved unchanged into server/requests/queued.rs, next to the two new methods. That keeps requests.rs from 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 gets queue.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 gets queue.not_author, an edit after approval gets queue.approved, and a late answer gets queue.not_found with decided. The 17th held prompt gets queue.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's queue_updated records name revisions 1 to 40 in log order. An edit is now logged under the queue lock that gave it its revision, as queue_approved and queue_declined already 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 gets queue.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 new fromAgentsOnly moved into server/requests/queued.rs with 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 warnings is 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 Autofix with [code]smith
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_approve runs the prompt in queue order at the revision the owner saw; stale revisions get queue.stale; _acpmux/queue_decline drops it and its writer answers queue.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 in queue_approved and 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_declined are 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 expose awaitingOwner and revision.
  • A held prompt and the owner's answer go only to the peer that listed queueApproval at handshake; an older peer gets queue.approval_unsupported and nothing is forwarded. A chat with a held prompt cannot move (transfer.held_prompts), checked against every bundle log segment.