@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,243 @@
|
|
|
1
|
+
// Hook injection + event listener — how claude-share learns Claude's state.
|
|
2
|
+
//
|
|
3
|
+
// We never scrape the repainting TUI for state (spec §agent state detection).
|
|
4
|
+
// Instead claude-share launches Claude with a `--settings` file whose four hooks
|
|
5
|
+
// each POST their stdin JSON to a private unix socket, and this module serves that
|
|
6
|
+
// socket and maps every hook to one internal event:
|
|
7
|
+
//
|
|
8
|
+
// Claude hook internal event why
|
|
9
|
+
// ──────────────── ────────────── ─────────────────────────────────────────
|
|
10
|
+
// UserPromptSubmit → 'busy' turn started; payload carries permission_mode
|
|
11
|
+
// Stop → 'idle' turn finished; queue may drain (fail-closed)
|
|
12
|
+
// Notification → 'ask' ONLY when notification_type=permission_prompt
|
|
13
|
+
// PostToolUse → 'tool' a tool ran; feeds the join context card
|
|
14
|
+
//
|
|
15
|
+
// Gate on events, not predictions (spec): Claude auto-approves harmless read-only
|
|
16
|
+
// bash via its own heuristics, so which command will prompt is not predictable. The
|
|
17
|
+
// permission gate therefore arms ONLY on the permission_prompt Notification — any
|
|
18
|
+
// other Notification is dropped here so it can never arm the gate.
|
|
19
|
+
|
|
20
|
+
import net from 'node:net';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { EventEmitter } from 'node:events';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
|
|
27
|
+
/** The Claude Code hooks we inject, in the order they appear in the settings file. */
|
|
28
|
+
export const HOOK_EVENTS = Object.freeze(['UserPromptSubmit', 'Stop', 'Notification', 'PostToolUse']);
|
|
29
|
+
|
|
30
|
+
// Hooks with a one-to-one internal event. Notification and UserPromptSubmit are
|
|
31
|
+
// special-cased below.
|
|
32
|
+
const SIMPLE = Object.freeze({
|
|
33
|
+
Stop: 'idle',
|
|
34
|
+
PostToolUse: 'tool',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map a Claude hook name + its parsed payload to an internal event, or null to
|
|
39
|
+
* drop it. Pure, so the whole routing table is unit-testable without a socket.
|
|
40
|
+
* @param {string} hook Claude hook name (e.g. 'UserPromptSubmit')
|
|
41
|
+
* @param {object} payload parsed hook JSON (may be null)
|
|
42
|
+
* @returns {'busy'|'idle'|'ask'|'tool'|null}
|
|
43
|
+
*/
|
|
44
|
+
export function mapHookEvent(hook, payload) {
|
|
45
|
+
if (hook === 'Notification') {
|
|
46
|
+
return payload && payload.notification_type === 'permission_prompt' ? 'ask' : null;
|
|
47
|
+
}
|
|
48
|
+
// A slash command (/model) or ! bash line fires UserPromptSubmit on submission
|
|
49
|
+
// but resolves client-side: no turn runs, so no Stop hook ever follows. Flipping
|
|
50
|
+
// busy on that edge wedges the room "brewing" forever (dogfood: /model from a
|
|
51
|
+
// draft froze the queue). Drop the busy edge for those — a slash command that
|
|
52
|
+
// DOES run a real turn (a skill) still flips busy at its first PostToolUse.
|
|
53
|
+
// No prompt text in the payload → fail toward busy (a real turn may be starting).
|
|
54
|
+
if (hook === 'UserPromptSubmit') {
|
|
55
|
+
const p = payload && typeof payload.prompt === 'string' ? payload.prompt.trimStart() : '';
|
|
56
|
+
return p.startsWith('/') || p.startsWith('!') ? null : 'busy';
|
|
57
|
+
}
|
|
58
|
+
return SIMPLE[hook] ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The body of the injected `node -e` poster. It reads Claude's hook JSON from
|
|
62
|
+
// stdin, connects to our unix socket, and writes one line: {hook, payload} where
|
|
63
|
+
// payload is the raw JSON string (the listener parses it). It fails silently on
|
|
64
|
+
// any socket error so a relay/listener hiccup can never break a Claude hook.
|
|
65
|
+
//
|
|
66
|
+
// Constraints that keep this shell-safe:
|
|
67
|
+
// • NO single quotes inside — the whole body is wrapped in single quotes for sh.
|
|
68
|
+
// • argv[1] = hook name, argv[2] = socket path (passed as command args).
|
|
69
|
+
const POSTER = [
|
|
70
|
+
'const net=require("net");',
|
|
71
|
+
'let d="";',
|
|
72
|
+
'process.stdin.on("data",c=>d+=c);',
|
|
73
|
+
'process.stdin.on("end",()=>{',
|
|
74
|
+
'const s=net.connect(process.argv[2]);',
|
|
75
|
+
's.on("connect",()=>s.end(JSON.stringify({hook:process.argv[1],payload:d})+"\\n"));',
|
|
76
|
+
's.on("error",()=>process.exit(0));',
|
|
77
|
+
'});',
|
|
78
|
+
].join('');
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the shell command string for one injected hook. Uses `node -e` (per plan,
|
|
82
|
+
* not `nc`), the current node binary (robust vs. PATH), and double-quotes the
|
|
83
|
+
* node path + socket path so spaces survive the shell.
|
|
84
|
+
* @param {string} hook Claude hook name
|
|
85
|
+
* @param {string} socketPath unix socket the listener serves
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function buildHookCommand(hook, socketPath) {
|
|
89
|
+
return `"${process.execPath}" -e '${POSTER}' ${hook} "${socketPath}"`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the Claude Code `--settings` object: one command hook per event, in the
|
|
94
|
+
* shape Claude expects (`{ hooks: { <Event>: [{ hooks: [{type,command}] }] } }`).
|
|
95
|
+
* @param {string} socketPath
|
|
96
|
+
* @returns {object}
|
|
97
|
+
*/
|
|
98
|
+
export function buildHookSettings(socketPath) {
|
|
99
|
+
const hooks = {};
|
|
100
|
+
for (const hook of HOOK_EVENTS) {
|
|
101
|
+
hooks[hook] = [{ hooks: [{ type: 'command', command: buildHookCommand(hook, socketPath) }] }];
|
|
102
|
+
}
|
|
103
|
+
return { hooks };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Write the settings file to a temp path and return it — pass this to Claude as
|
|
108
|
+
* `--settings <file>`. Caller deletes it on teardown.
|
|
109
|
+
* @param {string} socketPath
|
|
110
|
+
* @returns {string} absolute path to the settings file
|
|
111
|
+
*/
|
|
112
|
+
export function installHooks(socketPath) {
|
|
113
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
114
|
+
const file = path.join(os.tmpdir(), `claude-share-settings-${process.pid}-${rand}.json`);
|
|
115
|
+
fs.writeFileSync(file, JSON.stringify(buildHookSettings(socketPath), null, 2));
|
|
116
|
+
return file;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Serves the unix socket the injected hooks post to, turning each post into an
|
|
121
|
+
* internal event. Extends EventEmitter, so consumers do `.on('busy'|'idle'|'ask'|'tool', payload)`.
|
|
122
|
+
* Also emits `'mode'` when the tracked permission_mode changes, and `'error'` for
|
|
123
|
+
* post-listen server errors.
|
|
124
|
+
*/
|
|
125
|
+
export class HookListener extends EventEmitter {
|
|
126
|
+
#server;
|
|
127
|
+
#socketPath;
|
|
128
|
+
#mode = null;
|
|
129
|
+
|
|
130
|
+
/** @param {string} socketPath path to bind the unix socket at */
|
|
131
|
+
constructor(socketPath) {
|
|
132
|
+
super();
|
|
133
|
+
this.#socketPath = socketPath;
|
|
134
|
+
// A stale socket file from a crashed run would make listen() throw EADDRINUSE.
|
|
135
|
+
try {
|
|
136
|
+
fs.unlinkSync(socketPath);
|
|
137
|
+
} catch {}
|
|
138
|
+
|
|
139
|
+
this.#server = net.createServer((conn) => this.#onConnection(conn));
|
|
140
|
+
|
|
141
|
+
/** Resolves once the socket is accepting connections; rejects if it can't bind. */
|
|
142
|
+
this.ready = new Promise((resolve, reject) => {
|
|
143
|
+
const onErr = (err) => reject(err);
|
|
144
|
+
this.#server.once('error', onErr);
|
|
145
|
+
this.#server.listen(socketPath, () => {
|
|
146
|
+
this.#server.removeListener('error', onErr);
|
|
147
|
+
this.#server.on('error', (err) => this.emit('error', err));
|
|
148
|
+
resolve();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
// Don't crash the process if nobody awaits ready and the bind fails.
|
|
152
|
+
this.ready.catch(() => {});
|
|
153
|
+
// The socket must not keep the event loop alive on its own.
|
|
154
|
+
if (typeof this.#server.unref === 'function') this.#server.unref();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The last permission_mode seen on any hook payload (null until the first hook). */
|
|
158
|
+
get mode() {
|
|
159
|
+
return this.#mode;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#onConnection(conn) {
|
|
163
|
+
let buf = '';
|
|
164
|
+
let done = false;
|
|
165
|
+
conn.setEncoding('utf8');
|
|
166
|
+
const finish = () => {
|
|
167
|
+
if (done) return;
|
|
168
|
+
done = true;
|
|
169
|
+
this.#ingest(buf);
|
|
170
|
+
};
|
|
171
|
+
conn.on('data', (c) => {
|
|
172
|
+
buf += c;
|
|
173
|
+
});
|
|
174
|
+
conn.on('end', finish);
|
|
175
|
+
conn.on('close', finish);
|
|
176
|
+
conn.on('error', () => {}); // a dropped hook connection is not our problem
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#ingest(buf) {
|
|
180
|
+
for (const line of buf.split('\n')) {
|
|
181
|
+
const trimmed = line.trim();
|
|
182
|
+
if (trimmed === '') continue;
|
|
183
|
+
let msg;
|
|
184
|
+
try {
|
|
185
|
+
msg = JSON.parse(trimmed);
|
|
186
|
+
} catch {
|
|
187
|
+
continue; // malformed line: drop it, keep serving
|
|
188
|
+
}
|
|
189
|
+
this.#handle(msg);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#handle(msg) {
|
|
194
|
+
if (!msg || typeof msg !== 'object') return;
|
|
195
|
+
const hook = msg.hook;
|
|
196
|
+
let payload = msg.payload;
|
|
197
|
+
// The injected poster sends payload as a raw JSON string; parse it back.
|
|
198
|
+
if (typeof payload === 'string') {
|
|
199
|
+
try {
|
|
200
|
+
payload = JSON.parse(payload);
|
|
201
|
+
} catch {
|
|
202
|
+
payload = {};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!payload || typeof payload !== 'object') payload = {};
|
|
206
|
+
|
|
207
|
+
const event = mapHookEvent(hook, payload);
|
|
208
|
+
// permission_mode rides on EVERY hook payload (spec: mode tracking for free), so
|
|
209
|
+
// track it from any hook — not only UserPromptSubmit. A mode flip (Shift+Tab)
|
|
210
|
+
// then surfaces the moment the next hook of any kind fires (Stop, PostToolUse, a
|
|
211
|
+
// permission Notification, or the next prompt), so the room's mode-change warning
|
|
212
|
+
// banner is raised on the mode-change signal itself rather than waiting for the
|
|
213
|
+
// next UserPromptSubmit. Update before emitting so an event handler reading .mode
|
|
214
|
+
// sees the fresh value.
|
|
215
|
+
if (typeof payload.permission_mode === 'string' && payload.permission_mode !== this.#mode) {
|
|
216
|
+
this.#mode = payload.permission_mode;
|
|
217
|
+
this.emit('mode', this.#mode);
|
|
218
|
+
}
|
|
219
|
+
if (event) this.emit(event, payload);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Stop serving and remove the socket file. */
|
|
223
|
+
close() {
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
this.#server.close(() => {
|
|
226
|
+
try {
|
|
227
|
+
fs.unlinkSync(this.#socketPath);
|
|
228
|
+
} catch {}
|
|
229
|
+
resolve();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Start serving the hook socket. `listenHooks(path).on('busy'|'idle'|'ask'|'tool', payload)`.
|
|
237
|
+
* Await the returned listener's `.ready` before expecting events.
|
|
238
|
+
* @param {string} socketPath
|
|
239
|
+
* @returns {HookListener}
|
|
240
|
+
*/
|
|
241
|
+
export function listenHooks(socketPath) {
|
|
242
|
+
return new HookListener(socketPath);
|
|
243
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Room-URL helpers + the room-ready toast — pure so they are unit-testable and so the
|
|
2
|
+
// host/guest URL distinction lives in exactly one place.
|
|
3
|
+
//
|
|
4
|
+
// TWO different URLs, and mixing them up is a security bug:
|
|
5
|
+
// • hostUrl — carries ?host=<token>. Whoever opens it is auto-admitted as HOST (no
|
|
6
|
+
// knock): they can /end, /kick, set roles, /pause. This is for the host's OWN tab.
|
|
7
|
+
// • inviteUrl — token-free. This is the safe link to hand a friend; they land on the
|
|
8
|
+
// normal knock/roles flow. This is what we copy to the clipboard.
|
|
9
|
+
// The earlier code surfaced ONLY the hostUrl (status line + clipboard + "link copied"),
|
|
10
|
+
// so the natural "paste the clipboard link to a friend" flow silently granted the host
|
|
11
|
+
// seat. Keep them separate: hostUrl on the host's own status line, inviteUrl on the
|
|
12
|
+
// clipboard and the host tab's "copy invite" button.
|
|
13
|
+
|
|
14
|
+
// A deployed relay advertises its public https origin (`base`) in the room grant;
|
|
15
|
+
// links then carry that origin. Localhost dev has no base → http://host:port.
|
|
16
|
+
function origin({ base, host, port }) {
|
|
17
|
+
if (base) return String(base).replace(/\/+$/, '');
|
|
18
|
+
return `http://${host}:${port}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The host's own tab URL — carries the host token (auto-admit as host). */
|
|
22
|
+
export function hostUrl({ base, host, port, code, token }) {
|
|
23
|
+
return `${origin({ base, host, port })}/${code}?host=${token}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The token-free invite URL — the safe link to share with guests. */
|
|
27
|
+
export function inviteUrl({ base, host, port, code }) {
|
|
28
|
+
return `${origin({ base, host, port })}/${code}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The "room ready" toast. Only claims the clipboard when the copy actually happened —
|
|
33
|
+
* the copy is macOS-only, opt-out via CLAUDE_SHARE_NO_CLIPBOARD, and can fail. Claiming
|
|
34
|
+
* it unconditionally (as before) told a linux/opt-out/failed host the link was on the
|
|
35
|
+
* clipboard when it was not, which — with a truncated status line — could leave them
|
|
36
|
+
* unable to reach their own room.
|
|
37
|
+
* @param {boolean} copied did the invite actually land on the clipboard?
|
|
38
|
+
*/
|
|
39
|
+
export function readyToast(copied) {
|
|
40
|
+
return copied
|
|
41
|
+
? 'room ready · invite link copied to clipboard'
|
|
42
|
+
: 'room ready · copy the invite from your host tab';
|
|
43
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Relay identity pinning — the CLI-side half of host-key verification (spec
|
|
2
|
+
// §trust: the host must know it is talking to ITS relay, not an impersonator).
|
|
3
|
+
// Works like ssh's known_hosts, trust-on-first-use: the first connection to a
|
|
4
|
+
// relay stores its SHA256:… key fingerprint under "host:port"; every later
|
|
5
|
+
// connection must present the same one or the CLI refuses to connect.
|
|
6
|
+
//
|
|
7
|
+
// An explicit expected fingerprint (--fingerprint, e.g. copied from the relay's
|
|
8
|
+
// boot log) skips the store entirely and pins exactly that value.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** Read the pin store (a flat {"host:port": "SHA256:…"} map). Missing/corrupt ⇒ empty. */
|
|
14
|
+
export function loadPins(file) {
|
|
15
|
+
try {
|
|
16
|
+
const o = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
17
|
+
return o !== null && typeof o === 'object' && !Array.isArray(o) ? o : {};
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Persist one pin. Best-effort: an unwritable home degrades to session-only TOFU. */
|
|
24
|
+
export function savePin(file, key, fp) {
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
27
|
+
const pins = loadPins(file);
|
|
28
|
+
pins[key] = fp;
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify(pins, null, 2) + '\n', { mode: 0o600 });
|
|
30
|
+
} catch {
|
|
31
|
+
/* home not writable — the in-memory pin still guards this run */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One pin check per connection attempt, handed to connectRelay's verifyHostKey.
|
|
37
|
+
* `verify` returns whether to proceed; afterwards `outcome()` says what happened
|
|
38
|
+
* so the caller can tell a mismatch (scary, do not retry) from a plain failure.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {string} opts.key store key, "host:port"
|
|
42
|
+
* @param {string} opts.file pin-store path (~/.claude-share/known_relays.json)
|
|
43
|
+
* @param {string} [opts.expected] explicit fingerprint; when set the store is not consulted
|
|
44
|
+
* @returns {{verify(fp:string):boolean, outcome():'first-seen'|'match'|'mismatch'|null, expected():string|null, seen():string|null}}
|
|
45
|
+
*/
|
|
46
|
+
export function createPinCheck({ key, file, expected }) {
|
|
47
|
+
let want = expected ?? null;
|
|
48
|
+
let outcome = null;
|
|
49
|
+
let seen = null;
|
|
50
|
+
const verify = (fp) => {
|
|
51
|
+
seen = fp;
|
|
52
|
+
if (!want) {
|
|
53
|
+
want = loadPins(file)[key] ?? null;
|
|
54
|
+
if (!want) {
|
|
55
|
+
// First contact: adopt and store this identity (TOFU, like ssh).
|
|
56
|
+
savePin(file, key, fp);
|
|
57
|
+
want = fp;
|
|
58
|
+
outcome = 'first-seen';
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
outcome = fp === want ? 'match' : 'mismatch';
|
|
63
|
+
return outcome === 'match';
|
|
64
|
+
};
|
|
65
|
+
return { verify, outcome: () => outcome, expected: () => want, seen: () => seen };
|
|
66
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The PTY wrapper: spawn Claude in a terminal that is `bandRows` shorter than
|
|
2
|
+
// the real one, and hand its output to the caller split on synchronized-update
|
|
3
|
+
// frame boundaries so the band can redraw without smearing mid-repaint.
|
|
4
|
+
//
|
|
5
|
+
// Two pieces:
|
|
6
|
+
// • FrameSplitter — pure stream cutter on the `?2026l` marker (unit-tested).
|
|
7
|
+
// • startPty() — spawns node-pty, wires FrameSplitter + an idle flush, and
|
|
8
|
+
// exposes { onFrame, onExit, write, resize, kill }.
|
|
9
|
+
//
|
|
10
|
+
// node-pty is a native module and is imported lazily inside startPty, so this
|
|
11
|
+
// file (and FrameSplitter's tests) load fine even where node-pty isn't built.
|
|
12
|
+
|
|
13
|
+
/** Synchronized-update END marker. Claude ends every repaint frame with this. */
|
|
14
|
+
export const SU_END = '\x1b[?2026l';
|
|
15
|
+
/** Synchronized-update BEGIN marker (exported for reference / future use). */
|
|
16
|
+
export const SU_BEGIN = '\x1b[?2026h';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cuts a byte stream into frames on the `?2026l` boundary. Feed chunks with
|
|
20
|
+
* push(); it returns every complete frame (each ending in the marker) and holds
|
|
21
|
+
* the trailing partial frame until more arrives or you flush(). Handles a marker
|
|
22
|
+
* that straddles a chunk boundary because it buffers before searching.
|
|
23
|
+
*/
|
|
24
|
+
export class FrameSplitter {
|
|
25
|
+
#buf = '';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} chunk decoded terminal output
|
|
29
|
+
* @returns {string[]} complete frames this chunk finished (possibly empty)
|
|
30
|
+
*/
|
|
31
|
+
push(chunk) {
|
|
32
|
+
this.#buf += chunk;
|
|
33
|
+
const frames = [];
|
|
34
|
+
let idx;
|
|
35
|
+
while ((idx = this.#buf.indexOf(SU_END)) !== -1) {
|
|
36
|
+
const end = idx + SU_END.length;
|
|
37
|
+
frames.push(this.#buf.slice(0, end));
|
|
38
|
+
this.#buf = this.#buf.slice(end);
|
|
39
|
+
}
|
|
40
|
+
return frames;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Return + clear whatever partial frame is buffered (null if empty). */
|
|
44
|
+
flush() {
|
|
45
|
+
if (this.#buf === '') return null;
|
|
46
|
+
const rem = this.#buf;
|
|
47
|
+
this.#buf = '';
|
|
48
|
+
return rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Bytes currently buffered without a terminating marker. */
|
|
52
|
+
get pending() {
|
|
53
|
+
return this.#buf.length;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Spawn a command in a shortened PTY and stream its output as frames.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} opts
|
|
61
|
+
* @param {string} opts.cmd binary to spawn (e.g. 'claude')
|
|
62
|
+
* @param {string[]} [opts.args] arguments
|
|
63
|
+
* @param {number} [opts.bandRows=2] rows reserved at the bottom for the band
|
|
64
|
+
* @param {number} [opts.cols] real terminal columns (default stdout)
|
|
65
|
+
* @param {number} [opts.rows] real terminal rows (default stdout)
|
|
66
|
+
* @param {object} [opts.env] extra env merged over process.env
|
|
67
|
+
* @param {string} [opts.cwd] working directory
|
|
68
|
+
* @param {number} [opts.flushMs=8] idle delay before flushing a markerless tail
|
|
69
|
+
* @returns {Promise<{onFrame,onExit,write,resize,kill,cols,childRows}>}
|
|
70
|
+
*/
|
|
71
|
+
export async function startPty({
|
|
72
|
+
cmd,
|
|
73
|
+
args = [],
|
|
74
|
+
bandRows = 2,
|
|
75
|
+
cols,
|
|
76
|
+
rows,
|
|
77
|
+
env,
|
|
78
|
+
cwd,
|
|
79
|
+
flushMs = 8,
|
|
80
|
+
} = {}) {
|
|
81
|
+
const mod = await import('node-pty');
|
|
82
|
+
const spawn = mod.spawn ?? mod.default?.spawn;
|
|
83
|
+
if (typeof spawn !== 'function') {
|
|
84
|
+
throw new Error('node-pty did not export spawn(); is it installed and built?');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const realCols = cols ?? process.stdout.columns ?? 80;
|
|
88
|
+
const realRows = rows ?? process.stdout.rows ?? 24;
|
|
89
|
+
const childRows = Math.max(1, realRows - bandRows);
|
|
90
|
+
|
|
91
|
+
const term = spawn(cmd, args, {
|
|
92
|
+
name: 'xterm-256color',
|
|
93
|
+
cols: realCols,
|
|
94
|
+
rows: childRows,
|
|
95
|
+
cwd: cwd ?? process.cwd(),
|
|
96
|
+
env: { ...process.env, ...env },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const splitter = new FrameSplitter();
|
|
100
|
+
const frameCbs = new Set();
|
|
101
|
+
const exitCbs = new Set();
|
|
102
|
+
let flushTimer = null;
|
|
103
|
+
|
|
104
|
+
const emit = (chunk) => {
|
|
105
|
+
if (chunk) for (const cb of frameCbs) cb(chunk);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const scheduleFlush = () => {
|
|
109
|
+
if (flushTimer) return;
|
|
110
|
+
flushTimer = setTimeout(() => {
|
|
111
|
+
flushTimer = null;
|
|
112
|
+
emit(splitter.flush());
|
|
113
|
+
}, flushMs);
|
|
114
|
+
if (typeof flushTimer.unref === 'function') flushTimer.unref();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
term.onData((d) => {
|
|
118
|
+
for (const frame of splitter.push(typeof d === 'string' ? d : d.toString('utf8'))) {
|
|
119
|
+
emit(frame);
|
|
120
|
+
}
|
|
121
|
+
scheduleFlush();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
term.onExit((e) => {
|
|
125
|
+
if (flushTimer) {
|
|
126
|
+
clearTimeout(flushTimer);
|
|
127
|
+
flushTimer = null;
|
|
128
|
+
}
|
|
129
|
+
emit(splitter.flush()); // deliver any tail before we report exit
|
|
130
|
+
for (const cb of exitCbs) cb(e);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
/** Register a frame callback. Returns an unsubscribe fn. */
|
|
135
|
+
onFrame(cb) {
|
|
136
|
+
frameCbs.add(cb);
|
|
137
|
+
return () => frameCbs.delete(cb);
|
|
138
|
+
},
|
|
139
|
+
/** Register an exit callback ({exitCode, signal}). Returns an unsubscribe fn. */
|
|
140
|
+
onExit(cb) {
|
|
141
|
+
exitCbs.add(cb);
|
|
142
|
+
return () => exitCbs.delete(cb);
|
|
143
|
+
},
|
|
144
|
+
/** Send input (keystrokes) to the child. */
|
|
145
|
+
write(data) {
|
|
146
|
+
term.write(typeof data === 'string' ? data : data.toString('utf8'));
|
|
147
|
+
},
|
|
148
|
+
/** Resize the child PTY to (newCols, newRows - bandRows). Defaults to stdout size. */
|
|
149
|
+
resize(newCols, newRows) {
|
|
150
|
+
const c = newCols ?? process.stdout.columns ?? realCols;
|
|
151
|
+
const r = (newRows ?? process.stdout.rows ?? realRows) - bandRows;
|
|
152
|
+
term.resize(Math.max(1, c), Math.max(1, r));
|
|
153
|
+
},
|
|
154
|
+
/**
|
|
155
|
+
* Resize the child PTY to an EXACT size (cols × childRows), no band arithmetic.
|
|
156
|
+
* The band's height is dynamic (it grows/shrinks with content), so the caller
|
|
157
|
+
* computes Claude's region directly — rows minus the current band height — and
|
|
158
|
+
* sets it here (dogfood finding: the child must shrink/grow so the band never
|
|
159
|
+
* overlaps Claude's output). Undefined dimensions keep the current one.
|
|
160
|
+
*/
|
|
161
|
+
resizeChild(newCols, childRows) {
|
|
162
|
+
term.resize(Math.max(1, newCols ?? term.cols), Math.max(1, childRows ?? term.rows));
|
|
163
|
+
},
|
|
164
|
+
/** Kill the child. */
|
|
165
|
+
kill(signal) {
|
|
166
|
+
term.kill(signal);
|
|
167
|
+
},
|
|
168
|
+
get cols() {
|
|
169
|
+
return term.cols;
|
|
170
|
+
},
|
|
171
|
+
get childRows() {
|
|
172
|
+
return term.rows;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|