agent-from-zero 0.5.0 → 0.27.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/cost.js ADDED
@@ -0,0 +1,93 @@
1
+ // Token & cost accounting. The most-emphasized lesson of the Harness book is
2
+ // token economics: "cache breaks are silent", "90% of the bill is re-sending
3
+ // the same prompt". You cannot manage what you cannot see — so every model
4
+ // call's usage is metered, and /cost shows tokens, the cache hit rate, and an
5
+ // estimated spend. Nothing here leaves the machine; it is just arithmetic over
6
+ // the `usage` the API already returns.
7
+ export const DEFAULT_PRICING = {
8
+ inputPerM: 0.27, // $ / 1M — check your provider; override in settings.pricing
9
+ cachedInputPerM: 0.07, // cache hits are ~4x cheaper — this is why hit rate matters
10
+ outputPerM: 1.1, // output is the pricey one — don't let the model ramble
11
+ };
12
+ // Normalize a provider `usage` object into our four fields. Handles DeepSeek
13
+ // (prompt_cache_hit_tokens / prompt_cache_miss_tokens) and OpenAI
14
+ // (prompt_tokens_details.cached_tokens) shapes; falls back gracefully.
15
+ export function normalizeUsage(usage) {
16
+ if (!usage)
17
+ return { inputUncached: 0, inputCached: 0, output: 0 };
18
+ const prompt = Number(usage.prompt_tokens ?? 0); // total input tokens
19
+ const output = Number(usage.completion_tokens ?? 0); // total output tokens
20
+ // DeepSeek reports the split directly:
21
+ const dsHit = Number(usage.prompt_cache_hit_tokens ?? NaN);
22
+ const dsMiss = Number(usage.prompt_cache_miss_tokens ?? NaN);
23
+ if (!Number.isNaN(dsHit) && !Number.isNaN(dsMiss)) {
24
+ return { inputUncached: dsMiss, inputCached: dsHit, output };
25
+ }
26
+ // OpenAI reports only the cached count, nested:
27
+ const details = usage.prompt_tokens_details;
28
+ const cached = Number(details?.cached_tokens ?? 0);
29
+ return { inputUncached: Math.max(0, prompt - cached), inputCached: cached, output };
30
+ }
31
+ // Estimated dollar cost of some usage under some pricing. Pure — easy to test.
32
+ export function costOf(u, p) {
33
+ return (u.inputUncached * p.inputPerM + u.inputCached * p.cachedInputPerM + u.output * p.outputPerM) / 1_000_000;
34
+ }
35
+ // Cache hit rate over input tokens, 0..1. The single most useful number for
36
+ // spotting a silent cache break: if it drops, you are paying full price for a
37
+ // prefix that should have been free.
38
+ export function cacheHitRate(u) {
39
+ const input = u.inputUncached + u.inputCached;
40
+ return input === 0 ? 0 : u.inputCached / input;
41
+ }
42
+ // The process-wide meter. The loop records into it from every stream; the CLI
43
+ // builds it once (with the configured prices) and /cost reads it. A module
44
+ // singleton because metering is ambient — threading it through every call would
45
+ // add noise for a cross-cutting concern. Tests construct their own CostMeter.
46
+ let sessionMeter = null;
47
+ export function initCostMeter(pricing) {
48
+ sessionMeter = new CostMeter(pricing);
49
+ return sessionMeter;
50
+ }
51
+ export function recordUsage(usage) {
52
+ sessionMeter?.record(usage); // no-op until the CLI initializes it (tests stay silent)
53
+ }
54
+ // Accumulates usage across a session and formats /cost.
55
+ export class CostMeter {
56
+ pricing;
57
+ total = { inputUncached: 0, inputCached: 0, output: 0 };
58
+ calls = 0; // how many model calls were metered
59
+ constructor(pricing) {
60
+ this.pricing = pricing;
61
+ }
62
+ // Fold one response's usage into the running total.
63
+ record(usage) {
64
+ const u = normalizeUsage(usage);
65
+ if (u.inputUncached + u.inputCached + u.output === 0)
66
+ return; // a chunk with no usage — ignore
67
+ this.total.inputUncached += u.inputUncached;
68
+ this.total.inputCached += u.inputCached;
69
+ this.total.output += u.output;
70
+ this.calls++;
71
+ }
72
+ // Numbers for the status line: estimated spend so far and total tokens.
73
+ cost() {
74
+ return costOf(this.total, this.pricing);
75
+ }
76
+ inputTokens() {
77
+ return this.total.inputUncached + this.total.inputCached;
78
+ }
79
+ outputTokens() {
80
+ return this.total.output;
81
+ }
82
+ // The /cost report. Estimated, local-only.
83
+ report() {
84
+ const t = this.total;
85
+ const input = t.inputUncached + t.inputCached;
86
+ return [
87
+ `tokens this session (${this.calls} model calls):`,
88
+ ` input: ${input} (${t.inputCached} cached, ${(cacheHitRate(t) * 100).toFixed(0)}% hit rate)`,
89
+ ` output: ${t.output}`,
90
+ ` estimated cost: $${costOf(t, this.pricing).toFixed(4)} (rough — prices set in settings.pricing)`,
91
+ ].join("\n");
92
+ }
93
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs"; // read the target file to diff against
2
+ import path from "node:path"; // resolve the file path
3
+ import chalk from "chalk"; // color the +/- lines (auto-disabled when output isn't a TTY)
4
+ // Longest-common-subsequence over lines → an ordered list of ops. Classic DP:
5
+ // lcs[i][j] = length of the LCS of a[i:] and b[j:]. Walking it forwards turns the
6
+ // table into the smallest set of removals + additions that turns a into b.
7
+ export function diffLines(oldText, newText) {
8
+ const a = oldText.length ? oldText.split("\n") : []; // empty string = no lines (not [""])
9
+ const b = newText.length ? newText.split("\n") : [];
10
+ const n = a.length;
11
+ const m = b.length;
12
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); // (n+1)×(m+1), last row/col are zeros
13
+ for (let i = n - 1; i >= 0; i--) {
14
+ for (let j = m - 1; j >= 0; j--) {
15
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); // match extends the subsequence; else take the better side
16
+ }
17
+ }
18
+ const ops = [];
19
+ let i = 0;
20
+ let j = 0;
21
+ while (i < n && j < m) {
22
+ if (a[i] === b[j])
23
+ ops.push({ kind: " ", line: a[i++] }), j++; // common line — keep it as context
24
+ else if (lcs[i + 1][j] >= lcs[i][j + 1])
25
+ ops.push({ kind: "-", line: a[i++] }); // dropping a[i] keeps more of the LCS → it was removed
26
+ else
27
+ ops.push({ kind: "+", line: b[j++] }); // otherwise b[j] is an addition
28
+ }
29
+ while (i < n)
30
+ ops.push({ kind: "-", line: a[i++] }); // leftover old lines were all removed
31
+ while (j < m)
32
+ ops.push({ kind: "+", line: b[j++] }); // leftover new lines were all added
33
+ return ops;
34
+ }
35
+ const CONTEXT = 3; // unchanged lines to keep around each change, for readability
36
+ const MAX_LINES = 60; // hard cap on rendered lines — a huge diff must not flood the prompt
37
+ // Paint one line by its kind. chalk no-ops when colors are off, so the plain
38
+ // text is always present (and assertable in tests).
39
+ function paint(kind, text) {
40
+ if (kind === "+")
41
+ return chalk.green(text);
42
+ if (kind === "-")
43
+ return chalk.red(text);
44
+ return chalk.dim(text);
45
+ }
46
+ // Render ops as a compact unified diff: a "+A -B" summary header, the changed
47
+ // lines with a few lines of context, and long runs of untouched lines collapsed
48
+ // to a "⋮ (N unchanged lines)" marker so the reader sees the change, not the file.
49
+ export function renderDiff(oldText, newText, color = true) {
50
+ const ops = diffLines(oldText, newText);
51
+ const added = ops.filter((o) => o.kind === "+").length;
52
+ const removed = ops.filter((o) => o.kind === "-").length;
53
+ // Keep any unchanged line within CONTEXT of a change; drop the rest.
54
+ const keep = new Array(ops.length).fill(false);
55
+ ops.forEach((op, idx) => {
56
+ if (op.kind === " ")
57
+ return;
58
+ for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(ops.length - 1, idx + CONTEXT); k++)
59
+ keep[k] = true;
60
+ });
61
+ const out = [];
62
+ let i = 0;
63
+ while (i < ops.length) {
64
+ if (keep[i]) {
65
+ out.push(color ? paint(ops[i].kind, `${ops[i].kind} ${ops[i].line}`) : `${ops[i].kind} ${ops[i].line}`);
66
+ i++;
67
+ }
68
+ else {
69
+ let skipped = 0;
70
+ while (i < ops.length && !keep[i])
71
+ skipped++, i++; // collapse the whole untouched run at once
72
+ const note = ` ⋮ (${skipped} unchanged line${skipped === 1 ? "" : "s"})`;
73
+ out.push(color ? chalk.dim(note) : note);
74
+ }
75
+ }
76
+ const body = out.length > MAX_LINES ? [...out.slice(0, MAX_LINES), ` … (${out.length - MAX_LINES} more diff lines)`] : out;
77
+ const header = ` +${added} -${removed}`;
78
+ return [color ? chalk.dim(header) : header, ...body].join("\n");
79
+ }
80
+ const MAX_PREVIEW_BYTES = 200_000; // don't read+diff an enormous file just to preview it
81
+ // Read a file for diffing, or null if it shouldn't be diffed (missing, too big,
82
+ // or binary). A preview must never break a tool call, so every failure is null.
83
+ function safeRead(p) {
84
+ try {
85
+ if (fs.statSync(p).size > MAX_PREVIEW_BYTES)
86
+ return null; // too big to preview cheaply
87
+ const text = fs.readFileSync(p, "utf8");
88
+ return text.includes("\0") ? null : text; // a NUL byte means binary — don't diff it
89
+ }
90
+ catch {
91
+ return null; // unreadable / missing
92
+ }
93
+ }
94
+ // Build the diff preview shown before a write/edit runs. Returns null when there
95
+ // is nothing useful to show (not a write tool, unparseable args, binary file).
96
+ // NEVER throws.
97
+ export function previewChange(toolName, argsJson) {
98
+ let args;
99
+ try {
100
+ args = JSON.parse(argsJson);
101
+ }
102
+ catch {
103
+ return null; // dispatch will report the JSON error; nothing to preview
104
+ }
105
+ try {
106
+ if (toolName === "write_file") {
107
+ const p = path.resolve(args.path ?? "");
108
+ const exists = fs.existsSync(p);
109
+ const oldText = exists ? safeRead(p) : ""; // new file → empty "before"
110
+ if (oldText === null)
111
+ return null; // existing but binary/oversized
112
+ return ` ${exists ? "overwrite" : "create"} ${args.path}\n${renderDiff(oldText, String(args.content ?? ""))}`;
113
+ }
114
+ if (toolName === "edit_file") {
115
+ const p = path.resolve(args.path ?? "");
116
+ if (!fs.existsSync(p))
117
+ return null; // edit of a missing file → the tool will error
118
+ const oldText = safeRead(p);
119
+ if (oldText === null)
120
+ return null;
121
+ const occurrences = args.old_string ? oldText.split(args.old_string).length - 1 : 0;
122
+ // The tool only applies a unique match. If it's unique, diff the real
123
+ // before/after; otherwise just show the intended swap (the tool will
124
+ // reject a 0/ambiguous match anyway, but the human still sees the intent).
125
+ const newText = occurrences === 1 ? oldText.split(args.old_string).join(args.new_string ?? "") : null;
126
+ return newText !== null
127
+ ? ` edit ${args.path}\n${renderDiff(oldText, newText)}`
128
+ : ` edit ${args.path}\n${renderDiff(String(args.old_string ?? ""), String(args.new_string ?? ""))}`;
129
+ }
130
+ }
131
+ catch {
132
+ return null; // anything unexpected → no preview, never a crash
133
+ }
134
+ return null; // not a file-changing tool
135
+ }
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
+ }