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.
- package/.claude-plugin/marketplace.json +11 -0
- package/LICENSE +21 -0
- package/README.md +253 -2
- package/bin/inject.js +4 -0
- package/mod/.claude-plugin/plugin.json +6 -0
- package/mod/hooks/hooks.json +4 -0
- package/mod/hooks/register.js +69 -0
- package/mod/tsconfig.json +3 -0
- package/package.json +34 -4
- package/src/adapters/claude-code/hook.js +78 -0
- package/src/adapters/claude-code/index.js +14 -0
- package/src/adapters/claude-code/install.js +211 -0
- package/src/adapters/claude-code/output.js +39 -0
- package/src/adapters/claude-code/plain.js +47 -0
- package/src/adapters/claude-code/prompt-hook.js +20 -0
- package/src/adapters/claude-code/prompt.js +14 -0
- package/src/adapters/claude-code/steering.js +98 -0
- package/src/adapters/index.js +8 -0
- package/src/adapters/types.js +28 -0
- package/src/cli.js +40 -0
- package/src/core/format.js +32 -0
- package/src/core/ipc.js +61 -0
- package/src/core/log.js +34 -0
- package/src/core/queue.js +56 -0
- package/src/core/session.js +50 -0
- package/src/wrapper/intercept.js +349 -0
- package/src/wrapper/overlay.js +158 -0
- package/src/wrapper/pty.js +43 -0
- package/src/wrapper/run.js +130 -0
|
@@ -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
|
+
}
|