agent-from-zero 0.1.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/config.js CHANGED
@@ -1,8 +1,9 @@
1
- import fs from "node:fs"; // for the minimal .env loader
1
+ import fs from "node:fs"; // reading .env and settings files
2
+ import os from "node:os"; // home directory for the global settings path
3
+ import path from "node:path"; // path joining
2
4
  import chalk from "chalk"; // colored error output
3
5
  // Minimal .env loader: lets `npx mini-agent` work in any directory that has a
4
6
  // .env file — no dotenv dependency, no --env-file flag to remember.
5
- // Lives here (not in agent.ts) so the eval harness gets it for free too.
6
7
  try {
7
8
  for (const line of fs.readFileSync(".env", "utf8").split("\n")) {
8
9
  const m = line.match(/^([A-Za-z0-9_]+)=(.*)$/); // KEY=value, nothing fancier
@@ -13,21 +14,96 @@ try {
13
14
  catch {
14
15
  /* no .env here — the variables may come from the shell instead */
15
16
  }
16
- // Provider-agnostic configuration. mini-agent talks the OpenAI wire protocol,
17
- // so ANY compatible endpoint works — DeepSeek, OpenAI, Kimi, Qwen, GLM,
18
- // OpenRouter, a local Ollama... three variables and you're on a new model.
19
- //
20
- // Resolution order, most specific wins:
21
- // MINI_AGENT_* (generic) > DEEPSEEK_* (back-compat) > DeepSeek defaults
17
+ // ---- Settings files ------------------------------------------------------------
18
+ // Two layers, both optional:
19
+ // global: ~/.config/mini-agent/settings.json (your defaults, every project)
20
+ // project: ./.mini-agent/settings.json (this repo's rules, commit it if you like)
21
+ // Scalars (model, baseURL, contextWindow): project wins over global.
22
+ // Permission lists: BOTH layers apply — a deny from either layer is a deny.
23
+ // API keys never live in settings files — secrets stay in the environment.
24
+ export const GLOBAL_SETTINGS_PATH = path.join(os.homedir(), ".config", "mini-agent", "settings.json");
25
+ export const PROJECT_SETTINGS_PATH = path.resolve(".mini-agent", "settings.json");
26
+ // Read one settings file. A broken settings file is a HARD error, not a warning:
27
+ // silently ignoring it would silently drop the user's deny rules — fail closed.
28
+ function readSettings(p) {
29
+ if (!fs.existsSync(p))
30
+ return {}; // absent is fine
31
+ try {
32
+ return JSON.parse(fs.readFileSync(p, "utf8")); // parse or throw
33
+ }
34
+ catch (err) {
35
+ console.error(chalk.red(`Could not parse settings file: ${p}`));
36
+ console.error(chalk.dim(` ${err.message}`));
37
+ console.error("Fix or delete the file, then run again. (Ignoring it could drop your deny rules.)");
38
+ process.exit(1); // fail closed — never run with half-loaded security config
39
+ }
40
+ }
41
+ const globalSettings = readSettings(GLOBAL_SETTINGS_PATH); // the user's defaults
42
+ const projectSettings = readSettings(PROJECT_SETTINGS_PATH); // this project's rules
43
+ // Provider-agnostic configuration. Resolution order, most specific wins:
44
+ // env MINI_AGENT_* > project settings > global settings > legacy DEEPSEEK_* env > defaults
22
45
  export const CONFIG = {
23
- baseURL: process.env.MINI_AGENT_BASE_URL || // bring your own provider...
24
- process.env.DEEPSEEK_BASE_URL || // ...or the original DeepSeek override (kept for back-compat and tests)
25
- "https://api.deepseek.com", // the default provider
46
+ baseURL: process.env.MINI_AGENT_BASE_URL || // explicit env wins...
47
+ projectSettings.baseURL || // ...then the project file...
48
+ globalSettings.baseURL || // ...then the user's global file...
49
+ process.env.DEEPSEEK_BASE_URL || // ...then the legacy override (kept for back-compat and tests)
50
+ "https://api.deepseek.com", // ...and finally the default provider
26
51
  apiKey: process.env.MINI_AGENT_API_KEY || // generic key wins...
27
52
  process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
28
53
  "", // empty = missing; requireApiKey() turns that into a helpful error
29
- model: process.env.MINI_AGENT_MODEL || "deepseek-chat", // must support function calling
54
+ model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-chat", // must support function calling
55
+ // Sub-agents (the task tool) can run on a DIFFERENT model than the orchestrator:
56
+ // a cheap/fast one for grunt work (reading many files, broad search), or a
57
+ // strong one as an "advisor" to double-check. undefined → use the main model.
58
+ subAgentModel: process.env.MINI_AGENT_SUBAGENT_MODEL || projectSettings.subAgentModel || globalSettings.subAgentModel || undefined,
59
+ contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
60
+ projectSettings.contextWindow || // then files
61
+ globalSettings.contextWindow ||
62
+ 1_048_565, // DeepSeek's real window, learned from an actual API error
63
+ permissions: {
64
+ // Both layers apply: global rules + project rules. Mutable on purpose —
65
+ // the test suite injects rules here instead of writing temp files.
66
+ allow: [...(globalSettings.permissions?.allow ?? []), ...(projectSettings.permissions?.allow ?? [])],
67
+ deny: [...(globalSettings.permissions?.deny ?? []), ...(projectSettings.permissions?.deny ?? [])],
68
+ },
69
+ // Hooks for every event, merged across layers (global first, then project) by
70
+ // a generic union so adding a new event needs no edit here.
71
+ hooks: (() => {
72
+ const merged = {};
73
+ for (const src of [globalSettings.hooks, projectSettings.hooks]) {
74
+ for (const [event, defs] of Object.entries(src ?? {}))
75
+ merged[event] = [...(merged[event] ?? []), ...(defs ?? [])];
76
+ }
77
+ return merged;
78
+ })(),
79
+ // MCP servers: project entries override global ones with the same name.
80
+ mcpServers: { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}) },
81
+ // LLM permission judge: off unless a settings file turns it on. Project wins.
82
+ judge: {
83
+ enabled: projectSettings.judge?.enabled ?? globalSettings.judge?.enabled ?? false,
84
+ model: projectSettings.judge?.model || globalSettings.judge?.model || undefined, // undefined → use the main model
85
+ },
86
+ // Token prices for the /cost estimate (defaults applied in cost.ts). Project
87
+ // overrides global; either may set just the fields it cares about.
88
+ pricing: { ...(globalSettings.pricing ?? {}), ...(projectSettings.pricing ?? {}) },
89
+ // Long-term memory: auto-extract durable facts after each turn. Off by default
90
+ // — it costs an extra (cheap) model call per turn. Project overrides global.
91
+ memory: {
92
+ autoExtract: projectSettings.memory?.autoExtract ?? globalSettings.memory?.autoExtract ?? false,
93
+ },
30
94
  };
95
+ // Persist a single key to the global settings file, preserving all other keys.
96
+ // Used by /model to remember the user's last choice across sessions.
97
+ export function saveGlobalSetting(key, value) {
98
+ const dir = path.dirname(GLOBAL_SETTINGS_PATH);
99
+ if (!fs.existsSync(dir))
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ const current = fs.existsSync(GLOBAL_SETTINGS_PATH)
102
+ ? JSON.parse(fs.readFileSync(GLOBAL_SETTINGS_PATH, "utf8"))
103
+ : {};
104
+ current[key] = value;
105
+ fs.writeFileSync(GLOBAL_SETTINGS_PATH, JSON.stringify(current, null, 2) + "\n");
106
+ }
31
107
  // No key, no point: fail with instructions instead of a stack trace later.
32
108
  export function requireApiKey() {
33
109
  if (CONFIG.apiKey)
package/dist/context.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import fs from "node:fs"; // re-reading files from disk during recovery
2
2
  import chalk from "chalk"; // status lines
3
+ import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
3
4
  import { recentFiles, forgetFilesExcept } from "./tools.js"; // session file state (what was read/edited, and when)
4
5
  // ---- The context window ------------------------------------------------------
5
- // 1,048,565 came from a real API error message, not from documentation.
6
+ // The default (1,048,565) came from a real API error message, not from docs.
6
7
  // Trust what the API tells you over what you remember reading.
7
- export const CONTEXT_WINDOW = Number(process.env.MINI_AGENT_CONTEXT_WINDOW || 1_048_565); // overridable for other models
8
+ export const CONTEXT_WINDOW = CONFIG.contextWindow; // resolved in config.ts: env > project file > global file > default
8
9
  // Compact well before the hard limit: estimation is approximate and the summary
9
10
  // itself needs room. 80% is deliberately conservative.
10
- export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); // overridable so tests can trigger compaction cheaply
11
+ export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); // env override kept so tests can trigger compaction cheaply
11
12
  // How many times one query may compact / fail to compact before we give up.
