AGENTS.md · diff
git:20261023.0ca734c to git:20261023.e530a01
1 added, 1 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. 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` |
`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, 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`, 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)
npm run typecheck:server # tsc over server/*.mjs (checkJs): Worker code against the Worker's types alone (wrangler writes them), Node code against Node's (npm install first)
claude plugin validate --strict plugin # what the engine and the plugin directory check
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)
```
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.
+ - **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. A stream quiet for 35 s (the server sends a line every 10 s) died without a word, a Wi-Fi or VPN change: it's given up, and the row says Reconnecting at once (`STREAM_STALE_MS`).
- **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`.