agent-from-zero 0.5.0 → 0.28.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/README.md +44 -18
- package/dist/agent.js +446 -23
- package/dist/background.js +160 -0
- package/dist/board.js +179 -0
- package/dist/config.js +40 -6
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/editor.js +307 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +34 -9
- package/dist/judge.js +77 -0
- package/dist/loop.js +862 -22
- package/dist/markdown.js +152 -0
- package/dist/mcp.js +237 -0
- package/dist/memory.js +186 -0
- package/dist/mentions.js +62 -0
- package/dist/menu.js +154 -0
- package/dist/permissions.js +124 -4
- package/dist/prompt.js +32 -11
- package/dist/session.js +60 -18
- package/dist/skills.js +128 -0
- package/dist/team.js +214 -0
- package/dist/todos.js +87 -0
- package/dist/tools.js +133 -5
- package/dist/tui.js +67 -0
- package/dist/ui.js +138 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +7 -3
package/dist/editor.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import readline from "node:readline"; // raw-mode keypress decoding + the interface we pause
|
|
2
|
+
// ---- pure editing logic -----------------------------------------------------
|
|
3
|
+
// Map a keypress onto a new state + an action. Emacs-style bindings, the ones a
|
|
4
|
+
// terminal user expects. Everything printable (letters, digits, punctuation,
|
|
5
|
+
// pasted text, CJK) falls through to insertion at the cursor.
|
|
6
|
+
export function reduceEditor(s, str, key) {
|
|
7
|
+
const { buffer, cursor } = s;
|
|
8
|
+
const name = key?.name;
|
|
9
|
+
if (key?.ctrl) {
|
|
10
|
+
switch (name) {
|
|
11
|
+
case "c":
|
|
12
|
+
return { state: s, action: "cancel" }; // Ctrl+C → caller decides (at the prompt: quit)
|
|
13
|
+
case "d":
|
|
14
|
+
return buffer.length === 0 ? { state: s, action: "eof" } : forwardDelete(s); // Ctrl+D empty → EOF, else delete
|
|
15
|
+
case "a":
|
|
16
|
+
return { state: { buffer, cursor: 0 }, action: "edit" }; // start of line
|
|
17
|
+
case "e":
|
|
18
|
+
return { state: { buffer, cursor: buffer.length }, action: "edit" }; // end of line
|
|
19
|
+
case "u":
|
|
20
|
+
return { state: { buffer: buffer.slice(cursor), cursor: 0 }, action: "edit" }; // kill to start
|
|
21
|
+
case "k":
|
|
22
|
+
return { state: { buffer: buffer.slice(0, cursor), cursor }, action: "edit" }; // kill to end
|
|
23
|
+
case "w":
|
|
24
|
+
return killWordBack(s); // kill the word before the cursor
|
|
25
|
+
case "b":
|
|
26
|
+
return { state: { buffer, cursor: Math.max(0, cursor - 1) }, action: "edit" }; // back one char
|
|
27
|
+
case "f":
|
|
28
|
+
return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" }; // forward one char
|
|
29
|
+
default:
|
|
30
|
+
return { state: s, action: "none" };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
switch (name) {
|
|
34
|
+
case "return":
|
|
35
|
+
case "enter":
|
|
36
|
+
return { state: s, action: "submit" };
|
|
37
|
+
case "backspace":
|
|
38
|
+
return cursor > 0
|
|
39
|
+
? { state: { buffer: buffer.slice(0, cursor - 1) + buffer.slice(cursor), cursor: cursor - 1 }, action: "edit" }
|
|
40
|
+
: { state: s, action: "none" };
|
|
41
|
+
case "delete":
|
|
42
|
+
return forwardDelete(s);
|
|
43
|
+
case "left":
|
|
44
|
+
return { state: { buffer, cursor: Math.max(0, cursor - 1) }, action: "edit" };
|
|
45
|
+
case "right":
|
|
46
|
+
return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" };
|
|
47
|
+
case "home":
|
|
48
|
+
return { state: { buffer, cursor: 0 }, action: "edit" };
|
|
49
|
+
case "end":
|
|
50
|
+
return { state: { buffer, cursor: buffer.length }, action: "edit" };
|
|
51
|
+
case "up":
|
|
52
|
+
return { state: s, action: "histPrev" };
|
|
53
|
+
case "down":
|
|
54
|
+
return { state: s, action: "histNext" };
|
|
55
|
+
case "escape":
|
|
56
|
+
return { state: { buffer: "", cursor: 0 }, action: "edit" }; // Esc clears the line
|
|
57
|
+
case "tab":
|
|
58
|
+
return { state: s, action: "tab" };
|
|
59
|
+
default:
|
|
60
|
+
// Printable insertion. A paste arrives as a multi-char string; insert it whole.
|
|
61
|
+
if (str && !key?.meta && isPrintable(str)) {
|
|
62
|
+
return { state: { buffer: buffer.slice(0, cursor) + str + buffer.slice(cursor), cursor: cursor + str.length }, action: "edit" };
|
|
63
|
+
}
|
|
64
|
+
return { state: s, action: "none" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function forwardDelete(s) {
|
|
68
|
+
const { buffer, cursor } = s;
|
|
69
|
+
return cursor < buffer.length ? { state: { buffer: buffer.slice(0, cursor) + buffer.slice(cursor + 1), cursor }, action: "edit" } : { state: s, action: "none" };
|
|
70
|
+
}
|
|
71
|
+
function killWordBack(s) {
|
|
72
|
+
const { buffer, cursor } = s;
|
|
73
|
+
let i = cursor;
|
|
74
|
+
while (i > 0 && /\s/.test(buffer[i - 1]))
|
|
75
|
+
i--; // skip trailing spaces
|
|
76
|
+
while (i > 0 && !/\s/.test(buffer[i - 1]))
|
|
77
|
+
i--; // then the word
|
|
78
|
+
return { state: { buffer: buffer.slice(0, i) + buffer.slice(cursor), cursor: i }, action: "edit" };
|
|
79
|
+
}
|
|
80
|
+
function isPrintable(str) {
|
|
81
|
+
return [...str].every((c) => c >= " " && c !== "\x7f"); // no control chars (incl. lone newlines/DEL)
|
|
82
|
+
}
|
|
83
|
+
// ---- width + wrapping math --------------------------------------------------
|
|
84
|
+
const ANSI = /\x1b\[[0-9;]*m/g; // SGR color codes — they take zero display columns
|
|
85
|
+
// Display width of a string, ANSI-aware and CJK-aware (full-width glyphs are 2
|
|
86
|
+
// columns; String.length would miscount them and break wrapping/cursor math).
|
|
87
|
+
export function displayWidth(s) {
|
|
88
|
+
let w = 0;
|
|
89
|
+
for (const ch of s.replace(ANSI, ""))
|
|
90
|
+
w += charWidth(ch.codePointAt(0));
|
|
91
|
+
return w;
|
|
92
|
+
}
|
|
93
|
+
function charWidth(cp) {
|
|
94
|
+
if (cp >= 0x1100 &&
|
|
95
|
+
(cp <= 0x115f || // Hangul Jamo
|
|
96
|
+
cp === 0x2329 ||
|
|
97
|
+
cp === 0x232a ||
|
|
98
|
+
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK … Yi
|
|
99
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
100
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
|
|
101
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
|
|
102
|
+
(cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
|
|
103
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
104
|
+
(cp >= 0x1f300 && cp <= 0x1faff) || // emoji
|
|
105
|
+
(cp >= 0x20000 && cp <= 0x3fffd)) // CJK extension B+
|
|
106
|
+
)
|
|
107
|
+
return 2;
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
// Clip a (possibly ANSI-styled) string to a display width: keep escape codes
|
|
111
|
+
// (zero width) intact, never split a wide glyph. This stops a long status footer
|
|
112
|
+
// from wrapping — a wrapped footer row occupies TWO physical rows, which would
|
|
113
|
+
// desync the editor's row math and drop the caret a line too low (and then every
|
|
114
|
+
// repaint clears from the wrong row, stacking a fresh prompt each keystroke).
|
|
115
|
+
export function truncateToWidth(s, width) {
|
|
116
|
+
let w = 0;
|
|
117
|
+
let out = "";
|
|
118
|
+
let i = 0;
|
|
119
|
+
while (i < s.length) {
|
|
120
|
+
if (s[i] === "\x1b") {
|
|
121
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i)); // an SGR colour code — copy it, it has no width
|
|
122
|
+
if (m) {
|
|
123
|
+
out += m[0];
|
|
124
|
+
i += m[0].length;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const cp = s.codePointAt(i);
|
|
129
|
+
const ch = String.fromCodePoint(cp);
|
|
130
|
+
const cw = charWidth(cp);
|
|
131
|
+
if (w + cw > width)
|
|
132
|
+
return out + "\x1b[0m"; // would overflow → stop, closing any open colour
|
|
133
|
+
out += ch;
|
|
134
|
+
w += cw;
|
|
135
|
+
i += ch.length;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
// Wrap the input across the terminal width and find where the cursor lands.
|
|
140
|
+
// Row 0 begins after the prompt (promptW columns); continuation rows start at 0.
|
|
141
|
+
// Returns the buffer text per row (the driver prepends the prompt to row 0) plus
|
|
142
|
+
// the cursor's (row, col) — so a wrapped line keeps the caret exactly right.
|
|
143
|
+
export function layout(buffer, cursor, promptW, cols) {
|
|
144
|
+
const width = Math.max(2, cols);
|
|
145
|
+
const rows = [""];
|
|
146
|
+
let row = 0;
|
|
147
|
+
let col = promptW;
|
|
148
|
+
let cursorRow = 0;
|
|
149
|
+
let cursorCol = promptW;
|
|
150
|
+
let set = false;
|
|
151
|
+
let cu = 0; // code-unit offset, to match `cursor`
|
|
152
|
+
for (const ch of buffer) {
|
|
153
|
+
if (!set && cu === cursor) {
|
|
154
|
+
cursorRow = row;
|
|
155
|
+
cursorCol = col;
|
|
156
|
+
set = true;
|
|
157
|
+
}
|
|
158
|
+
const w = charWidth(ch.codePointAt(0));
|
|
159
|
+
if (col + w > width) {
|
|
160
|
+
row++;
|
|
161
|
+
col = 0;
|
|
162
|
+
rows[row] = "";
|
|
163
|
+
}
|
|
164
|
+
rows[row] += ch;
|
|
165
|
+
col += w;
|
|
166
|
+
cu += ch.length;
|
|
167
|
+
}
|
|
168
|
+
if (!set) {
|
|
169
|
+
cursorRow = row;
|
|
170
|
+
cursorCol = col; // cursor at the very end
|
|
171
|
+
}
|
|
172
|
+
if (cursorCol >= width) {
|
|
173
|
+
// caret sat exactly at the right edge — it belongs at the start of the next row
|
|
174
|
+
cursorRow++;
|
|
175
|
+
cursorCol = 0;
|
|
176
|
+
if (rows.length <= cursorRow)
|
|
177
|
+
rows.push("");
|
|
178
|
+
}
|
|
179
|
+
return { rows, cursorRow, cursorCol };
|
|
180
|
+
}
|
|
181
|
+
// Read one line with the full editor + pinned footer. Resolves with the line, or
|
|
182
|
+
// cancel (Ctrl+C) / eof (Ctrl+D on an empty line) for the caller to interpret.
|
|
183
|
+
export function editLine(rl, opts) {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
const input = opts.input ?? process.stdin;
|
|
186
|
+
const out = process.stdout;
|
|
187
|
+
if (!input.isTTY)
|
|
188
|
+
return resolve({ type: "eof" }); // caller guards, but be safe
|
|
189
|
+
const promptW = displayWidth(opts.prompt);
|
|
190
|
+
const footerRows = opts.footer ? opts.footer.split("\n") : [];
|
|
191
|
+
const history = opts.history ?? [];
|
|
192
|
+
let hi = history.length; // history cursor; == length means "the live draft"
|
|
193
|
+
let draft = "";
|
|
194
|
+
let state = { buffer: opts.initial ?? "", cursor: (opts.initial ?? "").length };
|
|
195
|
+
let done = false;
|
|
196
|
+
let first = true;
|
|
197
|
+
let prevCursorRow = 0; // where the caret sits within the block, to climb back next repaint
|
|
198
|
+
const wasRaw = input.isRaw === true; // restore THIS on exit (forcing false would close the live readline)
|
|
199
|
+
const cols = () => out.columns || 80;
|
|
200
|
+
// The session's readline keeps its OWN 'keypress' handler on stdin. Once
|
|
201
|
+
// readline has been activated (after the first resume), that handler fires on
|
|
202
|
+
// every key alongside ours and fights us for the cursor — the line stacks
|
|
203
|
+
// instead of redrawing in place. Detach the other keypress listeners while we
|
|
204
|
+
// own the input, and restore them exactly on exit (menu/confirm still need them).
|
|
205
|
+
let otherKeypress = [];
|
|
206
|
+
// Repaint the whole block in place: climb to its top, clear downward, reprint
|
|
207
|
+
// input rows + footer, then drop the caret onto its computed (row, col).
|
|
208
|
+
const draw = () => {
|
|
209
|
+
const { rows, cursorRow, cursorCol } = layout(state.buffer, state.cursor, promptW, cols());
|
|
210
|
+
if (!first) {
|
|
211
|
+
if (prevCursorRow > 0)
|
|
212
|
+
out.write(`\x1b[${prevCursorRow}A`); // up to the block's top row
|
|
213
|
+
out.write("\r");
|
|
214
|
+
}
|
|
215
|
+
out.write("\x1b[0J"); // clear from the top of the block to end of screen
|
|
216
|
+
const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
|
|
217
|
+
// Truncate each footer row so it can't wrap — otherwise its extra physical
|
|
218
|
+
// row throws off the cursor math below (caret lands low → prompts stack).
|
|
219
|
+
const fr = footerRows.map((f) => truncateToWidth(f, Math.max(1, cols() - 1)));
|
|
220
|
+
const all = [...inputRows, ...fr];
|
|
221
|
+
out.write(all.join("\r\n"));
|
|
222
|
+
const lastRow = all.length - 1; // every row is now exactly one physical line
|
|
223
|
+
if (lastRow > cursorRow)
|
|
224
|
+
out.write(`\x1b[${lastRow - cursorRow}A`); // up from the last footer row
|
|
225
|
+
out.write("\r");
|
|
226
|
+
if (cursorCol > 0)
|
|
227
|
+
out.write(`\x1b[${cursorCol}C`); // right to the caret column
|
|
228
|
+
prevCursorRow = cursorRow;
|
|
229
|
+
first = false;
|
|
230
|
+
};
|
|
231
|
+
const finish = (result) => {
|
|
232
|
+
if (done)
|
|
233
|
+
return;
|
|
234
|
+
done = true;
|
|
235
|
+
input.off("keypress", onKey);
|
|
236
|
+
for (const l of otherKeypress)
|
|
237
|
+
input.on("keypress", l); // give readline its keypress handler back
|
|
238
|
+
// Leave the typed command on screen, drop the footer, land on a fresh line.
|
|
239
|
+
const { rows } = layout(state.buffer, state.cursor, promptW, cols());
|
|
240
|
+
if (prevCursorRow > 0)
|
|
241
|
+
out.write(`\x1b[${prevCursorRow}A`);
|
|
242
|
+
out.write("\r\x1b[0J");
|
|
243
|
+
const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
|
|
244
|
+
out.write(inputRows.join("\r\n") + "\r\n");
|
|
245
|
+
try {
|
|
246
|
+
input.setRawMode(wasRaw); // hand stdin back exactly as we found it
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* not all streams support it */
|
|
250
|
+
}
|
|
251
|
+
rl.resume();
|
|
252
|
+
resolve(result);
|
|
253
|
+
};
|
|
254
|
+
const onKey = (str, key) => {
|
|
255
|
+
if (done)
|
|
256
|
+
return;
|
|
257
|
+
const next = reduceEditor(state, str, key || {});
|
|
258
|
+
state = next.state;
|
|
259
|
+
switch (next.action) {
|
|
260
|
+
case "submit":
|
|
261
|
+
return finish({ type: "line", value: state.buffer });
|
|
262
|
+
case "cancel":
|
|
263
|
+
return finish({ type: "cancel" });
|
|
264
|
+
case "eof":
|
|
265
|
+
return finish({ type: "eof" });
|
|
266
|
+
case "histPrev":
|
|
267
|
+
if (hi > 0) {
|
|
268
|
+
if (hi === history.length)
|
|
269
|
+
draft = state.buffer; // stash the live draft on the way up
|
|
270
|
+
hi--;
|
|
271
|
+
state = { buffer: history[hi], cursor: history[hi].length };
|
|
272
|
+
}
|
|
273
|
+
return draw();
|
|
274
|
+
case "histNext":
|
|
275
|
+
if (hi < history.length) {
|
|
276
|
+
hi++;
|
|
277
|
+
const b = hi === history.length ? draft : history[hi];
|
|
278
|
+
state = { buffer: b, cursor: b.length };
|
|
279
|
+
}
|
|
280
|
+
return draw();
|
|
281
|
+
case "tab":
|
|
282
|
+
opts.onTab?.(); // may print above us (e.g. expand a collapsed block) and scroll
|
|
283
|
+
first = true; // so the next paint lands fresh at the cursor, not a stale climb-up
|
|
284
|
+
return draw();
|
|
285
|
+
case "edit":
|
|
286
|
+
return draw();
|
|
287
|
+
case "none":
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
rl.pause(); // stop the session reader from turning our keystrokes into lines
|
|
293
|
+
if (input === process.stdin)
|
|
294
|
+
readline.emitKeypressEvents(input); // decode raw bytes into named keys
|
|
295
|
+
otherKeypress = input.listeners("keypress"); // readline's handler(s)…
|
|
296
|
+
for (const l of otherKeypress)
|
|
297
|
+
input.off("keypress", l); // …detached so only we drive the cursor
|
|
298
|
+
input.setRawMode(true);
|
|
299
|
+
input.resume(); // rl.pause() also paused the stream — resume it or no keys arrive
|
|
300
|
+
input.on("keypress", onKey);
|
|
301
|
+
draw();
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
finish({ type: "eof" }); // setup failed (non-TTY etc.) → behave like EOF
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
package/dist/form.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import chalk from "chalk"; // rendering only
|
|
2
|
+
// The rows are a flat list: every option of every question, then one submit row.
|
|
3
|
+
// rowCount = total options + 1. The cursor walks this flat list.
|
|
4
|
+
export function formRowCount(questions) {
|
|
5
|
+
return questions.reduce((n, q) => n + q.options.length, 0) + 1; // +1 for "Submit"
|
|
6
|
+
}
|
|
7
|
+
// Map a flat cursor to either a question/option, or the submit row.
|
|
8
|
+
export function rowAt(questions, cursor) {
|
|
9
|
+
let i = cursor;
|
|
10
|
+
for (let q = 0; q < questions.length; q++) {
|
|
11
|
+
if (i < questions[q].options.length)
|
|
12
|
+
return { q, opt: i };
|
|
13
|
+
i -= questions[q].options.length;
|
|
14
|
+
}
|
|
15
|
+
return { submit: true };
|
|
16
|
+
}
|
|
17
|
+
// The flat cursor position of a question's first option — used to jump the
|
|
18
|
+
// cursor to the first unanswered question when the user tries to submit early.
|
|
19
|
+
function cursorForQuestion(questions, q) {
|
|
20
|
+
let c = 0;
|
|
21
|
+
for (let i = 0; i < q; i++)
|
|
22
|
+
c += questions[i].options.length;
|
|
23
|
+
return c;
|
|
24
|
+
}
|
|
25
|
+
export function initFormState(questions) {
|
|
26
|
+
return { cursor: 0, selections: questions.map(() => -1) }; // nothing chosen yet
|
|
27
|
+
}
|
|
28
|
+
// The one transition function. Returns the next state, and `done: true` only
|
|
29
|
+
// when the user submits with every question answered.
|
|
30
|
+
export function reduceForm(questions, state, action) {
|
|
31
|
+
const rows = formRowCount(questions);
|
|
32
|
+
if (action === "up")
|
|
33
|
+
return { state: { ...state, cursor: Math.max(0, state.cursor - 1) } };
|
|
34
|
+
if (action === "down")
|
|
35
|
+
return { state: { ...state, cursor: Math.min(rows - 1, state.cursor + 1) } };
|
|
36
|
+
// select (Enter / Space)
|
|
37
|
+
const row = rowAt(questions, state.cursor);
|
|
38
|
+
if ("submit" in row) {
|
|
39
|
+
const firstUnanswered = state.selections.findIndex((s) => s < 0);
|
|
40
|
+
if (firstUnanswered < 0)
|
|
41
|
+
return { state, done: true }; // all answered → submit for real
|
|
42
|
+
return { state: { ...state, cursor: cursorForQuestion(questions, firstUnanswered) } }; // nudge to the gap
|
|
43
|
+
}
|
|
44
|
+
const selections = state.selections.slice();
|
|
45
|
+
selections[row.q] = row.opt; // set this question's answer (single-select)
|
|
46
|
+
return { state: { ...state, selections } };
|
|
47
|
+
}
|
|
48
|
+
// Turn a completed form into question→answer pairs for the model.
|
|
49
|
+
export function collectAnswers(questions, state) {
|
|
50
|
+
return questions.map((q, i) => ({ question: q.question, answer: q.options[state.selections[i]] ?? "(unanswered)" }));
|
|
51
|
+
}
|
|
52
|
+
const FORM_HINT = chalk.dim("↑↓ to move · Enter to choose / submit · Esc to cancel");
|
|
53
|
+
// Render the whole form: each question with radio-style options, the cursor on
|
|
54
|
+
// one row, then a submit row. Line count is constant for a given question set
|
|
55
|
+
// (selections don't add/remove lines), which is what lets the driver redraw in
|
|
56
|
+
// place by moving the cursor up a fixed number of lines.
|
|
57
|
+
export function renderForm(questions, state) {
|
|
58
|
+
const lines = ["The agent needs your input:", ""];
|
|
59
|
+
let flat = 0;
|
|
60
|
+
questions.forEach((q, qi) => {
|
|
61
|
+
lines.push(chalk.bold(`${qi + 1}. ${q.question}`));
|
|
62
|
+
q.options.forEach((opt, oi) => {
|
|
63
|
+
const onCursor = state.cursor === flat;
|
|
64
|
+
const chosen = state.selections[qi] === oi;
|
|
65
|
+
const text = `${chosen ? "●" : "○"} ${opt}`; // filled radio = chosen
|
|
66
|
+
lines.push(onCursor ? chalk.cyan.bold(`❯ ${text}`) : chosen ? chalk.cyan(` ${text}`) : chalk.dim(` ${text}`));
|
|
67
|
+
flat++;
|
|
68
|
+
});
|
|
69
|
+
lines.push("");
|
|
70
|
+
});
|
|
71
|
+
const onSubmit = state.cursor === flat; // the submit row is last
|
|
72
|
+
const allAnswered = state.selections.every((s) => s >= 0);
|
|
73
|
+
const submitText = allAnswered ? "▶ Submit answers" : "▶ Submit answers (answer every question first)";
|
|
74
|
+
lines.push(onSubmit ? chalk.green.bold(`❯ ${submitText}`) : chalk.dim(` ${submitText}`));
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push(FORM_HINT);
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
package/dist/hooks.js
CHANGED
|
@@ -10,13 +10,21 @@ import { emit } from "./telemetry.js"; // count hook activity
|
|
|
10
10
|
// logic on via hooks" architecture.
|
|
11
11
|
//
|
|
12
12
|
// Communication protocol (per event):
|
|
13
|
-
// exit 0 → allow. stdout is
|
|
14
|
-
//
|
|
13
|
+
// exit 0 → allow. stdout is captured (SessionStart/UserPromptSubmit inject it
|
|
14
|
+
// as context; a PreToolUse hook may emit {"toolInput":{…}} to REWRITE
|
|
15
|
+
// the tool's arguments — something the 3-state permission gate can't).
|
|
16
|
+
// exit 2 → block. stderr is fed back to the model. The MEANING of "block"
|
|
17
|
+
// differs by event and is the caller's to interpret (PreToolUse →
|
|
18
|
+
// don't run; Stop → keep going; UserPromptSubmit → drop the prompt;
|
|
19
|
+
// observational events just ignore it).
|
|
15
20
|
// other → the hook itself errored; we log and treat it as allow (a broken
|
|
16
21
|
// hook must not wedge the agent — but it is loud, not silent).
|
|
17
|
-
const DEFAULT_HOOK_TIMEOUT_MS = 10_000; //
|
|
22
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 10_000; // most hooks get 10s, then we kill it
|
|
23
|
+
// Differentiated timeouts: SessionEnd fires when the user is leaving (often via
|
|
24
|
+
// Ctrl+C) — it MUST return almost instantly, so it gets a tiny budget.
|
|
25
|
+
const EVENT_TIMEOUT_MS = { SessionEnd: 1_500 };
|
|
18
26
|
// Run one hook process, feeding it the event payload on stdin.
|
|
19
|
-
function runOne(hook, payload) {
|
|
27
|
+
function runOne(hook, payload, timeoutMs) {
|
|
20
28
|
return new Promise((resolve) => {
|
|
21
29
|
const child = spawn(hook.command, { shell: true, stdio: ["pipe", "pipe", "pipe"] }); // shell so users can write one-liners
|
|
22
30
|
let stdout = "", stderr = "", settled = false; // accumulate; resolve once
|
|
@@ -30,7 +38,7 @@ function runOne(hook, payload) {
|
|
|
30
38
|
const timer = setTimeout(() => {
|
|
31
39
|
child.kill("SIGKILL"); // a hung hook is killed, never hangs the agent
|
|
32
40
|
done(124); // 124 = timed out (treated as "other" → allow, logged)
|
|
33
|
-
}, hook.timeoutMs ??
|
|
41
|
+
}, hook.timeoutMs ?? timeoutMs);
|
|
34
42
|
child.stdout.on("data", (d) => (stdout += d));
|
|
35
43
|
child.stderr.on("data", (d) => (stderr += d));
|
|
36
44
|
child.on("error", () => done(127)); // could not start (e.g. command not found)
|
|
@@ -47,12 +55,29 @@ export async function runHooks(event, payload) {
|
|
|
47
55
|
const feedback = []; // stderr from blocking hooks
|
|
48
56
|
const stdout = []; // stdout from passing hooks
|
|
49
57
|
let block = false; // did any hook exit 2?
|
|
58
|
+
let rewrite; // a PreToolUse hook's replacement args
|
|
59
|
+
const timeout = EVENT_TIMEOUT_MS[event] ?? DEFAULT_HOOK_TIMEOUT_MS; // SessionEnd gets a tiny budget
|
|
50
60
|
for (const hook of hooks) {
|
|
51
|
-
const { code, stdout: out, stderr: err } = await runOne(hook, { event, ...payload }); // run it
|
|
61
|
+
const { code, stdout: out, stderr: err } = await runOne(hook, { event, ...payload }, timeout); // run it
|
|
52
62
|
emit("agent_hook_run", { event, code }); // observability for hooks themselves
|
|
53
63
|
if (code === 0) {
|
|
54
|
-
if (out)
|
|
55
|
-
stdout.push(out); // passing-hook output (used by SessionStart)
|
|
64
|
+
if (out) {
|
|
65
|
+
stdout.push(out); // passing-hook output (used by SessionStart / UserPromptSubmit)
|
|
66
|
+
// A PreToolUse hook can REWRITE the tool's arguments by printing
|
|
67
|
+
// {"toolInput": {...}} — the "side road" the permission gate can't take
|
|
68
|
+
// (it only allows/denies). Classic use: rewrite `git commit` to add a
|
|
69
|
+
// trailer. Last writer wins if several hooks rewrite.
|
|
70
|
+
if (event === "PreToolUse") {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(out);
|
|
73
|
+
if (parsed && parsed.toolInput && typeof parsed.toolInput === "object")
|
|
74
|
+
rewrite = JSON.stringify(parsed.toolInput);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* not control JSON — just plain stdout */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
56
81
|
}
|
|
57
82
|
else if (code === 2) {
|
|
58
83
|
block = true; // a blocking verdict
|
|
@@ -63,5 +88,5 @@ export async function runHooks(event, payload) {
|
|
|
63
88
|
console.error(chalk.yellow(` [hook] ${event} command exited ${code} (ignored): ${(err || out).slice(0, 120)}`));
|
|
64
89
|
}
|
|
65
90
|
}
|
|
66
|
-
return { block, feedback: feedback.join("\n"), stdout: stdout.join("\n") };
|
|
91
|
+
return { block, feedback: feedback.join("\n"), stdout: stdout.join("\n"), rewrite };
|
|
67
92
|
}
|
package/dist/judge.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { emit } from "./telemetry.js"; // observe the judge's decisions
|
|
2
|
+
// An OPTIONAL second layer over the rule-based permission gate. The rules
|
|
3
|
+
// (permissions.ts) own deny and the obvious allows; whatever they mark "ask"
|
|
4
|
+
// is the ambiguous middle — an unfamiliar-but-maybe-harmless command like
|
|
5
|
+
// `ffmpeg ...`. Asking the human about every one of those is approval fatigue.
|
|
6
|
+
//
|
|
7
|
+
// The judge looks ONLY at those "ask" verdicts and answers one question: is
|
|
8
|
+
// this clearly safe? If yes, we skip the prompt; if no (or it is unsure, or it
|
|
9
|
+
// errors), we keep asking. Two invariants make this safe to add:
|
|
10
|
+
// 1. The judge NEVER sees a deny — deny always wins, the rules decide it.
|
|
11
|
+
// 2. The judge can only DOWNGRADE ask→allow for the clearly safe. It can
|
|
12
|
+
// never create an allow the rules denied, and any doubt stays "ask".
|
|
13
|
+
// In other words: the judge reduces friction, it never reduces safety.
|
|
14
|
+
// The judge prompt is deliberately blunt and asks for a single rigid token, so
|
|
15
|
+
// parsing is unambiguous. "Err on the side of NO" is the load-bearing line.
|
|
16
|
+
const JUDGE_SYSTEM = `You are a security classifier for a coding agent. You are shown ONE shell command that the agent wants to run. Decide whether it is CLEARLY SAFE to run without asking a human.
|
|
17
|
+
|
|
18
|
+
CLEARLY SAFE = read-only or trivially reversible, no data loss, no network exfiltration, no privilege change, no writes outside the project, no credential access.
|
|
19
|
+
NOT clearly safe = anything that deletes, overwrites, installs, pushes, sends data out, changes permissions, or that you are unsure about.
|
|
20
|
+
|
|
21
|
+
Err strongly on the side of NO. When in doubt, answer no.
|
|
22
|
+
Respond with EXACTLY one line and nothing else: <safe>yes</safe> or <safe>no</safe>`;
|
|
23
|
+
const JUDGE_MAX_TOKENS = 16; // we only need one tiny tagged token — keep it cheap and fast
|
|
24
|
+
const MAX_CONSECUTIVE_JUDGE_ERRORS = 3; // after this many failures, disable the judge for the session
|
|
25
|
+
// Parse the judge's reply. PURE function (no I/O) so the safety logic is unit
|
|
26
|
+
// testable without the network. Fail closed: anything that is not an
|
|
27
|
+
// unambiguous "yes" means "ask".
|
|
28
|
+
export function interpretJudgeOutput(text) {
|
|
29
|
+
const m = text.toLowerCase().match(/<safe>\s*(yes|no)\s*<\/safe>/); // strict tag, nothing else trusted
|
|
30
|
+
if (!m)
|
|
31
|
+
return "ask"; // no clean verdict → ask (fail closed)
|
|
32
|
+
return m[1] === "yes" ? "allow" : "ask"; // only a clear yes downgrades
|
|
33
|
+
}
|
|
34
|
+
// Tracks the judge's health across a session so a flaky/misconfigured judge
|
|
35
|
+
// degrades gracefully to "always ask" instead of silently failing on every call.
|
|
36
|
+
export class Judge {
|
|
37
|
+
client;
|
|
38
|
+
model;
|
|
39
|
+
consecutiveErrors = 0; // resets on any successful classification
|
|
40
|
+
disabled = false; // tripped after too many consecutive errors
|
|
41
|
+
constructor(client, // same client as the loop
|
|
42
|
+
model) {
|
|
43
|
+
this.client = client;
|
|
44
|
+
this.model = model;
|
|
45
|
+
}
|
|
46
|
+
// Classify one "ask" command. Returns "allow" only when the judge is
|
|
47
|
+
// confident it is safe; "ask" in every other case (unsure, error, disabled).
|
|
48
|
+
async classify(command) {
|
|
49
|
+
if (this.disabled)
|
|
50
|
+
return "ask"; // circuit breaker tripped — back to asking
|
|
51
|
+
try {
|
|
52
|
+
const res = await this.client.chat.completions.create({
|
|
53
|
+
model: this.model,
|
|
54
|
+
max_tokens: JUDGE_MAX_TOKENS, // tiny — this must be cheap
|
|
55
|
+
temperature: 0, // deterministic classification
|
|
56
|
+
messages: [
|
|
57
|
+
{ role: "system", content: JUDGE_SYSTEM },
|
|
58
|
+
{ role: "user", content: `Command:\n${command}` }, // ONLY the command — no chat history to be injected through
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
this.consecutiveErrors = 0; // a clean call resets the breaker
|
|
62
|
+
const verdict = interpretJudgeOutput(res.choices[0]?.message?.content ?? "");
|
|
63
|
+
emit("agent_judge_verdict", { verdict }); // allow / ask, for tuning
|
|
64
|
+
return verdict;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A judge that errors must never accidentally allow something. Count the
|
|
68
|
+
// failure, and after a few in a row, stop trying (degrade to asking).
|
|
69
|
+
this.consecutiveErrors++;
|
|
70
|
+
if (this.consecutiveErrors >= MAX_CONSECUTIVE_JUDGE_ERRORS) {
|
|
71
|
+
this.disabled = true;
|
|
72
|
+
emit("agent_judge_disabled", {}); // we gave up on the judge this session
|
|
73
|
+
}
|
|
74
|
+
return "ask"; // fail closed
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|