12
13
  export const MAX_COMPACTIONS_PER_QUERY = 4; // a query that needs more is too big — stop, don't loop
13
14
  export const MAX_COMPACT_FAILURES = 3; // the compaction circuit breaker
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 ADDED
@@ -0,0 +1,92 @@
1
+ import { spawn } from "node:child_process"; // hooks are external programs
2
+ import chalk from "chalk"; // status lines
3
+ import { CONFIG } from "./config.js"; // user-configured hooks
4
+ import { emit } from "./telemetry.js"; // count hook activity
5
+ // Hooks let users extend the agent without touching its source: an external
6
+ // program runs at a lifecycle moment and can observe — or block — what happens.
7
+ // The contract is deliberately language-agnostic (a shell command, JSON on
8
+ // stdin, the exit code as the verdict) so a hook can be a bash one-liner, a
9
+ // Python script, anything. This is the "keep the core small, bolt business
10
+ // logic on via hooks" architecture.
11
+ //
12
+ // Communication protocol (per event):
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).
20
+ // other → the hook itself errored; we log and treat it as allow (a broken
21
+ // hook must not wedge the agent — but it is loud, not silent).
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 };
26
+ // Run one hook process, feeding it the event payload on stdin.
27
+ function runOne(hook, payload, timeoutMs) {
28
+ return new Promise((resolve) => {
29
+ const child = spawn(hook.command, { shell: true, stdio: ["pipe", "pipe", "pipe"] }); // shell so users can write one-liners
30
+ let stdout = "", stderr = "", settled = false; // accumulate; resolve once
31
+ const done = (code) => {
32
+ if (settled)
33
+ return;
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
37
+ };
38
+ const timer = setTimeout(() => {
39
+ child.kill("SIGKILL"); // a hung hook is killed, never hangs the agent
40
+ done(124); // 124 = timed out (treated as "other" → allow, logged)
41
+ }, hook.timeoutMs ?? timeoutMs);
42
+ child.stdout.on("data", (d) => (stdout += d));
43
+ child.stderr.on("data", (d) => (stderr += d));
44
+ child.on("error", () => done(127)); // could not start (e.g. command not found)
45
+ child.on("close", (code) => done(code ?? 0));
46
+ child.stdin.end(JSON.stringify(payload)); // the event, as JSON, on stdin
47
+ });
48
+ }
49
+ // Run every hook registered for an event, in order, and combine their verdicts.
50
+ // `match` (when present) filters by tool name so a hook can target one tool.
51
+ export async function runHooks(event, payload) {
52
+ const hooks = (CONFIG.hooks[event] ?? []).filter((h) => !h.match || h.match === payload.tool); // applicable hooks only
53
+ if (!hooks.length)
54
+ return { block: false, feedback: "", stdout: "" }; // the common case: nothing registered
55
+ const feedback = []; // stderr from blocking hooks
56
+ const stdout = []; // stdout from passing hooks
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
60
+ for (const hook of hooks) {
61
+ const { code, stdout: out, stderr: err } = await runOne(hook, { event, ...payload }, timeout); // run it
62
+ emit("agent_hook_run", { event, code }); // observability for hooks themselves
63
+ if (code === 0) {
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
+ }
81
+ }
82
+ else if (code === 2) {
83
+ block = true; // a blocking verdict
84
+ feedback.push(err || `(${event} hook blocked with no message)`); // the reason, for the model
85
+ }
86
+ else {
87
+ // Any other code: the hook is broken. Be loud, but do not wedge the agent.
88
+ console.error(chalk.yellow(` [hook] ${event} command exited ${code} (ignored): ${(err || out).slice(0, 120)}`));
89
+ }
90
+ }
91
+ return { block, feedback: feedback.join("\n"), stdout: stdout.join("\n"), rewrite };
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
+ }