agent-from-zero 0.34.2 → 0.35.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/agent.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import { statusCommand } from "./status.js";
2
3
  import OpenAI from "openai"; // the API client
3
4
  import chalk from "chalk"; // terminal colors
4
5
  import readline from "node:readline/promises"; // promise-based terminal input
5
6
  import { createRequire } from "node:module"; // to read package.json for --version
6
7
  import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
7
8
  import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.js"; // the state machine
8
- import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
9
+ import { buildSystemMessage, readProjectInstructions } from "./prompt.js"; // the constitution + optional AGENT.md project memory
9
10
  import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
10
11
  import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
11
12
  import { newSessionId, saveSession, latestSession, listSessions, loadSession, setSessionTitle } from "./session.js"; // conversation persistence (project-local) + the /resume picker
@@ -14,6 +15,12 @@ import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only
14
15
  import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
15
16
  import { connectMcpServers, listMcpServers, mcpActionsFor, runMcpAction } from "./mcp.js"; // external tool servers (MCP) + /mcp
16
17
  import { Judge } from "./judge.js"; // optional LLM permission classifier
18
+ import { AutoMode } from "./auto.js";
19
+ import { effortMenu, setEffort, selectedEffort } from "./effort.js";
20
+ import { parseCli, applyCliOptions, readPrintPrompt, CliUsageError } from "./cli.js";
21
+ import { reservePrintOutput, formatPrintResult } from "./print.js";
22
+ import { FollowUpQueue } from "./follow-up.js";
23
+ import { reviewDebugCommand } from "./auto-debug.js";
17
24
  import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
18
25
  import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
19
26
  import { clearTodos, getTodos, renderTodos } from "./todos.js"; // the agent's plan: /todos to view, cleared with the conversation
@@ -36,30 +43,46 @@ import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from
36
43
  // package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
37
44
  const pkg = createRequire(import.meta.url)("../package.json");
38
45
  // ---- CLI arguments --------------------------------------------------------------
39
- // Tiny by design: a help text, a version, and a one-shot print mode. Anything
40
- // fancier belongs in settings files, not flags.
46
+ // Process-local overrides share the same model/effort logic as the REPL.
41
47
  const USAGE = `mini-agent ${pkg.version} — a Claude Code-style CLI agent (any OpenAI-compatible model)
42
48
 
43
49
  Usage:
44
50
  mini-agent interactive session (REPL)
45
51
  mini-agent -r | --resume continue the most recent session in this directory
46
52
  mini-agent -p "<task>" one-shot: run a single task, print the result, exit
53
+ mini-agent exec "<task>" alias for non-interactive print mode
54
+ cat file | mini-agent -p "<task>" append piped text to the prompt
55
+ --prompt "<task>" explicit prompt (instead of a positional prompt)
56
+ --model <id> override the model for this run
57
+ --effort <level> supported effort for this model, or default
58
+ --permission-mode <mode> default | auto | bypassPermissions (this run only)
59
+ --dangerously-skip-permissions alias for --permission-mode bypassPermissions
60
+ --output-format text|json final answer or a JSON result (print mode only)
61
+ mini-agent --auto review tool actions automatically (Jev or your current vendor)
47
62
  mini-agent -v | --version print the version
48
63
  mini-agent -h | --help this text
49
64
 
50
65
  Configuration (optional):
51
66
  ${GLOBAL_SETTINGS_PATH} your defaults (model, baseURL, permissions, hooks)
52
67
  ${PROJECT_SETTINGS_PATH} per-project rules — both layers apply, deny always wins
53
- MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL override everything.
68
+ MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL override settings.
69
+ CLI model/effort flags override this run only; they never change saved settings.
70
+
71
+ Print mode: options may precede or follow the prompt; use -- for literal flags.
72
+ stdout contains only the final result; diagnostics go to stderr.
73
+ Bypass skips approvals and AI review; hard denies, plan restrictions and hooks remain.
74
+ Exit codes: 0 success, 1 execution failure, 2 invalid arguments, 130 interrupted.
54
75
 
55
76
  In a session, type /help for the in-session commands.`;
56
77
  // What we tell the user for every way a query can end. No raw stack traces.
