@claudecollab/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/package.json +36 -0
  4. package/packages/cli/bin/claude-share.js +1319 -0
  5. package/packages/cli/package.json +15 -0
  6. package/packages/cli/src/brain/card.js +157 -0
  7. package/packages/cli/src/brain/commands.js +122 -0
  8. package/packages/cli/src/brain/drafts.js +557 -0
  9. package/packages/cli/src/brain/gate.js +134 -0
  10. package/packages/cli/src/brain/knocks.js +27 -0
  11. package/packages/cli/src/brain/log.js +123 -0
  12. package/packages/cli/src/brain/queue.js +81 -0
  13. package/packages/cli/src/brain/state.js +245 -0
  14. package/packages/cli/src/hooks.js +243 -0
  15. package/packages/cli/src/invite.js +43 -0
  16. package/packages/cli/src/known-relays.js +66 -0
  17. package/packages/cli/src/pty.js +175 -0
  18. package/packages/cli/src/relay-client.js +271 -0
  19. package/packages/cli/src/renderer.js +230 -0
  20. package/packages/cli/src/screen-snapshot.js +139 -0
  21. package/packages/cli/src/term-chatter.js +89 -0
  22. package/packages/relay/auth.js +18 -0
  23. package/packages/relay/bin/serve.js +73 -0
  24. package/packages/relay/names.js +27 -0
  25. package/packages/relay/package.json +13 -0
  26. package/packages/relay/public/client.js +1553 -0
  27. package/packages/relay/public/index.html +110 -0
  28. package/packages/relay/public/style.css +952 -0
  29. package/packages/relay/rooms.js +149 -0
  30. package/packages/relay/server.js +652 -0
  31. package/packages/relay/web.js +334 -0
  32. package/packages/relay/wordlists.js +38 -0
  33. package/packages/shared/package.json +8 -0
  34. package/packages/shared/protocol.js +189 -0
