@claudecollab/cli 0.1.3 → 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.
@@ -24,6 +24,20 @@ const PASTE_START = '\x1b[200~';
24
24
  const PASTE_END = '\x1b[201~';
25
25
  export const NEW_DRAFT_BYTES = '\x0e'; // Ctrl+N — "start a fresh draft"
26
26
 
27
+ // The shared-view floor (mirrors FLOOR_COLS/FLOOR_ROWS in brain/state.js). A tab
28
+ // that measures below this is a phone-sized viewer: it reports the sentinel 0×0 so
29
+ // the host scales the mirror onto it instead of shrinking the room or parking it.
30
+ const FLOOR_COLS = 80;
31
+ const FLOOR_ROWS = 24;
32
+
33
+ // The bytes that answer Claude Code's permission ask, sent through the ordinary key
34
+ // channel (the role gate still applies). Real Claude v2 shows a numbered select —
35
+ // option 1 is always "Yes"; Esc is the "(esc)" No option, which denies regardless of
36
+ // how many options the prompt has. (Do NOT use "2" for deny — that is "Yes, and
37
+ // don't ask again", an APPROVE variant.) The fake-claude fixture accepts these too.
38
+ export const ASK_APPROVE_BYTES = '1';
39
+ export const ASK_DENY_BYTES = '\x1b';
40
+
27
41
  // ════════════════════════════════════════════════════════════════════════════
28
42
  // PURE HELPERS (exported; no DOM, no globals — safe to import in node)
29
43
  // ════════════════════════════════════════════════════════════════════════════
