acpmux: a peer's chat under a local raw id never touches the local chat's attachment
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)
- This PR: subscriptions to a peer's chat are marked
remote. - #25285: typing and presence (
_acpmux/typing,_acpmux/presence,presencein the attach reply). It rebases onto this PR once this lands. lane/hb-mp-view: viewing from_acpmux/viewper 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/newmade 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.remotemarks a subscription to a peer's chat.view::subscribe_remotesets it under the subscriptions lock on every forwarding path (attach, prompt/load/resume/fork, the fork's new id,session/newon 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.attachcounts a subscription that was remote as new.detachandrelease_allcount nothing for a remote one._acpmux/viewrefuses a remote one ("attach to the session before _acpmux/view").Conn::subscribe_withwas 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_attachmentswitching_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