@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,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
+ }