cmux daemon: bridge a link share to its hosted room, 3/3: the guest relay (cmux.share/1)
This PR's own diff is its last commit, 87588f32559 (+696/-13). It sits on #26827 (the host socket), which sits on #26825 (provisioning).
Leo (2026-10-28), on Theo's T3 Code "Share with guest": "we want to be having, be able to do this kind of flow for CMux multiplayer ... we can work on the agent chat multiplayer." A guest opens a link in any browser, joins with a name, watches live, and each message waits for the owner.
Tracks manaflow-ai/cmuxterm-hq#4766. Spec: chat-multiplayer.md 4.3, which covers the frame table, recipients, ordering and sizes. The room is manaflow-ai/cmux-platform#6 and #8.
What it does
The bridge starts a relay for each guest whose browser is in the room, from the room's hello or guest_joined. The relay opens a same-user connection to this daemon, a socket pair served by the real connection path (handle_connection_with_permit). Its first line binds it with agent-session-proxy-bind {link_guest}, then it attaches. Every frame is therefore the bound guest's, and the daemon's per-frame checks apply:
- the share and the guest must still be live;
- only the guest verbs are allowed;
- every prompt is held for the owner;
- a revoke, kick or expiry closes the connection at the daemon first.
What the relay sends the room names only that guest:
| From | To the room |
|---|---|
| attach reply | snapshot {to, chat {title, harness, status}, records, watermark, has_more, prompts}. It holds the newest records that fit in 512 KiB, has_more when older ones were left out, and the guest's held prompts (at most 16) plus its 16 most recently decided. |
| each live record | records {to: [guest], records} |
older {before_seq} | agent-session-events → page {to, records, has_more}, also at most 512 KiB |
guest_prompt {client_id, text} | agent-session-prompt with the client_id (the daemon's deterministic g- prompt id), then prompt_state |
| acpmux records | prompt_state: queued with awaitingOwner → held; queue_approved → approved; queue_declined → declined; queue_removed / prompt_retracted → removed; a refused prompt → refused with its error code |
prompt_status {client_ids} | one prompt_state for each prompt the relay knows |
guest_typing {on} | agent-session-typing |
- Size limits: a record over 256 KiB is sent cut, as its seq, dir and kind with
truncated: true, full_bytes. A 17th held prompt is refused withshare.too_many_held. - When a relay ends: a
guest_leftfor the guest's last socket ends it, and so do a kick, a revoke or expiry (the bound connection closes, so the room never hears more of that guest). The kick andendframes to the room are #26827's. - Reconnects: a guest reconnect (
guest_joinedagain) starts a new relay with a fresh snapshot. A host reconnect does the same for every guest thehellonames, after the daemon'sguests. - Owner presence: the relay sends
agent-session-view {visible: true}after its attach, so the owner's presence shows the guest viewing.
Known limits
- Presence:
presence {to}is not relayed. The daemon does not give a bound guest acpmux's presence set; it says who else has the chat open (stream.rs: "Not to a teammate yet"). Relaying it needs that policy changed for guests, with what a guest may see of the others decided first. That belongs in its own PR. prompt_statuscoverage: a prompt whose last state record is older than the relay's snapshot and later pages gets noprompt_statefromprompt_status. The relay knows states only from records it has seen; it does not query acpmux's prompt ledger.- Prompt states need acpmux's held prompts: the states come from acpmux's held-prompt records (
awaitingOwner,queue_approved,queue_declined), which arrive with the acpmux held-prompts PR. Until then a guest prompt is reportedheldonce acpmux accepts it. - Orphan room after a restart (from #26825): if the daemon restarts after a create that timed out, that share is revoked locally. A room the
PUTmade without answering lives until the share's expiry, because the owner token itsDELETEwould need is never kept.
Risk
risk:normal: daemon-only. It is inert until a share is provisioned with an owner token (the share sheet / op PR), and only the room's development environment is live. The relay reaches the chat only through a bound guest connection, so it adds no new path to the agent or the machine.
Tests
These use the fake room from #26825/#26827 and the existing fake acpmux.
- Bounded snapshot and Ann's own records and prompts: the fixture's attach page is 200 + 200 + 300 + 200 KiB.
- Ann's snapshot is at most 512 KiB, keeps seqs 11–13 with
has_more, and sends seq 12 cut (truncated,full_bytes, nomsg). The watermark is 13. - The record acpmux sent during the attach follows the snapshot, to Ann only.
- Ann's
guest_promptreaches acpmux as hers, with theg-id and approvalowner, and she getsprompt_state held. After acpmux'squeued {awaitingOwner}andqueue_approvedrecords, she getsprompt_state approved(held is not said twice). prompt_statusanswers for the known prompt only, andoldergets apage.- Bob, whose browser never joined, gets nothing.
- Ann's snapshot is at most 512 KiB, keeps seqs 11–13 with
- Reconnects and kick:
- Ann rejoining gets a fresh snapshot.
- After a host reconnect,
guestscomes first, then a snapshot for each connected guest. - Kicking Ann ends her relay: her later prompt never reaches acpmux. Bob's still does and gets his
prompt_state.
- The provisioning and host-socket tests from #26825 and #26827.
CI receipts are in a comment below.
Changelog
none
Agent session: agent session
Room contract (cmux-platform src/server/ChatShareRoom.ts)
- Host
hellonames each guest with an open socket once; with manaflow-ai/cmux-platform#147 it also givessockets(their count). Each guest socket then reportsguest_joined/guest_left. Without a count the daemon ends a guest's relay on its firstguest_left(no connection outlives the guest; another open tab reconnects for a fresh snapshot). - The room refuses a guest prompt itself past 2/s and 32 KiB, and when no host is ready; the daemon refuses past 16 held or in flight (
share.too_many_held) and past 64 asks kept before the relay's attachment answers (share.busy). - Each relay connection holds one of the daemon's connection permits (
MAX_SERVER_CONNECTIONS).
🤖 Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Bridges a link share to its hosted room (cmux.share/1, cmux-platform's ChatShareRoom): create provisions the share in the room, each active share keeps one host socket, and each guest whose browser is in the room gets a relay that connects back to this daemon as that guest and feeds the room that guest's chat. The daemon stays the authority — every frame passes its per-frame checks, and revoke, kick, expiry, or a closed chat closes the share at the daemon first.
- Create provisions the share with the owner's cmux access token (never stored) before answering; the room is
CMUX_SHARE_ROOM_URL, else the build's (none for a standalone daemon), and ships inert outside development (a PUT answers 404,share.room_refused; a 401 answersshare.needs_sign_in). An unanswered provision returnsshare.room_unreachable, is retried in the background, then revoked with its room DELETE queued; a provision that answers after the share's expiry never opens the share (it is retired in its place). - The queued room DELETE is dropped only once the room acknowledges it — 2xx, or 404/410 as gone; any other answer, 401 and 403 included, is retried with backoff.
- Each active share keeps one outbound host socket: after every
helloit answersguests {active}first,redeemis answered by the registry's own atomic redeem, and kicks are persisted until the room answerskicked. A dropped socket reconnects with backoff; 4000 replaced, 4001 ended, 4003 and a refused upgrade stop it. Closing the agent chat closes its link shares too (end {reason: closed}). - Each guest's relay sends a snapshot first: the newest records that fit in 512 KiB (
has_morewhen older ones remain), the watermark, chat title and status, and the guest's held plus most recently decided prompt states; then live records,olderpages,prompt_statefrom the reply and acpmux's records,prompt_statusanswers, andguest_typing. Room asks before the attach answers are held until it does (past 64 refused,share.busy); daemon records before the snapshot wait for it (past 256 the relay restarts fresh). - A relay counts its guest's sockets from
hello(sockets), so one of two leaving does not end it; without a count the firstguest_leftends it. The 16-held cap counts undecided prompt ids in a per-guest ledger kept across host reconnects (forgotten on a kick or the share's end), so a burst cannot pass it andprompt_statuscannot evict it; retrying a decided prompt takes no slot, history pages apply the decisions they name (prompts decided while the guest was away release their slots), and the guest's client ids carry to the next relay so a reconnect's snapshot names those prompts' states. A prompt's state moves forward only, so a refused retry never undoes a decision. - Records over 256 KiB go cut as seq, dir, kind with
truncatedandfull_bytes; a 17th held prompt is refused withshare.too_many_held. A relay keeps at most 32 calls and 2 history pages in flight; past thatoldergets an empty page that has more. A relay ends on the guest's lastguest_left, a kick, revoke, expiry, or a closed attachment, and while the guest stays connected it restarts after a backoff (250 ms doubling to 30 s) with a fresh snapshot; a guest or host reconnect starts a fresh relay too, and a replaced relay's late frames are dropped. After attach the relay sendsagent-session-view {visible: true}so the owner sees the guest viewing. - A guest relay's daemon connection claims one of the daemon's connection permits like any client; with none free the guest waits and is tried again on the backoff. An unanswered prompt stays in the ledger and every new relay of the guest sends it again once attached (acpmux runs a prompt id once), so one in flight across a host reconnect is neither lost nor said twice.
Known limits
presence {to}is not relayed (the daemon does not give a bound guest the presence set); relaying it needs a policy decision on what a guest may see of others.prompt_statuscovers only prompts whose state records the relay has seen, not acpmux's prompt ledger.- Prompt states come from acpmux's held-prompt records, which land with a separate acpmux change; until then a guest prompt is reported
heldonce accepted.
Risk
- Daemon-only and inert until a share is provisioned with an owner token; the relay reaches the chat only through a bound guest connection, so it adds no new path to the agent or machine.