@@ -0,0 +1,334 @@
1
+ // The relay's web door — a browser front-end onto the exact same room machinery
2
+ // the ssh guest door uses (spec v2: "the browser is the one multiplayer surface
3
+ // for everyone, host included"). startRelay() starts this when opts.webPort is set.
4
+ //
5
+ // Two jobs, both dumb on purpose (the brain stays in the host CLI):
6
+ //
7
+ // 1. HTTP static: GET / and GET /<roomCode> serve packages/relay/public/index.html
8
+ // (the SPA entry); GET /<file>.<ext> (e.g. /client.js, /style.css) serves that
9
+ // file from the public dir; GET /assets/* serves the vendored @xterm/xterm files
10
+ // straight out of node_modules. All three are path-traversal safe (resolved and
11
+ // pinned inside their base dir).
12
+ //
13
+ // 2. WS /ws?room=<code>&name=<name>&token=<browser-token> — a web participant.
14
+ // It knocks / admits / bans / caps through the SAME registry + room state as an
15
+ // ssh guest; the only difference is the transport:
16
+ // • fp = 'web:' + token (localStorage token → returning identity)
17
+ // • host tab: /ws?...&host=<hostToken> → fp = 'webhost:' + hostToken. The
18
+ // relay makes NO trust decision — it just forwards that fp in the knock;
19
+ // the brain auto-admits when it matches the token it generated (role host).
20
+ // • relay→browser BINARY frame = screen bytes (feed to xterm)
21
+ // TEXT frame = JSON protocol ({t:'state'|'joined'|'error'})
22
+ // • browser→relay TEXT frame = JSON {t:'key'|'resize'|'pointer'|'ui', …};
23
+ // the relay STAMPS the sender id (never trusts a browser-supplied one) and
24
+ // forwards to the host, so the role gate in the brain still governs.
25
+
26
+ import http from 'node:http';
27
+ import { createReadStream } from 'node:fs';
28
+ import { stat } from 'node:fs/promises';
29
+ import { randomUUID } from 'node:crypto';
30
+ import { createRequire } from 'node:module';
31
+ import { fileURLToPath } from 'node:url';
32
+ import path from 'node:path';
33
+ import { WebSocketServer } from 'ws';
34
+ import { TYPES, encode, validate } from '../shared/protocol.js';
35
+ import { sanitizeName } from './names.js';
36
+ import { secretsMatch } from './auth.js';
37
+
38
+ const here = path.dirname(fileURLToPath(import.meta.url));
39
+ const PUBLIC_DIR = path.join(here, 'public');
40
+ const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
41
+
42
+ // Resolve the vendored xterm package dir through node resolution so monorepo
43
+ // hoisting doesn't matter. `/assets/<rest>` maps to `<xtermDir>/<rest>`.
44
+ const require = createRequire(import.meta.url);
45
+ const XTERM_DIR = path.dirname(require.resolve('@xterm/xterm/package.json'));
46
+
47
+ const CONTENT_TYPES = {
48
+ '.html': 'text/html; charset=utf-8',
49
+ '.js': 'text/javascript; charset=utf-8',
50
+ '.mjs': 'text/javascript; charset=utf-8',
51
+ '.css': 'text/css; charset=utf-8',
52
+ '.map': 'application/json; charset=utf-8',
53
+ '.json': 'application/json; charset=utf-8',
54
+ '.ico': 'image/x-icon',
55
+ '.png': 'image/png',
56
+ '.svg': 'image/svg+xml',
57
+ '.woff': 'font/woff',
58
+ '.woff2': 'font/woff2',
59
+ '.ttf': 'font/ttf',
60
+ };
61
+ const contentType = (p) => CONTENT_TYPES[path.extname(p).toLowerCase()] ?? 'application/octet-stream';
62
+
63
+ // Served when public/index.html doesn't exist yet (T3 owns the real client).
64
+ const FALLBACK_INDEX =
65
+ '<!doctype html><meta charset="utf-8"><title>claudecollab</title>' +
66
+ '<body style="font:16px system-ui;margin:3rem;max-width:40rem">' +
67
+ '<h1>claudecollab</h1><p>the web client is not built yet.</p></body>';
68
+
69
+ function isObj(v) {
70
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
71
+ }
72
+
73
+ // ---- ws send helpers (fail-soft; a closed socket must never throw) -----------
74
+
75
+ function wsSendBinary(ws, data) {
76
+ if (ws.readyState !== ws.OPEN) return;
77
+ try {
78
+ ws.send(Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8'), { binary: true });
79
+ } catch {
80
+ /* socket already gone */
81
+ }
82
+ }
83
+ function wsSendText(ws, str) {
84
+ if (ws.readyState !== ws.OPEN) return;
85
+ try {
86
+ ws.send(typeof str === 'string' ? str : JSON.stringify(str));
87
+ } catch {
88
+ /* socket already gone */
89
+ }
90
+ }
91
+ function wsClose(ws) {
92
+ try {
93
+ ws.close();
94
+ } catch {
95
+ /* already closing */
96
+ }
97
+ }
98
+ const sendErr = (ws, reason) => wsSendText(ws, JSON.stringify({ t: 'error', reason }));
99
+
100
+ /**
101
+ * Start the HTTP + WebSocket web door. Shares the live routing state and the
102
+ * registry with the ssh relay so web participants are real room members.
103
+ *
104
+ * @param {object} ctx
105
+ * @param {number} ctx.port listen port (0 ⇒ ephemeral; spec prod: 443)
106
+ * @param {string} ctx.host bind address
107
+ * @param {Map<string,object>} ctx.live code -> live room (shared with server.js)
108
+ * @param {object} ctx.registry the shared room registry (cap/ban/knock-limit)
109
+ * @param {number} ctx.knockTimeoutMs waiting-screen timeout
110
+ * @param {(rec:object)=>void} ctx.onGuestGone shared guest-teardown (frees seat, LEFTs the host)
111
+ * @param {(stream:any,data:any)=>void} ctx.safeWrite shared fail-soft writer (to the host stream)
112
+ * @returns {Promise<{close():void, port:number, address:object}>}
113
+ */
114
+ export function startWebDoor(ctx) {
115
+ const { port = 0, host = '127.0.0.1', live, registry, knockTimeoutMs, onGuestGone, safeWrite } = ctx;
116
+
117
+ // ---- HTTP: index (SPA-style, room code in the path) + vendored xterm assets
118
+
119
+ function serveIndex(res) {
120
+ const s = createReadStream(INDEX_HTML);
121
+ s.on('open', () => {
122
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
123
+ s.pipe(res);
124
+ });
125
+ s.on('error', () => {
126
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
127
+ res.end(FALLBACK_INDEX);
128
+ });
129
+ }
130
+
131
+ // Serve a file from a fixed base dir, path-traversal safe. `rest` begins with '/';
132
+ // any '../' resolves out and fails the prefix check, so a request can never escape
133
+ // the base. Shared by the vendored xterm assets and the public client files.
134
+ async function serveFrom(baseDir, rest, res) {
135
+ const target = path.resolve(baseDir, '.' + rest);
136
+ if (target !== baseDir && !target.startsWith(baseDir + path.sep)) {
137
+ res.writeHead(403, { 'content-type': 'text/plain' });
138
+ return res.end('forbidden');
139
+ }
140
+ let st;
141
+ try {
142
+ st = await stat(target);
143
+ } catch {
144
+ res.writeHead(404, { 'content-type': 'text/plain' });
145
+ return res.end('not found');
146
+ }
147
+ if (!st.isFile()) {
148
+ res.writeHead(404, { 'content-type': 'text/plain' });
149
+ return res.end('not found');
150
+ }
151
+ // no-cache: the browser must revalidate on every load, or a tab keeps running a
152
+ // stale client (style/logic) after the host updates — confusing mid-iteration.
153
+ res.writeHead(200, {
154
+ 'content-type': contentType(target),
155
+ 'content-length': st.size,
156
+ 'cache-control': 'no-cache',
157
+ });
158
+ createReadStream(target).pipe(res);
159
+ }
160
+ const serveAsset = (rest, res) => serveFrom(XTERM_DIR, rest, res);
161
+ const servePublic = (rest, res) => serveFrom(PUBLIC_DIR, rest, res);
162
+
163
+ const server = http.createServer((req, res) => {
164
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
165
+ res.writeHead(405, { 'content-type': 'text/plain' });
166
+ return res.end('method not allowed');
167
+ }
168
+ let pathname;
169
+ try {
170
+ pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
171
+ } catch {
172
+ res.writeHead(400, { 'content-type': 'text/plain' });
173
+ return res.end('bad request');
174
+ }
175
+ if (pathname.startsWith('/assets/')) return void serveAsset(pathname.slice('/assets'.length), res);
176
+ // A single-segment path with a file extension (e.g. /client.js, /style.css,
177
+ // /favicon.ico) → a static file from the public dir. Room codes have no dot, so
178
+ // they never match here and fall through to the SPA below.
179
+ if (pathname.indexOf('/', 1) === -1 && path.extname(pathname)) return void servePublic(pathname, res);
180
+ // '/' or a single-segment path (a room code, e.g. /brave-otter) → the SPA.
181
+ if (pathname === '/' || (pathname.indexOf('/', 1) === -1 && pathname.length > 1)) return serveIndex(res);
182
+ res.writeHead(404, { 'content-type': 'text/plain' });
183
+ res.end('not found');
184
+ });
185
+
186
+ // ---- WS: web participants, on the shared room machinery ---------------------
187
+
188
+ const wss = new WebSocketServer({ noServer: true });
189
+
190
+ server.on('upgrade', (req, socket, head) => {
191
+ let url;
192
+ try {
193
+ url = new URL(req.url, 'http://localhost');
194
+ } catch {
195
+ return socket.destroy();
196
+ }
197
+ if (url.pathname !== '/ws') return socket.destroy();
198
+ wss.handleUpgrade(req, socket, head, (ws) => onConnect(ws, url, req));
199
+ });
200
+
201
+ function onConnect(ws, url, req) {
202
+ ws.on('error', () => {}); // client resets are routine; never crash the relay
203
+ const q = url.searchParams;
204
+ const code = q.get('room');
205
+ const hostToken = q.get('host');
206
+ const isHostTab = !!hostToken;
207
+ // Identity: the host tab carries the host token; a guest carries its browser
208
+ // token (session-only if absent). The relay makes no trust call — the brain does.
209
+ const fp = isHostTab ? 'webhost:' + hostToken : 'web:' + (q.get('token') || randomUUID());
210
+ // Printable-ASCII only, same as the ssh door: `?name=` is an untrusted URL param,
211
+ // and the host writes it verbatim to its terminal / log / session.md and mirrors
212
+ // it to every ssh guest — raw ESC/OSC/BEL bytes here would be escape injection.
213
+ const name = sanitizeName(q.get('name'), isHostTab ? 'host' : 'guest');
214
+ const ip = req.socket.remoteAddress || 'unknown';
215
+
216
+ // Same gate as the ssh door (spec: respect bans/lockouts/caps identically).
217
+ const room = code ? live.get(code) : null;
218
+ if (!code || !room || !registry.get(code)) return void (sendErr(ws, 'no-room'), wsClose(ws));
219
+ if (!room.hostPresent) return void (sendErr(ws, 'host-gone'), wsClose(ws));
220
+ if (fp && registry.get(code).banned.has(fp)) return void (sendErr(ws, 'banned'), wsClose(ws));
221
+ if (!registry.tryKnock(code, ip)) return void (sendErr(ws, 'lockout'), wsClose(ws));
222
+ // Room password pre-gate (mirrors the ssh door's prompt). Host tabs are
223
+ // exempt: their credential is the host token the brain itself minted — and a
224
+ // forged `?host=` gains nothing, it just lands as an ordinary pending knock
225
+ // the host will see and deny. Wrong guesses already burned a tryKnock slot
226
+ // above, so brute force hits the per-ip lockout fast.
227
+ if (!isHostTab && room.pass && !secretsMatch(q.get('pass') ?? '', room.pass)) {
228
+ return void (sendErr(ws, 'password'), wsClose(ws));
229
+ }
230
+
231
+ const rec = {
232
+ id: randomUUID(),
233
+ code,
234
+ kind: 'web', // marks this rec for the host channel's web-only branches (state/joined)
235
+ ws,
236
+ fp,
237
+ name,
238
+ phase: 'knocking', // web skips the ssh name prompt: name arrives in the query
239
+ cols: 80, // spec floor; refined by the browser's first {t:'resize'}
240
+ rows: 24,
241
+ knockTimer: null,
242
+ announced: false,
243
+ gone: false,
244
+ // Adapters so the shared host-channel handlers (SCREEN/TO/DENY/DROP/close…)
245
+ // treat a web rec exactly like an ssh one: byte writes become binary frames.
246
+ stream: { write: (d) => wsSendBinary(ws, d), end: () => wsClose(ws) },
247
+ conn: { end: () => wsClose(ws) },
248
+ sendText: (s) => wsSendText(ws, s), // STATE + the 'joined' signal go as text
249
+ };
250
+ room.pending.set(rec.id, rec);
251
+
252
+ // Knock the host (identical shape to the ssh door). A returning token carries
253
+ // its prior name in `seen`; brand-new ⇒ null.
254
+ const priorName = room.seen.get(fp);
255
+ const seen = priorName !== undefined ? priorName : null;
256
+ room.seen.set(fp, name);
257
+ rec.announced = true;
258
+ safeWrite(room.hostStream, encode({ t: TYPES.KNOCK, id: rec.id, name: rec.name, fp: rec.fp, seen }));
259
+ rec.knockTimer = setTimeout(() => {
260
+ if (rec.gone) return;
261
+ sendErr(ws, 'timeout');
262
+ wsClose(ws); // 'close' → onGuestGone: drops the seat, LEFTs the host
263
+ }, knockTimeoutMs);
264
+ rec.knockTimer.unref?.();
265
+
266
+ ws.on('message', (data, isBinary) => {
267
+ if (isBinary) return; // browsers only send JSON text; screen flows one way
268
+ if (rec.phase !== 'live') return; // ignore input until admitted (ssh parity)
269
+ const r = live.get(rec.code);
270
+ if (!r || !r.hostPresent || !r.guests.has(rec.id)) return;
271
+ let msg;
272
+ try {
273
+ msg = JSON.parse(data.toString('utf8'));
274
+ } catch {
275
+ return;
276
+ }
277
+ if (!isObj(msg)) return;
278
+ // Build the host-bound message with the relay-stamped id (never the browser's).
279
+ let out = null;
280
+ switch (msg.t) {
281
+ case 'key':
282
+ out = { t: TYPES.KEY, id: rec.id, data: msg.data };
283
+ break;
284
+ case 'resize':
285
+ if (Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
286
+ rec.cols = msg.cols;
287
+ rec.rows = msg.rows;
288
+ }
289
+ out = { t: TYPES.RESIZE, id: rec.id, cols: msg.cols, rows: msg.rows };
290
+ break;
291
+ case 'pointer':
292
+ out = { t: TYPES.POINTER, id: rec.id, x: msg.x, y: msg.y };
293
+ break;
294
+ case 'ui':
295
+ out = { t: TYPES.UI, id: rec.id, action: msg.action };
296
+ break;
297
+ default:
298
+ return;
299
+ }
300
+ if (validate(out)) safeWrite(r.hostStream, encode(out)); // fail closed on anything malformed
301
+ });
302
+
303
+ ws.on('close', () => onGuestGone(rec));
304
+ }
305
+
306
+ function close() {
307
+ for (const ws of wss.clients) {
308
+ try {
309
+ ws.terminate();
310
+ } catch {
311
+ /* already gone */
312
+ }
313
+ }
314
+ try {
315
+ wss.close();
316
+ } catch {
317
+ /* not started */
318
+ }
319
+ try {
320
+ server.close();
321
+ } catch {
322
+ /* not listening */
323
+ }
324
+ }
325
+
326
+ return new Promise((resolve, reject) => {
327
+ server.once('error', reject);
328
+ server.listen(port, host, function () {
329
+ server.removeListener('error', reject);
330
+ server.on('error', () => {}); // post-listen errors shouldn't crash the process
331
+ resolve({ close, port: this.address().port, address: this.address() });
332
+ });
333
+ });
334
+ }
@@ -0,0 +1,38 @@
1
+ // Room-code vocabulary: speakable, friendly, lowercase single words.
2
+ // Codes read as `adjective-animal` (e.g. brave-otter). ~100+ of each keeps the
3
+ // 2-word space >10k combinations, so collisions at creation are rare.
4
+
5
+ export const adjectives = [
6
+ 'brave', 'calm', 'clever', 'bright', 'bold', 'gentle', 'happy', 'jolly',
7
+ 'kind', 'lively', 'merry', 'nimble', 'proud', 'quiet', 'rapid', 'shiny',
8
+ 'smooth', 'swift', 'witty', 'zesty', 'amber', 'azure', 'cosmic', 'crimson',
9
+ 'dapper', 'eager', 'fancy', 'fuzzy', 'golden', 'grassy', 'husky', 'icy',
10
+ 'ivory', 'jade', 'keen', 'lucky', 'mellow', 'misty', 'mighty', 'noble',
11
+ 'olive', 'plucky', 'prime', 'royal', 'rustic', 'sandy', 'silver', 'snowy',
12
+ 'solar', 'spry', 'stormy', 'sunny', 'teal', 'tidy', 'tiny', 'vivid',
13
+ 'wavy', 'wise', 'zippy', 'breezy', 'cheery', 'cozy', 'crisp', 'curly',
14
+ 'dandy', 'dewy', 'dreamy', 'dusky', 'earthy', 'feisty', 'fleet', 'frosty',
15
+ 'glossy', 'hazy', 'humble', 'jazzy', 'leafy', 'lunar', 'minty', 'mossy',
16
+ 'peppy', 'perky', 'quirky', 'rosy', 'salty', 'scarlet', 'shady', 'sleek',
17
+ 'spicy', 'starry', 'sturdy', 'sylvan', 'thrifty', 'upbeat', 'velvet',
18
+ 'woolly', 'zealous', 'gleaming', 'radiant', 'spirited', 'cheerful',
19
+ 'dazzling', 'graceful', 'playful', 'twinkly',
20
+ ];
21
+
22
+ export const animals = [
23
+ 'otter', 'falcon', 'badger', 'beaver', 'bison', 'cheetah', 'cobra', 'condor',
24
+ 'coyote', 'cricket', 'dolphin', 'dingo', 'eagle', 'egret', 'ferret', 'finch',
25
+ 'gecko', 'gibbon', 'giraffe', 'goose', 'gopher', 'heron', 'hornet', 'ibex',
26
+ 'iguana', 'jackal', 'jaguar', 'kestrel', 'koala', 'lemur', 'leopard', 'lizard',
27
+ 'llama', 'lynx', 'magpie', 'marmot', 'meerkat', 'mole', 'moose', 'moth',
28
+ 'narwhal', 'newt', 'ocelot', 'octopus', 'orca', 'osprey', 'panda', 'panther',
29
+ 'parrot', 'pelican', 'penguin', 'pheasant', 'pigeon', 'puffin', 'python',
30
+ 'quail', 'rabbit', 'raccoon', 'raven', 'robin', 'salmon', 'seal', 'shark',
31
+ 'sparrow', 'spider', 'squid', 'stoat', 'stork', 'swan', 'tapir', 'tiger',
32
+ 'toucan', 'turtle', 'urchin', 'vulture', 'walrus', 'weasel', 'whale', 'wolf',
33
+ 'wombat', 'wren', 'yak', 'zebra', 'alpaca', 'antelope', 'armadillo', 'buffalo',
34
+ 'camel', 'caribou', 'chipmunk', 'cougar', 'crane', 'dove', 'elk', 'gazelle',
35
+ 'hare', 'hedgehog', 'hamster', 'kangaroo', 'kingfisher', 'mackerel', 'manatee',
36
+ 'mongoose', 'mustang', 'platypus', 'quokka', 'reindeer', 'salamander',
37
+ 'starling', 'terrier',
38
+ ];
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@claude-share/shared",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "private": true,
6
+ "type": "module",
7
+ "main": "protocol.js"
8
+ }
@@ -0,0 +1,189 @@
1
+ // claude-share wire protocol: JSON-lines over the host's ssh channel to the relay.
2
+ // Guests speak no protocol — they exchange raw terminal bytes. Only the host<->relay
3
+ // control channel uses these messages.
4
+
5
+ /** Canonical message-type strings (the `t` field of every message). */
6
+ export const TYPES = Object.freeze({
7
+ HELLO: 'hello', // host->relay: {t:'hello', want:'room', secret?, pass?} — new room (secret = relay
8
+ // room-creation credential; pass = optional per-room JOIN password guests must present)
9
+ RECLAIM: 'reclaim', // host->relay: {t:'reclaim', code} — take back an existing room after a drop
10
+ ROOM: 'room', // relay->host: {t:'room', code} — room granted (create OR reclaim)
11
+ GONE: 'gone', // relay->host: {t:'gone', code} — reclaim refused (expired / wrong key)
12
+ REFUSED: 'refused', // relay->host: {t:'refused', reason} — HELLO rejected ('secret' = bad/missing room secret)
13
+ KNOCK: 'knock', // relay->host: {t:'knock', id, name, fp, seen}
14
+ ADMIT: 'admit', // host->relay: {t:'admit', id}
15
+ DENY: 'deny', // host->relay: {t:'deny', id}
16
+ JOINED: 'joined', // relay->host: {t:'joined', id}
17
+ LEFT: 'left', // relay->host: {t:'left', id}
18
+ KEY: 'key', // relay->host: {t:'key', id, data} (guest keystrokes, base64)
19
+ RESIZE: 'resize', // relay->host: {t:'resize', id, cols, rows}
20
+ SCREEN: 'screen', // host->relay: {t:'screen', data} (broadcast frame, base64)
21
+ TO: 'to', // host->relay: {t:'to', id, data} (one guest only, base64)
22
+ DROP: 'drop', // host->relay: {t:'drop', id, ban}
23
+ END: 'end', // host->relay: {t:'end'}
24
+
25
+ // ── overlay-state channel (browser multiplayer surface) ────────────────────
26
+ // The browser is the multiplayer surface for everyone (host included): a plain
27
+ // ssh guest still exchanges raw bytes, but a browser view renders a rich overlay
28
+ // from the host's snapshot and mails back pointer moves and button-driven commands.
29
+ STATE: 'state', // host->relay: {t:'state', data:{room, participants:[{id,name,role,color}],
30
+ // drafts:<Drafts.snapshot()>, queue:[{n,author,text}], claudeState:'busy'|'idle'|'ask',
31
+ // paused:bool, pointers:{id:{x,y,name,color}}, knocks:[{id,name,fp,seen}]}} — full
32
+ // overlay snapshot, emitted on every brain change (throttled ≤1/50ms); the relay
33
+ // fans it out to browser views. Knocks live IN the state (the host admits from the browser).
34
+ POINTER: 'pointer', // guest->host (relay stamps `id` = sender): {t:'pointer', id?, x, y}
35
+ // x/y normalized 0..1 over the terminal canvas; the host rebroadcasts via state.pointers.
36
+ UI: 'ui', // guest->host (relay stamps `id` = sender): {t:'ui', id?, action}
37
+ // action = {kind:'admit'|'deny', id} | {kind:'command', text}. The command text runs
38
+ // through the existing parser AS that sender, so the role gate still applies.
39
+ });
40
+
41
+ /**
42
+ * Serialize a message to a newline-terminated JSON Buffer, ready to write to a stream.
43
+ * @param {object} obj
44
+ * @returns {Buffer}
45
+ */
46
+ export function encode(obj) {
47
+ return Buffer.from(JSON.stringify(obj) + '\n');
48
+ }
49
+
50
+ // Newline-free buffer cap (10 MB). Real messages are tiny (a full-repaint screen
51
+ // frame is a few KB of base64), so a partial line this large is a peer that
52
+ // never terminates — a memory-exhaustion flood. We drop it rather than grow.
53
+ const MAX_BUF = 10 * 1024 * 1024;
54
+
55
+ /**
56
+ * Stateful, per-connection stream decoder. Feed it raw chunks (Buffer or string);
57
+ * it buffers partial lines and returns an array of parsed messages once each line
58
+ * is complete. One instance per ssh channel.
59
+ */
60
+ export class Decoder {
61
+ #buf = '';
62
+
63
+ /**
64
+ * @param {Buffer|string} chunk
65
+ * @returns {object[]} messages completed by this chunk (possibly empty)
66
+ */
67
+ push(chunk) {
68
+ this.#buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
69
+ const out = [];
70
+ let nl;
71
+ while ((nl = this.#buf.indexOf('\n')) !== -1) {
72
+ const line = this.#buf.slice(0, nl);
73
+ this.#buf = this.#buf.slice(nl + 1);
74
+ if (line.trim() === '') continue;
75
+ try {
76
+ out.push(JSON.parse(line));
77
+ } catch {
78
+ // Malformed line: drop it (spec: the relay and host drop anything that
79
+ // fails the check). A single unparseable line must never sever the
80
+ // connection or swallow the valid messages that follow it.
81
+ }
82
+ }
83
+ // Only a newline-free remainder is left now. If it has outgrown the cap, no
84
+ // terminator is coming — clear it so a peer can't exhaust memory.
85
+ if (this.#buf.length > MAX_BUF) this.#buf = '';
86
+ return out;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Stateless one-shot decode of a chunk that contains only complete lines
92
+ * (e.g. the output of `encode`, which always ends in a newline). A trailing
93
+ * partial line is ignored. For streaming input use a {@link Decoder}.
94
+ * @param {Buffer|string} chunk
95
+ * @returns {object[]}
96
+ */
97
+ export function decode(chunk) {
98
+ return new Decoder().push(chunk);
99
+ }
100
+
101
+ const isStr = (v) => typeof v === 'string';
102
+ const isNum = (v) => Number.isFinite(v);
103
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
104
+
105
+ // A {t:'ui'} action: a knock verdict (admit/deny, carrying the target knock id), a
106
+ // roster action (role/kick, carrying the target PARTICIPANT id — not a @-mention, so
107
+ // duplicate/space/unicode names can't mis-target), or a button-driven command (command
108
+ // text). Anything else is rejected — this is the boundary that lets a browser button
109
+ // drive the host, so it fails closed.
110
+ function isUiAction(a) {
111
+ if (!isObj(a)) return false;
112
+ if (a.kind === 'admit' || a.kind === 'deny' || a.kind === 'kick') return isStr(a.id);
113
+ if (a.kind === 'role') return isStr(a.id) && isStr(a.role);
114
+ if (a.kind === 'command') return isStr(a.text);
115
+ if (a.kind === 'caret') return isStr(a.id) && isNum(a.offset); // click into a draft
116
+ if (a.kind === 'delrange') return isStr(a.id) && isNum(a.start) && isNum(a.end); // delete a selection
117
+ if (a.kind === 'deldraft') return isStr(a.id); // delete a whole draft box
118
+ if (a.kind === 'place') return isStr(a.id) && (a.home === true || (isNum(a.x) && isNum(a.y))); // move/resize a draft (shared)
119
+ if (a.kind === 'unqueue') return isNum(a.n); // pull a queued prompt back into a draft
120
+ if (a.kind === 'scroll') return isNum(a.lines); // wheel over the mirror → Claude's transcript scroll
121
+ if (a.kind === 'resync') return true; // host: force claude-state idle (missed hook)
122
+ return false;
123
+ }
124
+
125
+ /**
126
+ * Shallow structural validation of a decoded message. Returns true only for a
127
+ * known type whose required fields are present and correctly typed. The relay
128
+ * and host drop anything that fails this check.
129
+ * @param {unknown} obj
130
+ * @returns {boolean}
131
+ */
132
+ export function validate(obj) {
133
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return false;
134
+ switch (obj.t) {
135
+ case TYPES.HELLO:
136
+ // secret (optional): the relay-wide room-creation credential. Compared on
137
+ // the relay when configured; a plain relay ignores it.
138
+ // pass (optional): a join password for THIS room — the relay stores it and
139
+ // pre-gates every guest (ssh prompt / web form) before the knock is sent.
140
+ return (
141
+ obj.want === 'room' &&
142
+ (obj.secret === undefined || isStr(obj.secret)) &&
143
+ (obj.pass === undefined || isStr(obj.pass))
144
+ );
145
+ case TYPES.REFUSED:
146
+ return isStr(obj.reason);
147
+ case TYPES.ROOM:
148
+ // webUrl (optional): a deployed relay's public browser base URL — the host
149
+ // builds its printed/copied links from it instead of ssh-host:webPort.
150
+ return isStr(obj.code) && (obj.webUrl === undefined || isStr(obj.webUrl));
151
+ case TYPES.RECLAIM:
152
+ case TYPES.GONE:
153
+ return isStr(obj.code);
154
+ case TYPES.KNOCK:
155
+ return (
156
+ isStr(obj.id) &&
157
+ isStr(obj.name) &&
158
+ isStr(obj.fp) &&
159
+ (isStr(obj.seen) || typeof obj.seen === 'boolean' || obj.seen === null)
160
+ );
161
+ case TYPES.ADMIT:
162
+ case TYPES.DENY:
163
+ case TYPES.JOINED:
164
+ case TYPES.LEFT:
165
+ return isStr(obj.id);
166
+ case TYPES.KEY:
167
+ case TYPES.TO:
168
+ return isStr(obj.id) && isStr(obj.data);
169
+ case TYPES.RESIZE:
170
+ return isStr(obj.id) && isNum(obj.cols) && isNum(obj.rows);
171
+ case TYPES.SCREEN:
172
+ return isStr(obj.data);
173
+ case TYPES.DROP:
174
+ return isStr(obj.id) && typeof obj.ban === 'boolean';
175
+ case TYPES.END:
176
+ return true;
177
+ // Overlay channel. Shallow, like the rest: `data` need only be a plain object
178
+ // (browser views read it defensively). pointer/ui carry an optional sender `id`
179
+ // the relay stamps on the guest→host hop; guest→relay forms omit it.
180
+ case TYPES.STATE:
181
+ return isObj(obj.data);
182
+ case TYPES.POINTER:
183
+ return isNum(obj.x) && isNum(obj.y) && (obj.id === undefined || isStr(obj.id));
184
+ case TYPES.UI:
185
+ return (obj.id === undefined || isStr(obj.id)) && isUiAction(obj.action);
186
+ default:
187
+ return false;
188
+ }
189
+ }