agent-from-zero 0.34.2 → 0.35.1

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/loop.js CHANGED
@@ -1,17 +1,21 @@
1
+ import { AutoReviewBudget } from "./auto-budget.js";
1
2
  import chalk from "chalk"; // terminal colors for status lines
2
- import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
3
+ import { effortMessages, effortParameters, retainsReasoning } from "./effort.js";
4
+ import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool, declaresRecoverable } from "./tools.js"; // the tool manuals + the executor + file-state isolation
3
5
  import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
4
6
  import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
5
7
  import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
6
8
  import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
7
9
  import { SUB_AGENT_PROMPT, TEAMMATE_PROMPT } from "./prompt.js"; // the sub-agent + teammate constitutions
8
- import { LEAD, MAX_TEAMMATES, sendMessage, sendProtocol, readInbox, inboxCount, registerTeammate, finishTeammate, teammateExists, teammateCount, createRequest, resolveResponse, setTeammateState, anyTeammateBusy, runningTeammates, markShutdown, shutdownRequestId, resetTeam } from "./team.js"; // agent teams (Day 38) + team protocols (Day 39): mailboxes, registry, request/response contracts
10
+ import { LEAD, MAX_TEAMMATES, sendMessage, sendProtocol, readInbox, inboxCount, registerTeammate, finishTeammate, teammateExists, teammateCount, createRequest, resolveResponse, setTeammateState, anyTeammateBusy, runningTeammates, markShutdown, shutdownRequestId, resetTeam, listTeammateViews } from "./team.js"; // agent teams (Day 38) + team protocols (Day 39): mailboxes, registry, request/response contracts
9
11
  import { createTask, claimTask, completeTask, claimNextAvailable, boardSummary, resetBoard } from "./board.js"; // the shared task board (Day 40): autonomous work claiming
10
12
  import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
11
13
  import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
14
+ import { reviewHistory } from "./auto-context.js";
15
+ import { AgentProgress } from "./agent-progress.js";
12
16
  import { recordUsage } from "./cost.js"; // meter token usage from the stream
13
17
  import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
14
- import { recordToolCall, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
18
+ import { recordToolCall, recordToolResult, recordToolDetail, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
15
19
  import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
16
20
  import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
17
21
  import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
@@ -27,12 +31,14 @@ const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is no
27
31
  // user-facing explanation and exit code, instead of a generic "error".
28
32
  export var TerminateReason;
29
33
  (function (TerminateReason) {
34
+ TerminateReason["ReviewLimit"] = "review_limit";
30
35
  TerminateReason["Done"] = "done";
31
36
  TerminateReason["CircuitBreaker"] = "circuit_breaker";
32
37
  TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
33
38
  TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
34
39
  TerminateReason["ContextTooLong"] = "context_too_long";
35
40
  TerminateReason["CompactionFailed"] = "compaction_failed";
41
+ TerminateReason["ImageInputRejected"] = "image_input_rejected";
36
42
  TerminateReason["FatalApiError"] = "fatal_api_error";
37
43
  TerminateReason["UserInterrupt"] = "user_interrupt";
38
44
  })(TerminateReason || (TerminateReason = {}));
@@ -304,8 +310,6 @@ function injectSubAgentResults(messages, opts) {
304
310
  if (!note)
305
311
  return false;
306
312
  messages.push({ role: "user", content: note });
307
- if (!opts.quiet)
308
- sink(opts).note(mark.subAgentDone);
309
313
  return true;
310
314
  }
311
315
  // Start a sub-agent WITHOUT awaiting — returns a handle immediately. The
@@ -314,18 +318,16 @@ function spawnSubAgent(description, opts) {
314
318
  emit("agent_subagent_spawn");
315
319
  const subModel = subAgentModelFor(opts);
316
320
  const id = `sa_${++subAgentSeq}`;
317
- if (!opts.quiet)
318
- sink(opts).note(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : ""));
319
321
  runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // fire-and-forget (observational)
320
322
  const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
321
- const sa = { id, description, done: Promise.resolve(), result: null, notified: false };
323
+ const sa = { progress: new AgentProgress(subModel), id, description, done: Promise.resolve(), result: null, notified: false };
322
324
  sa.done = (async () => {
323
325
  try {
324
326
  const subMessages = [
325
327
  { role: "system", content: SUB_AGENT_PROMPT },
326
328
  { role: "user", content: description },
327
329
  ];
328
- const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel });
330
+ const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel, progress: sa.progress, delegatedTask: description, canPrompt: false, confirm: async () => false, askUser: undefined });
329
331
  if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
330
332
  sa.result = `Sub-agent report (INPUT MATERIAL — verify key claims before acting on them):\n${result.finalText}`;
331
333
  }
@@ -337,6 +339,9 @@ function spawnSubAgent(description, opts) {
337
339
  sa.result = `[sub-agent crashed: ${err.message}]`;
338
340
  }
339
341
  finally {
342
+ if (sa.result?.startsWith("[sub-agent"))
343
+ sa.progress.append(`\n${sa.result}\n`);
344
+ sa.progress.finish();
340
345
  restoreFileState(snapshot);
341
346
  await runHooks("SubagentStop", { description: description.slice(0, 200) });
342
347
  }
