Guest page: watch a shared agent chat in any browser (cmux.share/1 transport, join and ended pages)

TE
5d ago
+3079-79

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_id from /g/<share_id> and the secret from location.hash, then removes the fragment from the URL with history.replaceState before 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 with credentials: "include". attempt_id is 128 random bits kept in sessionStorage, so a retry after a lost reply is the same attempt. Errors map as follows: share.host_offline says to try again; share.expired, revoked, kicked and used/left go to the ended page; anything else says to check your connection.
  • Transport (guest/shareSocket.ts). cmux.share/1 over WS /v1/chat-shares/<id>/guest (cookie auth, same origin):
    • The snapshot comes first. Live records that arrive before it are ignored, and records with seq ≤ watermark are dropped (also copies sent twice).
    • It handles page (asked for with older {before_seq}), presence, prompt_state, end and kicked.
    • 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_status for this guest's unresolved client ids that the snapshot didn't answer (≤ 16).
    • end and kicked stop it for good. No polling and no interval timers.
  • Live chat. The records (the daemon's _acpmux/event transcript records) go into the pane's own model through a new AcpmuxDirectClient.fed(sessionId, listener) / feed(records, "snapshot" | "older" | "live"), the same fold the replay client uses, with no socket. They draw with the owner's VirtualTranscript: 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). postNative goes 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 readOnly draws 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.
  • Teardown (from review). A fed client registers no page listeners. dispose() drops a pending frame's draw, and feed() after it does nothing. The page disposes it on unmount, end and kicked. 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) and locales/ (en.js, <lang>.js, loader.js).
  • Serve index.html for GET /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 the wss: 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.

DarkLight
guest-page.join (phone)join darkjoin light
guest-page.live working, reasoning and output openedlive darklive light
guest-page.live asking (phone): only the owner answersask darkask light
guest-page.ended revoked (phone)ended darkended light

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/dispose on AcpmuxDirectClient, used only by the guest page;
    • setNativeHost in native.ts, unset in the app;
    • the readOnly prop, TranscriptReadOnly and GuestActionsBoundary, 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_status for unresolved ids;
    • backoff that doubles, has a ceiling and resets;
    • end/kicked stop reconnecting;
    • bad frames ignored.
  • test/guest-redeem.test.ts:
    • the hash is removed from the URL and the secret kept until joined;
    • attempt_id is 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 end shows 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. After dispose, 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 readOnly over 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.
  • bun run check (lint, format, typecheck, layer and style checks) and build-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