cmux daemon: bridge a link share to its hosted room, 3/3: the guest relay (cmux.share/1)

Merged
TE
6d ago
+1557-18

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:

FromTo the room
attach replysnapshot {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 recordrecords {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 recordsprompt_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 with share.too_many_held.
  • When a relay ends: a guest_left for 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 and end frames to the room are #26827's.
  • Reconnects: a guest reconnect (guest_joined again) starts a new relay with a fresh snapshot. A host reconnect does the same for every guest the hello names, after the daemon's guests.
  • 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_status coverage: a prompt whose last state record is older than the relay's snapshot and later pages gets no prompt_state from prompt_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 reported held once 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 PUT made without answering lives until the share's expiry, because the owner token its DELETE would 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, no msg). The watermark is 13.
    • The record acpmux sent during the attach follows the snapshot, to Ann only.
    • Ann's guest_prompt reaches acpmux as hers, with the g- id and approval owner, and she gets prompt_state held. After acpmux's queued {awaitingOwner} and queue_approved records, she gets prompt_state approved (held is not said twice).
    • prompt_status answers for the known prompt only, and older gets a page.
    • Bob, whose browser never joined, gets nothing.
  • Reconnects and kick:
    • Ann rejoining gets a fresh snapshot.
    • After a host reconnect, guests comes 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 hello names each guest with an open socket once; with manaflow-ai/cmux-platform#147 it also gives sockets (their count). Each guest socket then reports guest_joined / guest_left. Without a count the daemon ends a guest's relay on its first guest_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 Autofix with [code]smith
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 answers share.needs_sign_in). An unanswered provision returns share.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 hello it answers guests {active} first, redeem is answered by the registry's own atomic redeem, and kicks are persisted until the room answers kicked. 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_more when older ones remain), the watermark, chat title and status, and the guest's held plus most recently decided prompt states; then live records, older pages, prompt_state from the reply and acpmux's records, prompt_status answers, and guest_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 first guest_left ends 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 and prompt_status cannot 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 truncated and full_bytes; a 17th held prompt is refused with share.too_many_held. A relay keeps at most 32 calls and 2 history pages in flight; past that older gets an empty page that has more. A relay ends on the guest's last guest_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 sends agent-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_status covers 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 held once 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.