@claudecollab/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/package.json +36 -0
  4. package/packages/cli/bin/claude-share.js +1319 -0
  5. package/packages/cli/package.json +15 -0
  6. package/packages/cli/src/brain/card.js +157 -0
  7. package/packages/cli/src/brain/commands.js +122 -0
  8. package/packages/cli/src/brain/drafts.js +557 -0
  9. package/packages/cli/src/brain/gate.js +134 -0
  10. package/packages/cli/src/brain/knocks.js +27 -0
  11. package/packages/cli/src/brain/log.js +123 -0
  12. package/packages/cli/src/brain/queue.js +81 -0
  13. package/packages/cli/src/brain/state.js +245 -0
  14. package/packages/cli/src/hooks.js +243 -0
  15. package/packages/cli/src/invite.js +43 -0
  16. package/packages/cli/src/known-relays.js +66 -0
  17. package/packages/cli/src/pty.js +175 -0
  18. package/packages/cli/src/relay-client.js +271 -0
  19. package/packages/cli/src/renderer.js +230 -0
  20. package/packages/cli/src/screen-snapshot.js +139 -0
  21. package/packages/cli/src/term-chatter.js +89 -0
  22. package/packages/relay/auth.js +18 -0
  23. package/packages/relay/bin/serve.js +73 -0
  24. package/packages/relay/names.js +27 -0
  25. package/packages/relay/package.json +13 -0
  26. package/packages/relay/public/client.js +1553 -0
  27. package/packages/relay/public/index.html +110 -0
  28. package/packages/relay/public/style.css +952 -0
  29. package/packages/relay/rooms.js +149 -0
  30. package/packages/relay/server.js +652 -0
  31. package/packages/relay/web.js +334 -0
  32. package/packages/relay/wordlists.js +38 -0
  33. package/packages/shared/package.json +8 -0
  34. package/packages/shared/protocol.js +189 -0
