SLOPSHOPPER

Shared Sessions

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…

newpanebandspinnerrowsguard
v0.11.2MITupdated 2026-10-07Paradigm-Study/claude-share/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shared-session
│ ┃ Team ✕ › fix the failing auth test and add an audit log call │ ┃ Share with your team │ ┃ ⏺ Read(src/auth.ts) │ ┃ Sign in, and what you share goes to your ⎿ Read 6 lines │ ┃ team. Their live sessions show here, a press ⏺ Update(src/auth.ts) │ ┃ from joining. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ This share server has no sign-in, so teams ⎿ 3 pass, 1 fail │ ┃ aren't available on it. Sharing by link │ ┃ still works. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /share-session │ ⎿ shared-session: Couldn't share: HTTP 0 │ │ Team Share ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Team Share
Pane · Team
Share with your team Sign in, and what you share goes to your team. Their live sessions show here, a press from joining. This share server has no sign-in, so teams aren't available on it. Sharing by link still works.
Pane · shared-room
This session isn't shared Share it, and teammates join from their own Claude Code with a link. They see the conversation live and can prompt Claude here. [ Share this session ]
README

Shared Sessions for Claude Code

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.

  • One machine does the work. The session that shared is the host. Every turn and every tool runs there, in its folder, under its login.
  • Everyone else is a guest. A guest's prompt goes to the host and runs in turn. Every host turn plays out in each guest's own transcript as a prompt and a streamed reply. A guest's own model is never called.
  • The host stays in charge. When a guest's prompt makes Claude write a file, run a command or reach the network, the host is asked first, whatever the host's permission mode. The question shows the exact command or diff. The host can also make the session watch-only.
  • It lives in Claude's own UI. In Claude Desktop and the terminal:
  • a teammate's prompts carry their dot avatar and name;
  • the footer and working line say who's here and whose turn it is;
  • a Room panel has people, activity, host controls and a side chat Claude never reads;
  • in Desktop, the session list marks and pins shared sessions.
  • What Claude shows, everyone sees. When the host's Claude opens a file in the side panel or the Files pane, draws a widget, or opens a page or a local dev server in the browser pane, each guest's own Claude Code opens the same thing. A dev server it only gives the address of (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.
  • Teams. Sign in with GitHub or Google, make a team, and what you share goes to it: everyone in the team sees your live sessions in their Team panel and joins with one press (Join all for every one). Teammates come in by invite link, by their email's domain or by their GitHub organization; each team says whether its sessions are for its people only or for anyone with the link.

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.

Install

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 }
}

Use it

  • Join: open a share link. Its page opens Claude Desktop with the link in a new session's prompt box, so you press Enter. Pasting a link into any Claude Code session works too. Joining needs only the plugin: no server, account or sign-in (except for a session shared with a team that keeps its sessions to its people).
  • Team: press Team above the prompt, or type /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: press Share above the prompt, or type /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: press Room above the prompt, or type /room, for:
  • who's here and who Claude is working for;
  • the side chat, what was shown, and the activity;
  • settings: the host's rules for everyone, and each guest's own.
  • Show: what the host's Claude shows reaches everyone by itself. The host can also type /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.
  • In a terminal: the same, by keyboard. Type /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: press Leave, or Stop sharing twice (it ends the room for everyone), or type /stop-sharing. Esc in a guest stops the shared turn.
  • Restarting is fine. If the host quits Claude Code (to update, say), the room stays open: guests see the host away, and what they type meanwhile is answered there, not run. Reopening the same session (Claude Desktop does it after a restart, and also when you go on from an earlier message; 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.
  • Connection: if the room can't be reached, the row above the prompt says it is reconnecting; anything sent meanwhile goes out once it's back.

Privacy and the public server

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.

  • What it sees: while a session is shared, its prompts and the files people attach to them, Claude's replies, the tools it runs with the first lines of their results and the pictures in them (screenshots, images Claude opens), the side chat, and files or local previews the host's Claude shows. What Claude shows → Stays with me keeps the pictures and files on the host's machine. Sharing a session that already has history asks first whether to include it; everything means everything the session stored, from before any compaction too (its newest 4,000 rows).
  • How long: a room and everything in it is deleted 24 hours after the host stops sharing or was last seen. No analytics, no request logs.
  • Accounts, only for teams: sharing and joining need none. Sign in to use teams, and the server keeps your name, username, verified email, the GitHub organizations you're in, your teams and their settings, and which open sessions are shared with each team. Sign-ins are kept as hashes, never the token. All of it stays until you delete your account (/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.
  • Who can see it: anyone with the room's link; for a session shared with a team that keeps its sessions to its people, only people in that team. It's encrypted in transit, not end to end: the server can read what passes through it.
  • Limits: per network, 6 new rooms and 30 joins a minute; per room, 20 people, 100 MB of files and 900 posts a minute. Abused rooms get ended.

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.

Host a 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:

  • GitHub: Settings → Developer settings → OAuth Apps → New OAuth App. Homepage URL: your server. Authorization callback URL: 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).
  • Google: Google Cloud console → APIs & Services → Credentials → Create credentials → OAuth client ID → Web application. Authorized redirect URI: 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:

  • run /plugin configure shared-session and enter the address;
  • set SHARED_SESSION_SERVER in the env block of ~/.claude/settings.json, or in your organization's managed settings so a whole team gets it;
  • in your own fork, bake the address in with node scripts/set-server.mjs https://your-server.

