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/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 shown to the model only for SessionStart.
14
- // exit 2 → block. stderr is fed back to the model as the reason.
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; // a hook gets 10s, then we kill it
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 ?? DEFAULT_HOOK_TIMEOUT_MS);
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
+ }