@claudecollab/cli 0.1.0 → 0.1.1
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 +57 -42
- package/package.json +1 -1
- package/packages/cli/bin/claude-share.js +81 -9
- package/packages/cli/src/brain/drafts.js +10 -1
- package/packages/relay/bin/serve.js +0 -0
- package/packages/relay/public/client.js +223 -28
- package/packages/relay/public/index.html +6 -4
- package/packages/relay/public/style.css +162 -0
- package/packages/relay/web.js +8 -1
package/README.md
CHANGED
|
@@ -1,53 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ✦ claudecollab
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Multiplayer for your Claude Code session.** 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.
|
|
4
4
|
|
|
5
5
|
Think **screen-share where they can type too**.
|
|
6
6
|
|
|
7
|
+
<!-- demo gif goes here once captured: docs/demo.gif (host shares a session; a guest joins from a browser and types into the same Claude) -->
|
|
8
|
+
|
|
7
9
|
```mermaid
|
|
8
10
|
flowchart LR
|
|
9
|
-
H["you (host)<br/>terminal runs Claude"] <--> R["relay<br/>forwards bytes,
|
|
11
|
+
H["you (host)<br/>terminal runs Claude"] <--> R["relay<br/>forwards bytes, nothing to disk"]
|
|
10
12
|
R <--> HT["your host tab<br/>(admit · roles · pause · end)"]
|
|
11
13
|
R <--> G["friends' browsers"]
|
|
12
14
|
```
|
|
13
15
|
|
|
14
16
|
Your terminal stays plain Claude plus one status line. Everything multiplayer happens in the browser.
|
|
15
17
|
|
|
16
|
-
##
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
npm install
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
**Terminal 1 — relay:**
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
node packages/relay/bin/serve.js
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
**Terminal 2 — host:**
|
|
18
|
+
## Host a session
|
|
29
19
|
|
|
30
20
|
```bash
|
|
31
|
-
|
|
21
|
+
npm install -g @claudecollab/cli
|
|
22
|
+
collab
|
|
32
23
|
```
|
|
33
24
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
## Deploy the relay (permanent links)
|
|
37
|
-
|
|
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).
|
|
25
|
+
That's it. `collab` wraps your normal `claude` and prints your room link on the status line — that's **your** tab (admit people, manage roles). The invite link lands on your clipboard — that's the one you share. Friends need nothing but a browser.
|
|
51
26
|
|
|
52
27
|
## Two links — don't mix them up
|
|
53
28
|
|
|
@@ -58,9 +33,7 @@ Two protections kick in on a deployed relay:
|
|
|
58
33
|
|
|
59
34
|
The Invite button and your clipboard always hold the safe one.
|
|
60
35
|
|
|
61
|
-
Want a lock in front of the knock? Host with
|
|
62
|
-
enter it (browser field / ssh prompt) before their request ever reaches you.
|
|
63
|
-
Admitting stays your call either way.
|
|
36
|
+
Want a lock in front of the knock? Host with `collab --room-password <pw>` — guests must enter it before their request ever reaches you. Admitting stays your call either way.
|
|
64
37
|
|
|
65
38
|
## Roles
|
|
66
39
|
|
|
@@ -76,16 +49,54 @@ Admitting stays your call either way.
|
|
|
76
49
|
- **+ 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
50
|
- Sent while Claude is busy? It waits in the **queue** chip — edit or delete before it fires.
|
|
78
51
|
- Scroll the mirror like a real terminal. Scrolling is shared — one screen for everyone.
|
|
79
|
-
- Reloaded guests glide straight back in. Kicked guests
|
|
52
|
+
- Reloaded guests glide straight back in. Kicked guests lose the room; a room password (below) is the real lock if you need one.
|
|
80
53
|
|
|
81
54
|
## The one thing to really understand
|
|
82
55
|
|
|
83
56
|
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).
|
|
84
57
|
|
|
85
|
-
##
|
|
58
|
+
## Self-host the relay
|
|
59
|
+
|
|
60
|
+
`collab` defaults to the community relay at claudecollab.org. Rather run your own? The same package ships the server:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
collab-relay # local: ssh :2222 · web :8787
|
|
64
|
+
collab --relay ssh://127.0.0.1:2222 # host through it
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For a permanent public relay (Fly.io, any VPS — see `fly.toml` in this repo):
|
|
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: gate room creation
|
|
73
|
+
fly deploy
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Two protections are built in:
|
|
77
|
+
|
|
78
|
+
- **Room secret** — with `ROOM_SECRET` set, only hosts that present it (`CLAUDE_SHARE_SECRET` env or `--secret`) can create rooms. Guests are unaffected — they enter with a room link.
|
|
79
|
+
- **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. The relay prints its fingerprint at boot; pin explicitly with `--fingerprint SHA256:…`.
|
|
80
|
+
|
|
81
|
+
## Flags
|
|
82
|
+
|
|
83
|
+
| Flag | What it does |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `--relay <url>` | relay to use (`ssh://host:port`); default is the community relay |
|
|
86
|
+
| `--no-relay` | solo mode — no room, no relay, just Claude with the band |
|
|
87
|
+
| `--room-password <pw>` | guests must present this before they can knock |
|
|
88
|
+
| `--guests <role>` | role given to newly admitted guests (default `prompter`) |
|
|
89
|
+
| `--secret <s>` | room-creation credential for a `ROOM_SECRET`-gated relay (or `CLAUDE_SHARE_SECRET` env) |
|
|
90
|
+
| `--fingerprint <fp>` | pin the relay's ssh key explicitly (default: trust-on-first-use) |
|
|
91
|
+
| `--cmd <program>` | wrap something other than `claude` |
|
|
92
|
+
| `-- <args…>` | everything after `--` passes through to the wrapped program |
|
|
93
|
+
|
|
94
|
+
## Develop
|
|
86
95
|
|
|
87
96
|
```bash
|
|
88
|
-
npm
|
|
97
|
+
npm install
|
|
98
|
+
npm test # full suite: protocol, relay, brain, e2e
|
|
99
|
+
scripts/rig.sh # local relay + host wrapping a fake claude (UI work)
|
|
89
100
|
```
|
|
90
101
|
|
|
91
102
|
<details>
|
|
@@ -102,8 +113,12 @@ test/ end-to-end: host + host tab + guest on localhost
|
|
|
102
113
|
|
|
103
114
|
- The CLI wraps Claude in a PTY one row short — the bottom row is the status line.
|
|
104
115
|
- State (busy/idle/ask) comes from injected Claude Code hooks, never screen-scraping. Ambiguity fails closed.
|
|
105
|
-
- The relay
|
|
116
|
+
- The relay persists nothing to disk — rooms, names, and any room password live only in process memory (kill it and the host reconnects; kill the host and the room is over). It does relay your screen bytes in transit, so treat it like a screen-share, not a vault — or self-host your own.
|
|
106
117
|
|
|
107
118
|
</details>
|
|
108
119
|
|
|
109
|
-
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
[MIT](LICENSE). claudecollab is an independent open-source project — not affiliated with or endorsed by Anthropic. *Claude* is a trademark of Anthropic, PBC; this tool wraps the official Claude Code client you already have installed.
|
|
123
|
+
|
|
124
|
+
**TL;DR:** `npm i -g @claudecollab/cli` → `collab` → share the invite link. Guests need nothing. Roles keep control; prompts are real — admit people you trust.
|
package/package.json
CHANGED
|
@@ -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
|
|
123
|
-
//
|
|
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
|
-
'
|
|
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
|
|
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>`.
|
|
1005
|
-
//
|
|
1006
|
-
// the
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
652
|
-
|
|
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
|
|
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(); //
|
|
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
|
-
//
|
|
1055
|
-
//
|
|
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
|
-
|
|
1058
|
-
|
|
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
|
-
//
|
|
1062
|
-
//
|
|
1063
|
-
|
|
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('
|
|
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.
|
|
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.
|
|
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="
|
|
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
|
-
<!--
|
|
102
|
-
|
|
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;
|
package/packages/relay/web.js
CHANGED
|
@@ -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
|
-
|
|
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.
|