@claudecollab/cli 0.1.0 → 0.1.2

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.
package/README.md CHANGED
@@ -1,109 +1,111 @@
1
- # claude-share
1
+ # ✦ claudecollab
2
2
 
3
- **Make your Claude Code session multiplayer.** You run one command. Friends open a link no install and drive the same Claude with you: live cursors, shared drafts, a visible queue, roles.
3
+ **Use Claude Code with your friends.** You run one command. Your friends open a link and can collaborate with the same Claude as you.
4
4
 
5
- Think **screen-share where they can type too**.
6
-
7
- ```mermaid
8
- flowchart LR
9
- H["you (host)<br/>terminal runs Claude"] <--> R["relay<br/>forwards bytes, stores nothing"]
10
- R <--> HT["your host tab<br/>(admit · roles · pause · end)"]
11
- R <--> G["friends' browsers"]
12
5
  ```
13
-
14
- Your terminal stays plain Claude plus one status line. Everything multiplayer happens in the browser.
15
-
16
- ## Run it locally
17
-
18
- ```bash
19
- npm install
6
+ You a helper server Your friends
7
+ (you run Claude) <--> (passes messages) <--> (open a link)
20
8
  ```
21
9
 
22
- **Terminal 1 relay:**
23
-
24
- ```bash
25
- node packages/relay/bin/serve.js
26
- ```
10
+ Your terminal looks like normal Claude, just one extra line at the bottom. All the sharing happens in the web browser.
27
11
 
28
- **Terminal 2 host:**
12
+ ## Start a session
29
13
 
30
14
  ```bash
31
- node packages/cli/bin/claude-share.js --relay ssh://127.0.0.1:2222
15
+ npm install -g @claudecollab/cli
16
+ collab
32
17
  ```
33
18
 
34
- Open the link from the status line that's your host tab. The invite link is on your clipboard.
19
+ That's it. Run `collab` instead of `claude`. It works the same, but now it makes two links:
35
20
 
36
- ## Deploy the relay (permanent links)
21
+ - A **private link** — that's your control panel (you let people in and manage them).
22
+ - A **share link** — this one gets copied for you. Send it to your friends.
37
23
 
38
- ```bash
39
- fly launch --no-deploy # keep the provided fly.toml
40
- fly secrets set HOST_KEY="$(node packages/relay/bin/serve.js --make-key)"
41
- fly secrets set ROOM_SECRET="$(openssl rand -hex 16)"
42
- fly deploy
43
- ```
44
-
45
- Then host with `--relay ssh://your-app.fly.dev:2222`. Links print your public https origin automatically.
46
-
47
- Two protections kick in on a deployed relay:
48
-
49
- - **Room secret** — with `ROOM_SECRET` set, only hosts that present it (`CLAUDE_SHARE_SECRET` env or `--secret`) can create rooms; strangers who find your relay can't. Guests are unaffected — they enter with a room link.
50
- - **Identity pinning** — the CLI pins the relay's ssh key fingerprint on first connect (like ssh's `known_hosts`) and refuses to connect if it ever changes, so nobody can impersonate your relay. The relay prints its fingerprint at boot; pass it as `--fingerprint SHA256:…` to pin explicitly. Loopback relays are exempt (dev relays regenerate keys).
24
+ Your friends just open the share link in a browser. They don't install anything.
51
25
 
52
26
  ## Two links — don't mix them up
53
27
 
54
28
  | Link | Who it's for |
55
29
  |---|---|
56
- | `…/room?host=abc` | **you only** opening it grants the host seat |
57
- | `…/room` | **share this** friends land on the request-to-join flow |
30
+ | the one ending in `?host=…` | **Just you.** Opening it makes you the boss of the room. |
31
+ | the plain one | **Share this.** Friends use it to ask to join. |
58
32
 
59
- The Invite button and your clipboard always hold the safe one.
33
+ The Invite button adds the plain one to your clipboard.
60
34
 
61
- Want a lock in front of the knock? Host with `--room-password <pw>` guests must
62
- enter it (browser field / ssh prompt) before their request ever reaches you.
63
- Admitting stays your call either way.
35
+ Want an extra guardrail? Start with `collab --room-password <pw>`. Then friends have to type the password before they can even ask to join. You still choose who gets admitted.
64
36
 
65
37
  ## Roles
66
38
 
67
- | Role | See | Type · answer asks · slash/bash | Admit · kick · pause · end |
39
+ | Role | Can watch | Can type | Can run the room |
68
40
  |---|---|---|---|
69
- | 👁 viewer | | | |
70
- | ✎ prompter *(default)* | | | |
71
- | ★ host | | | |
41
+ | 👁 viewer | yes | no | no |
42
+ | ✎ prompter *(normal)* | yes | yes | no |
43
+ | ★ host *(you)* | yes | yes | yes |
44
+
45
+ "Run the room" means let people in, remove them, pause, or end it.
72
46
 
73
- ## How it feels
47
+ ## How it works
74
48
 
75
- - The whole page is the terminal. Type with no draft open keys go **straight into Claude** menus, asks, everything.
76
- - **+ draft** (or double-click) opens a floating glass box. Everyone's caret shows; Enter sends; Esc steps out; drag to move, ◢ to resize, ✕ to delete.
77
- - Sent while Claude is busy? It waits in the **queue** chip edit or delete before it fires.
78
- - Scroll the mirror like a real terminal. Scrolling is shared — one screen for everyone.
79
- - Reloaded guests glide straight back in. Kicked guests are out for good.
49
+ - Everyone who can type gets their own little box at the bottom to write in. Type there and press Enter to send. Your box stays open, so you can keep going. If Claude is busy, your message will get queued.
50
+ - Want to write a message *together* with someone? Click **+ draft** to open a shared box you both type in.
51
+ - To talk to Claude directly (to approve y/n questions, answer questions, etc) click Claude's CLI text line.
52
+ - Everyone sees the same screen, and scrolling is shared.
80
53
 
81
- ## The one thing to really understand
54
+ ## Important note
82
55
 
