inject-steer 0.0.0-stage → 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.
@@ -0,0 +1,56 @@
1
+ // In-memory message queue owned by the wrapper process.
2
+ //
3
+ // Node runs the socket handlers one at a time, so a claim is atomic: when the
4
+ // agent fires several hooks at once (parallel tool calls), exactly one of them
5
+ // receives a given message.
6
+
7
+ import { setFlag } from './session.js';
8
+
9
+ let nextId = 1;
10
+
11
+ export class Queue {
12
+ /** @param {string} dir session directory whose flag files mirror the queue */
13
+ constructor(dir) {
14
+ this.dir = dir;
15
+ /** @type {{id:number,text:string,urgency:'normal'|'urgent',queuedAt:string}[]} */
16
+ this.items = [];
17
+ }
18
+
19
+ add(text, urgency) {
20
+ const msg = { id: nextId++, text, urgency, queuedAt: new Date().toISOString() };
21
+ this.items.push(msg);
22
+ this.syncFlags();
23
+ return msg;
24
+ }
25
+
26
+ /**
27
+ * 'before-tool': if any urgent message is queued, take everything (the tool
28
+ * call is about to be denied, so pending notes ride along with the reason).
29
+ * Otherwise take nothing.
30
+ * 'after-tool': take the normal notes; urgent messages wait for the next
31
+ * tool call so they can block it.
32
+ */
33
+ claim(boundary) {
34
+ let taken;
35
+ if (boundary === 'before-tool') {
36
+ taken = this.items.some((m) => m.urgency === 'urgent') ? this.items : [];
37
+ } else {
38
+ taken = this.items.filter((m) => m.urgency === 'normal');
39
+ }
40
+ if (taken.length) {
41
+ const ids = new Set(taken.map((m) => m.id));
42
+ this.items = this.items.filter((m) => !ids.has(m.id));
43
+ this.syncFlags();
44
+ }
45
+ return taken;
46
+ }
47
+
48
+ get size() {
49
+ return this.items.length;
50
+ }
51
+
52
+ syncFlags() {
53
+ setFlag(this.dir, 'normal', this.items.some((m) => m.urgency === 'normal'));
54
+ setFlag(this.dir, 'urgent', this.items.some((m) => m.urgency === 'urgent'));
55
+ }
56
+ }
@@ -0,0 +1,50 @@
1
+ // Per-session runtime directory shared by the wrapper and the agent's hooks.
2
+ //
3
+ // The wrapper creates the directory, listens on <dir>/sock and passes the path
4
+ // to the agent through the INJECT_DIR environment variable. Hooks inherit the
5
+ // agent's environment, so each hook knows which wrapper (terminal) it belongs to.
6
+ //
7
+ // Two empty flag files mirror the queue so a hook can skip starting Node when
8
+ // there is nothing to deliver:
9
+ // <dir>/normal exists while at least one normal note is queued
10
+ // <dir>/urgent exists while at least one urgent message is queued
11
+
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ export const ENV_DIR = 'INJECT_DIR';
17
+ export const ENV_NODE = 'INJECT_NODE';
18
+
19
+ export function createSessionDir() {
20
+ // XDG_RUNTIME_DIR is per-user and private on most Linux systems; macOS has
21
+ // a per-user TMPDIR. mkdtemp creates the directory with mode 0700.
22
+ const base = process.env.XDG_RUNTIME_DIR || os.tmpdir();
23
+ return fs.mkdtempSync(path.join(base, 'inject-'));
24
+ }
25
+
26
+ export function socketPath(dir) {
27
+ return path.join(dir, 'sock');
28
+ }
29
+
30
+ export function flagPath(dir, urgency) {
31
+ return path.join(dir, urgency === 'urgent' ? 'urgent' : 'normal');
32
+ }
33
+
34
+ export function setFlag(dir, urgency, on) {
35
+ const file = flagPath(dir, urgency);
36
+ try {
37
+ if (on) fs.writeFileSync(file, '');
38
+ else fs.rmSync(file, { force: true });
39
+ } catch {
40
+ // The directory may already be gone during shutdown.
41
+ }
42
+ }
43
+
44
+ export function removeSessionDir(dir) {
45
+ try {
46
+ fs.rmSync(dir, { recursive: true, force: true });
47
+ } catch {
48
+ // Best effort.
49
+ }
50
+ }
@@ -0,0 +1,349 @@
1
+ // Keystroke interceptor. Pure logic, no I/O: feed() takes raw terminal input
2
+ // and returns what to forward to the agent plus UI events.
3
+ //
4
+ // The agent never sees "/inject ..." lines. To make that work without seeing
5
+ // the agent's input box, we keep a shadow copy of what the user typed since the
6
+ // last submit. When that copy is empty and the user starts typing a prefix of
7
+ // "/inject " or "/inject! ", we hold the keys back. If the prefix completes we
8
+ // switch to capture mode; if it diverges ("/init") we forward the held keys
9
+ // unchanged, so the agent sees exactly what was typed.
10
+ //
11
+ // Cursor keys, tab completion and other editing we can't model make the shadow
12
+ // copy unknown; detection then pauses until the next Enter or Ctrl+C. Things
13
+ // the terminal reports on its own (focus changes, mouse, replies to the
14
+ // agent's queries) are forwarded without affecting detection.
15
+
16
+ export const TRIGGERS = [
17
+ { prefix: '/inject! ', urgency: 'urgent' },
18
+ { prefix: '/inject ', urgency: 'normal' },
19
+ ];
20
+ const BARE_WORDS = ['/inject', '/inject!'];
21
+
22
+ const PASTE_START = '\x1b[200~';
23
+ const PASTE_END = '\x1b[201~';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Tokenizer
27
+
28
+ /**
29
+ * @typedef {{kind:'char', ch:string, raw:string}
30
+ * | {kind:'key', key:string, raw:string}
31
+ * | {kind:'paste', text:string, raw:string}
32
+ * | {kind:'report', raw:string}
33
+ * | {kind:'other', raw:string}} Token
34
+ */
35
+
36
+ // Keys reported through the kitty keyboard protocol (CSI <code>;<mods> u) or
37
+ // xterm modifyOtherKeys (CSI 27;<mods>;<code> ~), which terminals use when the
38
+ // agent turns them on.
39
+ function decodeModifiedKey(code, mods) {
40
+ const m = (mods || 1) - 1; // bit 0 shift, bit 2 ctrl
41
+ const shift = m & 1;
42
+ const ctrl = m & 4;
43
+ if (code === 13) return shift || ctrl ? 'newline' : m === 0 ? 'enter' : null;
44
+ if (code === 27 && m === 0) return 'escape';
45
+ if (code === 127 || code === 8) return m === 0 ? 'backspace' : null;
46
+ if (ctrl && (code === 99 || code === 67)) return 'ctrl-c';
47
+ if (ctrl && (code === 117 || code === 85)) return 'ctrl-u';
48
+ return null;
49
+ }
50
+
51
+ function keyForControl(ch) {
52
+ switch (ch) {
53
+ case '\r': return 'enter';
54
+ case '\n': return 'newline';
55
+ case '\x7f':
56
+ case '\b': return 'backspace';
57
+ case '\x03': return 'ctrl-c';
58
+ case '\x15': return 'ctrl-u';
59
+ default: return null;
60
+ }
61
+ }
62
+
63
+ class Tokenizer {
64
+ constructor() {
65
+ this.paste = null; // accumulated paste text while inside a bracketed paste
66
+ }
67
+
68
+ /** @returns {Token[]} */
69
+ tokens(data) {
70
+ const out = [];
71
+ let i = 0;
72
+ while (i < data.length) {
73
+ if (this.paste !== null) {
74
+ const end = data.indexOf(PASTE_END, i);
75
+ if (end === -1) {
76
+ this.paste += data.slice(i);
77
+ break;
78
+ }
79
+ const text = this.paste + data.slice(i, end);
80
+ this.paste = null;
81
+ out.push({ kind: 'paste', text, raw: PASTE_START + text + PASTE_END });
82
+ i = end + PASTE_END.length;
83
+ continue;
84
+ }
85
+ if (data.startsWith(PASTE_START, i)) {
86
+ this.paste = '';
87
+ i += PASTE_START.length;
88
+ continue;
89
+ }
90
+ const ch = data[i];
91
+ if (ch === '\x1b') {
92
+ const len = escapeLength(data, i);
93
+ const raw = data.slice(i, i + len);
94
+ out.push(classifyEscape(raw));
95
+ i += len;
96
+ continue;
97
+ }
98
+ const cp = data.codePointAt(i);
99
+ const s = String.fromCodePoint(cp);
100
+ i += s.length;
101
+ const key = keyForControl(s);
102
+ if (key) out.push({ kind: 'key', key, raw: s });
103
+ else if (cp < 0x20) out.push({ kind: 'other', raw: s });
104
+ else out.push({ kind: 'char', ch: s, raw: s });
105
+ }
106
+ return out;
107
+ }
108
+ }
109
+
110
+ function escapeLength(data, i) {
111
+ const next = data[i + 1];
112
+ if (next === undefined) return 1; // lone Esc
113
+ if (next === '[') {
114
+ let j = i + 2;
115
+ while (j < data.length) {
116
+ const c = data.charCodeAt(j);
117
+ if (c >= 0x40 && c <= 0x7e) return j - i + 1;
118
+ j++;
119
+ }
120
+ return data.length - i;
121
+ }
122
+ if (next === 'O') return Math.min(3, data.length - i);
123
+ if (next === ']' || next === 'P' || next === '_' || next === '^') {
124
+ // OSC / DCS / APC / PM string, ended by BEL or ST (ESC \)
125
+ for (let j = i + 2; j < data.length; j++) {
126
+ if (data[j] === '\x07') return j - i + 1;
127
+ if (data[j] === '\x1b' && data[j + 1] === '\\') return j - i + 2;
128
+ }
129
+ return data.length - i;
130
+ }
131
+ if (next === '\x1b') return 1; // Esc followed by another escape sequence
132
+ // Alt+<char>: one code point after Esc
133
+ return 1 + String.fromCodePoint(data.codePointAt(i + 1)).length;
134
+ }
135
+
136
+ // Input the terminal sends on its own rather than because a key was pressed.
137
+ function isReport(raw) {
138
+ const intro = raw[1];
139
+ if (intro === ']' || intro === 'P' || intro === '_' || intro === '^') return true; // query replies
140
+ if (intro !== '[') return false;
141
+ if (raw === '\x1b[I' || raw === '\x1b[O') return true; // focus in / out
142
+ if (/^\x1b\[<[\d;]*[Mm]$/.test(raw)) return true; // SGR mouse
143
+ if (/^\x1b\[\?[\d;]*[uc]$/.test(raw)) return true; // keyboard-flags / device-attributes replies
144
+ if (/^\x1b\[[\d;?]*\$y$/.test(raw)) return true; // mode report
145
+ if (/^\x1b\[\d+;\d+R$/.test(raw)) return true; // cursor position report
146
+ if (/^\x1b\[\d*n$/.test(raw)) return true; // status report
147
+ return false;
148
+ }
149
+
150
+ // A printable character sent as an escape code (kitty protocol or
151
+ // modifyOtherKeys), with no modifier other than Shift.
152
+ function printable(code, mods, shifted) {
153
+ const m = (mods || 1) - 1;
154
+ if (m & ~1) return null;
155
+ const cp = m === 1 && shifted ? shifted : code;
156
+ if (cp < 0x20 || cp === 0x7f || cp > 0x10ffff) return null;
157
+ const ch = String.fromCodePoint(cp);
158
+ return m === 1 && !shifted && /^[a-z]$/.test(ch) ? ch.toUpperCase() : ch;
159
+ }
160
+
161
+ function classifyEscape(raw) {
162
+ if (raw === '\x1b') return { kind: 'key', key: 'escape', raw };
163
+ if (isReport(raw)) return { kind: 'report', raw };
164
+ // kitty: CSI code[:shifted[:base]] [;mods[:event]] [;text] u
165
+ let m = /^\x1b\[(\d+)(?::(\d*))?(?::\d*)?(?:;(\d+)(?::\d+)?)?(?:;[\d:]*)?u$/.exec(raw);
166
+ if (m) {
167
+ const code = Number(m[1]);
168
+ const mods = Number(m[3] || 1);
169
+ const key = decodeModifiedKey(code, mods);
170
+ if (key) return { kind: 'key', key, raw };
171
+ const ch = printable(code, mods, m[2] ? Number(m[2]) : 0);
172
+ if (ch) return { kind: 'char', ch, raw };
173
+ }
174
+ // xterm modifyOtherKeys: CSI 27 ; mods ; code ~
175
+ m = /^\x1b\[27;(\d+);(\d+)~$/.exec(raw);
176
+ if (m) {
177
+ const key = decodeModifiedKey(Number(m[2]), Number(m[1]));
178
+ if (key) return { kind: 'key', key, raw };
179
+ const ch = printable(Number(m[2]), 1, 0);
180
+ if (ch && Number(m[1]) <= 2) return { kind: 'char', ch, raw };
181
+ }
182
+ if (raw === '\x1b\r') return { kind: 'key', key: 'newline', raw }; // Alt+Enter
183
+ return { kind: 'other', raw };
184
+ }
185
+
186
+ function oneLine(text) {
187
+ return text.replace(/\r\n|\r|\n/g, ' ');
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Interceptor
192
+
193
+ /**
194
+ * @typedef {{type:'capture', urgency:string, text:string}
195
+ * | {type:'submit', urgency:string, text:string}
196
+ * | {type:'cancel'}
197
+ * | {type:'usage'}} InterceptEvent
198
+ */
199
+
200
+ export class Interceptor {
201
+ constructor() {
202
+ this.tokenizer = new Tokenizer();
203
+ /** Shadow of the agent's input since the last submit; null when unknown. */
204
+ this.line = '';
205
+ /** Keys held back because they could still become a trigger. */
206
+ this.heldToks = [];
207
+ /** @type {{urgency:string, text:string} | null} */
208
+ this.capture = null;
209
+ }
210
+
211
+ get capturing() {
212
+ return this.capture !== null;
213
+ }
214
+
215
+ get held() {
216
+ return this.heldToks.map((t) => (t.kind === 'paste' ? t.text : t.ch)).join('');
217
+ }
218
+
219
+ /** @returns {{forward:string, events:InterceptEvent[]}} */
220
+ feed(data) {
221
+ const res = { forward: '', events: [] };
222
+ for (const tok of this.tokenizer.tokens(data)) {
223
+ // Always pass terminal reports through: the agent may be waiting on them.
224
+ if (tok.kind === 'report') res.forward += tok.raw;
225
+ else if (this.capture) this.captureToken(tok, res);
226
+ else this.passToken(tok, res);
227
+ }
228
+ return res;
229
+ }
230
+
231
+ passToken(tok, res) {
232
+ const held = this.held;
233
+ if (held || this.line === '') {
234
+ if (tok.kind === 'char' || tok.kind === 'paste') {
235
+ // A pasted "/inject ..." (e.g. copied from docs) is captured just like
236
+ // a typed one; the rest of the paste lands in the one-line editor.
237
+ const cand = held + (tok.kind === 'paste' ? tok.text : tok.ch);
238
+ const trigger = TRIGGERS.find((t) => cand.startsWith(t.prefix));
239
+ if (trigger) {
240
+ this.heldToks = [];
241
+ const text = oneLine(cand.slice(trigger.prefix.length));
242
+ this.capture = { urgency: trigger.urgency, text };
243
+ res.events.push({ type: 'capture', urgency: trigger.urgency, text });
244
+ return;
245
+ }
246
+ if (TRIGGERS.some((t) => t.prefix.startsWith(cand))) {
247
+ this.heldToks.push(tok);
248
+ return;
249
+ }
250
+ }
251
+ if (held && tok.kind === 'key') {
252
+ if (tok.key === 'enter' && BARE_WORDS.includes(held)) {
253
+ this.heldToks = [];
254
+ res.events.push({ type: 'usage' });
255
+ return;
256
+ }
257
+ if (tok.key === 'backspace') {
258
+ const last = this.heldToks.pop();
259
+ if (last.kind === 'paste' && last.text.length > 1) {
260
+ const text = last.text.slice(0, -1);
261
+ this.heldToks.push({ kind: 'paste', text, raw: text });
262
+ }
263
+ return;
264
+ }
265
+ if (tok.key === 'escape' || tok.key === 'ctrl-c') {
266
+ // The agent never saw the held keys, so dropping them is the whole
267
+ // cancel. Don't forward: Esc / Ctrl+C would interrupt the agent.
268
+ this.heldToks = [];
269
+ return;
270
+ }
271
+ }
272
+ // Not a trigger: release the held keys exactly as they were typed.
273
+ for (const t of this.heldToks) {
274
+ res.forward += t.raw;
275
+ this.track(t);
276
+ }
277
+ this.heldToks = [];
278
+ }
279
+
280
+ res.forward += tok.raw;
281
+ this.track(tok);
282
+ }
283
+
284
+ track(tok) {
285
+ if (tok.kind === 'key') {
286
+ switch (tok.key) {
287
+ case 'enter':
288
+ case 'ctrl-c':
289
+ case 'ctrl-u':
290
+ this.line = '';
291
+ return;
292
+ case 'newline':
293
+ if (this.line !== null) this.line += '\n';
294
+ return;
295
+ case 'backspace':
296
+ if (this.line !== null) this.line = this.line.slice(0, -1);
297
+ return;
298
+ default:
299
+ this.line = null; // Esc may clear, recall or interrupt; we can't tell
300
+ return;
301
+ }
302
+ }
303
+ if (this.line === null) return;
304
+ if (tok.kind === 'char') this.line += tok.ch;
305
+ else if (tok.kind === 'paste') this.line += tok.text;
306
+ else this.line = null;
307
+ }
308
+
309
+ captureToken(tok, res) {
310
+ const c = this.capture;
311
+ if (tok.kind === 'char') c.text += tok.ch;
312
+ else if (tok.kind === 'paste') c.text += oneLine(tok.text);
313
+ else if (tok.kind === 'key') {
314
+ switch (tok.key) {
315
+ case 'enter': {
316
+ this.capture = null;
317
+ this.line = '';
318
+ const text = c.text.trim();
319
+ res.events.push(text ? { type: 'submit', urgency: c.urgency, text } : { type: 'cancel' });
320
+ return;
321
+ }
322
+ case 'newline':
323
+ c.text += ' ';
324
+ break;
325
+ case 'backspace':
326
+ if (c.text === '') return this.cancel(res);
327
+ c.text = Array.from(c.text).slice(0, -1).join('');
328
+ break;
329
+ case 'ctrl-u':
330
+ c.text = '';
331
+ break;
332
+ case 'escape':
333
+ case 'ctrl-c':
334
+ return this.cancel(res);
335
+ default:
336
+ return;
337
+ }
338
+ } else {
339
+ return; // cursor keys etc. are ignored in the one-line editor
340
+ }
341
+ res.events.push({ type: 'capture', urgency: c.urgency, text: c.text });
342
+ }
343
+
344
+ cancel(res) {
345
+ this.capture = null;
346
+ this.line = '';
347
+ res.events.push({ type: 'cancel' });
348
+ }
349
+ }
@@ -0,0 +1,158 @@
1
+ // One-line status overlay drawn on the terminal's bottom row, on top of the
2
+ // agent's UI. The agent repaints its screen often, so we redraw after its
3
+ // output settles, and only when its output isn't stopped mid escape sequence.
4
+
5
+ const ESC = '\x1b';
6
+ const SAVE = `${ESC}7`;
7
+ const RESTORE = `${ESC}8`;
8
+ const RESET = `${ESC}[0m`;
9
+ const LABEL = `${ESC}[1;30;46m`; // bold black on cyan
10
+ const LABEL_URGENT = `${ESC}[1;97;41m`; // bold white on red
11
+ const DIM = `${ESC}[2m`;
12
+ const OK = `${ESC}[32m`;
13
+
14
+ const TOAST_MS = 4000;
15
+
16
+ export class Overlay {
17
+ /** @param {NodeJS.WriteStream} out */
18
+ constructor(out) {
19
+ this.out = out;
20
+ /** @type {{urgency:string,text:string}|null} */
21
+ this.input = null;
22
+ /** @type {{text:string, until:number}|null} */
23
+ this.toastMsg = null;
24
+ this.pending = 0;
25
+ this.visible = false;
26
+ this.childState = 'text';
27
+ this.timer = null;
28
+ this.toastTimer = null;
29
+ }
30
+
31
+ editing(urgency, text) {
32
+ this.input = { urgency, text };
33
+ this.draw();
34
+ }
35
+
36
+ stopEditing() {
37
+ this.input = null;
38
+ this.draw();
39
+ }
40
+
41
+ toast(text) {
42
+ this.toastMsg = { text, until: Date.now() + TOAST_MS };
43
+ clearTimeout(this.toastTimer);
44
+ this.toastTimer = setTimeout(() => {
45
+ this.toastMsg = null;
46
+ this.draw();
47
+ }, TOAST_MS);
48
+ this.toastTimer.unref?.();
49
+ this.draw();
50
+ }
51
+
52
+ setPending(n) {
53
+ this.pending = n;
54
+ this.draw();
55
+ }
56
+
57
+ /** Call after forwarding agent output to the terminal. */
58
+ afterOutput(chunk) {
59
+ this.childState = scanEscapeState(chunk, this.childState);
60
+ if (!this.visible && !this.content()) return;
61
+ clearTimeout(this.timer);
62
+ this.timer = setTimeout(() => this.draw(), 30);
63
+ this.timer.unref?.();
64
+ }
65
+
66
+ content() {
67
+ const cols = this.out.columns || 80;
68
+ if (this.input) {
69
+ const urgent = this.input.urgency === 'urgent';
70
+ const label = urgent ? `${LABEL_URGENT} inject! ${RESET}` : `${LABEL} inject ${RESET}`;
71
+ const hint = `${DIM} Enter send · Esc cancel${RESET}`;
72
+ const room = Math.max(10, cols - 9 - 2 - 24);
73
+ let text = this.input.text;
74
+ if (text.length > room) text = '…' + text.slice(text.length - room + 1);
75
+ return `${label} ${text}█${hint}`;
76
+ }
77
+ if (this.toastMsg) {
78
+ return clip(this.toastMsg.text, cols - 1);
79
+ }
80
+ if (this.pending > 0) {
81
+ const pill = `${LABEL} inject ${RESET}${DIM} ${this.pending} queued · delivers at next step${RESET}`;
82
+ return pill;
83
+ }
84
+ return '';
85
+ }
86
+
87
+ draw() {
88
+ if (this.childState !== 'text') {
89
+ // Don't split the agent's escape sequence; afterOutput() retries.
90
+ clearTimeout(this.timer);
91
+ this.timer = setTimeout(() => this.draw(), 30);
92
+ this.timer.unref?.();
93
+ return;
94
+ }
95
+ const rows = this.out.rows || 24;
96
+ const content = this.content();
97
+ if (!content && !this.visible) return;
98
+ this.out.write(`${SAVE}${ESC}[${rows};1H${ESC}[2K${content}${RESET}${RESTORE}`);
99
+ this.visible = Boolean(content);
100
+ }
101
+ }
102
+
103
+ export function queuedToast(urgency) {
104
+ return urgency === 'urgent'
105
+ ? `${LABEL_URGENT} inject! ${RESET} queued · will block the next tool call`
106
+ : `${LABEL} inject ${RESET} queued · delivers at next step`;
107
+ }
108
+
109
+ export function deliveredToast(count, urgent, doing) {
110
+ const what = urgent ? 'blocked the tool call and delivered' : 'delivered';
111
+ const n = count === 1 ? '' : ` ${count} messages`;
112
+ const where = doing ? `${DIM} (${doing})${RESET}` : '';
113
+ return `${OK}✓${RESET} inject: ${what}${n}${where}`;
114
+ }
115
+
116
+ export function usageToast() {
117
+ return `${LABEL} inject ${RESET} usage: /inject <note> · /inject! <urgent: blocks the next tool call>`;
118
+ }
119
+
120
+ // Strip SGR codes for length math; content is short so a rough clip is fine.
121
+ function clip(s, max) {
122
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
123
+ if (plain.length <= max) return s;
124
+ return plain.slice(0, Math.max(0, max - 1)) + '…';
125
+ }
126
+
127
+ /**
128
+ * Tracks whether the agent's output stream is inside an escape sequence, so
129
+ * the overlay never writes into the middle of one. Pass the returned state
130
+ * back in with the next chunk. Covers CSI, two-byte escapes, and OSC/DCS/APC
131
+ * strings terminated by BEL or ST.
132
+ */
133
+ export function scanEscapeState(chunk, state = 'text') {
134
+ for (let i = 0; i < chunk.length; i++) {
135
+ const c = chunk.charCodeAt(i);
136
+ switch (state) {
137
+ case 'text':
138
+ if (c === 0x1b) state = 'esc';
139
+ break;
140
+ case 'esc':
141
+ if (c === 0x5b) state = 'csi'; // [
142
+ else if (c === 0x5d || c === 0x50 || c === 0x5f || c === 0x5e) state = 'str'; // ] P _ ^
143
+ else state = 'text';
144
+ break;
145
+ case 'csi':
146
+ if (c >= 0x40 && c <= 0x7e) state = 'text';
147
+ break;
148
+ case 'str':
149
+ if (c === 0x07) state = 'text';
150
+ else if (c === 0x1b) state = 'strEsc';
151
+ break;
152
+ case 'strEsc':
153
+ state = c === 0x5c ? 'text' : 'str';
154
+ break;
155
+ }
156
+ }
157
+ return state;
158
+ }
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ /** Loads node-pty, repairing its macOS helper's permissions if needed. */
8
+ export function loadPty() {
9
+ ensureSpawnHelperExecutable();
10
+ try {
11
+ return require('node-pty');
12
+ } catch (err) {
13
+ // Linux has no prebuilt binary; node-pty compiles at install time, which
14
+ // needs build tools and an install script npm may have skipped.
15
+ const dir = path.dirname(path.dirname(require.resolve('node-pty/package.json')));
16
+ throw new Error(
17
+ `could not load node-pty (${err.message.split('\n')[0]}).\n` +
18
+ ` Install build tools (python3, make, a C++ compiler), then run:\n` +
19
+ ` cd "${dir}/.." && npm rebuild node-pty`,
20
+ );
21
+ }
22
+ }
23
+
24
+ // npm 11 skips dependency install scripts unless approved, and node-pty's
25
+ // postinstall is what marks its macOS prebuilt spawn-helper executable.
26
+ function ensureSpawnHelperExecutable() {
27
+ if (process.platform !== 'darwin') return;
28
+ const root = path.dirname(require.resolve('node-pty/package.json'));
29
+ const candidates = [
30
+ path.join(root, 'prebuilds', `darwin-${process.arch}`, 'spawn-helper'),
31
+ path.join(root, 'build', 'Release', 'spawn-helper'),
32
+ ];
33
+ for (const file of candidates) {
34
+ try {
35
+ const { mode } = fs.statSync(file);
36
+ if ((mode & 0o111) === 0) fs.chmodSync(file, 0o755);
37
+ } catch (err) {
38
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
39
+ throw new Error(`node-pty's spawn-helper is not executable and can't be fixed automatically. Run:\n chmod +x "${file}"`);
40
+ }
41
+ }
42
+ }
43
+ }