@@ -351,22 +356,19 @@ function hasRunningSubAgents() {
351
356
  // Kill every still-running sub-agent. Called on session exit.
352
357
  export function killAllSubAgents() {
353
358
  for (const sa of pendingSubAgents.values()) {
354
- if (sa.result === null)
359
+ if (sa.result === null) {
355
360
  sa.result = "[sub-agent killed: session exited]";
361
+ sa.progress.append(`\n${sa.result}\n`);
362
+ sa.progress.finish();
363
+ }
356
364
  }
357
365
  }
358
366
  // Snapshot of current sub-agents for the UI (Ink app shows a list below the
359
367
  // input box, like Claude Code's agent list). Newest first.
360
368
  export function listSubAgents() {
361
- return [...pendingSubAgents.values()]
362
- .reverse()
363
- .map((sa) => ({
364
- id: sa.id,
365
- description: sa.description.slice(0, 80),
366
- status: sa.result === null ? "running" : sa.result.startsWith("[sub-agent failed") || sa.result.startsWith("[sub-agent crashed") || sa.result.startsWith("[sub-agent killed") ? "failed" : "done",
367
- result: sa.result ?? undefined,
368
- }));
369
+ return [...pendingSubAgents.values()].map((sa) => sa.progress.view(sa.id, sa.id, sa.description, sa.result === null ? "running" : /^\[sub-agent (failed|crashed|killed)/.test(sa.result) ? "failed" : "done"));
369
370
  }
371
+ export function listAgentViews() { return [...listSubAgents(), ...listTeammateViews()]; }
370
372
  // Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
371
373
  // Without jitter, every client that failed at the same second retries at the
372
374
  // same second — a thundering herd against an already-struggling server.
@@ -390,15 +392,21 @@ opts) {
390
392
  const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
391
393
  const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
392
394
  const startedAt = Date.now(); // for the spinner's live elapsed counter
395
+ let reportedTokens;
396
+ if (opts.progress) {
397
+ opts.progress.activity = "Thinking";
398
+ opts.progress.liveTokens = 0;
399
+ }
393
400
  let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
394
401
  // Reasoning is COLLAPSED by default: while the model thinks, the spinner keeps
395
402
  // spinning (with a 💭 hint) instead of dumping the trace; we stash it for Ctrl+R.
396
403
  let reasoningBuf = ""; // the accumulated thinking, hidden behind the spinner
397
404
  let reasoningActive = false; // true while reasoning tokens are arriving → spinner shows 💭
398
- let reasoningShown = false; // have we printed the "thought for Ns" indicator yet?
399
- const spinner = opts.quiet
405
+ let reasoningShown = false; // has this trace been stored for Ctrl+R?
406
+ const spinner = opts.quiet || opts.subAgent
400
407
  ? null // the eval harness wants silence
401
- : sink(opts).spinner(spinnerText(word, 0, !!opts.subAgent, opts.model)); // a fresh spinner from the sink (stdout → ora; Ink → React state)
408
+ : sink(opts).spinner(spinnerText(word, 0, !!opts.subAgent)); // a fresh spinner from the sink (stdout → ora; Ink → React state)
409
+ let ans = null; // always close the live renderer, including interrupted streams
402
410
  let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
403
411
  let stallWarned = false; // only warn once per quiet stretch
404
412
  // The two-level watchdog, checked once per second:
@@ -409,7 +417,7 @@ opts) {
409
417
  // While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
410
418
  // reads differently from "it's waiting on the first token".
411
419
  if (spinner?.spinning)
412
- spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4)));
420
+ spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, Math.round(streamedChars / 4)));
413
421
  const quietMs = Date.now() - lastEvent; // ms since the last event
