acpmux: who types in a chat, as ephemeral presence (chat multiplayer, part 1 of 2)
Chat multiplayer, the daemon half: PR B, part 1 of 2. This part covers who types in a chat, as ephemeral presence (hq#4389 §5, §10.4).
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 ask, as subsession-card relayed it: "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."
This is built to the contract hb-acpmux sent the webview lane (item 5). The webview adapter is #25253, and PR A (author, provenance, answeredBy, queue checks) is #25268 (merged).
Split (each part lands on its own)
- #25344 (merged, 56921eba59a0): subscriptions to a peer's chat are marked
remote(SubOpts.remote,subscribe_remote/subscribe_local, and the count rules for attach, detach, view and close). This PR is rebased on it. - This PR: the
_acpmux/typingmethod; presence state keyed by (connection, author); the_acpmux/presencenotification;presencein the attach reply; the 2/s rate limit; the 8 s expiry; the people bound; cleanup on close and detach; the origin rule; protocol, schema and README. - Part 2 (
lane/hb-mp-view, PR to follow): viewing from_acpmux/viewper author, plus the owner's visible attach. Until part 2 lands,viewingis always false and an entry exists only while its person types.
What changed
hub/presence.rs(new) keeps presence in memory only, with one entry per (connection, author id) in each chat._acpmux/typing {sessionId, on}names its person in_meta.acpmux.author; with no author the person is{id: "owner", name: <$USER>}. An entry is removed when it stops typing, when its connection detaches the chat, or when the connection closes._acpmux/presence {sessionId, participants: [{id, name, devices, viewing, typing}]}goes to every connection attached to the chat on each change (server/fanout.rs). If a connection lags, it gets a snapshot of each chat it holds._acpmux/attachanswers the same object aspresence. There is one participant per person, anddevicescounts the connections that carry them. The hub never setsself.- Only this daemon's own chats have presence. A peer daemon's link is one connection carrying all of that daemon's clients, so it can stand for none of them: it makes no entry, its
_acpmux/typinganswerspresence.peer_unsupported, and it hears no_acpmux/presence. Typing in a chat that lives on a peer is refused on the forwarding daemon before forwarding, whatever the peer serves the link as, and that chat attaches with no participants. A connection's subscription to a peer's chat is markedremoteby #25344. This daemon's presence never applies to such a subscription, even when the peer's raw id equals a local chat's:send_presenceand the lag snapshots skip it, typing on it is refused, and replacing a local attachment with a remote one drops that connection's entries. The review (round 1) showed why relaying a peer's presence is unsafe: forwarded entries outlived their clients, and a raw-id relay let one peer speak for another's chat. Per-client presence across a peer link would need the forwarding daemon to name each client; that is a later step. - Registered:
_acpmux/typing(typedTypingParams),_acpmux/presence(typedPresenceNote) andAttachResult.presenceinprotocol/mod.rs; the capabilitychat-presence-v1;docs/acpmux-schema.json; the README (Protocol, "Chat presence"). Regenerated:spec/acpmux-protocol.jsonand the webview's generated protocol types. - The author parser and its origin rule come from #25268 (landed as d41c6b136a44); this PR uses base's
crate::author.
Who may send author
Accepted from:
- the unix socket;
- a peer daemon (servedAs=peer) forwarding for its own unix socket or app. On
_acpmux/typing, this PR then refuses it aspresence.peer_unsupported, as above.
Refused with author.origin_refused from:
- Web (remote dashboard, phones, read-only paired machines);
- the app pane over the WebSocket (LocalApp);
- a peer forwarding for its Web client (
via: web).
The author is only a label held in memory. It grants nothing: no permission, no mode, no answer.
Bounds and stop conditions
-
People per chat: at most 32 besides the owner. A new person past that is refused with
presence.full; anyone already in, and the owner, always get in. -
_acpmux/typing: at most 2 calls a second per connection, counted across all its chats (typing.rate_limited, retryable). -
Timer: the typing expiry, one tokio task per (connection, author, chat). It stops when:
- an
offaborts it; - a newer
onaborts it and starts the next one; - the entry is removed (detach, connection close) and dropping it aborts the task;
- otherwise it fires once at 8 s and ends.
A timer that fires checks its generation, so an older timer never ends a newer
on. - an
-
Close race:
release_alldrains the subscriptions and closes the connection's presence under the subscriptions lock. Every presence change readsendedunder that same lock, andendedis set beforerelease_all, so a request still running after the close adds nothing. -
Observer: one broadcast receiver per connection in its fan-out task, which ends with the connection (
fan.abort()). -
State: a chat's map is removed when its last entry goes, and a connection's rate window is removed when it closes.
Tests
Each test failed first on base (run https://github.com/manaflow-ai/cmux/actions/runs/40261745731: presence 8 of 8, plus the peer test).
tests/hub_integration/presence.rs covers:
- the attach reply carries presence, typing reaches attached clients, and the owner is named;
- an
onexpires after 8 s (paused tokio time); - the 2/s rate limit, with a separate allowance per connection;
- two authors on one connection, and one author on two devices;
- closing the connection, or detaching, removes its people;
- nothing reaches the store;
- an author from Web, LocalApp, or a peer via web is refused;
- the 32-person bound.
tests/peer_integration.rs covers:
- a peer's link makes no presence, and typing through it is refused;
- typing in a chat on a peer that serves the link as a dashboard token is refused before forwarding;
- a peer chat that shares a raw id with a local chat never hears local presence (on attach, and on
session/newmade on the peer); - switching one raw id between a local and a peer chat keeps the attachment count even.
Each peer test was added for a review finding and failed before its fix. The unit test in hub/presence.rs covers renewal, off, and close.
Green after rebasing onto #25344 (fast-checks https://github.com/manaflow-ai/cmux/actions/runs/40275405152): acpmux lib 901, hub_integration 454, peer_integration 42, remote_guard, local_app_origin and ephemeral_run all pass, and clippy -D warnings and fmt are clean.
Size, on top of #25344: +1125/-18 in 20 files. Of that, 163 lines are the spec IR, 63 are generated webview types, 333 are tests, about 20 are README/schema, and about 545 are hand-written product code. Its changed lines are the reviewed ones, except one regenerated import line that picks up base's new types.
Review
Codex gpt-6-astra at xhigh, through acpmux, reading only the mirror. Rounds 1 to 6 were BLOCK; each finding is fixed at this head (peer relay dropped; Web-served peer links; raw-id collisions between local and peer chats; the session/new path; a remote close decrementing a local count; local/peer switching; _acpmux/view on a remote subscription). Round 7: SHIP at 515e546442f73b2bef382db16ea3d757ec3a3a36. After the rebase onto #25268, Codex confirmed SHIP at 6f3fe76437868235da0124698cb942abc358df1f. This head is that patch less #25344's part (which landed on its own after its own SHIP), with the same changed lines.
The reviewer also noted one pre-existing, non-blocking issue: generic event delivery (server/mod.rs deliver) can still mix a local and a peer chat that share a raw id. This PR leaves it as it is on base.
No capture: daemon only; the webview lane draws it.
Agent session: big-red
🤖 Generated with Claude Code