83
- A guest prompt **runs on your machine, as you**. Admitting someone = trusting them with your Claude under the current mode. The relay sees your screen — treat a room like a screen-share, not a vault. You have **Pause** and **End** (with an optional `session.md` receipt).
56
+ When a friend types something, **it runs on your computer, as if you typed it.** So only let in people you trust. You can hit **Pause** anytime, or **End** to close the room.
84
57
 
85
- ## Tests
58
+ ## Run your own server (optional)
59
+
60
+ By default, `collab` uses our free server at claudecollab.org. Want to run your own instead? The same install includes it:
86
61
 
87
62
  ```bash
88
- npm test
63
+ collab-relay # start your own server
64
+ collab --relay ssh://127.0.0.1:2222 # use it
89
65
  ```
90
66
 
67
+ To put your server online for good (on Fly.io or any host — see `fly.toml`):
68
+
69
+ ```bash
70
+ fly launch --no-deploy
71
+ fly secrets set HOST_KEY="$(collab-relay --make-key)"
72
+ fly secrets set ROOM_SECRET="$(openssl rand -hex 16)" # optional lock (see below)
73
+ fly deploy
74
+ ```
75
+
76
+ Two safety features come built in:
77
+
78
+ - **Room password** (`ROOM_SECRET`) — if you set this, only people who know it can *start* rooms on your server. People *joining* a room don't need it.
79
+ - **Server ID check** the first time you connect, the app remembers your server's ID. If it ever changes, it stops and warns you, so nobody can pretend to be your server.
80
+
81
+ ## All the options
82
+
83
+ | Option | What it does |
84
+ |---|---|
85
+ | `--relay <url>` | which server to use; defaults to ours |
86
+ | `--no-relay` | go solo — just Claude, no room, no sharing |
87
+ | `--room-password <pw>` | friends must type this before they can ask to join |
88
+ | `--guests <role>` | what new people can do when let in (default: prompter) |
89
+ | `--secret <s>` | the password for a locked server (or use `CLAUDE_SHARE_SECRET`) |
90
+ | `--fingerprint <fp>` | lock onto a specific server's ID |
91
+ | `--cmd <program>` | run something other than `claude` |
92
+ | `-- <args…>` | anything after `--` is passed to the program you're running |
93
+
91
94
  <details>
92
- <summary>Architecture</summary>
95
+ <summary>How it's built</summary>
93
96
 
94
- Three packages, plain ESM JavaScript, Node 22, no build step.
97
+ Three parts, plain JavaScript, no build step:
95
98
 
96
99
  ```
97
- packages/shared/ protocol.js host↔relay wire messages
98
- packages/relay/ server.js (ssh door) · web.js (browser door) · public/ (client)
99
- packages/cli/ bin/claude-share.js the "brain": drafts, queue, roles, gate
100
- test/ end-to-end: host + host tab + guest on localhost
100
+ packages/shared/ the messages the app and server send each other
101
+ packages/relay/ the server (one door for terminals, one for browsers)
102
+ packages/cli/ the main program you run the brain
101
103
  ```
102
104
 
103
- - The CLI wraps Claude in a PTY one row short — the bottom row is the status line.
104
- - State (busy/idle/ask) comes from injected Claude Code hooks, never screen-scraping. Ambiguity fails closed.
105
- - The relay stores nothing: kill it and the host reconnects; kill the host and the room is over.
106
-
107
105
  </details>
108
106
 