414
422
  if (quietMs > IDLE_TIMEOUT_MS) {
415
423
  emit("agent_watchdog_idle"); // record the cut — these should be rare
@@ -431,53 +439,48 @@ opts) {
431
439
  // The tool manual depends on the agent kind (Lead / teammate / sub-agent)
432
440
  // — see toolsFor(). This is also where nested spawning is prevented: a
433
441
  // teammate's manual simply omits spawn_teammate and task.
434
- { model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
442
+ { model: opts.model, messages: effortMessages(opts.model, messages), ...effortParameters(opts.model), tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
435
443
  let content = ""; // accumulated answer text
436
- let ans = null; // renders the streamed answer (stdout: markdown block by block; Ink: React state) — non-null once the answer starts
437
444
  const calls = []; // tool calls under assembly, indexed by delta.index
438
- // The model has finished thinking and is about to say/do something. Close out
439
- // the spinner, stash the trace, and print a single one-line indicator with the
440
- // shortcut to reveal it. Idempotent + collapses to nothing for sub-agents and
441
- // the eval harness (they never show thinking).
445
+ // Stash thinking without appending one indicator per model round. The live
446
+ // status keeps animating while tool arguments or answer tokens arrive.
442
447
  const flushReasoning = () => {
443
448
  if (!reasoningBuf || reasoningShown)
444
449
  return;
445
450
  reasoningShown = true;
446
451
  reasoningActive = false;
447
- if (spinner?.spinning)
448
- spinner.stop();
449
452
  if (opts.quiet || opts.subAgent)
450
453
  return; // no UI for silent / nested runs
451
454
  recordReasoning(reasoningBuf); // keep it for Ctrl+R (never goes into history)
452
- const secs = Math.round((Date.now() - startedAt) / 1000);
453
- const toks = Math.round(reasoningBuf.length / 4); // same ~chars/4 estimate as the spinner
454
- const tokStr = toks >= 1000 ? `${(toks / 1000).toFixed(1)}k` : `${toks}`;
455
- sink(opts).reasoning(chalk.dim(`💭 thought for ${secs}s (~${tokStr} tokens) · Ctrl+R to view`));
456
455
  };
457
456
  for await (const chunk of stream) {
458
457
  lastEvent = Date.now(); // feed the watchdog
459
458
  stallWarned = false; // the stream spoke — reset the stall warning
460
- if (chunk.usage)
461
- recordUsage(chunk.usage); // the final usage chunk — meter it
459
+ if (chunk.usage) {
460
+ recordUsage(chunk.usage);
461
+ if (typeof chunk.usage.completion_tokens === "number")
462
+ reportedTokens = chunk.usage.completion_tokens;
463
+ } // the final usage chunk — meter it
462
464
  const delta = chunk.choices[0]?.delta; // this chunk's increment
463
465
  if (!delta)
464
466
  continue; // keep-alive or usage chunk — nothing to do
465
467
  // Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
466
468
  // separate `reasoning_content` field BEFORE the answer. We do NOT print it
467
- // (it buries the actual reply) and do NOT keep it (it must not be replayed
468
- // in later turns): we let the spinner keep spinning, stash the trace, and
469
- // surface it only behind Ctrl+R.
469
+ // (it buries the actual reply). Provider capabilities determine whether it
470
+ // must be retained for tool-call continuation; the UI stays collapsed.
470
471
  const reasoning = delta.reasoning_content;
471
472
  if (reasoning) {
472
473
  streamedChars += reasoning.length; // count reasoning toward the live token estimate
474
+ if (opts.progress)
475
+ opts.progress.liveTokens = Math.ceil(streamedChars / 4);
473
476
  reasoningBuf += reasoning; // stash it for Ctrl+R instead of streaming it
474
477
  reasoningActive = true; // the spinner now shows 💭 (set by the watchdog)
475
478
  }
476
479
  if (delta.content) {
477
- flushReasoning(); // thinking is over — show the one-line indicator first
480
+ flushReasoning();
478
481
  streamedChars += delta.content.length; // count the answer toward the live token estimate
479
- if (spinner?.spinning)
480
- spinner.stop(); // first token: replace the spinner with real output
482
+ if (spinner?.spinning && !sink(opts).streamingStatus)
483
+ spinner.stop();
481
484
  if (!opts.quiet && !opts.subAgent) {
482
485
  // Only the top-level agent streams to the screen — a sub-agent's
483
486
  // inner monologue would be mistaken for the answer. The sink decides
@@ -488,28 +491,37 @@ opts) {
488
491
  ans = sink(opts).answer(); // first token: start the answer renderer
489
492
  ans.push(delta.content); // hand the token to the renderer (it decides when to paint)
490
493
  }
494
+ if (opts.progress) {
495
+ opts.progress.activity = "Writing";
496
+ opts.progress.append(delta.content);
497
+ opts.progress.liveTokens = Math.ceil(streamedChars / 4);
498
+ }
491
499
  content += delta.content; // always keep it for the history (the answer only, never the reasoning)
492
500
  }
493
501
  if (delta.tool_calls?.length)
494
- flushReasoning(); // thinking is over — indicator before the tool logs (which print after we return)
502
+ flushReasoning();
495
503
  for (const tc of delta.tool_calls ?? []) {
496
- if (spinner?.spinning)
497
- spinner.stop(); // tool call starting — spinner served its purpose
498
504
  const slot = (calls[tc.index] ??= { id: "", name: "", args: "" }); // create the slot on first fragment
499
505
  if (tc.id)
500
506
  slot.id = tc.id; // id arrives once
501
507
  if (tc.function?.name)
502
508
  slot.name += tc.function.name; // name usually arrives whole; += is safe either way
509
+ streamedChars += tc.function?.arguments?.length ?? 0;
510
+ if (opts.progress)
511
+ opts.progress.liveTokens = Math.ceil(streamedChars / 4);
503
512
  if (tc.function?.arguments)
504
513
  slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
505
514
  }
506
515
  }
507
- flushReasoning(); // a reasoning-only turn (no content, no tools) still records + indicates
508
- if (ans)
509
- ans.end(); // flush the final (un-terminated) block + end the line cleanly (the sink owns the trailing newline)
510
- return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
516
+ flushReasoning();
517
+ return { content, toolCalls: calls.filter(Boolean), ...(retainsReasoning(opts.model) ? { reasoning: reasoningBuf } : {}) };
511
518
  }
512
519
  finally {
520
+ ans?.end(); // cancel delayed repaints even when the API throws or is interrupted
521
+ if (opts.progress) {
522
+ opts.progress.finishModelCall(reportedTokens);
523
+ opts.progress.append("\n");
524
+ }
513
525
  clearInterval(watchdog); // always stop the timer
514
526
  if (spinner?.spinning)
515
527
  spinner.stop(); // and never leave a zombie spinner
@@ -527,38 +539,105 @@ function agentIndent(opts) {
527
539
  // Promise.all batch; calls that can prompt must be awaited one at a time.
528
540
  async function runOneCall(call, opts) {
529
541
  const indent = agentIndent(opts);
530
- if (!opts.quiet) {
531
- // The announcement is FOLDED (Day-after-40 UI fix): instead of printing a
532
- // line per call (which floods the screen on a big exploration), we record it
533
- // for the per-round tally + Ctrl+T. Diffs / prompts / results below still
534
- // print live. A generous slice keeps Ctrl+T readable without dumping megabytes.
535
- const line = indent + mark.tool(call.name, call.args.length > 300 ? call.args.slice(0, 297) + "..." : call.args);
536
- recordToolCall(line, `tool: ${call.name}\nargs: ${call.args}`);
542
+ const trace = !opts.quiet && !opts.subAgent ? recordToolCall(indent + mark.tool(call.name, call.args.length > 300 ? call.args.slice(0, 297) + "..." : call.args), `tool: ${call.name}\nargs: ${call.args}`, call) : undefined;
543
+ if (opts.progress) {
544
+ opts.progress.toolCalls++;
545
+ opts.progress.activity = call.name;
546
+ opts.progress.append(`\n[${call.name}]\nargs: ${call.args}\n`);
547
+ }
548
+ const started = Date.now();
549
+ const status = () => `${call.name}… (${Math.floor((Date.now() - started) / 1000)}s)`;
550
+ const spinner = !opts.quiet && !opts.subAgent ? sink(opts).spinner(status()) : null;
551
+ const timer = spinner ? setInterval(() => spinner.set(status()), 1000) : undefined;
552
+ emit("agent_tool_call", { tool: call.name });
553
+ try {
554
+ const refusal = await authorizeCall(call, opts);
555
+ const content = refusal ?? await runWithHooks(call, opts);
556
+ if (!/^\[(?:permission|hook|error|follow-up)\]/.test(content) && !["task", "spawn_teammate", "send_message", "ask_user"].includes(call.name))
557
+ opts.reviewBudget?.executed();
558
+ if (trace)
559
+ recordToolResult(trace, content);
560
+ opts.progress?.append(`result: ${content}\n`);
561
+ if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
562
+ sink(opts).note(renderTodos(getTodos()));
563
+ }
564
+ return { id: call.id, content };
565
+ }
566
+ catch (error) {
567
+ if (trace)
568
+ recordToolResult(trace, `[error] ${error instanceof Error ? error.message : String(error)}`);
569
+ throw error;
537
570
  }
538
- // The permission gate sits between the model's intent and execution.
539
- const v = checkPermission(call.name, call.args);
540
- emit("agent_tool_call", { tool: call.name }); // every attempt, allowed or not
541
- // For file-changing tools, show the diff BEFORE the gate decides. A filename
542
- // ("edit_file: cart.js") is not enough to approve safely; the actual +/- lines
543
- // are. Skipped for a hard deny (nothing will run) and in quiet sub-agent runs.
544
- if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
545
- const preview = previewChange(call.name, call.args);
546
- if (preview)
547
- sink(opts).note(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
571
+ finally {
572
+ clearInterval(timer);
573
+ spinner?.stop();
574
+ }
575
+ }
576
+ // null grants this exact call; any returned text is a refusal for the model.
577
+ // Hook rewrites use the same gate, so approval of one input cannot grant a
578
+ // different action. There is no recursive hook invocation on re-authorization.
579
+ async function authorizeCall(call, opts) {
580
+ if (opts.signal.aborted || opts.isInterrupted())
581
+ return "[permission] Interrupted before execution.";
582
+ if (opts.reviewBudget?.exhausted)
583
+ return "[permission] Review limit reached. Stop and wait for human direction; this action was not executed.";
584
+ const auto = opts.autoMode?.enabled ?? false;
585
+ const v = checkPermission(call.name, call.args, auto);
586
+ // Auto-mode fast path: calls a reviewer would otherwise see, allowed because
587
+ // they are recoverable or pre-granted. Counted so the report shows the saving.
588
+ if (auto && v.decision === "allow" && (["run_bash", "run_bash_background", "write_file", "edit_file"].includes(call.name) || call.name.startsWith("mcp__"))) {
589
+ emit("agent_auto_skipped", { tool: call.name, reason: v.reason });
548
590
  }
549
- let content; // what goes back to the model as the tool result
550
591
  if (v.decision === "deny") {
592
+ if (auto && opts.autoMode?.policy === "rules")
593
+ opts.reviewBudget?.deny();
551
594
  emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
552
595
  if (!opts.quiet)
553
596
  sink(opts).note(mark.denied(v.reason)); // tell the user we blocked it
554
- content = `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`; // teach the model the boundary
597
+ return `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`;
555
598
  }
556
599
  else if (v.decision === "ask") {
557
600
  // Optional LLM judge: for a run_bash command the rules couldn't classify,
558
601
  // ask the judge first. It can only DOWNGRADE ask→allow for the clearly
559
602
  // safe; anything else still goes to the human. The judge never sees a deny.
560
603
  let autoAllowed = false;
561
- if (opts.judge && call.name === "run_bash") {
604
+ let reviewReason = "";
605
+ let reviewId = ""; // set when auto review ran; joins the human's answer to that review
606
+ // Auto mode's line is recoverability: a tool its server declares read-only
607
+ // or merely additive loses nothing, so it runs without review (plan mode
608
+ // already denied above).
609
+ const recoverable = auto && !v.requiresHuman ? declaresRecoverable(call.name) : null;
610
+ if (recoverable) {
611
+ autoAllowed = true;
612
+ emit("agent_auto_skipped", { tool: call.name, reason: recoverable });
613
+ if (!opts.quiet)
614
+ recordToolDetail(call.id, `${recoverable[0].toUpperCase()}${recoverable.slice(1)}; no review needed`);
615
+ // Exiting plan mode always belongs to the human, never to a classifier.
616
+ }
617
+ else if (!v.requiresHuman && opts.autoMode && (auto ? call.name !== "exit_plan_mode" : !!opts.judge && call.name === "run_bash")) {
618
+ const definition = toolsFor(opts).find((t) => t.type === "function" && t.function.name === call.name);
619
+ const description = definition?.type === "function" ? definition.function.description : undefined;
620
+ const review = await opts.autoMode.classify(call.name, call.args, opts.autoRequests ?? [], opts.signal, {
621
+ description, history: opts.autoHistory, delegatedTask: opts.delegatedTask,
622
+ notify: (message) => { if (!opts.quiet)
623
+ sink(opts).note(chalk.yellow(` ⎿ ${message}`)); },
624
+ });
625
+ if (opts.signal.aborted || opts.isInterrupted())
626
+ return "[permission] Interrupted before execution.";
627
+ reviewId = review.reviewId ?? "";
628
+ if (review.decision === "deny") {
629
+ opts.reviewBudget?.deny();
630
+ emit("agent_tool_auto_denied", { tool: call.name, rules: (review.ruleIds ?? []).join(","), ...(reviewId ? { reviewId } : {}) });
631
+ if (!opts.quiet)
632
+ recordToolDetail(call.id, review.reason);
633
+ return `[permission] Auto-denied (not a user refusal): ${review.reason}. Not executed. Choose a permitted approach or report the restriction. Do not retry the same prohibited effect through another tool, wrapper or worker.`;
634
+ }
635
+ autoAllowed = review.decision === "allow";
636
+ reviewReason = review.reason;
637
+ if (!opts.quiet)
638
+ recordToolDetail(call.id, review.reason);
639
+ }
640
+ else if (!auto && opts.judge && call.name === "run_bash") {
562
641
  let cmd = "";
563
642
  try {
564
643
  cmd = JSON.parse(call.args).command ?? "";
@@ -567,27 +646,38 @@ async function runOneCall(call, opts) {
567
646
  if (cmd && (await opts.judge.classify(cmd)) === "allow") {
568
647
  autoAllowed = true;
569
648
  if (!opts.quiet)
570
- sink(opts).note(mark.judge); // visible — the user can see the judge worked
649
+ recordToolDetail(call.id, "Model judge auto-approved");
571
650
  }
572
651
  }
573
- const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
652
+ if (opts.signal.aborted || opts.isInterrupted())
653
+ return "[permission] Interrupted before execution.";
654
+ // An unattended callback may contain the old blanket auto-approve policy.
655
+ // Auto mode never uses that policy as a fallback when Jev did not approve.
656
+ // A diff is needed when the human is deciding; automatic edits stay in
657
+ // the collapsed tool trace and remain available through /diff.
658
+ if (!autoAllowed && !opts.quiet && (call.name === "write_file" || call.name === "edit_file")) {
659
+ const preview = previewChange(call.name, call.args);
660
+ if (preview)
661
+ sink(opts).note(preview.replace(/^/gm, agentIndent(opts)));
662
+ }
663
+ const ok = autoAllowed || (!(auto && opts.canPrompt === false) && await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}${reviewReason ? `\n ${reviewReason}` : ""}`, call.name));
664
+ // Every human prompt in auto mode is an interruption worth counting, with
665
+ // where it came from: the reviewer asked ("review", joinable by reviewId),
666
+ // or a rule required a human without review ("requires_human"). Approved is
667
+ // a likely false block, declined a correct one; "unattended" reached no one.
668
+ if (auto && !autoAllowed)
669
+ emit("agent_auto_human", { tool: call.name, source: reviewId ? "review" : v.requiresHuman ? "requires_human" : "no_review",
670
+ decision: ok ? "approved" : opts.canPrompt === false ? "unattended" : "declined", ...(reviewId ? { reviewId } : {}) });
574
671
  if (!ok)
575
- emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
672
+ emit("agent_tool_declined", { tool: call.name, ...(reviewId ? { reviewId } : {}) }); // the human said no — that is signal
576
673
  if (!ok && !opts.quiet)
577
674
  sink(opts).note(mark.declined); // make the refusal visible
578
- content = ok
579
- ? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
580
- : `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
581
- }
582
- else {
583
- content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
584
- }
585
- // todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
586
- // the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
587
- if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
588
- sink(opts).note(renderTodos(getTodos()));
675
+ if (!ok)
676
+ return `[permission] Action not approved${reviewReason ? `: ${reviewReason}` : ". The user declined this action"}. Ask the user how to proceed, or choose a safer alternative.`;
589
677
  }
590
- return { id: call.id, content }; // paired by id for the API
678
+ if (opts.reviewBudget?.exhausted)
679
+ return "[permission] Review limit reached; not executed. Wait for human direction.";
680
+ return opts.signal.aborted || opts.isInterrupted() ? "[permission] Interrupted before execution." : null;
591
681
  }
592
682
  // Run an approved tool call, surrounded by the user's lifecycle hooks.
593
683
  // PreToolUse can block (exit 2) — its stderr is fed back to the model as the
@@ -597,6 +687,8 @@ async function runOneCall(call, opts) {
597
687
  // wrote"). Sub-agents skip hooks: hooks are about the human's project policy,
598
688
  // not internal delegation.
599
689
  async function runWithHooks(call, opts) {
690
+ if (opts.reviewBudget?.exhausted)
691
+ return "[permission] Review limit reached; not executed.";
600
692
  if (opts.subAgent)
601
693
  return execute(call, opts); // sub-agents run hook-free
602
694
  const pre = await runHooks("PreToolUse", { tool: call.name, args: call.args }); // before
@@ -607,19 +699,26 @@ async function runWithHooks(call, opts) {
607
699
  return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
608
700
  }
609
701
  // A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
610
- // permission gate already ran on the original args; the rewrite only narrows or
611
- // annotates, never escalates past a deny. Validate it's parseable, then use it.
612
- if (pre.rewrite) {
702
+ // permission gate ran on the original args. A rewrite is a NEW action and
703
+ // must pass the gate again; hooks cannot turn an approved read into a write.
704
+ if (pre.rewrite && pre.rewrite !== call.args) {
613
705
  try {
614
706
  JSON.parse(pre.rewrite); // must be valid JSON args
615
707
  if (!opts.quiet)
616
- sink(opts).note(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
708
+ recordToolDetail(call.id, `PreToolUse rewrote args: ${pre.rewrite}`);
617
709
  call = { ...call, args: pre.rewrite };
618
710
  }
619
711
  catch {
620
712
  /* malformed rewrite — ignore, run the original args */
621
713
  }
714
+ const refusal = await authorizeCall(call, opts);
715
+ if (refusal)
716
+ return refusal;
622
717
  }
718
+ if (opts.signal.aborted || opts.isInterrupted())
719
+ return "[permission] Interrupted before execution.";
720
+ if (opts.reviewBudget?.exhausted)
721
+ return "[permission] Review limit reached; not executed.";
623
722
  const result = await execute(call, opts); // the actual work
624
723
  const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
625
724
  if (post.block) {
@@ -871,10 +970,9 @@ async function runSpawnTeammate(call, opts) {
871
970
  // concurrent. It progresses whenever the event loop is free (e.g. while the
872
971
  // lead awaits its own API stream). We keep the promise so the lead and
873
972
  // shutdown know when it has ended.
874
- const done = runTeammate(name, role, task, opts);
875
- registerTeammate(name, role, done);
876
- if (!opts.quiet)
877
- sink(opts).note(mark.teammateStart(name, role));
973
+ const progress = new AgentProgress(opts.model);
974
+ const done = runTeammate(name, role, task, opts, progress);
975
+ registerTeammate(name, role, done, progress);
878
976
  return `Teammate "${name}" (${role}) spawned and is now working in parallel. It will message you (as "lead") with progress and its final result. Reply with send_message; keep coordinating the rest of the team. Do not wait idly — continue your own work.`;
879
977
  }
880
978
  // Run one teammate to completion: a fresh conversation seeded with its task, a
@@ -882,7 +980,7 @@ async function runSpawnTeammate(call, opts) {
882
980
  // a `result` message back to the lead — so the lead always learns the outcome,
883
981
  // success or failure. The teammate's file read-state is isolated by snapshot,
884
982
  // exactly like a sub-agent (it reads things the lead's conversation never saw).
885
- async function runTeammate(name, role, task, opts) {
983
+ async function runTeammate(name, role, task, opts, progress) {
886
984
  emit("agent_teammate_spawn");
887
985
  await runHooks("SubagentStart", { description: `teammate ${name}: ${role}`.slice(0, 200), model: opts.model });
888
986
  const snapshot = snapshotFileState();
@@ -903,8 +1001,11 @@ async function runTeammate(name, role, task, opts) {
903
1001
  ...opts,
904
1002
  subAgent: true,
905
1003
  teammate: { name },
1004
+ progress,
1005
+ delegatedTask: task,
906
1006
  maxRounds: TEAMMATE_MAX_ROUNDS,
907
1007
  confirm: teammateConfirm(name, sink(opts)),
1008
+ canPrompt: false,
908
1009
  askUser: undefined, // teammates have no line to the human
909
1010
  });
910
1011
  // How it ended decides what the lead hears. A shutdown handshake gets a
@@ -917,8 +1018,6 @@ async function runTeammate(name, role, task, opts) {
917
1018
  else
918
1019
  sendMessage(name, LEAD, summary, "result");
919
1020
  finishTeammate(name, result.reason === TerminateReason.Done);
920
- if (!opts.quiet)
921
- sink(opts).note(mark.teammateDone(name, result.reason === TerminateReason.Done));
922
1021
  }
923
1022
  catch (err) {
924
1023
  // A teammate must never take the whole process down. Report the failure to
@@ -1028,10 +1127,7 @@ function injectBackgroundNotifications(messages, opts) {
1028
1127
  const note = pendingNotifications(); // "" unless a task finished since we last checked
1029
1128
  if (!note)
1030
1129
  return false;
1031
- const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
1032
1130
  messages.push({ role: "user", content: note }); // the model reacts to it on the next round
1033
- if (!opts.quiet)
1034
- sink(opts).note(mark.bgNote(count));
1035
1131
  return true;
1036
1132
  }
1037
1133
  // Consume this agent's mailbox into its conversation, ROUTING protocol messages
@@ -1131,6 +1227,8 @@ async function continueForTeam(messages, opts) {
1131
1227
  return false; // a plain sub-agent has no team
1132
1228
  // The lead: wait for the team to act rather than spinning or ending early.
1133
1229
  while (inboxCount(LEAD) === 0 && anyTeammateBusy() && !opts.isInterrupted()) {
1230
+ if (opts.followUps?.size)
1231
+ return true;
1134
1232
  await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted); // yield to the teammates; they message us when they have something
1135
1233
  }
1136
1234
  return consumeInbox(messages, opts); // process whatever is waiting; false if the team is quiescent (→ the lead may finish)
@@ -1164,6 +1262,39 @@ async function shutdownTeam(opts) {
1164
1262
  // for a named reason or terminates for a named reason — nothing implicit.
1165
1263
  export async function runLoop(messages, // conversation history (mutated in place)
1166
1264
  opts) {
1265
+ // Children inherit this snapshot. Later human turns cannot silently grant
1266
+ // broader authorization to already-running background work.
1267
+ opts = { ...opts, reviewBudget: opts.reviewBudget ?? new AutoReviewBudget(), autoRequests: opts.autoRequests ?? opts.autoMode?.snapshot(), followUps: opts.subAgent ? undefined : opts.followUps };
1268
+ const recordHumanInput = (text) => {
1269
+ opts.autoMode?.recordRequest(text);
1270
+ opts.autoRequests = [...(opts.autoRequests ?? []), text];
1271
+ };
1272
+ // Capture at the actual UI callback, never by parsing an ask_user tool result
1273
+ // (which can also be restored or forged). Close over this loop's opts so the
1274
+ // answer reaches subsequent calls despite per-call option copies below.
1275
+ const askUser = opts.askUser;
1276
+ if (askUser && !opts.subAgent)
1277
+ opts.askUser = async (questions) => {
1278
+ const answers = await askUser(questions);
1279
+ if (!opts.signal.aborted && !opts.isInterrupted()) {
1280
+ for (const answer of answers ?? []) {
1281
+ const question = questions.find((q) => q.question === answer.question);
1282
+ if (question && typeof answer.answer === "string" && answer.answer.trim()) {
1283
+ recordHumanInput(`Human form reply (question is context; only the submitted answer expresses the user's choice): ${JSON.stringify({ question: question.question, answer: answer.answer })}`);
1284
+ }
1285
+ }
1286
+ }
1287
+ return answers;
1288
+ };
1289
+ const receiveFollowUps = () => {
1290
+ const pending = opts.followUps?.drain() ?? [];
1291
+ for (const message of pending) {
1292
+ messages.push({ role: "user", content: message.content });
1293
+ recordHumanInput(message.text);
1294
+ opts.onFollowUp?.(message.displayText ?? message.text);
1295
+ }
1296
+ return pending.length > 0;
1297
+ };
1167
1298
  // The loop's mutable state: budgets and counters, rewritten every iteration.
1168
1299
  const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
1169
1300
  const compaction = { count: 0, failures: 0 }; // compaction score card for this query
@@ -1180,6 +1311,9 @@ opts) {
1180
1311
  while (true) {
1181
1312
  if (opts.isInterrupted())
1182
1313
  return { reason: TerminateReason.UserInterrupt }; // user asked us to stop — obey before spending money
1314
+ if (opts.reviewBudget?.exhausted)
1315
+ return { reason: TerminateReason.ReviewLimit };
1316
+ receiveFollowUps();
1183
1317
  // Proactive compaction: act BEFORE the API rejects us. Waiting for the
1184
1318
  // hard limit means the failure already happened.
1185
1319
  if (estimateHistoryTokens(messages) > COMPACT_AT) {
@@ -1189,7 +1323,7 @@ opts) {
1189
1323
  if (!ok && compaction.failures >= MAX_COMPACT_FAILURES)
1190
1324
  return { reason: TerminateReason.CompactionFailed, detail: `${compaction.failures} consecutive compaction failures` }; // the compaction circuit breaker
1191
1325
  }
1192
- let out; // the assembled reply for this round
1326
+ let out;
1193
1327
  try {
1194
1328
  out = await streamModelCall(messages, opts); // streaming call with spinner + watchdog
1195
1329
  }
@@ -1211,6 +1345,9 @@ opts) {
1211
1345
  continue; // history is smaller now — retry the same round
1212
1346
  return { reason: TerminateReason.ContextTooLong, detail: e.message }; // compaction could not save us
1213
1347
  }
1348
+ if (e.kind === ApiErrorKind.BadRequest && messages.some((m) => Array.isArray(m.content) && m.content.some((part) => part.type === "image_url"))) {
1349
+ return { reason: TerminateReason.ImageInputRejected };
1350
+ }
1214
1351
  if (!e.retryable)
1215
1352
  return { reason: TerminateReason.FatalApiError, detail: `${e.kind}: ${e.message}` }; // bad key etc. — stop now
1216
1353
  emit("agent_api_error", { kind: e.kind, attempt: attempts.total + 1 }); // classified, greppable
@@ -1245,6 +1382,7 @@ opts) {
1245
1382
  messages.push({
1246
1383
  role: "assistant", // the model's turn
1247
1384
  content: out.content || null, // null when the turn was tool calls only
1385
+ ...(out.reasoning !== undefined ? { reasoning_content: out.reasoning } : {}),
1248
1386
  ...(out.toolCalls.length
1249
1387
  ? { tool_calls: out.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) }
1250
1388
  : {}), // omit the field entirely when there were no calls
@@ -1252,6 +1390,8 @@ opts) {
1252
1390
  if (out.content)
1253
1391
  lastText = out.content; // remember the latest answer text — the round-cap path returns it
1254
1392
  if (!out.toolCalls.length) {
1393
+ if (receiveFollowUps())
1394
+ break; // new human input takes precedence over finishing
1255
1395
  // The model wants to stop. A Stop hook gets the last word: if it exits
1256
1396
  // 2, the agent is NOT done — its stderr becomes a new instruction and
1257
1397
  // the loop continues. This is how you build test-driven AI: a Stop hook
@@ -1283,6 +1423,8 @@ opts) {
1283
1423
  // process, run another round instead of stopping.
1284
1424
  if (await continueForTeam(messages, opts))
1285
1425
  break; // exit the inner while → advance the round counter
1426
+ if (receiveFollowUps())
1427
+ break;
1286
1428
  // The lead is really finishing: disband any teammates still idling so
1287
1429
  // they exit cleanly (Day 39) instead of being orphaned.
1288
1430
  if (!opts.subAgent && !opts.teammate)
@@ -1297,25 +1439,28 @@ opts) {
1297
1439
  // Claude Code parallelizes safe reads while serializing risky work.
1298
1440
  let i = 0; // index into out.toolCalls
1299
1441
  while (i < out.toolCalls.length) {
1442
+ if (opts.followUps?.size && !opts.isInterrupted()) {
1443
+ // Resolve every outstanding tool ID BEFORE adding a user message.
1444
+ // Unstarted actions came from the old intent and must be replanned.
1445
+ for (const call of out.toolCalls.slice(i))
1446
+ messages.push({ role: "tool", tool_call_id: call.id, content: "[follow-up] Not executed: new user input arrived. Reconsider this action using the latest instructions." });
1447
+ receiveFollowUps();
1448
+ break;
1449
+ }
1300
1450
  const batch = []; // a run of safe, parallelizable calls
1301
1451
  while (i < out.toolCalls.length && isReadOnlyTool(out.toolCalls[i].name))
1302
1452
  batch.push(out.toolCalls[i++]);
1303
1453
  if (batch.length) {
1304
1454
  // Run the whole read-only batch at once, preserving result order.
1305
- const results = await Promise.all(batch.map((c) => runOneCall(c, opts)));
1455
+ const results = await Promise.all(batch.map((c) => runOneCall(c, { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) })));
1306
1456
  for (const r of results)
1307
1457
  messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
1308
1458
  continue; // back to the top — the next call is non-read-only
1309
1459
  }
1310
1460
  // A single non-read-only call: gate, maybe ask, execute — all serial.
1311
- const r = await runOneCall(out.toolCalls[i++], opts);
1461
+ const r = await runOneCall(out.toolCalls[i++], { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) });
1312
1462
  messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
1313
1463
  }
1314
- // Fold this round's tool calls into ONE tally line instead of one line per
1315
- // call (the per-call announcements were recorded for Ctrl+T in runOneCall).
1316
- if (!opts.quiet && out.toolCalls.length) {
1317
- sink(opts).note(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
1318
- }
1319
1464
  // Cron (Day s14): deliver any triggered scheduled jobs right after this
1320
1465
  // round's results so the model sees them on the next round.
1321
1466
  injectCronMessages(messages, opts);