@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
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// ── you're live ──
|
|
17
17
|
|
|
18
18
|
import { ROLE_GLYPH, stringWidth, truncateToWidth } from '../renderer.js';
|
|
19
|
+
import { stripControls } from './log.js';
|
|
19
20
|
|
|
20
21
|
// Keep the box comfortably inside an 80-col floor: content ≤ 74 ⇒ box ≤ 78.
|
|
21
22
|
const MAX_INNER = 74;
|
|
@@ -63,8 +64,11 @@ export function build(state, log, {
|
|
|
63
64
|
const joiner = state.get(joinerId);
|
|
64
65
|
const others = state.list().filter((p) => p.id !== joinerId);
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
// Names and prompt snippets are UNTRUSTED (a pasted name/prompt could carry raw
|
|
68
|
+
// ESC/OSC) and this card is printed straight into every joiner's terminal —
|
|
69
|
+
// sanitize each display copy before it lands in the box.
|
|
70
|
+
const peopleStr = others.map((p) => `${stripControls(p.name)}${glyph(p.role)}`).join(' ');
|
|
71
|
+
const joinStr = joiner ? `you join as ${stripControls(joiner.name)}${glyph(joiner.role)}` : '';
|
|
68
72
|
|
|
69
73
|
const rows = [];
|
|
70
74
|
rows.push(`people ${peopleStr}${joinStr ? ` · ${joinStr}` : ''}`);
|
|
@@ -74,7 +78,7 @@ export function build(state, log, {
|
|
|
74
78
|
if (recent.length === 0) {
|
|
75
79
|
rows.push(' (none yet)');
|
|
76
80
|
} else {
|
|
77
|
-
for (const p of recent) rows.push(` ${p.author} → "${p.text}" (${relAgo(now() - p.at)})`);
|
|
81
|
+
for (const p of recent) rows.push(` ${stripControls(p.author)} → "${stripControls(p.text)}" (${relAgo(now() - p.at)})`);
|
|
78
82
|
}
|
|
79
83
|
const files = log.files;
|
|
80
84
|
const shown = files.slice(0, maxFiles);
|
|
@@ -72,8 +72,12 @@ export function parse(text) {
|
|
|
72
72
|
}
|
|
73
73
|
return { name, error: QUEUE_USAGE };
|
|
74
74
|
}
|
|
75
|
+
case 'end':
|
|
76
|
+
// /end saves session.md by default; /end nosave honors the browser's
|
|
77
|
+
// "Just end" (and any host who typed it) so nothing is written to disk.
|
|
78
|
+
return { name, save: (parts[1] ?? '').toLowerCase() !== 'nosave' };
|
|
75
79
|
default:
|
|
76
|
-
// pause | resume | recap
|
|
80
|
+
// pause | resume | recap — no arguments
|
|
77
81
|
return { name };
|
|
78
82
|
}
|
|
79
83
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The room's turn history — every prompt sent to Claude, attributed to the person
|
|
2
|
+
// who sent it, paired with Claude's response. This is what each person's window
|
|
3
|
+
// shows: "who typed what", and what Claude answered.
|
|
4
|
+
//
|
|
5
|
+
// A turn opens when a queued prompt is actually written to Claude (the drain edge)
|
|
6
|
+
// and closes when Claude finishes (the Stop hook), at which point the response text
|
|
7
|
+
// is attached. At most one turn is open at a time — Claude runs one prompt per idle.
|
|
8
|
+
//
|
|
9
|
+
// The log lives only for the session (matches how rooms already work: a relay
|
|
10
|
+
// restart ends them). Only a bounded tail is kept so the state snapshot the browser
|
|
11
|
+
// polls can never grow without limit.
|
|
12
|
+
|
|
13
|
+
const MAX_TURNS = 60;
|
|
14
|
+
|
|
15
|
+
export class History {
|
|
16
|
+
/** @type {{id:number, author:string, prompt:string, response:string, running:boolean, at:number}[]} */
|
|
17
|
+
#turns = [];
|
|
18
|
+
#openId = null; // id of the turn awaiting a response, or null
|
|
19
|
+
#seq = 0;
|
|
20
|
+
#now;
|
|
21
|
+
|
|
22
|
+
/** @param {object} [opts] @param {() => number} [opts.now] injectable clock */
|
|
23
|
+
constructor({ now = () => Date.now() } = {}) {
|
|
24
|
+
this.#now = now;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Open a turn: a prompt has just gone to Claude. Returns the turn id.
|
|
29
|
+
* @param {string} author participant id who sent it
|
|
30
|
+
* @param {string} prompt the prompt text
|
|
31
|
+
*/
|
|
32
|
+
start(author, prompt) {
|
|
33
|
+
const id = ++this.#seq;
|
|
34
|
+
this.#turns.push({ id, author, prompt: String(prompt ?? ''), response: '', running: true, at: this.#now() });
|
|
35
|
+
if (this.#turns.length > MAX_TURNS) this.#turns.shift();
|
|
36
|
+
this.#openId = id;
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Is a turn currently awaiting its response? */
|
|
41
|
+
get open() {
|
|
42
|
+
return this.#openId != null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Close the open turn with Claude's response text. No-op if nothing is open
|
|
47
|
+
* (e.g. a prompt typed straight into Claude, not from a window).
|
|
48
|
+
* @param {string} response
|
|
49
|
+
*/
|
|
50
|
+
finish(response) {
|
|
51
|
+
if (this.#openId == null) return false;
|
|
52
|
+
const turn = this.#turns.find((t) => t.id === this.#openId);
|
|
53
|
+
if (turn) {
|
|
54
|
+
turn.response = String(response ?? '');
|
|
55
|
+
turn.running = false;
|
|
56
|
+
}
|
|
57
|
+
this.#openId = null;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Renderer-facing serialisation: plain objects, oldest→newest. */
|
|
62
|
+
snapshot() {
|
|
63
|
+
return this.#turns.map((t) => ({
|
|
64
|
+
id: t.id,
|
|
65
|
+
author: t.author,
|
|
66
|
+
prompt: t.prompt,
|
|
67
|
+
response: t.response,
|
|
68
|
+
running: t.running,
|
|
69
|
+
at: t.at,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -11,6 +11,24 @@
|
|
|
11
11
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
|
|
14
|
+
// Escape-injection guard: display copies only — pty-bound text stays raw (Claude
|
|
15
|
+
// must receive real bytes, including newlines). Pasted prompts land verbatim in
|
|
16
|
+
// join cards, the shared log, and session.md; a raw ESC/OSC/BEL there would inject
|
|
17
|
+
// terminal escapes into every guest's terminal and the saved file. This strips
|
|
18
|
+
// ANSI/OSC sequences wholesale, then any remaining C0 controls + DEL — but keeps
|
|
19
|
+
// \n and \t so multi-line prompts and tabs survive as plain text.
|
|
20
|
+
const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])/g;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Remove terminal control/escape sequences from a string, keeping printable text
|
|
24
|
+
* plus newlines and tabs. Safe to run on any untrusted display string.
|
|
25
|
+
* @param {string} s
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function stripControls(s) {
|
|
29
|
+
return String(s).replace(ANSI, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
export class Log {
|
|
15
33
|
#entries = [];
|
|
16
34
|
#files = [];
|
|
@@ -23,16 +41,16 @@ export class Log {
|
|
|
23
41
|
this.#startedAt = now();
|
|
24
42
|
}
|
|
25
43
|
|
|
26
|
-
/** Record an attributed prompt. */
|
|
44
|
+
/** Record an attributed prompt (sanitized for display — the raw text still went to the pty). */
|
|
27
45
|
prompt(author, text, at = this.#now()) {
|
|
28
|
-
const e = { kind: 'prompt', author, text:
|
|
46
|
+
const e = { kind: 'prompt', author: stripControls(author), text: stripControls(text), at };
|
|
29
47
|
this.#entries.push(e);
|
|
30
48
|
return e;
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
/** Record a room event (join/leave/role/kick/mode/pause). */
|
|
34
52
|
event(text, at = this.#now()) {
|
|
35
|
-
const e = { kind: 'event', text:
|
|
53
|
+
const e = { kind: 'event', text: stripControls(text), at };
|
|
36
54
|
this.#entries.push(e);
|
|
37
55
|
return e;
|
|
38
56
|
}
|
|
@@ -33,6 +33,19 @@ export function atLeast(role, min) {
|
|
|
33
33
|
export const FLOOR_COLS = 80;
|
|
34
34
|
export const FLOOR_ROWS = 24;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A scaled viewer reports the sentinel size 0×0 — "I scale, ignore my size". A
|
|
38
|
+
* browser tab below the 80×24 floor sends this instead of its real (tiny) size so
|
|
39
|
+
* the host renders the shared view zoomed on it rather than shrinking the room to
|
|
40
|
+
* fit or parking it on a "make your terminal bigger" hint (mobile room view). Such
|
|
41
|
+
* a participant is excluded from the clamp and is a mirror target, never a spectator.
|
|
42
|
+
* @param {{cols?:number, rows?:number}} p
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isScaledViewer(p) {
|
|
46
|
+
return p != null && p.cols === 0 && p.rows === 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// Stable participant colors for the browser overlay (spec §roles: "color is a
|
|
37
50
|
// bonus — colorblind-safe by not being load-bearing"; name tags carry identity).
|
|
38
51
|
// Eight distinct hues, assigned by the brain per fingerprint so a returning key
|
|
@@ -189,7 +202,11 @@ export class RoomState {
|
|
|
189
202
|
return true;
|
|
190
203
|
}
|
|
191
204
|
|
|
192
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Record a participant's terminal size (host or guest). The sentinel 0×0 is legal
|
|
207
|
+
* and means "scaled viewer" (see isScaledViewer): it is excluded from the clamp
|
|
208
|
+
* and never parks — a below-floor browser tab reports it instead of its real size.
|
|
209
|
+
*/
|
|
193
210
|
setSize(id, cols, rows) {
|
|
194
211
|
const p = this.participants.get(id);
|
|
195
212
|
if (!p) return false;
|
|
@@ -198,10 +215,14 @@ export class RoomState {
|
|
|
198
215
|
return true;
|
|
199
216
|
}
|
|
200
217
|
|
|
201
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* True if this participant's terminal is below the 80×24 floor (they spectate). A
|
|
220
|
+
* scaled viewer (sentinel 0×0) is never below the floor — it renders zoomed instead.
|
|
221
|
+
*/
|
|
202
222
|
belowFloor(id) {
|
|
203
223
|
const p = this.participants.get(id);
|
|
204
224
|
if (!p) return false;
|
|
225
|
+
if (isScaledViewer(p)) return false;
|
|
205
226
|
return (p.cols != null && p.cols < FLOOR_COLS) || (p.rows != null && p.rows < FLOOR_ROWS);
|
|
206
227
|
}
|
|
207
228
|
|
|
@@ -229,6 +250,7 @@ export class RoomState {
|
|
|
229
250
|
let cols = Infinity;
|
|
230
251
|
let rows = Infinity;
|
|
231
252
|
for (const p of this.participants.values()) {
|
|
253
|
+
if (isScaledViewer(p)) continue; // "I scale, ignore my size" — never constrains the room
|
|
232
254
|
if (this.belowFloor(p.id)) continue;
|
|
233
255
|
if (p.cols != null) cols = Math.min(cols, p.cols);
|
|
234
256
|
if (p.rows != null) rows = Math.min(rows, p.rows);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pull Claude's latest response text out of its session transcript.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code writes a JSONL transcript (one message object per line) and hands
|
|
4
|
+
// every hook the path to it (`transcript_path`). We never scrape the repainting
|
|
5
|
+
// TUI for the response — we read the transcript at the Stop edge and take the
|
|
6
|
+
// assistant text produced since the last HUMAN prompt. That's the clean turn.
|
|
7
|
+
//
|
|
8
|
+
// The format has drifted across Claude versions, so every accessor is defensive:
|
|
9
|
+
// content may be a plain string or an array of typed blocks; a "user" line may be
|
|
10
|
+
// a real human prompt OR a tool_result being fed back to the model (which must NOT
|
|
11
|
+
// count as a turn boundary). Anything unparseable is skipped.
|
|
12
|
+
|
|
13
|
+
/** The text of an assistant message, or null if `obj` isn't one. */
|
|
14
|
+
function assistantText(obj) {
|
|
15
|
+
if (!obj || obj.type !== 'assistant') return null;
|
|
16
|
+
const content = obj.message?.content ?? obj.content;
|
|
17
|
+
if (typeof content === 'string') return content;
|
|
18
|
+
if (Array.isArray(content)) {
|
|
19
|
+
const text = content
|
|
20
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
21
|
+
.map((b) => b.text)
|
|
22
|
+
.join('');
|
|
23
|
+
return text || null;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True only for a real human prompt (not a tool_result fed back as a user turn). */
|
|
29
|
+
function isHumanPrompt(obj) {
|
|
30
|
+
if (!obj || obj.type !== 'user') return false;
|
|
31
|
+
const content = obj.message?.content ?? obj.content;
|
|
32
|
+
if (typeof content === 'string') return true;
|
|
33
|
+
if (Array.isArray(content)) {
|
|
34
|
+
if (content.some((b) => b && b.type === 'tool_result')) return false;
|
|
35
|
+
return content.some((b) => b && b.type === 'text' && typeof b.text === 'string');
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract the assistant text produced after the last human prompt in a transcript.
|
|
42
|
+
* @param {string} jsonl the raw JSONL transcript contents (a tail is fine — a
|
|
43
|
+
* truncated leading line is skipped)
|
|
44
|
+
* @returns {string} the response text (may be ''); never throws
|
|
45
|
+
*/
|
|
46
|
+
export function extractLatestResponse(jsonl) {
|
|
47
|
+
const objs = [];
|
|
48
|
+
for (const line of String(jsonl ?? '').split('\n')) {
|
|
49
|
+
const t = line.trim();
|
|
50
|
+
if (!t) continue;
|
|
51
|
+
try {
|
|
52
|
+
objs.push(JSON.parse(t));
|
|
53
|
+
} catch {
|
|
54
|
+
/* a partial/broken line (common when reading a tail) — skip it */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
let lastPrompt = -1;
|
|
58
|
+
for (let i = 0; i < objs.length; i++) if (isHumanPrompt(objs[i])) lastPrompt = i;
|
|
59
|
+
const parts = [];
|
|
60
|
+
for (let i = lastPrompt + 1; i < objs.length; i++) {
|
|
61
|
+
const txt = assistantText(objs[i]);
|
|
62
|
+
if (txt) parts.push(txt);
|
|
63
|
+
}
|
|
64
|
+
return parts.join('\n\n').trim();
|
|
65
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// `collab go|off|status` — the subcommands the wrapped Claude runs INSIDE a session
|
|
2
|
+
// to drive it (spec phase 5a: the plugin's hands). Each reads CLAUDE_SHARE_CTL from
|
|
3
|
+
// the environment, sends one JSON-line request to the control socket, and prints the
|
|
4
|
+
// single reply as human-readable lines Claude can quote straight back to the user.
|
|
5
|
+
//
|
|
6
|
+
// live — room brave-otter
|
|
7
|
+
// invite: https://claudecollab.org/brave-otter
|
|
8
|
+
//
|
|
9
|
+
// No --json flag: the text IS the interface. Not inside a collab session (no
|
|
10
|
+
// CLAUDE_SHARE_CTL) → a friendly stderr hint + exit 2. A relay/handler failure →
|
|
11
|
+
// stderr the error + exit 1. Success → the lines above + exit 0.
|
|
12
|
+
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
|
|
15
|
+
/** Map `collab go` flags to request overrides the host validates. */
|
|
16
|
+
function parseGoArgs(args) {
|
|
17
|
+
const req = {};
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
const a = args[i];
|
|
20
|
+
if (a === '--guests') req.guests = args[++i];
|
|
21
|
+
else if (a === '--max-guests') req.max = Number(args[++i]);
|
|
22
|
+
else if (a === '--room-password') req.pass = args[++i];
|
|
23
|
+
}
|
|
24
|
+
return req;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Send one request line to the control socket and resolve with the parsed reply
|
|
29
|
+
* line. Rejects on connect error, malformed reply, or timeout.
|
|
30
|
+
* @param {string} sockPath the CLAUDE_SHARE_CTL unix socket
|
|
31
|
+
* @param {object} req the request object (must carry v:1)
|
|
32
|
+
* @param {number} ms reply timeout
|
|
33
|
+
*/
|
|
34
|
+
export function sendCtl(sockPath, req, ms = 5000) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const conn = net.connect(sockPath, () => conn.write(JSON.stringify(req) + '\n'));
|
|
37
|
+
let buf = '';
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
conn.destroy();
|
|
40
|
+
reject(new Error('control socket timed out'));
|
|
41
|
+
}, ms);
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
conn.on('data', (d) => {
|
|
44
|
+
buf += d.toString('utf8');
|
|
45
|
+
const nl = buf.indexOf('\n');
|
|
46
|
+
if (nl === -1) return;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
conn.end();
|
|
49
|
+
try {
|
|
50
|
+
resolve(JSON.parse(buf.slice(0, nl)));
|
|
51
|
+
} catch (err) {
|
|
52
|
+
reject(err);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
conn.on('error', (err) => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Human-readable rendering of a successful reply, per subcommand. */
|
|
63
|
+
export function formatReply(sub, reply) {
|
|
64
|
+
if (sub === 'off') return 'sharing stopped';
|
|
65
|
+
if (reply.room) {
|
|
66
|
+
const lines = [`live — room ${reply.room}`];
|
|
67
|
+
if (reply.inviteUrl) lines.push(`invite: ${reply.inviteUrl}`);
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
return 'not live — run `collab go` to share this session';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The testable core: returns an exit code and writes via injected out/err so tests
|
|
75
|
+
* never call process.exit. `ctlMain` is the thin process wrapper below.
|
|
76
|
+
* @param {object} opts
|
|
77
|
+
* @param {'go'|'off'|'status'} opts.sub
|
|
78
|
+
* @param {string[]} [opts.args]
|
|
79
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
80
|
+
* @param {(s:string)=>void} opts.out
|
|
81
|
+
* @param {(s:string)=>void} opts.err
|
|
82
|
+
* @returns {Promise<number>} exit code
|
|
83
|
+
*/
|
|
84
|
+
export async function runCtl({ sub, args = [], env = process.env, out, err }) {
|
|
85
|
+
const sockPath = env.CLAUDE_SHARE_CTL;
|
|
86
|
+
if (!sockPath) {
|
|
87
|
+
err('not inside a collab session — start one with: collab\n');
|
|
88
|
+
return 2;
|
|
89
|
+
}
|
|
90
|
+
const req = { v: 1, t: sub, ...(sub === 'go' ? parseGoArgs(args) : {}) };
|
|
91
|
+
// `go` may dial the relay, which the host bounds at 15s — wait past that for the
|
|
92
|
+
// authoritative verdict; the instant ops (off/status) keep the short timeout.
|
|
93
|
+
const timeout = sub === 'go' ? 20000 : 5000;
|
|
94
|
+
let reply;
|
|
95
|
+
try {
|
|
96
|
+
reply = await sendCtl(sockPath, req, timeout);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
err(`collab: ${e.message}\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
if (!reply || reply.ok !== true) {
|
|
102
|
+
err(`collab: ${reply?.error || 'request failed'}\n`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
out(formatReply(sub, reply) + '\n');
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The process entry point the `collab` bin dispatches `go|off|status` to. */
|
|
110
|
+
export async function ctlMain(sub, args) {
|
|
111
|
+
const code = await runCtl({
|
|
112
|
+
sub,
|
|
113
|
+
args,
|
|
114
|
+
env: process.env,
|
|
115
|
+
out: (s) => process.stdout.write(s),
|
|
116
|
+
err: (s) => process.stderr.write(s),
|
|
117
|
+
});
|
|
118
|
+
process.exit(code);
|
|
119
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The control socket — how the wrapped Claude drives its OWN session from the
|
|
2
|
+
// inside (spec phase 5a: `collab go|off|status`). A unix socket at CLAUDE_SHARE_CTL,
|
|
3
|
+
// 0600 (same-user only), speaking JSON-lines. Every request carries `v:1`; a wrong
|
|
4
|
+
// or absent version is refused machine-readably so a future protocol bump can't be
|
|
5
|
+
// silently misread. One reply line per request line.
|
|
6
|
+
//
|
|
7
|
+
// The transport is deliberately dumb: it version-gates, dispatches to a handler by
|
|
8
|
+
// request type, and writes the handler's reply. All product logic (go/off/status
|
|
9
|
+
// against the room lifecycle) lives in the handlers the caller passes — this file
|
|
10
|
+
// never touches the relay or the brain.
|
|
11
|
+
|
|
12
|
+
import net from 'node:net';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { Decoder } from '../../shared/protocol.js';
|
|
15
|
+
|
|
16
|
+
/** The control-socket protocol version. Every request must carry `v` equal to this. */
|
|
17
|
+
export const CTL_V = 1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Start the control-socket server.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {string} opts.path unix socket path (unlinked on close)
|
|
24
|
+
* @param {Record<string, (req:object)=>any>} opts.handlers
|
|
25
|
+
* map of request type → async handler returning the reply object. Unknown
|
|
26
|
+
* types get a generic error; a handler that throws becomes an error reply.
|
|
27
|
+
* @returns {{ close():void }}
|
|
28
|
+
*/
|
|
29
|
+
export function startCtl({ path: sockPath, handlers = {} } = {}) {
|
|
30
|
+
// A stale socket file from a crashed prior run would make listen() fail with
|
|
31
|
+
// EADDRINUSE — clear it first (we own the pid-stamped name).
|
|
32
|
+
try {
|
|
33
|
+
fs.unlinkSync(sockPath);
|
|
34
|
+
} catch {
|
|
35
|
+
/* nothing there — fine */
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const reply = (conn, obj) => {
|
|
39
|
+
try {
|
|
40
|
+
conn.write(JSON.stringify(obj) + '\n');
|
|
41
|
+
} catch {
|
|
42
|
+
/* client already gone */
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleLine = async (conn, msg) => {
|
|
47
|
+
// Version gate first — the cheapest, most important check. A wrong or absent
|
|
48
|
+
// version is refused before any handler runs (a client speaking a different
|
|
49
|
+
// protocol must not have its fields reinterpreted).
|
|
50
|
+
if (!msg || typeof msg !== 'object' || msg.v !== CTL_V) {
|
|
51
|
+
return reply(conn, { ok: false, error: 'version' });
|
|
52
|
+
}
|
|
53
|
+
const fn = handlers[msg.t];
|
|
54
|
+
if (typeof fn !== 'function') return reply(conn, { ok: false, error: `unknown request: ${msg.t}` });
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await fn(msg);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res = { ok: false, error: String(err?.message || err) };
|
|
60
|
+
}
|
|
61
|
+
reply(conn, res ?? { ok: false, error: 'no reply' });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const server = net.createServer((conn) => {
|
|
65
|
+
const dec = new Decoder(); // reused: buffers partial lines, drops malformed JSON
|
|
66
|
+
conn.on('data', (chunk) => {
|
|
67
|
+
for (const msg of dec.push(chunk)) handleLine(conn, msg);
|
|
68
|
+
});
|
|
69
|
+
conn.on('error', () => {}); // a client reset must never crash the host
|
|
70
|
+
});
|
|
71
|
+
server.on('error', () => {}); // post-listen socket errors are non-fatal
|
|
72
|
+
|
|
73
|
+
server.listen(sockPath, () => {
|
|
74
|
+
// Same-user only: the socket carries session control, so lock it to 0600 as
|
|
75
|
+
// soon as it exists (listen created the file with the process umask).
|
|
76
|
+
try {
|
|
77
|
+
fs.chmodSync(sockPath, 0o600);
|
|
78
|
+
} catch {
|
|
79
|
+
/* best-effort — a chmod failure must not take the session down */
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
close() {
|
|
85
|
+
try {
|
|
86
|
+
server.close();
|
|
87
|
+
} catch {
|
|
88
|
+
/* already closing */
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
fs.unlinkSync(sockPath);
|
|
92
|
+
} catch {
|
|
93
|
+
/* already gone */
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The first-run screen — shown once, on the first interactive `collab`. It states the
|
|
2
|
+
// core (the /collab plugin), offers the connector picker for link delivery, and
|
|
3
|
+
// carries the relay footer. Copy is Ian-approved (revised 2026-07-15) — do NOT
|
|
4
|
+
// rewrite it. Pure I/O injection: input/output are passed in, so the whole screen is
|
|
5
|
+
// testable by feeding byte sequences (no real TTY).
|
|
6
|
+
//
|
|
7
|
+
// A hand-rolled raw-mode picker (no dependency): ↑/↓ move the cursor, space toggles
|
|
8
|
+
// the highlighted connector, enter starts claude. No alt-screen — it prints in place,
|
|
9
|
+
// redrawing by moving the cursor up and clearing to the end of the screen.
|
|
10
|
+
|
|
11
|
+
/** The connectors offered for link delivery. Slack only for now (Ian, 2026-07-15). */
|
|
12
|
+
export const DEFAULT_CONNECTORS = [
|
|
13
|
+
{ key: 'slack', label: 'Slack', hint: 'DM the join link to a teammate', checked: true },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// ── styling (the approved mock's visual layer: orange accent = the band's color,
|
|
17
|
+
// green ✓, dim grays, a highlighted selection row, kbd chips). NO_COLOR disables
|
|
18
|
+
// every code; the words themselves never change — tests match on stripped text.
|
|
19
|
+
const useColor = !process.env.NO_COLOR;
|
|
20
|
+
const sgr = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
21
|
+
const o = (s) => sgr('1;38;5;214', s); // orange, bold — the band accent
|
|
22
|
+
const g = (s) => sgr('1;32', s); // green — the "already done" checkmark
|
|
23
|
+
const d = (s) => sgr('38;5;245', s); // dim gray — secondary copy
|
|
24
|
+
const b = (s) => sgr('1', s); // bold
|
|
25
|
+
const chip = (s) => (useColor ? `\x1b[48;5;236m\x1b[38;5;252m${s}\x1b[0m` : s); // kbd key chip (no padding — stripped text stays byte-equal to the approved copy)
|
|
26
|
+
const selRow = (s) => (useColor ? `\x1b[48;5;236m${s}\x1b[0m` : s); // highlighted picker row
|
|
27
|
+
|
|
28
|
+
// ── the CLAUDE COLLAB wordmark (the skills-installer look: chunky block glyphs with
|
|
29
|
+
// a light→dark gray gradient per row). 5-row bitmap font, only the letters we need.
|
|
30
|
+
// Width: "CLAUDE COLLAB" renders 75 cols incl. indent — under the 80-col floor; a
|
|
31
|
+
// narrower terminal gets the plain one-line title instead of wrapped soup.
|
|
32
|
+
const FONT = {
|
|
33
|
+
A: ['█████', '█ █', '█████', '█ █', '█ █'],
|
|
34
|
+
B: ['████ ', '█ █', '████ ', '█ █', '████ '],
|
|
35
|
+
C: ['█████', '█ ', '█ ', '█ ', '█████'],
|
|
36
|
+
D: ['████ ', '█ █', '█ █', '█ █', '████ '],
|
|
37
|
+
E: ['█████', '█ ', '███ ', '█ ', '█████'],
|
|
38
|
+
L: ['█ ', '█ ', '█ ', '█ ', '█████'],
|
|
39
|
+
O: ['█████', '█ █', '█ █', '█ █', '█████'],
|
|
40
|
+
U: ['█ █', '█ █', '█ █', '█ █', '█████'],
|
|
41
|
+
' ': [' ', ' ', ' ', ' ', ' '],
|
|
42
|
+
};
|
|
43
|
+
const GRADIENT = ['223', '216', '215', '214', '208']; // light → deep orange (the band accent family)
|
|
44
|
+
function wordmark(text) {
|
|
45
|
+
const rows = [];
|
|
46
|
+
for (let r = 0; r < 5; r++) {
|
|
47
|
+
const line = [...text].map((ch) => FONT[ch]?.[r] ?? ' ').join(' ');
|
|
48
|
+
rows.push(useColor ? `\x1b[38;5;${GRADIENT[r]}m${line}\x1b[0m` : line);
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render the first-run screen and drive the picker.
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {NodeJS.ReadableStream} [opts.input] raw key source (default process.stdin)
|
|
57
|
+
* @param {{write:(s:string)=>void}} [opts.output] screen sink (default process.stdout)
|
|
58
|
+
* @param {Array} [opts.connectors] connector rows (default DEFAULT_CONNECTORS)
|
|
59
|
+
* @returns {Promise<{connectors:string[]}>} the checked connector keys, in order
|
|
60
|
+
*/
|
|
61
|
+
export function runFirstRun({ input = process.stdin, output = process.stdout, connectors = DEFAULT_CONNECTORS } = {}) {
|
|
62
|
+
const items = connectors.map((c) => ({ ...c }));
|
|
63
|
+
let cursor = 0;
|
|
64
|
+
let lastLines = 0;
|
|
65
|
+
let pending = ''; // a partial escape sequence straddling two chunks
|
|
66
|
+
|
|
67
|
+
const frame = () => {
|
|
68
|
+
const rows = items.map((it, i) => {
|
|
69
|
+
const sel = i === cursor;
|
|
70
|
+
const arrow = sel ? o('▸') : ' ';
|
|
71
|
+
const box = it.checked ? o('[x]') : '[ ]';
|
|
72
|
+
const body = ` ${box} ${b(it.label.padEnd(10))}${d(it.hint)} `;
|
|
73
|
+
return `${arrow}${sel ? selRow(body) : body}`;
|
|
74
|
+
});
|
|
75
|
+
// The wordmark needs ~75 cols; a narrower terminal gets the plain title so the
|
|
76
|
+
// blocks never wrap into soup. Injected test outputs have no .columns → wordmark.
|
|
77
|
+
const wide = output.columns === undefined || output.columns >= 76;
|
|
78
|
+
const mark = wordmark('CLAUDE COLLAB');
|
|
79
|
+
const title = wide ? mark.map((l) => ` ${l}`) : [` ${o('✦ claudecollab')}`];
|
|
80
|
+
// The rule spans exactly the wordmark's width (strip the color codes to measure).
|
|
81
|
+
const ruleWidth = wide ? mark[0].replace(/\x1b\[[0-9;]*m/g, '').length : 58;
|
|
82
|
+
return [
|
|
83
|
+
'',
|
|
84
|
+
...title,
|
|
85
|
+
` ${sgr('38;5;208', '─'.repeat(ruleWidth))}`,
|
|
86
|
+
'',
|
|
87
|
+
` ${g('✓')} /collab will be added to Claude Code`,
|
|
88
|
+
` ${b('claude')} ${d('← start like you always do')}`,
|
|
89
|
+
` ${b('/collab')} ${d('← run /collab to turn it multiplayer!')}`,
|
|
90
|
+
'',
|
|
91
|
+
` ${d("Select Claude's connectors:")}`,
|
|
92
|
+
'',
|
|
93
|
+
...rows,
|
|
94
|
+
'',
|
|
95
|
+
` ${d('want your own server? `collab relay` (guide in the README).')} ${o('♥')}`,
|
|
96
|
+
'',
|
|
97
|
+
` ${chip('↑↓')} ${d('move ·')} ${chip('space')} ${d('toggle ·')} ${chip('enter')} ${d('start claude')}`,
|
|
98
|
+
];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const paint = () => {
|
|
102
|
+
const lines = frame();
|
|
103
|
+
let out = '';
|
|
104
|
+
if (lastLines > 0) out += `\x1b[${lastLines}A`; // back up over the previous frame
|
|
105
|
+
out += '\x1b[0J'; // clear from the cursor to the end of the screen
|
|
106
|
+
out += lines.join('\r\n') + '\r\n';
|
|
107
|
+
lastLines = lines.length;
|
|
108
|
+
output.write(out);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
let finished = false;
|
|
113
|
+
const finish = () => {
|
|
114
|
+
if (finished) return;
|
|
115
|
+
finished = true;
|
|
116
|
+
input.removeListener('data', onData);
|
|
117
|
+
try {
|
|
118
|
+
input.setRawMode?.(false);
|
|
119
|
+
} catch {}
|
|
120
|
+
resolve({ connectors: items.filter((it) => it.checked).map((it) => it.key) });
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const onData = (chunk) => {
|
|
124
|
+
let buf = pending + (typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
125
|
+
pending = '';
|
|
126
|
+
while (buf.length) {
|
|
127
|
+
if (buf[0] === '\x1b') {
|
|
128
|
+
if (buf.startsWith('\x1b[A') || buf.startsWith('\x1bOA')) {
|
|
129
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
130
|
+
buf = buf.slice(3);
|
|
131
|
+
paint();
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (buf.startsWith('\x1b[B') || buf.startsWith('\x1bOB')) {
|
|
135
|
+
cursor = (cursor + 1) % items.length;
|
|
136
|
+
buf = buf.slice(3);
|
|
137
|
+
paint();
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (buf.length < 3) {
|
|
141
|
+
pending = buf; // incomplete escape — wait for the rest of the sequence
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
buf = buf.slice(1); // an escape we don't handle — drop the ESC, keep going
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const ch = buf[0];
|
|
148
|
+
buf = buf.slice(1);
|
|
149
|
+
if (ch === ' ') {
|
|
150
|
+
items[cursor].checked = !items[cursor].checked;
|
|
151
|
+
paint();
|
|
152
|
+
} else if (ch === '\r' || ch === '\n' || ch === '\x03') {
|
|
153
|
+
// Enter starts claude; Ctrl-C also proceeds (never leaves the user stuck).
|
|
154
|
+
return finish();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
input.setRawMode?.(true);
|
|
161
|
+
} catch {}
|
|
162
|
+
input.resume?.();
|
|
163
|
+
input.on('data', onData);
|
|
164
|
+
paint();
|
|
165
|
+
});
|
|
166
|
+
}
|