acpmux: who types in a chat, as ephemeral presence (chat multiplayer, part 1 of 2)

Merged
TE
11d ago
+1125-18

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)

  1. #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.
  2. This PR: the _acpmux/typing method; presence state keyed by (connection, author); the _acpmux/presence notification; presence in 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.
  3. Part 2 (lane/hb-mp-view, PR to follow): viewing from _acpmux/view per author, plus the owner's visible attach. Until part 2 lands, viewing is 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/attach answers the same object as presence. There is one participant per person, and devices counts the connections that carry them. The hub never sets self.
  • 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/typing answers presence.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 marked remote by #25344. This daemon's presence never applies to such a subscription, even when the peer's raw id equals a local chat's: send_presence and 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 (typed TypingParams), _acpmux/presence (typed PresenceNote) and AttachResult.presence in protocol/mod.rs; the capability chat-presence-v1; docs/acpmux-schema.json; the README (Protocol, "Chat presence"). Regenerated: spec/acpmux-protocol.json and 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 as presence.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 off aborts it;
    • a newer on aborts 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.

  • Close race: release_all drains the subscriptions and closes the connection's presence under the subscriptions lock. Every presence change reads ended under that same lock, and ended is set before release_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 on expires 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/new made 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