@claudecollab/cli 0.1.2 → 0.2.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.
@@ -28,6 +28,11 @@ import { execFile } from 'node:child_process';
28
28
  import { randomBytes } from 'node:crypto';
29
29
  import ssh2 from 'ssh2';
30
30
  import { startPty } from '../src/pty.js';
31
+ import { stripShimDir } from '../src/shim.js';
32
+ import { shouldRunSetup, runSetup, setupMain } from '../src/setup-actions.js';
33
+ import { startCtl } from '../src/ctl.js';
34
+ import { ctlMain } from '../src/ctl-client.js';
35
+ import { writeRoomFile, clearRoomFile } from '../src/room-file.js';
31
36
  import { paint, ROLE_GLYPH } from '../src/renderer.js';
32
37
  import { ScreenSnapshot } from '../src/screen-snapshot.js';
33
38
  import { installHooks, listenHooks } from '../src/hooks.js';
@@ -35,18 +40,26 @@ import { connectRelay, parseRelayUrl, openingMove } from '../src/relay-client.js
35
40
  import { createPinCheck } from '../src/known-relays.js';
36
41
  import { hostUrl, inviteUrl, readyToast } from '../src/invite.js';
37
42
  import { createPartitioner } from '../src/term-chatter.js';
38
- import { RoomState, HOST_ID, atLeast, FLOOR_COLS, FLOOR_ROWS } from '../src/brain/state.js';
43
+ import { RoomState, HOST_ID, atLeast, FLOOR_COLS, FLOOR_ROWS, isScaledViewer } from '../src/brain/state.js';
39
44
  import { Queue } from '../src/brain/queue.js';
40
45
  import { Log } from '../src/brain/log.js';
41
46
  import { Drafts } from '../src/brain/drafts.js';
47
+ import { History } from '../src/brain/history.js';
48
+ import { extractLatestResponse } from '../src/brain/transcript.js';
42
49
  import { dispatch, classifySend, sendAllowed } from '../src/brain/gate.js';
43
50
  import { parse as parseCommand, permitted as commandPermitted, resolveMention } from '../src/brain/commands.js';
44
51
  import { build as buildCard, recapCard } from '../src/brain/card.js';
45
52
  import { foldKnock } from '../src/brain/knocks.js';
53
+ import { askContext, summarizeToolInput } from '../src/brain/ask.js';
46
54
 
