Share a Claude Code session with a link, or with your team. Teammates join from their own Claude Code and talk to it: one session does the work, everyone sees…

Share a Claude Code session with a link. Teammates open it, land in a Claude Code session of their own that is attached to yours, and talk to it. Everyone sees every prompt and every reply, live, as normal transcript rows.
http://localhost:3000/…) is shared too, at that page, once something answers there; so are the ones it opened or gave out before you pressed Share everything, if they're still running. Files are saved in the guest's project under .shared-session/ (kept out of git), and a dev server is reached through the room server. Screenshots the host's Claude takes (the browser pane's, a computer's) and images it opens show in the guest's tool cards as they do in the host's. A page it publishes (an Artifact) arrives with its pictures and other files beside it, and opens whole in the guest's browser pane, again whenever it's opened again; nobody needs the private claude.ai link. The Room shows anyone whose plugin is out of date, and a guest who types the update command into the session has it run on their own computer. A guest's message can carry attachments (images, PDFs): they're saved in the host's project and the host's Claude opens them.Built as a Claude Code plugin of function hooks (a "mod"), plus a small room server that runs on Cloudflare or as a single Node process. It works right after install: sharing goes through a public server this project runs (what it sees and keeps), and teams can point it at their own.
One line in a terminal:
claude plugin marketplace add Paradigm-Study/claude-share && claude plugin install shared-session@claude-share
Works in Claude Desktop and in the terminal. Requires Claude Code 2.1.286 or later: claude update (or npm install -g @anthropic-ai/claude-code@latest for an npm install); Claude Desktop includes it. On 2.1.285 (the "stable" channel today) it works with "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } in ~/.claude/settings.json, which the setup script sets for you. An older Claude Code answers /share-session with how to update. The installer mentions one optional setting (the share server); leave it, and sharing uses the public server. New sessions pick it up; quit and reopen Claude Desktop for sessions already open. (Claude Code installs third-party plugins from a marketplace it knows, hence the two steps in one line.)
Updates. Turn them on once: Room → Settings → Updates → Install by themselves (the setup script does it for you). From your next new session on, a release loads into every open session by itself, with nothing to restart: the plugin keeps a copy of itself in ~/.claude/shared-session/plugin, named in your settings' CLAUDE_CODE_PLUGIN_DIRS with CLAUDE_CODE_PLUGIN_DIR_WATCH=1, and that copy replaces its own files with each release from this repository (Claude Code reloads a watched plugin folder when its files change). It also turns on the marketplace's auto-update. Only when I update stops naming the folder; new sessions then run the installed copy. One exception: if you added the marketplace from a local folder (a clone, by path), Claude Desktop runs the installed copy ahead of the self-updating one, so there a release loads only when you quit and reopen it. The Room says so and copies the command that adds the marketplace from GitHub instead. A share server also turns away versions too old to work right, and says the one command that updates them:
claude plugin marketplace update claude-share && claude plugin update shared-session@claude-share
Or let the setup script do it. It works for a person or an agent; see AGENTS.md. From a clone of this repo:
node scripts/setup.mjs join
It also handles three more cases:
host --server <url> installs too, then shares through your team's server.deploy makes a server on Cloudflare.check --live has real throwaway sessions share and join, then removes them.It stops with a NEEDS HUMAN line at the one or two steps that need a person. Set CLAUDE_BIN=/path/to/claude to use a particular Claude Code binary.
For a whole team, nothing to type: in your organization's managed settings (or a project's checked-in .claude/settings.json), add:
{
"extraKnownMarketplaces": { "claude-share": { "source": { "source": "github", "repo": "Paradigm-Study/claude-share" }, "autoUpdate": true } },
"enabledPlugins": { "shared-session@claude-share": true }
}
/team. Sign in with GitHub or Google (your browser opens; Claude Code picks the sign-in up), then make a team and send people its invite link (they paste it as a message, or open it). The panel lists the sessions shared with your team right now: Join joins one in the session you're in if it's fresh, or opens a new Claude Desktop session with its link ready (press Enter); Join all does that for every one, one after another: each session that joins opens the next. Once you're in a team, Share goes to it (the button says Share with Acme); /share-session link, or What you share goes → By link alone, shares by link only. Owners choose who joins the team's sessions (Only people in the team, the default, or Anyone with the link) and who joins the team by themselves: a verified email at a domain (acme.com), or a GitHub organization. An owner can add only their own email's domain (never a public one like gmail.com) and organizations they're in, so nobody can pull strangers in. In a terminal: /team signin, /team create <name>, /team invite, /team sessions, /team join <n|all>, /team use <name|link>, /team access members|link, /team domains …, /team orgs …, /team leave, /team signout, /team delete-account./share-session. In a session that already has prompts, it asks first whether teammates may see them: Share everything, or Only from now on (/share-session all or /share-session new). The link is copied. It goes through the public server unless you set your own (below)./room, for:/share-file <path> to show a file, or /share-preview <port> to let teammates open a local dev server. Both are listed in the Room, where guests reopen them and the host stops previews. Someone who joins later gets everything so far as it was: each earlier prompt its own turn with its reply and tool cards, a few seconds for a long session, then any preview still open./share-session (in a session with history, /share-session all shares everything so far, /share-session new only what comes next), /room and /stop-sharing; paste a link as a prompt to join. /room opens the whole Room as a dialog: Tab and the arrows walk it, Enter picks, Esc closes; the settings and the chat are all there. The row above the prompt says who's here and whose turn it is, and its buttons take focus like the rest of the prompt area. A guest's transcript draws the host's tool calls the terminal's way: runs of reads, searches and commands fold into one line ("Searched for 2 patterns, read 3 files"), edits and the rest get their own Update(notes.txt) rows, and the Room's Tool calls: Each shown draws every call with its result; the host's prompts show as Name: … under the terminal's own "Prompt from the shared-session plugin" line. What the host's Claude shows reaches a terminal guest as a saved file or a link to open in a browser./stop-sharing. Esc in a guest stops the shared turn.claude --resume in a terminal) picks the room back up on the same link. /clear ends it, as Stop sharing does. A guest who restarts is back in the room when their session reopens, still pinned, with what they missed played in; Leave leaves for good.Out of the box, Share uses https://claude-share.proud-limit-da0a.workers.dev, run by this project (its page). Joining a link always uses the server in that link.
/team delete-account), which removes it at once. On your machine, the sign-in is in ~/.claude/shared-session/account.json, readable only by you.To keep sessions on your own infrastructure, run your own server (below) and set it in the plugin's server option or SHARED_SESSION_SERVER.
Teams can run their own room server instead of the public one. Rooms are short-lived: they expire 24 hours after the host was last seen, and nothing is kept after that.
Cloudflare (recommended): a Worker with one Durable Object per room. A quiet room costs nothing: open streams end at the Worker, and the room sleeps between changes. It fits the free plan for a small team. From a clone of this repo:
node scripts/setup.mjs deploy
That signs you in (npx wrangler login in a browser), deploys and waits for the server. It then points this machine at it and prints the one-line install for teammates. By hand, it's npx wrangler login, npx wrangler deploy, then npx wrangler deploy -c wrangler.preview.toml. The second Worker serves previews of a host's localhost on a host name of its own.
Anywhere else: any machine with Node 20 behind HTTPS.
PORT=8787 PUBLIC_URL=https://share.example.com DATA_FILE=./rooms.json node server/node.mjs
Previews are served on a second port (PREVIEW_PORT, default PORT + 1); set PREVIEW_URL to how people reach it, such as https://preview.example.com.
Teams on your own server need sign-in, from GitHub, Google or both. Make an OAuth app and give the server its id and secret:
https://<your server>/auth/github/callback. It asks for read:user user:email read:org (the organizations are for teams that let a GitHub organization in; an organization that restricts third-party apps lists only once an owner approves the app).https://<your server>/auth/google/callback. Scopes: openid email profile.On Cloudflare, npx wrangler secret put GITHUB_CLIENT_ID (and GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET). On Node, the same names as environment variables; accounts and teams are kept in DATA_FILE with the rooms. A server with neither has no sign-in, and teams aren't offered on it.
Then point Claude Code at it. Use any one of these:
/plugin configure shared-session and enter the address;SHARED_SESSION_SERVER in the env block of ~/.claude/settings.json, or in your organization's managed settings so a whole team gets it;node scripts/set-server.mjs https://your-server.| You see | What it means |
|---|---|
No Share button, or Unknown command: /share-session | The session started before the plugin was installed. Start a new session, or quit and reopen Claude Desktop. |
| An old error after updating the plugin | Same cause: a session keeps the plugin copy it started with, and /reload-plugins re-reads that copy. Restart Desktop or use a new session. |
Couldn't share: Sharing needs a share server… | No server is set. Run node scripts/setup.mjs host --server <url>, or deploy to make one. |
ECONNREFUSED when sharing | The server address points at a machine that isn't running a server. Run node scripts/setup.mjs check to see which address is set. |
hooks: Invalid input from claude plugin … | That claude is older than 2.1.286. Run claude update. setup.mjs uses Claude Desktop's bundled one instead on a Mac, or whatever you set in CLAUDE_BIN; it then notes that your terminal claude still needs claude update. |
You need a workers.dev subdomain on deploy | One-time Cloudflare setup: open Workers & Pages in the dashboard once, then deploy again. |
| TLS handshake errors right after the first deploy | The new certificate takes a minute or so. |
| A pasted link goes to Claude as a normal prompt | The plugin isn't loaded in that session (see the first row), or the link was edited. |
git config user.name, else the computer's username.plugin/relay/ws-relay.cjs); without Node on the host's machine, pages still load but their sockets don't connect. A browser holds one preview at a time (they share one host name), so joining opens the newest and the Room's Open switches.replay tool, which runs nothing. Each result is cut to its first 40 lines; its pictures come whole. A guest's session lists that tool while joined.curl for live updates. Each shared session keeps one curl process reading the room's stream, so a quiet room makes no requests. Without curl, or against a server too old to stream, the plugin polls instead, from every 0.4 s while busy down to every 15 s when quiet.sh and curl (macOS or Linux).| Path | What it is |
|---|---|
plugin/ | The plugin: hooks/register.tsx (hooks), hooks/rows.ts (transcript rows), hooks/look.ts (drawings), tests/ |
server/core.mjs | Room logic and the link page, shared by both servers |
server/worker.mjs, wrangler.toml | Cloudflare Worker + Durable Object |
server/node.mjs | Zero-dependency Node server |
test/e2e.mjs | End to end: a real host and guests through a server |
node server/node.mjs
claude --plugin-dir ./plugin
claude plugin test plugin
CLAUDE_BIN=$(which claude) node test/e2e.mjs
Add EXTERNAL_SERVER=1 SHARE_SERVER=https://your-server to run it against a deployed server instead of a local one, or SHARED_SESSION_TRANSPORT=poll to test the polling fallback.
The end-to-end run starts a host (in bypass mode, on purpose) and two guests, then checks:
MIT, see LICENSE. The Paradigm name, logo and the Clover character used in the link page and Room banner are trademarks of Paradigm Study and are not covered by the license; see TRADEMARKS.md.
hooks/register.tsx 5134 lines1// Shared sessions: Share turns this Claude Code session into one teammates
2// join from their own Claude Code by opening or pasting the link.
3//
4// The session that shares is the host: it runs every turn and every tool on
5// its own machine and mirrors its transcript to the room server. A session
6// that joins is a guest. What a guest types goes to the host as a prompt, and
7// every host turn plays out in the guest as a turn of its own: the guest's
8// `turn.step` answers from the host's stream instead of calling a model, so
9// the guest's transcript draws prompts and replies the way it draws its own.
10//
11// Everything a shared session adds to the screen sits where the app already
12// draws: a teammate's face and name on their prompt, who's here in the
13// footer's labels, whose turn it is on the working line, "for Alex" under a
14// tool call the host's Claude made for Alex, a preview in the approval
15// dialog, one row above the prompt, and the Room panel (people, activity,
16// a side chat Claude never reads, and the host's controls).
17
18import { atom, memberOf, read, update } from 'claude-code'
19import type { Elements, EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, RenderChildren, RenderSurface, Timer, TurnStepChunk } from 'claude-code'
20
21import type { ShareAccount, ShareActivity, ShareChat, ShareFileMeta, ShareMode, SharePerson, SharePolicy, ShareRoom, ShareShown, ShareTeam, ShareTeams, ShareTeamSession, ShareWorking } from '../types'
22import { ACCENT, BAD, GOOD, ROSE, ago, avatarSvg, bannerSvg, labelsFor, stackSvg, toolGlyph } from './look'
23import type { Face } from './look'
24import { attachmentsOf, delivered, rowsFromMessage, rowsToMarkdown, splitSpeaker, summarizeTool } from './rows'
25import type { Media, RoomImage, Row } from './rows'
26import { SERVER_URL } from './server'
27import { readsInside } from './paths'
28
29type $ = EngineInterface
30type UI = Elements[RenderSurface]
31
32const PLUGIN = 'shared-session'
33const ROOM = 'shared-room' // the Room panel's id
34const TEAM = 'shared-team' // the Team panel's id
35const ASK_HEADER = 'Shared' // marks the approval question this plugin asks
36// How this session hears about the room. While a plugin has a request in
37// flight, the engine holds every prompt after the first until it returns, so
38// the plugin never long-polls. Instead a `curl` child, whose open stream holds
39// nothing, keeps `stream?after=<seq>` open for the session's life, and the
40// room sends a line whenever it changes: a quiet room costs no requests at
41// all. Where curl can't start, or the server has no stream, the plugin polls
42// with requests that return at once, less often the longer nothing happens.
43const STREAM_STALE_MS = 70_000 // no line for this long (keepalives come every 25 s): reconnect
44const POLL_HOT_MS = 400 // something happened in the last few seconds
45const POLL_IDLE_MS = 1_500
46const HOT_FOR_MS = 15_000
47const RIDE_WAIT_MS = 600 // a riding turn waits this long for news at a time
48const TAIL_CHARS = 600
49
50// Tools a guest's turn runs without asking the host (policy `edits`): they only read.
51const READ_ONLY = new Set([
52 'Read',
53 'Glob',
54 'Grep',
55 'LS',
56 'NotebookRead',
57 'TodoWrite',
58 'ToolSearch',
59 'WebSearch',
60 'AskUserQuestion',
61 'Skill',
62 'EnterPlanMode',
63 'ExitPlanMode',
64 'TaskList',
65 'TaskGet',
66 'TaskOutput',
67])
68
69const DEFAULT_POLICY: SharePolicy = { prompts: 'everyone', approvals: 'edits', files: 'on' }
70
71const modeA = atom({ plugin: 'shared-session', key: 'mode' } as const, 'idle' as ShareMode)
72const roomA = atom({ plugin: 'shared-session', key: 'room' } as const, null as ShareRoom | null)
73const peopleA = atom({ plugin: 'shared-session', key: 'people' } as const, [] as SharePerson[])
74const workingA = atom({ plugin: 'shared-session', key: 'working' } as const, null as ShareWorking | null)
75const activityA = atom({ plugin: 'shared-session', key: 'activity' } as const, [] as ShareActivity[])
76const chatA = atom({ plugin: 'shared-session', key: 'chat' } as const, [] as ShareChat[])
77const unreadA = atom({ plugin: 'shared-session', key: 'unread' } as const, 0)
78const policyA = atom({ plugin: 'shared-session', key: 'policy' } as const, DEFAULT_POLICY)
79const trustedA = atom({ plugin: 'shared-session', key: 'trusted' } as const, [] as string[])
80const ownersA = atom({ plugin: 'shared-session', key: 'owners' } as const, {} as Record<string, string>)
81const liveUpdatesA = atom({ plugin: 'shared-session', key: 'liveUpdates' } as const, null as 'running' | 'next' | 'pinned' | null)
82const replayRunF = atom({ plugin: 'shared-session', key: 'replayRun' } as const, null as { tools?: string[]; hidden?: true } | null)
83const toolRowsA = atom({ plugin: 'shared-session', key: 'toolRows' } as const, 'grouped' as 'grouped' | 'each')
84const approvingA = atom({ plugin: 'shared-session', key: 'approving' } as const, null as { who: string; what: string; always: string } | null)
85const sidebarA = atom({ plugin: 'shared-session', key: 'sidebar' } as const, null as { title: string; pinned: boolean; id?: string } | null)
86const shownA = atom({ plugin: 'shared-session', key: 'shown' } as const, [] as ShareShown[])
87const previewsA = atom({ plugin: 'shared-session', key: 'previews' } as const, {} as Record<string, string>)
88const autoOpenA = atom({ plugin: 'shared-session', key: 'autoOpen' } as const, true)
89const askingA = atom({ plugin: 'shared-session', key: 'asking' } as const, null as { prompts: number } | null)
90const connectionA = atom({ plugin: 'shared-session', key: 'connection' } as const, 'live' as 'live' | 'reconnecting')
91const confirmingA = atom({ plugin: 'shared-session', key: 'confirming' } as const, null as 'stop' | null)
92const newerA = atom({ plugin: 'shared-session', key: 'newer' } as const, null as string | null)
93const pagesA = atom({ plugin: 'shared-session', key: 'pages' } as const, {} as Record<string, Record<string, unknown>>)
94const updatesA = atom({ plugin: 'shared-session', key: 'updates' } as const, null as boolean | null)
95const NO_TEAMS: ShareTeams = { server: '', account: null, teams: [], current: null, byLink: false, sessions: [], members: [], providers: [], signingIn: null, invite: null, loaded: 0, error: null }
96const teamsA = atom({ plugin: 'shared-session', key: 'teams' } as const, NO_TEAMS)
97
98// Context a host app puts into the person's message (Claude Desktop adds a
99// <system-reminder> to a first prompt): not typed, and never shared.
100const SYSTEM_BLOCKS = /<(system-reminder|task-notification)>[\s\S]*?<\/\1>/g
101
102function typedText(text: string): string {
103 return text.replace(SYSTEM_BLOCKS, '').trim()
104}
105
106// Guest: the ride a starting turn is. Its text can differ from what the
107// plugin submitted (an app's prompt hooks add to it, a long prompt is cut),
108// so: the exact text, else the same beginning, else the oldest waiting.
109function takeRide(text: string): Ride | undefined {
110 const exact = ridesByText.get(text)?.shift()
111 if (exact) return exact
112 const head = (t: string) => t.slice(0, 200)
113 for (const [key, list] of ridesByText) {
114 if (list.length && (text.startsWith(head(key)) || key.startsWith(head(text)))) return list.shift()
115 }
116 for (const list of ridesByText.values()) if (list.length) return list.shift()
117 return undefined
118}
119
120// Prompts a person typed: at the prompt box, through the Desktop app or an SDK
121// host, or over Remote Control. Only these join a shared session; a message a
122// peer session, channel or task delivered never does (once joined, everything
123// typed here goes to someone else's machine).
124const PERSON = new Set(['composer', 'sdk', 'bridge'])
125
126// A share link: <server origin, with any path prefix>/s/<room id>, alone.
127const LINK = /^\s*(https?:\/\/[^\s]+?)\/s\/([A-Za-z0-9_-]{16,64})\/?\s*$/
128// A team's invite link (`/i/<code>`), pasted as a message.
129const INVITE = /^\s*(https?:\/\/[^\s]+?)\/i\/([A-Za-z0-9_-]{8,40})\/?\s*$/
130
131type ServerEvent = {
132 seq: number
133 type: string
134 from: { seat: string; name: string; role: 'host' | 'guest' }
135 ts: number
136 body: Record<string, unknown>
137}
138
139type EventsPage = { seq: number; events: ServerEvent[]; people: SharePerson[]; ended: boolean; title: string }
140
141class ApiError extends Error {
142 constructor(
143 message: string,
144 readonly status: number,
145 readonly data: Record<string, unknown> = {},
146 ) {
147 super(message)
148 }
149}
150
151// ---------------------------------------------------------------------------
152// Module state. A reload starts these over; what must survive is in $.state.
153
154// The `server` option, as the plugin's options carry it (set by register).
155let configuredServer = ''
156
157const NO_SERVER =
158 'Sharing needs a share server, and none is set up yet. Run your own (README: "Host a server"), then set it with `/plugin configure shared-session` or the SHARED_SESSION_SERVER environment variable. Joining a link someone sent you needs nothing.'
159
160// ---------------------------------------------------------------------------
161// Updates. The plugin says its version on every request: the server turns a
162// version known to misbehave away (with the command that updates it) and says
163// which is newest. Claude Code installs updates by itself when the marketplace
164// has `autoUpdate` on, which the Room's Updates setting turns on.
165
166const MARKETPLACE = 'claude-share'
167const MARKETPLACE_SOURCE = { source: 'github', repo: 'Paradigm-Study/claude-share' }
168const UPDATE_COMMAND = 'claude plugin marketplace update claude-share && claude plugin update shared-session@claude-share'
169const CARDS_FROM = '0.9.0' // the first version whose tool calls guests draw as cards
170let pluginVersion: string | undefined
171
172async function ownVersion($: $): Promise<string> {
173 if (pluginVersion === undefined) {
174 try {
175 pluginVersion = String(JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)).version ?? '')
176 } catch {
177 pluginVersion = ''
178 }
179 }
180 return pluginVersion
181}
182
183function newerThan(a: string, b: string): boolean {
184 const x = a.split('.').map(n => Number.parseInt(n, 10) || 0)
185 const y = b.split('.').map(n => Number.parseInt(n, 10) || 0)
186 for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0)
187 return false
188}
189
190// What the server said is newest: noted once, and shown in the Room.
191async function noteLatest($: $, latest: unknown) {
192 const mine = await ownVersion($)
193 if (typeof latest !== 'string' || !mine || !newerThan(latest, mine) || (await read($, newerA)) === latest) return
194 await update($, newerA, () => latest)
195 const auto = await read($, updatesA)
196 const live = await read($, liveUpdatesA)
197 if (live === 'running') return void selfUpdate($, latest)
198 if (live === 'pinned') {
199 return void $.ui.log(
200 `Shared Sessions ${latest} is out (this session runs ${mine}). Claude Desktop runs the copy installed from your local marketplace folder, so a release loads only when you quit and reopen it. To have releases load in place, add the marketplace from GitHub: ${await switchCommand($)}`,
201 )
202 }
203 $.ui.log(
204 auto
205 ? `Shared Sessions ${latest} is out (this session runs ${mine}). It installs by itself; new sessions get it${live === 'next' ? ', and from then on updates load without a restart' : ''}.`
206 : `Shared Sessions ${latest} is out (this session runs ${mine}). Turn on Updates in the Room, or run: ${UPDATE_COMMAND}`,
207 )
208}
209
210// Updates in place. Claude Code loads a plugin folder named in the
211// settings' CLAUDE_CODE_PLUGIN_DIRS in every session, ahead of the installed
212// copy, and with CLAUDE_CODE_PLUGIN_DIR_WATCH=1 every running session reloads
213// it when its files change. So with Updates on, the plugin keeps a copy of
214// itself there, and that copy replaces its own files with each release (from
215// the repository the marketplace installs from): open sessions take it up
216// at once, no restart. Off, the folder is no longer named and new sessions
217// run the installed copy again.
218const LIVE_SUBDIR = '.claude/shared-session/plugin'
219const RELEASE_URL = 'https://codeload.github.com/Paradigm-Study/claude-share/tar.gz/refs/heads/main'
220const INSTALLED_ID = 'shared-session@claude-share'
221let updatingTo: string | null = null
222
223async function liveDirOf($: $): Promise<string | null> {
224 const home = await $.env.get('HOME')
225 return home ? `${home}/${LIVE_SUBDIR}` : null
226}
227const runsLive = ($: $) => $.plugin.root.replace(/\/+$/, '').endsWith(`/${LIVE_SUBDIR}`)
228
229async function readSettings($: $): Promise<{ path: string; settings: Record<string, unknown> } | null> {
230 const path = await settingsFile($)
231 if (!path) return null
232 try {
233 return { path, settings: (await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : {} }
234 } catch {
235 return null
236 }
237}
238
239// Names (or stops naming) the live folder in the settings' env, the rest kept.
240async function nameLiveDir($: $, on: boolean): Promise<boolean> {
241 const dir = await liveDirOf($)
242 const read0 = await readSettings($)
243 if (!dir || !read0) return false
244 const env = { ...((read0.settings.env ?? {}) as Record<string, string>) }
245 const dirs = String(env.CLAUDE_CODE_PLUGIN_DIRS ?? '').split(':').filter(d => d && d !== dir)
246 if (on) {
247 env.CLAUDE_CODE_PLUGIN_DIRS = [...dirs, dir].join(':')
248 env.CLAUDE_CODE_PLUGIN_DIR_WATCH = '1'
249 } else if (dirs.length) env.CLAUDE_CODE_PLUGIN_DIRS = dirs.join(':')
250 else {
251 delete env.CLAUDE_CODE_PLUGIN_DIRS
252 delete env.CLAUDE_CODE_PLUGIN_DIR_WATCH
253 }
254 try {
255 await $.fs.write(read0.path, `${JSON.stringify({ ...read0.settings, env }, null, 2)}\n`)
256 return true
257 } catch {
258 return false
259 }
260}
261
262// Turns updates in place on: this copy into the live folder, then named.
263async function enableLive($: $): Promise<boolean> {
264 const dir = await liveDirOf($)
265 if (!dir) return false
266 if (!runsLive($)) {
267 const copied = await sh(
268 $,
269 [
270 `src=${sq($.plugin.root)}; live=${sq(dir)}`,
271 'mkdir -p "$live" || exit 1',
272 'if command -v rsync >/dev/null 2>&1; then rsync -a --delete --checksum --exclude .claude --exclude tests "$src/" "$live/"; else cp -R "$src/." "$live/"; fi && echo copied',
273 '',
274 ].join('\n'),
275 )
276 if (!copied.out.includes('copied')) return false
277 }
278 if (!(await nameLiveDir($, true))) return false
279 const pinned = !runsLive($) && (await pinnedByDesktop($))
280 await update($, liveUpdatesA, () => (runsLive($) ? 'running' : pinned ? 'pinned' : 'next'))
281 return true
282}
283
284// The live copy: fetch the release, and when it's newer than this one, put
285// its files in place of this folder's (one session at a time, by a lock);
286// every session running the folder reloads with it.
287async function selfUpdate($: $, latest: string) {
288 if (!runsLive($) || updatingTo === latest || !(await read($, updatesA))) return
289 const mine = await ownVersion($)
290 if (!mine || !newerThan(latest, mine)) return
291 updatingTo = latest
292 const live = $.plugin.root.replace(/\/+$/, '')
293 const url = (await $.env.get('SHARED_SESSION_RELEASE_URL')) || RELEASE_URL
294 const fetched = await sh(
295 $,
296 [
297 `live=${sq(live)}; next="$live.next"; lock="$live.lock"`,
298 'if ! mkdir "$lock" 2>/dev/null; then',
299 ' [ -n "$(find "$lock" -maxdepth 0 -mmin +10 2>/dev/null)" ] && rmdir "$lock" 2>/dev/null && mkdir "$lock" 2>/dev/null || { echo busy; exit 0; }',
300 'fi',
301 'tmp=$(mktemp -d) || { rmdir "$lock"; exit 1; }',
302 `curl -fsSL --max-time 90 ${sq(url)} -o "$tmp/r.tgz" && tar -xzf "$tmp/r.tgz" -C "$tmp" || { rm -rf "$tmp"; rmdir "$lock"; echo failed; exit 0; }`,
303 'pj=$(find "$tmp" -path "*/plugin/.claude-plugin/plugin.json" | head -1); src="${pj%/.claude-plugin/plugin.json}"',
304 '[ -n "$pj" ] && [ -f "$src/hooks/register.tsx" ] || { rm -rf "$tmp"; rmdir "$lock"; echo failed; exit 0; }',
305 'rm -rf "$next" && mkdir -p "$next" && cp -R "$src/." "$next/" && rm -rf "$next/tests" "$next/.claude" "$tmp"',
306 `echo "version=$(sed -n 's/.*"version": *"\\([^"]*\\)".*/\\1/p' "$next/.claude-plugin/plugin.json" | head -1)"`,
307 '',
308 ].join('\n'),
309 )
310 const got = /version=(\S+)/.exec(fetched.out)?.[1]
311 const apply = Boolean(got && newerThan(got, mine))
312 if (apply) $.ui.log(`Updating Shared Sessions to ${got}. It reloads in place; nothing to restart.`)
313 if (got) {
314 await sh(
315 $,
316 [
317 `live=${sq(live)}; next="$live.next"; lock="$live.lock"`,
318 apply ? 'if command -v rsync >/dev/null 2>&1; then rsync -a --delete --checksum --exclude .claude "$next/" "$live/"; else cp -R "$next/." "$live/"; fi' : ':',
319 'rm -rf "$next"; rmdir "$lock" 2>/dev/null; echo done',
320 '',
321 ].join('\n'),
322 )
323 }
324 if (!apply) {
325 updatingTo = null // try again at the next check
326 // Said once a version: the download failing here (a proxy, no curl) would
327 // otherwise keep this session on its version unseen.
328 if (!got && !/busy/.test(fetched.out) && failedFor !== latest) {
329 failedFor = latest
330 $.ui.log(`Shared Sessions ${latest} is out, but this machine couldn't download it. Run: ${UPDATE_COMMAND} — the next session picks it up.`)
331 }
332 }
333}
334let failedFor: string | null = null
335
336// The live copy behind the installed one (someone ran the update command, or
337// a download failed here): take the installed copy's files, no network; every
338// session running the folder reloads with them.
339async function catchUpFromInstalled($: $) {
340 if (!runsLive($)) return
341 const mine = await ownVersion($)
342 const home = await $.env.get('HOME')
343 if (!mine || !home) return
344 let installed: { installPath?: unknown; version?: unknown } | undefined
345 try {
346 const list = JSON.parse(await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`))
347 installed = ((list.plugins ?? list)[INSTALLED_ID] as { installPath?: unknown; version?: unknown }[] | undefined)?.find(e => typeof e.installPath === 'string')
348 } catch {
349 return
350 }
351 const version = typeof installed?.version === 'string' ? installed.version : ''
352 if (!version || !newerThan(version, mine) || typeof installed?.installPath !== 'string') return
353 $.ui.log(`Updating Shared Sessions to ${version} from the installed copy. It reloads in place; nothing to restart.`)
354 await sh(
355 $,
356 [
357 `src=${sq(installed.installPath)}; live=${sq($.plugin.root.replace(/\/+$/, ''))}`,
358 '[ -f "$src/hooks/register.tsx" ] || exit 0',
359 'if command -v rsync >/dev/null 2>&1; then rsync -a --delete --checksum --exclude .claude --exclude tests "$src/" "$live/"; else cp -R "$src/." "$live/"; fi',
360 '',
361 ].join('\n'),
362 )
363}
364
365// What the server says is newest, asked now and then: a long-open session
366// learns of a release without sharing or joining anything.
367async function checkForUpdate($: $) {
368 try {
369 const info = await api<{ latest?: string }>($, await serverOf($), '/api/version')
370 await noteLatest($, info.latest)
371 if (typeof info.latest === 'string' && (await read($, liveUpdatesA)) === 'running') await selfUpdate($, info.latest)
372 } catch {}
373}
374
375async function settingsFile($: $): Promise<string | null> {
376 const home = await $.env.get('HOME')
377 return home ? `${home}/.claude/settings.json` : null
378}
379
380// Claude Desktop hands each session the installed copy's own folder when
381// its marketplace is a local one (a folder or a file, as a clone added by
382// path is). That folder comes ahead of the one that updates itself, so in
383// Desktop a release loads only at a restart; a GitHub marketplace's plugin is
384// left to Claude Code's loader, where the updating folder wins.
385async function pinnedByDesktop($: $): Promise<boolean> {
386 if ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) !== 'claude-desktop') return false
387 const home = await $.env.get('HOME')
388 if (!home) return false
389 try {
390 const known = JSON.parse(await $.fs.read(`${home}/.claude/plugins/known_marketplaces.json`)) as Record<string, { source?: { source?: unknown } }>
391 const kind = known[MARKETPLACE]?.source?.source
392 return kind === 'directory' || kind === 'file'
393 } catch {
394 return false
395 }
396}
397
398// From a local marketplace to the GitHub one, the plugin's server kept (the
399// marketplace's removal takes the plugin and its options with it).
400async function switchCommand($: $): Promise<string> {
401 const options = ((await readSettings($))?.settings.pluginConfigs as Record<string, { options?: { server?: unknown } }> | undefined)?.[INSTALLED_ID]?.options
402 const server = typeof options?.server === 'string' && options.server.replace(/\/+$/, '') !== SERVER_URL ? options.server : ''
403 return `claude plugin marketplace remove ${MARKETPLACE} && claude plugin marketplace add ${MARKETPLACE_SOURCE.repo} && claude plugin install ${INSTALLED_ID}${server ? ` --config server=${server}` : ''}`
404}
405
406// Whether the marketplace auto-updates, from the person's settings.
407async function readUpdates($: $) {
408 const path = await settingsFile($)
409 let on: boolean | null = null
410 let settings: Record<string, unknown> = {}
411 try {
412 if (path) settings = JSON.parse(await $.fs.read(path)) ?? {}
413 on = (settings.extraKnownMarketplaces as Record<string, { autoUpdate?: unknown }> | undefined)?.[MARKETPLACE]?.autoUpdate === true
414 } catch {
415 on = path && !(await $.fs.exists(path).catch(() => true)) ? false : null
416 }
417 const dir = await liveDirOf($)
418 const named = Boolean(dir && String((settings.env as Record<string, unknown> | undefined)?.CLAUDE_CODE_PLUGIN_DIRS ?? '').split(':').includes(dir))
419 // The marketplace added again (moved to GitHub, say) comes back without
420 // autoUpdate, and the plugin's options with it. The folder still named says
421 // the person chose updates (turning them off un-names it): on again.
422 const known = (settings.extraKnownMarketplaces ?? {}) as Record<string, Record<string, unknown>>
423 if (path && named && known[MARKETPLACE] && known[MARKETPLACE].autoUpdate === undefined) {
424 try {
425 await $.fs.write(path, `${JSON.stringify({ ...settings, extraKnownMarketplaces: { ...known, [MARKETPLACE]: { ...known[MARKETPLACE], autoUpdate: true } } }, null, 2)}\n`)
426 on = true
427 } catch {}
428 }
429 await update($, updatesA, () => on)
430 const pinned = named && !runsLive($) && (await pinnedByDesktop($))
431 await update($, liveUpdatesA, () => (runsLive($) ? 'running' : pinned ? 'pinned' : named ? 'next' : null))
432 // Automatic updates chosen before they could load in place (or the folder
433 // named for them gone): in place now.
434 const missing = named && dir ? !(await $.fs.exists(`${dir}/.claude-plugin/plugin.json`).catch(() => true)) : false
435 if (on && (!named || missing) && !runsLive($) && (await enableLive($))) {
436 $.ui.log('Shared Sessions now updates in place: from your next new session on, a release loads without a restart.')
437 }
438}
439
440// The Room's Updates setting: the marketplace's `autoUpdate` in the person's
441// own settings, everything else there left as it was.
442async function writeUpdates($: $, on: boolean) {
443 const path = await settingsFile($)
444 if (!path) return
445 let settings: Record<string, unknown> = {}
446 try {
447 if (await $.fs.exists(path)) settings = JSON.parse(await $.fs.read(path))
448 } catch {
449 $.ui.toast("Couldn't read ~/.claude/settings.json, so nothing changed")
450 return
451 }
452 const known = (settings.extraKnownMarketplaces ?? {}) as Record<string, Record<string, unknown>>
453 const entry = known[MARKETPLACE] ?? { source: MARKETPLACE_SOURCE }
454 settings.extraKnownMarketplaces = { ...known, [MARKETPLACE]: { ...entry, autoUpdate: on } }
455 try {
456 await $.fs.write(path, `${JSON.stringify(settings, null, 2)}\n`)
457 } catch {
458 $.ui.toast('Couldn\'t change it here: use /plugin → Marketplaces → claude-share')
459 return
460 }
461 await update($, updatesA, () => on)
462 if (on) {
463 const live = await enableLive($)
464 $.ui.toast(live ? (runsLive($) ? 'Updates: automatic, in place' : 'Updates: automatic. New sessions update in place') : 'Updates: automatic')
465 } else {
466 await nameLiveDir($, false)
467 await update($, liveUpdatesA, () => (runsLive($) ? 'running' : null))
468 $.ui.toast(runsLive($) ? 'Updates: manual, from your next new session on' : 'Updates: manual')
469 }
470}
471
472// Where Share creates rooms: the plugin's option, the environment, or the
473// build's default, in that order.
474async function serverOf($: $): Promise<string> {
475 const fromEnv = (await $.env.get('SHARED_SESSION_SERVER')) ?? ''
476 // The live copy is a plugin folder, configured under another id: the
477 // server saved for the installed copy (setup.mjs, /plugin configure) counts.
478 let saved = configuredServer
479 if (!saved && runsLive($)) {
480 const options = ((await readSettings($))?.settings.pluginConfigs as Record<string, { options?: { server?: unknown } }> | undefined)?.[INSTALLED_ID]?.options
481 saved = typeof options?.server === 'string' ? options.server : ''
482 }
483 const url = (saved || fromEnv || SERVER_URL).trim().replace(/\/+$/, '')
484 if (!/^https?:\/\/[^\s/]+/.test(url)) throw new Error(NO_SERVER)
485 return url
486}
487let myName: string | undefined
488// An entry held (`hold`) waits for its pictures to go up, and what follows it waits too.
489let outbox: { type: string; body: Record<string, unknown>; hold?: boolean }[] = []
490let flushTimer: Timer | null = null
491let flushing = false
492let flushFailures = 0
493let pollGeneration = 0 // the room feed's run; a new one ends the old
494let pollFailures = 0
495let pollDelay = 0 // the wait before the next background poll
496let lastActivity = 0
497// The open stream: its child, the cursor it started after, the events it has
498// brought since (riding turns read them), and those waiting for more.
499let feedStream: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | null = null
500let streamUp = false
501let streamFloor = 0
502let streamEnded = false
503const streamed: ServerEvent[] = []
504const feedWaiters = new Set<() => void>()
505let roomOpen = false
506
507// Host: guest prompts submitted and not yet started, oldest first.
508const pendingGuestPrompts: { who: string; framed: string; text: string; pid: string; attached?: string[] }[] = []
509// Guest: a prompt with attachments, held until its stored message hands over
510// their bytes (a prompt's hook sees only their kinds), then sent with them.
511let heldPrompt: { text: string; pid: string; names: string[]; timer: Timer } | null = null
512// Host: the approval question on screen, so its dialog can show a preview.
513let pendingAsk: { who: string; preview: string; descriptions: Record<string, string> } | null = null
514// The row-above-the-prompt answer to a teammate's call, when the dialog didn't show.
515let bandAnswer: ((label: string) => void) | null = null
516
517// Guest: the host's turns as announced, and how this session shows them.
518type HostTurn = { turnId: string; by: string; prompt: string; pid?: string; startSeq: number; shown: boolean; claimed: boolean }
519type Ride =
520 | { kind: 'own'; pid: string; fromSeq: number } // a prompt typed here
521 | { kind: 'turn'; turnId: string } // someone else's turn
522 | { kind: 'static'; rows: Row[] } // what happened before this session joined
523 | { kind: 'note'; text: string; then: Exchange[]; open?: ServerEvent[] } // an answer from the plugin itself
524 | { kind: 'artifact'; event: ServerEvent } // something the host showed outside a turn
525type Exchange = { prompt: string; rows: Row[] }
526const hostTurns: HostTurn[] = []
527const ownPending = new Set<string>()
528const ridesByText = new Map<string, Ride[]>()
529const ridesByTurn = new Map<string, Ride>()
530let localTurnActive = false
531// Guest: turns this plugin starts go out one at a time (prompts submitted
532// together reach the engine as one turn): what waits, and whether one has
533// gone out and not started yet.
534const laterRides: { text: string; ride: Ride }[] = []
535let rideSubmitted = false
536
537// ---------------------------------------------------------------------------
538// Server
539
540async function api<T>(
541 $: $,
542 server: string,
543 path: string,
544 init: { method?: string; token?: string; body?: unknown; account?: string } = {},
545): Promise<T> {
546 const headers: Record<string, string> = { 'content-type': 'application/json' }
547 if (init.token) headers.authorization = `Bearer ${init.token}`
548 // Who's signed in, for a team's session: a header, never the address.
549 if (init.account) headers['x-shared-session-account'] = init.account
550 const version = await ownVersion($)
551 if (version) headers['x-shared-session-version'] = version
552 const res = await $.http.fetch(`${server}${path}`, {
553 method: init.method ?? 'GET',
554 headers,
555 body: init.body === undefined ? undefined : JSON.stringify(init.body),
556 })
557 let data: Record<string, unknown> = {}
558 try {
559 data = JSON.parse(res.text)
560 } catch {}
561 if (!res.ok) throw new ApiError(typeof data.error === 'string' ? data.error : `HTTP ${res.status}`, res.status, data)
562 return data as T
563}
564
565const isGone = (error: unknown) => error instanceof ApiError && [401, 404, 410].includes(error.status)
566
567function send($: $, type: string, body: Record<string, unknown>) {
568 const last = outbox.at(-1)
569 if (type === 'delta' && last?.type === 'delta' && last.body.turnId === body.turnId) {
570 last.body = { ...last.body, text: `${last.body.text}${body.text}` }
571 } else {
572 outbox.push({ type, body })
573 }
574 scheduleFlush($, type === 'delta' ? 250 : 30)
575 if (type !== 'delta') {
576 void $.clock.now().then(now => {
577 lastActivity = now
578 })
579 kickPoll($)
580 }
581}
582
583// Host: a row to the room. A result's pictures (a screenshot, an image the
584// host's Claude read) go up as room files first, the row waiting in order for
585// them and what follows waiting for it, so a guest's card shows them as the
586// host's does. "What Claude shows: Stays with me" keeps them here. `media`
587// is the host's copy and never leaves.
588function sendRow($: $, row: Row, withMedia = true) {
589 const { media, ...plain } = row
590 if (!media?.length || !withMedia) return send($, 'row', plain)
591 const entry: { type: string; body: Record<string, unknown>; hold?: boolean } = { type: 'row', body: plain, hold: true }
592 outbox.push(entry)
593 void upImages($, media)
594 .then(images => {
595 if (images.length) entry.body = { ...entry.body, images }
596 })
597 .catch(() => {})
598 .finally(() => {
599 delete entry.hold
600 scheduleFlush($, 0)
601 })
602 void $.clock.now().then(now => {
603 lastActivity = now
604 })
605 kickPoll($)
606}
607
608const IMAGES_PER_RESULT = 8
609const HISTORY_IMAGES = 60 // results whose pictures a shared history carries, newest first
610const IMAGE_MAX = 8 * 1024 * 1024 // bytes; a room file holds 10 MB
611let uploading = 0
612const uploadWaiters: (() => void)[] = []
613
614async function upImages($: $, media: Media[]): Promise<RoomImage[]> {
615 const room = await read($, roomA)
616 if (!room || (await read($, policyA)).files === 'off') return []
617 const out: RoomImage[] = []
618 for (const [i, m] of media.slice(0, IMAGES_PER_RESULT).entries()) {
619 if (m.data.length * 0.75 > IMAGE_MAX) continue
620 // A few at a time: a shared history can hold dozens.
621 while (uploading >= 3) await new Promise<void>(done => uploadWaiters.push(done))
622 uploading += 1
623 try {
624 const meta = await uploadImage($, room, m, `image-${i + 1}.${m.type.split('/')[1]?.replace('jpeg', 'jpg') || 'png'}`)
625 if (meta) out.push({ id: meta.id, type: meta.type, size: meta.size })
626 } finally {
627 uploading -= 1
628 uploadWaiters.shift()?.()
629 }
630 }
631 return out
632}
633
634function scheduleFlush($: $, ms: number) {
635 if (flushTimer) return
636 flushTimer = $.clock.after(ms, () => {
637 flushTimer = null
638 void flush($)
639 })
640}
641
642async function flush($: $) {
643 if (flushing) return scheduleFlush($, 50)
644 const room = await read($, roomA)
645 if (!room) {
646 outbox = []
647 return
648 }
649 // Up to 200 events and ~400 KB a post (the server takes 512 KB).
650 let take = 0
651 let bytes = 0
652 while (take < Math.min(200, outbox.length) && !outbox[take]!.hold) {
653 bytes += JSON.stringify(outbox[take]).length
654 if (take > 0 && bytes > 400_000) break
655 take += 1
656 }
657 const batch = outbox.splice(0, take).map(({ type, body }) => ({ type, body }))
658 if (batch.length === 0) return
659 flushing = true
660 let retry = false
661 try {
662 await api($, room.server, `/api/rooms/${room.id}/events`, {
663 method: 'POST',
664 token: room.token,
665 body: { events: batch },
666 })
667 } catch (error) {
668 if (isGone(error)) outbox = []
669 else {
670 // Keep what matters and try again; live text is not worth replaying.
671 outbox.unshift(...batch.filter(item => item.type !== 'delta'))
672 retry = true
673 }
674 } finally {
675 flushing = false
676 }
677 if (retry) scheduleFlush($, Math.min(30_000, 2000 * 2 ** Math.min(flushFailures++, 4)))
678 else {
679 flushFailures = 0
680 if (outbox.length) scheduleFlush($, 30)
681 }
682}
683
684// Starts hearing about the room: the stream, or polls where it can't run.
685function startFeed($: $) {
686 const generation = ++pollGeneration
687 pollFailures = 0
688 stopStream()
689 $.clock.after(0, () => void runStream($, generation))
690}
691
692function stopStream() {
693 const child = feedStream
694 feedStream = null
695 streamUp = false
696 if (child) void child.return(undefined as never).catch(() => {})
697 wakeFeedWaiters()
698}
699
700function wakeFeedWaiters() {
701 for (const done of [...feedWaiters]) done()
702}
703
704// One curl per connection, its address and token on stdin (not in argv, where
705// other local users could read them). Ends when the room is gone, this run is
706// replaced, or curl can't run at all; otherwise reconnects, backing off.
707async function runStream($: $, generation: number) {
708 let failures = 0
709 while (generation === pollGeneration) {
710 const room = await read($, roomA)
711 if (!room || (await read($, modeA)) === 'idle') return
712 if ((await $.env.get('SHARED_SESSION_TRANSPORT')) === 'poll') return void startPolls($, generation, 'SHARED_SESSION_TRANSPORT=poll')
713 const config = [
714 `url = "${room.server}/api/rooms/${room.id}/stream?after=${room.seq}"`,
715 `header = "Authorization: Bearer ${room.token}"`,
716 'header = "Accept: application/x-ndjson"',
717 // Which plugin this is, so the room knows who needs to update.
718 `header = "x-shared-session-version: ${await ownVersion($)}"`,
719 'no-buffer',
720 'silent',
721 'connect-timeout = 10',
722 'write-out = "\\n{\\"httpStatus\\":%{http_code}}\\n"',
723 ].join('\n')
724 const child = $.process.spawn({ argv: ['curl', '-K', '-'], input: `${config}\n` })
725 feedStream = child
726 streamFloor = room.seq
727 streamEnded = false
728 streamed.length = 0
729 let started = false
730 let delivered = false
731 let status = 0
732 let refusal = ''
733 let buffer = ''
734 const opened = await $.clock.now()
735 let lastLine = opened
736 const watch = () => {
737 if (feedStream !== child) return
738 void $.clock.now().then(now => {
739 if (feedStream !== child) return
740 if (now - lastLine > STREAM_STALE_MS) stopStream()
741 else $.clock.after(20_000, watch)
742 })
743 }
744 $.clock.after(20_000, watch)
745 try {
746 for await (const chunk of child) {
747 started = true
748 if (generation !== pollGeneration) break
749 if (chunk.stream === 'stderr') continue
750 buffer += chunk.text
751 let i: number
752 while ((i = buffer.indexOf('\n')) >= 0) {
753 const line = buffer.slice(0, i).trim()
754 buffer = buffer.slice(i + 1)
755 if (!line) continue
756 let message: Record<string, unknown>
757 try {
758 message = JSON.parse(line)
759 } catch {
760 continue
761 }
762 lastLine = await $.clock.now()
763 if (typeof message.httpStatus === 'number') status = message.httpStatus
764 else if (typeof message.error === 'string') refusal = message.error
765 else if (message.proxy && typeof message.proxy === 'object') {
766 // Host: a guest's browser, through a preview, asking this machine.
767 void answerProxy($, room, message.proxy as Record<string, unknown>)
768 } else if (message.ws && typeof message.ws === 'object') {
769 // …or opening a WebSocket on it (live reload, a dev page's debug data).
770 void relaySocket($, room, message.ws as Record<string, unknown>)
771 }
772 else if (Array.isArray(message.events)) {
773 if (!streamUp && !delivered) $.ui.log('Shared session: listening on a stream', { to: 'debug' })
774 streamUp = true
775 delivered = true
776 failures = 0
777 await setConnection($, 'live')
778 await streamPage($, generation, message as unknown as EventsPage)
779 }
780 }
781 }
782 } catch {
783 // The first pull rejects when curl can't start: poll instead.
784 if (!started) {
785 if (feedStream === child) feedStream = null
786 if (generation === pollGeneration) startPolls($, generation, 'curl could not start')
787 return
788 }
789 }
790 if (feedStream === child) {
791 feedStream = null
792 streamUp = false
793 wakeFeedWaiters()
794 }
795 if (generation !== pollGeneration) return
796 if ([401, 404, 410].includes(status)) {
797 // A server without streams answers the address itself with "Not found".
798 if (status === 404 && refusal === 'Not found') return void startPolls($, generation, 'the server has no stream')
799 const mode = await read($, modeA)
800 await reset($)
801 $.ui.log(mode === 'guest' ? `${room.host}'s session is no longer shared.` : 'Sharing ended: the room closed on the server. Press Share to start a new one.')
802 return
803 }
804 // The server ends every stream after a few minutes: pick it up again at
805 // once. One that never got going, or ended soon after, backs off, so a
806 // proxy that cuts streams short is not hammered.
807 if (delivered && (await $.clock.now()) - opened > 60_000) continue
808 failures += 1
809 if (failures >= 2) await setConnection($, 'reconnecting')
810 await new Promise<void>(resolve => $.clock.after(Math.min(30_000, 500 * 2 ** failures), resolve))
811 }
812}
813
814// Shown in the row above the prompt and the Room: a room this session can't
815// reach says so, rather than looking live and quiet.
816async function setConnection($: $, state: 'live' | 'reconnecting') {
817 if ((await read($, connectionA)) !== state) await update($, connectionA, () => state)
818}
819
820function startPolls($: $, generation: number, why: string) {
821 $.ui.log(`Shared session: polling (${why})`, { to: 'debug' })
822 void pollOnce($, generation)
823}
824
825// A line from the stream: what a poll would have answered.
826async function streamPage($: $, generation: number, page: EventsPage) {
827 const room = await read($, roomA)
828 const mode = await read($, modeA)
829 if (!room || mode === 'idle' || generation !== pollGeneration) return
830 streamed.push(...page.events)
831 if (streamed.length > 2000) {
832 streamFloor = Math.max(streamFloor, streamed[streamed.length - 2001]?.seq ?? streamFloor)
833 streamed.splice(0, streamed.length - 2000)
834 }
835 if (page.ended) streamEnded = true
836 if (page.events.length > 0) lastActivity = await $.clock.now()
837 await receive($, mode, room, page)
838 wakeFeedWaiters()
839}
840
841// A wait inside a riding turn that its step's budget doesn't pay for. Each
842// turn.step dispatch may spend 10 s of its own time, and past that the engine
843// calls the model in its place; the clock stops while a $ call is in flight,
844// but not for a plain timer (nor $.clock.sleep), and a host's Claude can think
845// for much longer than 10 s before a word arrives. So waits are `sleep`s.
846async function pause($: $, ms: number) {
847 try {
848 await $.process.run(['sleep', (ms / 1000).toFixed(2)], { timeoutMs: ms + 2_000 })
849 } catch {
850 await new Promise<void>(resolve => $.clock.after(ms, resolve))
851 }
852}
853
854// Guest: the result of a call the host's Claude made, for its card here: read
855// from the room (the stream, or a short poll) until the host has it, the
856// host's turn ends, or the person stops this turn. Waits are budget-free.
857async function hostResult($: $, room: ShareRoom, call: { hostId: string; seq: number; turnId?: string }, signal: AbortSignal): Promise<string | ReplayBlock[]> {
858 const deadline = (await $.clock.now()) + 30 * 60_000
859 let cursor = call.seq
860 while (!signal.aborted && (await $.clock.now()) < deadline) {
861 const page = await ridePage($, room, cursor).catch(() => null)
862 for (const event of page?.events ?? []) {
863 cursor = Math.max(cursor, event.seq)
864 const row = event.body as unknown as Row
865 if (event.type === 'row' && row.kind === 'result' && row.id === call.hostId) return withImages($, room, `${row.isError ? 'Error: ' : ''}${stripLineNumbers(row.text)}`, row.images)
866 if (event.type === 'turn' && event.body.state === 'end' && (!call.turnId || event.body.turnId === call.turnId)) return "(the host's turn ended before this finished)"
867 if (event.type === 'ended') return '(sharing ended)'
868 }
869 if (page?.ended) return '(sharing ended)'
870 if (!page) await pause($, 500)
871 }
872 return signal.aborted ? '(stopped)' : '(still running on the host)'
873}
874
875// Guest: the tool host calls are drawn as. Only a joined session has it.
876async function readyReplay($: $) {
877 if (replayReady) return
878 replayReady = await $.tool
879 .register({
880 name: 'replay',
881 description:
882 "Shows a tool call made in the shared session this session joined, with the host's result. Used only by the Shared Sessions plugin; it runs nothing.",
883 inputSchema: { type: 'object', properties: { tool: { type: 'string' }, summary: { type: 'string' }, input: { type: 'object' } }, required: ['tool'] },
884 })
885 .then(
886 () => true,
887 () => false,
888 )
889}
890
891// What a riding turn reads next: from the stream when it is up and reaches
892// back far enough, else one short poll of its own.
893async function ridePage($: $, room: ShareRoom, cursor: number): Promise<EventsPage> {
894 if (streamUp && cursor >= streamFloor) {
895 const after = () => streamed.filter(e => e.seq > cursor)
896 const until = (await $.clock.now()) + RIDE_WAIT_MS
897 while (after().length === 0 && !streamEnded && streamUp && (await $.clock.now()) < until) await pause($, 150)
898 const events = after()
899 return { seq: Math.max(cursor, ...events.map(e => e.seq)), events, people: await read($, peopleA), ended: streamEnded, title: room.title }
900 }
901 return api<EventsPage>($, room.server, `/api/rooms/${room.id}/events?after=${cursor}&wait=${RIDE_WAIT_MS}`, { token: room.token })
902}
903
904// Without a stream: poll, fast while something is happening, then less and
905// less often (well inside the server's 45 s grace for a seat).
906function pollWait(now: number): number {
907 const quiet = now - lastActivity
908 if (localTurnActive) return POLL_IDLE_MS
909 if (quiet < HOT_FOR_MS) return POLL_HOT_MS
910 if (quiet < 2 * 60_000) return POLL_IDLE_MS
911 if (quiet < 10 * 60_000) return 5_000
912 return 15_000
913}
914
915// Something happened here: a quiet poller looks now instead of in 15 s.
916function kickPoll($: $) {
917 if (feedStream || streamUp || pollDelay < 5_000) return
918 const generation = ++pollGeneration
919 pollDelay = 0
920 $.clock.after(0, () => void pollOnce($, generation))
921}
922
923async function pollOnce($: $, generation: number) {
924 if (generation !== pollGeneration) return
925 const room = await read($, roomA)
926 const mode = await read($, modeA)
927 if (!room || mode === 'idle') return
928 try {
929 const page = await api<EventsPage>($, room.server, `/api/rooms/${room.id}/events?after=${room.seq}&wait=0`, {
930 token: room.token,
931 })
932 if (generation !== pollGeneration) return
933 pollFailures = 0
934 await setConnection($, 'live')
935 const now = await $.clock.now()
936 if (page.events.length > 0 || (await read($, workingA))) lastActivity = now
937 await receive($, mode, room, page)
938 if (generation === pollGeneration && (await read($, modeA)) !== 'idle') {
939 pollDelay = pollWait(now)
940 $.clock.after(pollDelay, () => void pollOnce($, generation))
941 }
942 } catch (error) {
943 if (generation !== pollGeneration) return
944 if (isGone(error)) {
945 await reset($)
946 $.ui.log(mode === 'guest' ? `${room.host}'s session is no longer shared.` : 'Sharing ended: the room closed on the server. Press Share to start a new one.')
947 return
948 }
949 pollFailures += 1
950 if (pollFailures >= 2) await setConnection($, 'reconnecting')
951 pollDelay = Math.min(15_000, 500 * 2 ** pollFailures)
952 $.clock.after(pollDelay, () => void pollOnce($, generation))
953 }
954}
955
956// What an event adds to the room as people see it: the timeline, the side
957// chat, the host's policy. `live` is false while a join replays history.
958async function absorb($: $, room: ShareRoom, mode: ShareMode, event: ServerEvent, live: boolean) {
959 const who = event.from.name
960 const mine = mode === 'host' ? event.from.role === 'host' : event.from.seat === room.seat
961 const body = event.body
962 const note = (entry: ShareActivity) => update($, activityA, list => [...list, entry].slice(-80))
963 switch (event.type) {
964 case 'join':
965 await note({ ts: event.ts, who, kind: 'join' })
966 if (live && !mine) $.ui.log(`${who} joined`)
967 break
968 case 'leave':
969 await note({ ts: event.ts, who, kind: 'leave' })
970 if (live && !mine) $.ui.log(`${who} left`)
971 break
972 case 'prompt':
973 if (typeof body.text === 'string') await note({ ts: event.ts, who, kind: 'prompt', text: body.text })
974 if (live && !mine && mode === 'host') nudgeSidebar($)
975 break
976 case 'stop':
977 await note({ ts: event.ts, who, kind: 'stop' })
978 break
979 case 'approval':
980 if (body.pending === false && typeof body.what === 'string') {
981 await note({ ts: event.ts, who: room.host, kind: body.allowed ? 'allowed' : 'denied', text: body.what })
982 }
983 break
984 case 'chat':
985 if (typeof body.text === 'string') {
986 const line: ShareChat = { ts: event.ts, who, seat: event.from.seat, text: body.text }
987 await update($, chatA, list => [...list, line].slice(-100))
988 if (live && !mine && !roomOpen) {
989 await update($, unreadA, n => n + 1)
990 $.ui.toast(`${who}: ${body.text.slice(0, 120)}`)
991 nudgeSidebar($)
992 }
993 }
994 break
995 case 'policy': {
996 const prompts = body.prompts === 'watch' ? 'watch' : 'everyone'
997 const approvals = body.approvals === 'all' || body.approvals === 'none' ? body.approvals : 'edits'
998 const files = body.files === 'off' ? 'off' : 'on'
999 await update($, policyA, (): SharePolicy => ({ prompts, approvals, files }))
1000 await note({ ts: event.ts, who: room.host, kind: 'policy', text: describePolicy({ prompts, approvals, files }) })
1001 break
1002 }
1003 case 'artifact': {
1004 const shown = shownOf(event)
1005 if (!shown) break
1006 await update($, shownA, list => [...list.filter(s => s.key !== shown.key), shown].slice(-40))
1007 await note({ ts: event.ts, who: room.host, kind: 'shown', text: shown.name })
1008 break
1009 }
1010 case 'preview':
1011 if (body.state === 'closed' && typeof body.pid === 'string') {
1012 const pid = body.pid
1013 await update($, shownA, list => list.map(s => (s.pid === pid ? { ...s, closed: true } : s)))
1014 }
1015 break
1016 }
1017}
1018
1019// What the Room panel lists for an artifact event.
1020function shownOf(event: ServerEvent): ShareShown | null {
1021 const body = event.body
1022 const kind = body.kind
1023 if (kind !== 'send' && kind !== 'widget' && kind !== 'file' && kind !== 'page' && kind !== 'preview' && kind !== 'link') return null
1024 const files = Array.isArray(body.files) ? (body.files as ShareFileMeta[]) : undefined
1025 const name =
1026 kind === 'preview'
1027 ? `${typeof body.title === 'string' && body.title ? body.title : 'localhost'} (preview)`
1028 : kind === 'widget'
1029 ? `a widget${typeof body.title === 'string' ? `: ${body.title.replaceAll('_', ' ')}` : ''}`
1030 : kind === 'link'
1031 ? String(body.url ?? 'a page')
1032 : files?.some(f => f.path) && files.length > 1
1033 ? `${files[0]!.name} (a page and ${files.length - 1} ${files.length === 2 ? 'file' : 'files'})`
1034 : (files ?? []).map(f => f.name).join(', ') || 'a file'
1035 return {
1036 key: kind === 'preview' && typeof body.pid === 'string' ? `preview:${body.pid}` : `seq:${event.seq}`,
1037 kind,
1038 name,
1039 ts: event.ts,
1040 files,
1041 pid: typeof body.pid === 'string' ? body.pid : undefined,
1042 port: typeof body.port === 'number' ? body.port : undefined,
1043 path: kind === 'preview' && typeof body.path === 'string' && body.path.startsWith('/') ? body.path : undefined,
1044 url: typeof body.url === 'string' ? body.url : undefined,
1045 }
1046}
1047
1048// The update command (or either half of it), as the "is out" line gives it.
1049const UPDATE_TYPED = /^\s*claude\s+plugin\s+(?:marketplace\s+update\s+claude-share|update\s+shared-session(?:@claude-share)?)(?:\s*(?:&&|;)\s*claude\s+plugin\s+(?:marketplace\s+update\s+claude-share|update\s+shared-session(?:@claude-share)?))*\s*$/
1050
1051// Guest (or anyone who typed it here): the update, run on this computer with
1052// its own Claude Code (\`claude\` on the PATH, else Claude Desktop's newest),
1053// then said: in place when this session runs the folder that updates itself,
1054// else from a new session.
1055async function updateHere($: $) {
1056 const home = (await $.env.get('HOME')) ?? ''
1057 const path = (await $.env.get('PATH')) ?? '/usr/bin:/bin'
1058 const { out, code } = await sh(
1059 $,
1060 [
1061 `export HOME=${sq(home)} PATH=${sq(path)}`,
1062 'C=$(command -v claude 2>/dev/null)',
1063 '[ -n "$C" ] || for d in "$HOME/Library/Application Support/Claude/claude-code"/*/*/claude.app/Contents/MacOS/claude; do [ -x "$d" ] && C="$d"; done',
1064 '[ -n "$C" ] || { echo "no claude"; exit 3; }',
1065 '"$C" plugin marketplace update claude-share 2>&1 | tail -1 && "$C" plugin update shared-session@claude-share 2>&1 | tail -1',
1066 '',
1067 ].join('\n'),
1068 )
1069 if (code !== 0) {
1070 $.ui.log(`Couldn't update Shared Sessions here (${out.trim().split('\n').pop() || 'no output'}). In a terminal on this computer, run: ${UPDATE_COMMAND}`)
1071 return
1072 }
1073 let installed = ''
1074 try {
1075 const list = JSON.parse(await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`))
1076 installed = String(((list.plugins ?? list)[INSTALLED_ID] as { version?: unknown }[] | undefined)?.find(x => typeof x.version === 'string')?.version ?? '')
1077 } catch {}
1078 const mine = await ownVersion($)
1079 if (runsLive($) && installed && newerThan(installed, mine)) {
1080 await catchUpFromInstalled($)
1081 return void $.ui.log(`Shared Sessions ${installed} is installed here and loads into this session in place.`)
1082 }
1083 if (installed && newerThan(installed, mine)) return void $.ui.log(`Shared Sessions ${installed} is installed here. Start a new session to use it (in Claude Desktop, quit and reopen the app); with Updates on in the Room, later releases load in place.`)
1084 $.ui.log(`Shared Sessions is up to date here (${installed || mine}).`)
1085}
1086
1087// The newest plugin version this session knows of: its own, or the server's.
1088async function latestKnown($: $): Promise<string> {
1089 const mine = await ownVersion($)
1090 const newer = await read($, newerA)
1091 return newer && (!mine || newerThan(newer, mine)) ? newer : mine
1092}
1093
1094// Host: a guest whose plugin is older than the newest, said once each, with
1095// the command that updates them (in a terminal on their computer, or typed
1096// in their Claude Code, where their plugin runs it there).
1097const notedBehind = new Set<string>()
1098async function noteBehind($: $, people: SharePerson[]) {
1099 const behind = people.filter(p => p.role === 'guest' && p.version && !notedBehind.has(`${p.name}@${p.version}`))
1100 if (!behind.length) return
1101 const latest = await latestKnown($)
1102 for (const p of behind) {
1103 notedBehind.add(`${p.name}@${p.version}`)
1104 if (!latest || !newerThan(latest, p.version!)) continue
1105 $.ui.log(`${p.name} runs Shared Sessions ${p.version}, older than ${latest}: some of what you show (pictures, pages) may not reach them as it does here. They update with: ${UPDATE_COMMAND}`)
1106 }
1107}
1108
1109async function receive($: $, mode: ShareMode, room: ShareRoom, page: EventsPage) {
1110 const hostWas = (await read($, peopleA)).find(p => p.role === 'host')?.online
1111 await update($, peopleA, () => page.people)
1112 if (mode === 'host') await noteBehind($, page.people)
1113 const hostIs = page.people.find(p => p.role === 'host')?.online
1114 if (mode === 'guest' && hostWas !== undefined && hostIs !== undefined && hostWas !== hostIs) {
1115 $.ui.toast(hostIs ? `${room.host} is back` : `${room.host}'s Claude Code closed; the room waits for them`)
1116 }
1117 let seq = room.seq
1118 for (const event of page.events) {
1119 seq = Math.max(seq, event.seq)
1120 const body = event.body
1121 await absorb($, room, mode, event, true)
1122 switch (event.type) {
1123 case 'prompt':
1124 if (mode === 'host' && typeof body.text === 'string') {
1125 await acceptPrompt($, event.from.name, body.text, typeof body.pid === 'string' ? body.pid : '', Array.isArray(body.attachments) ? (body.attachments as ShareFileMeta[]) : [])
1126 }
1127 break
1128 case 'stop':
1129 if (mode === 'host') await stopTurn($, event.from.name)
1130 break
1131 case 'artifact':
1132 // Shown during a host turn: that turn's ride makes the call. Otherwise
1133 // (a /share-file, say) it gets a short turn of its own.
1134 if (mode === 'guest' && !(typeof body.turnId === 'string' && hostTurns.some(t => t.turnId === body.turnId))) {
1135 const shown = shownOf(event)
1136 if (shown) {
1137 laterRides.push({ text: `${room.host} shared ${shown.name}`, ride: { kind: 'artifact', event } })
1138 scheduleRides($)
1139 }
1140 }
1141 break
1142 case 'delta':
1143 if (mode === 'guest' && typeof body.text === 'string') {
1144 const text = body.text
1145 await update($, workingA, w => (w ? { ...w, tail: `${w.tail}${text}`.slice(-TAIL_CHARS) } : w))
1146 }
1147 break
1148 case 'turn':
1149 if (mode !== 'guest') break
1150 if (body.state === 'start') {
1151 const turn = noteHostTurn(event)
1152 await update($, workingA, () => ({
1153 turnId: turn.turnId,
1154 by: turn.by,
1155 byGuest: turn.by !== room.host,
1156 startedAt: event.ts,
1157 tail: '',
1158 waitingFor: null,
1159 }))
1160 } else {
1161 await update($, workingA, w => (w?.turnId === body.turnId ? null : w))
1162 }
1163 break
1164 case 'approval':
1165 if (mode === 'guest') {
1166 const what = body.pending && typeof body.what === 'string' ? body.what : null
1167 await update($, workingA, w => (w ? { ...w, waitingFor: what } : w))
1168 }
1169 break
1170 case 'title':
1171 if (typeof body.title === 'string') {
1172 const title = body.title
1173 await update($, roomA, r => (r ? { ...r, title } : r))
1174 }
1175 break
1176 case 'ended':
1177 if (mode === 'guest') {
1178 await reset($)
1179 $.ui.log(`${room.host} stopped sharing this session.`)
1180 return
1181 }
1182 break
1183 }
1184 }
1185 await update($, roomA, r => (r && r.id === room.id ? { ...r, seq: Math.max(r.seq, seq) } : r))
1186 if (mode === 'guest') scheduleRides($)
1187}
1188
1189async function reset($: $, opts: { keepSidebar?: boolean } = {}) {
1190 // The sidebar row first, while the room is still known: the host's title
1191 // comes back; a guest's says whose session it was.
1192 const was = await read($, roomA)
1193 const wasMode = await read($, modeA)
1194 if (!opts.keepSidebar) await unmarkSidebar($, wasMode === 'guest' && was ? `${was.host}'s session · ${was.title}`.slice(0, 120) : undefined)
1195 pollGeneration += 1
1196 stopStream()
1197 outbox = []
1198 hostTurns.length = 0
1199 ownPending.clear()
1200 ridesByText.clear()hooks/look.ts 178 lines1// How a shared session looks. People are monochrome dots: ink on a light
2// theme, near-white on a dark one, a person's initial knocked out of theirs;
3// the dot breathes while Claude works for that person, and goes hollow when
4// they are away. The rest borrows Paradigm's landing page: paper and ink, the
5// window frame with its three muted dots, mono eyebrows, a serif title, and
6// Clover on the window's edge.
7//
8// The Desktop app draws these as images: transparent, animated, and styled
9// for light and dark under `prefers-color-scheme`, which follows the app's
10// theme. (Not `isInteractive`: that sandboxed frame paints an opaque page
11// behind the art, a white box on a dark theme.) Pure: strings in, strings out.
12
13import { CLOVER_WEBP } from './brand'
14
15const esc = (s: string) =>
16 s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c)
17
18// Paradigm's landing tokens, light and dark (the landing page itself is light).
19export const INK = '#011121'
20export const PAPER = '#faf8f7'
21export const ACCENT = '#6e94cc' // reads on both themes
22export const ROSE = '#dd7f77' // the window's red dot; "live"
23export const SAGE = '#799586'
24export const SAND = '#d2b99f'
25export const GOOD = '#5c9a6a' // allowed, on both themes
26export const BAD = '#d0776b' // declined, on both themes
27
28const FONT = `-apple-system,"Geist","SF Pro Text","Segoe UI",Inter,sans-serif`
29const SERIF = `"Gowun Batang","Iowan Old Style",Georgia,serif`
30const MONO = `"Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace`
31
32/** One letter for one word, two for two: "Sam" → S, "Alex Chen" → AC. */
33export function initials(name: string): string {
34 const parts = name.trim().split(/[\s._-]+/).filter(Boolean)
35 const letters = parts.length >= 2 ? `${parts[0]![0]}${parts[1]![0]}` : (parts[0] ?? '?').slice(0, 1)
36 return letters.toUpperCase()
37}
38
39/**
40 * Dot labels for everyone shown together: one initial, or two words' two;
41 * when single-word names share a first letter, two letters ("Sa", "Sc").
42 */
43export function labelsFor(names: readonly string[]): Map<string, string> {
44 const first = (n: string) => (n.trim()[0] ?? '?').toUpperCase()
45 const counts = new Map<string, number>()
46 for (const n of new Set(names)) counts.set(first(n), (counts.get(first(n)) ?? 0) + 1)
47 const out = new Map<string, string>()
48 for (const n of names) {
49 const words = n.trim().split(/[\s._-]+/).filter(Boolean)
50 if (words.length >= 2) out.set(n, `${words[0]![0]}${words[1]![0]}`.toUpperCase())
51 else if ((counts.get(first(n)) ?? 0) > 1) out.set(n, `${first(n)}${(words[0] ?? '').slice(1, 2).toLowerCase()}`)
52 else out.set(n, first(n))
53 }
54 return out
55}
56
57export type Face = { name: string; online: boolean; note?: string; active?: boolean; label?: string; version?: string }
58
59const DOTS = `<style>
60.dot{fill:${INK}}.ini{fill:#fff}.away .dot{fill:none;stroke:${INK}}.away .ini{fill:${INK}}.more{fill:#8a8f96}
61@media (prefers-color-scheme: dark){.dot{fill:#ececec}.ini{fill:${INK}}.away .dot{stroke:#ececec}.away .ini{fill:#ececec}.more{fill:#9aa0a6}}
62.face{transform-box:fill-box;transform-origin:center;transition:transform .22s cubic-bezier(.22,1,.36,1)}
63.face:hover{transform:scale(1.1)}
64.breathe .dot{transform-box:fill-box;transform-origin:center;animation:breathe 1.9s cubic-bezier(.45,0,.55,1) infinite}
65@keyframes breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.14)}}
66.live{transform-box:fill-box;transform-origin:center;animation:live 2s ease-out infinite}
67@keyframes live{0%{transform:scale(.6);opacity:.55}100%{transform:scale(2);opacity:0}}
68</style>`
69
70function face(f: Face, cx: number, cy: number, r: number): string {
71 const classes = ['face', f.active ? 'breathe' : '', f.online ? '' : 'away'].filter(Boolean).join(' ')
72 const letters = f.label ?? initials(f.name)
73 const size = letters.length > 1 ? r * 0.78 : r * 0.95
74 return `<g class="${classes}"><title>${esc(f.name)}${f.note ? ` · ${esc(f.note)}` : ''}</title><circle class="dot" cx="${cx}" cy="${cy}" r="${f.online ? r : r - 0.8}" stroke-width="1.5"/><text class="ini" x="${cx}" y="${cy}" dy=".36em" text-anchor="middle" font-family='${FONT}' font-size="${size.toFixed(1)}" font-weight="650">${esc(letters)}</text></g>`
75}
76
77/** One person's dot, `size` CSS pixels square. */
78export function avatarSvg(f: Face, size = 22): string {
79 const r = size / 2 - 1.5
80 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${DOTS}${face(f, size / 2, size / 2, r)}</svg>`
81}
82
83/**
84 * The room at a glance: a live mark, then a row of dots (at most `max`, then
85 * "+N"). Returns the markup and its width.
86 */
87export function stackSvg(faces: readonly Face[], opts: { live: boolean; size?: number; max?: number }): { source: string; width: number } {
88 const size = opts.size ?? 22
89 const r = size / 2 - 2
90 const gap = 5
91 const shown = faces.slice(0, opts.max ?? 6)
92 const extra = faces.length - shown.length
93 const lead = 16
94 const cy = size / 2
95 const width = Math.ceil(lead + shown.length * (r * 2 + gap) + (extra > 0 ? 22 : 0))
96 const live = opts.live
97 ? `<g><title>Live</title><circle class="live" cx="5" cy="${cy}" r="4" fill="${ROSE}"/><circle cx="5" cy="${cy}" r="3.2" fill="${ROSE}"/></g>`
98 : `<circle cx="5" cy="${cy}" r="3.2" fill="#9aa0a6"/>`
99 const dots = shown.map((f, i) => face(f, lead + r + i * (r * 2 + gap), cy, r)).join('')
100 const more =
101 extra > 0
102 ? `<text class="more" x="${lead + shown.length * (r * 2 + gap) + 1}" y="${cy}" dy=".36em" font-family='${FONT}' font-size="11" font-weight="600"><title>${extra} more</title>+${extra}</text>`
103 : ''
104 return {
105 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${size}" viewBox="0 0 ${width} ${size}">${DOTS}${live}${dots}${more}</svg>`,
106 width,
107 }
108}
109
110/**
111 * The Room panel's banner, as Paradigm's landing window: a paper card with a
112 * hairline edge, the three muted dots and a mono label in its bar, the host's
113 * dot, a serif title, and Clover perched on the frame. Its surfaces are warm
114 * like Claude's own: paper on the light theme, and on the dark one a card a
115 * step lighter than the app behind it (navy read as a foreign object there).
116 */
117export function bannerSvg(o: { title: string; host: string; live: boolean; detail: string; status?: string; width?: number }): string {
118 const w = o.width ?? 420
119 const top = 30 // room above the frame for Clover
120 const h = top + 108
121 // An average glyph is about half an em in both faces (measured: 0.45 in the
122 // serif title, 0.5 in the text): a guess on the wide side cut titles with a
123 // fifth of the line still free.
124 const fit = (text: string, px: number, em: number) => {
125 const max = Math.floor(px / em)
126 return text.length > max ? `${text.slice(0, Math.max(1, max - 1)).trimEnd()}…` : text
127 }
128 const title = fit(o.title, w - 62 - 18, 16.5 * 0.47)
129 const detail = fit(o.detail, w - 62 - 18, 12 * 0.52)
130 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
131<style>
132.card{fill:#fffdfc;stroke:#e6e1d9}.bar{stroke:#e6e1d9}.grid{fill:#1f1e1d;fill-opacity:.05}.title{fill:${INK}}.detail{fill:#5f5d58}.label{fill:#2c4a7b}.wash{fill:#e4ecf7}
133${DOTS.replace(/^<style>|<\/style>$/g, '')}
134@media (prefers-color-scheme: dark){.card{fill:#30302e;stroke:#43423e}.bar{stroke:#43423e}.grid{fill:#ffffff;fill-opacity:.05}.title{fill:#f2f0eb}.detail{fill:#a9a69e}.label{fill:#c3d3ea}.wash{fill:#3a3f47}.cat{filter:url(#night)}}
135</style>
136<defs><filter id="night" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 .9 0"/></filter>
137<pattern id="g" width="14" height="14" patternUnits="userSpaceOnUse"><circle class="grid" cx="2" cy="2" r="1"/></pattern>
138<clipPath id="c"><rect x=".5" y="${top + 0.5}" width="${w - 1}" height="${h - top - 1}" rx="7"/></clipPath></defs>
139<rect class="card" x=".5" y="${top + 0.5}" width="${w - 1}" height="${h - top - 1}" rx="7"/>
140<g clip-path="url(#c)"><rect x="0" y="${top + 34}" width="${w}" height="${h}" fill="url(#g)"/></g>
141<line class="bar" x1="1" y1="${top + 34}" x2="${w - 1}" y2="${top + 34}"/>
142<circle cx="18" cy="${top + 17}" r="4.5" fill="${ROSE}"/><circle cx="32" cy="${top + 17}" r="4.5" fill="${SAND}"/><circle cx="46" cy="${top + 17}" r="4.5" fill="${SAGE}"/>
143<rect class="wash" x="62" y="${top + 8}" rx="9" width="132" height="18"/>
144<text class="label" x="72" y="${top + 21}" font-family='${MONO}' font-size="9.5" font-weight="600" letter-spacing="1.2">SHARED SESSION</text>
145${
146 o.live
147 ? `<g><circle class="live" cx="${w - 52}" cy="${top + 17}" r="4" fill="${ROSE}"/><circle cx="${w - 52}" cy="${top + 17}" r="3.2" fill="${ROSE}"/><text x="${w - 43}" y="${top + 21}" font-family='${MONO}' font-size="10" font-weight="700" letter-spacing="1.2" fill="${ROSE}">LIVE</text></g>`
148 : `<text class="detail" x="${w - 16}" y="${top + 21}" text-anchor="end" font-family='${MONO}' font-size="10" font-weight="700" letter-spacing="1.2">${esc(o.status ?? 'ENDED')}</text>`
149}
150${face({ name: o.host, online: true }, 36, top + 71, 15)}
151<text class="title" x="62" y="${top + 67}" font-family='${SERIF}' font-size="16.5">${esc(title)}</text>
152<text class="detail" x="62" y="${top + 87}" font-family='${FONT}' font-size="12">${esc(detail)}</text>
153<image class="cat" href="${CLOVER_WEBP}" x="${w - 118}" y="0" width="92" height="${top + 10}" preserveAspectRatio="xMidYMax meet"/>
154</svg>`
155}
156
157/** "3m ago" style ages for activity and chat. */
158export function ago(ts: number, now: number): string {
159 const s = Math.max(0, Math.round((now - ts) / 1000))
160 if (s < 10) return 'just now'
161 if (s < 60) return `${s}s ago`
162 const m = Math.round(s / 60)
163 if (m < 60) return `${m}m ago`
164 const h = Math.round(m / 60)
165 return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
166}
167
168/** Glyph per tool, for compact lines a guest's transcript shows. */
169export function toolGlyph(tool: string): string {
170 if (tool === 'Bash') return '❯'
171 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return '✎'
172 if (tool === 'Read') return '◧'
173 if (tool === 'Grep' || tool === 'Glob') return '⌕'
174 if (tool === 'WebFetch' || tool === 'WebSearch') return '◍'
175 if (tool === 'Agent' || tool === 'Task') return '◈'
176 return '●'
177}
178hooks/rows.ts 242 lines1// What a shared session sends its guests: the host's transcript as compact
2// rows, converted from the Messages API blocks `session.append` and
3// `$.session.messages({ as: 'api' })` hand over.
4
5import { toolGlyph } from './look'
6
7export type Row = {
8 kind: 'user' | 'assistant' | 'tool' | 'result'
9 /** Who typed a user row. */
10 who?: string
11 text: string
12 /** A tool row's tool name. */
13 tool?: string
14 /** A few lines under a tool row: an edit's diff, a write's head. */
15 detail?: string
16 isError?: boolean
17 /** A tool row's call id, and a result row's: pairs the two. */
18 id?: string
19 /** A tool row's input as the host's Claude sent it, when small enough to replay. */
20 input?: Record<string, unknown>
21 /** A result row's pictures (a screenshot, an image read), as room files. */
22 images?: RoomImage[]
23 /** The host's own copy of those pictures, before they go up; never sent. */
24 media?: Media[]
25}
26
27/** A picture in the room's files: its id (the bytes' SHA-256), type and size. */
28export type RoomImage = { id: string; type: string; size: number }
29/** A picture's bytes, base64, and its type. */
30export type Media = { data: string; type: string }
31
32/** What a prompt carries besides its words: base64 images and documents (a PDF). */
33export function attachmentsOf(content: unknown): Media[] {
34 const out: Media[] = []
35 for (const b of blocksOf(content) as (Block & { source?: { type?: unknown; media_type?: unknown; data?: unknown } })[]) {
36 if ((b.type === 'image' || b.type === 'document') && b.source?.type === 'base64' && typeof b.source.data === 'string' && typeof b.source.media_type === 'string') {
37 out.push({ data: b.source.data, type: b.source.media_type })
38 }
39 }
40 return out
41}
42
43/** The pictures in a tool result: the Messages API's base64 image blocks (and MCP's). */
44export function imagesOf(content: unknown): Media[] {
45 const out: Media[] = []
46 for (const b of blocksOf(content) as (Block & { source?: { type?: unknown; media_type?: unknown; data?: unknown }; data?: unknown; mimeType?: unknown })[]) {
47 if (b.type !== 'image') continue
48 if (b.source?.type === 'base64' && typeof b.source.data === 'string' && typeof b.source.media_type === 'string') out.push({ data: b.source.data, type: b.source.media_type })
49 else if (typeof b.data === 'string' && typeof b.mimeType === 'string') out.push({ data: b.data, type: b.mimeType })
50 }
51 return out
52}
53
54type Block = {
55 type: string
56 id?: string
57 tool_use_id?: string
58 text?: string
59 name?: string
60 input?: unknown
61 content?: unknown
62 is_error?: boolean
63}
64
65// A message another session, a teammate or a channel delivered: its markup
66// off, its sender's name kept (`from-name`, `source`, `teammate`).
67const DELIVERED = /^\s*(?:Another Claude session sent a message:\s*)?<(cross-session-message|teammate-message|channel|agent-message)\b([^>]*)>([\s\S]*?)(?:<\/\1>\s*)?$/
68
69export function delivered(text: string): { from: string; text: string } | null {
70 const m = DELIVERED.exec(text)
71 if (!m) return null
72 const attr = (name: string) => new RegExp(`\\b${name}="([^"]*)"`).exec(m[2] ?? '')?.[1]
73 if (m[1] === 'agent-message') {
74 // A subagent's hand-back: long, and the session's own work, not a prompt anyone typed.
75 const body = (m[3] ?? '').replace(/^\s*\[Subagent hand-back\][^\n]*\n?/, '').trim()
76 return { from: 'Subagent', text: body.length > 600 ? `${body.slice(0, 600)}…` : body }
77 }
78 return { from: attr('from-name') ?? attr('source') ?? attr('teammate') ?? attr('from') ?? 'another session', text: (m[3] ?? '').trim() }
79}
80
81const INPUT_MAX = 16_000 // chars of JSON a tool row carries to replay
82
83// User-side text the engine records around a command or injects as context
84// (a skill's instructions, a compacted conversation's summary, a background
85// task's notice): none of it is something a person typed.
86const ENGINE_TEXT =
87 /^\s*(<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|system-reminder|bash-input|bash-stdout|bash-stderr|task-notification|user-prompt-submit-hook)\b|Base directory for this skill:|This session is being continued from a previous conversation|\[SYSTEM NOTIFICATION|Tool loaded\.\s*$|\[Request interrupted by user)/
88
89// Context a host app or the engine puts into a user message; never shown to others.
90const SYSTEM_BLOCKS = /<(system-reminder|task-notification)>[\s\S]*?<\/\1>/g
91
92const RESULT_LINES = 40
93const RESULT_CHARS = 4000
94const DETAIL_LINES = 12
95
96function blocksOf(content: unknown): Block[] {
97 if (typeof content === 'string') return [{ type: 'text', text: content }]
98 return Array.isArray(content) ? (content as Block[]) : []
99}
100
101function textOf(content: unknown): string {
102 return blocksOf(content)
103 .filter(b => b.type === 'text' && typeof b.text === 'string')
104 .map(b => b.text)
105 .join('\n')
106}
107
108function clip(text: string, lines: number, chars: number): string {
109 const all = text.replace(/\s+$/, '').split('\n')
110 let out = all.slice(0, lines).join('\n')
111 if (out.length > chars) out = `${out.slice(0, chars)}…`
112 const more = all.length - lines
113 return more > 0 ? `${out}\n… +${more} lines` : out
114}
115
116function relative(path: unknown, cwd: string): string {
117 if (typeof path !== 'string') return ''
118 return cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
119}
120
121const str = (v: unknown) => (typeof v === 'string' ? v : '')
122
123export function summarizeTool(name: string, input: unknown, cwd: string): { text: string; detail?: string } {
124 const i = (input ?? {}) as Record<string, unknown>
125 switch (name) {
126 case 'Bash':
127 return { text: (str(i.command).split('\n')[0] ?? '').slice(0, 200) }
128 case 'Read':
129 return { text: relative(i.file_path, cwd) }
130 case 'Write':
131 return {
132 text: relative(i.file_path, cwd),
133 detail: clip(str(i.content), 6, 600),
134 }
135 case 'Edit': {
136 const minus = str(i.old_string).split('\n').map(l => `- ${l}`)
137 const plus = str(i.new_string).split('\n').map(l => `+ ${l}`)
138 return { text: relative(i.file_path, cwd), detail: clip([...minus, ...plus].join('\n'), DETAIL_LINES, 1200) }
139 }
140 case 'NotebookEdit':
141 return { text: relative(i.notebook_path, cwd) }
142 case 'Grep':
143 return { text: `${str(i.pattern)}${i.path ? ` in ${relative(i.path, cwd)}` : ''}` }
144 case 'Glob':
145 return { text: str(i.pattern) }
146 case 'WebFetch':
147 return { text: str(i.url) }
148 case 'WebSearch':
149 return { text: str(i.query) }
150 case 'Agent':
151 case 'Task':
152 return { text: str(i.description) }
153 case 'TodoWrite':
154 return { text: `${Array.isArray(i.todos) ? i.todos.length : 0} todos` }
155 default: {
156 const json = JSON.stringify(input ?? {})
157 return { text: json.length > 120 ? `${json.slice(0, 120)}…` : json }
158 }
159 }
160}
161
162/**
163 * The rows one transcript message makes. `who` names the person behind a
164 * user-typed row; `null` means the row was not typed by a person (skip it).
165 */
166export function rowsFromMessage(
167 message: { role?: string; type?: string; isMeta?: boolean; content: unknown },
168 who: string | null,
169 cwd: string,
170): Row[] {
171 if (message.isMeta) return []
172 const role = message.role ?? message.type
173 const rows: Row[] = []
174 for (const block of blocksOf(message.content)) {
175 if (role === 'user') {
176 if (block.type === 'text' && typeof block.text === 'string') {
177 const text = block.text.replace(SYSTEM_BLOCKS, '').trim()
178 // A prompt another session delivered is the session's prompt too, under the sender's name.
179 const sent = delivered(text)
180 if (sent) {
181 if (sent.text) rows.push({ kind: 'user', who: sent.from, text: sent.text })
182 continue
183 }
184 if (who === null || ENGINE_TEXT.test(text) || !text) continue
185 rows.push({ kind: 'user', who, text })
186 } else if (block.type === 'tool_result') {
187 // A file read's lines come numbered (" 12\tcode"); the numbers are noise
188 // here, and so is what the engine adds for the model (reminders).
189 const text = textOf(block.content).replace(SYSTEM_BLOCKS, '').replace(/^ *\d+\t/gm, '').trim()
190 const media = imagesOf(block.content)
191 rows.push({
192 kind: 'result',
193 text: text ? clip(text, RESULT_LINES, RESULT_CHARS) : media.length ? '' : '(no output)',
194 isError: block.is_error === true || undefined,
195 id: block.tool_use_id,
196 ...(media.length ? { media } : {}),
197 })
198 }
199 } else if (role === 'assistant') {
200 if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
201 rows.push({ kind: 'assistant', text: block.text.trim() })
202 } else if (block.type === 'tool_use' && block.name) {
203 const { text, detail } = summarizeTool(block.name, block.input, cwd)
204 const input = block.input && typeof block.input === 'object' && JSON.stringify(block.input).length <= INPUT_MAX ? (block.input as Record<string, unknown>) : undefined
205 rows.push({ kind: 'tool', tool: block.name, text, detail, id: block.id, input })
206 }
207 }
208 }
209 return rows
210}
211
212/** Splits a guest prompt the host submitted as `Name: text`. */
213export function splitSpeaker(text: string): { who: string; text: string } | null {
214 const match = /^([^\n:]{1,40}): ([\s\S]*)$/.exec(text)
215 return match?.[1] && match[2] !== undefined ? { who: match[1], text: match[2] } : null
216}
217
218/** The rows as markdown: what a guest's local transcript keeps as text. */
219export function rowsToMarkdown(rows: readonly Row[]): string {
220 const parts: string[] = []
221 for (const row of rows) {
222 switch (row.kind) {
223 case 'user':
224 parts.push(`**${row.who ?? 'Someone'}:** ${row.text}`)
225 break
226 case 'assistant':
227 parts.push(row.text)
228 break
229 case 'tool': {
230 const head = `${toolGlyph(row.tool ?? '')} **${row.tool}**${row.text ? ` \`${row.text.replaceAll('`', "'")}\`` : ''}`
231 const diff = row.detail && (row.tool === 'Edit' || row.tool === 'Write')
232 parts.push(diff ? `${head}\n\n\`\`\`${row.tool === 'Edit' ? 'diff' : ''}\n${row.detail}\n\`\`\`` : head)
233 break
234 }
235 case 'result':
236 parts.push(` ⎿ ${row.isError ? 'Error: ' : ''}${row.text.replace(/^ *\d+\t/gm, '').split('\n')[0]}`)
237 break
238 }
239 }
240 return parts.join('\n\n')
241}
242hooks/server.ts 9 lines1// Where Share creates rooms, when neither the plugin's `server` option nor the
2// SHARED_SESSION_SERVER environment variable says: the public server this
3// project runs (README, "Privacy and the public server"), so sharing works
4// right after install. A team that runs its own sets the option, or bakes it
5// in with `node scripts/set-server.mjs <url>` in its own fork.
6//
7// Joining never reads this: a share link carries its own server.
8export const SERVER_URL = 'https://claude-share.proud-limit-da0a.workers.dev'
9hooks/paths.ts 22 lines1// Paths a guest's tool call touches, checked against the host's project.
2
3// A guest's read runs without asking only inside this project: a share link
4// can be forwarded, and ~/.ssh is one Read away. Paths are the ones the read
5// tools take; anything relative climbing out, or absolute elsewhere, asks.
6export function readsInside(tool: string, input: Record<string, unknown>, cwd: string): boolean {
7 const paths = [input.file_path, input.notebook_path, input.path, tool === 'Glob' ? input.pattern : undefined].filter(
8 (p): p is string => typeof p === 'string' && p.length > 0,
9 )
10 const root = cwd.replace(/\/+$/, '')
11 return paths.every(p => {
12 if (p.startsWith('~')) return false
13 const parts: string[] = []
14 for (const part of (p.startsWith('/') ? p : `${root}/${p}`).split('/')) {
15 if (part === '..') parts.pop()
16 else if (part && part !== '.') parts.push(part)
17 }
18 const full = `/${parts.join('/')}`
19 return full === root || full.startsWith(`${root}/`)
20 })
21}
22hooks/brand.ts 4 lines1// Paradigm's Clover (paradigm-study-web/public/clover/clover.webp): the cat
2// that perches on the landing page's window, here on the Room banner's.
3export const CLOVER_WEBP = 'data:image/webp;base64,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'
4types/index.d.ts 173 lines1export type ShareMode = 'idle' | 'host' | 'guest'
2
3export type ShareRoom = {
4 /** The room server's origin, from the share link or the plugin's default. */
5 server: string
6 id: string
7 url: string
8 title: string
9 /** The host's display name. */
10 host: string
11 /** This seat's bearer token (the host's or a guest's). */
12 token: string
13 /** This seat's id in the room: `host`, or the guest seat the server gave. */
14 seat: string
15 /** Last event seq this session has seen. */
16 seq: number
17 /** When this session started sharing or joined, ms since the epoch. */
18 since: number
19 /** The team it's shared with: listed for its people, whose access setting says who joins. */
20 team?: { id: string; name: string } | null
21}
22
23export type SharePerson = { id: string; name: string; role: 'host' | 'guest'; online: boolean; /** Their plugin's version, as the room heard it. */ version?: string }
24
25export type ShareWorking = {
26 turnId: string
27 /** Who the running turn is for. */
28 by: string
29 /** True when a guest's prompt started it (its tools need the host's approval). */
30 byGuest: boolean
31 startedAt: number
32 /** The tail of the text Claude is streaming, for guests. */
33 tail: string
34 /** A tool call waiting for the host's approval, as guests see it. */
35 waitingFor: string | null
36}
37
38/** One line of the Room panel's timeline. */
39export type ShareActivity = {
40 ts: number
41 who: string
42 kind: 'join' | 'leave' | 'prompt' | 'allowed' | 'denied' | 'stop' | 'policy' | 'shown'
43 text?: string
44}
45
46/** The room's side channel: people talk, Claude never reads it. */
47export type ShareChat = { ts: number; who: string; seat: string; text: string }
48
49/** The host's say over what guests may do. */
50export type SharePolicy = {
51 /** `everyone`: guests' prompts run; `watch`: guests follow along and chat. */
52 prompts: 'everyone' | 'watch'
53 /** Which of a guest's tool calls ask the host first. */
54 approvals: 'edits' | 'all' | 'none'
55 /** Whether what the host's Claude shows (files, widgets, pages, previews) reaches guests. */
56 files?: 'on' | 'off'
57}
58
59/** A file as the room keeps it: its bytes are at files/<id>. */
60export type ShareFileMeta = { id: string; name: string; type: string; size: number; /** Where a page's file sits beside it (`img/01.jpg`). */ path?: string }
61
62/**
63 * Something the host's Claude showed: a file in the side panel or the Files
64 * pane, a widget, a page, or a preview of the host's localhost.
65 */
66export type ShareShown = {
67 key: string
68 kind: 'send' | 'widget' | 'file' | 'page' | 'preview' | 'link'
69 name: string
70 ts: number
71 files?: ShareFileMeta[]
72 pid?: string
73 port?: number
74 /** A preview's page: where its links land. */
75 path?: string
76 url?: string
77 /** A preview the host has stopped. */
78 closed?: boolean
79}
80
81/** The person signed in to the share server (GitHub or Google). */
82export type ShareAccount = { id: string; name: string; login?: string; email?: string; provider: string }
83
84/** A team, as one of its people sees it (domains and orgs: owners only). */
85export type ShareTeam = {
86 id: string
87 name: string
88 role: 'owner' | 'member'
89 /** `members`: only people in the team join its sessions; `link`: anyone with a link. */
90 access: 'members' | 'link'
91 domains?: string[]
92 githubOrgs?: string[]
93 invite: string
94}
95
96/** A session shared with a team, as the team's list shows it. */
97export type ShareTeamSession = { id: string; url: string; title: string; host: string; hostAccount?: string; hostOnline: boolean; people: string[]; createdAt: number }
98
99/** The Team panel: who's signed in, their teams, and what's shared with the one shown. */
100export type ShareTeams = {
101 /** The share server these are on. */
102 server: string
103 account: ShareAccount | null
104 teams: ShareTeam[]
105 /** The team the panel shows, and Share goes to unless `byLink`. */
106 current: string | null
107 /** Share goes by link alone, not to the team. */
108 byLink: boolean
109 sessions: ShareTeamSession[]
110 members: { id: string; name: string; login?: string; role: 'owner' | 'member' }[]
111 /** Who the server signs people in with. */
112 providers: { id: string; label: string }[]
113 /** A sign-in waiting in the browser. */
114 signingIn: { provider: string; url: string; until: number } | null
115 /** An invite link to take once signed in. */
116 invite: { server: string; code: string } | null
117 /** When the list was last asked for, ms since the epoch (0: never). */
118 loaded: number
119 error: string | null
120 /** The panel's new-team field is open. */
121 creating?: boolean
122}
123
124declare module 'claude-code' {
125 interface PluginState {
126 'shared-session': {
127 mode: ShareMode
128 room: ShareRoom | null
129 people: SharePerson[]
130 working: ShareWorking | null
131 activity: ShareActivity[]
132 chat: ShareChat[]
133 /** Chat lines that arrived while the Room panel was closed. */
134 unread: number
135 policy: SharePolicy
136 /** Guests whose tool calls the host allowed for the rest of the session. */
137 trusted: string[]
138 /** Host: who each tool call of a guest's turn was for, by tool_use_id. */
139 owners: Record<string, string>
140 /** What the host's Claude showed, newest last (the Room panel's Files). */
141 shown: ShareShown[]
142 /** Guest: open what the host's Claude shows without asking (default), or ask each time. */
143 autoOpen: boolean
144 /** Host: the previews this session shares, port → preview id. */
145 previews: Record<string, string>
146 /** Claude Desktop's sidebar row as it was before sharing marked it, to put back. */
147 sidebar: { title: string; pinned: boolean; id?: string } | null
148 /** Share was pressed in a session with history: the row asks whether to include it. */
149 asking: { prompts: number } | null
150 /** Whether this session hears the room: `live`, or `reconnecting` after failed tries. */
151 connection: 'live' | 'reconnecting'
152 /** Guest, in a terminal: replayed reads, searches and commands folded into count lines, or each drawn. */
153 toolRows: 'grouped' | 'each'
154 /** Guest: per replayed call (by tool_use_id), its run's tools on the first call, `hidden` on the rest. */
155 replayRun: StateFamily<{ tools?: string[]; hidden?: true } | null>
156 /** Host: a teammate's tool call waiting for an answer in the row above the prompt (the question dialog didn't show). */
157 approving: { who: string; what: string; always: string } | null
158 /** A button pressed once that ends something for everyone, waiting for its second press. */
159 confirming: 'stop' | null
160 /** A newer plugin than this one, as the share server last said. */
161 newer: string | null
162 /** Whether Claude Code updates this plugin by itself (the marketplace's `autoUpdate`); null when unknown. */
163 updates: boolean | null
164 /** Updates in place: this session runs the copy that updates itself (`running`), or new sessions will (`next`). */
165 liveUpdates: 'running' | 'next' | 'pinned' | null
166 /** Pages the host's Claude published, by artifact id: what goes to the room again when one is opened again. */
167 pages: Record<string, Record<string, unknown>>
168 /** The Team panel's sign-in, teams and team sessions. */
169 teams: ShareTeams
170 }
171 }
172}
173