57
78
  const EXIT_NOTES = {
79
+ [TerminateReason.ReviewLimit]: "Repeated automatic denials — stopped. Tell the agent how to proceed; prohibited actions were not executed.",
58
80
  [TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
59
81
  [TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
60
82
  [TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
61
83
  [TerminateReason.ContextTooLong]: "The conversation no longer fits the model's context window, and compaction could not shrink it enough. Use /clear to start fresh.",
62
84
  [TerminateReason.CompactionFailed]: "Automatic compaction kept failing — stopping instead of looping. Use /clear to start fresh.",
85
+ [TerminateReason.ImageInputRejected]: "The provider rejected this image request. Check image limits and use /model to select a vision-capable model at your current vendor, then retry (images remain in the conversation).",
63
86
  [TerminateReason.FatalApiError]: "Unrecoverable API error — retrying would not help. Check your API key and request.",
64
87
  [TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
65
88
  };
@@ -71,9 +94,13 @@ const SESSION_HELP = `commands:
71
94
  /model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
72
95
  /stats event counts for this session (local telemetry — nothing leaves this machine)
73
96
  /memory show the durable facts the agent remembers about this project
97
+ /status session usage and vendor account balance/cost
74
98
  /cost tokens, cache hit rate and estimated spend this session (local)
75
99
  /mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
76
100
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
101
+ /effort [level] list/select the current model’s supported reasoning effort
102
+ /auto toggle auto mode — risky or uncertain actions still require approval
103
+ /auto debug [on|off|status] inspect permission reviewer requests and replies
77
104
  /todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
78
105
  /bg list background tasks this session (run_bash_background) and their status
79
106
  /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
@@ -88,39 +115,39 @@ const SESSION_HELP = `commands:
88
115
 
89
116
  keys (at the prompt):
90
117
  Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
91
- Ctrl+T reveal the folded tool-call trace for the last answer (read_file/search/… shown as a tally)
118
+ Ctrl+T reveal the folded tool-call trace for the last answer (recent arguments and results)
92
119
  Tab expand/collapse the most recent tool output`;
93
120
  // Injected when the user turns plan mode on, so the model knows the rules of the
94
121
  // mode it now lives in (the permission gate enforces them regardless).
95
122
  const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
123
+ let printOutput;
124
+ let printCleanup;
96
125
  async function main() {
97
126
  // ---- Flag handling, before anything touches the network -----------------------
98
- const argv = process.argv.slice(2); // everything after "mini-agent"
99
- if (argv.includes("-v") || argv.includes("--version")) {
127
+ const cli = parseCli(process.argv.slice(2));
128
+ if (cli.version) {
100
129
  console.log(pkg.version); // just the number — script-friendly
101
130
  return;
102
131
  }
103
- if (argv.includes("-h") || argv.includes("--help")) {
132
+ if (cli.help) {
104
133
  console.log(USAGE); // the full help text
105
134
  return;
106
135
  }
107
- // Print mode: -p / --print takes the task from the remaining arguments.
108
- const pIdx = argv.findIndex((a) => a === "-p" || a === "--print"); // where the flag sits
109
- const printTask = pIdx >= 0 ? argv.slice(pIdx + 1).join(" ").trim() : null; // everything after it is the task
110
- if (pIdx >= 0 && !printTask) {
111
- console.error('Usage: mini-agent -p "<task>"'); // -p without a task is a usage error
112
- process.exitCode = 2;
113
- return;
136
+ applyCliOptions(cli);
137
+ const printTask = cli.print ? await readPrintPrompt(cli.prompt, process.stdin, !!process.stdin.isTTY) : null;
138
+ if (cli.print) {
139
+ printOutput = reservePrintOutput();
140
+ chalk.level = 0; // no terminal escapes in script diagnostics
114
141
  }
115
142
  // Interactive sessions default to the Ink REPL (a pinned input box, the
116
143
  // conversation scrolling above it — like Claude Code). It does its OWN full
117
144
  // setup (MCP, skills, judge, cost, hooks, session), so we hand off here before
118
145
  // the readline path below ever runs. Print mode (-p) is a non-interactive
119
- // one-shot that streams to stdout, so it stays on the readline path. Set
146
+ // one-shot that writes its final result to stdout, so it stays on this path. Set
120
147
  // MINI_AGENT_NO_INK=1 to use the readline REPL below instead (a simpler
121
148
  // reference front-end, and an escape hatch for terminals Ink misbehaves in).
122
149
  if (printTask === null && process.env.MINI_AGENT_NO_INK !== "1") {
123
- await launchInk({ resume: argv.includes("-r") || argv.includes("--resume") });
150
+ await launchInk({ resume: cli.resume });
124
151
  return;
125
152
  }
126
153
  requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
@@ -132,7 +159,8 @@ async function main() {
132
159
  });
133
160
  // The system message is built ONCE per session: stable prefix = cache hits
134
161
  // on every request. Nothing session-specific may sneak into it later.
135
- const systemMessage = buildSystemMessage();
162
+ const projectInstructions = readProjectInstructions();
163
+ const systemMessage = buildSystemMessage(projectInstructions);
136
164
  let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
137
165
  // ---- Session persistence ---------------------------------------------------------
138
166
  // Every conversation is snapshotted to .mini-agent/sessions/ after each turn;
@@ -141,7 +169,7 @@ async function main() {
141
169
  let sessionId = newSessionId(); // this session's identity (and file name)
142
170
  let initialTitle; // only a resumed session has one already
143
171
  const sessionStartedAt = Date.now(); // for the status line's elapsed clock
144
- if (argv.includes("-r") || argv.includes("--resume")) {
172
+ if (cli.resume) {
145
173
  const prev = latestSession(); // newest snapshot in this directory, if any
146
174
  if (prev) {
147
175
  messages.push(...prev.messages); // the old conversation joins the fresh constitution
@@ -161,6 +189,13 @@ async function main() {
161
189
  // finish before the first model call so the tools appear in the manual. A
162
190
  // server that fails to start is skipped, never fatal.
163
191
  const disconnectMcp = await connectMcpServers();
192
+ if (cli.print)
193
+ printCleanup = () => {
194
+ stopCronScheduler();
195
+ killAllBackground();
196
+ killAllSubAgents();
197
+ disconnectMcp();
198
+ };
164
199
  process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
165
200
  process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
166
201
  process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed so they don't leave pending promises
@@ -172,9 +207,12 @@ async function main() {
172
207
  console.log(chalk.dim(`(cron: ${cronCount} durable job${cronCount !== 1 ? "s" : ""} loaded)`));
173
208
  startCronScheduler();
174
209
  // The optional LLM permission judge, built once if a settings file enabled it.
175
- const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
210
+ const judge = CONFIG.judge.enabled && !CONFIG.bypassPermissions ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
211
+ const autoMode = new AutoMode(client, { projectInstructions });
212
+ for (const notice of autoMode.startupNotices())
213
+ console.log(chalk.dim(notice));
176
214
  if (judge)
177
- console.log(chalk.dim(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`));
215
+ console.log(chalk.dim(`(permission judge on — ${autoMode.backend})`));
178
216
  // The remember tool: let the model save durable facts to long-term memory.
179
217
  // Registered like any tool, so it flows through the same permission gate.
180
218
  registerExternalTool(rememberTool);
@@ -220,13 +258,17 @@ async function main() {
220
258
  controller.abort(); // cancel the in-flight request
221
259
  });
222
260
  messages.push({ role: "user", content: printTask }); // the single task
261
+ autoMode.recordRequest(printTask);
223
262
  const result = await runLoop(messages, {
224
263
  client,
225
264
  model: CONFIG.model,
265
+ quiet: true, // final result only; no spinner, reasoning or intermediate prose
226
266
  signal: controller.signal,
227
267
  isInterrupted: () => interrupted,
228
268
  subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
229
269
  judge, // auto-allow clearly-safe commands even unattended (safer than blanket AUTO_APPROVE)
270
+ autoMode,
271
+ canPrompt: false,
230
272
  // Print mode never prompts: unattended means nobody can say yes.
231
273
  // MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
232
274
  // were enforced in permissions.ts long before this runs.
@@ -238,14 +280,18 @@ async function main() {
238
280
  },
239
281
  });
240
282
  saveSession(sessionId, CONFIG.model, messages); // print-mode runs are resumable too
283
+ await runHooks("SessionEnd", {});
241
284
  emit("agent_session_end"); // close the books
285
+ printOutput.write(formatPrintResult(cli.outputFormat, {
286
+ sessionId, model: CONFIG.model, effort: selectedEffort(CONFIG.model), result, usage: costMeter.snapshot(),
287
+ }));
242
288
  if (result.reason !== TerminateReason.Done) {
243
289
  console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
244
290
  if (result.detail)
245
291
  console.error(chalk.dim(result.detail.slice(0, 300))); // raw detail on stderr
246
292
  process.exitCode = result.reason === TerminateReason.UserInterrupt ? 130 : 1; // scripts can branch on this
247
293
  }
248
- return; // the answer itself already streamed to stdout
294
+ return; // the outer finally closes scheduler/MCP handles so scripts can exit
249
295
  }
