acpmux: chat multiplayer A: person author, provenance, answeredBy, queue.not_author
Chat multiplayer, the daemon half, PR A of 2 (author, provenance, answeredBy, queue checks). PR B (hb-mp-presence) adds presence and typing.
The ask, word for word (subsession-card, relaying Leo): "A new ask for acpmux: multiplayer for regular agent chats. Leo, word for word (2026-10-26): "we can still do the regular chat multiplayer" and "we can just send some codexes to do whatever". The design doc is https://github.com/manaflow-ai/cmuxterm-hq/pull/4389 (plans/cmux-next/chat-multiplayer/DESIGN.md), under Codex review. My helper chat-multiplayer is building the webview, render-only behind an adapter. The daemon half is yours: 1. Provenance kind person_message {from, fromName, login, node}. It comes in as _meta.acpmux.author and is accepted only from unix-socket callers. Record it on user_message, queued and steer records, and keep it through peer forwarding. 2. answeredBy {person, name} on permission_decision, taken from author on permission_respond. A losing answer's not_found error carries data {answeredBy, outcome} (first answer wins). 3. An author check on queue_edit, queue_remove and steer_withdraw: refuse with queue.not_author. 4. Ephemeral presence, never stored in the log: _acpmux/typing {sessionId, on} (at most 2 per second, expires after 8 s); a _acpmux/presence {sessionId, participants:[{id,name,viewing,typing,self?}]} notification, and presence in the attach reply; viewing from _acpmux/view, per author. Person ids are tailnet:<login> until accounts are mapped. Codex xhigh review, then land, and reply with the PR number(s). Stop when they're merged."
This builds the contract hb-acpmux sent to the webview lane (#25253 renders it), items 1 to 4.
What changed
src/author.rs(new):Author {id, name, login, node}andauthor(params), which reads_meta.acpmux.author.idistailnet:<login>oruser_<id>, one line of 1 to 256 bytes;nameis 1 to 64 characters;loginandnodeare optional, one line of up to 256 bytes each. Unknown fields are dropped, and anything else getsauthor.value_refused.- Which origins may send
author: the local unix socket, and a peer link (servedAspeer: the owner's own machine forwarding its unix socket or app), the same wayfromAgentsurvives forwarding. Everything else is refused withauthor.origin_refused, on every method: Web, the app pane (LocalApp) and a peer forwarding for its own Web client (via: web).server/remote_guard.rscheck_innerenforces this.mark_forwardedkeepsauthor(it still dropsprovenance), and the hub that runs the chat stamps the provenance itself. - Trust: an author is only a label plus a restriction. It never satisfies the person rule (
hub/person.rs). The one change there: apermission_respondfor an ask already answered and still in the settled store passes the person check, but only to reach thenot_foundit then gets, which changes nothing. An allow that carries an author but no person proof is stillpermission.person_required, and a test covers that. It never unlocks a permission. Its only effect is to narrow who can change a queued prompt. session/prompt: an author is recorded as_meta.provenance {kind: "person_message", from: id, fromName: name, login?, node?}onuser_message,queuedand steer records. A person's prompt is not an agent message: it has noagentsender, it doesn't skip the agent-pair person rule, andprompt_withdrawrefuses it (withdraw.not_a_message)._acpmux/queue_edit(every op exceptresume, includingwithdrawof an unread steer) and_acpmux/queue_removerefuse withqueue.not_authorunless the caller's author id equals the prompt's. No author on both sides counts as equal (the owner). An agent message counts as the owner's. An edit by the same author keeps theperson_messageprovenance; it is written onqueue_updated, so a restart keeps it too. An owner's edit of an agent message still drops it, as before.permission_decision.answeredBygains{id, name}fromauthoronpermission_respond, next to a paired device's{device, feedItem}. A losing answer'snot_found(-32002) now carriesdata {answeredBy, outcome}.answeredByis null when the owner won or the ask was cancelled.docs/acpmux-schema.jsondocuments all of the above.
Stop conditions (new state, no timer)
hub/permission_settled.rs keeps each chat's settled answers by permission id so a later answer can be told who won. No task or timer is involved. An entry is dropped once it is 10 minutes old (checked on every write and read), when it falls out of the newest 64 for its chat, or when the chat goes away. SteerNow.waiting now maps each unread steer to its author (a HashSet before); entries are removed exactly as before, when the agent answers the steer.
Tests (red at 8a373933, green at the head)
prompt_provenance::an_author_is_taken_from_the_unix_socket_and_a_forwarding_peer_only: accepted from unix and Peer; refused from Web, LocalApp and Peervia: web.peer_integration::a_persons_prompt_through_a_peer_is_recorded_as_theirs_on_the_owning_daemon: a prompt forwarded through a peer is still recorded with its author.prompt_provenance::a_persons_queued_prompt_and_steer_record_who_sent_them: covers user_message, queued and steer records.queue_restore::a_persons_queued_prompt_stays_theirs_across_a_crash: provenance and the author check survive a SIGKILL restart, through the author's own edit.prompt_provenance::a_losing_answer_is_told_who_answered_first:data.answeredByandoutcome; null for the owner; no allow without the person's proof.prompt_provenance::only_the_sender_changes_a_waiting_promptandsteer_now::only_the_sender_takes_back_a_persons_steer:not_authorfor another author and for the owner on a person's prompt; allowed for the same author, and for the owner on the owner's own prompt or an agent's message.- Review findings, each with a test: a person's prompt takes no supersede key, new or restored (
prompt_provenance::a_persons_prompt_neither_supersedes_nor_is_superseded,author::tests::a_restored_persons_prompt_takes_no_supersede_key); a group answer and a Stop are recorded in the settled store under the same lock (permission_groups::an_answer_after_a_group_answer_is_told_the_group_won,prompt_provenance::an_answer_after_a_stop_is_told_the_ask_was_cancelled). - Unit tests:
author::testsandpermission_settled::tests. - Full acpmux run on fast-checks: fmt, clippy -D warnings, lib, hub_integration, durable_agents, peer_integration all green.
Size
About 920 changed lines: roughly 470 are source and the rest are tests and the schema doc. claude-sr2 cleared this size as long as the hand-written part stays under ~800.
Shared with PR B
src/author.rs (crate::author) is shared with hb-mp-presence's #25285, which carries an identical copy. Whichever PR lands second drops its copy.
No capture: daemon only; the webview lane draws it.
Agent session: big-red
š¤ Generated with Claude Code