acpmux: a peer's chat under a local raw id never touches the local chat's attachment

Merged
TE
11d ago
+180-16

Chat multiplayer groundwork, split out of #25285 to keep that PR near the size limit. With this, a connection's subscription to a peer's chat never touches this daemon's own chat that has the same raw id.

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".

Split (each lands on its own)

  1. This PR: subscriptions to a peer's chat are marked remote.
  2. #25285: typing and presence (_acpmux/typing, _acpmux/presence, presence in the attach reply). It rebases onto this PR once this lands.
  3. lane/hb-mp-view: viewing from _acpmux/view per author.

The bug (on base today)

A peer can list a chat under a raw id that is also one of this daemon's chats. When that happens, a client's subscription to the peer's chat was booked against the local chat:

  • session/new made on a peer counted a local attachment (2 instead of 1). Closing that client then took the local attachment away.
  • On one connection, attaching to the peer's chat after the local one left the local attachment counted. Its close and view then changed the local chat's count and shown.

What changed

  • SubOpts.remote marks a subscription to a peer's chat. view::subscribe_remote sets it under the subscriptions lock on every forwarding path (attach, prompt/load/resume/fork, the fork's new id, session/new on a peer). If this connection held the local chat of that raw id, that attachment ends first, as a detach would.
  • view::subscribe_local (prompt/load/resume/fork on a local chat) turns a remote subscription back into a local one and counts it.
  • attach counts a subscription that was remote as new. detach and release_all count nothing for a remote one. _acpmux/view refuses a remote one ("attach to the session before _acpmux/view").
  • Conn::subscribe_with was removed (no callers left).
  • Out of scope, and unchanged from base: generic event delivery (deliver, mailbox receipts) for colliding raw ids.

No timers, observers or loops are added.

Tests

tests/peer_integration.rs (with a created answer added to the lying peer):

  • a_chat_made_on_a_peer_under_a_local_id_counts_no_local_attachment
  • switching_one_raw_id_between_a_local_and_a_peer_chat_keeps_the_count_even: local→peer→close, peer→view→close, local→peer→local, and peer→prompt to local.

Both fail with base's src, at run https://github.com/manaflow-ai/cmux/actions/runs/40273539201: "the peer's chat counted as this daemon's" (2 vs 1), and "the peer's chat replaced the local attachment" (1 vs 0). At the same run with this head: peer_integration 39, lib 886, hub_integration 435, remote_guard, local_app_origin and ephemeral_run all pass, and clippy -D warnings and fmt are clean.

Size: +180/-16.

Review

Codex gpt-6-astra at xhigh, through acpmux, reading only the mirror: SHIP at 3b7a4adecefd057a8896ebb8673ca86f34e9bf19. The same code passed 7 rounds as part of #25285 (rounds 2 to 6 found the raw-id cases this PR fixes).

No capture: daemon only, no UI.

Agent session: big-red
🤖 Generated with Claude Code