Troubleshooting

You seeWhat it means
No Share button, or Unknown command: /share-sessionThe session started before the plugin was installed. Start a new session, or quit and reopen Claude Desktop.
An old error after updating the pluginSame 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 sharingThe 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 deployOne-time Cloudflare setup: open Workers & Pages in the dashboard once, then deploy again.
TLS handshake errors right after the first deployThe new certificate takes a minute or so.
A pasted link goes to Claude as a normal promptThe plugin isn't loaded in that session (see the first row), or the link was edited.

Security model

  • The link is the key. Room ids are 128-bit random values; anyone holding a link can join while the host shares. Names are self-declared: git config user.name, else the computer's username.
  • A team can keep its sessions to its people. A session shared with a team whose setting is Only people in the team lets in only people signed in with an account in that team, checked against the team as it is at each join. Sign-in uses the provider's verified email for domains, and GitHub's own membership list for organizations. Claude Code opens the sign-in page with only a hash of a secret it keeps, and collects the sign-in with that secret, so a sign-in link seen elsewhere can't be used to take it.
  • The server relays plaintext. It sees the shared transcript: prompts, replies, tool calls and results. Run it somewhere you trust.
  • Guests can't act on the host's machine without the host. By default, only reads inside the host's project run without asking; a guest's request to read anywhere else, edit, run commands or use the web asks the host first. "Always allow" trusts one person for the session. The host can require approval for every tool, or none. The question comes up in Claude Code's own dialog; where that can't show (a settings hook that takes AskUserQuestion, say), it's asked in the row above the prompt instead, never answered for the host.
  • What Claude shows travels too, unless the host keeps it. Files Claude shows, and the pictures in its tool results (screenshots, images it opens; the newest 60 results' worth from a shared history), go through the server (10 MB each, 100 MB a room, deleted with the room). The host can keep them in Room → Host controls.
  • Previews are for the room only. Each guest opens a preview with a link that lasts ten minutes and lets in only the first browser that uses it. The host's plugin only fetches ports the host shared, and previews end with the room.
  • Previews carry WebSockets. A dev server's live reload works through a preview, and so do Next.js 16 dev pages, which don't start without their socket. The host's plugin relays each socket with a small Node script (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.
  • Only what a person types travels. Desktop's hidden context notes are stripped from prompts before they leave a machine. A link relayed by another session, a channel or a task never joins anything.

Limits

  • Guests' tool cards are replays. The host's tool calls show in a guest's transcript as tool cards (Bash, Read, Edit…) with the host's results, drawn by the plugin's own 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.
  • Not mirrored: subagents' inner steps and pasted images.
  • No "Alex is typing…". The engine doesn't see keystrokes in Claude Desktop's composer.
  • Needs 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.
  • Failed host turns: guests see a note in the reply when the host's turn errors or is refused.
  • Previews are plain HTTP. No WebSockets, so a dev server's hot reload doesn't reach guests; they reload. Request bodies are limited to 1 MB and responses to 20 MB. The host needs sh and curl (macOS or Linux).
  • What's shown opens by itself. A page or preview opens in a guest's browser pane without asking, even in auto mode; a guest can choose in the Room to be asked first, and a public link always asks. A terminal guest gets the file saved and a note instead of a viewer.
  • Session list changes are Desktop-only. The 👥 title and the pin use Claude Desktop's own sidebar tools. Elsewhere they're skipped.

Develop

PathWhat it is
plugin/The plugin: hooks/register.tsx (hooks), hooks/rows.ts (transcript rows), hooks/look.ts (drawings), tests/
server/core.mjsRoom logic and the link page, shared by both servers
server/worker.mjs, wrangler.tomlCloudflare Worker + Durable Object
server/node.mjsZero-dependency Node server
test/e2e.mjsEnd 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:

  • sharing and joining;
  • history and attribution;
  • live replies, and that guests never call a model;
  • ordering;
  • Esc from a guest;
  • that everyone hears the room over one open stream;
  • that a file the host shares lands in the guest's project, that a guest opens the host's localhost through a preview, that someone who joins later is handed it with everything before in the join reply, and that a dev server the host's Claude gives the address of opens for guests at that page;
  • that a guest's write is refused without approval;
  • that sharing ends cleanly.

License

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.

Source 7 files
hooks/register.tsx 5134 lines
1// 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 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}
178
hooks/rows.ts 242 lines
1// 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}
242
hooks/server.ts 9 lines
1// 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'
9
hooks/paths.ts 22 lines
1// 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}
22
hooks/brand.ts 4 lines
1// 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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'
4
types/index.d.ts 173 lines
1export 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