250
296
  // ---- Interactive session (REPL) -------------------------------------------------
251
297
  // The welcome box: who am I, which model and host am I on, and the basics.
@@ -260,6 +306,7 @@ async function main() {
260
306
  // Bonus: in a terminal, lines typed while a task runs queue up as the next
261
307
  // commands instead of vanishing.
262
308
  const pendingLines = []; // lines that arrived before anyone asked
309
+ const followUps = new FollowUpQueue();
263
310
  const lineWaiters = []; // askers waiting for a line
264
311
  let stdinDone = false; // has stdin ended?
265
312
  const history = []; // past prompts, for ↑/↓ recall in the editor
@@ -327,13 +374,34 @@ async function main() {
327
374
  };
328
375
  process.on("SIGINT", onSigint); // covers non-TTY runs
329
376
  rl.on("SIGINT", onSigint); // covers TTY raw mode, where readline swallows the signal itself
330
- // Ask the human to approve a dangerous action — shares the session readline.
377
+ // Menus keep a text editor for follow-ups, sharing the session readline.
378
+ const enqueuePromptFollowUp = async (text) => {
379
+ if (!running) {
380
+ pendingLines.push(text);
381
+ return true;
382
+ }
383
+ const hook = await runHooks("UserPromptSubmit", { prompt: text });
384
+ if (hook.block)
385
+ return false;
386
+ const { augmented } = expandMentions(text);
387
+ followUps.enqueue({ text, content: augmented + (hook.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${hook.stdout}` : "") });
388
+ return true;
389
+ };
390
+ const select = async (options) => {
391
+ editing = true;
392
+ try {
393
+ return await promptSelect(rl, options, process.stdin, enqueuePromptFollowUp);
394
+ }
395
+ finally {
396
+ editing = false;
397
+ }
398
+ };
331
399
  // Fail closed: in a non-interactive session nobody can say yes, so the
332
400
  // answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
333
401
  // denies were already enforced in permissions.ts before we get here.
334
402
  const confirm = async (question, toolName) => {
335
403
  console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
336
- if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
404
+ if (!autoMode.enabled && process.env.MINI_AGENT_AUTO_APPROVE === "1") {
337
405
  console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
338
406
  return true; // approve without asking
339
407
  }
@@ -344,9 +412,10 @@ async function main() {
344
412
  // Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
345
413
  // option lets the user stop being asked about this tool for the session.
346
414
  const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
347
- const choice = await promptSelect(rl, ["Yes", allowLabel, "No — let me tell the agent what to do instead"]);
348
- const approved = choice === 0 || choice === 1;
349
- if (choice === 1 && toolName) {
415
+ const options = autoMode.enabled ? ["Yes, once", "No — let me tell the agent what to do instead"] : ["Yes", allowLabel, "No — let me tell the agent what to do instead"];
416
+ const choice = await select(options);
417
+ const approved = choice === 0 || (!autoMode.enabled && choice === 1);
418
+ if (!autoMode.enabled && choice === 1 && toolName) {
350
419
  CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
351
420
  console.log(chalk.dim(` won't ask again for ${toolName} this session`));
352
421
  }
@@ -359,7 +428,13 @@ async function main() {
359
428
  const askUser = async (questions) => {
360
429
  if (!process.stdin.isTTY)
361
430
  return null;
362
- return promptForm(rl, questions);
431
+ editing = true;
432
+ try {
433
+ return await promptForm(rl, questions, process.stdin, enqueuePromptFollowUp);
434
+ }
435
+ finally {
436
+ editing = false;
437
+ }
363
438
  };
364
439
  // Ask the endpoint what models it serves (the OpenAI-compatible /models API).
365
440
  // Empty list on any failure — not every endpoint implements it, and a failed
@@ -410,7 +485,7 @@ async function main() {
410
485
  return;
411
486
  }
412
487
  console.log(chalk.dim("switch model (this session):"));
413
- const choice = await promptSelect(rl, formatModelChoices(models, CONFIG.model));
488
+ const choice = await select(formatModelChoices(models, CONFIG.model));
414
489
  if (choice < 0 || models[choice] === CONFIG.model) {
415
490
  console.log(chalk.dim("(model unchanged)"));
416
491
  return;
@@ -443,14 +518,14 @@ async function main() {
443
518
  console.log(chalk.dim(` ${label}`));
444
519
  if (!process.stdin.isTTY)
445
520
  return;
446
- const choice = await promptSelect(rl, labels.map((l) => l.replace(/ · /g, " — ")));
521
+ const choice = await select(labels.map((l) => l.replace(/ · /g, " — ")));
447
522
  if (choice < 0) {
448
523
  console.log(chalk.dim("(cancelled)"));
449
524
  return;
450
525
  }
451
526
  const server = servers[choice];
452
527
  const actions = mcpActionsFor(server);
453
- const actionChoice = await promptSelect(rl, actions.map((a) => a.label));
528
+ const actionChoice = await select(actions.map((a) => a.label));
454
529
  if (actionChoice < 0) {
455
530
  console.log(chalk.dim("(cancelled)"));
456
531
  return;
@@ -464,6 +539,41 @@ async function main() {
464
539
  };
465
540
  // Handle a /slash command. Returns true if the line was a command.
466
541
  const handleCommand = async (line) => {
542
+ if (line === "/status" || line.startsWith("/status ")) {
543
+ console.log(await statusCommand(line, costMeter));
544
+ return true;
545
+ }
546
+ if (line.startsWith("/skills "))
547
+ line = `/skill ${line.slice(8).trim()}`;
548
+ if (line === "/effort" || line.startsWith("/effort ")) {
549
+ const target = CONFIG.model;
550
+ const value = line.slice("/effort".length).trim();
551
+ if (value)
552
+ console.log(chalk.dim(setEffort(target, value)));
553
+ else {
554
+ const menu = effortMenu(target);
555
+ console.log(chalk.dim(menu.header));
556
+ if (menu.values.length) {
557
+ if (!process.stdin.isTTY)
558
+ console.log(menu.labels.join("\n"));
559
+ else {
560
+ const selected = await select(menu.labels);
561
+ if (selected >= 0)
562
+ console.log(chalk.dim(setEffort(target, menu.values[selected])));
563
+ }
564
+ }
565
+ }
566
+ return true;
567
+ }
568
+ const debug = reviewDebugCommand(line);
569
+ if (debug !== null) {
570
+ console.log(chalk.dim(debug));
571
+ return true;
572
+ }
573
+ if (line === "/auto") {
574
+ console.log(chalk.dim(autoMode.toggle()));
575
+ return true;
576
+ }
467
577
  // /model takes an optional argument (/model <name>), so it can't be a plain
468
578
  // switch case — handle it before the exact-match switch.
469
579
  if (line === "/model" || line.startsWith("/model ")) {
@@ -484,11 +594,12 @@ async function main() {
484
594
  return true;
485
595
  }
486
596
  messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
597
+ autoMode.recordRequest(`Run the "${s.name}" skill.`);
487
598
  console.log(chalk.dim(`(running skill: ${s.name})`));
488
599
  running = true;
489
600
  interrupted = false;
490
601
  controller = new AbortController();
491
- const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, subAgentModel: CONFIG.subAgentModel, judge });
602
+ const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, followUps, onFollowUp: (text) => console.log(sentMessage(text)), subAgentModel: CONFIG.subAgentModel, judge, autoMode, canPrompt: !!process.stdin.isTTY });
492
603
  running = false;
493
604
  saveSession(sessionId, CONFIG.model, messages);
494
605
  if (r.reason !== TerminateReason.Done)
@@ -517,6 +628,7 @@ async function main() {
517
628
  console.log(chalk.dim(SESSION_HELP)); // the command reference
518
629
  return true;
519
630
  case "/clear":
631
+ autoMode.clearRequests();
520
632
  messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
521
633
  forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
522
634
  clearUndo(); // a fresh conversation should not undo the previous one's writes
@@ -649,6 +761,7 @@ async function main() {
649
761
  return true;
650
762
  }
651
763
  messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
764
+ autoMode.clearRequests(); // old transcripts do not preserve trustworthy input provenance
652
765
  sessionId = chosen.id; // keep appending to the resumed session's file
653
766
  titleAttempted = false; // a resumed session without a title gets one on its next message
654
767
  pendingTitle = chosen.title;
@@ -748,7 +861,8 @@ async function main() {
748
861
  console.log(""); // one blank line separates the previous answer from the prompt
749
862
  footer = statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // status pinned below the prompt
750
863
  }
751
- const line = (await readUserLine(framedPrompt(isPlanMode()), footer, sentMessage)).trim(); // next input (echoed as a sent message above), or "exit" on EOF
864
+ const prompt = autoMode.enabled && !isPlanMode() ? chalk.yellow("auto ❯ ") : framedPrompt(isPlanMode());
865
+ const line = (await readUserLine(prompt, footer, sentMessage)).trim(); // next input, or "exit" on EOF
752
866
  if (!line)
753
867
  continue; // empty line — just re-prompt
754
868
  if (line === "exit" || line === "quit") {
@@ -795,6 +909,7 @@ async function main() {
795
909
  }
796
910
  }
797
911
  messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
912
+ autoMode.recordRequest(line); // original human text, before file/hook attachments
798
913
  clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
799
914
  clearToolCalls(); // ...and Ctrl+T should reveal THIS turn's tool calls
800
915
  running = true; // Ctrl+C now means "interrupt the task"
@@ -807,8 +922,12 @@ async function main() {
807
922
  isInterrupted: () => interrupted, // for stopping between steps
808
923
  confirm, // for permission prompts
809
924
  askUser, // the multi-question form the model can pop
925
+ followUps,
926
+ onFollowUp: (text) => console.log(sentMessage(text)),
810
927
  subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
811
928
  judge, // optional LLM classifier for the "ask" middle ground
929
+ autoMode,
930
+ canPrompt: !!process.stdin.isTTY,
812
931
  });
813
932
  running = false; // back at the prompt — Ctrl+C means "exit" again
814
933
  saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
@@ -842,4 +961,10 @@ async function main() {
842
961
  tuiCleanup(); // restore terminal
843
962
  console.log(chalk.dim("bye.")); // a clean goodbye
844
963
  }
845
- main(); // kick everything off
964
+ main().catch((error) => {
965
+ console.error(error instanceof Error ? error.message : String(error));
966
+ process.exitCode = error instanceof CliUsageError ? 2 : 1;
967
+ }).finally(() => {
968
+ printCleanup?.();
969
+ printOutput?.restore();
970
+ });
@@ -0,0 +1,15 @@
1
+ // One object per root task, shared by descendants and interrupt/resume. An
2
+ // exhausted budget is sticky: a late successful worker cannot reopen the gate.
3
+ export class AutoReviewBudget {
4
+ consecutive = 0;
5
+ total = 0;
6
+ stopped = false;
7
+ get exhausted() { return this.stopped; }
8
+ deny() {
9
+ this.total++;
10
+ this.consecutive++;
11
+ this.stopped ||= this.consecutive >= 3 || this.total >= 20;
12
+ }
13
+ executed() { if (!this.stopped)
14
+ this.consecutive = 0; }
15
+ }
@@ -0,0 +1,125 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const HISTORY_BYTES = 8000;
3
+ const RESULT_BYTES = 2000;
4
+ function resultEvidence(result) {
5
+ if (!result)
6
+ return {};
7
+ if (Buffer.byteLength(result, "utf8") <= RESULT_BYTES) {
8
+ try {
9
+ const value = JSON.parse(result);
10
+ if (value !== null && typeof value === "object")
11
+ return { resultData: value };
12
+ }
13
+ catch { /* prose is not structured evidence */ }
14
+ }
15
+ return { resultDataOmitted: true };
16
+ }
17
+ function projectInput(tool, args) {
18
+ try {
19
+ const value = JSON.parse(args);
20
+ if (!value || typeof value !== "object" || Array.isArray(value))
21
+ return value;
22
+ if (tool === "read_file")
23
+ return { path: value.path };
24
+ if (tool === "search")
25
+ return { pattern: value.pattern, path: value.path, file_glob: value.file_glob };
26
+ if (tool === "run_bash" || tool === "run_bash_background")
27
+ return { command: value.command };
28
+ return value; // retain edits, writes and opaque inputs without guessing relevance
29
+ }
30
+ catch {
31
+ return { malformedArguments: args };
32
+ }
33
+ }
34
+ // This projection deliberately cannot create user authorization. Only completed
35
+ // tool-call/result pairs contribute context; queued sibling actions, assistant
36
+ // prose and synthetic user-role notifications are excluded. Small structured
37
+ // results retain target/effect evidence; arbitrary prose and large results do not.
38
+ // User requests/project rules/current action are separately kept in full.
39
+ export function reviewHistory(messages, inherited) {
40
+ const results = new Map();
41
+ for (const message of messages)
42
+ if (message.role === "tool") {
43
+ results.set(message.tool_call_id, typeof message.content === "string" ? message.content : "");
44
+ }
45
+ const actions = [...(inherited?.actions ?? [])];
46
+ for (const message of messages) {
47
+ if (message.role !== "assistant")
48
+ continue;
49
+ for (const call of message.tool_calls ?? []) {
50
+ if (call.type !== "function" || !results.has(call.id))
51
+ continue;
52
+ const result = results.get(call.id);
53
+ actions.push({ callId: call.id, tool: call.function.name, input: projectInput(call.function.name, call.function.arguments),
54
+ outcome: /^\[permission\] Auto-denied/.test(result) ? "auto_denied" : /^\[follow-up\]/.test(result) ? "skipped" : /^\[(?:permission|hook)\]/.test(result) ? "denied" : /^\[error\]/.test(result) ? "failed" : "returned", ...resultEvidence(result) });
55
+ }
56
+ }
57
+ let bytes = 0;
58
+ let start = actions.length;
59
+ while (start > 0) {
60
+ const size = Buffer.byteLength(JSON.stringify(actions[start - 1]), "utf8");
61
+ if (bytes + size > HISTORY_BYTES)
62
+ break; // keep a contiguous suffix, not misleading gaps
63
+ bytes += size;
64
+ start--;
65
+ }
66
+ return { actions: actions.slice(start), omittedActions: (inherited?.omittedActions ?? 0) + start };
67
+ }
68
+ // Only identity facts cross the new policy boundary. Small JSON is not trusted
69
+ // simply because it is small. Keep nesting for provenance, discard prose and
70
+ // instruction-shaped fields; these observations still cannot authorize actions.
71
+ export function ruleHistory(history) {
72
+ const project = (value, depth = 0) => {
73
+ if (depth > 6 || !value || typeof value !== "object")
74
+ return undefined;
75
+ if (Array.isArray(value)) {
76
+ const entries = value.slice(0, 30).map((v) => project(v, depth + 1)).filter((v) => v !== undefined);
77
+ return entries.length ? entries : undefined;
78
+ }
79
+ const facts = {};
80
+ for (const [key, item] of Object.entries(value)) {
81
+ if (/^(id|user_id|channel_id|name|display_name|real_name|email|username)$/.test(key)
82
+ && typeof item === "string" && item.length <= 200 && !/[\r\n]/.test(item))
83
+ facts[key] = item;
84
+ else if (/^(results|users|members|data|profile)$/.test(key)) {
85
+ const nested = project(item, depth + 1);
86
+ if (nested !== undefined)
87
+ facts[key] = nested;
88
+ }
89
+ }
90
+ return Object.keys(facts).length ? facts : undefined;
91
+ };
92
+ return { ...history, actions: history.actions.map(({ resultData, ...action }) => {
93
+ const facts = project(resultData);
94
+ return { ...action, ...(facts === undefined ? {} : { resultData: facts }),
95
+ ...(resultData !== undefined && JSON.stringify(facts) !== JSON.stringify(resultData) ? { resultDataOmitted: true } : {}) };
96
+ }) };
97
+ }
98
+ // Read once at startup. Later tool edits to git config must not expand the set
99
+ // of established destinations. Do not send URL credentials/query tokens.
100
+ export function startupRemotes(cwd) {
101
+ try {
102
+ const output = execFileSync("git", ["config", "--get-regexp", "^remote\\..*\\.(url|pushurl)$"], { cwd, encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] });
103
+ return output.trim().split("\n").flatMap((line) => {
104
+ const entry = /^remote\.(.+)\.(url|pushurl) (.+)$/.exec(line);
105
+ if (!entry)
106
+ return [];
107
+ let value = entry[3];
108
+ try {
109
+ const url = new URL(value);
110
+ url.username = "";
111
+ url.password = "";
112
+ url.search = "";
113
+ url.hash = "";
114
+ value = url.toString();
115
+ }
116
+ catch {
117
+ value = value.replace(/^[^@/]+@/, "");
118
+ }
119
+ return [{ name: entry[1], kind: entry[2] === "pushurl" ? "push" : "fetch", url: value }];
120
+ });
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }