@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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +36 -0
- package/packages/cli/bin/claude-share.js +1319 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/brain/card.js +157 -0
- package/packages/cli/src/brain/commands.js +122 -0
- package/packages/cli/src/brain/drafts.js +557 -0
- package/packages/cli/src/brain/gate.js +134 -0
- package/packages/cli/src/brain/knocks.js +27 -0
- package/packages/cli/src/brain/log.js +123 -0
- package/packages/cli/src/brain/queue.js +81 -0
- package/packages/cli/src/brain/state.js +245 -0
- package/packages/cli/src/hooks.js +243 -0
- package/packages/cli/src/invite.js +43 -0
- package/packages/cli/src/known-relays.js +66 -0
- package/packages/cli/src/pty.js +175 -0
- package/packages/cli/src/relay-client.js +271 -0
- package/packages/cli/src/renderer.js +230 -0
- package/packages/cli/src/screen-snapshot.js +139 -0
- package/packages/cli/src/term-chatter.js +89 -0
- package/packages/relay/auth.js +18 -0
- package/packages/relay/bin/serve.js +73 -0
- package/packages/relay/names.js +27 -0
- package/packages/relay/package.json +13 -0
- package/packages/relay/public/client.js +1553 -0
- package/packages/relay/public/index.html +110 -0
- package/packages/relay/public/style.css +952 -0
- package/packages/relay/rooms.js +149 -0
- package/packages/relay/server.js +652 -0
- package/packages/relay/web.js +334 -0
- package/packages/relay/wordlists.js +38 -0
- package/packages/shared/package.json +8 -0
- package/packages/shared/protocol.js +189 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// The host-side relay client — the host brain's one outbound connection to the
|
|
2
|
+
// relay (spec §architecture: "one outbound connection"). It is the mirror image
|
|
3
|
+
// of packages/relay/server.js's host channel: an ssh2 client that authenticates
|
|
4
|
+
// as username 'host', opens a no-pty shell, and speaks the JSON-lines protocol
|
|
5
|
+
// from packages/shared over that channel.
|
|
6
|
+
//
|
|
7
|
+
// Everything product-shaped stays in the CLI (bin/claude-share.js); this module
|
|
8
|
+
// only frames messages and surfaces relay events as callbacks. It deliberately
|
|
9
|
+
// knows nothing about drafts, roles, or the band.
|
|
10
|
+
//
|
|
11
|
+
// host→relay: hello / reclaim / admit / deny / screen / to / drop / end / state
|
|
12
|
+
// relay→host: room / gone / knock / joined / left / key / resize / pointer / ui
|
|
13
|
+
//
|
|
14
|
+
// The host's ssh key is a stable identity: its fingerprint gates room reclaim on
|
|
15
|
+
// the relay (spec §failure-behavior), so callers pass a persistent privateKey.
|
|
16
|
+
|
|
17
|
+
import ssh2 from 'ssh2';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { TYPES, encode, validate, Decoder } from '../../shared/protocol.js';
|
|
20
|
+
|
|
21
|
+
const { Client, utils } = ssh2;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a relay location into { host, port }. Accepts `ssh://host:port`,
|
|
25
|
+
* `ssh://host` (port defaults to 22), and a bare `host:port`. Undefined/empty
|
|
26
|
+
* falls back to the localhost dev relay.
|
|
27
|
+
* @param {string} [url]
|
|
28
|
+
* @returns {{host:string, port:number}}
|
|
29
|
+
*/
|
|
30
|
+
export function parseRelayUrl(url) {
|
|
31
|
+
if (!url) return { host: '127.0.0.1', port: 2222 };
|
|
32
|
+
let s = String(url).trim();
|
|
33
|
+
if (!s.includes('://')) s = 'ssh://' + s;
|
|
34
|
+
let u;
|
|
35
|
+
try {
|
|
36
|
+
u = new URL(s);
|
|
37
|
+
} catch {
|
|
38
|
+
return { host: '127.0.0.1', port: 2222 };
|
|
39
|
+
}
|
|
40
|
+
return { host: u.hostname || '127.0.0.1', port: u.port ? Number(u.port) : 22 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Coerce screen/to payloads (Buffer or string) to the base64 the protocol wants.
|
|
44
|
+
const toB64 = (data) => (Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8')).toString('base64');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The opening protocol move for a (re)connection once it is ready: ask for a fresh
|
|
48
|
+
* room when we hold none, or RECLAIM the room we already own after a drop (spec
|
|
49
|
+
* §failure-behavior: "reconnect with the same key restores identity + role; relay
|
|
50
|
+
* holds the code 10 min"). bin/claude-share.js calls this each time a relay
|
|
51
|
+
* connection becomes ready, so an initial connect says hello and every reconnect
|
|
52
|
+
* reclaims — the piece the CLI was missing.
|
|
53
|
+
* @param {string|null|undefined} heldRoom the room code we currently hold, if any
|
|
54
|
+
* @returns {{t:'hello'} | {t:'reclaim', code:string}}
|
|
55
|
+
*/
|
|
56
|
+
export function openingMove(heldRoom) {
|
|
57
|
+
return heldRoom ? { t: 'reclaim', code: heldRoom } : { t: 'hello' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Connect to the relay as the host and return an event/command handle.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {string} [opts.url] relay location (default localhost:2222)
|
|
65
|
+
* @param {string|Buffer} [opts.privateKey] host ssh private key (stable identity;
|
|
66
|
+
* an ephemeral one is generated if omitted)
|
|
67
|
+
* @param {number} [opts.keepaliveInterval] ssh keepalive ms (default 20s)
|
|
68
|
+
* @param {string} [opts.secret] room-creation credential; rides every hello()
|
|
69
|
+
* (a relay with ROOM_SECRET set requires it)
|
|
70
|
+
* @param {string} [opts.roomPass] optional join password for the room this host
|
|
71
|
+
* creates — the relay challenges every guest
|
|
72
|
+
* with it before their knock is delivered
|
|
73
|
+
* @param {(fp:string)=>boolean} [opts.verifyHostKey] called with the relay's SHA256:…
|
|
74
|
+
* key fingerprint during the handshake; return
|
|
75
|
+
* false to abort (impersonation defense).
|
|
76
|
+
* Omitted = accept any key (dev/loopback).
|
|
77
|
+
* @returns {{
|
|
78
|
+
* ready: Promise<void>,
|
|
79
|
+
* onRoom, onGone, onRefused, onKnock, onJoin, onLeave, onKey, onResize, onPointer, onUi, onClose, onError,
|
|
80
|
+
* hello():void, reclaim(code):void,
|
|
81
|
+
* admit(id):void, deny(id):void,
|
|
82
|
+
* sendScreen(data):void, sendTo(id,data):void, sendState(data):void,
|
|
83
|
+
* drop(id,ban):void, end():void, close():void
|
|
84
|
+
* }}
|
|
85
|
+
*/
|
|
86
|
+
export function connectRelay(opts = {}) {
|
|
87
|
+
const { url, keepaliveInterval = 20000, secret, roomPass, verifyHostKey } = opts;
|
|
88
|
+
const { host, port } = parseRelayUrl(url);
|
|
89
|
+
// A stable key lets the host reclaim its room after a drop; if the caller has
|
|
90
|
+
// none we still connect (relay rejects `none` auth) with a throwaway key —
|
|
91
|
+
// reclaim just won't work across restarts, which is fine for a one-off run.
|
|
92
|
+
const privateKey = opts.privateKey ?? utils.generateKeyPairSync('ed25519').private;
|
|
93
|
+
|
|
94
|
+
const client = new Client();
|
|
95
|
+
const dec = new Decoder();
|
|
96
|
+
|
|
97
|
+
// One callback set per event; on*() returns an unsubscribe fn.
|
|
98
|
+
const sets = {
|
|
99
|
+
room: new Set(),
|
|
100
|
+
gone: new Set(),
|
|
101
|
+
refused: new Set(),
|
|
102
|
+
knock: new Set(),
|
|
103
|
+
join: new Set(),
|
|
104
|
+
leave: new Set(),
|
|
105
|
+
key: new Set(),
|
|
106
|
+
resize: new Set(),
|
|
107
|
+
pointer: new Set(),
|
|
108
|
+
ui: new Set(),
|
|
109
|
+
close: new Set(),
|
|
110
|
+
error: new Set(),
|
|
111
|
+
};
|
|
112
|
+
const on = (set) => (cb) => {
|
|
113
|
+
set.add(cb);
|
|
114
|
+
return () => set.delete(cb);
|
|
115
|
+
};
|
|
116
|
+
const emit = (name, ...args) => {
|
|
117
|
+
for (const cb of sets[name]) {
|
|
118
|
+
try {
|
|
119
|
+
cb(...args);
|
|
120
|
+
} catch {
|
|
121
|
+
/* a listener throwing must not desync the wire loop */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
let stream = null;
|
|
127
|
+
let closed = false;
|
|
128
|
+
|
|
129
|
+
const send = (obj) => {
|
|
130
|
+
if (closed || !stream) return;
|
|
131
|
+
try {
|
|
132
|
+
stream.write(encode(obj));
|
|
133
|
+
} catch {
|
|
134
|
+
/* channel already gone */
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Route one decoded relay→host message to its callback set. Drop anything that
|
|
139
|
+
// fails validation (spec: fail closed — the host never acts on a malformed line).
|
|
140
|
+
const route = (msg) => {
|
|
141
|
+
if (!validate(msg)) return;
|
|
142
|
+
switch (msg.t) {
|
|
143
|
+
case TYPES.ROOM:
|
|
144
|
+
// webUrl: the relay's PUBLIC browser base (a deployed relay behind TLS) —
|
|
145
|
+
// links must print https://domain/room, not http://ssh-host:webPort/room.
|
|
146
|
+
return emit('room', msg.code, msg.webUrl);
|
|
147
|
+
case TYPES.GONE:
|
|
148
|
+
return emit('gone', msg.code);
|
|
149
|
+
case TYPES.REFUSED:
|
|
150
|
+
// The relay rejected our HELLO (reason 'secret' = bad/missing room
|
|
151
|
+
// credential). Not transient — the caller should stop, not reconnect.
|
|
152
|
+
return emit('refused', msg.reason);
|
|
153
|
+
case TYPES.KNOCK:
|
|
154
|
+
return emit('knock', { id: msg.id, name: msg.name, fp: msg.fp, seen: msg.seen });
|
|
155
|
+
case TYPES.JOINED:
|
|
156
|
+
return emit('join', msg.id);
|
|
157
|
+
case TYPES.LEFT:
|
|
158
|
+
return emit('leave', msg.id);
|
|
159
|
+
case TYPES.KEY:
|
|
160
|
+
return emit('key', { id: msg.id, data: Buffer.from(msg.data, 'base64') });
|
|
161
|
+
case TYPES.RESIZE:
|
|
162
|
+
return emit('resize', { id: msg.id, cols: msg.cols, rows: msg.rows });
|
|
163
|
+
// Browser-view inputs, labeled by the relay with the sending guest's id.
|
|
164
|
+
case TYPES.POINTER:
|
|
165
|
+
return emit('pointer', { id: msg.id, x: msg.x, y: msg.y });
|
|
166
|
+
case TYPES.UI:
|
|
167
|
+
return emit('ui', { id: msg.id, action: msg.action });
|
|
168
|
+
// host→relay types (hello/admit/screen/state/…) never arrive here; ignore.
|
|
169
|
+
default:
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
let settled = false;
|
|
175
|
+
const ready = new Promise((resolve, reject) => {
|
|
176
|
+
client.on('error', (err) => {
|
|
177
|
+
if (!settled) {
|
|
178
|
+
settled = true;
|
|
179
|
+
reject(err);
|
|
180
|
+
}
|
|
181
|
+
emit('error', err);
|
|
182
|
+
});
|
|
183
|
+
client.on('close', () => {
|
|
184
|
+
closed = true;
|
|
185
|
+
emit('close');
|
|
186
|
+
});
|
|
187
|
+
client.on('ready', () => {
|
|
188
|
+
// No pty — the host channel is a pure byte pipe for the protocol (mirrors
|
|
189
|
+
// the relay, which routes by username 'host', not by whether a pty exists).
|
|
190
|
+
client.shell(false, (err, s) => {
|
|
191
|
+
if (err) {
|
|
192
|
+
if (!settled) {
|
|
193
|
+
settled = true;
|
|
194
|
+
reject(err);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
stream = s;
|
|
199
|
+
s.on('data', (chunk) => {
|
|
200
|
+
for (const msg of dec.push(chunk)) route(msg);
|
|
201
|
+
});
|
|
202
|
+
s.on('close', () => {
|
|
203
|
+
closed = true;
|
|
204
|
+
emit('close');
|
|
205
|
+
});
|
|
206
|
+
settled = true;
|
|
207
|
+
resolve();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
const connectOpts = { host, port, username: 'host', privateKey, keepaliveInterval };
|
|
211
|
+
if (verifyHostKey) {
|
|
212
|
+
// ssh2 hands the raw public-key blob to hostVerifier during the handshake;
|
|
213
|
+
// we reduce it to the standard SHA256:… fingerprint (same derivation the
|
|
214
|
+
// relay uses) so callers compare one printable string. Returning false
|
|
215
|
+
// aborts the connection before any bytes of ours reach an impersonator.
|
|
216
|
+
connectOpts.hostVerifier = (keyBlob) => {
|
|
217
|
+
const fp = 'SHA256:' + createHash('sha256').update(keyBlob).digest('base64').replace(/=+$/, '');
|
|
218
|
+
try {
|
|
219
|
+
return verifyHostKey(fp) === true;
|
|
220
|
+
} catch {
|
|
221
|
+
return false; // a throwing verifier must fail closed, not connect
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
client.connect(connectOpts);
|
|
226
|
+
});
|
|
227
|
+
// Nobody may await ready (fire-and-forget wiring); don't crash on a rejection.
|
|
228
|
+
ready.catch(() => {});
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
ready,
|
|
232
|
+
|
|
233
|
+
onRoom: on(sets.room), // (code) room granted (create or reclaim)
|
|
234
|
+
onGone: on(sets.gone), // (code) reclaim refused (expired / wrong key)
|
|
235
|
+
onRefused: on(sets.refused), // (reason) hello rejected ('secret'); don't reconnect
|
|
236
|
+
onKnock: on(sets.knock), // ({id,name,fp,seen}) a guest is knocking
|
|
237
|
+
onJoin: on(sets.join), // (id) a guest was admitted
|
|
238
|
+
onLeave: on(sets.leave), // (id) a guest disconnected
|
|
239
|
+
onKey: on(sets.key), // ({id, data:Buffer}) guest keystrokes, labeled
|
|
240
|
+
onResize: on(sets.resize), // ({id, cols, rows}) guest terminal size
|
|
241
|
+
onPointer: on(sets.pointer), // ({id, x, y}) browser cursor move (normalized)
|
|
242
|
+
onUi: on(sets.ui), // ({id, action}) browser button command, labeled
|
|
243
|
+
onClose: on(sets.close), // () the control channel closed
|
|
244
|
+
onError: on(sets.error), // (err) ssh transport error
|
|
245
|
+
|
|
246
|
+
hello: () => send({ t: TYPES.HELLO, want: 'room', ...(secret ? { secret } : {}), ...(roomPass ? { pass: roomPass } : {}) }),
|
|
247
|
+
reclaim: (code) => send({ t: TYPES.RECLAIM, code }),
|
|
248
|
+
admit: (id) => send({ t: TYPES.ADMIT, id }),
|
|
249
|
+
deny: (id) => send({ t: TYPES.DENY, id }),
|
|
250
|
+
sendScreen: (data) => send({ t: TYPES.SCREEN, data: toB64(data) }),
|
|
251
|
+
sendTo: (id, data) => send({ t: TYPES.TO, id, data: toB64(data) }),
|
|
252
|
+
sendState: (data) => send({ t: TYPES.STATE, data }),
|
|
253
|
+
drop: (id, ban = false) => send({ t: TYPES.DROP, id, ban: !!ban }),
|
|
254
|
+
end: () => send({ t: TYPES.END }),
|
|
255
|
+
|
|
256
|
+
/** Tear down the transport without ending the room (e.g. on host crash). */
|
|
257
|
+
close: () => {
|
|
258
|
+
closed = true;
|
|
259
|
+
try {
|
|
260
|
+
stream?.end();
|
|
261
|
+
} catch {
|
|
262
|
+
/* already closed */
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
client.end();
|
|
266
|
+
} catch {
|
|
267
|
+
/* already closed */
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// The band renderer — the SINGLE status line painted under Claude's full-screen TUI.
|
|
2
|
+
//
|
|
3
|
+
// Post-dogfood verdict (design v2): the host terminal is the engine room — plain,
|
|
4
|
+
// native Claude, exactly as solo. The multiplayer band is dead; the browser is the
|
|
5
|
+
// one multiplayer surface for everyone, host included. All the terminal shows is one
|
|
6
|
+
// status line, pinned to the very bottom row:
|
|
7
|
+
//
|
|
8
|
+
// ─ <room> · <n> people · <claude-state> · <room URL> ─
|
|
9
|
+
//
|
|
10
|
+
// Claude runs in a PTY one row shorter than the real terminal (see pty.js), so its
|
|
11
|
+
// absolute cursor addressing can never touch that bottom row. paint() draws only the
|
|
12
|
+
// band row(s): it saves the cursor (DECSC), positions + clears each row, writes the
|
|
13
|
+
// status line on the last one, and restores the cursor (DECRC) — the tmux status-bar
|
|
14
|
+
// technique. paint() is a pure function of state (same state in, same ANSI out), so
|
|
15
|
+
// it stays golden-file testable.
|
|
16
|
+
|
|
17
|
+
// ── ANSI vocabulary ───────────────────────────────────────────────────────────
|
|
18
|
+
const SAVE = '\x1b7'; // DECSC — save cursor position
|
|
19
|
+
const RESTORE = '\x1b8'; // DECRC — restore cursor position
|
|
20
|
+
const CLEAR_LINE = '\x1b[2K'; // erase entire line
|
|
21
|
+
const RESET = '\x1b[0m';
|
|
22
|
+
const ORANGE = '\x1b[38;5;214m'; // status line (matches spike band)
|
|
23
|
+
const posLine = (row) => `\x1b[${row};1H`; // move to column 1 of `row`
|
|
24
|
+
|
|
25
|
+
// Role → status-line glyph (spec §roles: viewer 👁, prompter ✎, host ★).
|
|
26
|
+
// Still exported for the join context card (brain/card.js) and event toasts.
|
|
27
|
+
export const ROLE_GLYPH = Object.freeze({
|
|
28
|
+
host: '★',
|
|
29
|
+
prompter: '✎',
|
|
30
|
+
viewer: '👁',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── display-width helpers ─────────────────────────────────────────────────────
|
|
34
|
+
// A terminal cannot count JS UTF-16 code units; it counts cells. These helpers
|
|
35
|
+
// let us truncate the status line so it can never wrap into Claude's rows. Width
|
|
36
|
+
// follows the wcwidth convention: combining marks 0, East-Asian-wide + emoji 2,
|
|
37
|
+
// ambiguous 1. Load-bearing for the "no overflow" invariant, not cosmetics.
|
|
38
|
+
|
|
39
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[78]/g;
|
|
40
|
+
|
|
41
|
+
/** Remove CSI/OSC/DECSC-DECRC escape sequences (they occupy zero cells). */
|
|
42
|
+
export function stripAnsi(str) {
|
|
43
|
+
return String(str).replace(ANSI_RE, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const inRanges = (cp, ranges) => {
|
|
47
|
+
for (const [lo, hi] of ranges) if (cp >= lo && cp <= hi) return true;
|
|
48
|
+
return false;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ZERO_WIDTH = [
|
|
52
|
+
[0x0300, 0x036f], // combining diacritical marks
|
|
53
|
+
[0x0483, 0x0489],
|
|
54
|
+
[0x0591, 0x05bd],
|
|
55
|
+
[0x200b, 0x200f], // zero-width space/joiner + bidi marks
|
|
56
|
+
[0x202a, 0x202e],
|
|
57
|
+
[0x2060, 0x2064],
|
|
58
|
+
[0xfe00, 0xfe0f], // variation selectors
|
|
59
|
+
[0xfe20, 0xfe2f], // combining half marks
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const WIDE = [
|
|
63
|
+
[0x1100, 0x115f], // Hangul Jamo
|
|
64
|
+
[0x2329, 0x232a],
|
|
65
|
+
[0x2e80, 0x303e], // CJK radicals … punctuation
|
|
66
|
+
[0x3041, 0x33ff],
|
|
67
|
+
[0x3400, 0x4dbf],
|
|
68
|
+
[0x4e00, 0x9fff], // CJK unified ideographs
|
|
69
|
+
[0xa000, 0xa4cf],
|
|
70
|
+
[0xac00, 0xd7a3], // Hangul syllables
|
|
71
|
+
[0xf900, 0xfaff], // CJK compatibility ideographs
|
|
72
|
+
[0xfe30, 0xfe4f], // CJK compatibility forms
|
|
73
|
+
[0xff00, 0xff60], // fullwidth forms
|
|
74
|
+
[0xffe0, 0xffe6],
|
|
75
|
+
[0x1f000, 0x1faff], // emoji & pictographs
|
|
76
|
+
[0x20000, 0x3fffd], // CJK extension planes
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
function cellWidth(cp) {
|
|
80
|
+
if (inRanges(cp, ZERO_WIDTH)) return 0;
|
|
81
|
+
return inRanges(cp, WIDE) ? 2 : 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Visible column width of a string, ignoring ANSI escapes. */
|
|
85
|
+
export function stringWidth(str) {
|
|
86
|
+
let w = 0;
|
|
87
|
+
for (const ch of stripAnsi(str)) w += cellWidth(ch.codePointAt(0));
|
|
88
|
+
return w;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Truncate PLAIN text (no embedded ANSI) to at most `max` visible cells, never
|
|
93
|
+
* splitting a wide character across the boundary.
|
|
94
|
+
*/
|
|
95
|
+
export function truncateToWidth(str, max) {
|
|
96
|
+
if (max <= 0) return '';
|
|
97
|
+
let w = 0;
|
|
98
|
+
let out = '';
|
|
99
|
+
for (const ch of String(str)) {
|
|
100
|
+
const cw = cellWidth(ch.codePointAt(0));
|
|
101
|
+
if (w + cw > max) break;
|
|
102
|
+
out += ch;
|
|
103
|
+
w += cw;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Truncate to `max` cells like {@link truncateToWidth}, but when the text actually
|
|
110
|
+
* doesn't fit, end it in a single-cell ellipsis so a trimmed toast reads as clipped
|
|
111
|
+
* prose ("copy the invite…") instead of a raw mid-word cut (finding 5).
|
|
112
|
+
*/
|
|
113
|
+
export function ellipsize(str, max) {
|
|
114
|
+
if (max <= 0) return '';
|
|
115
|
+
const s = String(str);
|
|
116
|
+
if (stringWidth(s) <= max) return s; // fits whole — no ellipsis
|
|
117
|
+
if (max === 1) return '…';
|
|
118
|
+
return truncateToWidth(s, max - 1) + '…';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── the status line ─────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
const color = (sgr, plain, width) => (width <= 0 ? '' : sgr + truncateToWidth(plain, width) + RESET);
|
|
124
|
+
|
|
125
|
+
const SEP = ' · ';
|
|
126
|
+
const FRAME_L = '─ ';
|
|
127
|
+
const FRAME_R = ' ─';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The one and only band line: `─ <room> · <n> people · <claude-state> · <room URL> ─`.
|
|
131
|
+
* Missing pieces are simply dropped (no room URL when solo, no count when unknown);
|
|
132
|
+
* the whole line is width-clamped so it can never wrap into Claude's rows. Transient
|
|
133
|
+
* messages (toasts, pause, event notices) are folded into the claude-state slot by
|
|
134
|
+
* the caller — the band is one line and never grows.
|
|
135
|
+
*
|
|
136
|
+
* The URL is load-bearing: on the host's terminal it is the ONLY place the host-tab
|
|
137
|
+
* URL (with its token) is shown, and clipboard copy is best-effort/optional. Clamping
|
|
138
|
+
* the whole line truncated the URL — the last slot — into uselessness on common widths
|
|
139
|
+
* (100 cols cut it to "…8787/humble-s"; the 80-col floor left almost nothing). So the
|
|
140
|
+
* URL is packed FIRST at full width and never truncated; room, people and claude-state
|
|
141
|
+
* then fill what remains (that priority), the last one trimmed and the rest dropped —
|
|
142
|
+
* the URL always survives the clamp. Only an absurdly narrow terminal (< URL width)
|
|
143
|
+
* trims the URL, where the never-wrap-into-Claude invariant has to win.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} state
|
|
146
|
+
* @param {string|null} [state.room] room code (null ⇒ "claudecollab")
|
|
147
|
+
* @param {number} [state.people] participant count
|
|
148
|
+
* @param {string} [state.claudeState] Claude's state / a folded-in toast
|
|
149
|
+
* @param {string|null} [state.url] the room URL to print (null when solo)
|
|
150
|
+
* @param {number} [width=80] column budget
|
|
151
|
+
* @returns {string} colored, width-clamped line
|
|
152
|
+
*/
|
|
153
|
+
export function statusLine(state = {}, width = 80) {
|
|
154
|
+
const room = state.room || 'claudecollab';
|
|
155
|
+
const url = state.url ? String(state.url) : null;
|
|
156
|
+
const n = state.people;
|
|
157
|
+
const people = Number.isFinite(n) ? `${n} ${n === 1 ? 'person' : 'people'}` : null;
|
|
158
|
+
const claudeState = state.claudeState ? String(state.claudeState) : null;
|
|
159
|
+
|
|
160
|
+
const sepW = stringWidth(SEP);
|
|
161
|
+
const inner = width - stringWidth(FRAME_L) - stringWidth(FRAME_R);
|
|
162
|
+
if (inner <= 0) return color(ORANGE, truncateToWidth(`${FRAME_L}${room}`, width), width);
|
|
163
|
+
|
|
164
|
+
// Reading order for display vs keep-priority for the clamp (URL highest).
|
|
165
|
+
const display = [
|
|
166
|
+
{ key: 'room', text: room },
|
|
167
|
+
{ key: 'people', text: people },
|
|
168
|
+
{ key: 'claude', text: claudeState },
|
|
169
|
+
{ key: 'url', text: url },
|
|
170
|
+
].filter((s) => s.text != null);
|
|
171
|
+
const priority = ['url', 'room', 'people', 'claude'];
|
|
172
|
+
|
|
173
|
+
const chosen = new Map(); // key -> text (whole, or truncated for the last that fits)
|
|
174
|
+
let used = 0; // inner columns consumed
|
|
175
|
+
for (const key of priority) {
|
|
176
|
+
const slot = display.find((s) => s.key === key);
|
|
177
|
+
if (!slot) continue;
|
|
178
|
+
const sep = chosen.size > 0 ? sepW : 0;
|
|
179
|
+
const w = stringWidth(slot.text);
|
|
180
|
+
if (used + sep + w <= inner) {
|
|
181
|
+
chosen.set(key, slot.text);
|
|
182
|
+
used += sep + w;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Doesn't fit whole. The URL is kept even here (truncated only if it can't fit
|
|
186
|
+
// alone); every other slot is trimmed into what's left, then packing stops.
|
|
187
|
+
if (key === 'url') {
|
|
188
|
+
// A URL trimmed with an ellipsis reads as a broken link, so hard-cut it (this
|
|
189
|
+
// only happens far below the 80-col floor; see the doc comment above).
|
|
190
|
+
chosen.set(key, truncateToWidth(slot.text, inner - used - sep));
|
|
191
|
+
} else {
|
|
192
|
+
const budget = inner - used - sep;
|
|
193
|
+
// room/people/claude (toasts land here) ellipsize so a trim never cuts mid-word.
|
|
194
|
+
if (budget >= 1) chosen.set(key, ellipsize(slot.text, budget));
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parts = display.filter((s) => chosen.has(s.key)).map((s) => chosen.get(s.key));
|
|
200
|
+
return color(ORANGE, `${FRAME_L}${parts.join(SEP)}${FRAME_R}`, width);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render the band as a single ANSI string, positioned at the bottom `bandRows` rows
|
|
205
|
+
* of a `cols`×`rows` terminal. The status line sits on the band's LAST row (the
|
|
206
|
+
* terminal's very bottom); any rows above it in the band are cleared blank. The
|
|
207
|
+
* cursor is saved before and restored after so Claude's own cursor is never
|
|
208
|
+
* disturbed. In practice bandRows is 1 (the band is permanently one line).
|
|
209
|
+
*
|
|
210
|
+
* @param {object} state see {@link statusLine} plus:
|
|
211
|
+
* @param {number} [state.cols=80] real (clamped) terminal columns
|
|
212
|
+
* @param {number} [state.rows=24] real (clamped) terminal rows
|
|
213
|
+
* @param {number} [state.bandRows=1] reserved bottom rows
|
|
214
|
+
* @returns {string} ANSI for the band only (empty string if bandRows <= 0)
|
|
215
|
+
*/
|
|
216
|
+
export function paint(state = {}) {
|
|
217
|
+
const cols = state.cols ?? 80;
|
|
218
|
+
const rows = state.rows ?? 24;
|
|
219
|
+
const bandRows = state.bandRows ?? 1;
|
|
220
|
+
if (bandRows <= 0) return '';
|
|
221
|
+
|
|
222
|
+
const top = Math.max(1, rows - bandRows + 1);
|
|
223
|
+
let out = SAVE;
|
|
224
|
+
for (let i = 0; i < bandRows; i++) {
|
|
225
|
+
const content = i === bandRows - 1 ? statusLine(state, cols) : '';
|
|
226
|
+
out += posLine(top + i) + CLEAR_LINE + content;
|
|
227
|
+
}
|
|
228
|
+
out += RESTORE;
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// A rolling snapshot of the child's current screen, so a guest admitted mid-session
|
|
2
|
+
// sees the LIVE screen immediately — not blank space until the next frame (spec
|
|
3
|
+
// §renderer: "there is exactly one live screen, mirrored to everyone"; dogfood
|
|
4
|
+
// finding: a guest admitted while Claude was idle saw the context card, then nothing).
|
|
5
|
+
//
|
|
6
|
+
// We deliberately don't emulate a terminal (deps are ssh2 + node-pty only). Instead
|
|
7
|
+
// we retain the bytes the child has emitted SINCE it last cleared the screen:
|
|
8
|
+
//
|
|
9
|
+
// • an alternate-screen TUI (Claude v2) enters `?1049h`, then repaints with
|
|
10
|
+
// absolute cursor positioning — replaying the bytes since that enter/last clear
|
|
11
|
+
// reconstructs the current screen for a fresh attacher;
|
|
12
|
+
// • a line app (`cat`) never clears, so the buffer is simply the echoed content.
|
|
13
|
+
//
|
|
14
|
+
// A byte cap bounds memory; on overflow we keep the tail (the most recent, most
|
|
15
|
+
// relevant paint). The host replays get() to a joiner right after the context card.
|
|
16
|
+
|
|
17
|
+
// Sequences that wipe the whole screen (so earlier bytes no longer matter):
|
|
18
|
+
// \x1b[2J clear entire screen \x1b[3J clear screen + scrollback
|
|
19
|
+
// \x1bc full reset (RIS) \x1b[?1049h enter alternate screen
|
|
20
|
+
const CLEAR_RE = /\x1b\[2J|\x1b\[3J|\x1bc|\x1b\[\?1049h/g;
|
|
21
|
+
|
|
22
|
+
// A synchronized-update frame ends here (Claude v2). We trim the memory cap at a
|
|
23
|
+
// frame boundary when we can, so a joiner's replay never starts mid-repaint.
|
|
24
|
+
const SU_END = '\x1b[?2026l';
|
|
25
|
+
|
|
26
|
+
// Cursor visibility (DECTCEM) is a STICKY mode set once on a state change, not
|
|
27
|
+
// re-asserted per frame — so the establishing ?25h often lives in a frame the
|
|
28
|
+
// clear-reset or cap-trim has already dropped, and a joiner replaying the window
|
|
29
|
+
// resolves to cursor-hidden while everyone who watched live shows it. We track the
|
|
30
|
+
// effective state across ALL pushed bytes and re-assert it at the end of get().
|
|
31
|
+
const CURSOR_SHOW = '\x1b[?25h';
|
|
32
|
+
const CURSOR_HIDE = '\x1b[?25l';
|
|
33
|
+
|
|
34
|
+
// A COMPLETE escape sequence: CSI (ESC [ … final), OSC (ESC ] … BEL/ST), or a
|
|
35
|
+
// 2-byte ESC form (ESC 7 / ESC 8). Used to find spans so a cut never lands in the
|
|
36
|
+
// middle of one, and to decide whether a trailing ESC… is finished (finding 2).
|
|
37
|
+
const ESC_SEQ = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[78]/g;
|
|
38
|
+
const ESC_SEQ_AT_START = /^(?:\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[78])/;
|
|
39
|
+
|
|
40
|
+
// The smallest index >= min at which slicing leaves a CLEAN start — never inside an
|
|
41
|
+
// escape sequence, so a joiner's terminal never renders a fragment (";246m…") as
|
|
42
|
+
// literal text. If min sits inside a sequence we jump to that sequence's end; in
|
|
43
|
+
// plain text any index is safe, so min itself is returned.
|
|
44
|
+
function safeStart(buf, min) {
|
|
45
|
+
if (min <= 0) return 0;
|
|
46
|
+
ESC_SEQ.lastIndex = 0;
|
|
47
|
+
let m;
|
|
48
|
+
while ((m = ESC_SEQ.exec(buf)) !== null) {
|
|
49
|
+
const start = m.index;
|
|
50
|
+
const end = start + m[0].length;
|
|
51
|
+
if (end <= min) continue; // span entirely before min → irrelevant
|
|
52
|
+
if (start >= min) return min; // min is in clean text before the next span
|
|
53
|
+
return end; // min is inside this span → jump past it
|
|
54
|
+
}
|
|
55
|
+
return min; // no span covers min → plain text
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Drop a DANGLING trailing escape (a "\x1b[38;5" with no final byte, or a bare ESC)
|
|
59
|
+
// so the replay never ends mid-sequence and eat the live frames that follow it. A
|
|
60
|
+
// completed sequence — even with trailing text — is kept whole.
|
|
61
|
+
function dropTrailingPartial(s) {
|
|
62
|
+
const esc = s.lastIndexOf('\x1b');
|
|
63
|
+
if (esc === -1) return s; // no escape at all
|
|
64
|
+
if (ESC_SEQ_AT_START.test(s.slice(esc))) return s; // last ESC starts a complete seq
|
|
65
|
+
return s.slice(0, esc); // dangling partial → cut it off
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ScreenSnapshot {
|
|
69
|
+
#buf = '';
|
|
70
|
+
#cap;
|
|
71
|
+
#cursorShown = true; // terminals start with a visible cursor
|
|
72
|
+
|
|
73
|
+
/** @param {{cap?:number}} [opts] max bytes retained (default 256 KiB) */
|
|
74
|
+
constructor({ cap = 1 << 18 } = {}) {
|
|
75
|
+
this.#cap = Math.max(1, cap | 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Absorb one chunk of child output (a frame or partial frame). */
|
|
79
|
+
push(chunk) {
|
|
80
|
+
if (!chunk) return;
|
|
81
|
+
const prevLen = this.#buf.length;
|
|
82
|
+
this.#buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
83
|
+
|
|
84
|
+
// Track effective cursor visibility over the appended bytes. Scan a window
|
|
85
|
+
// reaching a few bytes back so a sequence split across chunks still counts.
|
|
86
|
+
const win = this.#buf.slice(Math.max(0, prevLen - 8));
|
|
87
|
+
const show = win.lastIndexOf(CURSOR_SHOW);
|
|
88
|
+
const hide = win.lastIndexOf(CURSOR_HIDE);
|
|
89
|
+
if (show !== -1 || hide !== -1) this.#cursorShown = show > hide;
|
|
90
|
+
|
|
91
|
+
// Drop everything before the last full-screen clear / alt-screen enter: the
|
|
92
|
+
// screen was wiped there, so nothing prior is visible any more. The cut lands ON
|
|
93
|
+
// the clear escape, so the retained buffer starts at a clean boundary.
|
|
94
|
+
CLEAR_RE.lastIndex = 0;
|
|
95
|
+
let last = -1;
|
|
96
|
+
let m;
|
|
97
|
+
while ((m = CLEAR_RE.exec(this.#buf))) last = m.index;
|
|
98
|
+
if (last > 0) this.#buf = this.#buf.slice(last);
|
|
99
|
+
|
|
100
|
+
this.#trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Bound memory to the cap, cutting only at a CLEAN boundary — never mid escape
|
|
104
|
+
// sequence (finding 2). Prefer a frame boundary (right after an SU_END): the
|
|
105
|
+
// smallest such cut whose tail fits the cap, dropping whole older frames. When no
|
|
106
|
+
// frame boundary fits (a line app with no markers), fall back to the smallest
|
|
107
|
+
// escape-safe index so the retained tail can never begin inside a sequence.
|
|
108
|
+
#trim() {
|
|
109
|
+
if (this.#buf.length <= this.#cap) return;
|
|
110
|
+
const target = this.#buf.length - this.#cap;
|
|
111
|
+
let cut = -1;
|
|
112
|
+
let idx = this.#buf.indexOf(SU_END);
|
|
113
|
+
while (idx !== -1) {
|
|
114
|
+
const after = idx + SU_END.length;
|
|
115
|
+
if (this.#buf.length - after <= this.#cap) {
|
|
116
|
+
cut = after;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
idx = this.#buf.indexOf(SU_END, after);
|
|
120
|
+
}
|
|
121
|
+
if (cut === -1) cut = safeStart(this.#buf, target);
|
|
122
|
+
this.#buf = this.#buf.slice(cut);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The bytes to replay so an attacher sees the current screen (may be ''). */
|
|
126
|
+
get() {
|
|
127
|
+
const out = dropTrailingPartial(this.#buf);
|
|
128
|
+
if (!out) return out;
|
|
129
|
+
// Re-assert the effective cursor visibility: the sticky ?25h/?25l that set it
|
|
130
|
+
// may have been trimmed out of the window above (a joiner during a busy spell
|
|
131
|
+
// otherwise lands cursor-hidden while the host's terminal shows it).
|
|
132
|
+
return out + (this.#cursorShown ? CURSOR_SHOW : CURSOR_HIDE);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Bytes currently retained. */
|
|
136
|
+
get size() {
|
|
137
|
+
return this.#buf.length;
|
|
138
|
+
}
|
|
139
|
+
}
|