Guest page: watch a shared agent chat in any browser (cmux.share/1 transport, join and ended pages)
Leo's words (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." The guest opens a link in any browser with no install, types "Your name", joins, and watches the agent work live, "including command output and the agent's reasoning".
Plan and inventory: manaflow-ai/cmuxterm-hq#4766. Spec: leo-workspace design/chat-multiplayer.md, sections 3 (guest surface) and 4.3 (the room and its cmux.share/1 frames). This is part 1 of spec PR 7: the transport, the read-only live transcript, and the join and ended pages. The guest composer is part 2 (below).
What it does
- Link. The page reads
share_idfrom/g/<share_id>and the secret fromlocation.hash, then removes the fragment from the URL withhistory.replaceStatebefore anything renders. The secret is kept in the tab's sessionStorage only until the redeem works, so a reload before Join still works. The page also sets<meta name="referrer" content="no-referrer">. - Join page. Before redeem it shows nothing about the chat: "Join a shared cmux chat. The owner approves each message you send." One field, "Your name" (1 to 64 characters, one line; a control, bidi or zero-width character is refused, the daemon's rule), and Join. Join POSTs
/v1/chat-shares/<id>/redeem {attempt_id, secret, name}same-origin withcredentials: "include".attempt_idis 128 random bits kept in sessionStorage, so a retry after a lost reply is the same attempt. Errors map as follows:share.host_offlinesays to try again;share.expired,revoked,kickedandused/leftgo to the ended page; anything else says to check your connection. - Transport (
guest/shareSocket.ts).cmux.share/1overWS /v1/chat-shares/<id>/guest(cookie auth, same origin):- The snapshot comes first. Live
recordsthat arrive before it are ignored, and records withseq ≤ watermarkare dropped (also copies sent twice). - It handles
page(asked for witholder {before_seq}),presence,prompt_state,endandkicked. - Reconnect uses one one-shot backoff timer (0.5 s doubling to 30 s, with jitter); a snapshot resets it. A reconnect gets a fresh snapshot, then asks
prompt_statusfor this guest's unresolved client ids that the snapshot didn't answer (≤ 16). endandkickedstop it for good. No polling and no interval timers.
- The snapshot comes first. Live
- Live chat. The records (the daemon's
_acpmux/eventtranscript records) go into the pane's own model through a newAcpmuxDirectClient.fed(sessionId, listener)/feed(records, "snapshot" | "older" | "live"), the same fold the replay client uses, with no socket. They draw with the owner'sVirtualTranscript: reasoning, tool rows with output, plans and markdown, as in the app. No composer, pickers, session list, inspector or Session Info. A pending ask shows "Waiting for to answer" and its command. - No owner reach, as a class (from two review passes).
- No owner host (
guest/guestHost.ts, installed before anything draws).postNativegoes through a host the page sets, and here that host refuses before any page host or WebKit handler is looked for, so a guest page opened in cmux's own browser can't reach that app either. The chip host refuses too. There is no bookmark transport, no message-menu source and no pane action table. - One boundary (
conversation/GuestActionsBoundary.tsx). It provides an empty value for every owner action context: turn actions, shell, subagent nav/pane/stop, subsessions, session hover, started mentions, artifacts, image viewer, render pages, run in background, background output, failed sends, sign in, chat accounts, update offer, speed toggle, prompt focus and avatars.VirtualTranscript readOnlydraws all of itself inside it, minimap included, and so does the guest page's live chat. - No host fetches read only. The hooks that ask the host on their own do nothing: path and site info, the reply policy, pull request answers and existence, bookmarks, agent avatars, and scroll pacing. Reply images and media draw their text and mount no loader. The read-only page is marked, so the context-menu reporter and More report nothing from it. Shell rows draw no Stop or Open without actions.
- Rows draw read only (
readOnlyRegistry): an ask is a line that says who it waits on, a queued agent message is its received line, a paused agent pair has no Resume. Suggested tasks, started chats, a turn's preview page and its render frame draw nothing.
- No owner host (
- Teardown (from review). A
fedclient registers no page listeners.dispose()drops a pending frame's draw, andfeed()after it does nothing. The page disposes it on unmount,endandkicked. The header shows the chat title, "Shared by ", and who else is here (the faces stack, never this guest itself). - Ended page. "This link has expired", "The owner ended sharing", "You were removed" or "This link was already used". There is no retry.
- Layout and theme. Phone width works, and light/dark follow the browser (
prefers-color-scheme, live).
What I stubbed
The pane's model layer did not need forking. The adapter is fed/feed/dispose on the direct client, which reuses rebuild/reduce and the frame-batched emit, plus the null host and read-only boundary above. Code highlighting stays plain, because the bundle ships no highlight-worker.js (the same as the pane dev server). The chat.harness/status fields of the snapshot are not drawn yet.
What the room must serve (cmux-platform PR)
Build:
scripts/cmux-next/build-guest-page-web.sh # → webviews/dist/guest-page/
scripts/cmux-next/build-guest-page-web.sh --out DIR # → DIR
CMUX_GUEST_PAGE_BASE=/g/ (default) # the path prefix the files are served under
- The output is
index.html,assets/(hashed JS, CSS, fonts) andlocales/(en.js,<lang>.js,loader.js). - Serve
index.htmlforGET /g/<share_id>. Serve every other file of the output under/g/(/g/assets/...,/g/locales/...). - The page runs no inline script, so
script-src 'self'works. It connects only to its own origin:connect-src 'self'plus thewss:same-origin socket. - Size: the entry JS is 2.5 MB and its CSS 680 KB, before compression. Language and math chunks load lazily.
Captures
These are gallery states rendered with Chromium on cmux-ci at the head, with fixture content only.
| Dark | Light | |
|---|---|---|
guest-page.join (phone) | ![]() | ![]() |
guest-page.live working, reasoning and output opened | ![]() | ![]() |
guest-page.live asking (phone): only the owner answers | ![]() | ![]() |
guest-page.ended revoked (phone) | ![]() | ![]() |
The gallery entries and their variants are:
guest-page.join:empty,offline,bad-link.guest-page.live:working,asking,connecting.guest-page.ended:expired,revoked,removed,used.
guest-page.waiting (a held guest message) is part 2, with the composer that sends it.
Part 2
#26819, stacked on this PR: the guest composer, its own messages, typing, and guest-page.waiting.
Risk
Normal. This is a new, standalone web page; nothing in the app loads it.
- Shared code changes are additive and off by default:
fed/feed/disposeonAcpmuxDirectClient, used only by the guest page;setNativeHostinnative.ts, unset in the app;- the
readOnlyprop,TranscriptReadOnlyandGuestActionsBoundary, false or unused everywhere else. Each hook only skips its host ask when it is true.
- The pane's own tests for these rows still pass: agent pairs, incoming messages, permission rows, pending steers, the edited card, session offers, background tasks, reply images and media, bookmarks, and the minimap.
- The secret never reaches a URL, a referrer or logs from the page. It sits in the tab's sessionStorage until the redeem works.
- The page's authority is nil: admission and what the guest may read are the daemon's (spec 4.2).
Tests
All tests run on cmux-ci at the head:
test/guest-share-socket.test.ts, with an in-process fake room:- snapshot first, the watermark and dedup;
older/page;- a reconnect on one timer into a fresh snapshot, with
prompt_statusfor unresolved ids; - backoff that doubles, has a ceiling and resets;
end/kickedstop reconnecting;- bad frames ignored.
test/guest-redeem.test.ts:- the hash is removed from the URL and the secret kept until joined;
attempt_idis reused on retry;- name validation;
- redeem POSTs same-origin with credentials;
- error mapping.
test/guest-page.test.tsx:- Join stays off until a valid name is typed;
- a cut link shows a message;
- owner offline shows a message, and a retry is the same attempt;
- each final error shows the ended page with no retry;
- joined, the owner's transcript renders read only (no Allow/Reject, no editor), the ask names the owner, and the face stack leaves this guest out;
- the room's
endshows the ended page.
test/guest-fed-client.test.ts: the fed client draws a snapshot, folds a live record once and puts an older page in front. Afterdispose, no listener is left and a pending frame draws nothing; this failed before the fix.test/guest-read-only-transcript.test.tsx: drawn normally, the rows carry the owner's controls; read only, none.test/guest-no-host-calls.test.tsx, the class test.- It mounts
VirtualTranscript readOnlyover every row kind that has an owner action or a host read. Then it mounts the joined guest page. - A spy sits behind every host route (native, WebKit handlers, chip host, action table, message-menu handler and source, bookmark transport) and in every owner action context. The spies are installed after the guest host, as a page that forgot one would.
- The test hovers, right-clicks, clicks and presses Enter on every element, runs every timer and frame (fake timers, no fixed waits), and expects zero calls. A third test checks the guest host itself routes nothing.
- Reverting any one reviewed fix turns it red: shell actions, subagent Stop, the menu reporter, More, the image load, media, bookmarks,
link.inspect,pr.inspect. So does dropping the null native host.
- It mounts
bun run check(lint, format, typecheck, layer and style checks) andbuild-guest-page-web.sh.
Size: part 1 of the spec's PR 7, already split (the composer is part 2). About 500 of the counted lines are tests, and the transport has no other consumer to land with.
Changelog
none (the guest page ships with the hosted room, not the app)
Agent session: agent session
🤖 Generated with Claude Code







