AGENTS.md · diff
git:20261016.dc6eb0a to git:20261022.127ab41
150 added, 102 removed. Audit B to B.
# Working in this repo (for agents)
- Shared Sessions for Claude Code: a Claude Code plugin (`plugin/`, function hooks) and a small room server (`server/`). A person shares a Claude Code session with a link; teammates join from their own Claude Code. See README.md for what it does; this file is how to set it up and change it.
+ Shared Sessions for Claude Code: a Claude Code plugin (`plugin/`, function hooks) and a small room server (`server/`). A person shares a Claude Code session with a link; teammates join from their own Claude Code. README.md says what it does; this file is how to set it up and change it, and the rules the code keeps that are easy to undo by accident. The code is the authority on details.
## Setting it up for someone
Use `scripts/setup.mjs`; it is non-interactive and safe to re-run. Every step prints one line, `OK …`, `NOTE …` (informational), `NEEDS HUMAN …` or `FAIL …`, and it exits 0 (all OK), 2 (a person must act) or 1 (failed). On `NEEDS HUMAN`, stop and relay that line to the person verbatim, then re-run the same command once they say it's done. Relay `NOTE` lines too.
It finds a Claude Code new enough for this plugin: `CLAUDE_BIN` if set, else `claude` on PATH, else (macOS) the newest one Claude Desktop bundles. If it FAILs on the version, set `CLAUDE_BIN=/path/to/claude` to a 2.1.286+ binary.
| The person wants | Run |
| --- | --- |
| only to join links teammates send | `node scripts/setup.mjs join` |
| to share (and join), and the team already has a server | `node scripts/setup.mjs host --server <https://…>` (includes `join`) |
| to share, and nobody has a server yet | `node scripts/setup.mjs deploy` (Cloudflare; prints the teammates' one-line install) |
| to know whether it works | `node scripts/setup.mjs check --live` |
- What `check --live` proves:
- - the plugin is installed and the saved server answers;
- - the server can create, seat, serve and end a room;
- - a real throwaway session shares using the saved setting, its room stays open when that session closes, and reopening it (`--resume`) to `/stop-sharing` ends it;
- - a real throwaway session joins a room from its link.
-
- Its throwaway sessions run in the system temp folder, and their transcripts are deleted afterwards. It can't see Claude Desktop's UI (the Share button, the Room panel): ask the person to look.
+ `check --live` proves that the plugin is installed and the saved server answers; that the server can create, seat, serve and end a room; that a real throwaway session shares with the saved setting, its room outlives that session, and reopening it (`--resume`) to `/stop-sharing` ends it; and that a real throwaway session joins a room from its link. Its sessions run in the system temp folder, and their transcripts are deleted afterwards. It can't see Claude Desktop's UI (the Share button, the Room panel): ask the person to look.
Facts that matter:
- - **Joining needs the plugin and nothing else: no server, no account, no sign-in.** The link carries its server. The exception is a session shared with a team whose access is "Only people in the team": it needs a sign-in (`/team`) with an account in that team. Sharing uses the public server unless the plugin's `server` option (what `setup.mjs` sets) or `SHARED_SESSION_SERVER` names another.
- - **Steps only a person can do.** Never attempt these yourself; `setup.mjs` stops at each with a `NEEDS HUMAN` line.
- - `npx wrangler login`, which is a browser OAuth approval.
- - Creating the Cloudflare account's `workers.dev` subdomain, once, in the dashboard.
- - Making the GitHub and Google OAuth apps for teams' sign-in on a server of one's own (README, "Teams on your own server"), and `npx wrangler secret put` of their ids and secrets: secrets a person pastes, never an agent.
+ - **Joining needs the plugin and nothing else**: no server, account or sign-in, since the link carries its server. The exception is a session shared with a team whose access is "Only people in the team", which needs a sign-in (`/team`) with an account in that team. Sharing uses the public server unless the plugin's `server` option (what `setup.mjs` sets) or `SHARED_SESSION_SERVER` names another.
+ - **Steps only a person can do.** Never attempt these yourself; `setup.mjs` stops at each with a `NEEDS HUMAN` line:
+ - `npx wrangler login`, which is a browser OAuth approval;
+ - creating the Cloudflare account's `workers.dev` subdomain, once, in the dashboard;
+ - making the GitHub and Google OAuth apps for teams' sign-in on a server of one's own (README, "Teams on your own server"), and `npx wrangler secret put` of their ids and secrets: secrets a person pastes, never an agent.
+ - **A share server is https**, except one on this computer (`secureServer`; the plugin refuses another, `PlainServer`, and so does `setup.mjs`, `validServer`): plain http would carry room tokens, sign-ins and the session unencrypted. A room saved on one isn't taken up again, host's or guest's (`plainSaved`, in `resumeHosting` and `resumeJoined`), so no room is ever active on one; `api` and the stream refuse one too.
- **A new `workers.dev` certificate takes a minute or so.** TLS handshake errors right after a first deploy are expected; `setup.mjs deploy` waits for it.
- - **Claude Code 2.1.286+ is required** (function-hook plugins), or 2.1.285 with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` (early access there; `setup.mjs` sets it in `~/.claude/settings.json`). 2.1.284 and older can't run it: they lack `session.append`. A `claude` on PATH can be older than Claude Desktop's bundled one; `setup.mjs` finds a new-enough binary (or set `CLAUDE_BIN`). Where the hooks module doesn't load (a version too old, a session started before the plugin was installed or updated, hooks switched off), `plugin/commands/share-session.md` answers `/share-session` instead with the fixes. Where it does load, the module answers the command itself under both names Claude Code lists, `/share-session` and `/shared-session:share-session`, so the markdown never reaches the model.
- - **A session keeps the plugin copy it started with.** After installing or updating, *new* sessions get it; an open Claude Desktop session needs the app quit and reopened. `/reload-plugins` re-reads the same old copy, so it does not pick up a new version.
- - **The plugin's commands** are `/share-session`, `/room`, `/who`, `/team`, `/invite`, `/share-file`, `/share-preview` and `/stop-sharing`. They are deliberately not `/share`, which is Claude's own.
- - **Sharing sends the session's whole history.** That's prompts, replies, tool calls and the first lines of tool results, sent to everyone with the link and through the server. Before sharing an existing session for someone, tell them that. The plugin asks too: Share in a session with prompts offers **Share everything** or **Only from now on**, and `/share-session` there answers with `/share-session all` and `/share-session new` instead of sharing. A fresh session shares at once.
+ - **Claude Code 2.1.286+ is required** (function-hook plugins), or 2.1.285 with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`, which `setup.mjs` sets in the settings Claude Code reads (`$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`). 2.1.284 and older lack `session.append`. A `claude` on PATH can be older than Claude Desktop's bundled one. Where the hooks module doesn't load (a version too old, a session started before the plugin was installed or updated, hooks switched off), `plugin/commands/share-session.md` answers `/share-session` with the fixes; where it loads, the module answers the command itself under both names Claude Code lists, so the markdown never reaches the model.
+ - **A session keeps the plugin copy it started with.** After installing or updating, new sessions get it; an open Claude Desktop session needs the app quit and reopened, and `/reload-plugins` re-reads the same old copy. Updates in place (below) are the exception.
+ - **The plugin's commands** are `/share-session`, `/room`, `/who`, `/team`, `/invite`, `/share-file`, `/share-preview` and `/stop-sharing`. `/share-preview <port or http://localhost:port/path> [title]` lets teammates open a local web server at the same address. They are deliberately not `/share`, which is Claude's own.
+ - **Sharing sends the session's whole history**: prompts, replies, tool calls and the first lines of tool results, to everyone with the link and through the server. Before sharing an existing session for someone, tell them that. The plugin asks too: Share in a session with prompts offers **Share everything** or **Only from now on**, and `/share-session` there answers with `/share-session all` and `/share-session new` instead of sharing. A fresh session shares at once.
## Changing it
```bash
cd plugin && npx -y -p typescript@5 tsc -p . # type-check against the engine's API (needs plugin/.claude/types, below)
claude plugin validate --strict plugin # what the engine and the plugin directory check
- claude plugin test plugin # UI tests (desktop + terminal surfaces)
- node test/local-preview.mjs # TCP helpers through a real Node room (no model)
- node test/tcp-worker.mjs # Worker TCP lifecycle and hibernation state (no model)
- node test/teams.mjs # accounts and teams on the server (no model)
- CLAUDE_BIN=$(which claude) node test/e2e.mjs # real host + guests through a local server (needs model access)
- EXTERNAL_SERVER=1 SHARE_SERVER=https://… node test/e2e.mjs # …or through a deployed one
+ claude plugin test plugin # plugin/tests: the UI tests (band.test.tsx, desktop and terminal) and the pure modules' (window, preview, proxy, upstream)
+ node test/setup.mjs # setup.mjs against a fake CLI and a temporary home, the deploy order included
+ node test/local-preview.mjs # TCP helpers through a real Node room; real Chrome and Firefox preview windows and their WebRTC when installed
+ node test/browser-proxy.mjs # the preview window's SOCKS5 helper, its DNS-over-HTTPS against a local TLS fixture (openssl for the test CA)
+ node test/discover.mjs # same-project service discovery, and who holds a port
+ node test/tcp-worker.mjs # the Worker's TCP lifecycle and hibernation, its public link, files and preview keys
+ node test/peer.mjs # messages and receipts between a guest's own sessions and the host's
+ node test/mailbox.mjs # the host's mailbox helper and relay/receipt.cjs through real Unix sockets (macOS or Linux)
+ node test/previews.mjs # preview shares, forwarding, Stop, needs, the public link, bounded bodies and events, a worst-case meta, the home page's claims
+ node test/teams.mjs # accounts and teams, the sign-in's confirmation, Continue in a real headless Chrome when there is one
+ node test/cloud.mjs # the cloud host's handoff through the core; with CLOUD_IMAGE and model access, a real runner in Docker
+ node test/security.mjs # where a room's files land on a teammate's computer (relay/save.sh) and where a guest's read really goes (relay/real.sh), through real links; names, the room page, what a seat holds open, uploads, and on the Worker team removals, what a room serves and the daily room count
+ CLAUDE_BIN=$(which claude) node test/e2e.mjs # a real host and guests through a local server (needs model access)
+ EXTERNAL_SERVER=1 SHARE_SERVER=https://… node test/e2e.mjs # …or through a deployed one (`npx wrangler dev` runs the Worker locally: SHARE_SERVER=http://127.0.0.1:8787)
```
- - `plugin/.claude/types/claude-code.d.ts` is the engine's API (copy it in from a Claude Code session with `/plugin-types plugin/.claude/types`; it is git-ignored). It is the authority on every hook, `$` call and element.
- - **Engine rules the validator enforces:**
- - `$` may only be passed to functions declared at the top of `register.tsx`;
- - `atom(...)` references use literal plugin and key strings;
- - a link element accepts only `https:` (or `http://localhost`) addresses.
- - **Behavior that shaped the design.** Don't undo these without new evidence:
- - **No long-polling from the plugin; updates come through a `curl` child.** While a plugin has a request in flight, the engine holds every prompt after the first until it returns. A child process's open stream holds nothing. So each shared session runs one `curl` (via `$.process.spawn`) reading `stream?after=<seq>`, one JSON line per room change. Riding turns read what it brings, too. Its address and token go to curl on stdin, never in argv. Without curl, or when the server has no stream, it polls: requests that return at once, every 0.4 s when busy, down to every 15 s when quiet.
- - **A host's step streams to guests while it runs, and only while someone's watching.** The host's `turn.step` passes each piece of the engine's stream to `liveFeed`: text, thinking, and a call being drafted go out as `delta` events (the room passes them on and never stores them; `send` joins pieces of one kind until the next post, 250 ms), and none at all when nobody else is online in the room. A call being drafted goes as a progress line (`drafting`: the tool, what it's for from the start of its arguments, `draftTarget`, and its length every 2,000 characters), never its arguments, which come whole in its row. A guest's ride yields the host's thinking as `thinking` chunks (the engine shows them live and doesn't record them; blocks numbered as they come, so thinking precedes the words), and its working line and the row above the prompt say what's happening this moment (`doing` on `working`: Thinking, "Writing notes.md · 4k characters", "Running npm test"). A plugin's tool card only exists once its call is made, so a call can't be streamed into a card. Older guests ignore the new kinds of delta.
- - **No prompts from inside another hook's dispatch.** A prompt submitted there is refused, so the plugin's prompts go out from a timer (`submitLater`).
- - **No running its own commands for output.** A plugin's own `$.command.run` skips that plugin's command hooks, so guest transcripts are drawn by riding turns (`turn.step` answered from the room, no model call) instead.
- - **Strip the app's context.** Claude Desktop puts a `<system-reminder>` into a first prompt. `typedText()` strips it before matching links or forwarding prompts.
- - **A guest forwards only what a person typed.** A guest's prompt goes to the host's Claude only from `GUEST_SENDS` origins (the prompt box, Desktop/SDK, Remote Control, an unattested same-user socket, a click's follow-up) and never `isMeta`: a channel's (Slack, OpenClaw), a peer session's or a task's message stays in the guest's session.
- - **Newer engines raise more than 2.1.295 did.** From 2.1.300 a subagent's run raises `turn.start` (with `agentId`), and a prompt typed mid-turn reaches `session.append` twice, as it's queued (door `queue`) and as it goes in; the host's hooks skip the first of each, or a subagent's brief went out as the host's prompt and its working line never cleared. 2.1.295 refuses a whole tree with a prop it doesn't know (`Text` `weight`/`size`/`mono`/`tabular`, a named `gap`), drawing the engine's own instead; the engine's version is in `$.env.get('AI_AGENT')` (`claude-code_2-1-300_…`), so a newer prop goes only where that says it's understood.
- - **Showing things in a guest's Claude Code happens in a turn.** A plugin's own call to a viewer, outside any turn, shows nothing: `SendUserFile` reports "delivered" but nothing opens, and auto mode refuses widgets and the browser pane. The same call made as a step of a turn (`turn.step` yielding a `tool` chunk) opens in Desktop's own viewers. So riding turns replay what the host's Claude showed as tool steps. The Files pane (`ccd_view.show_pane`) is the exception; it opens from a plugin's own call, which the Room panel's Open uses.
- - **A guest session never calls its own model.** Every turn there is a ride the plugin answers from the room. Rides are matched by the turn's text, then by its beginning, then oldest first, because the text a turn starts with can differ from what the plugin submitted. A turn that still matches nothing gets a short local note from `turn.step` without `next`, never a model call. Every later step of a guest's turn is answered too: a Stop hook in the guest's own settings can make the engine continue a turn the ride already ended.
- - **A riding step gets 10 s of its own time.** Each `turn.step` dispatch's budget counts the hook's own time, plain timers and `$.clock.sleep` included, and past it the engine runs the model in the hook's place: in a guest, that is the guest's own Claude answering the host's prompt on the guest's machine. A host's Claude can be silent far longer (a slow tool, a long think over a big context), so a ride waits through `pause()`, a `sleep` child, because the clock stops while a `$` call is in flight. The hook's `.catch` is the backstop: a guest step that throws or overruns ends quietly, and the host turn it was showing plays again after it.
- - **Pictures in tool results travel as room files.** A result's image blocks (a browser-pane or computer screenshot, an image `Read`) are taken from the row (`media`, never sent), uploaded (`upImages`, three at a time, under "What Claude shows") and named on the row by file id (`images`); the row waits in the outbox (`hold`) and everything after it waits too, so events stay in order without holding the turn. A guest's replay answers with an array of blocks, text and `{ type: 'image', source: { type: 'base64', … } }` (`withImages`, downloaded once per id): a registered tool's result may be a string or such an array, which the transcript keeps as the tool's image result and Desktop draws in the card; MCP's `{ data, mimeType }` shape is refused by the API. A shared history carries the newest 60 results' pictures. The terminal says them as it says its own (`Read image`, `[Image]`).
- - **A published page travels with its files.** An Artifact publish uploads the page and its `files` (a map or list, sources relative to `root`), each tagged with its place beside the page (`path`, made relative and inside by `pagePath`), as one `page` event. A guest saves such a bundle in a folder of its own (`.shared-session/<host>/page-<id>/`, `downloadShown`), each file at its place, and opens the page in the browser pane, where a page inside the project runs live and its relative `img/…` resolve; the Room's Open does the same. A page alone still goes as `send`.
- - **Claude asking for the share command gets the plugin's answer.** The model can invoke `/share-session` as a skill (`Skill`), which skips command hooks and would read the markdown fallback ("the plugin isn't running"). A `tool.call` hook on `Skill` answers it with the forked result shape (`{ success, commandName, status: 'forked', agentId, result }`, which passes Skill's output check) saying where things stand (`shareAnswer`); Claude never starts sharing itself. A page Claude publishes or opens again while hosting adds a note to its result (`context`) that it already went to everyone, so Claude doesn't send people after the private claude.ai link; an `open` re-sends the page with its files (`pagesA` by artifact id, else the newest publish in the transcript, `earlierPage`).
- - **The update command typed by a guest runs on the guest's computer** (`UPDATE_TYPED`, `updateHere`: `claude` on PATH, else Desktop's newest, with HOME and PATH passed), never sent to the host's Claude; a folder copy then catches up and reloads in place, otherwise it says to start a new session.
- - **The room knows everyone's plugin version.** The server keeps each seat's version from its requests (`x-shared-session-version`, on the curl stream too) and the host's, lists them in `people`, and wakes the room when one changes; the Room says "on X, needs an update" beside anyone behind the newest known (`latestKnown`), and the host hears it once per guest with the update command (`noteBehind`).
- - **A guest's attachments travel as room files.** A prompt's hook sees only its attachments' kinds; their bytes come with the guest's own stored message (`session.append`, door `prompt`, a person's origin), so the prompt waits for it (`heldPrompt`, 8 s at most, then goes with its words alone and says so), its images and PDFs go up as room files (guests may post files), and the prompt goes with their ids. The host saves them under `.shared-session/<guest>/` (`saveAttachments`) and names each in the prompt with how to see it (`📎 path (attached by …; open it with the Read tool to see it)`): a plugin's prompt carries words alone, a hook can't add media to a stored row ("carries media"), and a plugin's own `prompt.submit` hooks don't run for the prompts it submits, so the words are the only way to tell Claude. Read shows the picture in a card, to the guests too.
- - **Host tool calls replay as cards of the plugin's own tool, never the real one.** A guest's ride yields each host tool call as `mcp__shared-session__replay` (registered on join), whose `tool.call` hook waits for the host's result row (paired by tool_use id) and returns it; its `.catch` answers too, so nothing beneath ever runs. The `ToolUse` render hook draws it as the call it replays (`tool` rewritten to `Bash`, `Read`…). Answering a built-in tool's call instead would be checked against that tool's output schema, and a result that fails the check lets the real tool run on the guest's machine. Viewer calls (files, widgets, pages) are still made again for real (`replayOf`), so their rows stay out of the card replay.
- - **A join's history is a turn per earlier prompt, two steps each.** Only a turn shows a prompt as the person's own message (`$.session.append` takes notes alone), so each exchange is a ride: one step with its text and every call as a replay card whose result is preset from the room (`historyBlocks`, `hostCalls`), the next with its last words (`staticRuns`). Never a step per call: 25 prompts and 300 calls land in about 3 s.
- - **A host's room outlives its process.** `$.state` lasts only through a hot reload, so a host session that ends (anything but `/clear` or a logout) saves its room in a private checkpoint file under its session id (`keepHosting`), and `session.start` of the same id (a resume keeps it) takes it up again (`resumeHosting`), declining guest prompts sent meanwhile. A guest's place is kept the same way (`keepJoined`, `resumeJoined`): its sidebar row stays as it is, and reopened, it takes its seat back, or a new one if the room let the old one go after its grace, and plays the exchanges after its saved seq. Only Leave, `/clear` or a logout leaves for good. Claude Desktop can reopen a session under a new Claude Code id (a rewind, going on from an earlier message), so a saved place also carries Desktop's own id for the session (`CLAUDE_CODE_HOST_SESSION_ID`, saved as `place`; places saved before that match by the sidebar row's id, the same one), and a session that finds nothing under its own id takes the newest place saved under Desktop's (`savedKey`), ending older rooms of that session it never took back. The server never ends a room for its host going away; guests see the host offline after the 45 s grace, and a guest's prompt then gets a local note instead of going out. The room records the host's plugin version from its requests (`hostVersion`), so a guest's join note says when the host is too old for cards.
- - **Room recovery survives a change of plugin loader.** Since 0.11.20, `hosting:` and `joined:` checkpoints live in `${CLAUDE_CONFIG_DIR || HOME + '/.claude'}/shared-session/rooms`, outside the plugin code. Installed (`shared-session@claude-share`) and folder (`shared-session@inline`) copies have different `$.store` identities; putting recovery there lost the room on the first restart after Updates was enabled. Startup migrates the current identity's legacy checkpoints before `readUpdates`, and `nameLiveDir` refuses either loader transition if migration fails. Each checkpoint has a private file (directory 0700, file 0600), written through stdin to a unique temporary file and atomically renamed; migration instead publishes with `ln` only if absent. Canonical state always wins, even over a newer legacy timestamp. Consuming or deleting a checkpoint writes a small credential-free tombstone, so a stale legacy entry cannot resurrect a stopped or left room. Legacy entries are removed only after the canonical file is validated; failures retain the legacy data. Both loader copies must be 0.11.20 or later. A checkpoint stranded in another identity by an older version can migrate only after the fixed plugin loads under that identity. Do not read Claude's undocumented hashed store files. This covers room recovery, not the `live:` presence cache, invite cache, notification preferences or `JOIN_QUEUE`; those remain in `$.store`. Separate files protect unrelated sessions; concurrent writers for the same session retain the existing single-owner assumption.
- - **A reload redraws what's on screen.** `session.start` (which a reload fires too) calls `$.ui.invalidate('ui.render')`, so an update in place redraws the rows this plugin drew with the new code, in ongoing shared sessions, no resharing; what a row draws from must be in `$.state` for that (a terminal's printed scrollback stays as printed). A relayed message in a teammate's prompt draws as "Name relayed a message from X" over a quote, folded to its head until ctrl+o (`isExpanded`); the engine keeps `origin` and `from` read-only, so it can't be drawn as its own message row.
- - **Updates load in place, without a restart.** A session keeps the installed copy it started with, so with Updates on the plugin copies itself to `~/.claude/shared-session/plugin` and names it in `~/.claude/settings.json`'s `env`: `CLAUDE_CODE_PLUGIN_DIRS` (a plugin folder overrides the installed copy in every session, Desktop's included) and `CLAUDE_CODE_PLUGIN_DIR_WATCH=1` (long-lived sessions reload a folder whose files change). That copy asks the server for `/api/version` 15 s after start and every 30 min, and when it's behind, downloads `main`'s tarball, checks its version is newer, and replaces its files (`selfUpdate`, under a lock): every session running it reloads, `$.state` and shared rooms intact. Only a session with a surface does it (`drawn`): a `claude -p` run (a script, `setup.mjs check --live`, the e2e) can exit mid-download, which once stranded the lock and held every session back. The lock (`plugin.lock`) names the Claude Code process holding it (`owner`, the shell's `$PPID`), so a lock whose holder is gone is taken at once (one with no owner, after 3 minutes), and a session that finds it busy looks again in 2 minutes. Someone who turned on automatic updates before this is moved to it at the next session start (`readUpdates`), and a folder named but gone is made again. The folder copy also catches up from the installed copy when that is newer (`catchUpFromInstalled`, 3 s after start, no network), so the update command always works, and a download that fails says so once with that command. Every copy compares contents (`rsync --checksum`): two releases' files can match in size and second. The folder copy is configured under another id, so `serverOf` reads the installed copy's saved `server` too.
- - **Claude Desktop pins the installed copy for a local marketplace.** Desktop hands each session the installed folder of every `~/.claude` plugin whose marketplace is a `directory` or `file` source (a clone added by path) as a plugin folder, ahead of `CLAUDE_CODE_PLUGIN_DIRS`; with the same plugin name the first one wins, so the self-updating copy never runs there and a release loads only at a restart. A GitHub marketplace's plugin is left to Claude Code's own loader, where the folder copy overrides the installed one. The Room and the "is out" line say so, with the command that moves the marketplace to GitHub (`pinnedByDesktop`, `switchCommand`). Removing a marketplace takes its plugins, their options and its `autoUpdate` with it; with the folder still named, `readUpdates` turns `autoUpdate` back on (turning Updates off un-names the folder, so a named one means they were chosen).
- - **The host is asked, never answered for.** A guest's call is put to the host with `$.ui.ask` (AskUserQuestion). A settings hook on AskUserQuestion, or a host with no dialog, refuses that at once, which read as "Decline": so a refusal within 1.5 s, or an answer that isn't one of the options, asks in the row above the prompt instead (`approvingA`, `bandAnswer`, ten minutes, then no). A plain `-p` run draws no row and declines as before.
- - **Previews carry WebSockets.** Next.js 16's dev pages wait on React debug data sent over the dev server's socket, so without one they never hydrate (a blank page whose assets all load). A browser's socket on the preview host name goes to the room, which tells the host's stream (`{ ws: { id, port, path, protocols } }`, `announceSocket`); the host's plugin starts `plugin/relay/ws-relay.cjs` under Node (found like a dev server's, settings and token on stdin), which opens the local socket and one back to `ws/<id>` (host only), and the room passes messages between the pair as they are. On Cloudflare the Durable Object holds both halves, tagged `pb:<id>`/`ph:<id>` and paired by tag, so it sleeps between messages; `node.mjs` has a small WebSocket server for the same. A plugin's child takes stdin once (`input`), which is why the relay talks to the room itself.
- - **Raw TCP forwarding is the host's choice, never Claude's mention.** A port the host shares itself (`/share-preview`, or **Allow forwarding** on a preview in the Room's Shown page, `allowForwarding`) is created with `tcp: true` and listed in `forwardPorts`; one shared because Claude mentioned or opened it (`shareLocal`) is not, and opens through its link alone (the public HTTP gateway, as before 0.11.16). The room refuses a grant or a socket for a preview without `tcp` (403), and the host's plugin relays only ports in `forwardPorts`, with at most `TCP_RELAYS` (64) queued, connecting or active at once. Before 0.11.17 any port Claude's reply named was shared if anything answered, and forwarded as raw TCP: a guest could prompt "reply with http://localhost:5432" and reach the host's database. `shareLocal` now shares only a web server: an HTTP answer, or a connection held while it compiles (curl's 28); an empty or garbled reply (a database, a cache) isn't shared. A guest's listener starts only when they press **Open** (or restores one they opened before); a replay of a forwarded preview says to press Open, and one Claude mentioned opens its link. A room's TCP budget refunds each half's unused reservation when it closes (`tcpRefund`; `used` and `refunded` both only grow, so a reload or overlapping write can't reset spending), opens and grants are capped per seat too (`seatOpensPerMinute`, in memory), a seat dropped from the away list takes its grants, a grant's seat must still be the room's (`seatKnown`), the Worker writes `tcp:stats` only when it changed and schedules an alarm only for a grant made, and `POST …/tunnel` is rate-limited per network (`TUNNEL_LIMIT`).
- - **One Host worker, many TCP sockets.** `relayTcp` atomically reserves capacity and lazily starts `plugin/relay/tcp-worker.cjs` once per Host room; every preview shares that process, while each TCP stream still has its own WS pair. Its one stdin input carries the room, Host bearer, plugin version and an independent 256-bit `controlToken`. Subsequent `open`, `close-preview` and bounded best-effort `reject` commands use short authenticated HTTP POSTs to its ephemeral `127.0.0.1` listener (exact Host, no Origin, 4 KiB body, eight control sockets, two-second deadline), never a continuously written stdin. Keep the handle, capability, readiness, queue and identity module-local, never in state/store/files. The plugin continuously drains stdout (`ready` and `closed`, bounded per line), supervises startup at five seconds and each IPC at two, and ends the owned HookStream on failure; only new connections may start another worker. Before dispatch it rechecks the room, generation, explicit port grant and reservation identity. Stop fences that preview and cancels queued opens before closing its sockets; reset/session end revoke local work before slow UI or network cleanup. Do not replay old TCP bytes or POSTs.
- - **TCP capacity belongs to a seat, not a grant.** Server admission counts pending plus active channels once by their guest-side `ownerSeat`: 32 per seat across all grants/previews, 64 per room. The Host worker includes connecting sockets in its 64; a Guest listener bounds accepted and pending sockets across both IP families at 32. Grant POSTs and new WS opens share seat 240/room 1200 operations per minute; grant POST IP 60/min, grant rotation/expiry, the 1 GiB room byte budget and four-frame/256 KiB directional credits stay intact. Worker reconstruction reads ownerSeat from TCP attachments, never control-seat metadata. The Host alone may `POST ws/:id/reject` with exactly `{code:'BUSY'|'CONNECT'}` (at most 256 UTF-8 bytes); check auth and pending identity again after the body read, never close a ready pair. BUSY is WS 1013 (or handshake HTTP 429), AUTH only actual handshake HTTP 401/403. Guest `channel-error` and `channel-ready` are not listener failure or expiry: a refused/cancelled socket must not automatically renew its grant, change port, close other flows or silently switch to Public link. Show deduplicated BUSY/AUTH warnings, clear on success. Only an explicit Room Open after AUTH renews authorization, preferring the saved local port; BUSY reuses the listener. Both helper stdout readers bound individual lines, not a chunk that may contain many status rows. Keep FIN half-close semantics; no idle timeout to reclaim legitimate connections. Full behavior requires updated Server, Host and Guest; TCP protocol remains 1.
- - **A guest's local port is independent of the host's.** `ensureLocalPreview` covers Room Open and restoring a listener opened before (not replay). `plugin/relay/local-preview.cjs` binds only IPv4/IPv6 loopback, tries the preferred port, rolls back partial binds on conflict and chooses an OS-assigned free port; an explicit Local port stays exact and reports conflicts. Never kill another process or silently use Public link. The helper's `{state:'ready',port}` supplies the actual URL and Room mapping. `localPreviews` saves pid, host `port`, actual `localPort` and optional explicit `requestedPort`, never credentials; reload/resume restores mappings without reopening a browser. Node 20+ and `sh` are required on both machines. A member-authenticated POST to `previews/:pid/tunnel` grants a persisted single-pid/seat TCP capability; reissuing keeps the newest two per seat/pid and revokes older channels, so port edits/reloads do not exhaust the grant allowance. It goes to the guest helper on stdin and its outer WS Authorization header, never to application bytes, argv or browser URLs. The host's stream advertises `tcp=1`; `{tcp:{id,pid,port}}` starts a deduplicated owned relay only if `previewsA[String(port)] === pid`. A TCP socket is one WS pair; host connects fixed loopback before pairing, server sends ready, binary carries unchanged bytes, and credit challenges bound queues across Worker hibernation. FIN preserves half-close; broken streams are not replayed. Stop/Leave/end/expiry close connections/listeners, Open grants anew; no HTTP reconnect page or callback POST retry. TCP metadata uses a distinct attachment purpose, never a control stream's top-level seat. The existing Public link HTTP/WS gateway is separate. Check real Node transport with `node test/local-preview.mjs`, Worker attachments/reconstruction with `node test/tcp-worker.mjs`; the no-model TCP suite verifies alternate local ports and a single production Host worker, the UI suite drives explicit Open and lifecycle races, and the model CLI e2e verifies the Open instruction rather than claiming it clicked an unrendered Room. A changed local port still changes origin: fixed OAuth callbacks/absolute redirects/HMR URLs must match, localhost cookies are not isolated by port, and raw TCP leaves Host/Origin/CSRF enforcement to the app. No browser/session/system proxy is configured.
- - **The host shares a localhost address its Claude gives out** (`shareLocal`), like one opened in the browser pane: when "What Claude shows" goes to everyone and the port answers. Share with history, and a host session reopening its room, do the same for the pages the history opened (`navigate`, `preview_start`, `browser_batch`) or printed, each port at its last page (`localOpens`), and a live `browser_batch` open is shared like a `navigate`. Preview tickets carry the page's path, and the entry redirects there.
- - **The plugin's own turns go out one at a time.** Prompts submitted together reach the engine as one turn, so each ride waits for the last to finish (`laterRides`, `rideSubmitted`). A step that ends a turn says something, even after a call: an empty reply makes the engine ask for another step.
- - **In auto mode only an allow rule lets the plugin pin.** Desktop starts new sessions in auto mode (`source=auto_default` in its log). Its check that a sidebar call is about the caller's own session (which would let it through) misreads the id it's given, so every `ccd_sidebar.set_pinned` goes to auto mode's classifier. That classifier runs on the server and reviews only tool calls a model turn produced; a plugin's own call has no request behind it, so it gets no verdict (`server_not_requested`, "the request that produced this action did not ask for one"), whatever words come with it: `$.tool.call` with `consent` was tried live in 0.11.9 and refused the same way. Allow rules are checked before the classifier, so **Allow pinning** (above the prompt in Desktop and in the Room, shown once a pin was refused, `pinRefusedA`) adds rules for exactly `DESK_TOOLS` to `~/.claude/settings.json` on that press, never unasked (`allowDeskTools`). A refused pin is tried again after 20 s and a minute (`setPinned`); each refusal goes to the debug log and the store's `pinLog` (the last 30) with Desktop's reason. A row whose pin never took is pinned again when the plugin loads, an update in place included, and within a minute of pinning being allowed in any session (`repinMarked`, `repinIfAllowed`); once one took (`pinOk` on the sidebar state), later loads leave the row as the person keeps it.
- - **Auto mode refuses a plugin's step to the browser pane** ("no verdict"). When it does, the ride's next step offers the ready localhost preview link and the Room's Open, or a precise listener startup error. It never falls back to a public origin by itself.
- - **The browser pane runs a local page live only inside the project folder**; outside, it shows a static snapshot. That's why guests save files under `.shared-session/` in their project.
- - **Desktop-only things stay in Desktop.** Drawings (`Svg`) only on the `desktop` surface: a terminal's table can carry an Svg it paints as nothing, so the letters-and-dots fallbacks draw there. Claude Desktop's own MCP servers (`ccd_*`, `Claude_Browser`) are called only when `CLAUDE_CODE_ENTRYPOINT` is `claude-desktop`. The terminal paints a collapse mark (`[-]`) over the right end of the row above the prompt, so the row keeps 4 columns clear there.
- - **A plugin gets at most 4 MiB of an answer.** `$.http.fetch` cuts a response body at 4 MiB of bytes without a word (measured: 8–64 MB of ASCII all come back as 4,194,304 characters, Chinese as 1,398,102), and a long session's history (5000 events, up to 32,000 characters each) is many times it. So a join and an events read ask for pages (`?max=` in bytes, `PAGE_BYTES`): the server sends events oldest first while their UTF-8 fits, with `more` and the cursor at the last one sent (`pageOf`), and the plugin reads on (`eventsSince`, a join's rest from `historySeq` up to the join's `seq`). A plugin that doesn't ask gets the newest events that fit (`newestOf`), never a body cut mid-JSON. `api()` reports an answer that doesn't parse as a failure, not an empty one, and only a request that got no answer as unreachable (`NetworkError`); a join that fails after its seat was taken gives the seat back. The curl stream has no such limit.
- - **History is read from the transcript file**, not `session.messages`: after a compaction that holds only the summary onward, so a long session shared only its last stretch. `sessionHistory` reads `projects/*/<session id>.jsonl` (summaries and subagents left out), the newest 6000 messages; Share sends the newest 4000 rows (a room keeps 5000 events). A plugin's own file read refuses anything over 4 MiB, and a long session's transcript is many times that, so it reads through a child, newest line first (`readBackward`: `cat`/`head -n` | `tac` or `tail -r` | `grep` for user and assistant rows), stopping once it has enough, with pictures kept for the newest 60 results only. A session reopened after a compaction goes on in a new file that starts at the summary: its oldest message's parent is a row in an earlier file of the same project, read on from that line back. Without this, a big or reopened session counted no earlier prompts (so Share didn't ask) and shared only from its last compaction.
- - **A guest's Stop hooks don't run** (`classic.Stop` answered in guest mode): every turn there is a replay, and a person's Stop hook (a notifier, a channel bridge) cost over a second per replayed turn (16 s vs 1 s for ten prompts) and could keep a ride going. A host turn nobody typed (a background task finishing) is labelled from its task notification (`startedBy`), not "(continued)".
- - **The terminal draws a call by the tool that ran**, so the replay tool's rows there are the plugin's own tree (`terminalName`, `replayLines`: `Name(summary)` and the folded result the terminal shows for the real tool), and its `ToolResult` draws nothing; Desktop draws the renamed call as that tool's card. The engine groups calls by the tool that ran, and a registered tool can't ask to be grouped (`$.tool.register` takes name, description and schema), so the plugin keeps its own runs in a per-call state family (`replayRun`, written by `saveRun`: the run's tools on its first call, `hidden` on the rest; state outlives a reload, module variables don't) (`joinRun`: consecutive Read/Grep/Glob/Bash/LS replays with no words between, a history step's whole run at once, a live turn's growing with `$.ui.invalidate`): the run's first call draws the count line in the terminal's words (`runLine`), the rest draw nothing; the guest's Room setting "Tool calls: Each shown" draws every call instead (ctrl+o can't expand a plugin's row). A prompt a plugin submits doesn't reach the `UserMessage` hook in the terminal (it draws its own "Prompt from the … plugin" row), so names there stay `Name: …`. The Room opens with `focus`, `closeOnEscape` and `rows`: inline in a terminal it is otherwise a third of the screen and can't be reached by keys.
- - **Testing the terminal by hand or by script:** drive `claude` in a pty and keep reading its output the whole time. An undrained pty blocks the app on write, which looks exactly like a hung turn (no log lines, a late stream watchdog).
- - **Text that truncates beside buttons sits in a Box with `minWidth={0}`.** A flex item doesn't shrink below its content's width without it, so a long title (a session's first prompt, often an @-mentioned path) pushed Join and Dismiss off the row; the buttons' Box keeps `flexShrink={0}`. Titles in narrow rows go through `tidyTitle` (the notes Claude Code writes into a message, `[Image: original …]` and `[Pasted text …]`, left out by `promptWords`, which Share also makes a new room's title with; absolute paths three deep or more shortened to their last part; URLs and short paths left).
- - **Drawings fit the engine that runs them.** 2.1.299 draws a type scale (`Text` `size`, `weight`, `mono`, `tabular`) and named spacing (`gap="sm"`); 2.1.295 refuses a whole tree holding any of them and draws its own. Everything is written for the newer engine and every hook that uses them returns `fit(tree, modern)` (`fit.ts`): on an older engine, heavy weights become `bold`, the rest are left out and steps become cells. `modern` is read from `AI_AGENT` (`claude-code_2-1-299_agent`) on every `view()`. The `Client` modules (`live.tsx`, `timeline.tsx`) take a `modern` prop for the same reason. Tests run as 2.1.299 (the world's `AI_AGENT`); a copy of the suite with the world on 2.1.295, run by Desktop's 2.1.295 binary, must pass all but the test that asks for the type scale. `plugin/.claude/types` should be at least 2.1.299's API.
- - **The engine's own rows, kept and added to.** A `ui.render` hook's `await next(e)` is core's drawing (`{ type: 'engine', ref }`), which a `Box` may hold (a `Text` may not): a person's prompt keeps Desktop's bubble under their name chip, a reply keeps its text with "◆ Claude, for X" over its first block (`replyFor`, by message id, from `session.append`) and reactions and who saw it under it, the terminal's turn line keeps its words, a tool row keeps its card under "↳ for X". In tests nothing answers beneath the plugins: answer `ui.render` with `{ type: 'engine', ref: 0 }`.
- - **The room beside the transcript is built from its events, live and replayed.** `absorb` (live, and for every history replay: a join, a guest catching up, a host resuming) keeps the turns (`turn` start/end: the Timeline), the files changed (a file call's `row` and its `result`: the host's hunks as `patch` when it sent them, `patchOf` the tool's `structuredPatch` in the host's `tool.call`; else worked out from the call's input, `diffOf`/`writeOf`), the calls counted (in memory, `flushCalls` once per page), reactions (`react`) and who saw each reply (`seen`), each filed under `replyKey(text)`, the same on every machine. Guests send `seen` for replies their transcript draws on screen (`onScreen` not null), batched four seconds after the first. The server keeps only well-formed `react` and `seen` (`tidyMark`). `@name` in a turn's prompt or a chat line (`mentionsOf`) toasts the person named, pings, and suggests `@who ` in their prompt box. Sounds (`plugin/sounds/*.wav`, `cue`) and the voice follow the person's `prefs` in `$.store` (`prefsOf`): a ping for mentions and a chime when their prompt starts by default, joins only under "all", the voice and the status line off until chosen. A Timeline click posts its turn from the `Client`, and the hook scrolls the transcript to that prompt's row (`promptRows`, filled as prompts draw).
- - **What moves, moves on the surface's clock.** "Claude for Kyle 0:42 · Writing notes.md" above the prompt and the Room's "now" block are a `Client` (`live.tsx`) ticking from the elapsed time the hooks hand it, so the plugin never redraws for a timer; the Timeline is a `Client` too (`timeline.tsx`: pointer, ←/→, `post`). A `Client` draws with the terminal's elements (no `Svg`) and must not read the hooks' clock. The live dot is a plain image with a CSS ring (`liveSvg`), still under reduced motion.
- - **One look, one row.** Everyone has a color of their own (`look.ts`: `PEOPLE`, eight hues at one OKLCH lightness that read as a fill under a white initial and as a bold name on both themes; `colorsFor` gives them in the room's order, the host first, a clash taking the free color farthest round the wheel, so every member of a room computes the same colors and a newcomer never repaints anyone). Live is the theme's `claude` orange (`chip`), status words take theme keys (`success`, `error`, `warning`, `permission`). The row above the prompt is one row whatever happens: an invite, the share question and a teammate waiting for the host's OK take it over while they last, a refused pin is a **Pin** button in it; nothing adds a line under it. The Room and the Team panel each put what they're about on top and the rest on pages (`tabsOf`, `roomTab`/`teamTab` in state, a digit each in a terminal). A dropped connection and its return stay out of the transcript (`lostAt`); the Room's chat timeline keeps them.
- - **A Fragment draws as a column Box, and a Button holds only strings and Text.** `<>…</>` in a row stacks its children vertically, and inside a Button it's refused ("holds strings and Text, not <Box>"), so rows take arrays of keyed children and a Button's colored parts are an array of Text.
- - **Plain images, not interactive art.** Interactive (sandboxed-frame) art paints an opaque box on dark themes, so the drawings are plain images styled with `prefers-color-scheme`.
- - **Teams live in the server's directory, and rooms only ask it.** `server/teams.mjs` (`Directory`, over a key-value store: a Durable Object's storage on Cloudflare, `DATA_FILE` on Node) holds accounts, teams, invites and each team's list of open sessions. A room asks it two things, `shareCheck` (may this account share with this team) at create or `POST rooms/:id/team`, and `joinCheck` (the team's access as it is now) at every join, and tells it `register`/`unregister`; on Cloudflare those go over `/rpc/*` on the directory's stub, which the Worker never forwards from outside (`Directory.owns` lists the public paths). A list asks each room for its info and drops the ended and expired.
- - **A sign-in's token never travels in an address or argv.** The plugin opens `/auth/<provider>/start?login=<sha256 of a secret>` (`startSignIn`), and collects the token by POSTing the secret to `/api/auth/poll` every 2 s (delivered once; the server keeps only hashes of tokens). The token is saved per server in `~/.claude/shared-session/account.json` through `sh` with `umask 077` (`saveSignIn`, the JSON on stdin), and sent only to the server that gave it: `Authorization` for the team API, `x-shared-session-account` on a share or a join. `SHARED_SESSION_NO_BROWSER=1` opens nothing and raises no notification (the e2e reads the link from `/team signin`'s reply instead); `AUTH_TEST=1` on a Node server adds the `test` provider, which signs in whoever its link names.
- - **Invites go to accounts, and a machine asks for them once.** A host invites teammates (`POST rooms/:id/invite`, the room's host token plus the host's sign-in; the directory takes only people who share a team with them) and the directory keeps one per person and room (`sinv:`, 12 hours, dropped when the room ends). Inviting again replaces it with a new time, so it comes back for someone who set it aside and counts as new on their machine (an invite is known by room and time, `inviteKey`: notified again, said again); the Room keeps **Invite again** beside "Invited 2m ago" until they're in. Every session reads a shared copy in `$.store` every 10 s (`pollInvites`, `INVITES_KEY`), and the one that finds it older than 30 s asks `/api/me/invites` for the machine, so ten open sessions make one request. An idle session's row above the prompt shows the newest ("Sam invites you", **Join** the one primary, **Dismiss**); a session that's in a room (every open session can be) shows it as a button in its own row, in place of Invite and Copy link, which opens the Team panel's Live page, and says it once in a toast (before 0.11.14 it showed nowhere there); the Team panel lists all of them; answering one drops it on the server and in the shared copy (`dropInvite`), and Join joins the way the Team panel does (`joinInvited`). The session that fetched new ones raises a notification for each, once for the machine (`notified` in the shared copy, `notifyInvite`): `$.ui.notify` where a terminal channel sends it, else (Claude Desktop has none, `no-surface`) `osascript display notification` or `notify-send`; off by the store's `inviteNotify`, and never under `SHARED_SESSION_NO_BROWSER=1`.
- - **"Join" joins here only in a fresh session.** A session that is idle with no prompts joins in place (`joinHere`: `join`, then a ride as pasting the link gives); anything else opens a new Claude Desktop session with `claude://code/new?q=<link>` (Desktop prefills the prompt, it doesn't send it: the person presses Enter), or in a terminal copies `claude '<link>'`. Desktop's own `start_session` tool is gated and takes a Claude task brief, so it's not used. A session holds one room, so **Join all** is a session per room, and since Desktop holds one new-session draft at a time (a second `code/new` replaces the first), the rest wait in `$.store` (`JOIN_QUEUE`: the room being joined, the rest, ten minutes) and the session that joins one opens the next (`continueJoins`).
- - **Release:**
- 1. Bump `plugin/.claude-plugin/plugin.json` `version`. Claude Code updates by version, not by commit: a push without a bump reaches nobody.
- 2. Run the checks above.
- 3. Push to `main`, then deploy the server (both Workers), in that order. The server reports the version it was deployed with as the newest; plugins that update in place download `main` when that's newer than theirs, so `main` must already have it. The others show "Version X is out" against it. Installs with the marketplace's `autoUpdate` on (Room → Settings → Updates, or what `setup.mjs` sets) update within minutes of a session starting; others with `claude plugin marketplace update claude-share && claude plugin update shared-session@claude-share`.
- 4. When an older version must stop being used (it misbehaves), raise `PLUGIN_MIN` in `server/core.mjs` and deploy *after* the push: the server then turns that version away from sharing and joining, with the update command in the message. Every plugin sends its version as `x-shared-session-version`; one that sends none is older than this scheme and counts as too old.
- - **Server:** `server/core.mjs` is shared by `node.mjs` (Node 20+) and `worker.mjs` (Cloudflare Worker + Durable Object, deploy with `npx wrangler deploy`). Keep `wrangler.toml`'s `compatibility_date` no newer than the wrangler you deploy with supports.
- - **Nothing runs on a timer while a room is quiet.** Who is still here is settled by the next request, or by an alarm a disconnect sets for when its 45 s grace ends. The only other timer is the room's expiry.
- - **A seat let go comes back.** A guest with no connection for 45 s (a laptop asleep, a network gap, routinely) is let go (`sweep`, "lost connection" in the Room), but its token is kept (`away`, the newest 100, stored with the room) and seats it again as it was the next time it's used (`comeBack`, from `auth`). Before this, the next request got a 401 and the plugin told the guest the session had ended: that was most of "everyone keeps dropping". The plugin also takes a new seat itself on a 401 (`reseat`, `stillSeated`: the stream, a poll, or the outbox, whose batch is kept and sent again), and only a room that's gone (404, 410) or a join it refuses ends its place. Leaving on purpose (`left`) isn't kept.
- - **One person, one entry, wherever they are.** A share and a join carry the person's sign-in when there is one (`x-shared-session-account`), so the room knows seats by account: `people` lists a person once with all their `seats` (the host's own other sessions fold into the host), a second session of theirs adds no "joined" and its leaving no "left" while another stays, a team's `sessions` list who's in each by account (`accounts`), and invites skip people already in. The plugin finds itself by `seats` (`isMe`). On one computer, each session in a room writes `live:<room>:<session>` to `$.store` (`holdRoom`, at share, join, resume and every minute; three minutes unwritten is gone): a pasted link, the Team panel's Join and an invite's Join open the session already in that room (`openHeld`, `claude://claude.ai/epitaxy/<Desktop id>`) instead of joining twice; the same link pasted again within two minutes joins anyway. The Team panel shows **Open** for those, says "you're in it elsewhere" for rooms the account is in on another computer, and **Join all** and the Team button's count leave out everything that's already yours.
- - **On Cloudflare, streams end at the Worker, not the Durable Object.** A Durable Object holding an open HTTP response is billed for every second. So the Worker holds the client's stream and talks to the room over a WebSocket the object accepts with the hibernation API; the object sleeps between changes. A closing socket is still listed by `getWebSockets()` while its close is handled; `worker.mjs` tracks closed ones itself.
- - **Every stream ends itself after 5 minutes** (`STREAM_MAX_MS`), and the plugin reconnects at once. Presence doesn't depend on noticing a dead connection (a sleeping laptop's can look alive for many minutes): a client that's gone just never reconnects. A stream that ends within a minute backs off instead, so a proxy that cuts streams short isn't hammered.
- - **A room loaded from storage starts its seq numbers past any it may have handed out** (`Room.resume`), because live events (deltas) are never stored.
- - **Previews run on a second Worker from the same script** (`wrangler.preview.toml`, `ROLE = "preview"`), its own workers.dev host name, so an app's root-relative paths work. A ticket becomes a cookie there; it lets in one browser, which may use it again (a reload, a reopened pane), and no other. Requests go room → host's stream → the host's plugin (`sh` + `curl` against localhost, only ports it shared) → `POST proxy/<id>` back.
- - `npx wrangler dev` runs the Worker locally (real Durable Objects and WebSockets); point the e2e at it with `EXTERNAL_SERVER=1 SHARE_SERVER=http://127.0.0.1:8787`.
- - **The public server.** The plugin shares through `SERVER_URL` (`plugin/hooks/server.ts`) unless its `server` option or `SHARED_SESSION_SERVER` says otherwise. Running it:
- - **Costs** are bounded by `LIMITS` in `server/core.mjs` (enforced on Cloudflare by the `[[ratelimits]]` bindings in `wrangler.toml`, which must say the same), `ROOM_SEATS_MAX`, `ROOM_FILES_MAX` and the 24 h expiry. Cloudflare has no hard spending cap; a person sets a budget alert in the dashboard (Billing → Budget alerts).
- - **Stop new rooms at once:** `npx wrangler deploy --var NEW_ROOMS:off` (joining existing links keeps working); deploy again without it to reopen.
- - **End an abused room:** `curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" <server>/api/admin/rooms/<id>/end`, with the token set by `npx wrangler secret put ADMIN_TOKEN` (Node: the `ADMIN_TOKEN` environment variable).
- - **What it may say it keeps** is on its home page (`homePage` in `server/core.mjs`) and in the README's "Privacy and the public server": change both with any change to what's stored or for how long.
- - **Sidebar marks never stack:** `markSidebar` strips earlier `👥 … · ` and `…'s session · ` marks (`unmarked`) before saving and marking, since a share that never unmarked (an app quitting mid-share) leaves one behind.
- - **Never commit `rooms.json`.** It holds room tokens, and on Node the accounts and teams too; it is git-ignored.
+ All but the e2e run without a model.
+
+ - `plugin/.claude/types/claude-code.d.ts` is the engine's API, at least 2.1.299's (copy it in from a Claude Code session with `/plugin-types plugin/.claude/types`; it is git-ignored). It is the authority on every hook, `$` call and element.
+ - The validator enforces three engine rules: `$` may only be passed to functions declared at the top of `register.tsx`; `atom(...)` references use literal plugin and key strings; a link element accepts only `https:` (or `http://localhost`) addresses.
+ - The UI tests run as 2.1.299 (the world's `AI_AGENT`); a copy of them with the world on 2.1.295, run by a 2.1.295 binary, must pass all but the test of the type scale. Nothing answers beneath the plugins there: answer `ui.render` with `{ type: 'engine', ref: 0 }`.
+ - To drive the terminal by hand or by script, run `claude` in a pty and keep reading its output the whole time: an undrained pty blocks the app on write, which looks exactly like a hung turn.
+
+ ## Rules that shaped the design
+
+ Don't undo these without new evidence. Names in backticks are where to look.
+
+ ### The engine
+
+ - **No long-polling from the plugin.** While a plugin has a request in flight, the engine holds every prompt after the first. So each shared session reads the room through one `curl` child (`$.process.spawn`) on `stream?after=<seq>`, one JSON line per change, its address and token on stdin, never in argv. Without curl it polls, with requests that return at once. Every stream ends itself after 5 minutes and reconnects; one that ends within a minute backs off.
+ - **One dispatch sees one moment.** Every read in a dispatch returns what its first read saw, until the dispatch writes. A look after a wait is therefore a write (`update($, atom, v => v)`, as `replaced` and `endUnheard` do), and a marker that must survive a wait is module memory (`closeMark`).
+ - **A hook's budget is its own time.** Past about 10 s (plain timers and `$.clock.sleep` count) the engine answers in the hook's place: in a guest's ride, that is the guest's own model answering the host's prompt. Waits that may be long go through `nap` or `pause` (a `sleep` child: the clock stops while a `$` call is in flight), never a plain promise, and every hook's `.catch` answers on the safe side (a ride ends quietly; an unanswered approval is denied).
+ - **No prompts from inside another hook's dispatch** (they're refused), so the plugin's prompts go out from a timer (`submitLater`, `nextGuestPromptSoon`). A plugin's own `$.command.run` skips its own command hooks, so guest transcripts are drawn by riding turns (`turn.step` answered from the room).
+ - **A plugin gets at most 4 MiB of an answer**: `$.http.fetch` cuts a body there without a word, and the plugin's own file read refuses anything larger. Joins and event reads ask for pages (`?max=`, `PAGE_BYTES`, `eventsSince`), and an answer that doesn't parse is a failure, never an empty one (`api`). History is read from the transcript file, newest line first, through a child (`sessionHistory`, `readBackward`), following a compacted session back into its earlier file: `session.messages` holds only what follows the last compaction.
+ - **Engines differ.** 2.1.295 refuses a whole tree holding a prop it doesn't know (the type scale, named gaps), so drawings return `fit(tree, modern)` (`fit.ts`), `modern` read from `AI_AGENT` on every view. From 2.1.300 a subagent's run raises `turn.start` (with `agentId`), and a prompt typed mid-turn reaches `session.append` twice (door `queue`, then as it goes in); the host's hooks skip the first of each.
+ - **Showing things in a guest's Claude Code happens in a turn.** A plugin's own call to a viewer outside a turn shows nothing, and auto mode refuses widgets and the browser pane; the same call yielded as a `tool` chunk of a `turn.step` opens in Desktop's viewers, so rides replay what the host's Claude showed. The Files pane (`ccd_view.show_pane`) and Claude's browser for an Open (`Claude_Browser.preview_start`, `inPane`) are called directly. The browser pane runs a local page live only inside the project folder, which is why guests save what's shown under `.shared-session/` there.
+ - **Auto mode gives a plugin's own calls no verdict**: its classifier reviews only calls a model turn produced. Only an allow rule lets them through, written to the settings Claude Code reads on the person's press alone, never unasked (`allowTools`): **Allow pinning** for the sidebar (`DESK_TOOLS`, retried and logged by `setPinned`) and **Allow previews in Claude** for Claude's browser. Refused there, a preview opens in the person's own browser (a shared page in the Files pane) and says so once; a ride's replay says how to open it (`refusedNote`).
+ - **Desktop-only things stay in Desktop.** `Svg` drawings only on the `desktop` surface; Claude Desktop's own servers (`ccd_*`, `Claude_Browser`) only when `CLAUDE_CODE_ENTRYPOINT` is `claude-desktop`. The terminal paints a collapse mark over the right end of the row above the prompt, so that row keeps 4 columns clear, and draws a call by the tool that ran, so replays draw their own rows there (`terminalName`, `replayLines`, runs grouped by `joinRun`). The Room opens with `focus`, `closeOnEscape` and `rows`, or inline in a terminal keys can't reach it.
+ - **How things draw.** A Fragment draws as a column Box and a Button holds only strings and Text, so rows take keyed arrays. Text that truncates beside buttons sits in a Box with `minWidth={0}`, the buttons' Box `flexShrink={0}`; titles go through `tidyTitle`. Drawings are plain images styled with `prefers-color-scheme`, never interactive art, which paints an opaque box on dark themes. What moves is a `Client` (`live.tsx`, `timeline.tsx`) ticking on the surface's clock from what the hooks hand it, drawn with the terminal's elements. A `ui.render` hook keeps the engine's own drawing (`await next(e)`, a ref a Box may hold) and adds to it.
+ - **One look, one row.** Each person's color comes from `look.ts` (`PEOPLE`, `colorsFor`: the host first, the same on every machine); status words take theme keys. The row above the prompt is one row whatever happens (an invite, the share question, an approval, a Pin take it over), and nothing adds a line under it. A dropped connection and its return stay out of the transcript (`lostAt`). `session.start` (a reload fires it too) redraws what the plugin drew (`$.ui.invalidate('ui.render')`), so what a row draws from lives in `$.state`.
+
+ ### A guest's session
+
+ - **A guest never calls its own model.** Every turn is a ride the plugin answers from the room, matched by its text, then its beginning, then oldest first. A turn that matches nothing gets a short local note from `turn.step` without `next`. Every later step is answered too (a Stop hook can continue a turn), and the guest's Stop hooks don't run (`classic.Stop`). A ride is read before the mode (`ridesByTurn`): only a subagent's step, or a turn that's no ride while the mode isn't `guest`, reaches the model.
+ - **Host tool calls replay as the plugin's own tool, never the real one.** Each is a call of `mcp__shared-session__replay`, whose `tool.call` hook returns the host's result row (its `.catch` too), drawn as the call it replays (the `ToolUse` render hook). Answering a built-in tool's call instead is checked against that tool's output schema, and a result that fails the check runs the real tool on the guest's machine. Viewer calls (files, widgets, pages) are made again for real (`replayOf`).
+ - **A join's history is a turn per earlier prompt, two steps each** (`historyBlocks`, `staticRuns`), never a step per call. The plugin's own turns go one at a time (`laterRides`), and a step that ends a turn always says something: an empty reply makes the engine ask for another step.
+ - **A guest forwards what a person typed** (`GUEST_SENDS` origins, never `isMeta`), Claude Desktop's `<system-reminder>` stripped first (`typedText`). A prompt's attachments wait for their bytes from `session.append` (`heldPrompt`, 8 s at most) and go up as room files; the host saves them under `.shared-session/<guest>/` and names each in the prompt (`saveAttachments`), since a plugin's prompt carries words alone. Pictures in tool results, and pages with their files, travel as room files too (`upImages`, `withImages`, `downloadShown`). The update command typed by a guest runs on the guest's computer (`updateHere`).
+ - **Messages from a guest's own other sessions.** A `SendMessage` from another of the guest's sessions to the joined one enters as Claude Code delivered it and, with Room → Settings → Messages from my other sessions on (`prefs.relayPeers`), goes on to the host (`relayQueued`, `POST peer`), carrying its id and hop chain from the transcript (`peerRow`) and a handle in place of the sender's address (`keepRoute`). Its turn is a ride of its own, never a note followed by rides: a plugin prompt entering first would strip the hop chain, and with it the loop guard. Where it can't go, the sender is told (`tellPeer`, `relayRefusal`, `peerRefusal`); receipts go back through `relay/receipt.cjs`. The room passes these to the other side's streams alone, never storing them (`peerRoute`).
+ - **The host's Claude answers through a mailbox.** One helper a room (`relay/mailbox.cjs`, `runMailbox`), started detached so Claude Code doesn't take its frames as its own, opens a socket per teammate session (`ss-<32 hex>.sock` beside this session's own) and writes Claude Code's own frames into this session (`deliver`), so the host's Claude Code queues, holds, loop-cuts and answers them natively. It takes frames only from this session's socket, and ends with the room, with this Claude Code, or on a signal. Where the host's side can't take one, the teammate's turn is declined (`declined`); no mailbox path reaches the room (`hideMailboxes`). Both lean on what Claude Code 2.1.304 doesn't document (socket frames, `peer_message_status` receipts, the delivery notices' words, the transcript row's `origin`); `test/mailbox.mjs` pins the bytes. Out of scope: `SendUserFile` to a mailbox, and Windows.
+
+ ### The host's session
+
+ - **The host is asked, never answered for.** A teammate's call is put to the host with `$.ui.ask`. A refusal within 1.5 s (a settings hook, no dialog) or an answer that isn't an option asks in the row above the prompt instead (`approvingA`, ten minutes), waited for in naps. Calls wait in one list, oldest first (`approvals`), each answered only by its own dialog or buttons, and trust is looked at again before each. A call is a no once its room is over (`askedRoomOpen`); a module replaced by a reload declines what waits in it (`replaced`); a failed hook's `.catch` denies (`noteAsked`); where no row is drawn (`-p`, VS Code alone) a call is declined (`drawsRow`). A call is named by one line (`callWhat`) and what that leaves out (`callExtent`), and the Room shows it whole.
+ - **A teammate's read runs unasked only where it really is inside the project** (`readsInsideReally`): the paths a read tool names (`readTargets`: a Glob pattern up to its first wildcard, nothing climbing after one; any other path whole, whatever it holds), then where each really is, links followed (`relay/real.sh`, on argv; a path that can't be told, or a look past 5 s, asks). A link in the project, a repository's own, can name any file of the host's. Claude Code's Grep and Glob don't follow links inside a folder they search (ripgrep without `--follow`, 2.1.304), so the paths named are what can lead out.
+ - **An agent's calls are whoever's turn started it** (`callOwner`; `agent.spawn` records each agent's owner): a teammate's agent stays theirs in later turns, and any other call is the running turn's.
+ - **Always allow is for a person, never a name** (`trusts`, `ShareTrust`): an owner (`ShareOwner`) is a name and the seat its prompt came from, carried from the room's event through the queue (`guestPrompts`), the turn (`working.seat`), its calls (`owners`) and its agents (`agents`); trust matches that seat, or its account as the room's people say (`seatAccount`), so another session of theirs is trusted and someone who joins under their name isn't. An owner whose seat can't be told (a framed turn whose name two people share) is never always allowed. A turn is its queued prompt's by the longest framing in its words (`known`), so a prompt quoting another's queued words stays its sender's.
+ - **Teammates' prompts go to Claude one at a time, each its own turn** (`acceptPrompt`, `guestPrompts`, `nextGuestPrompt` once idle). Submitted together they reached the engine as one turn, every call counted as the first teammate's. A turn that starts `<name>: ` for a current teammate counts as theirs, the safe side. One Claude Code drops is declined to them (`notTaken`).
+ - **A host's step streams to guests while it runs, and only while someone's watching** (`liveFeed`): text, thinking and a call being drafted go as `delta` events the room never stores; a drafted call goes as a progress line, never its arguments.
+ - **Claude asking for `/share-session` as a Skill gets the plugin's answer** (`shareAnswer`): Claude never starts sharing itself. A page Claude publishes while hosting notes that it already went to everyone.
+ - **An event the room refuses as too large is dropped, never sent again** (`singly`), and said once; a typed prompt refused so ends its turn here (`tooLarge`). A host's event is never refused: the room cuts it to fit (`fitBody`).
+ - **The room knows everyone's plugin version** (`x-shared-session-version` on every request, the stream's too), and the Room says who needs an update (`latestKnown`, `noteBehind`). The Room beside the transcript is built from the room's events by `absorb`, live and on every replay (a join, a catch-up, a resume), filed under `replyKey(text)` so it's the same on every machine.
+
+ ### Rooms across restarts, reloads and exits
+
+ - **A room outlives its process.** `$.state` lasts only through a hot reload, so a session that ends (anything but `/clear` or a logout) saves its room in a private checkpoint (`closeRoom($, 'keep')`), and a start of the same session takes it up again (`resumeHosting`, `resumeJoined`). Claude Desktop can reopen a session under a new Claude Code id, so a place is also found by Desktop's own (`CLAUDE_CODE_HOST_SESSION_ID`, `savedKey`). A resume deletes the checkpoint as it reads it and holds it (`takeBack`) until the room's mode is set; meanwhile what depends on the room waits, at most 10 s from the take-back's start (`takenBack`, `TAKE_BACK_WAIT_MS`), and only making or joining a room here gives a late one up. A guest's prompt typed while its place is late gets a local note, never a model call. The server never ends a room for its host going away.
+ - **Checkpoints live outside the plugin's code**, in `${CLAUDE_CONFIG_DIR || HOME + '/.claude'}/shared-session/rooms`, since installed and folder copies of the plugin have different `$.store`. Each is a private file (folder 0700, file 0600), written through stdin and renamed into place, removed when consumed. Don't read Claude's undocumented hashed store files.
+ - **Every way out of a room is `closeRoom($, how)`, in one order** (`closeSteps`): the room's scope (`RoomScope`) closes and what runs for it is told to stop, then the close's marker (`closingA`) and the mode, the checkpoint, the window, local work, the server, and the room's state. One close at a time (`untilClosed`), one Share at a time (`inShareTurn`), both waited for in naps; a close cut short is finished by the next start (`finishClose`). `/clear` and a logout fit `session.end`'s 1.5 s.
+ - **Cancellation keeps the person's intent across awaits.** Long-lived work checks the scope it began in (`stillInRoom`), and a request also the seat's token (`sameRoom`), which a reseat changes. A Stop, Close or Leave of a preview bumps its epoch (`stopLocalPreview`), ending an Open or listener begun before. The host's share writes and their revocations go in turn (`sharesLane`).
+ - **Updates load in place.** With Updates on, the plugin copies itself to `~/.claude/shared-session/plugin` and names that folder in the settings' `env` (`CLAUDE_CODE_PLUGIN_DIRS`, `CLAUDE_CODE_PLUGIN_DIR_WATCH=1`); that copy updates itself from `main` when the server reports a newer version (`selfUpdate`, under `plugin.lock`, only in a session with a surface: a `-p` run can exit mid-download) or from a newer installed copy (`catchUpFromInstalled`), and every session running it reloads with its rooms intact. Copies compare contents (`rsync --checksum`). Claude Desktop pins the installed copy of a marketplace added by path, so there a release loads only at a restart (`pinnedByDesktop`).
+
+ ### Previews: what's shared, and who decides
+
+ - **Raw TCP forwarding is the host's choice, never Claude's mention.** Only the host's own `/share-preview`, **Allow forwarding**, a mention's **Allow**, **Share too** or a need's **Share** forward a port (`hostForwards`, `tcp: true`). A port Claude mentioned or opened is shared only once the host says so (`offerShow`: the row asks "Share localhost:P with the room?", kind `show`; Not now stands for the room, unanswered or with no row, a `-p` run, nothing is shared; a cloud host asks its approver, `pumpHostAsks`), since a teammate's prompt can have Claude name any port of the host's; then through its link alone (`sharePublic`), and only when it answers as a page (`lookAtPage`, `pageLook`): never an API, an empty reply or a debugger. With guests here, whether they may open it at the same address is asked after (`offerSameAddress`), once the first question lets go of the port. Share with history does the same for the pages it opened (`localOpens`). Before 0.11.17 a guest could prompt Claude to name a port and reach the host's database. Every yes is the host's own click; nothing grants raw TCP by itself.
+ - **Discovery proposes; the host decides.** `relay/discover.cjs` reads the host's listeners (macOS `lsof`, Linux `/proc`) and offers the shared port's project's other web services, one service a question in the row above the prompt (`servicesOf`, `questionsOf`, `claimAsks`), never DevTools, the plugin's helpers, Claude Code, browsers, IDE helpers, ssh, kubectl, Docker, or a port shared already. A guest's window reaching an unshared port asks the host through the room (`POST need`, on the host's stream alone, never stored). Not now and Stop stand for the room (`hostMemoryA`) until the host shares the port on purpose.
+ - **A port forwarded here is never shared on.** A port held here by another session's preview (`relayedHere`: `discover.cjs` in owner mode) is never shared or forwarded, and teammates' connections to one that became so are refused (`relayedNow`). When it can't tell, it shares as before: the guard fails open.
+ - **What's shared is the room's to say.** Every page the room sends carries its list (`Room.previewList()`, in `Room.page()` and the join answer), which a guest takes before the page's events (`takeRoomList`); an open preview the list leaves out has ended, for good. Only lists in the feed's order are taken: a reseat's join answer (`reseat`) can be older than a page the stream brought meanwhile, so its list isn't. How a preview shows stays its artifacts' (`listedAs`).
+ - **A Stop the room didn't hear is told again** (`endUnheard`: on a connect or a poll, once a minute at most), each looked at as it's told: never one shared again since (the room gives a port the same preview back) or one whose port a share is being made for (`sharingPorts`). A share of a port waits, a few seconds at most, for an end of it being told (`endingPorts`), so the room answers it with a preview of its own, never one it then ends; one still being told after that isn't shared, and the person is told to try again. Meanwhile its link's requests hear at once that it ended.
+
+ ### Previews: the public link
+
+ - **Previews are served by a second Worker from the same script** (`wrangler.preview.toml`, `ROLE = "preview"`), on a host name of its own so an app's root-relative paths work. A single-use ticket becomes a host-only, SameSite=Lax cookie for one browser (`enterPreview`), whose session lasts 12 hours from its newest link and ends with its seat or the preview. Only single-use links go in URLs, and links and cookies name a room by its preview key, never its id, which joins it (`previewKey`). The main Worker serves no preview. On one host name for every room, no service worker may register, and a browser entering without this room's cookie has the host name's storage cleared first (`Clear-Site-Data`, which browsers honor only over HTTPS): a self-hosted `PREVIEW_URL` wants HTTPS and a domain of its own. Clearing can't stop a page of one room still open in that browser from making requests, as the same origin, to the previews of a room it enters later. A host name per room closes that: with `PREVIEW_URL` a wildcard (`https://*.previews.example`, `roomOrigins`), each room's previews are at `<label>.<rest>`, the label its preview key's bytes in base32 (`previewLabel`, `previewKeyOf`, `previewHostLabel`; a Durable Object's id is 64 hex, past a DNS label's 63). Then the host name alone names the room, the preview Worker says so to it (`x-preview-own`, dropping any a browser sent), and entering needs no clearing hop. Its DNS and route are a person's to add (docs/guide.md, "A host name per room"). An app's cookies stay on its host name: a Set-Cookie loses its Domain, and one named `cs_pv` is dropped (`proxiedAnswer`).
+ - **The host fetches each request from its own localhost** with curl (`proxyOnce`), only from a port it shares as that preview, with Origin and Referer set to the local origin (`localHeaders`). Every curl to the room, and the local fetch, has `globoff`. A fetch stops at 20 MB and 25 s, and fetches are bounded (`PROXY_RUNS`, `PROXY_QUEUE`); a HEAD is curl's own `head`. The answer is a head line, then the body. A preview stopped here answers at once that it ended (`answerPage`).
+ - **WebSockets go through `relay/ws-relay.cjs`**, which opens the local socket first and only then the room's leg, so the room pairs a browser only with a socket the app took. A pair carries what let its browser in and closes when any of it ends (`publicPairValid`); both adapters bound pairs alike (`PUBLIC_LIMITS`). Next.js dev pages don't hydrate without them.
+
+ ### Previews: forwarding to a guest's localhost
+
+ - **Each TCP connection carries the guest's own seat token** (`GET previews/:pid/tunnel`), which the room checks as it opens (`tcpSocketTarget`): a guest seat, here or away within its grace, of a forwarded preview, with the host connected. Nothing is granted or stored for it. The token goes to the guest's helper on stdin and into that header alone, never to application bytes, argv or URLs. Leave, the away drop, Stop and the room's end close a seat's channels.
+ - **One host worker, many sockets** (`relay/tcp-worker.cjs`, started by `relayTcp`). It's commanded through an authenticated loopback control port (`controlServer`, `controlPost`), never a stdin written on (a child takes its stdin once, `input`), and supervised like the mailbox helper and the window's proxy (`Helper`); its handle and capability live in memory alone (`RoomChildren`). It relays only a port whose share is that preview and forwarded (`tcpAllowed`). Never replay TCP bytes or POSTs; keep FIN half-close; no idle timeout.
+ - **TCP capacity belongs to a seat** (`TCP_LIMITS`): 32 channels a seat and 64 a room, pending and active; 240 opens a minute a seat and 1,200 a room; a 1 GiB byte budget a room; four 64 KiB chunks of credit a direction; and what a half is sent besides data bounded at 1 MiB until it proves it read it (`chargeControl`, a `check` answered `seen`). BUSY is 1013 (or a handshake 429), AUTH only a handshake 401 or 403. A refused socket never restarts its listener, changes port or switches to the public link by itself; only the window's tunnel tries once more with the token held now (`retryOnce`), then waits for an Open.
+ - **A guest's listener binds loopback alone and never takes a port from anyone** (`relay/local-preview.cjs`). It knocks before binding (macOS lets a specific bind sit beside another process's wildcard listener and take its traffic), rolls back partial binds, and a strict bind (the host's number) reports a taken or refused port (`DENIED`) instead of moving. Never kill another process or silently use the public link. Node 20+ and `sh` are needed on both machines.
+ - **Open puts a preview at the host's own numbers** (`openShownPreview`, `bindForOpen`), like VS Code's port forwarding: what the page uses, then the page, each at the host's port, opened at the host's exact address in Claude's browser or the person's own (`showPage`), so origins, OAuth callbacks and cookies stay the host's. It costs the guest those numbers while they're in the room, and their `localhost` cookies go to the host's servers; that's said once. With the host away, nothing is bound. Close and the host's Stop free the page and what only it uses (`onlyUsedBy`). A port taken or refused here opens the preview window instead; with no window possible, the page moves to a free port with a note of what that costs (`elsewhereNote`). Room → Settings → Previews chooses Claude's browser, the person's own, or always a separate window. A reload restores each mapping strictly at its port, opening nothing (`restoreLocalPreviews`).
+
+ ### Previews: the separate window
+
+ - **A taken port opens a browser window of its own, at the host's own address**, on a private profile under `shared-session/browser` (0700): a signed-in host's, shared by their rooms so the guest signs in once (`h-…`), else the room's for this session. Two sessions never cold-start one profile (`takeProfile`, a launch lock). The browser is the first that can of Chrome, Edge, Brave, Chromium and their channels, Firefox 140+, Vivaldi, Opera (`detectScript`, `pickBrowser`; `SHARED_SESSION_BROWSER` overrides), the person's default last on macOS, where the window would take their links. There's no window with Safari alone (it has only the system's proxy), a snap browser, no display, SSH, a platform other than macOS and Linux, or `SHARED_SESSION_NO_BROWSER=1` (`windowChoice`).
+ - **The window's localhost reaches only the host's shares**, through a SOCKS5 proxy of its own (`relay/browser-proxy.cjs`). Only `localhost`, `*.localhost` and loopback literals route there: another name whose answer is loopback is refused (`rebound`), since a page's own name rebound to loopback after it loaded would read the shares as that page's origin. Chromium starts with `--proxy-server=socks5://127.0.0.1:<port> --proxy-bypass-list=<-loopback>` (without `<-loopback>`, localhost goes to the guest's own services) and WebRTC kept to proxied UDP (`chromePrefs`); Firefox through `user.js` (`FIREFOX_PREFS`: localhost through the proxy, no direct failover, names resolved by the proxy). Chromium also starts with `--use-mock-keychain` on macOS and `--password-store=basic` on Linux, deliberately: the profile's folder permissions keep it private. Flags never carry tokens and never `--ignore-certificate-errors`, and the system proxy is never changed.
+ - **The proxy refuses the guest's own network**: the guest's own addresses, private ranges and its interfaces' subnets (unless `SHARED_SESSION_PREVIEW_LAN=1`), and always link-local, cloud metadata, multicast and reserved ranges, their IPv6-mapped and translated forms too; a numeric name that isn't canonical is refused before any lookup. Other traffic leaves through the guest's own proxy when there is one (`upstream.ts`; not a PAC file, one with credentials, `https://` or `socks4://`, which is said once). An upstream is given checked addresses, never names: a name going through it is looked up over DNS-over-HTTPS through that upstream (Cloudflare, then Google, their certificates checked) once the local answer allows it, so the upstream can't take a name to the guest's network. The price is that a proxy choosing its route by name sees only addresses, which `docs/guide.md` says where Google sign-in fails.
+ - **The window's life.** macOS launches it with `open -n -a` (without `-n` the flags reach the person's running browser), Linux with `setsid -f`; it must show within 10 s, or the person is told what would open the page instead (`windowRemedy`). The proxy's port belongs to the window, so a reload binds it exactly again (`restorePreviewWindow`). A window is closed only as the one process holding its profile's lock (`HELD`), never by pattern. A look that fails decides nothing (`windowProbe`'s `undefined`); asked twice and still unknown, the Open ends and says so (`unknownLook`); every shell step has its time (`WINDOW_STEP_MS`). `openWindow` goes in four steps through `windowStep` and puts away what it took if the room was left (`abandonWindow`). Leave, the room's end, `/clear` and a logout close it and trash a room's profile; `session.start` sweeps old ones.
+
+ ### The cloud host (docs/cloud-host.md)
+
+ - **Anthropic's cloud sessions can't host a room**: they don't install a repository's plugins, and their machine pauses after a few minutes idle with nothing outside able to wake it. So the room server runs the host itself: `HostContainer` (a Durable Object with a container, one a room by `idFromName`) on Cloudflare, `server/cloud-docker.mjs` under Node when `CLOUD_IMAGE` names the image. Both are `ctx.cloud` (`start`, `stop`) to the core; the core alone decides who may move, answer and bring home.
+ - **The runner takes the room up as a restarted host does.** `cloud/runner.mjs` lays the project out at the path it had on the mover's machine (so the conversation's paths hold), commits it as the base, writes the room as the plugin's own `hosting:` checkpoint, and runs `claude -p --input-format stream-json` with `--resume` and the plugin; `resumeHosting` does the rest, and in runner mode (`SHARED_SESSION_RUNNER=1`, `isRunner`) runs the prompts sent during the move instead of declining them. The runner says it's ready once the plugin logs "Still sharing this session". It runs as root only to make that path, and Claude Code as `runner` (bypass mode refuses root).
+ - **The room server drives the runner over the container's own port** with a control key made for that container (`/start` the configuration, `/bundle` the packed session, `/stop` answers with `git diff --binary` since the base), never a public address. The packed session streams to it from the outer Worker against a single-use ticket the move minted (`PUT cloud/bundle`, `CLOUD_BUNDLE_MAX`); nothing buffers it.
+ - **Approvals go to the approver.** A runner never shows a dialog: a call that would ask goes to the room as `ask` (with the approval's id), and only the approver's seats (the seat the move made, or another of the same account, `isApprover`) may `answer`, once, an `ask` that exists (`cloudAnswer`); ten minutes unanswered is a no. A room set to ask nothing still asks on a runner: it holds its approver's Claude sign-in. Every question the host's row would ask about its localhost is asked the same way, one at a time (`canAskHost` is true on a runner, and `pumpHostAsks` sends the question as an `ask` of its kind, `approverAsk`): sharing a page Claude gave out (`show`), letting teammates open it at the same address (`mention`), what it uses beside it (`related`), a teammate's window's need (`need`). The approver words it from its ports (`localAskOf`, `cloudHostWords`) and answers with the host's own row's yes or Not now, which holds for the room; one unanswered in ten minutes is taken back (`ask { id, done: true }`) and asked again later. What a yes came to is said back the same way, since the runner's own toast shows nowhere (`offer`: `done` with the question's ports `shared` and `failed`, as the runner's shares say afterwards). The container's control port (8080, `RUNNER_PORT`) is never asked about or shared (`claimAsks`, `ensurePreview`, discovery's exclude). The approver draws the oldest question in the row above the prompt and all of them in the Room (`cloudAsksA`), taken from history too.
+ - **The approver sets the cloud host's settings.** Their Room's settings are the cloud host's while it runs (`cloudSettings`, `setPolicyHere`): a change holds in their session (Bring home takes it) and goes to the room as `set`, which only the approver's seats may post, of what the host's Room sets (`cloudSet`: no "ask before nothing", which a runner doesn't keep). The runner sets it as its own and says so to everyone (`takeSet`, `setPolicy`); `untrust` is the approver's Ask again, whose names they keep with their place (`cloudTrustedA`, `cloudTrusted` in the joined checkpoint: the move's, then each Always allow they gave), so a reopened approver can still ask again. The room marks the approver's prompts (`from.approver`), so a runner's watch-only keeps to teammates and never turns its approver away.
+ - **The mover's session switches through the restart paths.** Move writes a `joined:` checkpoint with the new seat (`cloudMine`) and runs `resumeJoined`; Bring home writes a `hosting:` one with the new host token and runs `resumeHosting`, then applies the changes with `git apply` when they apply cleanly, else keeps the patch under `.shared-session/cloud/`. The mover's transcript keeps every cloud turn as rides, so its Claude goes on knowing them.
+ - **Secrets.** The mover's Claude token comes from `claude setup-token`, read from the clipboard on their press (never drawn), kept in `shared-session/cloud-token` (0600), sent in the move's body and passed to the runner, never stored by the room (`cloudAuth` keeps only the sign-in's shape). The move rotates the host token; Bring home mints a new one. A week after the move the runner stops by itself (`CLOUD_MAX_MS`), its changes kept for Bring home.
+ - **Deploying needs no Docker.** The image is built once and kept in this project's Cloudflare account's registry, and `wrangler.toml` names it (`registry.cloudflare.com/<account>/claude-share-host:r-<hash>`). Its tag is a hash of `cloud/Dockerfile` and `cloud/runner.mjs`, and `test/cloud.mjs` fails when they change without a new image: run `node scripts/push-image.mjs` (Docker with `buildx`), which builds it for `linux/amd64`, pushes it and rewrites the line. A release doesn't need a new image: the runner takes the plugin from its repository's `main` at the server's version (`config.plugin`, `PLUGIN_SOURCE`), falling back to the one built in. Deploying to another account, `scripts/setup.mjs` builds from `cloud/Dockerfile` through `wrangler.selfhost.toml` (`cloudImageConfig`), and says when that needs Docker. The account needs Workers Paid.
+
+ ### The server
+
+ - **`server/core.mjs` is shared** by `node.mjs` (Node 20+) and `worker.mjs` (a Cloudflare Worker and Durable Object). What both must agree on lives in core (`PUBLIC_LIMITS`, `TCP_LIMITS`, `publicPairValid`, `tcpStep`, `mergeTCPStats`); the adapters keep only sockets, timers and persistence. Every body is read through `readBody`, bounded in bytes as it arrives, and the outer Worker buffers no room body (`bodyLimit`, `bodyTooLarge`). Keep `wrangler.toml`'s `compatibility_date` no newer than the wrangler you deploy with supports.
+ - **Nothing runs on a timer while a room is quiet.** Who is still here is settled by the next request, or by one alarm (`schedule()`), written only when what's due is sooner than the one set. On Cloudflare the Worker, not the Durable Object, holds a client's stream (an object holding an open response is billed by the second) and talks to the room over a hibernating WebSocket; a change goes to stream sockets alone (`broadcast()`).
+ - **What a client holds open is bounded.** A seat (the host's too) holds `SEAT_STREAMS` (4) streams and `SEAT_POLLS` (4) long polls at once; one more ends its oldest stream, or answers its oldest poll at once (a reconnect's old stream, not noticed gone yet, is the usual one). A Node stream sends its events in pages of `STREAM_PAGE` (512 KiB), the next as its reader takes the last (`pull`), and ends, what it holds with it, once `STREAM_UNREAD` (1 MiB) sits unread; `node.mjs` writes a response at its client's pace (`drained`) and closes one whose stream failed. A client reconnects from its cursor, so nothing is lost.
+ - **A seat let go comes back.** A guest with no connection for 45 s is let go but its token kept (`away`, the newest 100), and it's seated again as it was when next used (`comeBack`). The plugin takes a new seat on a 401 (`reseat`, `stillSeated`), and only a room that's gone (404, 410) ends its place. A Leave or the away drop takes what the seat had for previews (`revokeSeat`).
+ - **One person, one entry, wherever they are.** A share or a join carries the person's sign-in when there is one (`x-shared-session-account`), so the room lists a person once with all their seats (`people`, `isMe`). On one computer, a session in a room holds it in `$.store` (`holdRoom`), so a link to that room opens that session (`openHeld`) instead of joining twice.
+ - **Everything is bounded in bytes.** An event's strings are cut at 32,000 characters and its nesting at 32 levels (`clampBody`); a guest's event past 128 KiB is refused and a host's cut to fit (`EVENT_BYTES`, `fitBody`). A room keeps 5,000 events and 16 MiB of them (`HISTORY_LIMIT`, `HISTORY_BYTES`), 128 open previews, and 100 MB of files, each file's record kept beside its bytes (`bm:`) so `meta` stays under 1 MiB; a file going up is counted against that before it's stored (`filesPending`), so uploads at once can't pass it together, and a room reads `FILE_UPLOADS` (4) at once, 429 past it; a file going out, and a preview's answer, count against what the room may send (`SERVE_LIMITS`, below). A room loaded from storage starts its seq past any it may have handed out (`Room.resume`). Node keeps one process for every room, so nothing a client sends may end it (`upgrading`, `later`).
+ - **Teams live in the server's directory** (`server/teams.mjs`, `Directory`), and rooms only ask it (`shareCheck`, `joinCheck`) and tell it (`register`, `unregister`): on Cloudflare over `/rpc/*` on its stub, which the Worker never forwards from outside (`Directory.owns`). What it has timed (session invites, sign-ins under way) is deleted when due whether or not anyone asks (`sweep`, the adapter told when by `onDue`: the directory object's alarm, a Node timer), and a room's end or expiry takes its invites and its place on its team's list (`roomEnded`, through `ended`; a session's invites are indexed by `rinv:`). Deleting an account also takes the invites it sent and its sessions' places on its teams' lists. A members-only team's rooms hear from it whom the team has now, after someone leaves or is removed and when it turns members-only (`enforce`, `keepTeam`; on Cloudflare a room object's `__team`, a path the Worker never forwards either), and let every other guest seat go, here or away (`Room.keepTeam`, reason `removed`), with what it holds for previews; a stream or a long poll whose seat is gone ends (`seated`). A join asks the team after its waits (its body, the sign-in's look), and again if the team let someone go meanwhile (`teamGen`), so a removal while it's under way never ends in a seat. An account makes 30 changes a minute at most.
+ - **A sign-in's token never travels in an address or argv, and only the browser's Continue signs in.** The plugin opens `/auth/<provider>/start?login=<sha256 of a secret>` and collects the token by POSTing the secret to `/api/auth/poll` (`startSignIn`). Whoever makes a start link chooses its `login`, so the page asks first, showing a code the plugin shows too (`signInCode`); it's never framed, cached or sent as a referrer, runs one script allowed by its hash (`CONTINUE_SCRIPT`), and enables Continue only once it has been seen and focused, so a page raised under a double-click can't take its second click. The token is minted at the poll that collects it, and the server keeps only hashes. An account's email is proof only of what its newest sign-in verified (`emailVerified`): one the provider stops vouching for is shown still, but leaves the index (`byEmail`) and takes no team by its domain; GitHub's addresses unread fail the sign-in rather than read as none. It's saved per server in `shared-session/account.json` in the config folder (`accountFile`, `umask 077`) and sent only to the server that gave it. `AUTH_TEST=1` on a Node server adds a test provider.
+ - **Invites go to accounts** (`POST rooms/:id/invite`, kept 12 hours), fetched by one session for the whole machine (`pollInvites`, `INVITES_KEY`). **Join** joins in place only in a fresh session (`joinHere`); otherwise it opens a new Claude Desktop session with `claude://code/new?q=<link>`, or copies `claude '<link>'` in a terminal, one room at a time (`JOIN_QUEUE`).
+
+ ## Release
+
+ 1. Bump `plugin/.claude-plugin/plugin.json` `version`. Claude Code updates by version, not by commit: a push without a bump reaches nobody.
+ 2. Run the checks above.
+ 3. Push to `main`, then deploy the server, in that order. On an update, deploy the preview Worker first (`npx wrangler deploy -c wrangler.preview.toml`, then `npx wrangler deploy`), since it must already route what the new main Worker's rooms make; the first time, the main Worker goes first, since the preview Worker's `ROOMS` binding names its `RoomObject`. `node scripts/setup.mjs deploy` does both in the right order, and on an update stops with FAIL before the main Worker if the preview Worker's deploy fails. The server reports the version it was deployed with as the newest, and plugins that update in place then download `main`, so `main` must already have it; the others show "Version X is out". Without updates in place: `claude plugin marketplace update claude-share && claude plugin update shared-session@claude-share`.
+ 4. When an older version must stop being used, raise `PLUGIN_MIN` in `server/core.mjs` and deploy after the push: the server then turns that version away from sharing and joining, with the update command. A plugin that sends no `x-shared-session-version` counts as too old.
+ 5. Rolling the main Worker back to 0.11.24 or older loses the files of rooms made since 0.11.30: those Workers read file records from `meta`, which no longer lists them. Rooms last 24 hours.
+
+ ## Running the public server
+
+ The plugin shares through `SERVER_URL` (`plugin/hooks/server.ts`) unless its `server` option or `SHARED_SESSION_SERVER` says otherwise.
+
+ - **Costs** are bounded by `LIMITS` in `server/core.mjs` (on Cloudflare by the `[[ratelimits]]` bindings in `wrangler.toml`, which must say the same), the room ceilings above, the previews' `TCP_LIMITS` and `PUBLIC_LIMITS`, `SERVE_LIMITS` and the 24 h expiry. `SERVE_LIMITS` bounds what a room sends out of its files and its previews' links, which no rate limit covers (a GET has none): 8 GiB a room a day (UTC), reserved 64 MiB at a time with the TCP counters (`tcpStats.served`, `serveReserve`) and kept as the request that reserved it is answered (a file's before its bytes go, a preview answer's just after it's handed over), so a restart can't reset the day and misses one reservation at most, and 256 MiB a seat a minute; past either, 429, and the host isn't asked (`serveLeft`). New rooms are counted by the Worker's `GateObject` per address a day, kept in its storage (`a:<date>:<keyed hash>`, the key a random one of that day's, `k:<date>`) so a gate loaded again counts on, one Share at a time, and deleted by its alarm at the day's end. Cloudflare has no hard spending cap; a person sets a budget alert in the dashboard (Billing → Budget alerts).
+ - **Stop new rooms at once:** `npx wrangler deploy --var NEW_ROOMS:off` (joining existing links keeps working); deploy again without it to reopen.
+ - **End an abused room:** `curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" <server>/api/admin/rooms/<id>/end`, the token set by `npx wrangler secret put ADMIN_TOKEN` (Node: the `ADMIN_TOKEN` environment variable).
+ - **What it may say it keeps** is on its home page (`homePage` in `server/core.mjs`) and in "Privacy and the public server" in the README and `docs/guide.md`: change all of them with any change to what's stored or for how long. `node test/previews.mjs` checks the home page's sentences against the constants they name.
+ - **Sidebar marks never stack**: `markSidebar` strips earlier marks (`unmarked`) before marking, since a share that never unmarked leaves one behind.
+ - **Never commit `rooms.json`, nor the `rooms.json.tcp` Node writes beside it.** The first holds room tokens, and on Node the accounts and teams too; the second holds room ids, which are what a room's link carries. `.gitignore` lists `rooms.json`.