@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,27 @@
1
+ // Pending-knock bookkeeping (spec §knock). One card per guest: a WS reconnect during
2
+ // the join flow re-knocks with a NEW connection id but the SAME fingerprint, and the
3
+ // stock ssh/browser flows can double-fire a knock too. Without dedup the host sees two
4
+ // cards for one guest and can admit the stale one (finding 3), landing a phantom in the
5
+ // roster and count. So a re-knock from the same fingerprint REPLACES the prior pending
6
+ // knock rather than stacking a duplicate.
7
+ //
8
+ // Pure, so it is unit-tested without a relay. bin/claude-share.js calls foldKnock on
9
+ // each relay KNOCK, swaps in the returned list, and denies the `replaced` connections.
10
+
11
+ /**
12
+ * Fold a new knock into the pending list, replacing any earlier pending knock from the
13
+ * same (non-empty) fingerprint. Keyless / tokenless guests carry an empty fp — every
14
+ * one of those is a distinct person, so they are never deduped.
15
+ *
16
+ * @param {Array<{id:string,name:string,fp:string,seen:*}>} pending current pending knocks
17
+ * @param {{id:string,name:string,fp:string,seen:*}} knock the arriving knock
18
+ * @returns {{pending: Array, replaced: string[]}} new list (newest knock last) + the
19
+ * connection ids of the superseded knocks the caller should deny/forget
20
+ */
21
+ export function foldKnock(pending, knock) {
22
+ const list = Array.isArray(pending) ? pending : [];
23
+ if (!knock || !knock.fp) return { pending: [...list, knock], replaced: [] };
24
+ const replaced = list.filter((k) => k.fp === knock.fp).map((k) => k.id);
25
+ const kept = list.filter((k) => k.fp !== knock.fp);
26
+ return { pending: [...kept, knock], replaced };
27
+ }
@@ -0,0 +1,123 @@
1
+ // The in-memory attributed log — the room's who-typed-what record (spec §host
2
+ // controls, §join context card). It captures three kinds of entry:
3
+ //
4
+ // prompt — a sent draft, attributed to its author (feeds the card + /recap)
5
+ // event — a room event: joins, leaves, role changes, kicks, mode flips, pause
6
+ // tool — a Claude PostToolUse, with the files it touched (feeds the card)
7
+ //
8
+ // The log lives only for the session. On /end the host chooses whether to persist
9
+ // it: N discards it, Y writes session.md via write(). toText() is the plain
10
+ // transcript /recap hands to a one-shot `claude -p`.
11
+
12
+ import fs from 'node:fs';
13
+
14
+ export class Log {
15
+ #entries = [];
16
+ #files = [];
17
+ #now;
18
+ #startedAt;
19
+
20
+ /** @param {object} [opts] @param {() => number} [opts.now] injectable clock */
21
+ constructor({ now = () => Date.now() } = {}) {
22
+ this.#now = now;
23
+ this.#startedAt = now();
24
+ }
25
+
26
+ /** Record an attributed prompt. */
27
+ prompt(author, text, at = this.#now()) {
28
+ const e = { kind: 'prompt', author, text: String(text), at };
29
+ this.#entries.push(e);
30
+ return e;
31
+ }
32
+
33
+ /** Record a room event (join/leave/role/kick/mode/pause). */
34
+ event(text, at = this.#now()) {
35
+ const e = { kind: 'event', text: String(text), at };
36
+ this.#entries.push(e);
37
+ return e;
38
+ }
39
+
40
+ /** Record a tool use and merge any touched files into the ordered unique list. */
41
+ tool(name, files = [], at = this.#now()) {
42
+ const list = Array.isArray(files) ? files : [];
43
+ const e = { kind: 'tool', name, files: [...list], at };
44
+ for (const f of list) if (f && !this.#files.includes(f)) this.#files.push(f);
45
+ this.#entries.push(e);
46
+ return e;
47
+ }
48
+
49
+ /** All entries, as copies. */
50
+ get entries() {
51
+ return this.#entries.map((e) => ({ ...e }));
52
+ }
53
+
54
+ /** The de-duplicated files touched so far, in first-touch order. */
55
+ get files() {
56
+ return [...this.#files];
57
+ }
58
+
59
+ get startedAt() {
60
+ return this.#startedAt;
61
+ }
62
+
63
+ /** Just the prompt entries. */
64
+ prompts() {
65
+ return this.#entries.filter((e) => e.kind === 'prompt').map((e) => ({ ...e }));
66
+ }
67
+
68
+ /** The last `n` prompts, oldest→newest. */
69
+ recentPrompts(n = 3) {
70
+ const p = this.prompts();
71
+ return p.slice(Math.max(0, p.length - n));
72
+ }
73
+
74
+ /** Author of the most recent prompt, or null. */
75
+ lastPromptAuthor() {
76
+ const p = this.#entries.filter((e) => e.kind === 'prompt');
77
+ return p.length ? p[p.length - 1].author : null;
78
+ }
79
+
80
+ /** Plain attributed transcript — the input /recap feeds to `claude -p`. */
81
+ toText() {
82
+ return this.#entries
83
+ .map((e) => {
84
+ if (e.kind === 'prompt') return `${e.author}: ${e.text}`;
85
+ if (e.kind === 'event') return `* ${e.text}`;
86
+ if (e.kind === 'tool') return `[tool] ${e.name}${e.files.length ? ' ' + e.files.join(', ') : ''}`;
87
+ return '';
88
+ })
89
+ .join('\n');
90
+ }
91
+
92
+ /**
93
+ * Render the attributed log as a Markdown session record.
94
+ * @param {object} [opts]
95
+ * @param {string} [opts.title] document title
96
+ * @returns {string}
97
+ */
98
+ toMarkdown({ title = 'claude-share session' } = {}) {
99
+ const rel = (at) => {
100
+ const s = Math.max(0, Math.round((at - this.#startedAt) / 1000));
101
+ const mm = String(Math.floor(s / 60)).padStart(2, '0');
102
+ const ss = String(s % 60).padStart(2, '0');
103
+ return `${mm}:${ss}`;
104
+ };
105
+ const lines = [`# ${title}`, ''];
106
+ for (const e of this.#entries) {
107
+ if (e.kind === 'prompt') lines.push(`- \`${rel(e.at)}\` **${e.author}**: ${e.text}`);
108
+ else if (e.kind === 'event') lines.push(`- \`${rel(e.at)}\` _${e.text}_`);
109
+ else if (e.kind === 'tool')
110
+ lines.push(`- \`${rel(e.at)}\` \`${e.name}\`${e.files.length ? ' — ' + e.files.join(', ') : ''}`);
111
+ }
112
+ if (this.#files.length) {
113
+ lines.push('', '## Files touched', '', ...this.#files.map((f) => `- ${f}`));
114
+ }
115
+ return lines.join('\n') + '\n';
116
+ }
117
+
118
+ /** Write the Markdown record to a file (spec §host controls: /end → session.md). */
119
+ write(filePath, opts) {
120
+ fs.writeFileSync(filePath, this.toMarkdown(opts));
121
+ return filePath;
122
+ }
123
+ }
@@ -0,0 +1,81 @@
1
+ // The attributed queue — where sent drafts land while Claude is busy, and from
2
+ // which they drain, in order, one per idle turn (spec §queue). Pure logic; the
3
+ // wiring calls drain() from the 'idle' hook handler and feeds the released item
4
+ // to the PTY.
5
+ //
6
+ // Permissions (spec): the author may edit or delete their own item; the host and
7
+ // the host may delete anyone's. Draining is fail-closed — it only releases an item
8
+ // when Claude is *known* idle; any other (or unknown) state keeps the queue frozen
9
+ // so a missed hook can never auto-fire a stranger's prompt.
10
+
11
+ import { atLeast } from './state.js';
12
+
13
+ export class Queue {
14
+ #items = [];
15
+ #seq = 0;
16
+
17
+ /** Append an attributed item; returns the stored record (with its id). */
18
+ enqueue(text, author) {
19
+ const item = { id: `q${++this.#seq}`, text: String(text), author };
20
+ this.#items.push(item);
21
+ return item;
22
+ }
23
+
24
+ /** Defensive copies so a caller can't mutate queue internals through a read. */
25
+ get items() {
26
+ return this.#items.map((i) => ({ ...i }));
27
+ }
28
+
29
+ get length() {
30
+ return this.#items.length;
31
+ }
32
+
33
+ /** The front item (copy) without removing it, or null. */
34
+ peek() {
35
+ return this.#items[0] ? { ...this.#items[0] } : null;
36
+ }
37
+
38
+ /**
39
+ * Edit a queued item's text. Author-only (spec): the host can DELETE any
40
+ * item but may not rewrite what someone else meant to say.
41
+ * @returns {boolean} true if edited
42
+ */
43
+ edit(id, text, byUserId, _byRole) {
44
+ const it = this.#items.find((i) => i.id === id);
45
+ if (!it || it.author !== byUserId) return false;
46
+ it.text = String(text);
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Delete a queued item. Allowed for the author, or for the host on any item.
52
+ * @returns {boolean} true if removed
53
+ */
54
+ remove(id, byUserId, byRole) {
55
+ const idx = this.#items.findIndex((i) => i.id === id);
56
+ if (idx === -1) return false;
57
+ const it = this.#items[idx];
58
+ if (it.author !== byUserId && !atLeast(byRole, 'host')) return false;
59
+ this.#items.splice(idx, 1);
60
+ return true;
61
+ }
62
+
63
+ /** Drop every item authored by a user (they left or were kicked). Returns the count. */
64
+ removeByAuthor(userId) {
65
+ const before = this.#items.length;
66
+ this.#items = this.#items.filter((i) => i.author !== userId);
67
+ return before - this.#items.length;
68
+ }
69
+
70
+ /**
71
+ * Release the front item — but only when Claude is *known* idle. Any other state
72
+ * (busy, ask, or an ambiguous/undefined signal) returns null and leaves the queue
73
+ * untouched (fail closed, spec §agent-state-detection).
74
+ * @param {string} claudeState the current hook-derived state
75
+ * @returns {{id:string,text:string,author:string}|null}
76
+ */
77
+ drain(claudeState) {
78
+ if (claudeState !== 'idle') return null;
79
+ return this.#items.shift() ?? null;
80
+ }
81
+ }
@@ -0,0 +1,245 @@
1
+ // Room state — the host brain's canonical view of who is in the room, what role
2
+ // each holds, Claude's current permission mode, whether sharing is paused, and
3
+ // how big the shared screen may be (spec §roles, §renderer size clamp).
4
+ //
5
+ // Pure state, no I/O. The relay client feeds it join/leave/resize; the hook
6
+ // listener feeds it the mode; commands mutate roles/pause. The renderer and the
7
+ // join-context card read it. Roles rank here so gate.js / queue.js / commands.js
8
+ // share one source of truth for "who outranks whom".
9
+
10
+ /** Role names, lowest privilege first. */
11
+ export const ROLES = Object.freeze(['viewer', 'prompter', 'host']);
12
+
13
+ /** Numeric privilege rank — higher answers more (spec §roles table). */
14
+ export const ROLE_RANK = Object.freeze({ viewer: 0, prompter: 1, host: 2 });
15
+
16
+ /**
17
+ * Is `role` at least as privileged as `min`? Fails closed: an unknown actor role
18
+ * ranks below everything, and an unknown required floor is unreachable.
19
+ * @param {string} role
20
+ * @param {string} min
21
+ * @returns {boolean}
22
+ */
23
+ export function atLeast(role, min) {
24
+ const have = ROLE_RANK[role];
25
+ const need = ROLE_RANK[min];
26
+ if (have == null || need == null) return false;
27
+ return have >= need;
28
+ }
29
+
30
+ // Spec §renderer: the shared view is clamped to the smallest participant's
31
+ // terminal, floored at 80×24. A participant below the floor spectates instead of
32
+ // shrinking the room for everyone.
33
+ export const FLOOR_COLS = 80;
34
+ export const FLOOR_ROWS = 24;
35
+
36
+ // Stable participant colors for the browser overlay (spec §roles: "color is a
37
+ // bonus — colorblind-safe by not being load-bearing"; name tags carry identity).
38
+ // Eight distinct hues, assigned by the brain per fingerprint so a returning key
39
+ // keeps its color on reconnect. Kept accessible/distinct; never load-bearing.
40
+ export const PALETTE = Object.freeze([
41
+ '#e5484d', // red
42
+ '#f76b15', // orange
43
+ '#ffb224', // amber
44
+ '#30a46c', // green
45
+ '#0091ff', // blue
46
+ '#8e4ec6', // purple
47
+ '#e93d82', // pink
48
+ '#12a594', // teal
49
+ ]);
50
+
51
+ /** The host's own participant id (the host is not a relay guest). */
52
+ export const HOST_ID = 'host';
53
+
54
+ export class RoomState {
55
+ /** @type {Map<string,{id:string,name:string,fp:string|null,role:string,cols?:number,rows?:number}>} */
56
+ participants = new Map();
57
+ mode = 'default';
58
+ paused = false;
59
+ room = null;
60
+ startedAt;
61
+
62
+ #defaultRole;
63
+ #now;
64
+ // fp -> the last role that fingerprint held this session. A returning key resumes
65
+ // its seat's role on reconnect (spec §identity: name, color, role restored on
66
+ // reconnect); keyless guests (no fp) are never recorded and rejoin as new.
67
+ #seatRoles = new Map();
68
+ // colorKey -> palette color. The key is a fingerprint (so a returning key keeps its
69
+ // color) or, for the host and keyless guests, the participant id. Assigned in first-
70
+ // seen order and wrapped modulo the palette so it never runs out.
71
+ #colorByKey = new Map();
72
+
73
+ /**
74
+ * @param {object} [opts]
75
+ * @param {string} [opts.hostName='host'] display name for the host
76
+ * @param {string} [opts.defaultRole='prompter'] role new guests get (spec default)
77
+ * @param {{cols:number,rows:number}} [opts.hostSize] the host terminal size
78
+ * @param {() => number} [opts.now] injectable clock (ms)
79
+ */
80
+ constructor({ hostName = 'host', defaultRole = 'prompter', hostSize, now = () => Date.now() } = {}) {
81
+ this.#now = now;
82
+ this.#defaultRole = defaultRole;
83
+ this.startedAt = now();
84
+ this.participants.set(HOST_ID, {
85
+ id: HOST_ID,
86
+ name: hostName,
87
+ fp: null,
88
+ role: 'host',
89
+ color: this.#assignColor(HOST_ID),
90
+ cols: hostSize?.cols,
91
+ rows: hostSize?.rows,
92
+ });
93
+ }
94
+
95
+ // Return the stable color for a color key, assigning the next palette slot on
96
+ // first sight. Reused for a returning fingerprint (same key ⇒ same color).
97
+ #assignColor(key) {
98
+ const existing = this.#colorByKey.get(key);
99
+ if (existing) return existing;
100
+ const color = PALETTE[this.#colorByKey.size % PALETTE.length];
101
+ this.#colorByKey.set(key, color);
102
+ return color;
103
+ }
104
+
105
+ /** The palette color assigned to a participant, or null if unknown. */
106
+ colorOf(id) {
107
+ return this.participants.get(id)?.color ?? null;
108
+ }
109
+
110
+ setRoom(code) {
111
+ this.room = code;
112
+ }
113
+
114
+ /** Update the permission mode; returns true only if it actually changed. */
115
+ setMode(mode) {
116
+ if (mode === this.mode) return false;
117
+ this.mode = mode;
118
+ return true;
119
+ }
120
+
121
+ setPaused(paused) {
122
+ this.paused = !!paused;
123
+ }
124
+
125
+ get(id) {
126
+ return this.participants.get(id);
127
+ }
128
+
129
+ roleOf(id) {
130
+ return this.participants.get(id)?.role ?? null;
131
+ }
132
+
133
+ nameOf(id) {
134
+ return this.participants.get(id)?.name ?? null;
135
+ }
136
+
137
+ /** All participants including the host, in insertion order. */
138
+ list() {
139
+ return [...this.participants.values()];
140
+ }
141
+
142
+ /** Just the guests (everyone but the host). */
143
+ guests() {
144
+ return this.list().filter((p) => p.id !== HOST_ID);
145
+ }
146
+
147
+ /**
148
+ * Add a guest. A returning fingerprint (one that held a seat earlier this session)
149
+ * resumes the role it last held — spec §identity: role is restored on reconnect,
150
+ * and §failure-behavior: "reconnect with same key restores identity + role". A new
151
+ * or keyless guest gets the explicit role, else the room default. Returns the record.
152
+ */
153
+ addGuest(id, { name, fp, role } = {}) {
154
+ const restored = fp ? this.#seatRoles.get(fp) : undefined;
155
+ const p = {
156
+ id,
157
+ name: name ?? 'guest',
158
+ fp: fp ?? null,
159
+ role: restored ?? role ?? this.#defaultRole,
160
+ // Keyed guests color by fingerprint (restored on reconnect); keyless by id.
161
+ color: this.#assignColor(fp ?? id),
162
+ cols: undefined,
163
+ rows: undefined,
164
+ };
165
+ this.participants.set(id, p);
166
+ return p;
167
+ }
168
+
169
+ /**
170
+ * Drop a guest. The host can never be removed. A keyed guest's current role is
171
+ * remembered against its fingerprint so a same-key reconnect resumes the seat.
172
+ */
173
+ removeGuest(id) {
174
+ if (id === HOST_ID) return false;
175
+ const p = this.participants.get(id);
176
+ if (p?.fp) this.#seatRoles.set(p.fp, p.role);
177
+ return this.participants.delete(id);
178
+ }
179
+
180
+ /**
181
+ * Set a guest's role. Refuses to touch the host, to promote anyone to host, or
182
+ * to set an unknown role (spec: host is fixed; /role targets prompter|viewer).
183
+ */
184
+ setRole(id, role) {
185
+ const p = this.participants.get(id);
186
+ if (!p || id === HOST_ID) return false;
187
+ if (!Object.prototype.hasOwnProperty.call(ROLE_RANK, role) || role === 'host') return false;
188
+ p.role = role;
189
+ return true;
190
+ }
191
+
192
+ /** Record a participant's terminal size (host or guest). */
193
+ setSize(id, cols, rows) {
194
+ const p = this.participants.get(id);
195
+ if (!p) return false;
196
+ p.cols = cols;
197
+ p.rows = rows;
198
+ return true;
199
+ }
200
+
201
+ /** True if this participant's terminal is below the 80×24 floor (they spectate). */
202
+ belowFloor(id) {
203
+ const p = this.participants.get(id);
204
+ if (!p) return false;
205
+ return (p.cols != null && p.cols < FLOOR_COLS) || (p.rows != null && p.rows < FLOOR_ROWS);
206
+ }
207
+
208
+ /**
209
+ * Guest ids eligible for the live mirror — at or above the 80×24 floor. Below-floor
210
+ * guests are excluded: they spectate on a "make your terminal bigger" hint instead
211
+ * of receiving a shared view sized for larger terminals (spec §renderer clamp).
212
+ */
213
+ mirrorTargets() {
214
+ return this.guests().filter((p) => !this.belowFloor(p.id)).map((p) => p.id);
215
+ }
216
+
217
+ /** Guest ids parked on the spectate hint (below the floor). */
218
+ spectators() {
219
+ return this.guests().filter((p) => this.belowFloor(p.id)).map((p) => p.id);
220
+ }
221
+
222
+ /**
223
+ * The shared view dimensions: the component-wise minimum over participants that
224
+ * meet the floor, then raised to the 80×24 floor. Below-floor participants are
225
+ * excluded (they spectate) and participants of unknown size do not constrain it.
226
+ * @returns {{cols:number, rows:number}}
227
+ */
228
+ clamp() {
229
+ let cols = Infinity;
230
+ let rows = Infinity;
231
+ for (const p of this.participants.values()) {
232
+ if (this.belowFloor(p.id)) continue;
233
+ if (p.cols != null) cols = Math.min(cols, p.cols);
234
+ if (p.rows != null) rows = Math.min(rows, p.rows);
235
+ }
236
+ if (!Number.isFinite(cols)) cols = FLOOR_COLS;
237
+ if (!Number.isFinite(rows)) rows = FLOOR_ROWS;
238
+ return { cols: Math.max(FLOOR_COLS, cols), rows: Math.max(FLOOR_ROWS, rows) };
239
+ }
240
+
241
+ /** Milliseconds since the session started (uses the injected clock). */
242
+ ageMs() {
243
+ return this.#now() - this.startedAt;
244
+ }
245
+ }