@@ -55,17 +69,17 @@ export function parseLocation(loc) {
55
69
 
56
70
  /**
57
71
  * Build the `/ws` query the web door expects. A host tab carries `host`; a guest
58
- * carries its browser `token` (returning identity), claimed `name`, and — for a
59
- * passworded room the join password `pass` (host tabs never need one).
72
+ * carries its browser `token` (returning identity) and claimed `name`.
73
+ * The room password NEVER rides the URL it is sent as the first WS message in
74
+ * reply to the relay's {t:'pass?'} challenge (proxy/access-log/history leak fix).
60
75
  * @returns {string} e.g. "/ws?room=brave-otter&name=sid&token=abc"
61
76
  */
62
- export function buildWsPath({ code, name, token, hostToken, pass, seat } = {}) {
77
+ export function buildWsPath({ code, name, token, hostToken, seat } = {}) {
63
78
  const q = new URLSearchParams();
64
79
  if (code) q.set('room', code);
65
80
  if (name) q.set('name', name);
66
81
  if (hostToken) q.set('host', hostToken);
67
82
  else if (token) q.set('token', token);
68
- if (pass && !hostToken) q.set('pass', pass);
69
83
  // The host-seat secret binds the host seat to THIS browser (leaked-link defense):
70
84
  // it never rides the shareable URL — the browser mints it and keeps it locally —
71
85
  // so a stolen host link opened elsewhere presents a different seat and is refused.
@@ -346,6 +360,8 @@ export function overlayView(state, selfId) {
346
360
  if (b) claimed.add(b.id);
347
361
  return b || null;
348
362
  };
363
+ // Turn history (who typed what + Claude's response), attributed by author.
364
+ const allTurns = Array.isArray(s.history) ? s.history : [];
349
365
  const panels = prompters.map((p) => {
350
366
  const box = windowBoxFor(p.id);
351
367
  return {
@@ -359,6 +375,14 @@ export function overlayView(state, selfId) {
359
375
  text: box?.text ?? '',
360
376
  typing: !!(box && (box.text || box.carets.some((c) => c.id === p.id))),
361
377
  pending: queue.filter((q) => q.author === p.id).map((q) => ({ n: q.n, text: q.text })),
378
+ turns: allTurns
379
+ .filter((t) => t.author === p.id)
380
+ .map((t) => ({
381
+ id: t.id,
382
+ prompt: t.prompt ?? '',
383
+ response: t.response ?? '',
384
+ running: !!t.running,
385
+ })),
362
386
  };
363
387
  });
364
388
  const floatingDrafts = drafts.filter((d) => !claimed.has(d.id));
@@ -377,6 +401,11 @@ export function overlayView(state, selfId) {
377
401
  queue,
378
402
  claudeState: s.claudeState || 'idle',
379
403
  claude: claudeLabel(s.claudeState),
404
+ // What Claude is asking permission for (only present while claudeState==='ask').
405
+ ask:
406
+ s.ask && typeof s.ask === 'object'
407
+ ? { tool: String(s.ask.tool || 'tool'), summary: String(s.ask.summary || '') }
408
+ : null,
380
409
  paused: !!s.paused,
381
410
  knocks,
382
411
  // The latest addressed notice ({id, msg, seq}) — shown as a toast when it's mine.
@@ -500,6 +529,13 @@ function main() {
500
529
  const newDraftBtn = $('#new-draft');
501
530
  const hostControls = $('#host-controls');
502
531
  const knocksBox = $('#knocks');
532
+ const askCard = $('#ask-card');
533
+ const askTool = $('#ask-tool');
534
+ const askSummary = $('#ask-summary');
535
+ const askActions = $('#ask-actions');
536
+ const askApprove = $('#ask-approve');
537
+ const askDeny = $('#ask-deny');
538
+ const askViewnote = $('#ask-viewnote');
503
539
  const avatarsBox = $('#avatars');
504
540
  const popover = $('#popover');
505
541
  const copyInviteBtn = $('#copy-invite');
@@ -547,12 +583,14 @@ function main() {
547
583
  timeout: 'The host did not answer in time. Try again when they are around.',
548
584
  denied: 'The host declined your request.',
549
585
  closed: 'The connection closed before you were let in — try again.',
586
+ busy: 'Too many people are waiting to join — try again in a minute.',
550
587
  // password gets its own copy in fail(): the message depends on whether we
551
588
  // had one to send ("needs a password" vs "wrong password").
552
589
  password: 'This room is password-protected.',
553
590
  };
554
591
 
555
592
  let sentPass = null; // what the last attempt presented (drives the fail() copy)
593
+ let pendingPass = ''; // the password to send when the relay challenges with {t:'pass?'}
556
594
 
557
595
  function beginKnock() {
558
596
  const code = (loc.code || codeInput.value || '').trim();
@@ -578,6 +616,9 @@ function main() {
578
616
 
579
617
  // ── connection ───────────────────────────────────────────────────────────────
580
618
  function connect({ code, name, token, hostToken, pass, seat }) {
619
+ // Stash the password to answer the relay's {t:'pass?'} challenge — it rides the
620
+ // wire, never the URL. Host tabs never present one.
621
+ pendingPass = hostToken ? '' : pass || '';
581
622
  // Supersede any previous socket COMPLETELY. A zombie from an earlier attempt
582
623
  // still has live handlers — when the brain dedupes its knock, its deny/close
583
624
  // would splash "declined" into a UI that belongs to the NEW attempt.
@@ -595,7 +636,7 @@ function main() {
595
636
  ? 'Opening your room…'
596
637
  : `Waiting for the host to let you in…`;
597
638
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
598
- const url = proto + '//' + location.host + buildWsPath({ code, name, token, hostToken, pass, seat });
639
+ const url = proto + '//' + location.host + buildWsPath({ code, name, token, hostToken, seat });
599
640
  let sock;
600
641
  try {
601
642
  sock = new WebSocket(url);
@@ -638,6 +679,12 @@ function main() {
638
679
  }
639
680
  if (!msg || typeof msg !== 'object') return;
640
681
  switch (msg.t) {
682
+ case 'pass?':
683
+ // The relay is challenging for the room password over the wire (never the
684
+ // URL). Answer with the stored/typed value; a wrong/empty one comes back as
685
+ // an {t:'error',reason:'password'} that reveals the field.
686
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t: 'pass', pass: pendingPass }));
687
+ break;
641
688
  case 'joined':
642
689
  // A host tab IS the host (the brain maps it to HOST_ID and never adds it as a
643
690
  // second participant — finding 4), so adopt the host's roster identity: the
@@ -787,6 +834,19 @@ function main() {
787
834
  // as a dead mouse report (stdin is disabled). Capture it before xterm and mail
788
835
  // it to the brain, which types the real report into the pty.
789
836
  stage.addEventListener('wheel', onStageWheel, { passive: false, capture: true });
837
+ // Copy Claude's output: xterm owns selection inside the mirror (it suppresses
838
+ // the native DOM selection there), so on Cmd/Ctrl+C with no page-text selected,
839
+ // put xterm's selection on the clipboard. A real DOM selection — a prompt or
840
+ // response highlighted in a window box — copies natively and is left alone.
841
+ document.addEventListener('copy', (e) => {
842
+ if (phase !== 'live' || !term) return;
843
+ const domSel = window.getSelection();
844
+ if (domSel && !domSel.isCollapsed && String(domSel).trim()) return;
845
+ if (term.hasSelection?.()) {
846
+ e.clipboardData?.setData('text/plain', term.getSelection());
847
+ e.preventDefault();
848
+ }
849
+ });
790
850
  }
791
851
 
792
852
  let wheelAcc = 0;
@@ -839,7 +899,11 @@ function main() {
839
899
  const { cw, ch } = refCellSize();
840
900
  const cols = Math.max(2, Math.floor((rect.width - 24) / cw));
841
901
  const rows = Math.max(1, Math.floor((rect.height - 20) / ch));
842
- sendMsg({ t: 'resize', cols, rows });
902
+ // Below the shared floor (a phone): report the sentinel 0×0 — "I scale, ignore my
903
+ // size" — so the host neither shrinks the room to us nor parks us on a hint. We
904
+ // keep rendering state.view zoomed (scaleTerm). Report real capacity otherwise.
905
+ const scaled = cols < FLOOR_COLS || rows < FLOOR_ROWS;
906
+ sendMsg({ t: 'resize', cols: scaled ? 0 : cols, rows: scaled ? 0 : rows });
843
907
  scaleTerm();
844
908
  }
845
909
 
@@ -1047,6 +1111,13 @@ function main() {
1047
1111
  setTimeout(() => composer.focus(), 0); // keys land in the new shared box
1048
1112
  });
1049
1113
 
1114
+ // Approve / Deny a permission ask: exactly the keystrokes a person at the terminal
1115
+ // would press, through the ordinary key channel — no new privileged action, so the
1116
+ // host's role gate (prompter+) applies untouched. Real Claude answers a numbered
1117
+ // select: "1" = Yes, Esc = the "(esc)" No option (see ASK_APPROVE/DENY_BYTES).
1118
+ askApprove.addEventListener('click', () => sendKey(ASK_APPROVE_BYTES));
1119
+ askDeny.addEventListener('click', () => sendKey(ASK_DENY_BYTES));
1120
+
1050
1121
  // ── host controls ────────────────────────────────────────────────────────────
1051
1122
  // Copy the SAFE, token-free invite link — never this tab's own host URL (finding 1).
1052
1123
  copyInviteBtn?.addEventListener('click', async () => {
@@ -1105,18 +1176,18 @@ function main() {
1105
1176
  endConfirm.append(el('span', null, 'Save session.md?'));
1106
1177
  const save = el('button', 'kbtn yes', 'Save & end');
1107
1178
  const skip = el('button', 'kbtn no', 'Just end');
1108
- // The save choice is resolved by the host CLI's /end confirmation; the browser
1109
- // fires /end either way (the two-step gate lives here so the host never has to
1110
- // touch the terminal). Both buttons end the room.
1111
- save.onclick = () => finishEnd();
1112
- skip.onclick = () => finishEnd();
1179
+ // The two-step gate lives here so the host never has to touch the terminal.
1180
+ // "Save & end" writes session.md (/end); "Just end" writes nothing
1181
+ // (/end nosave) so it can't overwrite an existing file. Both end the room.
1182
+ save.onclick = () => finishEnd(true);
1183
+ skip.onclick = () => finishEnd(false);
1113
1184
  endConfirm.append(save, skip);
1114
1185
  }
1115
1186
  }
1116
- function finishEnd() {
1187
+ function finishEnd(save) {
1117
1188
  endStep = 0;
1118
1189
  renderEndConfirm();
1119
- sendCommand('/end');
1190
+ sendCommand(save ? '/end' : '/end nosave');
1120
1191
  }
1121
1192
 
1122
1193
  // ── render ─────────────────────────────────────────────────────────────────
@@ -1158,6 +1229,7 @@ function main() {
1158
1229
  ghostHint(v);
1159
1230
  applyView(v);
1160
1231
  renderStatusbar(v);
1232
+ renderAsk(v);
1161
1233
  renderClaudeChip(v);
1162
1234
  renderPaused(v);
1163
1235
  renderPanels(v);
@@ -1194,6 +1266,23 @@ function main() {
1194
1266
  sbRole.append(b);
1195
1267
  }
1196
1268
 
1269
+ // The permission ask card — the touch-first approve/deny surface (all widths, the
1270
+ // one thing a phone most needs). Approve/Deny are ORDINARY keystrokes sent through
1271
+ // the key channel (the host's role gate applies); only people who may type see the
1272
+ // buttons, viewers see the card without them. The old amber pulse stays; its
1273
+ // "click to answer" pill is suppressed while the card is up (see .has-ask-card).
1274
+ function renderAsk(v) {
1275
+ const show = v.claudeState === 'ask' && !!v.ask;
1276
+ askCard.hidden = !show;
1277
+ stage.classList.toggle('has-ask-card', show);
1278
+ if (!show) return;
1279
+ askTool.textContent = v.ask.tool || 'tool';
1280
+ askSummary.textContent = v.ask.summary || '';
1281
+ askSummary.hidden = !v.ask.summary;
1282
+ askActions.hidden = !v.canCompose; // buttons only for prompter+ (role gate mirrors this)
1283
+ askViewnote.hidden = v.canCompose; // viewers see the card, but no buttons
1284
+ }
1285
+
1197
1286
  function renderClaudeChip(v) {
1198
1287
  claudeChip.className = 'chip claude ' + v.claude.kind;
1199
1288
  claudeChip.textContent = v.claude.text;
@@ -1229,9 +1318,11 @@ function main() {
1229
1318
 
1230
1319
  // ── per-user windows: everyone's own space to type a prompt SEPARATELY ────────
1231
1320
  // The panel DOM is rebuilt only when the roster changes; the dynamic bits (draft
1232
- // text, status, sent prompts) update in place so a focused input never loses focus.
1321
+ // text, status, history) update in place so a focused input never loses focus.
1233
1322
  let panelsKey = '';
1234
- const panelEls = new Map(); // participant id -> { input, body, status }
1323
+ const panelEls = new Map(); // participant id -> { input, body, status, bodyKey }
1324
+ const expandedTurns = new Set(); // turn ids the viewer expanded to full response
1325
+ const RESP_LIMIT = 280; // chars of a response shown before "show more"
1235
1326
  function renderPanels(v) {
1236
1327
  const key = v.panels.map((p) => `${p.id}:${p.name}:${p.color}:${p.canType}`).join('|');
1237
1328
  if (key !== panelsKey) {
@@ -1259,14 +1350,16 @@ function main() {
1259
1350
  wrap.append(input);
1260
1351
  win.append(head, body, wrap);
1261
1352
  panelsGrid.append(win);
1262
- panelEls.set(p.id, { input, body, status });
1353
+ panelEls.set(p.id, { input, body, status, bodyKey: null });
1263
1354
  }
1264
1355
  }
1265
1356
  for (const p of v.panels) {
1266
1357
  const e = panelEls.get(p.id);
1267
1358
  if (!e) continue;
1268
- e.status.textContent = p.typing ? 'typing…' : 'idle';
1359
+ const running = p.turns.some((t) => t.running);
1360
+ e.status.textContent = p.typing ? 'typing…' : running ? 'waiting…' : 'idle';
1269
1361
  e.status.classList.toggle('typing', p.typing);
1362
+ e.status.classList.toggle('waiting', !p.typing && running);
1270
1363
  // mirror the live draft into the input — the brain is the authority, so we
1271
1364
  // never echo locally; setting value keeps every tab showing the same text.
1272
1365
  if (e.input.value !== p.text) {
@@ -1281,41 +1374,110 @@ function main() {
1281
1374
  }
1282
1375
  }
1283
1376
  }
1284
- e.body.innerHTML = '';
1285
- if (p.pending.length === 0) {
1286
- e.body.append(
1287
- el('div', 'uw-empty', p.isSelf ? 'Your sent prompts show here. Enter to send.' : 'No prompts yet.'),
1288
- );
1289
- } else {
1290
- for (const q of p.pending) {
1291
- const line = el('div', 'uw-line sent');
1292
- line.append(el('span', 'uw-n', '#' + q.n), document.createTextNode('> ' + q.text));
1293
- e.body.append(line);
1377
+ renderPanelBody(e, p);
1378
+ }
1379
+ }
1380
+
1381
+ // Render one window's body: attributed history (prompt + Claude's response, with
1382
+ // a show-more toggle) then any still-queued prompts. Diff-guarded so an unrelated
1383
+ // state frame (a pointer move) never rebuilds it mid-selection and kills a copy.
1384
+ function renderPanelBody(e, p) {
1385
+ const expandedHere = p.turns.filter((t) => expandedTurns.has(t.id)).map((t) => t.id);
1386
+ const key = JSON.stringify({ turns: p.turns, pending: p.pending, expandedHere, self: p.isSelf });
1387
+ if (key === e.bodyKey) return;
1388
+ // Don't clobber an active selection inside this body (the viewer is copying).
1389
+ const sel = window.getSelection();
1390
+ if (e.bodyKey !== null && sel && !sel.isCollapsed && e.body.contains(sel.anchorNode)) return;
1391
+ // "stick to bottom" only when already near the bottom before the rebuild.
1392
+ const stick = e.bodyKey === null || e.body.scrollHeight - e.body.scrollTop - e.body.clientHeight < 40;
1393
+ e.bodyKey = key;
1394
+ e.body.innerHTML = '';
1395
+
1396
+ if (p.turns.length === 0 && p.pending.length === 0) {
1397
+ e.body.append(
1398
+ el('div', 'uw-empty', p.isSelf ? 'Your prompts and Claude’s replies show here. Enter to send.' : 'No prompts yet.'),
1399
+ );
1400
+ return;
1401
+ }
1402
+
1403
+ for (const t of p.turns) {
1404
+ const turn = el('div', 'uw-turn');
1405
+ const promptLine = el('div', 'uw-line uw-prompt');
1406
+ promptLine.append(el('span', 'uw-caret', '›'), document.createTextNode(' ' + t.prompt));
1407
+ turn.append(promptLine);
1408
+
1409
+ if (t.running) {
1410
+ turn.append(el('div', 'uw-resp running', 'Claude is working…'));
1411
+ } else if (t.response) {
1412
+ const resp = el('div', 'uw-resp');
1413
+ const long = t.response.length > RESP_LIMIT;
1414
+ const expanded = expandedTurns.has(t.id);
1415
+ const shown = long && !expanded ? t.response.slice(0, RESP_LIMIT).trimEnd() + '…' : t.response;
1416
+ resp.append(document.createTextNode(shown));
1417
+ if (long) {
1418
+ const more = el('button', 'uw-more', expanded ? 'show less' : 'show more');
1419
+ more.type = 'button';
1420
+ more.onclick = () => {
1421
+ if (expandedTurns.has(t.id)) expandedTurns.delete(t.id);
1422
+ else expandedTurns.add(t.id);
1423
+ render();
1424
+ };
1425
+ resp.append(more);
1294
1426
  }
1427
+ turn.append(resp);
1295
1428
  }
1429
+ e.body.append(turn);
1296
1430
  }
1431
+
1432
+ for (const q of p.pending) {
1433
+ const line = el('div', 'uw-line uw-queued');
1434
+ line.append(el('span', 'uw-n', '#' + q.n), document.createTextNode(' ' + q.text));
1435
+ line.append(el('span', 'uw-badge', 'queued'));
1436
+ e.body.append(line);
1437
+ }
1438
+
1439
+ if (stick) e.body.scrollTop = e.body.scrollHeight;
1440
+ }
1441
+
1442
+ // The host routes a prompter's keys straight to Claude whenever they have no
1443
+ // draft box open ("you're at the terminal"). A window input must therefore
1444
+ // GUARANTEE a box exists before its keystrokes land, or the text leaks into
1445
+ // Claude (and is never recorded as that person's turn). After each send the box
1446
+ // is consumed, so we re-arm on the next keystroke. `draftArmed` bridges the state
1447
+ // round-trip so we never spawn a second empty box while the first is in flight.
1448
+ let draftArmed = false;
1449
+ function myWindowBox(v) {
1450
+ const mine = v?.panels.find((p) => p.isSelf);
1451
+ if (!mine?.boxId) return null;
1452
+ return v.drafts.find((d) => d.id === mine.boxId && !d.place) || null;
1453
+ }
1454
+ function ensureMyDraft() {
1455
+ const v = lastState ? overlayView(lastState, selfId) : null;
1456
+ const box = myWindowBox(v);
1457
+ if (box) {
1458
+ draftArmed = false;
1459
+ // keep my caret in my own window box (not a floating box I also co-wrote)
1460
+ if (!box.carets.some((c) => c.self)) {
1461
+ sendMsg({ t: 'ui', action: { kind: 'caret', id: box.id, offset: box.text.length } });
1462
+ }
1463
+ return;
1464
+ }
1465
+ if (draftArmed) return; // already asked for one; it just hasn't echoed back yet
1466
+ sendKey(NEW_DRAFT_BYTES);
1467
+ draftArmed = true;
1297
1468
  }
1298
1469
 
1299
1470
  // Wire a self window input: keys route to the brain (which owns the draft), so the
1300
1471
  // input is a controlled mirror of my private box. Enter sends it into the queue.
1301
1472
  function wirePanelInput(input) {
1302
- input.addEventListener('focus', () => {
1303
- const v = lastState ? overlayView(lastState, selfId) : null;
1304
- const mine = v?.panels.find((p) => p.isSelf);
1305
- if (!mine) return;
1306
- if (mine.boxId) {
1307
- // make sure my caret sits in my window box (not a floating box I co-wrote)
1308
- const box = v.drafts.find((d) => d.id === mine.boxId);
1309
- const caretHere = box?.carets.some((c) => c.self);
1310
- if (!caretHere) sendMsg({ t: 'ui', action: { kind: 'caret', id: mine.boxId, offset: mine.text.length } });
1311
- } else {
1312
- sendKey(NEW_DRAFT_BYTES); // no private draft yet → start one
1313
- }
1314
- });
1473
+ input.addEventListener('focus', () => ensureMyDraft());
1315
1474
  input.addEventListener('keydown', (e) => {
1316
1475
  const bytes = keyToBytes(e); // Enter → send, editing chords, printable chars
1317
1476
  if (bytes != null) {
1318
1477
  e.preventDefault();
1478
+ // Ordered on the wire: the new-draft byte lands before this key, so the key
1479
+ // always composes into my box instead of leaking to Claude.
1480
+ if (e.key !== 'Escape') ensureMyDraft();
1319
1481
  sendKey(bytes);
1320
1482
  return;
1321
1483
  }
@@ -1325,7 +1487,10 @@ function main() {
1325
1487
  input.addEventListener('paste', (e) => {
1326
1488
  e.preventDefault();
1327
1489
  const text = (e.clipboardData || window.clipboardData)?.getData('text') || '';
1328
- if (text) sendKey(pasteBytes(text));
1490
+ if (text) {
1491
+ ensureMyDraft();
1492
+ sendKey(pasteBytes(text));
1493
+ }
1329
1494
  });
1330
1495
  // never let a local echo diverge from the brain's authoritative text
1331
1496
  input.addEventListener('input', () => {
@@ -85,6 +85,21 @@
85
85
  </div>
86
86
 
87
87
  <main class="workspace">
88
+ <!-- permission ask: the one thing a phone most needs to answer. Big Approve/
89
+ Deny buttons; on desktop it floats over the terminal, on narrow it leads
90
+ the column. Buttons send ordinary keystrokes (the role gate still applies). -->
91
+ <div id="ask-card" class="ask-card" hidden>
92
+ <div class="ask-head">
93
+ <span class="ask-badge">permission</span>
94
+ <span>Claude wants to use <b id="ask-tool">tool</b></span>
95
+ </div>
96
+ <div id="ask-summary" class="ask-summary" hidden></div>
97
+ <div id="ask-actions" class="ask-actions" hidden>
98
+ <button id="ask-approve" class="ask-btn yes" type="button">Approve</button>
99
+ <button id="ask-deny" class="ask-btn no" type="button">Deny</button>
100
+ </div>
101
+ <div id="ask-viewnote" class="ask-viewnote" hidden>waiting for someone who can type to answer</div>
102
+ </div>
88
103
  <!-- Claude's live session: the shared terminal everyone watches respond.
89
104
  Floating "+ draft" boxes (write a prompt TOGETHER) overlay it. -->
90
105
  <div id="stage">