@claudecollab/cli 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -7
- package/package.json +2 -3
- package/packages/cli/bin/claude-share.js +298 -24
- package/packages/cli/src/brain/ask.js +67 -0
- package/packages/cli/src/brain/card.js +7 -3
- package/packages/cli/src/brain/commands.js +5 -1
- package/packages/cli/src/brain/history.js +72 -0
- package/packages/cli/src/brain/log.js +21 -3
- package/packages/cli/src/brain/state.js +24 -2
- package/packages/cli/src/brain/transcript.js +65 -0
- package/packages/cli/src/ctl-client.js +119 -0
- package/packages/cli/src/ctl.js +97 -0
- package/packages/cli/src/first-run.js +166 -0
- package/packages/cli/src/pty.js +38 -0
- package/packages/cli/src/relay-client.js +13 -3
- package/packages/cli/src/room-file.js +22 -0
- package/packages/cli/src/setup-actions.js +163 -0
- package/packages/cli/src/shim.js +125 -0
- package/packages/relay/bin/serve.js +2 -0
- package/packages/relay/public/client.js +206 -41
- package/packages/relay/public/index.html +15 -0
- package/packages/relay/public/style.css +232 -3
- package/packages/relay/rooms.js +5 -2
- package/packages/relay/server.js +34 -3
- package/packages/relay/web.js +102 -24
- package/packages/shared/protocol.js +11 -1
|
@@ -28,6 +28,11 @@ import { execFile } from 'node:child_process';
|
|
|
28
28
|
import { randomBytes } from 'node:crypto';
|
|
29
29
|
import ssh2 from 'ssh2';
|
|
30
30
|
import { startPty } from '../src/pty.js';
|
|
31
|
+
import { stripShimDir } from '../src/shim.js';
|
|
32
|
+
import { shouldRunSetup, runSetup, setupMain } from '../src/setup-actions.js';
|
|
33
|
+
import { startCtl } from '../src/ctl.js';
|
|
34
|
+
import { ctlMain } from '../src/ctl-client.js';
|
|
35
|
+
import { writeRoomFile, clearRoomFile } from '../src/room-file.js';
|
|
31
36
|
import { paint, ROLE_GLYPH } from '../src/renderer.js';
|
|
32
37
|
import { ScreenSnapshot } from '../src/screen-snapshot.js';
|
|
33
38
|
import { installHooks, listenHooks } from '../src/hooks.js';
|
|
@@ -35,18 +40,26 @@ import { connectRelay, parseRelayUrl, openingMove } from '../src/relay-client.js
|
|
|
35
40
|
import { createPinCheck } from '../src/known-relays.js';
|
|
36
41
|
import { hostUrl, inviteUrl, readyToast } from '../src/invite.js';
|
|
37
42
|
import { createPartitioner } from '../src/term-chatter.js';
|
|
38
|
-
import { RoomState, HOST_ID, atLeast, FLOOR_COLS, FLOOR_ROWS } from '../src/brain/state.js';
|
|
43
|
+
import { RoomState, HOST_ID, atLeast, FLOOR_COLS, FLOOR_ROWS, isScaledViewer } from '../src/brain/state.js';
|
|
39
44
|
import { Queue } from '../src/brain/queue.js';
|
|
40
45
|
import { Log } from '../src/brain/log.js';
|
|
41
46
|
import { Drafts } from '../src/brain/drafts.js';
|
|
47
|
+
import { History } from '../src/brain/history.js';
|
|
48
|
+
import { extractLatestResponse } from '../src/brain/transcript.js';
|
|
42
49
|
import { dispatch, classifySend, sendAllowed } from '../src/brain/gate.js';
|
|
43
50
|
import { parse as parseCommand, permitted as commandPermitted, resolveMention } from '../src/brain/commands.js';
|
|
44
51
|
import { build as buildCard, recapCard } from '../src/brain/card.js';
|
|
45
52
|
import { foldKnock } from '../src/brain/knocks.js';
|
|
53
|
+
import { askContext, summarizeToolInput } from '../src/brain/ask.js';
|
|
46
54
|
|
|
47
55
|
function parseArgs(argv) {
|
|
48
56
|
const opts = {
|
|
49
57
|
relay: true,
|
|
58
|
+
// Lazy by default (spec phase 5a): a fresh `collab` starts pixel-identical to
|
|
59
|
+
// plain claude and does NOT dial the relay until go-live (`collab go`). --live
|
|
60
|
+
// restores the old behavior — dial at startup — for rigs, tests, and anyone who
|
|
61
|
+
// wants a room immediately. --no-relay still means never share.
|
|
62
|
+
live: false,
|
|
50
63
|
// Default to the community relay so a fresh `collab` needs zero setup; dev
|
|
51
64
|
// rigs and tests always pass --relay explicitly (ssh://127.0.0.1:2222).
|
|
52
65
|
relayUrl: 'ssh://claudecollab.org:2222',
|
|
@@ -59,12 +72,16 @@ function parseArgs(argv) {
|
|
|
59
72
|
secret: process.env.CLAUDE_SHARE_SECRET || undefined,
|
|
60
73
|
fingerprint: undefined, // explicit relay key pin (SHA256:…); default is TOFU
|
|
61
74
|
roomPassword: undefined, // optional join password guests must present before knocking
|
|
75
|
+
cap: undefined, // optional requested room size (--max-guests); the relay clamps it
|
|
76
|
+
yes: false, // --yes: skip the first-run setup screen (for scripts / non-interactive)
|
|
62
77
|
childArgs: [],
|
|
63
78
|
};
|
|
64
79
|
const passthrough = [];
|
|
65
80
|
for (let i = 0; i < argv.length; i++) {
|
|
66
81
|
const a = argv[i];
|
|
67
82
|
if (a === '--no-relay') opts.relay = false;
|
|
83
|
+
else if (a === '--live') opts.live = true;
|
|
84
|
+
else if (a === '--yes') opts.yes = true;
|
|
68
85
|
else if (a === '--relay') opts.relayUrl = argv[++i] ?? opts.relayUrl;
|
|
69
86
|
else if (a === '--no-hooks') opts.hooks = false;
|
|
70
87
|
else if (a === '--web-port') opts.webPort = Math.max(1, Number(argv[++i]) || opts.webPort);
|
|
@@ -73,7 +90,10 @@ function parseArgs(argv) {
|
|
|
73
90
|
else if (a === '--secret') opts.secret = argv[++i] ?? opts.secret;
|
|
74
91
|
else if (a === '--fingerprint') opts.fingerprint = argv[++i] ?? opts.fingerprint;
|
|
75
92
|
else if (a === '--room-password') opts.roomPassword = argv[++i] ?? opts.roomPassword;
|
|
76
|
-
else if (a === '--') {
|
|
93
|
+
else if (a === '--max-guests') {
|
|
94
|
+
const n = Math.floor(Number(argv[++i]));
|
|
95
|
+
if (Number.isFinite(n) && n > 0) opts.cap = n; // a bad value is ignored (relay default stands)
|
|
96
|
+
} else if (a === '--') {
|
|
77
97
|
passthrough.push(...argv.slice(i + 1));
|
|
78
98
|
break;
|
|
79
99
|
} else passthrough.push(a); // unknown flags pass through to the child
|
|
@@ -114,16 +134,53 @@ function loadHostKey() {
|
|
|
114
134
|
async function main() {
|
|
115
135
|
const opts = parseArgs(process.argv.slice(2));
|
|
116
136
|
const { stdin, stdout } = process;
|
|
137
|
+
// The room-creation secret is the WRAPPER's credential, captured into opts.secret
|
|
138
|
+
// above — scrub it from our environment NOW so no child ever inherits it (the pty
|
|
139
|
+
// child, hook processes, /recap's `claude -p`). Without this, the Claude inside a
|
|
140
|
+
// shared session could be prompted by any guest to echo $CLAUDE_SHARE_SECRET into
|
|
141
|
+
// the mirrored screen.
|
|
142
|
+
delete process.env.CLAUDE_SHARE_SECRET;
|
|
143
|
+
|
|
144
|
+
// ── first run: the one-screen setup (installs /collab + shims `claude`) ────────
|
|
145
|
+
// Shown ONCE, only on a real interactive run. The bold hazard: rigs/tests spawn
|
|
146
|
+
// interactive PTYs, so this MUST be gated off for them — every rig/e2e host spawn
|
|
147
|
+
// sets CLAUDE_SHARE_SKIP_SETUP=1 (shouldRunSetup honors it), or the suite hangs
|
|
148
|
+
// here at the picker. runSetup writes the shown-once marker when it completes.
|
|
149
|
+
if (shouldRunSetup({ stdinTTY: stdin.isTTY, stdoutTTY: stdout.isTTY, skipEnv: process.env.CLAUDE_SHARE_SKIP_SETUP, yes: opts.yes })) {
|
|
150
|
+
try {
|
|
151
|
+
await runSetup({ input: stdin, output: stdout });
|
|
152
|
+
} catch {
|
|
153
|
+
/* setup is best-effort — never block the session on it */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
117
156
|
// The terminal band is permanently exactly ONE status line, pinned to the bottom
|
|
118
157
|
// row. Claude gets every other row; the child PTY is resized to rows-1 each repaint.
|
|
119
158
|
const BAND_ROWS = 1;
|
|
120
159
|
const multiplayer = opts.relay; // the guest composer/gate + mirror are active only when sharing
|
|
160
|
+
// Lazy sharing (spec phase 5a): pre-live, the wrapper is pixel-identical to plain
|
|
161
|
+
// claude — the relay is not dialed and the band paints ZERO rows, so the child owns
|
|
162
|
+
// every terminal row. wantLive flips exactly once, at go-live (--live at startup, or
|
|
163
|
+
// `collab go` over the control socket). isLive() gates every sharing-shaped behavior
|
|
164
|
+
// (band height, the shared-size clamp); bandRows() is the one height source.
|
|
165
|
+
let wantLive = opts.live;
|
|
166
|
+
const isLive = () => multiplayer && wantLive;
|
|
167
|
+
const bandRows = () => (isLive() ? BAND_ROWS : 0);
|
|
121
168
|
// The host's browser tab authenticates with this token: it knocks with fingerprint
|
|
122
169
|
// `webhost:<token>:<seat>`, and the brain auto-admits it as host. The token goes in
|
|
123
170
|
// the room URL we print/copy; the SEAT does not — the browser mints it locally, so
|
|
124
171
|
// possessing the (leak-prone) URL is not enough to take the host seat.
|
|
125
172
|
const hostToken = randomBytes(16).toString('hex');
|
|
126
173
|
const HOST_FP_PREFIX = `webhost:${hostToken}`;
|
|
174
|
+
// The room file the wrapped Claude reads to learn its own invite link. Its PATH is
|
|
175
|
+
// ALWAYS exported (CLAUDE_SHARE_ROOM_FILE, below) so "running under collab" is
|
|
176
|
+
// detectable; the file only EXISTS while a room is live (written on grant/reclaim,
|
|
177
|
+
// removed on gone/give-up/exit). Whitelisted: the host token never reaches it.
|
|
178
|
+
const roomFile = path.join(os.tmpdir(), `claude-share-room-${process.pid}.json`);
|
|
179
|
+
// The control socket the wrapped Claude drives with `collab go|off|status` (spec
|
|
180
|
+
// phase 5a). Its PATH is exported as CLAUDE_SHARE_CTL for every spawn; the socket
|
|
181
|
+
// itself is created below (0600, same-user only) and unlinked in cleanup(). The
|
|
182
|
+
// host URL/token NEVER crosses it — replies carry room + inviteUrl only.
|
|
183
|
+
const ctlPath = path.join(os.tmpdir(), `claude-share-ctl-${process.pid}.sock`);
|
|
127
184
|
// The seat this session is bound to: the first host browser's seat secret claims
|
|
128
185
|
// it; later browsers presenting the token with a different/absent seat are refused
|
|
129
186
|
// (leaked-link defense). Reset by a host-terminal Ctrl-G handoff (see below).
|
|
@@ -163,6 +220,7 @@ async function main() {
|
|
|
163
220
|
const queue = new Queue();
|
|
164
221
|
const log = new Log();
|
|
165
222
|
const drafts = new Drafts();
|
|
223
|
+
const history = new History(); // per-turn who-typed-what + Claude's response
|
|
166
224
|
const toasted = new Set(); // userIds already shown the viewer toast
|
|
167
225
|
const knockInfo = new Map(); // id -> {name, fp} captured at knock time
|
|
168
226
|
const pointers = new Map(); // userId -> {x, y} normalized 0..1 (browser cursors)
|
|
@@ -177,6 +235,12 @@ async function main() {
|
|
|
177
235
|
const admittedFps = new Set();
|
|
178
236
|
const claude = { state: 'idle' };
|
|
179
237
|
let armed = false; // true only while a permission ask is pending (hook-armed)
|
|
238
|
+
// What Claude is asking permission for (tool + a short, stripControls'd input
|
|
239
|
+
// summary), surfaced in the state snapshot while claude.state === 'ask' so the
|
|
240
|
+
// browser's ask card can show it. `lastTool` remembers the most recent tool this
|
|
241
|
+
// turn (its input is the best summary we get); both clear when the turn moves on.
|
|
242
|
+
let askInfo = null;
|
|
243
|
+
let lastTool = null;
|
|
180
244
|
let pendingKnocks = []; // FIFO of guest {id,name,fp,seen} awaiting the host tab's admit
|
|
181
245
|
let currentUrl = null; // the host-tab URL (WITH host token) — host's own terminal only
|
|
182
246
|
let inviteUrlStr = null; // the token-free invite URL — safe to show guests in the mirror
|
|
@@ -185,6 +249,7 @@ async function main() {
|
|
|
185
249
|
// text is dropped (drafts are created explicitly — never by stray typing).
|
|
186
250
|
const nudgedAt = new Map();
|
|
187
251
|
let relay = null;
|
|
252
|
+
let ctl = null; // the control-socket server (started below; closed in cleanup)
|
|
188
253
|
let exited = false;
|
|
189
254
|
// Reconnect-with-reclaim state (spec §failure-behavior: the relay holds the code
|
|
190
255
|
// 10 min after a host drop). On a lost connection we reconnect and RECLAIM the
|
|
@@ -217,7 +282,16 @@ async function main() {
|
|
|
217
282
|
|
|
218
283
|
let pty;
|
|
219
284
|
try {
|
|
220
|
-
|
|
285
|
+
// CLAUDE_SHARE_ROOM_FILE is set for EVERY spawn, solo included: its presence
|
|
286
|
+
// tells the child it's running under collab; the file at that path appears only
|
|
287
|
+
// once a room is live (phase 5's skill relies on that live-vs-not distinction).
|
|
288
|
+
const childEnv = { CLAUDE_SHARE_ROOM_FILE: roomFile, CLAUDE_SHARE_CTL: ctlPath };
|
|
289
|
+
// Recursion guard: when the wrapped command is `claude`, strip the shim dir from
|
|
290
|
+
// the child's PATH so it resolves to the REAL claude, never back to the shim that
|
|
291
|
+
// execs us (stripShimDir is spike-proven; see src/shim.js). Any other --cmd keeps
|
|
292
|
+
// PATH untouched.
|
|
293
|
+
if (opts.cmd === 'claude') childEnv.PATH = stripShimDir(process.env.PATH, os.homedir());
|
|
294
|
+
pty = await startPty({ cmd: opts.cmd, args: childArgs, bandRows: bandRows(), env: childEnv });
|
|
221
295
|
} catch (err) {
|
|
222
296
|
process.stderr.write(
|
|
223
297
|
`collab: could not start "${opts.cmd}": ${err.message}\n` +
|
|
@@ -272,9 +346,12 @@ async function main() {
|
|
|
272
346
|
// the mirror letterboxed until the first guest join reshaped it.
|
|
273
347
|
// Solo with no tab there is no shared view, so we use the host's own terminal.
|
|
274
348
|
const viewSize = () => {
|
|
275
|
-
|
|
349
|
+
// Pre-live (and solo) the child owns the real terminal exactly — no floor clamp,
|
|
350
|
+
// no resize theft. The shared-size clamp only governs once we are actually live.
|
|
351
|
+
if (!isLive()) return { cols: stdout.columns || 80, rows: stdout.rows || 24 };
|
|
276
352
|
let { cols, rows } = state.clamp();
|
|
277
353
|
for (const s of hostTabSizes.values()) {
|
|
354
|
+
if (isScaledViewer(s)) continue; // a below-floor host tab scales too — ignore its size
|
|
278
355
|
cols = Math.min(cols, s.cols);
|
|
279
356
|
rows = Math.min(rows, s.rows);
|
|
280
357
|
}
|
|
@@ -310,7 +387,11 @@ async function main() {
|
|
|
310
387
|
participants: state.list().map((p) => ({ id: p.id, name: p.name, role: p.role, color: p.color })),
|
|
311
388
|
drafts: drafts.snapshot(),
|
|
312
389
|
queue: queue.items.map((it, i) => ({ n: i + 1, author: it.author, text: it.text })),
|
|
390
|
+
history: history.snapshot(),
|
|
313
391
|
claudeState: claude.state,
|
|
392
|
+
// What Claude is asking permission for, only while it is actually asking. The
|
|
393
|
+
// browser ask card reads this; approve/deny are ordinary keystrokes (role-gated).
|
|
394
|
+
ask: claude.state === 'ask' ? askInfo : null,
|
|
314
395
|
paused: state.paused,
|
|
315
396
|
pointers: Object.fromEntries(
|
|
316
397
|
[...pointers]
|
|
@@ -366,7 +447,7 @@ async function main() {
|
|
|
366
447
|
// Claude's region is resized to rows-1 so the line can never overlap its output.
|
|
367
448
|
const repaintBand = () => {
|
|
368
449
|
const { cols, rows } = viewSize();
|
|
369
|
-
const band =
|
|
450
|
+
const band = bandRows(); // 0 pre-live (paint() no-ops, child keeps every row), 1 once live
|
|
370
451
|
|
|
371
452
|
// Keep Claude's region to exactly the rows the band does not occupy, at the
|
|
372
453
|
// shared width — resize the child only when that actually changes.
|
|
@@ -508,6 +589,32 @@ async function main() {
|
|
|
508
589
|
t.unref?.();
|
|
509
590
|
};
|
|
510
591
|
|
|
592
|
+
// Claude's response to the turn that just finished, read from the session
|
|
593
|
+
// transcript the Stop hook hands us (`transcript_path`). We read only the tail —
|
|
594
|
+
// the latest turn is at the end — so a long session's transcript stays cheap, and
|
|
595
|
+
// a truncated leading line is tolerated by the extractor. Any failure → '' (the
|
|
596
|
+
// window still shows the prompt; the response just stays blank).
|
|
597
|
+
const RESPONSE_TAIL_BYTES = 256 * 1024;
|
|
598
|
+
const captureResponse = (payload) => {
|
|
599
|
+
const file = payload && typeof payload.transcript_path === 'string' ? payload.transcript_path : null;
|
|
600
|
+
if (!file) return '';
|
|
601
|
+
try {
|
|
602
|
+
const fd = fs.openSync(file, 'r');
|
|
603
|
+
try {
|
|
604
|
+
const { size } = fs.fstatSync(fd);
|
|
605
|
+
const start = Math.max(0, size - RESPONSE_TAIL_BYTES);
|
|
606
|
+
const len = size - start;
|
|
607
|
+
const buf = Buffer.alloc(len);
|
|
608
|
+
fs.readSync(fd, buf, 0, len, start);
|
|
609
|
+
return extractLatestResponse(buf.toString('utf8'));
|
|
610
|
+
} finally {
|
|
611
|
+
fs.closeSync(fd);
|
|
612
|
+
}
|
|
613
|
+
} catch {
|
|
614
|
+
return '';
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
511
618
|
const pump = () => {
|
|
512
619
|
if (Date.now() < ptyLockUntil) {
|
|
513
620
|
const retry = setTimeout(pump, 160);
|
|
@@ -529,6 +636,7 @@ async function main() {
|
|
|
529
636
|
}, 120);
|
|
530
637
|
enter.unref?.();
|
|
531
638
|
claude.state = 'busy'; // optimistic; the UserPromptSubmit hook confirms
|
|
639
|
+
history.start(item.author, item.text); // open a turn; Stop closes it with the response
|
|
532
640
|
armSubmitWatch();
|
|
533
641
|
repaintBand();
|
|
534
642
|
};
|
|
@@ -704,7 +812,7 @@ async function main() {
|
|
|
704
812
|
runRecap(by);
|
|
705
813
|
break;
|
|
706
814
|
case 'end':
|
|
707
|
-
endSession();
|
|
815
|
+
endSession(parsed.save);
|
|
708
816
|
break;
|
|
709
817
|
}
|
|
710
818
|
repaintBand();
|
|
@@ -727,13 +835,15 @@ async function main() {
|
|
|
727
835
|
|
|
728
836
|
// /end — end the room now. The two-step "end? / save?" confirmation is a browser-UI
|
|
729
837
|
// concern (the host tab gates it and fires a single /end); the terminal has no y/n
|
|
730
|
-
// path anymore.
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
function endSession() {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
838
|
+
// path anymore. The browser's "Save & end" sends `/end` (save) and "Just end" sends
|
|
839
|
+
// `/end nosave` (skip). `save` defaults to true so a bare host-typed /end still keeps
|
|
840
|
+
// the record; a nosave never touches disk (so it can't overwrite an existing file).
|
|
841
|
+
function endSession(save = true) {
|
|
842
|
+
if (save) {
|
|
843
|
+
try {
|
|
844
|
+
log.write(path.join(process.cwd(), 'session.md'));
|
|
845
|
+
} catch {}
|
|
846
|
+
}
|
|
737
847
|
relay?.end();
|
|
738
848
|
cleanup(0);
|
|
739
849
|
}
|
|
@@ -956,6 +1066,8 @@ async function main() {
|
|
|
956
1066
|
if (!atLeast(role, 'host')) return;
|
|
957
1067
|
claude.state = 'idle';
|
|
958
1068
|
armed = false;
|
|
1069
|
+
askInfo = null;
|
|
1070
|
+
lastTool = null;
|
|
959
1071
|
log.event('host resynced claude state to idle');
|
|
960
1072
|
showToast('state resynced to idle');
|
|
961
1073
|
pump();
|
|
@@ -1007,13 +1119,23 @@ async function main() {
|
|
|
1007
1119
|
// identically. Handlers use their own instance `r` for replies, so a stale
|
|
1008
1120
|
// instance can never write to (or resurrect) a superseded connection.
|
|
1009
1121
|
function wireRelay(r) {
|
|
1122
|
+
// A superseded instance (off() tore it down mid-dial, or a reconnect replaced
|
|
1123
|
+
// it) must never mutate room state: a stale late ROOM grant would resurrect a
|
|
1124
|
+
// session the user just turned off, and a stale REFUSED would veto future gos.
|
|
1125
|
+
// onClose below already carries the same guard.
|
|
1126
|
+
const current = () => r === relay;
|
|
1010
1127
|
r.onRoom((code, webUrl) => {
|
|
1128
|
+
if (!current()) return;
|
|
1011
1129
|
reconnectAttempts = 0;
|
|
1012
1130
|
publicBase = webUrl || null; // a deployed relay's public https origin (or none)
|
|
1013
1131
|
const reclaimed = state.room === code; // we already held this exact code → reclaim
|
|
1014
1132
|
state.setRoom(code);
|
|
1015
1133
|
currentUrl = hostRoomUrl(code); // the host's own tab URL lives in the status line
|
|
1016
1134
|
inviteUrlStr = inviteRoomUrl(code); // the token-free variant the mirror shows guests
|
|
1135
|
+
// Expose the live room's INVITE link to the wrapped Claude (whitelist drops the
|
|
1136
|
+
// token-bearing host URL). Runs on create AND reclaim — a reclaim may carry a
|
|
1137
|
+
// fresh publicBase, so the file is always rewritten from the current values.
|
|
1138
|
+
writeRoomFile(roomFile, { room: code, inviteUrl: inviteUrlStr, webUrl: publicBase ?? undefined });
|
|
1017
1139
|
if (reclaimed) {
|
|
1018
1140
|
showToast('reconnected — room reclaimed', 6000);
|
|
1019
1141
|
return;
|
|
@@ -1023,24 +1145,38 @@ async function main() {
|
|
|
1023
1145
|
copyInvite(inviteRoomUrl(code), (copied) => showToast(readyToast(copied), 15000));
|
|
1024
1146
|
});
|
|
1025
1147
|
r.onGone(() => {
|
|
1148
|
+
if (!current()) return;
|
|
1026
1149
|
// Reclaim refused: the 10-min TTL lapsed, the relay restarted (fresh
|
|
1027
1150
|
// registry), or the room truly ended. Nothing to return to — drop the code
|
|
1028
1151
|
// AND its links (a dead URL on the band reads as a live room), finish solo.
|
|
1029
1152
|
state.setRoom(null);
|
|
1030
1153
|
currentUrl = null;
|
|
1031
1154
|
inviteUrlStr = null;
|
|
1155
|
+
clearRoomFile(roomFile); // the room is gone — the child must not read a dead link
|
|
1032
1156
|
showToast('the room expired while disconnected — continuing solo', 8000);
|
|
1033
1157
|
repaintBand();
|
|
1034
1158
|
});
|
|
1035
1159
|
r.onRefused((reason) => {
|
|
1160
|
+
if (!current()) return;
|
|
1036
1161
|
// The relay rejected our HELLO outright. 'secret' = it requires a room
|
|
1037
|
-
// secret we didn't present (or ours is wrong)
|
|
1162
|
+
// secret we didn't present (or ours is wrong); 'version' = it speaks a newer
|
|
1163
|
+
// protocol than we do (update the CLI). Terminal, not transient.
|
|
1038
1164
|
relayVetoed = true;
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1165
|
+
let msg;
|
|
1166
|
+
if (reason === 'secret') msg = 'relay requires a room secret — set CLAUDE_SHARE_SECRET (or --secret) and restart. running solo';
|
|
1167
|
+
else if (reason === 'version') msg = 'relay speaks a newer protocol — update with: npm update -g @claudecollab/cli. running solo';
|
|
1168
|
+
else msg = `relay refused the connection (${reason}) — running solo`;
|
|
1169
|
+
// A refusal means sharing never began — after the explanatory toast has had
|
|
1170
|
+
// its 15s on the band, return the session to fully invisible (band gone, the
|
|
1171
|
+
// child reclaims the row) instead of sticking a dead live-band on a solo
|
|
1172
|
+
// session. goLiveStarted resets so a later `go` (fixed secret) can redial.
|
|
1043
1173
|
showToast(msg, 15000);
|
|
1174
|
+
const invis = setTimeout(() => {
|
|
1175
|
+
wantLive = false;
|
|
1176
|
+
goLiveStarted = false;
|
|
1177
|
+
repaintBand();
|
|
1178
|
+
}, 15000);
|
|
1179
|
+
invis.unref?.();
|
|
1044
1180
|
repaintBand();
|
|
1045
1181
|
});
|
|
1046
1182
|
r.onKnock((knock) => {
|
|
@@ -1159,7 +1295,9 @@ async function main() {
|
|
|
1159
1295
|
// CLI was missing. Failures on a held room retry within the TTL; an initial
|
|
1160
1296
|
// failure just runs solo.
|
|
1161
1297
|
function openRelay() {
|
|
1162
|
-
|
|
1298
|
+
// wantLive is the invariant: only a live-intending session may dial. Guards any
|
|
1299
|
+
// stray timer/callback that survives an off() teardown (belt to off's braces).
|
|
1300
|
+
if (exited || relayVetoed || !wantLive) return;
|
|
1163
1301
|
// Fresh pin check per attempt (it re-reads the store, picking up a pin the
|
|
1164
1302
|
// previous attempt just made). null = loopback dev relay, no verification.
|
|
1165
1303
|
const pin =
|
|
@@ -1173,6 +1311,7 @@ async function main() {
|
|
|
1173
1311
|
privateKey: loadHostKey(),
|
|
1174
1312
|
secret: opts.secret,
|
|
1175
1313
|
roomPass: opts.roomPassword,
|
|
1314
|
+
cap: opts.cap,
|
|
1176
1315
|
verifyHostKey: pin ? pin.verify : undefined,
|
|
1177
1316
|
});
|
|
1178
1317
|
} catch (err) {
|
|
@@ -1215,6 +1354,7 @@ async function main() {
|
|
|
1215
1354
|
if (exited) return;
|
|
1216
1355
|
if (!state.room) return showToast('relay disconnected — running solo', 8000);
|
|
1217
1356
|
if (reconnectAttempts >= MAX_RECONNECT) {
|
|
1357
|
+
clearRoomFile(roomFile); // gave up reclaiming — the room is no longer reachable
|
|
1218
1358
|
return showToast('could not reconnect to the relay — continuing solo', 8000);
|
|
1219
1359
|
}
|
|
1220
1360
|
reconnectAttempts++;
|
|
@@ -1224,7 +1364,108 @@ async function main() {
|
|
|
1224
1364
|
reconnectTimer.unref?.();
|
|
1225
1365
|
}
|
|
1226
1366
|
|
|
1227
|
-
|
|
1367
|
+
// Go live on demand: dial the relay and flip wantLive so the band appears and the
|
|
1368
|
+
// child shrinks by one row (via repaintBand's resize path). Idempotent — the guard
|
|
1369
|
+
// makes it callable exactly once, whether from --live at startup or `collab go`
|
|
1370
|
+
// mid-session over the control socket. --no-relay can never go live.
|
|
1371
|
+
let goLiveStarted = false;
|
|
1372
|
+
function goLive() {
|
|
1373
|
+
if (goLiveStarted || exited || !opts.relay) return;
|
|
1374
|
+
goLiveStarted = true;
|
|
1375
|
+
wantLive = true;
|
|
1376
|
+
openRelay();
|
|
1377
|
+
repaintBand(); // band appears; the child is resized to rows-1
|
|
1378
|
+
}
|
|
1379
|
+
if (opts.relay && opts.live) goLive();
|
|
1380
|
+
|
|
1381
|
+
// ── control socket: `collab go|off|status` drives the room lifecycle ──────────
|
|
1382
|
+
// The wrapped Claude connects here (CLAUDE_SHARE_CTL) to go live / stop / query
|
|
1383
|
+
// WITHOUT restarting. Replies carry room + inviteUrl only — never the host URL or
|
|
1384
|
+
// token (those stay on the host's own terminal / room file whitelist).
|
|
1385
|
+
ctl = startCtl({
|
|
1386
|
+
path: ctlPath,
|
|
1387
|
+
handlers: {
|
|
1388
|
+
// Go live: apply any valid overrides, dial the relay, and resolve on the FIRST
|
|
1389
|
+
// of room-granted / relay-refused / 15s timeout. Idempotent when already live.
|
|
1390
|
+
go: async (req) => {
|
|
1391
|
+
if (!opts.relay) return { ok: false, error: 'sharing disabled (--no-relay)' };
|
|
1392
|
+
if (state.room) return { ok: true, room: state.room, inviteUrl: inviteUrlStr ?? undefined };
|
|
1393
|
+
// Overrides (each validated; a bad value is ignored so the current setting stands).
|
|
1394
|
+
if (req.guests === 'viewer' || req.guests === 'prompter') opts.guests = req.guests;
|
|
1395
|
+
if (Number.isFinite(req.max) && Math.floor(req.max) > 0) opts.cap = Math.floor(req.max);
|
|
1396
|
+
if (typeof req.pass === 'string' && req.pass.length) opts.roomPassword = req.pass;
|
|
1397
|
+
goLive();
|
|
1398
|
+
if (!relay) return { ok: false, error: 'sharing unavailable' };
|
|
1399
|
+
// One-shot waiter over the live relay instance — do not restructure wireRelay,
|
|
1400
|
+
// just resolve on whichever event lands first. wireRelay's own onRoom runs
|
|
1401
|
+
// before this one (subscribed earlier), so inviteUrlStr is set by the time
|
|
1402
|
+
// we read it. onClose covers a dead/vetoed relay so `go` can't hang 15s.
|
|
1403
|
+
return await new Promise((resolve) => {
|
|
1404
|
+
let done = false;
|
|
1405
|
+
let offRoom, offRefused, offClose, timer;
|
|
1406
|
+
const finish = (v) => {
|
|
1407
|
+
if (done) return;
|
|
1408
|
+
done = true;
|
|
1409
|
+
offRoom?.();
|
|
1410
|
+
offRefused?.();
|
|
1411
|
+
offClose?.();
|
|
1412
|
+
clearTimeout(timer);
|
|
1413
|
+
resolve(v);
|
|
1414
|
+
};
|
|
1415
|
+
offRoom = relay.onRoom((code) => finish({ ok: true, room: code, inviteUrl: inviteUrlStr ?? inviteRoomUrl(code) }));
|
|
1416
|
+
offRefused = relay.onRefused((reason) => finish({ ok: false, error: `relay refused (${reason})` }));
|
|
1417
|
+
offClose = relay.onClose(() => finish({ ok: false, error: relayVetoed ? 'relay refused the connection' : 'relay unavailable' }));
|
|
1418
|
+
timer = setTimeout(() => finish({ ok: false, error: 'relay timeout' }), 15000);
|
|
1419
|
+
timer.unref?.();
|
|
1420
|
+
});
|
|
1421
|
+
},
|
|
1422
|
+
// Stop sharing: end the room, tear down the relay, clear the links + room file,
|
|
1423
|
+
// drop the band (repaint), and let a later `go` dial again. Idempotent.
|
|
1424
|
+
off: async () => {
|
|
1425
|
+
if (state.room || relay) {
|
|
1426
|
+
const r = relay;
|
|
1427
|
+
relay = null; // null first so the instance's onClose sees r !== relay (no reconnect)
|
|
1428
|
+
try {
|
|
1429
|
+
r?.end();
|
|
1430
|
+
} catch {}
|
|
1431
|
+
try {
|
|
1432
|
+
r?.close();
|
|
1433
|
+
} catch {}
|
|
1434
|
+
// Disarm any pending reclaim: a reconnect timer left ticking would dial
|
|
1435
|
+
// openRelay() after this teardown and HELLO a brand-new room with the band
|
|
1436
|
+
// hidden — an invisibly-live room, the one thing off() must make impossible.
|
|
1437
|
+
clearTimeout(reconnectTimer);
|
|
1438
|
+
reconnectTimer = null;
|
|
1439
|
+
reconnectAttempts = 0;
|
|
1440
|
+
state.setRoom(null);
|
|
1441
|
+
currentUrl = null;
|
|
1442
|
+
inviteUrlStr = null;
|
|
1443
|
+
clearRoomFile(roomFile);
|
|
1444
|
+
wantLive = false;
|
|
1445
|
+
goLiveStarted = false;
|
|
1446
|
+
// Purge every participant footprint — the process now OUTLIVES the room, so
|
|
1447
|
+
// stale guests would haunt the next go (wrong count, crushed size clamp,
|
|
1448
|
+
// orphaned drafts/queue items). forgetGuest also logs each departure.
|
|
1449
|
+
for (const g of [...state.guests()]) forgetGuest(g.id);
|
|
1450
|
+
pendingKnocks = [];
|
|
1451
|
+
knockInfo.clear();
|
|
1452
|
+
pointers.clear();
|
|
1453
|
+
hostTabIds.clear();
|
|
1454
|
+
hostTabSizes.clear();
|
|
1455
|
+
guestPartitioners.clear();
|
|
1456
|
+
repaintBand(); // band disappears; the child reclaims the row
|
|
1457
|
+
}
|
|
1458
|
+
return { ok: true };
|
|
1459
|
+
},
|
|
1460
|
+
// Query without changing anything. inviteUrl only — never the host URL/token.
|
|
1461
|
+
status: async () => ({
|
|
1462
|
+
ok: true,
|
|
1463
|
+
live: !!state.room,
|
|
1464
|
+
room: state.room ?? undefined,
|
|
1465
|
+
inviteUrl: inviteUrlStr ?? undefined,
|
|
1466
|
+
}),
|
|
1467
|
+
},
|
|
1468
|
+
});
|
|
1228
1469
|
|
|
1229
1470
|
// ── hook events → brain ───────────────────────────────────────────────────────
|
|
1230
1471
|
if (hooks) {
|
|
@@ -1233,21 +1474,30 @@ async function main() {
|
|
|
1233
1474
|
clearSubmitWatch(); // the submission is confirmed — the optimistic busy is real
|
|
1234
1475
|
claude.state = 'busy';
|
|
1235
1476
|
armed = false;
|
|
1477
|
+
askInfo = null; // a new turn is running — forget the previous turn's ask/tool
|
|
1478
|
+
lastTool = null;
|
|
1236
1479
|
repaintBand();
|
|
1237
1480
|
});
|
|
1238
|
-
hooks.on('idle', () => {
|
|
1481
|
+
hooks.on('idle', (payload) => {
|
|
1239
1482
|
lastHookAt = Date.now();
|
|
1240
1483
|
clearSubmitWatch();
|
|
1241
1484
|
claude.state = 'idle';
|
|
1242
1485
|
armed = false;
|
|
1486
|
+
askInfo = null; // the turn closed — the ask (if any) is answered
|
|
1487
|
+
lastTool = null;
|
|
1488
|
+
// Close the open turn with Claude's response, read cleanly from the session
|
|
1489
|
+
// transcript the Stop hook points us at (never scraped from the TUI).
|
|
1490
|
+
if (history.open) history.finish(captureResponse(payload));
|
|
1243
1491
|
pump();
|
|
1244
1492
|
repaintBand();
|
|
1245
1493
|
});
|
|
1246
|
-
hooks.on('ask', () => {
|
|
1494
|
+
hooks.on('ask', (payload) => {
|
|
1247
1495
|
lastHookAt = Date.now();
|
|
1248
1496
|
clearSubmitWatch();
|
|
1249
1497
|
claude.state = 'ask';
|
|
1250
1498
|
armed = true; // the ONLY thing that arms a composing-side y/n → Claude (spec: gate on events)
|
|
1499
|
+
// Capture WHAT Claude is asking (tool + short summary) for the browser ask card.
|
|
1500
|
+
askInfo = askContext(payload, lastTool);
|
|
1251
1501
|
repaintBand();
|
|
1252
1502
|
});
|
|
1253
1503
|
hooks.on('tool', (p) => {
|
|
@@ -1258,11 +1508,13 @@ async function main() {
|
|
|
1258
1508
|
// a real turn flips busy here instead; its Stop still returns to idle.
|
|
1259
1509
|
if (claude.state === 'idle') claude.state = 'busy';
|
|
1260
1510
|
const name = p?.tool_name ?? 'tool';
|
|
1261
|
-
const files = [];
|
|
1262
1511
|
const inp = p?.tool_input ?? {};
|
|
1512
|
+
const files = [];
|
|
1263
1513
|
for (const key of ['file_path', 'path', 'notebook_path']) {
|
|
1264
1514
|
if (typeof inp[key] === 'string') files.push(inp[key]);
|
|
1265
1515
|
}
|
|
1516
|
+
// Remember this tool as the context for an ask that may follow this turn.
|
|
1517
|
+
lastTool = { name, summary: summarizeToolInput(inp) };
|
|
1266
1518
|
log.tool(name, files);
|
|
1267
1519
|
repaintBand();
|
|
1268
1520
|
});
|
|
@@ -1298,6 +1550,9 @@ async function main() {
|
|
|
1298
1550
|
try {
|
|
1299
1551
|
hooks?.close();
|
|
1300
1552
|
} catch {}
|
|
1553
|
+
try {
|
|
1554
|
+
ctl?.close(); // close the control socket and unlink its file
|
|
1555
|
+
} catch {}
|
|
1301
1556
|
for (const f of [settingsFile, socketPath]) {
|
|
1302
1557
|
if (f) {
|
|
1303
1558
|
try {
|
|
@@ -1305,6 +1560,7 @@ async function main() {
|
|
|
1305
1560
|
} catch {}
|
|
1306
1561
|
}
|
|
1307
1562
|
}
|
|
1563
|
+
clearRoomFile(roomFile); // the session is over — the link is dead (covers /end, exit, signals)
|
|
1308
1564
|
stdout.write(TERM_RESTORE + '\r\n[claude-share exited]\r\n');
|
|
1309
1565
|
process.exit(code ?? 0);
|
|
1310
1566
|
};
|
|
@@ -1385,7 +1641,25 @@ async function main() {
|
|
|
1385
1641
|
repaintBand(); // draw the band immediately, before the first frame
|
|
1386
1642
|
}
|
|
1387
1643
|
|
|
1388
|
-
|
|
1644
|
+
// `collab relay [args]` IS the relay (single-bin: npx resolves one bin). serve.js
|
|
1645
|
+
// runs on import and reads process.argv — splice ours out so its args line up.
|
|
1646
|
+
let run;
|
|
1647
|
+
const sub = process.argv[2];
|
|
1648
|
+
if (sub === 'relay') {
|
|
1649
|
+
process.argv.splice(2, 1);
|
|
1650
|
+
run = import('../../relay/bin/serve.js');
|
|
1651
|
+
} else if (sub === 'go' || sub === 'off' || sub === 'status') {
|
|
1652
|
+
// Run INSIDE a wrapped session: drive it via the control socket (CLAUDE_SHARE_CTL)
|
|
1653
|
+
// and print a human-readable result. ctlMain owns its own exit code.
|
|
1654
|
+
run = ctlMain(sub, process.argv.slice(3));
|
|
1655
|
+
} else if (sub === 'setup') {
|
|
1656
|
+
// Re-run the first-run screen + actions ignoring the marker; `--undo` reverses the
|
|
1657
|
+
// shim + plugin. setupMain owns its own exit code.
|
|
1658
|
+
run = setupMain(process.argv.slice(3));
|
|
1659
|
+
} else {
|
|
1660
|
+
run = main();
|
|
1661
|
+
}
|
|
1662
|
+
run.catch((err) => {
|
|
1389
1663
|
process.stderr.write(`collab: ${err?.stack || err}\n`);
|
|
1390
1664
|
process.exit(1);
|
|
1391
1665
|
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// What Claude is asking permission for — the context the browser's ask card shows.
|
|
2
|
+
//
|
|
3
|
+
// The permission gate arms on a `Notification` hook (notification_type ===
|
|
4
|
+
// 'permission_prompt'); that payload carries only a human `message` string (real
|
|
5
|
+
// Claude: "Claude needs your permission to use Bash"), never the tool's structured
|
|
6
|
+
// input. The concrete input (a command, a file path) is only known if a `tool`
|
|
7
|
+
// (PostToolUse) hook fired earlier in the SAME turn — the fake-claude fixture fires
|
|
8
|
+
// one, real Claude may not (there is no PreToolUse hook injected). So we take the
|
|
9
|
+
// best available: the last tool's name + input summary when we have it, otherwise
|
|
10
|
+
// the Notification message.
|
|
11
|
+
//
|
|
12
|
+
// Whatever we surface leaks no more than the host's own log already shows: it is
|
|
13
|
+
// stripControls'd and clamped, so a crafted tool input can never smuggle escape
|
|
14
|
+
// sequences or a wall of text into every guest's screen.
|
|
15
|
+
|
|
16
|
+
import { stripControls } from './log.js';
|
|
17
|
+
|
|
18
|
+
/** Max length of the ask summary shown on the card (spec: a short input summary). */
|
|
19
|
+
export const ASK_SUMMARY_MAX = 120;
|
|
20
|
+
const TOOL_MAX = 40;
|
|
21
|
+
|
|
22
|
+
// Input fields worth showing, most-useful first. A tool's summary is the first of
|
|
23
|
+
// these that is a non-empty string (a command, an edited file, a search pattern…).
|
|
24
|
+
const SUMMARY_FIELDS = ['command', 'file_path', 'path', 'notebook_path', 'pattern', 'prompt', 'url', 'description'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pull a short, safe summary string out of a tool's input object.
|
|
28
|
+
* @param {object} [toolInput]
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function summarizeToolInput(toolInput) {
|
|
32
|
+
const inp = toolInput && typeof toolInput === 'object' ? toolInput : {};
|
|
33
|
+
for (const key of SUMMARY_FIELDS) {
|
|
34
|
+
if (typeof inp[key] === 'string' && inp[key].trim() !== '') return inp[key];
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Collapse whitespace runs (newlines/tabs survive stripControls) to single spaces so
|
|
40
|
+
// the summary is one clean line, then strip controls and clamp.
|
|
41
|
+
function clean(s, max) {
|
|
42
|
+
return stripControls(String(s ?? ''))
|
|
43
|
+
.replace(/\s+/g, ' ')
|
|
44
|
+
.trim()
|
|
45
|
+
.slice(0, max);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Best-effort tool name out of a permission message: "…use Bash", "allow Edit?".
|
|
49
|
+
function toolFromMessage(msg) {
|
|
50
|
+
const m = /\b(?:use|allow|run|permission (?:to use|for))\s+([A-Za-z][A-Za-z0-9_]*)/i.exec(String(msg ?? ''));
|
|
51
|
+
return m ? m[1] : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the ask context for the state snapshot from the arming Notification payload
|
|
56
|
+
* and the last tool seen this turn (either may be absent).
|
|
57
|
+
* @param {object} [payload] the 'ask' (Notification) hook payload
|
|
58
|
+
* @param {{name?:string, summary?:string}|null} [lastTool] last PostToolUse this turn
|
|
59
|
+
* @returns {{tool:string, summary:string}}
|
|
60
|
+
*/
|
|
61
|
+
export function askContext(payload, lastTool) {
|
|
62
|
+
const msg = payload && typeof payload.message === 'string' ? payload.message : '';
|
|
63
|
+
const tool = (lastTool && lastTool.name) || toolFromMessage(msg) || 'tool';
|
|
64
|
+
// Prefer the concrete tool input we saw; else the Notification's human message.
|
|
65
|
+
const rawSummary = (lastTool && lastTool.summary) || msg || '';
|
|
66
|
+
return { tool: clean(tool, TOOL_MAX), summary: clean(rawSummary, ASK_SUMMARY_MAX) };
|
|
67
|
+
}
|