109
- **TL;DR:** relay up → `claude-share` → open your link, share the invite. Guests need nothing. Roles keep control; prompts are real — admit people you trust.
107
+ ## License
108
+
109
+ [MIT](LICENSE) — free to use and change. This is an independent project, not made or approved by Anthropic.
110
+
111
+ Contributions are welcome! Please open a PR and add @ir272 as a reviewer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudecollab/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -119,9 +119,38 @@ async function main() {
119
119
  const BAND_ROWS = 1;
120
120
  const multiplayer = opts.relay; // the guest composer/gate + mirror are active only when sharing
121
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.
122
+ // `webhost:<token>:<seat>`, and the brain auto-admits it as host. The token goes in
123
+ // the room URL we print/copy; the SEAT does not the browser mints it locally, so
124
+ // possessing the (leak-prone) URL is not enough to take the host seat.
124
125
  const hostToken = randomBytes(16).toString('hex');
126
+ const HOST_FP_PREFIX = `webhost:${hostToken}`;
127
+ // The seat this session is bound to: the first host browser's seat secret claims
128
+ // it; later browsers presenting the token with a different/absent seat are refused
129
+ // (leaked-link defense). Reset by a host-terminal Ctrl-G handoff (see below).
130
+ let boundSeat = null;
131
+ // Parse a knock fingerprint: null ⇒ not a host tab; '' ⇒ host token but NO seat
132
+ // (a raw link opened without our client, or a forgery) ⇒ refused; else the seat.
133
+ const hostSeatOf = (fp) => {
134
+ if (typeof fp !== 'string' || !fp.startsWith(HOST_FP_PREFIX)) return null;
135
+ const rest = fp.slice(HOST_FP_PREFIX.length);
136
+ return rest.startsWith(':') ? rest.slice(1) : '';
137
+ };
138
+ // Is this fingerprint the bound host tab (or the first to claim the empty seat)?
139
+ const isBoundHost = (fp) => {
140
+ const seat = hostSeatOf(fp);
141
+ return seat !== null && seat !== '' && seat === boundSeat;
142
+ };
143
+ // Handoff window: after a mismatched host attempt is refused, pressing Ctrl-G in
144
+ // the host TERMINAL (which no remote participant can reach) releases the seat so
145
+ // the next device claims it. Armed briefly and only then; zero interception else.
146
+ let handoffTimer = null;
147
+ const armHandoff = () => {
148
+ clearTimeout(handoffTimer);
149
+ handoffTimer = setTimeout(() => {
150
+ handoffTimer = null;
151
+ }, 60_000);
152
+ handoffTimer.unref?.();
153
+ };
125
154
  // The current live Claude screen, cached so a joiner sees it instantly (finding 1).
126
155
  const snapshot = new ScreenSnapshot();
127
156
 
@@ -192,7 +221,10 @@ async function main() {
192
221
  } catch (err) {
193
222
  process.stderr.write(
194
223
  `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',
224
+ 'A "posix_spawnp failed" here usually means node-pty\'s spawn-helper lost its execute bit\n' +
225
+ '(package managers that skip install scripts do this). Fix:\n' +
226
+ ' chmod +x "$(npm root -g)/@claudecollab/cli/node_modules/node-pty/prebuilds/"*/spawn-helper\n' +
227
+ 'Otherwise check that the wrapped command exists on your PATH.\n',
196
228
  );
197
229
  process.exit(1);
198
230
  }
@@ -509,6 +541,17 @@ async function main() {
509
541
 
510
542
  if (cls.kind === 'command') return handleCommand(userId, text);
511
543
 
544
+ // Pause must freeze EVERY guest path to Claude, not just raw keystrokes
545
+ // (onKey already bails on pause). A crafted {ui,command} rides the one channel
546
+ // left open during pause — handleUi allows kind:'command' so the host can
547
+ // /resume — and those management commands returned above via handleCommand.
548
+ // Anything still here is Claude-bound (prompt / claude-slash / bash) and must
549
+ // be frozen, or a prompter could keep driving Claude while sharing reads paused.
550
+ if (state.paused) {
551
+ notify(userId, 'sharing is paused — the host will resume shortly');
552
+ return;
553
+ }
554
+
512
555
  if (!sendAllowed(cls.kind, role)) {
513
556
  const what = cls.kind === 'bash' ? 'run bash' : cls.kind === 'claude-slash' ? 'use slash commands' : 'send that';
514
557
  notify(userId, `you can't ${what} — ask the host for a role that can type`);
@@ -767,7 +810,7 @@ async function main() {
767
810
  r.admit(knock.id);
768
811
  // The host's own tab IS the host, not a room member (finding 4): no roster entry,
769
812
  // no context card. It still mirrors the live screen, so it does get the snapshot.
770
- if (knock.fp === `webhost:${hostToken}`) {
813
+ if (isBoundHost(knock.fp)) {
771
814
  hostTabIds.add(knock.id);
772
815
  knockInfo.set(knock.id, { name: knock.name, fp: knock.fp, role: 'host' });
773
816
  if (!state.paused) {
@@ -1001,11 +1044,26 @@ async function main() {
1001
1044
  repaintBand();
1002
1045
  });
1003
1046
  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);
1047
+ // The host's own browser tab knocks with fp `webhost:<token>:<seat>`. The seat
1048
+ // binds the host seat to the FIRST browser to present one; later browsers with
1049
+ // the token but a different/absent seat are refused (a leaked host LINK carries
1050
+ // the token but not the seat). This is the one knock answered with no ui action.
1051
+ const hostSeat = hostSeatOf(knock.fp);
1052
+ if (hostSeat !== null) {
1053
+ if (boundSeat === null && hostSeat !== '') {
1054
+ boundSeat = hostSeat; // first claim wins
1055
+ log.event('host seat claimed');
1056
+ }
1057
+ if (isBoundHost(knock.fp)) {
1058
+ admitAndCatchUp(r, knock);
1059
+ return;
1060
+ }
1061
+ // Token but wrong/empty seat: refuse host access. Arm a handoff so the real
1062
+ // host — if it IS them on a new device — can release the seat with Ctrl-G.
1063
+ r.deny(knock.id);
1064
+ armHandoff();
1065
+ showToast('⚠ a device opened your host link but is not your bound host — refused. If it is you, press Ctrl-G here to hand over the host seat.', 20000);
1066
+ log.event('refused a host-link opener with the wrong seat');
1009
1067
  return;
1010
1068
  }
1011
1069
  // A guest admitted earlier this session (same browser token) walks straight
@@ -1293,6 +1351,20 @@ async function main() {
1293
1351
  const { chatter, human } = partitionHostInput(d);
1294
1352
  if (chatter) pty.write(Buffer.from(chatter, 'binary'));
1295
1353
  if (human) {
1354
+ // Ctrl-G (BEL) releases the bound host seat — but ONLY while a handoff is
1355
+ // armed (right after a blocked device tried to claim), so a stray BEL never
1356
+ // gets eaten during normal use. This lives on host stdin, which no remote
1357
+ // participant can reach — that is what makes the release host-only.
1358
+ if (handoffTimer && human.includes('\x07')) {
1359
+ clearTimeout(handoffTimer);
1360
+ handoffTimer = null;
1361
+ boundSeat = null;
1362
+ showToast('host seat released — reload your other device to claim it', 12000);
1363
+ log.event('host released the seat via Ctrl-G handoff');
1364
+ const stripped = human.split('\x07').join('');
1365
+ if (stripped) pty.write(Buffer.from(stripped, 'binary'));
1366
+ return;
1367
+ }
1296
1368
  sniffInterrupt(human); // the host's own Esc interrupt is hookless too
1297
1369
  pty.write(Buffer.from(human, 'binary'));
1298
1370
  }
@@ -426,7 +426,16 @@ export class Drafts {
426
426
  const box = this.activeBox(userId);
427
427
  if (!box || box.atoms.length === 0) return { send: null, changed: false };
428
428
  const send = { text: expandedText(box), author: userId, authors: [...box.authors], boxId: box.id };
429
- this.boxes = this.boxes.filter((b) => b !== box); // the sent draft vanishes, cursors and all
429
+ // A solo window box (unplaced, only its owner in it) is the per-user bottom-row
430
+ // composer: empty it but KEEP it so the owner can fire the next prompt right away
431
+ // (it queues behind the running one) instead of falling through to Claude. Placed
432
+ // or co-written boxes are one-shot and still vanish, cursors and all.
433
+ if (box.place == null && box.cursors.size === 1) {
434
+ box.atoms = [];
435
+ box.cursors.set(userId, 0);
436
+ } else {
437
+ this.boxes = this.boxes.filter((b) => b !== box);
438
+ }
430
439
  return { send, changed: true };
431
440
  }
432
441
 
File without changes
@@ -59,13 +59,17 @@ export function parseLocation(loc) {
59
59
  * passworded room — the join password `pass` (host tabs never need one).
60
60
  * @returns {string} e.g. "/ws?room=brave-otter&name=sid&token=abc"
61
61
  */
62
- export function buildWsPath({ code, name, token, hostToken, pass } = {}) {
62
+ export function buildWsPath({ code, name, token, hostToken, pass, seat } = {}) {
63
63
  const q = new URLSearchParams();
64
64
  if (code) q.set('room', code);
65
65
  if (name) q.set('name', name);
66
66
  if (hostToken) q.set('host', hostToken);
67
67
  else if (token) q.set('token', token);
68
68
  if (pass && !hostToken) q.set('pass', pass);
69
+ // The host-seat secret binds the host seat to THIS browser (leaked-link defense):
70
+ // it never rides the shareable URL — the browser mints it and keeps it locally —
71
+ // so a stolen host link opened elsewhere presents a different seat and is refused.
72
+ if (seat && hostToken) q.set('seat', seat);
69
73
  return '/ws?' + q.toString();
70
74
  }
71
75
 
@@ -328,6 +332,37 @@ export function overlayView(state, selfId) {
328
332
  seenLabel: seenLabel(k.seen),
329
333
  }));
330
334
 
335
+ // Two composing surfaces (the windows UI):
336
+ // • PANELS — one per person who can type. A panel shows that user's PRIVATE
337
+ // draft: a home (place==null) box only they occupy → their own space to type
338
+ // a prompt SEPARATELY.
339
+ // • FLOATING drafts — any box with a place (opened via "+ draft") or co-written
340
+ // by 2+ people → floats over the columns for writing a prompt TOGETHER.
341
+ const prompters = participants.filter((p) => p.role !== 'viewer');
342
+ const claimed = new Set();
343
+ const windowBoxFor = (uid) => {
344
+ let b = drafts.find((d) => !d.place && !claimed.has(d.id) && d.carets.some((c) => c.id === uid));
345
+ if (!b) b = drafts.find((d) => !d.place && !claimed.has(d.id) && d.authors.length === 1 && d.authors[0].id === uid);
346
+ if (b) claimed.add(b.id);
347
+ return b || null;
348
+ };
349
+ const panels = prompters.map((p) => {
350
+ const box = windowBoxFor(p.id);
351
+ return {
352
+ id: p.id,
353
+ name: p.name,
354
+ color: p.color,
355
+ role: p.role,
356
+ isSelf: p.id === selfId,
357
+ canType: p.id === selfId && caps.canCompose,
358
+ boxId: box?.id ?? null,
359
+ text: box?.text ?? '',
360
+ typing: !!(box && (box.text || box.carets.some((c) => c.id === p.id))),
361
+ pending: queue.filter((q) => q.author === p.id).map((q) => ({ n: q.n, text: q.text })),
362
+ };
363
+ });
364
+ const floatingDrafts = drafts.filter((d) => !claimed.has(d.id));
365
+
331
366
  return {
332
367
  room: s.room || '',
333
368
  selfId: selfId || null,
@@ -337,6 +372,8 @@ export function overlayView(state, selfId) {
337
372
  participants: participants.map((p) => ({ ...p, isSelf: p.id === selfId })),
338
373
  othersPointers,
339
374
  drafts,
375
+ panels,
376
+ floatingDrafts,
340
377
  queue,
341
378
  claudeState: s.claudeState || 'idle',
342
379
  claude: claudeLabel(s.claudeState),
@@ -360,6 +397,12 @@ function main() {
360
397
  // Accepted room password, stored per room so a reload glides straight back in
361
398
  // (same promise as the token). Cleared the moment the relay rejects it.
362
399
  const passKey = (code) => 'claude-share:pass:' + code;
400
+ // Per-browser host-seat secret (leaked-link defense). Minted once and kept in
401
+ // this browser's storage; presented only when opening a host tab. A stolen host
402
+ // link opened in another browser mints a DIFFERENT one, so the host CLI (which
403
+ // binds the seat to the first it sees) refuses it. Reloads reuse it, so the real
404
+ // host glides back into the seat.
405
+ const SEAT_KEY = 'claude-share:seat';
363
406
  const POINTER_HZ = 30; // send own pointer ≤ 30/s (spec)
364
407
  const POINTER_MS = Math.ceil(1000 / POINTER_HZ);
365
408
 
@@ -394,6 +437,18 @@ function main() {
394
437
  return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
395
438
  });
396
439
 
440
+ // The per-browser host-seat secret: minted once, reused forever (so reloads keep
441
+ // the seat). Both host-tab entry points — the auto-connect on load and beginKnock
442
+ // — must send it, or the CLI (which requires a seat to grant host) refuses.
443
+ const mintSeat = () => {
444
+ let s = store.get(SEAT_KEY);
445
+ if (!s) {
446
+ s = uuid();
447
+ store.set(SEAT_KEY, s);
448
+ }
449
+ return s;
450
+ };
451
+
397
452
  // ── app state ───────────────────────────────────────────────────────────────
398
453
  const loc = parseLocation(window.location);
399
454
  let ws = null;
@@ -438,6 +493,7 @@ function main() {
438
493
  const pausedCard = $('#paused');
439
494
  const claudeChip = $('#claude-chip');
440
495
  const draftsLayer = $('#drafts');
496
+ const panelsGrid = $('#panels');
441
497
  const queueChip = $('#queue-chip');
442
498
  const queuePop = $('#queue-pop');
443
499
  const composer = $('#composer');
@@ -515,11 +571,13 @@ function main() {
515
571
  const pass = typed || store.get(passKey(code)) || '';
516
572
  sentPass = pass || null;
517
573
  if (typed) store.set(passKey(code), typed);
518
- connect({ code, name, token, hostToken: loc.hostToken, pass });
574
+ // Host tabs carry the per-browser seat secret (minted once, kept locally).
575
+ const seat = loc.hostToken ? mintSeat() : null;
576
+ connect({ code, name, token, hostToken: loc.hostToken, pass, seat });
519
577
  }
520
578
 
521
579
  // ── connection ───────────────────────────────────────────────────────────────
522
- function connect({ code, name, token, hostToken, pass }) {
580
+ function connect({ code, name, token, hostToken, pass, seat }) {
523
581
  // Supersede any previous socket COMPLETELY. A zombie from an earlier attempt
524
582
  // still has live handlers — when the brain dedupes its knock, its deny/close
525
583
  // would splash "declined" into a UI that belongs to the NEW attempt.
@@ -537,7 +595,7 @@ function main() {
537
595
  ? 'Opening your room…'
538
596
  : `Waiting for the host to let you in…`;
539
597
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
540
- const url = proto + '//' + location.host + buildWsPath({ code, name, token, hostToken, pass });
598
+ const url = proto + '//' + location.host + buildWsPath({ code, name, token, hostToken, pass, seat });
541
599
  let sock;
542
600
  try {
543
601
  sock = new WebSocket(url);
@@ -648,8 +706,12 @@ function main() {
648
706
  if (!term) createTerm();
649
707
  fit();
650
708
  render();
651
- // Keep the composer ready to type the moment the room is live.
652
- focusComposer();
709
+ // Drop the cursor into my own window so I can type a prompt right away.
710
+ setTimeout(() => {
711
+ const mine = panelEls.get(selfId);
712
+ if (mine && !mine.input.disabled) mine.input.focus();
713
+ else focusComposer();
714
+ }, 0);
653
715
  }
654
716
 
655
717
  function createTerm() {
@@ -925,13 +987,18 @@ function main() {
925
987
  // brain sees the Esc as "leave the box" (never an interrupt while composing);
926
988
  // the next keystroke is then raw to Claude.
927
989
  stage.addEventListener('mousedown', (e) => {
928
- if (e.target.closest('.fdraft')) return;
990
+ if (e.target.closest('.fdraft, .user-window')) return;
991
+ // Clicking Claude's terminal means "talk to Claude": leave any draft I'm in
992
+ // (its text lives on) and hand my keys to Claude's live session.
993
+ const active = document.activeElement;
994
+ if (active?.classList?.contains('uw-input')) active.blur();
929
995
  const v = lastState ? overlayView(lastState, selfId) : null;
930
996
  if (v && v.drafts.some((d) => d.focusedBySelf)) sendKey('\x1b');
997
+ if (v?.canCompose && !composer.disabled) setTimeout(() => composer.focus(), 0);
931
998
  });
932
- // Double-click an empty spot → a new draft spawns right there (explicit creation).
999
+ // Double-click empty space → a shared "write together" draft spawns right there.
933
1000
  stage.addEventListener('dblclick', (e) => {
934
- if (e.target.closest('.fdraft')) return;
1001
+ if (e.target.closest('.fdraft, .user-window')) return;
935
1002
  const v = lastState ? overlayView(lastState, selfId) : null;
936
1003
  if (!v?.canCompose) return;
937
1004
  const r = stage.getBoundingClientRect();
@@ -949,6 +1016,7 @@ function main() {
949
1016
  // Straight to Claude. A key that landed on the still-focused catcher was
950
1017
  // already mailed by its own handler — don't send it twice.
951
1018
  if (e.target === composer) return;
1019
+ if (e.target.classList?.contains('uw-input')) return; // a window owns its keys
952
1020
  const b = directKeyBytes(e);
953
1021
  if (b != null) {
954
1022
  e.preventDefault();
@@ -971,8 +1039,12 @@ function main() {
971
1039
  setTimeout(focusComposer, 0);
972
1040
  });
973
1041
  newDraftBtn.addEventListener('click', () => {
1042
+ // "+ draft" opens a SHARED box to write a prompt together: spawn a draft and
1043
+ // float it over the columns (a place != null box is the shared surface).
1044
+ const n = draftsLayer.querySelectorAll('.fdraft').length;
1045
+ pendingSpawn = { x: 0.24 + (n % 3) * 0.07, y: 0.22 + (n % 3) * 0.09 };
974
1046
  sendKey(NEW_DRAFT_BYTES);
975
- composer.focus(); // straight to the catcher — the new box is already yours
1047
+ setTimeout(() => composer.focus(), 0); // keys land in the new shared box
976
1048
  });
977
1049
 
978
1050
  // ── host controls ────────────────────────────────────────────────────────────
@@ -1051,24 +1123,44 @@ function main() {
1051
1123
  function render() {
1052
1124
  if (!lastState) return;
1053
1125
  const v = overlayView(lastState, selfId);
1054
- // Direct is derived: anyone who can prompt, with no draft focused, types
1055
- // straight into Claude.
1126
+ // A just-spawned "+ draft" (or double-click) box: pin it to a floating spot so
1127
+ // it becomes a shared "write together" box rather than a private window draft.
1128
+ if (pendingSpawn) {
1129
+ const fresh = v.drafts.find((d) => d.focusedBySelf && !d.place && d.text === '');
1130
+ if (fresh) {
1131
+ sendMsg({ t: 'ui', action: { kind: 'place', id: fresh.id, ...pendingSpawn } });
1132
+ pendingSpawn = null;
1133
+ }
1134
+ }
1135
+ // Direct-to-Claude: you can prompt, you're not composing in any draft, and your
1136
+ // cursor isn't in a window input → keys go straight into Claude's terminal (so
1137
+ // y/n permission prompts and the like still work, like before).
1056
1138
  const selfComposing = v.drafts.some((d) => d.focusedBySelf);
1057
- directOn = v.canCompose && !selfComposing;
1058
- // The "keys go into Claude" indicator is Claude's own blinking cursor in the
1059
- // mirror (forced filled+blinking via CSS) — no chip needed.
1139
+ const inWindow = document.activeElement?.classList?.contains('uw-input');
1140
+ directOn = v.canCompose && !selfComposing && !inWindow;
1060
1141
  stage.classList.toggle('direct', directOn);
1061
- // Composing the key catcher must hold focus, so typing lands in the draft
1062
- // without a manual click (unless a drag-selection is standing).
1063
- if (selfComposing && !composer.disabled && document.activeElement !== composer) {
1142
+ // Claude is waiting on a permission ask nudge people to click the terminal to
1143
+ // answer, so a stray y/n doesn't get composed into their own window instead.
1144
+ stage.classList.toggle('ask', v.claudeState === 'ask');
1145
+ // While co-writing a FLOATING box, the invisible key-catcher must hold focus so
1146
+ // keys land in the shared box (unless a window input owns them).
1147
+ const floatingFocused = v.floatingDrafts.some((d) => d.focusedBySelf);
1148
+ if (floatingFocused && !inWindow && !composer.disabled && document.activeElement !== composer) {
1064
1149
  const sel = window.getSelection();
1065
1150
  if (!sel || sel.isCollapsed) composer.focus();
1151
+ } else if (directOn && !composer.disabled && document.activeElement !== composer && !inWindow) {
1152
+ // hand keys to Claude when nothing else owns focus
1153
+ const ae = document.activeElement;
1154
+ const typingElsewhere = ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA');
1155
+ const sel = window.getSelection();
1156
+ if (!typingElsewhere && (!sel || sel.isCollapsed)) composer.focus();
1066
1157
  }
1067
1158
  ghostHint(v);
1068
1159
  applyView(v);
1069
1160
  renderStatusbar(v);
1070
1161
  renderClaudeChip(v);
1071
1162
  renderPaused(v);
1163
+ renderPanels(v);
1072
1164
  renderDrafts(v);
1073
1165
  renderQueue(v);
1074
1166
  renderComposer(v);
@@ -1118,7 +1210,7 @@ function main() {
1118
1210
  if (hinted) return;
1119
1211
  if (v.participants.filter((p) => p.role !== 'viewer').length < 2) return;
1120
1212
  hinted = true;
1121
- localToast('compose together: + draft, or double-click the terminal');
1213
+ localToast('type your own prompt in your window — or press + draft to write one together');
1122
1214
  }
1123
1215
  function localToast(msg, ms = 6000) {
1124
1216
  toastEl.textContent = msg;
@@ -1132,6 +1224,115 @@ function main() {
1132
1224
  function renderPaused(v) {
1133
1225
  pausedCard.hidden = !v.paused;
1134
1226
  stage.classList.toggle('paused', v.paused);
1227
+ panelsGrid.parentElement?.classList.toggle('paused', v.paused);
1228
+ }
1229
+
1230
+ // ── per-user windows: everyone's own space to type a prompt SEPARATELY ────────
1231
+ // 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.
1233
+ let panelsKey = '';
1234
+ const panelEls = new Map(); // participant id -> { input, body, status }
1235
+ function renderPanels(v) {
1236
+ const key = v.panels.map((p) => `${p.id}:${p.name}:${p.color}:${p.canType}`).join('|');
1237
+ if (key !== panelsKey) {
1238
+ panelsKey = key;
1239
+ panelsGrid.innerHTML = '';
1240
+ panelEls.clear();
1241
+ for (const p of v.panels) {
1242
+ const win = el('div', 'user-window' + (p.isSelf ? ' self' : ''));
1243
+ win.style.setProperty('--c', p.color);
1244
+ const head = el('div', 'uw-header');
1245
+ const dot = el('span', 'uw-dot');
1246
+ dot.style.setProperty('--c', p.color);
1247
+ head.append(dot, el('span', 'uw-name', p.isSelf ? `You (${p.name})` : p.name));
1248
+ const status = el('span', 'uw-status');
1249
+ head.append(status);
1250
+ const body = el('div', 'uw-body');
1251
+ const wrap = el('div', 'uw-input-wrap');
1252
+ const input = el('input', 'uw-input');
1253
+ input.type = 'text';
1254
+ input.spellcheck = false;
1255
+ input.autocomplete = 'off';
1256
+ input.placeholder = p.isSelf ? 'Type your prompt…' : `${p.name}'s prompt…`;
1257
+ input.disabled = !p.canType;
1258
+ if (p.canType) wirePanelInput(input);
1259
+ wrap.append(input);
1260
+ win.append(head, body, wrap);
1261
+ panelsGrid.append(win);
1262
+ panelEls.set(p.id, { input, body, status });
1263
+ }
1264
+ }
1265
+ for (const p of v.panels) {
1266
+ const e = panelEls.get(p.id);
1267
+ if (!e) continue;
1268
+ e.status.textContent = p.typing ? 'typing…' : 'idle';
1269
+ e.status.classList.toggle('typing', p.typing);
1270
+ // mirror the live draft into the input — the brain is the authority, so we
1271
+ // never echo locally; setting value keeps every tab showing the same text.
1272
+ if (e.input.value !== p.text) {
1273
+ const focused = document.activeElement === e.input;
1274
+ e.input.value = p.text;
1275
+ if (focused) {
1276
+ const end = e.input.value.length;
1277
+ try {
1278
+ e.input.setSelectionRange(end, end);
1279
+ } catch {
1280
+ /* not a text input state */
1281
+ }
1282
+ }
1283
+ }
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);
1294
+ }
1295
+ }
1296
+ }
1297
+ }
1298
+
1299
+ // Wire a self window input: keys route to the brain (which owns the draft), so the
1300
+ // input is a controlled mirror of my private box. Enter sends it into the queue.
1301
+ 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
+ });
1315
+ input.addEventListener('keydown', (e) => {
1316
+ const bytes = keyToBytes(e); // Enter → send, editing chords, printable chars
1317
+ if (bytes != null) {
1318
+ e.preventDefault();
1319
+ sendKey(bytes);
1320
+ return;
1321
+ }
1322
+ if (e.ctrlKey || e.metaKey) return; // browser shortcuts stay native
1323
+ if (e.key.length === 1) e.preventDefault(); // unmapped printable: swallow
1324
+ });
1325
+ input.addEventListener('paste', (e) => {
1326
+ e.preventDefault();
1327
+ const text = (e.clipboardData || window.clipboardData)?.getData('text') || '';
1328
+ if (text) sendKey(pasteBytes(text));
1329
+ });
1330
+ // never let a local echo diverge from the brain's authoritative text
1331
+ input.addEventListener('input', () => {
1332
+ const v = lastState ? overlayView(lastState, selfId) : null;
1333
+ const mine = v?.panels.find((p) => p.isSelf);
1334
+ if (mine && input.value !== mine.text) input.value = mine.text;
1335
+ });
1135
1336
  }
1136
1337
 
1137
1338
  let lastDraftsJson = '';
@@ -1140,19 +1341,13 @@ function main() {
1140
1341
  // Rebuild only when the drafts actually changed: every rebuild would destroy a
1141
1342
  // native drag-selection, and state frames arrive for unrelated reasons
1142
1343
  // (pointers). Mid-drag renders are skipped too — pointerup re-syncs.
1143
- const json = JSON.stringify(v.drafts);
1344
+ const json = JSON.stringify(v.floatingDrafts);
1144
1345
  if (json === lastDraftsJson || draggingDraft) return;
1145
1346
  lastDraftsJson = json;
1146
1347
  draftsLayer.innerHTML = '';
1147
1348
  const nextIds = new Set();
1148
- for (const [i, d] of v.drafts.entries()) {
1349
+ for (const [i, d] of v.floatingDrafts.entries()) {
1149
1350
  nextIds.add(d.id);
1150
- // A box I just spawned by double-click lands where I clicked — for everyone.
1151
- if (pendingSpawn && d.focusedBySelf && !d.place && d.text === '') {
1152
- sendMsg({ t: 'ui', action: { kind: 'place', id: d.id, ...pendingSpawn } });
1153
- d.place = pendingSpawn; // optimistic, until the next state frame confirms
1154
- pendingSpawn = null;
1155
- }
1156
1351
  // Entrance animation only for a box that just APPEARED — this render runs on
1157
1352
  // every keystroke (full rebuild), and replaying it would strobe. Same idea
1158
1353
  // for the border beam: phase it by wall-clock so a rebuild never resets it.
@@ -1534,7 +1729,7 @@ function main() {
1534
1729
  // Host tab: no name prompt — connect straight to its own room (name comes from
1535
1730
  // the shared state once live).
1536
1731
  joinScreen.hidden = false;
1537
- connect({ code: loc.code, name: '', token: null, hostToken: loc.hostToken });
1732
+ connect({ code: loc.code, name: '', token: null, hostToken: loc.hostToken, seat: mintSeat() });
1538
1733
  joinForm.hidden = true;
1539
1734
  waiting.hidden = false;
1540
1735
  } else {
@@ -65,7 +65,7 @@
65
65
  <button id="toast" class="notice-chip" type="button" title="dismiss" hidden></button>
66
66
  <span id="knocks" class="knocks"></span>
67
67
  <button id="queue-chip" class="ctrl qchip" type="button" title="Prompts waiting for Claude" hidden></button>
68
- <button id="new-draft" class="ctrl" type="button" title="Start a draft (or double-click the terminal)">+ draft</button>
68
+ <button id="new-draft" class="ctrl" type="button" title="Open a shared draft to write a prompt together">+ draft</button>
69
69
  <span id="host-controls" class="host-controls" hidden>
70
70
  <button id="copy-invite" class="ctrl" type="button" title="Copy invite link"><span class="ico">⧉</span> Invite</button>
71
71
  <button id="pause-btn" class="ctrl" type="button" title="Pause sharing"><span class="ico">⏸</span></button>
@@ -85,10 +85,11 @@
85
85
  </div>
86
86
 
87
87
  <main class="workspace">
88
+ <!-- Claude's live session: the shared terminal everyone watches respond.
89
+ Floating "+ draft" boxes (write a prompt TOGETHER) overlay it. -->
88
90
  <div id="stage">
89
91
  <div id="term"></div>
90
92
  <div id="cursors" aria-hidden="true"></div>
91
- <!-- floating draft boxes live INSIDE the terminal surface -->
92
93
  <div id="drafts" class="overlays"></div>
93
94
  <div id="paused" class="paused-card" hidden>
94
95
  <div class="paused-inner">
@@ -98,8 +99,9 @@
98
99
  </div>
99
100
  </div>
100
101
  </div>
101
- <!-- invisible key catcher: keystrokes land here and are mailed to the
102
- brain; the floating draft boxes are the only visible composer -->
102
+ <!-- one window per person: your own space to compose a prompt SEPARATELY -->
103
+ <div id="panels" class="panels-grid"></div>
104
+ <!-- invisible key catcher: routes direct + floating-draft keystrokes to the brain -->
103
105
  <textarea id="composer" class="composer" rows="1" spellcheck="false" autocomplete="off" aria-label="compose"></textarea>
104
106
  </main>
105
107
  </section>
@@ -527,6 +527,168 @@ body {
527
527
  box-shadow: 0 0 0 1px rgba(232, 163, 61, 0.4), 0 0 22px rgba(232, 163, 61, 0.14),
528
528
  0 24px 60px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05);
529
529
  }
530
+ /* Claude is waiting on a yes/no — pulse the frame and label it, so nobody answers
531
+ by typing into their own window. Clicking the terminal hands keys to Claude. */
532
+ #stage.ask {
533
+ cursor: pointer;
534
+ animation: ask-pulse 1.6s ease-in-out infinite;
535
+ }
536
+ #stage.ask::after {
537
+ content: "⚠ Claude needs a yes / no — click here to answer";
538
+ position: absolute;
539
+ top: 12px;
540
+ left: 50%;
541
+ transform: translateX(-50%);
542
+ z-index: 8;
543
+ padding: 5px 14px;
544
+ border-radius: 999px;
545
+ font-size: 12px;
546
+ font-weight: 600;
547
+ letter-spacing: 0.01em;
548
+ color: #1a1205;
549
+ background: rgba(232, 163, 61, 0.96);
550
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
551
+ pointer-events: none;
552
+ white-space: nowrap;
553
+ }
554
+ @keyframes ask-pulse {
555
+ 0%,
556
+ 100% {
557
+ box-shadow: 0 0 0 1.5px rgba(232, 163, 61, 0.55), 0 0 18px rgba(232, 163, 61, 0.18),
558
+ 0 24px 60px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05);
559
+ }
560
+ 50% {
561
+ box-shadow: 0 0 0 1.5px rgba(232, 163, 61, 0.9), 0 0 34px rgba(232, 163, 61, 0.42),
562
+ 0 24px 60px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05);
563
+ }
564
+ }
565
+
566
+ /* ── the per-user compose windows: a row beneath Claude's live terminal ──────
567
+ Each column is one person's own space to write a prompt SEPARATELY; the
568
+ "+ draft" floating boxes overlay the terminal when people write TOGETHER. */
569
+ .panels-grid {
570
+ flex: 0 0 auto;
571
+ height: clamp(150px, 30vh, 240px);
572
+ display: grid;
573
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
574
+ gap: 12px;
575
+ align-items: stretch;
576
+ }
577
+ .panels-grid:empty {
578
+ display: none;
579
+ }
580
+ .user-window {
581
+ display: flex;
582
+ flex-direction: column;
583
+ min-height: 0;
584
+ border-radius: 14px;
585
+ background: rgba(10, 12, 18, 0.62);
586
+ backdrop-filter: blur(14px);
587
+ -webkit-backdrop-filter: blur(14px);
588
+ box-shadow: 0 0 0 1px var(--ring-soft), inset 0 1px 0 rgba(255, 255, 255, 0.04);
589
+ overflow: hidden;
590
+ transition: box-shadow 0.18s ease;
591
+ }
592
+ .user-window.self {
593
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--c, #9aa4b2) 42%, transparent),
594
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
595
+ }
596
+ .uw-header {
597
+ display: flex;
598
+ align-items: center;
599
+ gap: 8px;
600
+ padding: 9px 13px;
601
+ font-family: var(--mono);
602
+ font-size: 12px;
603
+ font-weight: 600;
604
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
605
+ flex: 0 0 auto;
606
+ }
607
+ .uw-dot {
608
+ width: 8px;
609
+ height: 8px;
610
+ border-radius: 50%;
611
+ background: var(--c, #9aa4b2);
612
+ box-shadow: 0 0 10px var(--c, transparent);
613
+ flex: 0 0 auto;
614
+ }
615
+ .uw-name {
616
+ color: var(--text);
617
+ flex: 1;
618
+ min-width: 0;
619
+ overflow: hidden;
620
+ text-overflow: ellipsis;
621
+ white-space: nowrap;
622
+ }
623
+ .uw-status {
624
+ font-size: 10.5px;
625
+ font-weight: 500;
626
+ color: var(--text-faint);
627
+ }
628
+ .uw-status.typing {
629
+ color: var(--c, var(--amber));
630
+ }
631
+ .uw-body {
632
+ flex: 1;
633
+ min-height: 0;
634
+ overflow-y: auto;
635
+ padding: 10px 13px;
636
+ font-family: var(--mono);
637
+ font-size: 12px;
638
+ line-height: 1.55;
639
+ color: var(--text-dim);
640
+ }
641
+ .uw-empty {
642
+ color: var(--text-faint);
643
+ font-style: italic;
644
+ opacity: 0.7;
645
+ }
646
+ .uw-line {
647
+ margin: 0 0 4px;
648
+ white-space: pre-wrap;
649
+ word-break: break-word;
650
+ }
651
+ .uw-line.sent {
652
+ color: var(--text-dim);
653
+ }
654
+ .uw-line .uw-n {
655
+ color: var(--text-faint);
656
+ margin-right: 6px;
657
+ }
658
+ .uw-input-wrap {
659
+ flex: 0 0 auto;
660
+ padding: 9px 11px 11px;
661
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
662
+ }
663
+ .uw-input {
664
+ width: 100%;
665
+ font: inherit;
666
+ font-family: var(--mono);
667
+ font-size: 12.5px;
668
+ color: var(--text);
669
+ background: rgba(0, 0, 0, 0.35);
670
+ border: none;
671
+ border-radius: 10px;
672
+ padding: 9px 12px;
673
+ box-shadow: 0 0 0 1px var(--ring-soft), inset 0 1px 2px rgba(0, 0, 0, 0.3);
674
+ outline: none;
675
+ transition: box-shadow 0.15s ease;
676
+ }
677
+ .uw-input::placeholder {
678
+ color: var(--text-faint);
679
+ }
680
+ .uw-input:focus {
681
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--c, var(--amber)) 55%, transparent),
682
+ inset 0 1px 2px rgba(0, 0, 0, 0.3);
683
+ }
684
+ .uw-input:disabled {
685
+ opacity: 0.5;
686
+ cursor: default;
687
+ }
688
+ .workspace.paused .panels-grid {
689
+ filter: blur(4px) brightness(0.6);
690
+ pointer-events: none;
691
+ }
530
692
  #cursors {
531
693
  position: absolute;
532
694
  inset: 10px 12px;
@@ -206,7 +206,14 @@ export function startWebDoor(ctx) {
206
206
  const isHostTab = !!hostToken;
207
207
  // Identity: the host tab carries the host token; a guest carries its browser
208
208
  // token (session-only if absent). The relay makes no trust call — the brain does.
209
- const fp = isHostTab ? 'webhost:' + hostToken : 'web:' + (q.get('token') || randomUUID());
209
+ // Host tabs carry a per-browser seat secret; fold it into the synthetic host
210
+ // fingerprint (`webhost:<token>:<seat>`) so the CLI can bind the host seat to
211
+ // the first browser it sees. Sanitize to a safe charset — it lands in the fp
212
+ // the CLI parses. The relay makes no trust call; the brain owns the binding.
213
+ const seat = (q.get('seat') || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64);
214
+ const fp = isHostTab
215
+ ? 'webhost:' + hostToken + (seat ? ':' + seat : '')
216
+ : 'web:' + (q.get('token') || randomUUID());
210
217
  // Printable-ASCII only, same as the ssh door: `?name=` is an untrusted URL param,
211
218
  // and the host writes it verbatim to its terminal / log / session.md and mirrors
212
219
  // it to every ssh guest — raw ESC/OSC/BEL bytes here would be escape injection.