@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 +71 -69
- 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,109 +1,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ✦ claudecollab
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
+
## Start a session
|
|
29
13
|
|
|
30
14
|
```bash
|
|
31
|
-
|
|
15
|
+
npm install -g @claudecollab/cli
|
|
16
|
+
collab
|
|
32
17
|
```
|
|
33
18
|
|
|
34
|
-
|
|
19
|
+
That's it. Run `collab` instead of `claude`. It works the same, but now it makes two links:
|
|
35
20
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
57
|
-
|
|
|
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
|
|
33
|
+
The Invite button adds the plain one to your clipboard.
|
|
60
34
|
|
|
61
|
-
Want
|
|
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 |
|
|
39
|
+
| Role | Can watch | Can type | Can run the room |
|
|
68
40
|
|---|---|---|---|
|
|
69
|
-
| 👁 viewer |
|
|
70
|
-
| ✎ prompter *(
|
|
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
|
|
47
|
+
## How it works
|
|
74
48
|
|
|
75
|
-
-
|
|
76
|
-
-
|
|
77
|
-
-
|
|
78
|
-
-
|
|
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
|
-
##
|
|
54
|
+
## Important note
|
|
82
55
|
|
|
83
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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>
|
|
95
|
+
<summary>How it's built</summary>
|
|
93
96
|
|
|
94
|
-
Three
|
|
97
|
+
Three parts, plain JavaScript, no build step:
|
|
95
98
|
|
|
96
99
|
```
|
|
97
|
-
packages/shared/
|
|
98
|
-
packages/relay/ server
|
|
99
|
-
packages/cli/
|
|
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
|
-
|
|
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
|
@@ -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.
|