47
55
  function parseArgs(argv) {
48
56
  const opts = {
49
57
  relay: true,
58
+ // Lazy by default (spec phase 5a): a fresh `collab` starts pixel-identical to
59
+ // plain claude and does NOT dial the relay until go-live (`collab go`). --live
60
+ // restores the old behavior — dial at startup — for rigs, tests, and anyone who
61
+ // wants a room immediately. --no-relay still means never share.
62
+ live: false,
50
63
  // Default to the community relay so a fresh `collab` needs zero setup; dev
51
64
  // rigs and tests always pass --relay explicitly (ssh://127.0.0.1:2222).
52
65
  relayUrl: 'ssh://claudecollab.org:2222',
@@ -59,12 +72,16 @@ function parseArgs(argv) {
59
72
  secret: process.env.CLAUDE_SHARE_SECRET || undefined,
60
73
  fingerprint: undefined, // explicit relay key pin (SHA256:…); default is TOFU
61
74
  roomPassword: undefined, // optional join password guests must present before knocking
75
+ cap: undefined, // optional requested room size (--max-guests); the relay clamps it
76
+ yes: false, // --yes: skip the first-run setup screen (for scripts / non-interactive)
62
77
  childArgs: [],
63
78
  };
64
79
  const passthrough = [];
65
80
  for (let i = 0; i < argv.length; i++) {
66
81
  const a = argv[i];
67
82
  if (a === '--no-relay') opts.relay = false;
83
+ else if (a === '--live') opts.live = true;
84
+ else if (a === '--yes') opts.yes = true;
68
85
  else if (a === '--relay') opts.relayUrl = argv[++i] ?? opts.relayUrl;
69
86
  else if (a === '--no-hooks') opts.hooks = false;
70
87
  else if (a === '--web-port') opts.webPort = Math.max(1, Number(argv[++i]) || opts.webPort);
@@ -73,7 +90,10 @@ function parseArgs(argv) {
73
90
  else if (a === '--secret') opts.secret = argv[++i] ?? opts.secret;
74
91
  else if (a === '--fingerprint') opts.fingerprint = argv[++i] ?? opts.fingerprint;
75
92
  else if (a === '--room-password') opts.roomPassword = argv[++i] ?? opts.roomPassword;
76
- else if (a === '--') {
93
+ else if (a === '--max-guests') {
94
+ const n = Math.floor(Number(argv[++i]));
95
+ if (Number.isFinite(n) && n > 0) opts.cap = n; // a bad value is ignored (relay default stands)
96
+ } else if (a === '--') {
77
97
  passthrough.push(...argv.slice(i + 1));
78
98
  break;
79
99
  } else passthrough.push(a); // unknown flags pass through to the child
@@ -114,16 +134,53 @@ function loadHostKey() {
114
134
  async function main() {
115
135
  const opts = parseArgs(process.argv.slice(2));
116
136
  const { stdin, stdout } = process;
137
+ // The room-creation secret is the WRAPPER's credential, captured into opts.secret
138
+ // above — scrub it from our environment NOW so no child ever inherits it (the pty
139
+ // child, hook processes, /recap's `claude -p`). Without this, the Claude inside a
140
+ // shared session could be prompted by any guest to echo $CLAUDE_SHARE_SECRET into
141
+ // the mirrored screen.
142
+ delete process.env.CLAUDE_SHARE_SECRET;
143
+
144
+ // ── first run: the one-screen setup (installs /collab + shims `claude`) ────────
145
+ // Shown ONCE, only on a real interactive run. The bold hazard: rigs/tests spawn
146
+ // interactive PTYs, so this MUST be gated off for them — every rig/e2e host spawn
147
+ // sets CLAUDE_SHARE_SKIP_SETUP=1 (shouldRunSetup honors it), or the suite hangs
148
+ // here at the picker. runSetup writes the shown-once marker when it completes.
149
+ if (shouldRunSetup({ stdinTTY: stdin.isTTY, stdoutTTY: stdout.isTTY, skipEnv: process.env.CLAUDE_SHARE_SKIP_SETUP, yes: opts.yes })) {
150
+ try {
151
+ await runSetup({ input: stdin, output: stdout });
152
+ } catch {
153
+ /* setup is best-effort — never block the session on it */
154
+ }
155
+ }
117
156
  // The terminal band is permanently exactly ONE status line, pinned to the bottom
118
157
  // row. Claude gets every other row; the child PTY is resized to rows-1 each repaint.
119
158
  const BAND_ROWS = 1;
120
159
  const multiplayer = opts.relay; // the guest composer/gate + mirror are active only when sharing
160
+ // Lazy sharing (spec phase 5a): pre-live, the wrapper is pixel-identical to plain
161
+ // claude — the relay is not dialed and the band paints ZERO rows, so the child owns
162
+ // every terminal row. wantLive flips exactly once, at go-live (--live at startup, or
163
+ // `collab go` over the control socket). isLive() gates every sharing-shaped behavior
164
+ // (band height, the shared-size clamp); bandRows() is the one height source.
165
+ let wantLive = opts.live;
166
+ const isLive = () => multiplayer && wantLive;
167
+ const bandRows = () => (isLive() ? BAND_ROWS : 0);
121
168
  // The host's browser tab authenticates with this token: it knocks with fingerprint
122
169
  // `webhost:<token>:<seat>`, and the brain auto-admits it as host. The token goes in
123
170
  // the room URL we print/copy; the SEAT does not — the browser mints it locally, so
124
171
  // possessing the (leak-prone) URL is not enough to take the host seat.
125
172
  const hostToken = randomBytes(16).toString('hex');
126
173
  const HOST_FP_PREFIX = `webhost:${hostToken}`;
174
+ // The room file the wrapped Claude reads to learn its own invite link. Its PATH is
175
+ // ALWAYS exported (CLAUDE_SHARE_ROOM_FILE, below) so "running under collab" is
176
+ // detectable; the file only EXISTS while a room is live (written on grant/reclaim,
177
+ // removed on gone/give-up/exit). Whitelisted: the host token never reaches it.
178
+ const roomFile = path.join(os.tmpdir(), `claude-share-room-${process.pid}.json`);
179
+ // The control socket the wrapped Claude drives with `collab go|off|status` (spec
180
+ // phase 5a). Its PATH is exported as CLAUDE_SHARE_CTL for every spawn; the socket
181
+ // itself is created below (0600, same-user only) and unlinked in cleanup(). The
182
+ // host URL/token NEVER crosses it — replies carry room + inviteUrl only.
183
+ const ctlPath = path.join(os.tmpdir(), `claude-share-ctl-${process.pid}.sock`);
127
184
  // The seat this session is bound to: the first host browser's seat secret claims
128
185
  // it; later browsers presenting the token with a different/absent seat are refused
129
186
  // (leaked-link defense). Reset by a host-terminal Ctrl-G handoff (see below).
@@ -163,6 +220,7 @@ async function main() {
163
220
  const queue = new Queue();
164
221
  const log = new Log();
165
222
  const drafts = new Drafts();
223
+ const history = new History(); // per-turn who-typed-what + Claude's response
166
224
  const toasted = new Set(); // userIds already shown the viewer toast
167
225
  const knockInfo = new Map(); // id -> {name, fp} captured at knock time
168
226
  const pointers = new Map(); // userId -> {x, y} normalized 0..1 (browser cursors)
@@ -177,6 +235,12 @@ async function main() {
177
235
  const admittedFps = new Set();
178
236
  const claude = { state: 'idle' };
179
237
  let armed = false; // true only while a permission ask is pending (hook-armed)
238
+ // What Claude is asking permission for (tool + a short, stripControls'd input
239
+ // summary), surfaced in the state snapshot while claude.state === 'ask' so the
240
+ // browser's ask card can show it. `lastTool` remembers the most recent tool this
241
+ // turn (its input is the best summary we get); both clear when the turn moves on.
242
+ let askInfo = null;
243
+ let lastTool = null;
180
244
  let pendingKnocks = []; // FIFO of guest {id,name,fp,seen} awaiting the host tab's admit
181
245
  let currentUrl = null; // the host-tab URL (WITH host token) — host's own terminal only
182
246
  let inviteUrlStr = null; // the token-free invite URL — safe to show guests in the mirror
@@ -185,6 +249,7 @@ async function main() {
185
249
  // text is dropped (drafts are created explicitly — never by stray typing).
186
250
  const nudgedAt = new Map();
187
251
  let relay = null;
252
+ let ctl = null; // the control-socket server (started below; closed in cleanup)
188
253
  let exited = false;
189
254
  // Reconnect-with-reclaim state (spec §failure-behavior: the relay holds the code
190
255
  // 10 min after a host drop). On a lost connection we reconnect and RECLAIM the
@@ -217,7 +282,16 @@ async function main() {
217
282
 
218
283
  let pty;
219
284
  try {
220
- pty = await startPty({ cmd: opts.cmd, args: childArgs, bandRows: BAND_ROWS });
285
+ // CLAUDE_SHARE_ROOM_FILE is set for EVERY spawn, solo included: its presence
286
+ // tells the child it's running under collab; the file at that path appears only
287
+ // once a room is live (phase 5's skill relies on that live-vs-not distinction).
288
+ const childEnv = { CLAUDE_SHARE_ROOM_FILE: roomFile, CLAUDE_SHARE_CTL: ctlPath };
289
+ // Recursion guard: when the wrapped command is `claude`, strip the shim dir from
290
+ // the child's PATH so it resolves to the REAL claude, never back to the shim that
291
+ // execs us (stripShimDir is spike-proven; see src/shim.js). Any other --cmd keeps
292
+ // PATH untouched.
293
+ if (opts.cmd === 'claude') childEnv.PATH = stripShimDir(process.env.PATH, os.homedir());
294
+ pty = await startPty({ cmd: opts.cmd, args: childArgs, bandRows: bandRows(), env: childEnv });
221
295
  } catch (err) {
222
296
  process.stderr.write(
223
297
  `collab: could not start "${opts.cmd}": ${err.message}\n` +
@@ -272,9 +346,12 @@ async function main() {
272
346
  // the mirror letterboxed until the first guest join reshaped it.
273
347
  // Solo with no tab there is no shared view, so we use the host's own terminal.
274
348
  const viewSize = () => {
275
- if (!multiplayer) return { cols: stdout.columns || 80, rows: stdout.rows || 24 };
349
+ // Pre-live (and solo) the child owns the real terminal exactly no floor clamp,
350
+ // no resize theft. The shared-size clamp only governs once we are actually live.
351
+ if (!isLive()) return { cols: stdout.columns || 80, rows: stdout.rows || 24 };
276
352
  let { cols, rows } = state.clamp();
277
353
  for (const s of hostTabSizes.values()) {
354
+ if (isScaledViewer(s)) continue; // a below-floor host tab scales too — ignore its size
278
355
  cols = Math.min(cols, s.cols);
279
356
  rows = Math.min(rows, s.rows);
280
357
  }
@@ -310,7 +387,11 @@ async function main() {
310
387
  participants: state.list().map((p) => ({ id: p.id, name: p.name, role: p.role, color: p.color })),
311
388
  drafts: drafts.snapshot(),
312
389
  queue: queue.items.map((it, i) => ({ n: i + 1, author: it.author, text: it.text })),
390
+ history: history.snapshot(),
313
391
  claudeState: claude.state,
392
+ // What Claude is asking permission for, only while it is actually asking. The
393
+ // browser ask card reads this; approve/deny are ordinary keystrokes (role-gated).
394
+ ask: claude.state === 'ask' ? askInfo : null,
314
395
  paused: state.paused,
315
396
  pointers: Object.fromEntries(
316
397
  [...pointers]
@@ -366,7 +447,7 @@ async function main() {
366
447
  // Claude's region is resized to rows-1 so the line can never overlap its output.
367
448
  const repaintBand = () => {
368
449
  const { cols, rows } = viewSize();
369
- const band = BAND_ROWS;
450
+ const band = bandRows(); // 0 pre-live (paint() no-ops, child keeps every row), 1 once live
370
451
 
371
452
  // Keep Claude's region to exactly the rows the band does not occupy, at the
372
453
  // shared width — resize the child only when that actually changes.
@@ -508,6 +589,32 @@ async function main() {
508
589
  t.unref?.();
509
590
  };
510
591
 
592
+ // Claude's response to the turn that just finished, read from the session
593
+ // transcript the Stop hook hands us (`transcript_path`). We read only the tail —
594
+ // the latest turn is at the end — so a long session's transcript stays cheap, and
595
+ // a truncated leading line is tolerated by the extractor. Any failure → '' (the
596
+ // window still shows the prompt; the response just stays blank).
597
+ const RESPONSE_TAIL_BYTES = 256 * 1024;
598
+ const captureResponse = (payload) => {
599
+ const file = payload && typeof payload.transcript_path === 'string' ? payload.transcript_path : null;
600
+ if (!file) return '';
601
+ try {
602
+ const fd = fs.openSync(file, 'r');
603
+ try {
604
+ const { size } = fs.fstatSync(fd);
605
+ const start = Math.max(0, size - RESPONSE_TAIL_BYTES);
606
+ const len = size - start;
607
+ const buf = Buffer.alloc(len);
608
+ fs.readSync(fd, buf, 0, len, start);
609
+ return extractLatestResponse(buf.toString('utf8'));
610
+ } finally {
611
+ fs.closeSync(fd);
612
+ }
613
+ } catch {
614
+ return '';
615
+ }
616
+ };
617
+
511
618
  const pump = () => {
512
619
  if (Date.now() < ptyLockUntil) {
513
620
  const retry = setTimeout(pump, 160);
@@ -529,6 +636,7 @@ async function main() {
529
636
  }, 120);
530
637
  enter.unref?.();
531
638
  claude.state = 'busy'; // optimistic; the UserPromptSubmit hook confirms
639
+ history.start(item.author, item.text); // open a turn; Stop closes it with the response
532
640
  armSubmitWatch();
533
641
  repaintBand();
534
642
  };
@@ -704,7 +812,7 @@ async function main() {
704
812
  runRecap(by);
705
813
  break;
706
814
  case 'end':
707
- endSession();
815
+ endSession(parsed.save);
708
816
  break;
709
817
  }
710
818
  repaintBand();
@@ -727,13 +835,15 @@ async function main() {
727
835
 
728
836
  // /end — end the room now. The two-step "end? / save?" confirmation is a browser-UI
729
837
  // concern (the host tab gates it and fires a single /end); the terminal has no y/n
730
- // path anymore. We write the attributed session.md then disconnect everyone
731
- // defaulting to save preserves the record, since the browser doesn't yet signal a
732
- // no-save choice (spec §host controls: the log is kept in memory either way).
733
- function endSession() {
734
- try {
735
- log.write(path.join(process.cwd(), 'session.md'));
736
- } catch {}
838
+ // path anymore. The browser's "Save & end" sends `/end` (save) and "Just end" sends
839
+ // `/end nosave` (skip). `save` defaults to true so a bare host-typed /end still keeps
840
+ // the record; a nosave never touches disk (so it can't overwrite an existing file).
841
+ function endSession(save = true) {
842
+ if (save) {
843
+ try {
844
+ log.write(path.join(process.cwd(), 'session.md'));
845
+ } catch {}
846
+ }
737
847
  relay?.end();
738
848
  cleanup(0);
739
849
  }
@@ -956,6 +1066,8 @@ async function main() {
956
1066
  if (!atLeast(role, 'host')) return;
957
1067
  claude.state = 'idle';
958
1068
  armed = false;
1069
+ askInfo = null;
1070
+ lastTool = null;
959
1071
  log.event('host resynced claude state to idle');
960
1072
  showToast('state resynced to idle');
961
1073
  pump();
@@ -1007,13 +1119,23 @@ async function main() {
1007
1119
  // identically. Handlers use their own instance `r` for replies, so a stale
1008
1120
  // instance can never write to (or resurrect) a superseded connection.
1009
1121
  function wireRelay(r) {
1122
+ // A superseded instance (off() tore it down mid-dial, or a reconnect replaced
1123
+ // it) must never mutate room state: a stale late ROOM grant would resurrect a
1124
+ // session the user just turned off, and a stale REFUSED would veto future gos.
1125
+ // onClose below already carries the same guard.
1126
+ const current = () => r === relay;
1010
1127
  r.onRoom((code, webUrl) => {
1128
+ if (!current()) return;
1011
1129
  reconnectAttempts = 0;
1012
1130
  publicBase = webUrl || null; // a deployed relay's public https origin (or none)
1013
1131
  const reclaimed = state.room === code; // we already held this exact code → reclaim
1014
1132
  state.setRoom(code);
1015
1133
  currentUrl = hostRoomUrl(code); // the host's own tab URL lives in the status line
1016
1134
  inviteUrlStr = inviteRoomUrl(code); // the token-free variant the mirror shows guests
1135
+ // Expose the live room's INVITE link to the wrapped Claude (whitelist drops the
1136
+ // token-bearing host URL). Runs on create AND reclaim — a reclaim may carry a
1137
+ // fresh publicBase, so the file is always rewritten from the current values.
1138
+ writeRoomFile(roomFile, { room: code, inviteUrl: inviteUrlStr, webUrl: publicBase ?? undefined });
1017
1139
  if (reclaimed) {
1018
1140
  showToast('reconnected — room reclaimed', 6000);
1019
1141
  return;
@@ -1023,24 +1145,38 @@ async function main() {
1023
1145
  copyInvite(inviteRoomUrl(code), (copied) => showToast(readyToast(copied), 15000));
1024
1146
  });
1025
1147
  r.onGone(() => {
1148
+ if (!current()) return;
1026
1149
  // Reclaim refused: the 10-min TTL lapsed, the relay restarted (fresh
1027
1150
  // registry), or the room truly ended. Nothing to return to — drop the code
1028
1151
  // AND its links (a dead URL on the band reads as a live room), finish solo.
1029
1152
  state.setRoom(null);
1030
1153
  currentUrl = null;
1031
1154
  inviteUrlStr = null;
1155
+ clearRoomFile(roomFile); // the room is gone — the child must not read a dead link
1032
1156
  showToast('the room expired while disconnected — continuing solo', 8000);
1033
1157
  repaintBand();
1034
1158
  });
1035
1159
  r.onRefused((reason) => {
1160
+ if (!current()) return;
1036
1161
  // The relay rejected our HELLO outright. 'secret' = it requires a room
1037
- // secret we didn't present (or ours is wrong). Terminal, not transient.
1162
+ // secret we didn't present (or ours is wrong); 'version' = it speaks a newer
1163
+ // protocol than we do (update the CLI). Terminal, not transient.
1038
1164
  relayVetoed = true;
1039
- const msg =
1040
- reason === 'secret'
1041
- ? 'relay requires a room secretset CLAUDE_SHARE_SECRET (or --secret) and restart. running solo'
1042
- : `relay refused the connection (${reason}) — running solo`;
1165
+ let msg;
1166
+ if (reason === 'secret') msg = 'relay requires a room secret — set CLAUDE_SHARE_SECRET (or --secret) and restart. running solo';
1167
+ else if (reason === 'version') msg = 'relay speaks a newer protocolupdate with: npm update -g @claudecollab/cli. running solo';
1168
+ else msg = `relay refused the connection (${reason}) — running solo`;
1169
+ // A refusal means sharing never began — after the explanatory toast has had
1170
+ // its 15s on the band, return the session to fully invisible (band gone, the
1171
+ // child reclaims the row) instead of sticking a dead live-band on a solo
1172
+ // session. goLiveStarted resets so a later `go` (fixed secret) can redial.
1043
1173
  showToast(msg, 15000);
1174
+ const invis = setTimeout(() => {
1175
+ wantLive = false;
1176
+ goLiveStarted = false;
1177
+ repaintBand();
1178
+ }, 15000);
1179
+ invis.unref?.();
1044
1180
  repaintBand();
1045
1181
  });
1046
1182
  r.onKnock((knock) => {
@@ -1159,7 +1295,9 @@ async function main() {
1159
1295
  // CLI was missing. Failures on a held room retry within the TTL; an initial
1160
1296
  // failure just runs solo.
1161
1297
  function openRelay() {
1162
- if (exited || relayVetoed) return;
1298
+ // wantLive is the invariant: only a live-intending session may dial. Guards any
1299
+ // stray timer/callback that survives an off() teardown (belt to off's braces).
1300
+ if (exited || relayVetoed || !wantLive) return;
1163
1301
  // Fresh pin check per attempt (it re-reads the store, picking up a pin the
1164
1302
  // previous attempt just made). null = loopback dev relay, no verification.
1165
1303
  const pin =
@@ -1173,6 +1311,7 @@ async function main() {
1173
1311
  privateKey: loadHostKey(),
1174
1312
  secret: opts.secret,
1175
1313
  roomPass: opts.roomPassword,
1314
+ cap: opts.cap,
1176
1315
  verifyHostKey: pin ? pin.verify : undefined,
1177
1316
  });
1178
1317
  } catch (err) {
@@ -1215,6 +1354,7 @@ async function main() {
1215
1354
  if (exited) return;
1216
1355
  if (!state.room) return showToast('relay disconnected — running solo', 8000);
1217
1356
  if (reconnectAttempts >= MAX_RECONNECT) {
1357
+ clearRoomFile(roomFile); // gave up reclaiming — the room is no longer reachable
1218
1358
  return showToast('could not reconnect to the relay — continuing solo', 8000);
1219
1359
  }
1220
1360
  reconnectAttempts++;
@@ -1224,7 +1364,108 @@ async function main() {
1224
1364
  reconnectTimer.unref?.();
1225
1365
  }
1226
1366
 
1227
- if (opts.relay) openRelay();
1367
+ // Go live on demand: dial the relay and flip wantLive so the band appears and the
1368
+ // child shrinks by one row (via repaintBand's resize path). Idempotent — the guard
1369
+ // makes it callable exactly once, whether from --live at startup or `collab go`
1370
+ // mid-session over the control socket. --no-relay can never go live.
1371
+ let goLiveStarted = false;
1372
+ function goLive() {
1373
+ if (goLiveStarted || exited || !opts.relay) return;
1374
+ goLiveStarted = true;
1375
+ wantLive = true;
1376
+ openRelay();
1377
+ repaintBand(); // band appears; the child is resized to rows-1
1378
+ }
1379
+ if (opts.relay && opts.live) goLive();
1380
+
1381
+ // ── control socket: `collab go|off|status` drives the room lifecycle ──────────
1382
+ // The wrapped Claude connects here (CLAUDE_SHARE_CTL) to go live / stop / query
1383
+ // WITHOUT restarting. Replies carry room + inviteUrl only — never the host URL or
1384
+ // token (those stay on the host's own terminal / room file whitelist).
1385
+ ctl = startCtl({
1386
+ path: ctlPath,
1387
+ handlers: {
1388
+ // Go live: apply any valid overrides, dial the relay, and resolve on the FIRST
1389
+ // of room-granted / relay-refused / 15s timeout. Idempotent when already live.
1390
+ go: async (req) => {
1391
+ if (!opts.relay) return { ok: false, error: 'sharing disabled (--no-relay)' };
1392
+ if (state.room) return { ok: true, room: state.room, inviteUrl: inviteUrlStr ?? undefined };
1393
+ // Overrides (each validated; a bad value is ignored so the current setting stands).
1394
+ if (req.guests === 'viewer' || req.guests === 'prompter') opts.guests = req.guests;
1395
+ if (Number.isFinite(req.max) && Math.floor(req.max) > 0) opts.cap = Math.floor(req.max);
1396
+ if (typeof req.pass === 'string' && req.pass.length) opts.roomPassword = req.pass;
1397
+ goLive();
1398
+ if (!relay) return { ok: false, error: 'sharing unavailable' };
1399
+ // One-shot waiter over the live relay instance — do not restructure wireRelay,
1400
+ // just resolve on whichever event lands first. wireRelay's own onRoom runs
1401
+ // before this one (subscribed earlier), so inviteUrlStr is set by the time
1402
+ // we read it. onClose covers a dead/vetoed relay so `go` can't hang 15s.
1403
+ return await new Promise((resolve) => {
1404
+ let done = false;
1405
+ let offRoom, offRefused, offClose, timer;
1406
+ const finish = (v) => {
1407
+ if (done) return;
1408
+ done = true;
1409
+ offRoom?.();
1410
+ offRefused?.();
1411
+ offClose?.();
1412
+ clearTimeout(timer);
1413
+ resolve(v);
1414
+ };
1415
+ offRoom = relay.onRoom((code) => finish({ ok: true, room: code, inviteUrl: inviteUrlStr ?? inviteRoomUrl(code) }));
1416
+ offRefused = relay.onRefused((reason) => finish({ ok: false, error: `relay refused (${reason})` }));
1417
+ offClose = relay.onClose(() => finish({ ok: false, error: relayVetoed ? 'relay refused the connection' : 'relay unavailable' }));
1418
+ timer = setTimeout(() => finish({ ok: false, error: 'relay timeout' }), 15000);
1419
+ timer.unref?.();
1420
+ });
1421
+ },
1422
+ // Stop sharing: end the room, tear down the relay, clear the links + room file,
1423
+ // drop the band (repaint), and let a later `go` dial again. Idempotent.
1424
+ off: async () => {
1425
+ if (state.room || relay) {
1426
+ const r = relay;
1427
+ relay = null; // null first so the instance's onClose sees r !== relay (no reconnect)
1428
+ try {
1429
+ r?.end();
1430
+ } catch {}
1431
+ try {
1432
+ r?.close();
1433
+ } catch {}
1434
+ // Disarm any pending reclaim: a reconnect timer left ticking would dial
1435
+ // openRelay() after this teardown and HELLO a brand-new room with the band
1436
+ // hidden — an invisibly-live room, the one thing off() must make impossible.
1437
+ clearTimeout(reconnectTimer);
1438
+ reconnectTimer = null;
1439
+ reconnectAttempts = 0;
1440
+ state.setRoom(null);
1441
+ currentUrl = null;
1442
+ inviteUrlStr = null;
1443
+ clearRoomFile(roomFile);
1444
+ wantLive = false;
1445
+ goLiveStarted = false;
1446
+ // Purge every participant footprint — the process now OUTLIVES the room, so
1447
+ // stale guests would haunt the next go (wrong count, crushed size clamp,
1448
+ // orphaned drafts/queue items). forgetGuest also logs each departure.
1449
+ for (const g of [...state.guests()]) forgetGuest(g.id);
1450
+ pendingKnocks = [];
1451
+ knockInfo.clear();
1452
+ pointers.clear();
1453
+ hostTabIds.clear();
1454
+ hostTabSizes.clear();
1455
+ guestPartitioners.clear();
1456
+ repaintBand(); // band disappears; the child reclaims the row
1457
+ }
1458
+ return { ok: true };
1459
+ },
1460
+ // Query without changing anything. inviteUrl only — never the host URL/token.
1461
+ status: async () => ({
1462
+ ok: true,
1463
+ live: !!state.room,
1464
+ room: state.room ?? undefined,
1465
+ inviteUrl: inviteUrlStr ?? undefined,
1466
+ }),
1467
+ },
1468
+ });
1228
1469
 
1229
1470
  // ── hook events → brain ───────────────────────────────────────────────────────
1230
1471
  if (hooks) {
@@ -1233,21 +1474,30 @@ async function main() {
1233
1474
  clearSubmitWatch(); // the submission is confirmed — the optimistic busy is real
1234
1475
  claude.state = 'busy';
1235
1476
  armed = false;
1477
+ askInfo = null; // a new turn is running — forget the previous turn's ask/tool
1478
+ lastTool = null;
1236
1479
  repaintBand();
1237
1480
  });
1238
- hooks.on('idle', () => {
1481
+ hooks.on('idle', (payload) => {
1239
1482
  lastHookAt = Date.now();
1240
1483
  clearSubmitWatch();
1241
1484
  claude.state = 'idle';
1242
1485
  armed = false;
1486
+ askInfo = null; // the turn closed — the ask (if any) is answered
1487
+ lastTool = null;
1488
+ // Close the open turn with Claude's response, read cleanly from the session
1489
+ // transcript the Stop hook points us at (never scraped from the TUI).
1490
+ if (history.open) history.finish(captureResponse(payload));
1243
1491
  pump();
1244
1492
  repaintBand();
1245
1493
  });
1246
- hooks.on('ask', () => {
1494
+ hooks.on('ask', (payload) => {
1247
1495
  lastHookAt = Date.now();
1248
1496
  clearSubmitWatch();
1249
1497
  claude.state = 'ask';
1250
1498
  armed = true; // the ONLY thing that arms a composing-side y/n → Claude (spec: gate on events)
1499
+ // Capture WHAT Claude is asking (tool + short summary) for the browser ask card.
1500
+ askInfo = askContext(payload, lastTool);
1251
1501
  repaintBand();
1252
1502
  });
1253
1503
  hooks.on('tool', (p) => {
@@ -1258,11 +1508,13 @@ async function main() {
1258
1508
  // a real turn flips busy here instead; its Stop still returns to idle.
1259
1509
  if (claude.state === 'idle') claude.state = 'busy';
1260
1510
  const name = p?.tool_name ?? 'tool';
1261
- const files = [];
1262
1511
  const inp = p?.tool_input ?? {};
1512
+ const files = [];
1263
1513
  for (const key of ['file_path', 'path', 'notebook_path']) {
1264
1514
  if (typeof inp[key] === 'string') files.push(inp[key]);
1265
1515
  }
1516
+ // Remember this tool as the context for an ask that may follow this turn.
1517
+ lastTool = { name, summary: summarizeToolInput(inp) };
1266
1518
  log.tool(name, files);
1267
1519
  repaintBand();
1268
1520
  });
@@ -1298,6 +1550,9 @@ async function main() {
1298
1550
  try {
1299
1551
  hooks?.close();
1300
1552
  } catch {}
1553
+ try {
1554
+ ctl?.close(); // close the control socket and unlink its file
1555
+ } catch {}
1301
1556
  for (const f of [settingsFile, socketPath]) {
1302
1557
  if (f) {
1303
1558
  try {
@@ -1305,6 +1560,7 @@ async function main() {
1305
1560
  } catch {}
1306
1561
  }
1307
1562
  }
1563
+ clearRoomFile(roomFile); // the session is over — the link is dead (covers /end, exit, signals)
1308
1564
  stdout.write(TERM_RESTORE + '\r\n[claude-share exited]\r\n');
1309
1565
  process.exit(code ?? 0);
1310
1566
  };
@@ -1385,7 +1641,25 @@ async function main() {
1385
1641
  repaintBand(); // draw the band immediately, before the first frame
1386
1642
  }
1387
1643
 
1388
- main().catch((err) => {
1644
+ // `collab relay [args]` IS the relay (single-bin: npx resolves one bin). serve.js
1645
+ // runs on import and reads process.argv — splice ours out so its args line up.
1646
+ let run;
1647
+ const sub = process.argv[2];
1648
+ if (sub === 'relay') {
1649
+ process.argv.splice(2, 1);
1650
+ run = import('../../relay/bin/serve.js');
1651
+ } else if (sub === 'go' || sub === 'off' || sub === 'status') {
1652
+ // Run INSIDE a wrapped session: drive it via the control socket (CLAUDE_SHARE_CTL)
1653
+ // and print a human-readable result. ctlMain owns its own exit code.
1654
+ run = ctlMain(sub, process.argv.slice(3));
1655
+ } else if (sub === 'setup') {
1656
+ // Re-run the first-run screen + actions ignoring the marker; `--undo` reverses the
1657
+ // shim + plugin. setupMain owns its own exit code.
1658
+ run = setupMain(process.argv.slice(3));
1659
+ } else {
1660
+ run = main();
1661
+ }
1662
+ run.catch((err) => {
1389
1663
  process.stderr.write(`collab: ${err?.stack || err}\n`);
1390
1664
  process.exit(1);
1391
1665
  });
@@ -0,0 +1,67 @@
1
+ // What Claude is asking permission for — the context the browser's ask card shows.
2
+ //
3
+ // The permission gate arms on a `Notification` hook (notification_type ===
4
+ // 'permission_prompt'); that payload carries only a human `message` string (real
5
+ // Claude: "Claude needs your permission to use Bash"), never the tool's structured
6
+ // input. The concrete input (a command, a file path) is only known if a `tool`
7
+ // (PostToolUse) hook fired earlier in the SAME turn — the fake-claude fixture fires
8
+ // one, real Claude may not (there is no PreToolUse hook injected). So we take the
9
+ // best available: the last tool's name + input summary when we have it, otherwise
10
+ // the Notification message.
11
+ //
12
+ // Whatever we surface leaks no more than the host's own log already shows: it is
13
+ // stripControls'd and clamped, so a crafted tool input can never smuggle escape
14
+ // sequences or a wall of text into every guest's screen.
15
+
16
+ import { stripControls } from './log.js';
17
+
18
+ /** Max length of the ask summary shown on the card (spec: a short input summary). */
19
+ export const ASK_SUMMARY_MAX = 120;
20
+ const TOOL_MAX = 40;
21
+
22
+ // Input fields worth showing, most-useful first. A tool's summary is the first of
23
+ // these that is a non-empty string (a command, an edited file, a search pattern…).
24
+ const SUMMARY_FIELDS = ['command', 'file_path', 'path', 'notebook_path', 'pattern', 'prompt', 'url', 'description'];
25
+
26
+ /**
27
+ * Pull a short, safe summary string out of a tool's input object.
28
+ * @param {object} [toolInput]
29
+ * @returns {string}
30
+ */
31
+ export function summarizeToolInput(toolInput) {
32
+ const inp = toolInput && typeof toolInput === 'object' ? toolInput : {};
33
+ for (const key of SUMMARY_FIELDS) {
34
+ if (typeof inp[key] === 'string' && inp[key].trim() !== '') return inp[key];
35
+ }
36
+ return '';
37
+ }
38
+
39
+ // Collapse whitespace runs (newlines/tabs survive stripControls) to single spaces so
40
+ // the summary is one clean line, then strip controls and clamp.
41
+ function clean(s, max) {
42
+ return stripControls(String(s ?? ''))
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+ .slice(0, max);
46
+ }
47
+
48
+ // Best-effort tool name out of a permission message: "…use Bash", "allow Edit?".
49
+ function toolFromMessage(msg) {
50
+ const m = /\b(?:use|allow|run|permission (?:to use|for))\s+([A-Za-z][A-Za-z0-9_]*)/i.exec(String(msg ?? ''));
51
+ return m ? m[1] : '';
52
+ }
53
+
54
+ /**
55
+ * Build the ask context for the state snapshot from the arming Notification payload
56
+ * and the last tool seen this turn (either may be absent).
57
+ * @param {object} [payload] the 'ask' (Notification) hook payload
58
+ * @param {{name?:string, summary?:string}|null} [lastTool] last PostToolUse this turn
59
+ * @returns {{tool:string, summary:string}}
60
+ */
61
+ export function askContext(payload, lastTool) {
62
+ const msg = payload && typeof payload.message === 'string' ? payload.message : '';
63
+ const tool = (lastTool && lastTool.name) || toolFromMessage(msg) || 'tool';
64
+ // Prefer the concrete tool input we saw; else the Notification's human message.
65
+ const rawSummary = (lastTool && lastTool.summary) || msg || '';
66
+ return { tool: clean(tool, TOOL_MAX), summary: clean(rawSummary, ASK_SUMMARY_MAX) };
67
+ }