AGENTS.md · git:20261014.5b323b0 · 2026-10-14 · sha256 ae0116f2ede90dc6

AGENTS.md git:20261014.5b323b0B

Immutable. This exact content never changes and is served at /api/v1/blob/ae0116f2ede90dc6.

# 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.

## 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.

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.
- **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`, `/team`, `/invite` 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.

## 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
```

- `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.
  - **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 `$.store` 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.
  - **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. 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.
  - **A guest's local port is independent of the host's.** `ensureLocalPreview` covers replay, refused-viewer notes and Room Open. `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`; same-machine model e2e verifies alternate local ports. 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).
  - **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.