@@ -0,0 +1,1319 @@
1
+ #!/usr/bin/env node
2
+ // claude-share — wrap Claude Code in a PTY and share the session. Post-dogfood
3
+ // verdict (design v2): the host terminal is the ENGINE ROOM — plain, native Claude,
4
+ // exactly as solo. Host stdin passes straight through to Claude; the only overlay is
5
+ // ONE status line pinned to the bottom row (room · people · claude-state · room URL).
6
+ //
7
+ // The browser is the one multiplayer surface for everyone, host included. On room
8
+ // creation the CLI prints (and copies) the room URL with a host token; the host opens
9
+ // it in a browser tab and does ALL multiplayer there — admitting knocks, co-writing
10
+ // drafts, roles, /pause, /end. Web guests join by URL with the same knock/roles model;
11
+ // the ssh guest door remains functional but uninvested.
12
+ //
13
+ // The brain still lives here (single authority): RoomState tracks
14
+ // participants/roles/mode/size; the per-role gate routes every GUEST keystroke to the
15
+ // draft composer, to Claude, or to nothing; sent drafts land in the attributed queue
16
+ // and drain one-per-idle (fail closed); /role /kick /pause /resume /recap /end are
17
+ // wired; joiners get a context card before the mirror; the shared view is clamped to
18
+ // the smallest participant (floor 80×24). Browser tabs are views + labeled input
19
+ // sources — the host tab's {t:'ui'} admit/deny answers knocks.
20
+ //
21
+ // Solo mode (`--no-relay`): the host drives Claude directly, no relay, no room URL.
22
+
23
+ import process from 'node:process';
24
+ import os from 'node:os';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { execFile } from 'node:child_process';
28
+ import { randomBytes } from 'node:crypto';
29
+ import ssh2 from 'ssh2';
30
+ import { startPty } from '../src/pty.js';
31
+ import { paint, ROLE_GLYPH } from '../src/renderer.js';
32
+ import { ScreenSnapshot } from '../src/screen-snapshot.js';
33
+ import { installHooks, listenHooks } from '../src/hooks.js';
34
+ import { connectRelay, parseRelayUrl, openingMove } from '../src/relay-client.js';
35
+ import { createPinCheck } from '../src/known-relays.js';
36
+ import { hostUrl, inviteUrl, readyToast } from '../src/invite.js';
37
+ import { createPartitioner } from '../src/term-chatter.js';
38
+ import { RoomState, HOST_ID, atLeast, FLOOR_COLS, FLOOR_ROWS } from '../src/brain/state.js';
39
+ import { Queue } from '../src/brain/queue.js';
40
+ import { Log } from '../src/brain/log.js';
41
+ import { Drafts } from '../src/brain/drafts.js';
42
+ import { dispatch, classifySend, sendAllowed } from '../src/brain/gate.js';
43
+ import { parse as parseCommand, permitted as commandPermitted, resolveMention } from '../src/brain/commands.js';
44
+ import { build as buildCard, recapCard } from '../src/brain/card.js';
45
+ import { foldKnock } from '../src/brain/knocks.js';
46
+
47
+ function parseArgs(argv) {
48
+ const opts = {
49
+ relay: true,
50
+ // Default to the community relay so a fresh `collab` needs zero setup; dev
51
+ // rigs and tests always pass --relay explicitly (ssh://127.0.0.1:2222).
52
+ relayUrl: 'ssh://claudecollab.org:2222',
53
+ webPort: 8787, // the relay's browser-door port — the CLI prints it in the room URL
54
+ cmd: 'claude',
55
+ hooks: true,
56
+ guests: 'prompter', // default role for a newly admitted guest (spec default)
57
+ // Room-creation credential — required by a relay running with ROOM_SECRET.
58
+ // Env beats a flag for real use (a flag shows in `ps`); the flag wins if both.
59
+ secret: process.env.CLAUDE_SHARE_SECRET || undefined,
60
+ fingerprint: undefined, // explicit relay key pin (SHA256:…); default is TOFU
61
+ roomPassword: undefined, // optional join password guests must present before knocking
62
+ childArgs: [],
63
+ };
64
+ const passthrough = [];
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const a = argv[i];
67
+ if (a === '--no-relay') opts.relay = false;
68
+ else if (a === '--relay') opts.relayUrl = argv[++i] ?? opts.relayUrl;
69
+ else if (a === '--no-hooks') opts.hooks = false;
70
+ else if (a === '--web-port') opts.webPort = Math.max(1, Number(argv[++i]) || opts.webPort);
71
+ else if (a === '--cmd') opts.cmd = argv[++i];
72
+ else if (a === '--guests') opts.guests = argv[++i] ?? opts.guests;
73
+ else if (a === '--secret') opts.secret = argv[++i] ?? opts.secret;
74
+ else if (a === '--fingerprint') opts.fingerprint = argv[++i] ?? opts.fingerprint;
75
+ else if (a === '--room-password') opts.roomPassword = argv[++i] ?? opts.roomPassword;
76
+ else if (a === '--') {
77
+ passthrough.push(...argv.slice(i + 1));
78
+ break;
79
+ } else passthrough.push(a); // unknown flags pass through to the child
80
+ }
81
+ opts.childArgs = passthrough;
82
+ return opts;
83
+ }
84
+
85
+ function hostName() {
86
+ try {
87
+ return os.userInfo().username || process.env.USER || 'host';
88
+ } catch {
89
+ return process.env.USER || 'host';
90
+ }
91
+ }
92
+
93
+ // The host's stable ssh identity — its fingerprint gates room reclaim on the relay
94
+ // (spec §failure-behavior), so it must survive restarts. Persisted under
95
+ // ~/.claude-share, generated on first run, readable only by the user.
96
+ function loadHostKey() {
97
+ const dir = path.join(os.homedir(), '.claude-share');
98
+ const keyPath = path.join(dir, 'host_key');
99
+ try {
100
+ return fs.readFileSync(keyPath);
101
+ } catch {
102
+ /* not created yet */
103
+ }
104
+ const priv = ssh2.utils.generateKeyPairSync('ed25519').private;
105
+ try {
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ fs.writeFileSync(keyPath, priv, { mode: 0o600 });
108
+ } catch {
109
+ /* home not writable — fall back to an in-memory key for this run */
110
+ }
111
+ return priv;
112
+ }
113
+
114
+ async function main() {
115
+ const opts = parseArgs(process.argv.slice(2));
116
+ const { stdin, stdout } = process;
117
+ // The terminal band is permanently exactly ONE status line, pinned to the bottom
118
+ // row. Claude gets every other row; the child PTY is resized to rows-1 each repaint.
119
+ const BAND_ROWS = 1;
120
+ const multiplayer = opts.relay; // the guest composer/gate + mirror are active only when sharing
121
+ // The host's browser tab authenticates with this token: it knocks with fingerprint
122
+ // `webhost:<token>`, and the brain auto-admits it as host (the one knock answered
123
+ // with no host already present). The token also goes in the room URL we print/copy.
124
+ const hostToken = randomBytes(16).toString('hex');
125
+ // The current live Claude screen, cached so a joiner sees it instantly (finding 1).
126
+ const snapshot = new ScreenSnapshot();
127
+
128
+ // ── the brain ─────────────────────────────────────────────────────────────
129
+ const state = new RoomState({
130
+ hostName: hostName(),
131
+ defaultRole: opts.guests,
132
+ hostSize: { cols: stdout.columns || 80, rows: stdout.rows || 24 },
133
+ });
134
+ const queue = new Queue();
135
+ const log = new Log();
136
+ const drafts = new Drafts();
137
+ const toasted = new Set(); // userIds already shown the viewer toast
138
+ const knockInfo = new Map(); // id -> {name, fp} captured at knock time
139
+ const pointers = new Map(); // userId -> {x, y} normalized 0..1 (browser cursors)
140
+ // The host's own browser tab(s): connection ids that authenticated with the host
141
+ // token. They are the HOST, not separate participants (finding 4) — mapped to
142
+ // HOST_ID for input attribution and never counted as a second roster entry.
143
+ const hostTabIds = new Set();
144
+ const hostTabSizes = new Map(); // host-tab connection id -> {cols, rows} capacity
145
+ // Fingerprints admitted this session. A returning fp walks back in without a
146
+ // second admit (a reload shouldn't cost a knock); kicks remove the fp AND ban
147
+ // it at the relay, so a kicked guest can never ride this back in.
148
+ const admittedFps = new Set();
149
+ const claude = { state: 'idle' };
150
+ let armed = false; // true only while a permission ask is pending (hook-armed)
151
+ let pendingKnocks = []; // FIFO of guest {id,name,fp,seen} awaiting the host tab's admit
152
+ let currentUrl = null; // the host-tab URL (WITH host token) — host's own terminal only
153
+ let inviteUrlStr = null; // the token-free invite URL — safe to show guests in the mirror
154
+ const ui = { toast: null, toastTimer: null, notice: null, noticeSeq: 0 };
155
+ // Throttle for the "no draft focused" nudge a prompter gets when their typed
156
+ // text is dropped (drafts are created explicitly — never by stray typing).
157
+ const nudgedAt = new Map();
158
+ let relay = null;
159
+ let exited = false;
160
+ // Reconnect-with-reclaim state (spec §failure-behavior: the relay holds the code
161
+ // 10 min after a host drop). On a lost connection we reconnect and RECLAIM the
162
+ // room we still hold, rather than abandoning it.
163
+ let reconnectTimer = null;
164
+ let reconnectAttempts = 0;
165
+ const MAX_RECONNECT = 8;
166
+ // A terminal relay verdict (REFUSED hello, or its ssh identity changed). Not
167
+ // transient — retrying is pointless (secret) or dangerous (impersonation), so
168
+ // this flag stops the reconnect loop for the rest of the run.
169
+ let relayVetoed = false;
170
+
171
+ // ── hook-based state detection (Task 4) ─────────────────────────────────────
172
+ const childArgs = [...opts.childArgs];
173
+ let hooks = null;
174
+ let settingsFile = null;
175
+ let socketPath = null;
176
+ if (opts.hooks && opts.cmd === 'claude') {
177
+ try {
178
+ socketPath = path.join(os.tmpdir(), `claude-share-${process.pid}.sock`);
179
+ settingsFile = installHooks(socketPath);
180
+ hooks = listenHooks(socketPath);
181
+ await hooks.ready;
182
+ childArgs.unshift('--settings', settingsFile);
183
+ } catch (err) {
184
+ process.stderr.write(`collab: hook setup failed (${err.message}); state detection off\n`);
185
+ hooks = null;
186
+ }
187
+ }
188
+
189
+ let pty;
190
+ try {
191
+ pty = await startPty({ cmd: opts.cmd, args: childArgs, bandRows: BAND_ROWS });
192
+ } catch (err) {
193
+ process.stderr.write(
194
+ `collab: could not start "${opts.cmd}": ${err.message}\n` +
195
+ 'If this is a native-module error, run `npm rebuild node-pty` where this package is installed.\n',
196
+ );
197
+ process.exit(1);
198
+ }
199
+
200
+ const CLAUDE_STATUS = { busy: '✻ brewing…', idle: '● idle', ask: '⚠ permission ask pending' };
201
+
202
+ // ── band rendering (one status line) ─────────────────────────────────────────
203
+ // The engine-room band is a single line. Draft boxes, the queue, knock prompts —
204
+ // all of that moved to the browser (state.knocks / state.drafts). Transient
205
+ // messages fold into the claude-state slot; nothing ever grows past one row.
206
+ const nameOf = (id) => state.nameOf(id) ?? knockInfo.get(id)?.name ?? id;
207
+
208
+ // The middle slot of the status line: a live toast wins (folded in temporarily),
209
+ // then pause, then Claude's hook-tracked state, then a plain live/connecting/solo.
210
+ const stateSlot = () => {
211
+ if (ui.toast) return ui.toast;
212
+ if (state.paused) return '⏸ paused';
213
+ if (hooks) return CLAUDE_STATUS[claude.state] ?? claude.state;
214
+ if (state.room) return 'live';
215
+ return relay ? 'connecting…' : 'solo';
216
+ };
217
+
218
+ // The status line's fields. The host's OWN terminal shows the host-tab URL (carries
219
+ // the token); the MIRRORED variant sent to guests shows only the token-free invite
220
+ // URL — the host token must NEVER leave the host's own stdout (finding 1).
221
+ const statusFields = (mirror = false) => ({
222
+ room: state.room,
223
+ people: state.list().length,
224
+ claudeState: stateSlot(),
225
+ url: mirror ? inviteUrlStr : currentUrl,
226
+ });
227
+
228
+ // The host's own browser tab is the host, not a separate participant (finding 4):
229
+ // map its connection id to HOST_ID so its keystrokes/pointer/ui are attributed to
230
+ // the host and it never shows up as a second roster entry (or inflates the count).
231
+ const actorOf = (id) => (hostTabIds.has(id) ? HOST_ID : id);
232
+
233
+ // The coordinate space the band paints into. In a shared session it is the CLAMPED
234
+ // shared size (min of participants, floor 80×24), so every participant — the host
235
+ // included — sees the SAME layout anchored at the same rows (finding 3), and the
236
+ // one painted band can be written to the host and mirrored to guests unchanged.
237
+ // The host TAB is not a roster participant (finding 4) but it IS a size
238
+ // participant: folding its capacity in makes the shared view browser-shaped from
239
+ // the start — solo, the clamp used to carry only the host TERMINAL's aspect, so
240
+ // the mirror letterboxed until the first guest join reshaped it.
241
+ // Solo with no tab there is no shared view, so we use the host's own terminal.
242
+ const viewSize = () => {
243
+ if (!multiplayer) return { cols: stdout.columns || 80, rows: stdout.rows || 24 };
244
+ let { cols, rows } = state.clamp();
245
+ for (const s of hostTabSizes.values()) {
246
+ cols = Math.min(cols, s.cols);
247
+ rows = Math.min(rows, s.rows);
248
+ }
249
+ return { cols: Math.max(FLOOR_COLS, cols), rows: Math.max(FLOOR_ROWS, rows) };
250
+ };
251
+
252
+ // Send a shared-view frame to the guests who should see it. When everyone is at or
253
+ // above the 80×24 floor this is a single broadcast; when at least one guest is below
254
+ // the floor we deliver per-guest to the eligible ones only, so a below-floor guest
255
+ // stays parked on the spectate hint instead of receiving a mirror sized for bigger
256
+ // terminals (spec §renderer clamp). Suppressed while paused.
257
+ const mirror = (data) => {
258
+ if (!relay || !multiplayer || state.paused) return;
259
+ if (state.spectators().length === 0) {
260
+ relay.sendScreen(data); // broadcast reaches every live guest AND the host tab
261
+ } else {
262
+ for (const id of state.mirrorTargets()) relay.sendTo(id, data);
263
+ // The host's own tab is not a roster participant (finding 4), so it isn't in
264
+ // mirrorTargets — deliver to it explicitly so its mirror never freezes when a
265
+ // below-floor spectator forces the per-guest path.
266
+ for (const id of hostTabIds) relay.sendTo(id, data);
267
+ }
268
+ };
269
+
270
+ // ── overlay state (the browser multiplayer surface) ──────────────────────────
271
+ // The brain assembles a full snapshot the relay fans out to browser views: who's
272
+ // here (with stable per-fingerprint colors), the shared drafts (cursors+authors),
273
+ // the attributed queue, Claude's state, pause, live pointers, and the pending
274
+ // knocks (the host admits from the browser). Everything is in userId-space —
275
+ // `participants` is the id → {name, role, color} lookup the view joins against.
276
+ const buildState = () => ({
277
+ room: state.room,
278
+ participants: state.list().map((p) => ({ id: p.id, name: p.name, role: p.role, color: p.color })),
279
+ drafts: drafts.snapshot(),
280
+ queue: queue.items.map((it, i) => ({ n: i + 1, author: it.author, text: it.text })),
281
+ claudeState: claude.state,
282
+ paused: state.paused,
283
+ pointers: Object.fromEntries(
284
+ [...pointers]
285
+ .filter(([id]) => state.get(id)) // drop a pointer whose owner has left
286
+ .map(([id, p]) => [id, { x: p.x, y: p.y, name: nameOf(id), color: state.colorOf(id) }]),
287
+ ),
288
+ knocks: pendingKnocks.map((k) => ({ id: k.id, name: k.name, fp: k.fp, seen: k.seen })),
289
+ // The latest per-user notice, addressed by id. Browser tabs show it as a toast
290
+ // when it's theirs; injecting bytes into the mirror (the ssh-guest channel) is
291
+ // invisible there because Claude's TUI repaints right over them.
292
+ notice: ui.notice,
293
+ // The shared clamp — the ONE size every mirrored frame is painted at. Browser
294
+ // tabs must render their xterm at exactly this size (scaling visually to fit)
295
+ // or wide frames wrap into garbage; a tab's own container size is only its
296
+ // CAPACITY, reported via resize and folded into this clamp.
297
+ view: viewSize(),
298
+ });
299
+
300
+ // Emit at most one state frame per 50ms: fire immediately when outside the window,
301
+ // otherwise coalesce a burst into a single trailing emit at the window's end.
302
+ const STATE_THROTTLE_MS = 50;
303
+ let stateTimer = null;
304
+ let lastStateAt = 0;
305
+ const emitState = () => {
306
+ if (!relay || !multiplayer) return;
307
+ lastStateAt = Date.now();
308
+ relay.sendState(buildState());
309
+ };
310
+ const scheduleState = () => {
311
+ if (!relay || !multiplayer || stateTimer) return;
312
+ const wait = STATE_THROTTLE_MS - (Date.now() - lastStateAt);
313
+ if (wait <= 0) return emitState();
314
+ stateTimer = setTimeout(() => {
315
+ stateTimer = null;
316
+ emitState();
317
+ }, wait);
318
+ stateTimer.unref?.();
319
+ };
320
+
321
+ // The child PTY size we last applied. The band height is dynamic, so we resize
322
+ // Claude's region whenever the band or the shared width changes (each resize costs
323
+ // Claude a repaint, so only on an actual change).
324
+ let appliedCols = null;
325
+ let appliedChildRows = null;
326
+ // Ghost-band cleanup state (see repaintBand): the shared rows we last painted at,
327
+ // and whether a clamp shrink still needs its below-region erase delivered.
328
+ let lastPaintRows = null;
329
+ let pendingClearBelow = false;
330
+
331
+ // The band is one status line, pinned to the bottom row of the (clamped) shared
332
+ // view. The same painted line goes to the host's stdout and, mirrored, to every
333
+ // eligible ssh guest; browser tabs render their own overlay from state instead.
334
+ // Claude's region is resized to rows-1 so the line can never overlap its output.
335
+ const repaintBand = () => {
336
+ const { cols, rows } = viewSize();
337
+ const band = BAND_ROWS;
338
+
339
+ // Keep Claude's region to exactly the rows the band does not occupy, at the
340
+ // shared width — resize the child only when that actually changes.
341
+ const childRows = Math.max(1, rows - band);
342
+ if (cols !== appliedCols || childRows !== appliedChildRows) {
343
+ const reclamp = appliedCols !== null;
344
+ appliedCols = cols;
345
+ appliedChildRows = childRows;
346
+ try {
347
+ pty.resizeChild(cols, childRows);
348
+ } catch {}
349
+ // A shared-size change reflows the mirrors' old-size content into ghost text
350
+ // (Claude repaints its region on SIGWINCH, but never the rows above it).
351
+ // Erase screen+scrollback IN the byte stream so it is ordered BEFORE the
352
+ // repaint frames — a client-side clear can't be ordered against frames.
353
+ if (reclamp) mirror('\x1b[2J\x1b[3J\x1b[H');
354
+ }
355
+
356
+ // Paint TWO variants of the one status line from the SAME layout: the host's own
357
+ // terminal gets the host-tab URL (with its token); guests get a token-free variant
358
+ // (the invite URL) so the host token can never leak through the mirror (finding 1).
359
+ let hostPaint = paint({ cols, rows, bandRows: band, ...statusFields(false) });
360
+ let mirrorPaint = paint({ cols, rows, bandRows: band, ...statusFields(true) });
361
+ // Ghost-band cleanup: when the shared clamp SHRINKS, rows below the new region
362
+ // keep stale band paint on any terminal taller than the new clamp. Erase below
363
+ // the region BEFORE repainting (order matters: on a terminal exactly `rows`
364
+ // tall the erase clips to the band's last row, and the repaint restores it).
365
+ // The flag persists until a repaint actually reaches guests (pause suppresses
366
+ // the mirror, and a ghost must not outlive the pause).
367
+ if (lastPaintRows !== null && rows < lastPaintRows) pendingClearBelow = true;
368
+ lastPaintRows = rows;
369
+ if (pendingClearBelow) {
370
+ const clear = `\x1b7\x1b[${rows + 1};1H\x1b[0J\x1b8`;
371
+ hostPaint = clear + hostPaint;
372
+ mirrorPaint = clear + mirrorPaint;
373
+ if (!state.paused) pendingClearBelow = false;
374
+ }
375
+ if (stdout.isTTY) stdout.write(hostPaint);
376
+ mirror(mirrorPaint); // mirror() owns the relay/multiplayer/paused checks
377
+ scheduleState(); // repaintBand is the brain's "something changed" chokepoint
378
+ };
379
+
380
+ const showToast = (msg, ms = 4000) => {
381
+ ui.toast = msg;
382
+ clearTimeout(ui.toastTimer);
383
+ ui.toastTimer = setTimeout(() => {
384
+ ui.toast = null;
385
+ repaintBand();
386
+ }, ms);
387
+ ui.toastTimer.unref?.();
388
+ repaintBand();
389
+ };
390
+
391
+ // The throttled "your typing went nowhere" nudge (default: explicit-draft copy).
392
+ const nudge = (userId, msg = 'no draft focused — hit + draft (or double-click the terminal) to compose') => {
393
+ const now = Date.now();
394
+ if (now - (nudgedAt.get(userId) ?? 0) < 8000) return;
395
+ nudgedAt.set(userId, now);
396
+ notify(userId, msg);
397
+ };
398
+
399
+ // A message meant for one participant: an addressed state.notice (browser tabs
400
+ // render it as a header chip), surfaced in the host's band too so moderation
401
+ // stays visible. Never injected as raw bytes — Claude's TUI repaints over them
402
+ // on an ssh terminal and they smear ABOVE the band in a web mirror.
403
+ const notify = (userId, msg) => {
404
+ ui.notice = { id: userId, msg, seq: ++ui.noticeSeq };
405
+ showToast(msg); // repaints the band, which also schedules a state emit
406
+ };
407
+
408
+ // Post prose to the SHARED screen — the host's stdout and every live guest's
409
+ // terminal (spec: /recap posts its summary to the shared screen, for all to read,
410
+ // not a host-only toast). Claude repaints its region on the next frame; the band
411
+ // repaints after. Not while paused.
412
+ const broadcast = (text) => {
413
+ const framed = '\r\n' + String(text).replace(/\r?\n/g, '\r\n') + '\r\n';
414
+ if (stdout.isTTY) stdout.write(framed);
415
+ mirror(framed);
416
+ repaintBand();
417
+ };
418
+
419
+ // ── size clamp (spec §renderer) ────────────────────────────────────────────
420
+ // The shared size changed (a join/leave/kick/resize): repaint, which recomputes
421
+ // the clamp, the band height, and Claude's region size in one place.
422
+ const recomputeClamp = () => repaintBand();
423
+
424
+ // ── queue drain (fail closed, one per idle) ─────────────────────────────────
425
+ // A split write (text, then \r 120ms later) briefly owns the pty; pumping a
426
+ // prompt into that gap would interleave bytes into the half-typed line. The
427
+ // lock defers the pump past the gap and retries.
428
+ let ptyLockUntil = 0;
429
+
430
+ // pump()'s busy is OPTIMISTIC — only real once the UserPromptSubmit hook
431
+ // confirms the prompt actually submitted. Claude's paste heuristic sometimes
432
+ // swallows a trailing Enter (timing-dependent), leaving the text unsent in the
433
+ // input while the room shows "brewing" forever. Watchdog: re-press Enter once
434
+ // at 2s; if still unconfirmed at 8s, fall back to idle so the room can't wedge.
435
+ let submitTimers = [];
436
+ const clearSubmitWatch = () => {
437
+ for (const t of submitTimers) clearTimeout(t);
438
+ submitTimers = [];
439
+ };
440
+ const armSubmitWatch = () => {
441
+ clearSubmitWatch();
442
+ const retry = setTimeout(() => {
443
+ try {
444
+ pty.write('\r');
445
+ } catch {}
446
+ }, 2000);
447
+ const bail = setTimeout(() => {
448
+ if (claude.state === 'busy') {
449
+ claude.state = 'idle';
450
+ showToast('a prompt may not have submitted — check the input line', 8000);
451
+ repaintBand();
452
+ }
453
+ }, 8000);
454
+ retry.unref?.();
455
+ bail.unref?.();
456
+ submitTimers = [retry, bail];
457
+ };
458
+
459
+ // An interrupt — a lone Esc reaching the pty while a turn runs — aborts the
460
+ // turn, but Claude fires NO Stop hook for it (verified live), so the room would
461
+ // stick "brewing" until a manual resync. If no hook of any kind lands shortly
462
+ // after the Esc, trust the interrupt and return to idle. Esc during an 'ask'
463
+ // dismisses the ask the same hookless way, so any non-idle state qualifies.
464
+ let lastHookAt = 0;
465
+ const sniffInterrupt = (bytes) => {
466
+ if (bytes !== '\x1b' || !hooks || claude.state === 'idle') return;
467
+ const sentAt = Date.now();
468
+ const t = setTimeout(() => {
469
+ if (claude.state !== 'idle' && lastHookAt < sentAt) {
470
+ claude.state = 'idle';
471
+ armed = false;
472
+ pump();
473
+ repaintBand();
474
+ }
475
+ }, 1500);
476
+ t.unref?.();
477
+ };
478
+
479
+ const pump = () => {
480
+ if (Date.now() < ptyLockUntil) {
481
+ const retry = setTimeout(pump, 160);
482
+ retry.unref?.();
483
+ return;
484
+ }
485
+ if (!hooks || claude.state !== 'idle') return; // no drain unless known-idle
486
+ const item = queue.drain('idle');
487
+ if (!item) return;
488
+ // Split write, same as slash sends: written as one atomic chunk, Claude's
489
+ // paste heuristic can treat the trailing \r as a pasted newline and the
490
+ // prompt sits unsent (dogfood: intermittent stuck-in-queue wedge).
491
+ ptyLockUntil = Date.now() + 150;
492
+ pty.write(item.text);
493
+ const enter = setTimeout(() => {
494
+ try {
495
+ pty.write('\r');
496
+ } catch {}
497
+ }, 120);
498
+ enter.unref?.();
499
+ claude.state = 'busy'; // optimistic; the UserPromptSubmit hook confirms
500
+ armSubmitWatch();
501
+ repaintBand();
502
+ };
503
+
504
+ // ── routing a sent draft ─────────────────────────────────────────────────────
505
+ const routeSend = (userId, send) => {
506
+ const role = state.roleOf(userId) ?? 'viewer';
507
+ const text = send.text;
508
+ const cls = classifySend(text);
509
+
510
+ if (cls.kind === 'command') return handleCommand(userId, text);
511
+
512
+ if (!sendAllowed(cls.kind, role)) {
513
+ const what = cls.kind === 'bash' ? 'run bash' : cls.kind === 'claude-slash' ? 'use slash commands' : 'send that';
514
+ notify(userId, `you can't ${what} — ask the host for a role that can type`);
515
+ return;
516
+ }
517
+ // Bound for Claude. Log by display name (card is display-only); attribute the
518
+ // queue by userId (permission + purge on leave).
519
+ log.prompt(nameOf(userId), text);
520
+ if (!hooks) {
521
+ pty.write(text + '\r'); // no state tracking → send immediately
522
+ return;
523
+ }
524
+ // Claude slash commands and ! bash never fire hooks, so a busy→idle round trip
525
+ // can't confirm them. Queueing one flips the room 'busy' forever and wedges the
526
+ // queue (dogfood: /model froze the room). Fire them only while Claude is
527
+ // known-idle, and leave the state alone — no hook will ever clear it.
528
+ if (cls.kind === 'claude-slash' || cls.kind === 'bash') {
529
+ if (claude.state !== 'idle') {
530
+ notify(userId, `Claude is ${claude.state} — slash and bash fire only while it's idle`);
531
+ return;
532
+ }
533
+ // Type like a human: the text, then Enter as its own keystroke a beat later.
534
+ // Written as one atomic chunk, Claude's paste heuristic can treat the trailing
535
+ // \r as a pasted newline and the command submits as a plain chat message.
536
+ // ponytail: two slash sends inside the gap could interleave; humans don't.
537
+ ptyLockUntil = Date.now() + 150; // keep pump() out of the split-write gap
538
+ pty.write(text);
539
+ const enter = setTimeout(() => {
540
+ try {
541
+ pty.write('\r');
542
+ } catch {}
543
+ }, 120);
544
+ enter.unref?.();
545
+ return;
546
+ }
547
+ queue.enqueue(text, userId);
548
+ pump();
549
+ repaintBand();
550
+ };
551
+
552
+ // ── roster mutations, targeted by participant id ──────────────────────────────
553
+ // Both the host tab's roster buttons ({t:'ui'} role/kick, carrying the id) and the
554
+ // /role /kick text commands (which resolve a @-mention to an id first) funnel here,
555
+ // so the mutation is identical and never re-parses a claimed name (finding 4). Return
556
+ // false on an unresolved/illegal target so the caller can word its own error.
557
+ function applyRole(actorId, targetId, role) {
558
+ if (!targetId || !state.setRole(targetId, role)) return false; // setRole refuses host/unknown
559
+ const msg = `${nameOf(actorId)} set ${state.nameOf(targetId)} to ${role} ${ROLE_GLYPH[role] ?? ''}`;
560
+ log.event(msg);
561
+ showToast(msg);
562
+ return true;
563
+ }
564
+ function applyKick(actorId, targetId) {
565
+ if (!targetId || targetId === HOST_ID || !state.get(targetId)) return false;
566
+ const name = state.nameOf(targetId);
567
+ const fp = state.get(targetId)?.fp;
568
+ if (fp) admittedFps.delete(fp); // no auto-readmit for the kicked
569
+ relay?.drop(targetId, true); // ban=true blocklists the fingerprint
570
+ state.removeGuest(targetId);
571
+ drafts.removeUser(targetId);
572
+ queue.removeByAuthor(targetId);
573
+ knockInfo.delete(targetId);
574
+ pointers.delete(targetId);
575
+ recomputeClamp();
576
+ const msg = `${name} was kicked`;
577
+ log.event(msg);
578
+ showToast(msg);
579
+ return true;
580
+ }
581
+
582
+ // ── claude-share commands ─────────────────────────────────────────────────────
583
+ function handleCommand(userId, text) {
584
+ const role = state.roleOf(userId) ?? 'viewer';
585
+ const parsed = parseCommand(text);
586
+ if (!parsed) return;
587
+ if (!commandPermitted(parsed.name, role)) {
588
+ notify(userId, `you can't run /${parsed.name}`);
589
+ return;
590
+ }
591
+ if (parsed.error) {
592
+ notify(userId, parsed.error);
593
+ return;
594
+ }
595
+ const by = nameOf(userId);
596
+ switch (parsed.name) {
597
+ case 'role': {
598
+ // Text-command path keeps @-mention resolution (and its name-specific error);
599
+ // the mutation itself is the shared, id-based applyRole (finding 4).
600
+ const targetId = resolveMention(state, parsed.mention);
601
+ if (!applyRole(userId, targetId, parsed.role)) {
602
+ notify(userId, `can't set @${parsed.mention} to ${parsed.role}`);
603
+ return;
604
+ }
605
+ break;
606
+ }
607
+ case 'kick': {
608
+ const targetId = resolveMention(state, parsed.mention);
609
+ if (!applyKick(userId, targetId)) {
610
+ notify(userId, `can't kick @${parsed.mention}`);
611
+ return;
612
+ }
613
+ break;
614
+ }
615
+ case 'pause': {
616
+ state.setPaused(true);
617
+ // Route the hold card through mirrorTargets() like everything else, so a
618
+ // below-floor spectator (already parked on the "make your terminal bigger"
619
+ // hint) is not clobbered with it (finding 5).
620
+ const hold = '\x1b[2J\x1b[H ⏸ sharing paused — the host will resume shortly\r\n';
621
+ for (const id of state.mirrorTargets()) relay?.sendTo(id, hold);
622
+ log.event(`${by} paused sharing`);
623
+ showToast('sharing paused — guests see a hold card');
624
+ break;
625
+ }
626
+ case 'resume':
627
+ state.setPaused(false);
628
+ log.event(`${by} resumed sharing`);
629
+ showToast('sharing resumed');
630
+ break;
631
+ case 'queue': {
632
+ // Reachable path for Queue.edit()/remove() (spec §queue): an author edits or
633
+ // deletes their own queued item; the host deletes any. Per-item rights
634
+ // are enforced by the Queue methods; the index is 1-based (the renderer's
635
+ // numbering). handleCommand only runs for prompter+, so viewers never arrive.
636
+ const item = queue.items[parsed.index - 1];
637
+ if (!item) {
638
+ notify(userId, `no queued item #${parsed.index}`);
639
+ return;
640
+ }
641
+ if (parsed.sub === 'del') {
642
+ if (!queue.remove(item.id, userId, role)) {
643
+ notify(userId, `you can't delete queued item #${parsed.index} — it isn't yours`);
644
+ return;
645
+ }
646
+ const msg = `${by} removed queued item #${parsed.index}`;
647
+ log.event(msg);
648
+ showToast(msg);
649
+ } else {
650
+ if (!queue.edit(item.id, parsed.text, userId, role)) {
651
+ notify(userId, 'you can only edit your own queued item');
652
+ return;
653
+ }
654
+ const msg = `${by} edited queued item #${parsed.index}`;
655
+ log.event(msg);
656
+ showToast(msg);
657
+ }
658
+ break;
659
+ }
660
+ case 'recap':
661
+ runRecap(by);
662
+ break;
663
+ case 'end':
664
+ endSession();
665
+ break;
666
+ }
667
+ repaintBand();
668
+ }
669
+
670
+ // /recap — one-shot headless Claude over the attributed log, posted to the room.
671
+ // Best-effort: uses the host's existing auth; failures are surfaced, never fatal.
672
+ function runRecap(by) {
673
+ showToast('recap: asking Claude…', 8000);
674
+ const prompt = `Summarize this collaborative coding session transcript in 3-4 plain sentences:\n\n${log.toText()}`;
675
+ execFile('claude', ['-p', prompt], { timeout: 30000, maxBuffer: 1 << 20 }, (err, out) => {
676
+ const summary = err ? 'recap unavailable (claude -p failed)' : String(out).trim();
677
+ log.event(`recap by ${by}: ${summary}`);
678
+ // Post the FULL prose to the shared screen so everyone — guests included, and
679
+ // the prompter who ran it — reads the whole recap, not a 60-char host toast.
680
+ broadcast(recapCard(by, summary, { cols: viewSize().cols }));
681
+ showToast('recap posted to the room', 6000);
682
+ });
683
+ }
684
+
685
+ // /end — end the room now. The two-step "end? / save?" confirmation is a browser-UI
686
+ // concern (the host tab gates it and fires a single /end); the terminal has no y/n
687
+ // path anymore. We write the attributed session.md then disconnect everyone —
688
+ // defaulting to save preserves the record, since the browser doesn't yet signal a
689
+ // no-save choice (spec §host controls: the log is kept in memory either way).
690
+ function endSession() {
691
+ try {
692
+ log.write(path.join(process.cwd(), 'session.md'));
693
+ } catch {}
694
+ relay?.end();
695
+ cleanup(0);
696
+ }
697
+
698
+ // Free a guest's seat and purge their footprint — draft boxes, queued items, any
699
+ // stale knock — then re-clamp the shared view and announce the departure. Shared
700
+ // by a relay-signaled LEFT (wifi drop / natural disconnect) and a self-detach.
701
+ const forgetGuest = (id) => {
702
+ const wasParticipant = !!state.get(id); // an admitted guest (not just a pending knock)
703
+ const name = nameOf(id);
704
+ state.removeGuest(id);
705
+ drafts.removeUser(id);
706
+ queue.removeByAuthor(id);
707
+ pendingKnocks = pendingKnocks.filter((k) => k.id !== id);
708
+ knockInfo.delete(id);
709
+ pointers.delete(id);
710
+ recomputeClamp(); // repaints + refreshes the roster/count either way
711
+ // Only announce a departure for someone who actually joined — a superseded pending
712
+ // knock (deduped reconnect / timeout) or a closing host tab never "left" (finding 3).
713
+ if (wasParticipant) {
714
+ log.event(`${name} left`);
715
+ showToast(`${name} left`);
716
+ }
717
+ };
718
+
719
+ // ── input effect application (host + guests, one table via the gate) ─────────
720
+ const applyInput = (userId, eff) => {
721
+ switch (eff.kind) {
722
+ case 'pty':
723
+ sniffInterrupt(eff.data);
724
+ pty.write(eff.data);
725
+ break;
726
+ case 'draft': {
727
+ // Drafts are created EXPLICITLY (the + draft chip / a double-click send
728
+ // Ctrl+N) — stray typing with no focused box is dropped, with a nudge when
729
+ // it looked like real text (only viewers' strays reach here now).
730
+ if (!drafts.activeBox(userId) && !String(eff.bytes).includes('\x0e')) {
731
+ if (/^[^\x00-\x1f\x7f]/.test(String(eff.bytes))) nudge(userId);
732
+ break;
733
+ }
734
+ const r = drafts.keystroke(userId, eff.bytes);
735
+ if (r.send) routeSend(userId, r.send);
736
+ repaintBand();
737
+ break;
738
+ }
739
+ case 'detach':
740
+ // Ctrl+C: the guest leaves on their own. The relay drops the connection
741
+ // WITHOUT echoing a LEFT back (it assumes host-initiated drops mean the host
742
+ // already knows) — so onLeave never fires and we must free the seat here, or
743
+ // the guest lingers as a ghost in the status line, queue, and size clamp.
744
+ if (userId !== HOST_ID) {
745
+ relay?.drop(userId, false); // close the connection, no ban
746
+ forgetGuest(userId);
747
+ }
748
+ break;
749
+ case 'toast':
750
+ notify(eff.target, eff.message);
751
+ break;
752
+ case 'drop':
753
+ default:
754
+ break;
755
+ }
756
+ };
757
+
758
+ // ── relay client ─────────────────────────────────────────────────────────────
759
+ // Admit a knock AND send its catch-up in ONE synchronous burst (finding 2): the
760
+ // join context card, then the CURRENT screen snapshot, straight after ADMIT. Because
761
+ // nothing awaits between them, no pty frame can interleave — so on the wire the order
762
+ // is ADMIT, card, snapshot, then any live frame the host mirrors afterward. The
763
+ // joiner therefore applies the snapshot BEFORE any queued live frame and sees exactly
764
+ // one clean copy of the screen (the old code sent the catch-up a round-trip later, in
765
+ // onJoin, so live frames emitted during that window slipped in first — the garble).
766
+ const admitAndCatchUp = (r, knock) => {
767
+ r.admit(knock.id);
768
+ // The host's own tab IS the host, not a room member (finding 4): no roster entry,
769
+ // no context card. It still mirrors the live screen, so it does get the snapshot.
770
+ if (knock.fp === `webhost:${hostToken}`) {
771
+ hostTabIds.add(knock.id);
772
+ knockInfo.set(knock.id, { name: knock.name, fp: knock.fp, role: 'host' });
773
+ if (!state.paused) {
774
+ try {
775
+ const snap = snapshot.get();
776
+ if (snap) r.sendTo(knock.id, snap);
777
+ } catch {}
778
+ }
779
+ return;
780
+ }
781
+ const info = knockInfo.get(knock.id) ?? { name: knock.name, fp: knock.fp };
782
+ // addGuest restores the role a returning fingerprint last held this session
783
+ // (spec §identity); a new/keyless guest takes the room default (opts.guests).
784
+ const g = state.addGuest(knock.id, { name: info.name, fp: info.fp, role: info.role ?? opts.guests });
785
+ if (info.fp) admittedFps.add(info.fp); // reloads re-enter without a second admit
786
+ log.event(`${g.name} joined as ${g.role}`);
787
+ // The join context card lands in the guest's own scrollback first (spec §join card).
788
+ try {
789
+ const card = buildCard(state, log, { joinerId: knock.id, claudeState: claude.state });
790
+ r.sendTo(knock.id, card.replace(/\n/g, '\r\n') + '\r\n');
791
+ } catch {}
792
+ // Then the CURRENT Claude screen so the joiner sees it live immediately.
793
+ try {
794
+ if (state.paused) {
795
+ // The snapshot is exactly what /pause promises to hide — a joiner during a
796
+ // pause gets the hold card, and the live screen on /resume.
797
+ r.sendTo(knock.id, '\r\n⏸ sharing is paused — the live screen will appear when the host resumes\r\n');
798
+ } else {
799
+ const snap = snapshot.get();
800
+ if (snap) r.sendTo(knock.id, snap);
801
+ }
802
+ } catch {}
803
+ showToast(`${g.name} joined as ${g.role} ${ROLE_GLYPH[g.role] ?? ''}`);
804
+ recomputeClamp();
805
+ };
806
+
807
+ // Resolve a specific pending knock by id. Answered ONLY from the host's browser tab
808
+ // (a {t:'ui'} admit/deny button); state.knocks shows the host all pending knocks.
809
+ // The terminal has no y/n knock path anymore (post-dogfood verdict).
810
+ const answerKnockById = (knockId, admitYes) => {
811
+ const idx = pendingKnocks.findIndex((k) => k.id === knockId);
812
+ if (idx === -1 || !relay) return;
813
+ const [knock] = pendingKnocks.splice(idx, 1);
814
+ if (admitYes) admitAndCatchUp(relay, knock);
815
+ else {
816
+ relay.deny(knock.id);
817
+ showToast(`declined ${knock.name}`);
818
+ }
819
+ repaintBand();
820
+ };
821
+
822
+ // A browser button command from guest `id`, executed AS that sender so the role
823
+ // gate still applies (verdict: browser tabs are labeled input sources; the brain
824
+ // stays the single authority). Admit/deny is a host action; a command runs the
825
+ // exact draft-send path (classify → gate → route).
826
+ const handleUi = (id, action) => {
827
+ if (!action || typeof action !== 'object') return;
828
+ const role = state.roleOf(id) ?? 'viewer';
829
+ // While paused, only room management stays live (resume/end/admit/roles);
830
+ // draft edits, scrolling, and queue changes are frozen with the mirror.
831
+ if (state.paused && !['admit', 'deny', 'kick', 'role', 'command', 'resync'].includes(action.kind)) return;
832
+ if (action.kind === 'admit' || action.kind === 'deny') {
833
+ if (!atLeast(role, 'host')) return notify(id, 'only the host can admit or deny knocks');
834
+ answerKnockById(action.id, action.kind === 'admit');
835
+ return;
836
+ }
837
+ // Roster buttons carry the target's participant id (not a name), so a duplicate or
838
+ // space/unicode name can never mis-target a kick/role change (finding 4).
839
+ if (action.kind === 'role') {
840
+ if (!atLeast(role, 'host')) return notify(id, 'only the host can set roles');
841
+ if (!applyRole(id, action.id, action.role)) notify(id, "can't set that role");
842
+ repaintBand();
843
+ return;
844
+ }
845
+ if (action.kind === 'kick') {
846
+ if (!atLeast(role, 'host')) return notify(id, 'only the host can kick');
847
+ if (!applyKick(id, action.id)) notify(id, "can't kick that participant");
848
+ repaintBand();
849
+ return;
850
+ }
851
+ // A mouse click inside a draft box: place that user's caret there (joining the
852
+ // box if it isn't theirs). Composing is prompter+, same as typing into it.
853
+ if (action.kind === 'caret') {
854
+ if (!atLeast(role, 'prompter')) return;
855
+ if (drafts.placeCaret(id, action.id, action.offset)) repaintBand();
856
+ return;
857
+ }
858
+ // A drag-selection being deleted (or typed over — the replacement char follows
859
+ // as an ordinary key). Same bar as typing: prompter and up.
860
+ if (action.kind === 'delrange') {
861
+ if (!atLeast(role, 'prompter')) return;
862
+ if (drafts.deleteRange(id, action.id, action.start, action.end)) repaintBand();
863
+ return;
864
+ }
865
+ // Move/resize a draft — SHARED: everyone sees the box travel live. Placement
866
+ // is stage-fraction coordinates; home:true snaps it back above the input line.
867
+ // No band repaint — the terminal status line doesn't carry draft geometry.
868
+ if (action.kind === 'place') {
869
+ if (!atLeast(role, 'prompter')) return;
870
+ const spot = action.home ? null : { x: action.x, y: action.y, w: action.w };
871
+ if (drafts.placeBox(action.id, spot)) scheduleState();
872
+ return;
873
+ }
874
+ // "Edit" on a queued item: pull it OUT of the queue and back into a fresh
875
+ // draft box, focused on the requester. Author-only, like /queue edit.
876
+ if (action.kind === 'unqueue') {
877
+ const item = queue.items[Math.trunc(action.n) - 1];
878
+ if (!item) return notify(id, `no queued item #${action.n}`);
879
+ if (item.author !== id) return notify(id, 'you can only edit your own queued item');
880
+ queue.remove(item.id, id, role);
881
+ drafts.seedDraft(id, item.text);
882
+ log.event(`${nameOf(id)} pulled queued item back to a draft`);
883
+ repaintBand();
884
+ return;
885
+ }
886
+ // The ✕ on a draft box: authors delete their own; the host, any.
887
+ if (action.kind === 'deldraft') {
888
+ if (!atLeast(role, 'prompter')) return;
889
+ const box = drafts.boxes.find((b) => b.id === action.id);
890
+ if (!box) return;
891
+ const mine = box.authors.has(id) || box.cursors.has(id);
892
+ if (!mine && !atLeast(role, 'host')) return notify(id, "you can only delete a draft you're part of");
893
+ if (drafts.deleteBox(action.id)) repaintBand();
894
+ return;
895
+ }
896
+ // Wheel over the mirror. Claude owns transcript scrolling — it enables mouse
897
+ // tracking and scrolls internally on wheel reports; the terminal never receives
898
+ // scrollback lines (verified: history stays 0 even solo). So the browser wheel
899
+ // becomes Claude's own scroll, typed into the pty and shared like any input.
900
+ if (action.kind === 'scroll') {
901
+ if (!atLeast(role, 'prompter')) return;
902
+ const n = Math.min(8, Math.abs(action.lines | 0));
903
+ if (!n) return;
904
+ const btn = action.lines < 0 ? 64 : 65; // SGR wheel up / down
905
+ const { cols, rows } = viewSize();
906
+ pty.write(`\x1b[<${btn};${Math.max(1, cols >> 1)};${Math.max(1, rows >> 1)}M`.repeat(n));
907
+ return;
908
+ }
909
+ // The host's escape hatch for a wedged state machine: if a hook was missed
910
+ // (observed once after declining a permission ask), the room sticks 'busy' and
911
+ // the queue holds forever. Clicking the state chip forces idle and drains.
912
+ if (action.kind === 'resync') {
913
+ if (!atLeast(role, 'host')) return;
914
+ claude.state = 'idle';
915
+ armed = false;
916
+ log.event('host resynced claude state to idle');
917
+ showToast('state resynced to idle');
918
+ pump();
919
+ repaintBand();
920
+ return;
921
+ }
922
+ if (action.kind === 'command') routeSend(id, { text: String(action.text) });
923
+ };
924
+
925
+ const { host: RELAY_HOST, port: RELAY_PORT } = parseRelayUrl(opts.relayUrl);
926
+ // Relay identity pinning (TOFU, like ssh known_hosts; --fingerprint pins
927
+ // explicitly). Loopback is exempt unless a fingerprint was given: dev relays
928
+ // regenerate their key every boot, and loopback MITM is outside the threat
929
+ // model — a real relay is never at 127.0.0.1.
930
+ const RELAY_LOOPBACK = ['127.0.0.1', '::1', 'localhost'].includes(RELAY_HOST);
931
+ const PIN_FILE = path.join(os.homedir(), '.claude-share', 'known_relays.json');
932
+ // Two URLs, deliberately kept apart (see src/invite.js). The host opens the hostUrl
933
+ // (carries the token → auto-admit as host); the token-free inviteUrl is the safe link
934
+ // to hand a friend. The status line shows the hostUrl (the host's own private
935
+ // terminal); the clipboard + the host tab's "copy invite" button use the inviteUrl.
936
+ // A deployed relay advertises its public https origin in the room grant; links
937
+ // then print https://domain/room. Localhost dev has none → http://host:webPort.
938
+ let publicBase = null;
939
+ const hostRoomUrl = (code) => hostUrl({ base: publicBase, host: RELAY_HOST, port: opts.webPort, code, token: hostToken });
940
+ const inviteRoomUrl = (code) => inviteUrl({ base: publicBase, host: RELAY_HOST, port: opts.webPort, code });
941
+
942
+ // Best-effort clipboard copy of the INVITE (never the host URL). Only reports success
943
+ // once pbcopy has actually exited cleanly — macOS-only, opt-out via the env var, and
944
+ // failures no longer masquerade as a copy (finding 5).
945
+ const copyInvite = (text, done) => {
946
+ if (process.platform !== 'darwin' || process.env.CLAUDE_SHARE_NO_CLIPBOARD) return done(false);
947
+ try {
948
+ const pb = execFile('pbcopy', (err) => done(!err));
949
+ pb.stdin.on('error', () => {}); // a broken pipe surfaces via the exec callback
950
+ pb.stdin.end(text);
951
+ } catch {
952
+ done(false);
953
+ }
954
+ };
955
+ // One stateful chatter partitioner per guest (split sequences carry per stream).
956
+ const guestPartitioners = new Map();
957
+ const guestPartitioner = (id) => {
958
+ if (!guestPartitioners.has(id)) guestPartitioners.set(id, createPartitioner());
959
+ return guestPartitioners.get(id);
960
+ };
961
+
962
+ // Attach every relay→brain handler to one relay instance. Reused verbatim for the
963
+ // initial connection and each reconnect, so a reattached connection behaves
964
+ // identically. Handlers use their own instance `r` for replies, so a stale
965
+ // instance can never write to (or resurrect) a superseded connection.
966
+ function wireRelay(r) {
967
+ r.onRoom((code, webUrl) => {
968
+ reconnectAttempts = 0;
969
+ publicBase = webUrl || null; // a deployed relay's public https origin (or none)
970
+ const reclaimed = state.room === code; // we already held this exact code → reclaim
971
+ state.setRoom(code);
972
+ currentUrl = hostRoomUrl(code); // the host's own tab URL lives in the status line
973
+ inviteUrlStr = inviteRoomUrl(code); // the token-free variant the mirror shows guests
974
+ if (reclaimed) {
975
+ showToast('reconnected — room reclaimed', 6000);
976
+ return;
977
+ }
978
+ // Copy the SAFE invite (token-free); the host reaches their own tab via the
979
+ // status-line URL. Claim the copy only if it actually happened (finding 5).
980
+ copyInvite(inviteRoomUrl(code), (copied) => showToast(readyToast(copied), 15000));
981
+ });
982
+ r.onGone(() => {
983
+ // Reclaim refused: the 10-min TTL lapsed, the relay restarted (fresh
984
+ // registry), or the room truly ended. Nothing to return to — drop the code
985
+ // AND its links (a dead URL on the band reads as a live room), finish solo.
986
+ state.setRoom(null);
987
+ currentUrl = null;
988
+ inviteUrlStr = null;
989
+ showToast('the room expired while disconnected — continuing solo', 8000);
990
+ repaintBand();
991
+ });
992
+ r.onRefused((reason) => {
993
+ // The relay rejected our HELLO outright. 'secret' = it requires a room
994
+ // secret we didn't present (or ours is wrong). Terminal, not transient.
995
+ relayVetoed = true;
996
+ const msg =
997
+ reason === 'secret'
998
+ ? 'relay requires a room secret — set CLAUDE_SHARE_SECRET (or --secret) and restart. running solo'
999
+ : `relay refused the connection (${reason}) — running solo`;
1000
+ showToast(msg, 15000);
1001
+ repaintBand();
1002
+ });
1003
+ r.onKnock((knock) => {
1004
+ // The host's own browser tab knocks with fp `webhost:<token>`. Auto-admit it as
1005
+ // host — it is the one knock answered with no explicit ui action, and it is how
1006
+ // the host gets in to answer everyone else's knocks. It is never a pending knock.
1007
+ if (knock.fp === `webhost:${hostToken}`) {
1008
+ admitAndCatchUp(r, knock);
1009
+ return;
1010
+ }
1011
+ // A guest admitted earlier this session (same browser token) walks straight
1012
+ // back in — a reload or network blip shouldn't cost them a second admit.
1013
+ // Kicked fps were removed above AND banned at the relay, so they never reach here.
1014
+ if (knock.fp && admittedFps.has(knock.fp)) {
1015
+ knockInfo.set(knock.id, { name: knock.name, fp: knock.fp });
1016
+ admitAndCatchUp(r, knock);
1017
+ return;
1018
+ }
1019
+ // Dedup by fingerprint: a WS reconnect during the join flow re-knocks with a new
1020
+ // connection id but the SAME fp — replace the stale card, don't stack a duplicate
1021
+ // (finding 3). Deny the superseded connection so it can't be admitted later.
1022
+ const { pending, replaced } = foldKnock(pendingKnocks, knock);
1023
+ pendingKnocks = pending;
1024
+ for (const staleId of replaced) {
1025
+ r.deny(staleId);
1026
+ knockInfo.delete(staleId);
1027
+ }
1028
+ knockInfo.set(knock.id, { name: knock.name, fp: knock.fp });
1029
+ repaintBand();
1030
+ });
1031
+ r.onJoin(() => {
1032
+ // The admit action (admitAndCatchUp) already added the participant and sent the
1033
+ // catch-up (card + snapshot) synchronously, so the snapshot is guaranteed to be
1034
+ // on the wire before any live frame (finding 2). JOINED is just the relay's
1035
+ // confirmation; the RESIZE that follows it drives the clamp (see onResize).
1036
+ });
1037
+ r.onLeave((id) => {
1038
+ guestPartitioners.delete(id);
1039
+ hostTabIds.delete(id); // a host tab closing is not a roster departure
1040
+ hostTabSizes.delete(id); // …but its capacity leaves the clamp
1041
+ forgetGuest(id);
1042
+ });
1043
+ r.onKey(({ id, data }) => {
1044
+ // Guests' terminals also answer mirrored queries and emit mouse reports.
1045
+ // Claude already gets the HOST terminal's answers; guest chatter is
1046
+ // dropped (v1: guest mouse doesn't drive the shared session).
1047
+ const { human } = guestPartitioner(id)(data);
1048
+ if (!human) return;
1049
+ // Paused = the room is frozen for every browser/ssh participant (the host's
1050
+ // own terminal still works): no typing, no draft edits, until resume.
1051
+ if (state.paused) return;
1052
+ const actor = actorOf(id); // a host tab drives Claude AS the host (finding 4)
1053
+ const role = state.roleOf(actor) ?? 'viewer';
1054
+ const composing = drafts.activeBox(actor) !== null;
1055
+ // Anyone who can prompt and has no draft focused is AT the terminal: keys go
1056
+ // raw to Claude (composing is opt-in) — asks, modes, everything (prompter and
1057
+ // host are the two typing roles now). Two keys stay ours even then: Ctrl+C
1058
+ // (an ssh guest's "leave the room") and Ctrl+N (the + draft chip).
1059
+ if (!composing && atLeast(role, 'prompter') && human !== '\x03' && human !== '\x0e') {
1060
+ sniffInterrupt(human);
1061
+ return pty.write(human);
1062
+ }
1063
+ applyInput(actor, dispatch(actor, role, Buffer.from(human, 'binary'), { armed, toasted, composing }));
1064
+ });
1065
+ r.onPointer(({ id, x, y }) => {
1066
+ const actor = actorOf(id);
1067
+ if (!actor || !state.get(actor)) return; // only a known participant's cursor
1068
+ pointers.set(actor, { x, y });
1069
+ scheduleState(); // rebroadcast via state.pointers (spec); no band repaint needed
1070
+ });
1071
+ r.onUi(({ id, action }) => handleUi(actorOf(id), action));
1072
+ r.onResize(({ id, cols, rows }) => {
1073
+ if (hostTabIds.has(id)) {
1074
+ // The host's own tab: a size participant, not a roster one (see viewSize).
1075
+ // The relay announces a placeholder 80×24 for every web rec at admit,
1076
+ // BEFORE the tab has measured itself — folding that in crashes the clamp
1077
+ // to the floor for a beat and Claude reflows its transcript twice (the
1078
+ // wrap garbage guests then see). 80×24 IS the clamp floor, so a real
1079
+ // report that size carries no information — skip both.
1080
+ if (cols !== FLOOR_COLS || rows !== FLOOR_ROWS) hostTabSizes.set(id, { cols, rows });
1081
+ } else {
1082
+ state.setSize(id, cols, rows);
1083
+ if (state.belowFloor(id)) {
1084
+ r.sendTo(id, '\x1b[2J\x1b[H your terminal is below 80×24 — make it bigger to join the shared view\r\n');
1085
+ }
1086
+ }
1087
+ recomputeClamp();
1088
+ repaintBand();
1089
+ });
1090
+ r.onClose(() => {
1091
+ if (exited || r !== relay) return; // a superseded instance's close is not ours
1092
+ relay = null;
1093
+ if (relayVetoed) return; // refused/identity-mismatch — already explained, never retry
1094
+ scheduleReconnect();
1095
+ });
1096
+ r.onError(() => {}); // transport errors surface via onClose; never crash
1097
+ }
1098
+
1099
+ // Open (or reopen) the relay connection. On ready, hello for a fresh room or
1100
+ // RECLAIM the one we still hold (spec §failure-behavior) — this is the reclaim the
1101
+ // CLI was missing. Failures on a held room retry within the TTL; an initial
1102
+ // failure just runs solo.
1103
+ function openRelay() {
1104
+ if (exited || relayVetoed) return;
1105
+ // Fresh pin check per attempt (it re-reads the store, picking up a pin the
1106
+ // previous attempt just made). null = loopback dev relay, no verification.
1107
+ const pin =
1108
+ opts.fingerprint || !RELAY_LOOPBACK
1109
+ ? createPinCheck({ key: `${RELAY_HOST}:${RELAY_PORT}`, file: PIN_FILE, expected: opts.fingerprint })
1110
+ : null;
1111
+ let r;
1112
+ try {
1113
+ r = connectRelay({
1114
+ url: opts.relayUrl,
1115
+ privateKey: loadHostKey(),
1116
+ secret: opts.secret,
1117
+ roomPass: opts.roomPassword,
1118
+ verifyHostKey: pin ? pin.verify : undefined,
1119
+ });
1120
+ } catch (err) {
1121
+ if (state.room) return scheduleReconnect();
1122
+ process.stderr.write(`collab: relay setup failed (${err.message}); running solo\n`);
1123
+ return;
1124
+ }
1125
+ relay = r;
1126
+ wireRelay(r);
1127
+ r.ready
1128
+ .then(() => {
1129
+ const move = openingMove(state.room);
1130
+ if (move.t === 'reclaim') r.reclaim(move.code);
1131
+ else r.hello();
1132
+ })
1133
+ .catch((err) => {
1134
+ if (r === relay) relay = null;
1135
+ if (pin?.outcome() === 'mismatch') {
1136
+ // The relay presented a key that doesn't match our pin. Either the
1137
+ // operator rotated it on purpose, or someone is impersonating the
1138
+ // relay — refuse loudly either way and never auto-retry.
1139
+ relayVetoed = true;
1140
+ process.stderr.write(
1141
+ `collab: RELAY IDENTITY CHANGED — refusing to connect.\n` +
1142
+ `collab: pinned ${pin.expected()}, but ${RELAY_HOST}:${RELAY_PORT} presented ${pin.seen()}.\n` +
1143
+ `collab: if the relay key was rotated on purpose, delete the "${RELAY_HOST}:${RELAY_PORT}"\n` +
1144
+ `collab: entry in ${PIN_FILE} and restart. otherwise DO NOT connect.\n`
1145
+ );
1146
+ showToast('relay identity changed — refusing to connect (details in terminal). running solo', 15000);
1147
+ return;
1148
+ }
1149
+ if (state.room) scheduleReconnect(); // lost a held room — keep trying within TTL
1150
+ else process.stderr.write(`collab: relay unavailable (${err.message}); running solo\n`);
1151
+ });
1152
+ }
1153
+
1154
+ // A dropped connection: if we still hold a room, back off and reconnect to reclaim
1155
+ // it; otherwise there is nothing to return to, so go solo.
1156
+ function scheduleReconnect() {
1157
+ if (exited) return;
1158
+ if (!state.room) return showToast('relay disconnected — running solo', 8000);
1159
+ if (reconnectAttempts >= MAX_RECONNECT) {
1160
+ return showToast('could not reconnect to the relay — continuing solo', 8000);
1161
+ }
1162
+ reconnectAttempts++;
1163
+ showToast(`relay disconnected — reconnecting to reclaim the room (${reconnectAttempts}/${MAX_RECONNECT})…`, 8000);
1164
+ clearTimeout(reconnectTimer);
1165
+ reconnectTimer = setTimeout(openRelay, reconnectAttempts === 1 ? 300 : 1500);
1166
+ reconnectTimer.unref?.();
1167
+ }
1168
+
1169
+ if (opts.relay) openRelay();
1170
+
1171
+ // ── hook events → brain ───────────────────────────────────────────────────────
1172
+ if (hooks) {
1173
+ hooks.on('busy', () => {
1174
+ lastHookAt = Date.now();
1175
+ clearSubmitWatch(); // the submission is confirmed — the optimistic busy is real
1176
+ claude.state = 'busy';
1177
+ armed = false;
1178
+ repaintBand();
1179
+ });
1180
+ hooks.on('idle', () => {
1181
+ lastHookAt = Date.now();
1182
+ clearSubmitWatch();
1183
+ claude.state = 'idle';
1184
+ armed = false;
1185
+ pump();
1186
+ repaintBand();
1187
+ });
1188
+ hooks.on('ask', () => {
1189
+ lastHookAt = Date.now();
1190
+ clearSubmitWatch();
1191
+ claude.state = 'ask';
1192
+ armed = true; // the ONLY thing that arms a composing-side y/n → Claude (spec: gate on events)
1193
+ repaintBand();
1194
+ });
1195
+ hooks.on('tool', (p) => {
1196
+ lastHookAt = Date.now();
1197
+ clearSubmitWatch(); // any hook activity proves Claude took the prompt
1198
+ // Tool activity proves a turn is running. A slash command's UserPromptSubmit
1199
+ // is dropped (it may never Stop — see mapHookEvent), so a slash that DOES run
1200
+ // a real turn flips busy here instead; its Stop still returns to idle.
1201
+ if (claude.state === 'idle') claude.state = 'busy';
1202
+ const name = p?.tool_name ?? 'tool';
1203
+ const files = [];
1204
+ const inp = p?.tool_input ?? {};
1205
+ for (const key of ['file_path', 'path', 'notebook_path']) {
1206
+ if (typeof inp[key] === 'string') files.push(inp[key]);
1207
+ }
1208
+ log.tool(name, files);
1209
+ repaintBand();
1210
+ });
1211
+ hooks.on('mode', (m) => {
1212
+ const changed = state.setMode(m);
1213
+ // Any mode change with guests present raises a prominent warning banner into
1214
+ // the shared transcript (spec §roles).
1215
+ if (changed && state.guests().length) {
1216
+ const warn =
1217
+ m === 'bypassPermissions'
1218
+ ? '⚠ bypass mode: everyone in this room can now drive real commands with no asks'
1219
+ : `⚠ mode changed to ${m} — visible to the whole room`;
1220
+ log.event(warn);
1221
+ showToast(warn, 8000);
1222
+ }
1223
+ repaintBand();
1224
+ });
1225
+ }
1226
+
1227
+ // ── teardown ──────────────────────────────────────────────────────────────────
1228
+ const cleanup = (code) => {
1229
+ if (exited) return;
1230
+ exited = true;
1231
+ clearTimeout(reconnectTimer); // stop any pending reclaim attempt
1232
+ try {
1233
+ if (stdin.isTTY) stdin.setRawMode(false);
1234
+ } catch {}
1235
+ stdin.pause();
1236
+ try {
1237
+ relay?.end();
1238
+ relay?.close();
1239
+ } catch {}
1240
+ try {
1241
+ hooks?.close();
1242
+ } catch {}
1243
+ for (const f of [settingsFile, socketPath]) {
1244
+ if (f) {
1245
+ try {
1246
+ fs.unlinkSync(f);
1247
+ } catch {}
1248
+ }
1249
+ }
1250
+ stdout.write(TERM_RESTORE + '\r\n[claude-share exited]\r\n');
1251
+ process.exit(code ?? 0);
1252
+ };
1253
+
1254
+ // Undo every terminal mode the child may have enabled on the REAL terminal via
1255
+ // passthrough — kitty keyboard, mouse tracking (the "35;138;1M" spam if left on),
1256
+ // focus events, bracketed paste — then show the cursor and leave the alt screen.
1257
+ // Dying without this leaves the user's shell receiving mouse coordinates.
1258
+ const TERM_RESTORE =
1259
+ '\x1b[<u' + // pop kitty keyboard flags
1260
+ '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l' + // mouse tracking off
1261
+ '\x1b[?1004l' + // focus reporting off
1262
+ '\x1b[?2004l' + // bracketed paste off
1263
+ '\x1b[?25h' + // cursor visible
1264
+ '\x1b[?1049l'; // leave the alternate screen
1265
+
1266
+ // pkill/kill and a closing terminal tab must restore the terminal too — without
1267
+ // these handlers, SIGTERM/SIGHUP skip cleanup entirely (dogfood finding).
1268
+ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
1269
+ process.on(sig, () => cleanup(0));
1270
+ }
1271
+
1272
+ // ── passthrough + broadcast ────────────────────────────────────────────────────
1273
+ // Write every Claude frame locally; mirror it to guests unless paused. The band
1274
+ // redraws on the frame boundary so it never smears mid-repaint.
1275
+ pty.onFrame((chunk) => {
1276
+ snapshot.push(chunk); // retain the current screen so a joiner sees it live (finding 1)
1277
+ stdout.write(chunk);
1278
+ mirror(chunk);
1279
+ repaintBand();
1280
+ });
1281
+ pty.onExit(({ exitCode }) => cleanup(exitCode ?? 0));
1282
+
1283
+ // ── host stdin — STRAIGHT THROUGH to Claude ──────────────────────────────────
1284
+ // The terminal is the engine room: the host drives Claude exactly as solo. There
1285
+ // is no composer, no gate, and no knock/end y/n on host input anymore — all
1286
+ // multiplayer moves happen in the host's browser tab. We still run the chatter
1287
+ // partitioner (terminal DA/version replies + mouse reports are the terminal
1288
+ // answering Claude); both halves flow to the PTY, so Claude sees every byte.
1289
+ if (stdin.isTTY) stdin.setRawMode(true);
1290
+ stdin.resume();
1291
+ const partitionHostInput = createPartitioner();
1292
+ stdin.on('data', (d) => {
1293
+ const { chatter, human } = partitionHostInput(d);
1294
+ if (chatter) pty.write(Buffer.from(chatter, 'binary'));
1295
+ if (human) {
1296
+ sniffInterrupt(human); // the host's own Esc interrupt is hookless too
1297
+ pty.write(Buffer.from(human, 'binary'));
1298
+ }
1299
+ });
1300
+
1301
+ process.on('SIGWINCH', () => {
1302
+ state.setSize(HOST_ID, stdout.columns || 80, stdout.rows || 24);
1303
+ recomputeClamp();
1304
+ repaintBand();
1305
+ });
1306
+ process.on('exit', () => {
1307
+ try {
1308
+ if (stdin.isTTY) stdin.setRawMode(false);
1309
+ if (stdout.isTTY) stdout.write(TERM_RESTORE);
1310
+ } catch {}
1311
+ });
1312
+
1313
+ repaintBand(); // draw the band immediately, before the first frame
1314
+ }
1315
+
1316
+ main().catch((err) => {
1317
+ process.stderr.write(`collab: ${err?.stack || err}\n`);
1318
+ process.exit(1);
1319
+ });