acpmux: chat multiplayer A: person author, provenance, answeredBy, queue.not_author

Merged
TE
12d ago
+1034-63

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} and author(params), which reads _meta.acpmux.author. id is tailnet:<login> or user_<id>, one line of 1 to 256 bytes; name is 1 to 64 characters; login and node are optional, one line of up to 256 bytes each. Unknown fields are dropped, and anything else gets author.value_refused.
  • Which origins may send author: the local unix socket, and a peer link (servedAs peer: the owner's own machine forwarding its unix socket or app), the same way fromAgent survives forwarding. Everything else is refused with author.origin_refused, on every method: Web, the app pane (LocalApp) and a peer forwarding for its own Web client (via: web). server/remote_guard.rs check_inner enforces this. mark_forwarded keeps author (it still drops provenance), 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: a permission_respond for an ask already answered and still in the settled store passes the person check, but only to reach the not_found it then gets, which changes nothing. An allow that carries an author but no person proof is still permission.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?} on user_message, queued and steer records. A person's prompt is not an agent message: it has no agent sender, it doesn't skip the agent-pair person rule, and prompt_withdraw refuses it (withdraw.not_a_message).
  • _acpmux/queue_edit (every op except resume, including withdraw of an unread steer) and _acpmux/queue_remove refuse with queue.not_author unless 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 the person_message provenance; it is written on queue_updated, so a restart keeps it too. An owner's edit of an agent message still drops it, as before.
  • permission_decision.answeredBy gains {id, name} from author on permission_respond, next to a paired device's {device, feedItem}. A losing answer's not_found (-32002) now carries data {answeredBy, outcome}. answeredBy is null when the owner won or the ask was cancelled.
  • docs/acpmux-schema.json documents 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 Peer via: 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.answeredBy and outcome; null for the owner; no allow without the person's proof.
  • prompt_provenance::only_the_sender_changes_a_waiting_prompt and steer_now::only_the_sender_takes_back_a_persons_steer: not_author for 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::tests and permission_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