agent-from-zero 0.5.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/loop.js CHANGED
@@ -3,11 +3,19 @@ import ora from "ora"; // the "thinking..." spinner while waiting for the first
3
3
  import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
4
4
  import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
5
5
  import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
6
+ import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
6
7
  import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
7
- import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent's own constitution
8
+ import { SUB_AGENT_PROMPT, TEAMMATE_PROMPT } from "./prompt.js"; // the sub-agent + teammate constitutions
9
+ 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 { createTask, claimTask, completeTask, claimNextAvailable, boardSummary, resetBoard } from "./board.js"; // the shared task board (Day 40): autonomous work claiming
8
11
  import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
9
12
  import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
10
- export const MAX_ROUNDS = 15; // model→tool round cap per query
13
+ import { recordUsage } from "./cost.js"; // meter token usage from the stream
14
+ import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
15
+ import { printToolSummary } from "./tui.js"; // collapsible tool output
16
+ import { MarkdownStream } from "./markdown.js"; // render the streamed answer as terminal markdown
17
+ import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
18
+ import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
11
19
  export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
12
20
  export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
13
21
  export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
@@ -20,7 +28,6 @@ const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is no
20
28
  export var TerminateReason;
21
29
  (function (TerminateReason) {
22
30
  TerminateReason["Done"] = "done";
23
- TerminateReason["RoundCap"] = "round_cap";
24
31
  TerminateReason["CircuitBreaker"] = "circuit_breaker";
25
32
  TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
26
33
  TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
@@ -29,6 +36,12 @@ export var TerminateReason;
29
36
  TerminateReason["FatalApiError"] = "fatal_api_error";
30
37
  TerminateReason["UserInterrupt"] = "user_interrupt";
31
38
  })(TerminateReason || (TerminateReason = {}));
39
+ // Which model should a delegated sub-agent run on? The configured sub-agent
40
+ // model if set and non-blank, otherwise the same model as the orchestrator.
41
+ // Pulled out as a pure function so the tiering rule is testable on its own.
42
+ export function subAgentModelFor(opts) {
43
+ return opts.subAgentModel?.trim() || opts.model;
44
+ }
32
45
  // The task tool: the parent's handle on sub-agents. Defined here (not in
33
46
  // tools.ts) because running it needs the loop itself — it IS a loop.
34
47
  const taskTool = {
@@ -49,19 +62,234 @@ On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a
49
62
  },
50
63
  },
51
64
  };
65
+ // The ask_user tool: the model's handle on the user. When it needs the human to
66
+ // choose between options, it calls this instead of asking in prose — and gets
67
+ // back clean, structured selections rather than a free-text paragraph to parse.
68
+ const askUserTool = {
69
+ type: "function",
70
+ function: {
71
+ name: "ask_user",
72
+ description: `Ask the user one or more multiple-choice questions and get back their selections. Use this WHENEVER you need the user to make a decision between options (which approach, which file, yes/no/which) — it is easier for them to pick than to type, and the answers come back unambiguous.
73
+ Provide each question with 2–4 concrete options. Do NOT use it for open-ended input that has no clear options; ask in prose for that.
74
+ If the user cancels, you'll be told — then proceed with your best judgment or ask again.`,
75
+ parameters: {
76
+ type: "object",
77
+ properties: {
78
+ questions: {
79
+ type: "array",
80
+ description: "The questions to ask, each with a list of options",
81
+ items: {
82
+ type: "object",
83
+ properties: {
84
+ question: { type: "string", description: "The question text" },
85
+ options: { type: "array", items: { type: "string" }, description: "2–4 concrete choices" },
86
+ },
87
+ required: ["question", "options"],
88
+ },
89
+ },
90
+ },
91
+ required: ["questions"],
92
+ },
93
+ },
94
+ };
95
+ // The spawn_teammate tool (Day 38): the Lead's handle on a PERSISTENT worker.
96
+ // Unlike `task` (one-shot, returns once), a teammate runs concurrently and keeps
97
+ // talking — it messages the lead as it works and is reachable via send_message.
98
+ // Defined here (not tools.ts) because running it needs the loop itself.
99
+ const spawnTeammateTool = {
100
+ type: "function",
101
+ function: {
102
+ name: "spawn_teammate",
103
+ description: `Spawn a PERSISTENT teammate that works in parallel with you and the rest of the team. Use this for a large task with distinct streams of work that need to coordinate as they go (e.g. one teammate on the API, one on the database) — not for a quick lookup (use task) or a single linear job (do it yourself).
104
+ The teammate runs concurrently in a fresh context with read/write/search/bash tools. It can message you and other teammates at any time, and you'll receive its result when it finishes. You can run a few at once.
105
+ Give it a short unique name and a one-line role, and a complete self-contained task (it knows nothing about this conversation). After spawning, keep coordinating: you'll get its messages in your inbox; reply with send_message.`,
106
+ parameters: {
107
+ type: "object",
108
+ properties: {
109
+ name: { type: "string", description: "A short, unique name for the teammate (e.g. 'api', 'db', 'tests')" },
110
+ role: { type: "string", description: "One line describing its specialty/responsibility" },
111
+ task: { type: "string", description: "The complete, self-contained task for the teammate to carry out" },
112
+ },
113
+ required: ["name", "role", "task"],
114
+ },
115
+ },
116
+ };
117
+ // The send_message tool (Day 38): drop a message in another agent's mailbox.
118
+ // Available to the Lead AND to teammates — it is how a team coordinates.
119
+ const sendMessageTool = {
120
+ type: "function",
121
+ function: {
122
+ name: "send_message",
123
+ description: `Send a message to another agent on the team (a teammate by name, or "lead"). The message lands in their inbox and they see it on their next round. Use it to share findings, hand off work, ask a specific question, or report progress to the lead. Returns once delivered; it does NOT wait for a reply — keep working, and their response will arrive in your inbox.`,
124
+ parameters: {
125
+ type: "object",
126
+ properties: {
127
+ to: { type: "string", description: 'Recipient agent name, or "lead" for the coordinator' },
128
+ content: { type: "string", description: "The message text" },
129
+ },
130
+ required: ["to", "content"],
131
+ },
132
+ },
133
+ };
134
+ // The team protocol tools (Day 39). Both sides of each contract are a tool:
135
+ // the lead asks for shutdown / reviews a plan; a teammate submits a plan. Each
136
+ // carries a request_id so the answer can be correlated to the ask.
137
+ const requestShutdownTool = {
138
+ type: "function",
139
+ function: {
140
+ name: "request_shutdown",
141
+ description: `Ask a teammate to shut down GRACEFULLY. It finishes what it's mid-way through, confirms, and exits cleanly — far better than abandoning it (which can leave a half-written file). Use it when a teammate's part is done, or when you're wrapping up the whole task. You'll get a shutdown confirmation back. (When you finish, any teammates still up are shut down for you automatically.)`,
142
+ parameters: {
143
+ type: "object",
144
+ properties: {
145
+ teammate: { type: "string", description: "Name of the teammate to shut down" },
146
+ reason: { type: "string", description: "Optional short reason, shown to the teammate" },
147
+ },
148
+ required: ["teammate"],
149
+ },
150
+ },
151
+ };
152
+ const requestPlanTool = {
153
+ type: "function",
154
+ function: {
155
+ name: "request_plan",
156
+ description: `Tell a teammate to submit a PLAN (via submit_plan) and wait for your approval BEFORE it carries out a risky or far-reaching change (e.g. an auth refactor, a schema migration). Use this when you want to pre-authorize high-stakes work instead of reviewing the damage after. The teammate will send you a plan_approval_request; approve or reject it with review_plan.`,
157
+ parameters: {
158
+ type: "object",
159
+ properties: {
160
+ teammate: { type: "string", description: "Name of the teammate" },
161
+ task: { type: "string", description: "The risky work it must plan before doing" },
162
+ },
163
+ required: ["teammate", "task"],
164
+ },
165
+ },
166
+ };
167
+ const reviewPlanTool = {
168
+ type: "function",
169
+ function: {
170
+ name: "review_plan",
171
+ description: `Approve or reject a plan a teammate submitted for approval. You'll have received a plan_approval_request with a request_id — pass that id and your decision. On approval the teammate proceeds; on rejection it revises and may submit again. Always include a brief reason on a rejection so it can fix the plan.`,
172
+ parameters: {
173
+ type: "object",
174
+ properties: {
175
+ request_id: { type: "string", description: "The request_id from the plan_approval_request" },
176
+ decision: { type: "string", enum: ["approve", "reject"], description: "approve | reject" },
177
+ reason: { type: "string", description: "Short reason (required-in-spirit on a rejection)" },
178
+ },
179
+ required: ["request_id", "decision"],
180
+ },
181
+ },
182
+ };
183
+ const submitPlanTool = {
184
+ type: "function",
185
+ function: {
186
+ name: "submit_plan",
187
+ description: `Submit a PLAN to the lead and wait for approval BEFORE carrying out a risky or far-reaching change. Use it whenever you're about to do something high-stakes (refactor auth, migrate a schema, delete/rewrite many files) — propose first, act after. After you call this, STOP and wait: the lead's approval (or rejection) will arrive in your inbox. Do not start the work until it's approved.`,
188
+ parameters: {
189
+ type: "object",
190
+ properties: {
191
+ plan: { type: "string", description: "The concise, ordered plan you intend to carry out" },
192
+ },
193
+ required: ["plan"],
194
+ },
195
+ },
196
+ };
197
+ // The task board tools (Day 40). The board is how teammates find work without
198
+ // the lead handing it out: create tasks, see the board, claim a ready one, mark
199
+ // it done. claim/complete are loop-level (not registry tools) because they need
200
+ // to know WHO is calling — the claimer's identity is the agent name, which only
201
+ // the loop has.
202
+ const createTaskTool = {
203
+ type: "function",
204
+ function: {
205
+ name: "create_task",
206
+ description: `Add a task to the shared board for a teammate to pick up. Use it to break a big job into independent, claimable pieces. A task can declare blockedBy (ids of tasks that must finish first), so you can lay out a dependency order (e.g. schema → routes → tests) and teammates will respect it. Returns the new task id.`,
207
+ parameters: {
208
+ type: "object",
209
+ properties: {
210
+ subject: { type: "string", description: "Short one-line title of the task" },
211
+ description: { type: "string", description: "The full, self-contained instruction for whoever claims it" },
212
+ blockedBy: { type: "array", items: { type: "string" }, description: "Tasks that must be completed before this one can start — by task id or by their subject (optional)" },
213
+ },
214
+ required: ["subject"],
215
+ },
216
+ },
217
+ };
218
+ const listTasksTool = {
219
+ type: "function",
220
+ function: {
221
+ name: "list_tasks",
222
+ description: `Show the shared task board: every task with its status (pending / in_progress / completed), owner, and dependencies. Use it to see what's left, what's claimed, and what's ready to start.`,
223
+ parameters: { type: "object", properties: {}, required: [] },
224
+ },
225
+ };
226
+ const claimTaskTool = {
227
+ type: "function",
228
+ function: {
229
+ name: "claim_task",
230
+ description: `Claim a pending, unblocked task from the board as your own — it becomes in_progress and owned by you. Claim BEFORE you start working it. If the claim fails (someone beat you to it, or it's still blocked), pick another task or wait. When you're done, call complete_task. (You usually don't need to call this by hand: while idle you are offered ready work automatically.)`,
231
+ parameters: {
232
+ type: "object",
233
+ properties: { task_id: { type: "string", description: "The id of the task to claim" } },
234
+ required: ["task_id"],
235
+ },
236
+ },
237
+ };
238
+ const completeTaskTool = {
239
+ type: "function",
240
+ function: {
241
+ name: "complete_task",
242
+ description: `Mark a task you own as completed. This may unblock downstream tasks (you'll be told which). Only call it once the work is actually done and verified.`,
243
+ parameters: {
244
+ type: "object",
245
+ properties: { task_id: { type: "string", description: "The id of the task to complete" } },
246
+ required: ["task_id"],
247
+ },
248
+ },
249
+ };
250
+ // Which tool manuals does THIS agent get? One place so the three agent kinds
251
+ // stay clearly distinct:
252
+ // - Lead / top-level: everything, plus task, ask_user, and the team tools
253
+ // (spawn + message + the protocol tools it drives: shutdown / plan review).
254
+ // - Teammate: a focused worker set + send_message + submit_plan; NO task, NO
255
+ // spawn_teammate (nested spawning is forbidden), NO ask_user (can't reach
256
+ // the human), and none of the planning/background extras.
257
+ // - Plain sub-agent (task): all built-ins except todo_write (unchanged).
258
+ const TEAMMATE_TOOLS = new Set(["read_file", "write_file", "edit_file", "search", "run_bash"]); // the teammate's focused kit
259
+ function toolsFor(opts) {
260
+ const builtins = toolDefinitions();
261
+ if (opts.teammate) {
262
+ // A teammate works the board: it can add tasks it discovers, see the board,
263
+ // claim ready work, and complete what it owns.
264
+ const kit = builtins.filter((t) => t.type === "function" && TEAMMATE_TOOLS.has(t.function.name));
265
+ return [...kit, sendMessageTool, submitPlanTool, createTaskTool, listTasksTool, claimTaskTool, completeTaskTool];
266
+ }
267
+ if (opts.subAgent) {
268
+ // Sub-agents don't get todo_write: the plan is the top-level agent's, and a
269
+ // sub-agent writing to the shared list would clobber it mid-task.
270
+ return builtins.filter((t) => !(t.type === "function" && t.function.name === "todo_write"));
271
+ }
272
+ // The Lead can do everything: orchestration, the protocols, and authoring +
273
+ // watching the board (it lays out tasks; teammates pull from it).
274
+ return [...builtins, taskTool, askUserTool, spawnTeammateTool, sendMessageTool, requestShutdownTool, requestPlanTool, reviewPlanTool, createTaskTool, listTasksTool];
275
+ }
52
276
  // Run one sub-agent: a fresh conversation, same tools minus task, same
53
277
  // permission gate, and the parent's file read-state protected by a snapshot.
54
278
  async function runSubAgent(description, opts) {
55
279
  emit("agent_subagent_spawn"); // delegation is worth counting
280
+ const subModel = subAgentModelFor(opts); // the tier this delegated work runs on
281
+ // Show the delegation, and the model when it differs from the orchestrator's —
282
+ // the tiering should be visible, not a silent surprise on the bill.
56
283
  if (!opts.quiet)
57
- console.log(chalk.blue(` ⎿ sub-agent started: ${description.slice(0, 100)}`)); // show the delegation
284
+ console.log(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
285
+ await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
58
286
  const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
59
287
  try {
60
288
  const subMessages = [
61
289
  { role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
62
290
  { role: "user", content: description }, // the task is its entire world
63
291
  ];
64
- const result = await runLoop(subMessages, { ...opts, subAgent: true }); // recurse, flagged as sub-agent
292
+ const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel }); // recurse as a sub-agent, on its own tier
65
293
  if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
66
294
  // The framing matters: the parent must treat this as material to verify,
67
295
  // not as conclusions to copy — the most common multi-agent failure mode.
@@ -71,8 +299,9 @@ async function runSubAgent(description, opts) {
71
299
  }
72
300
  finally {
73
301
  restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
302
+ await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
74
303
  if (!opts.quiet)
75
- console.log(chalk.blue(" ⎿ sub-agent done")); // close the bracket
304
+ console.log(mark.subAgentDone); // close the bracket
76
305
  }
77
306
  }
78
307
  // Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
@@ -89,21 +318,28 @@ async function interruptibleSleep(ms, isInterrupted) {
89
318
  await new Promise((r) => setTimeout(r, Math.min(SLICE, ms - waited))); // sleep one slice (or the remainder)
90
319
  }
91
320
  }
321
+ let modelCallSeq = 0; // increments per model call — seeds the rotating spinner word
92
322
  // One streaming model call: prints text as it arrives, assembles tool calls
93
323
  // from their deltas, and guards the stream with a two-level watchdog.
94
324
  async function streamModelCall(messages, // the full history to send
95
325
  opts) {
96
326
  const idleAbort = new AbortController(); // the watchdog's own kill switch
97
327
  const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
328
+ const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
329
+ const startedAt = Date.now(); // for the spinner's live elapsed counter
330
+ let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
98
331
  const spinner = opts.quiet
99
332
  ? null // the eval harness wants silence
100
- : ora({ text: opts.subAgent ? "sub-agent working..." : "thinking...", discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
333
+ : ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
101
334
  let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
102
335
  let stallWarned = false; // only warn once per quiet stretch
103
336
  // The two-level watchdog, checked once per second:
104
337
  // - idle (nothing at all for 90s) → cut the stream, let the retry layer handle it
105
338
  // - stall (slow but alive for 30s) → log it and keep waiting
339
+ // It also ticks the spinner: elapsed seconds + a live token estimate (~chars/4).
106
340
  const watchdog = setInterval(() => {
341
+ if (spinner?.isSpinning)
342
+ spinner.text = spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
107
343
  const quietMs = Date.now() - lastEvent; // ms since the last event
108
344
  if (quietMs > IDLE_TIMEOUT_MS) {
109
345
  emit("agent_watchdog_idle"); // record the cut — these should be rare
@@ -120,29 +356,62 @@ opts) {
120
356
  const stream = await opts.client.chat.completions.create(
121
357
  // Sub-agents do NOT get the task tool: one level of delegation only.
122
358
  // Nested spawning means orphan processes and debugging hell.
123
- { model: opts.model, messages, tools: opts.subAgent ? toolDefinitions : [...toolDefinitions, taskTool], stream: true }, { signal });
359
+ // include_usage adds a final chunk carrying token counts — that is how we
360
+ // meter cost without a second (counting) request.
361
+ // The tool manual depends on the agent kind (Lead / teammate / sub-agent)
362
+ // — see toolsFor(). This is also where nested spawning is prevented: a
363
+ // teammate's manual simply omits spawn_teammate and task.
364
+ { model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
124
365
  let content = ""; // accumulated answer text
125
- let printedPrefix = false; // have we printed the 🤖 prefix yet?
366
+ let printedPrefix = false; // have we printed the answer prefix yet?
367
+ let printedThinking = false; // have we started printing the reasoning trace yet?
368
+ let md = null; // renders the streamed answer as markdown, block by block
126
369
  const calls = []; // tool calls under assembly, indexed by delta.index
127
370
  for await (const chunk of stream) {
128
371
  lastEvent = Date.now(); // feed the watchdog
129
372
  stallWarned = false; // the stream spoke — reset the stall warning
373
+ if (chunk.usage)
374
+ recordUsage(chunk.usage); // the final usage chunk — meter it
130
375
  const delta = chunk.choices[0]?.delta; // this chunk's increment
131
376
  if (!delta)
132
377
  continue; // keep-alive or usage chunk — nothing to do
378
+ // Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
379
+ // separate `reasoning_content` field BEFORE the answer. Show it dimly so a
380
+ // switch to a reasoning model is visibly different — but do NOT keep it:
381
+ // the reasoning is not the answer and must not be replayed in later turns.
382
+ const reasoning = delta.reasoning_content;
383
+ if (reasoning) {
384
+ streamedChars += reasoning.length; // count reasoning toward the live token estimate
385
+ if (spinner?.isSpinning)
386
+ spinner.stop();
387
+ if (!opts.quiet && !opts.subAgent) {
388
+ if (!printedThinking) {
389
+ process.stdout.write("\n" + chalk.dim("💭 thinking: ")); // label the trace once
390
+ printedThinking = true;
391
+ }
392
+ process.stdout.write(chalk.dim(reasoning)); // stream the thinking, dimmed
393
+ }
394
+ }
133
395
  if (delta.content) {
396
+ streamedChars += delta.content.length; // count the answer toward the live token estimate
134
397
  if (spinner?.isSpinning)
135
398
  spinner.stop(); // first token: replace the spinner with real output
136
399
  if (!opts.quiet && !opts.subAgent) {
137
400
  // Only the top-level agent streams to the screen — a sub-agent's
138
401
  // inner monologue would be mistaken for the answer.
402
+ if (printedThinking && !printedPrefix)
403
+ process.stdout.write("\n"); // end the thinking block before the answer
139
404
  if (!printedPrefix) {
140
- process.stdout.write("\n🤖 "); // prefix once, then stream raw
405
+ // Stream the answer through the markdown renderer: it buffers a block
406
+ // (paragraph / heading / list / table) and prints it formatted the
407
+ // moment that block completes — still live, but no raw `##`/`|---|`.
408
+ // The ⏺ marker leads the first line; wrapped lines align under it.
409
+ md = new MarkdownStream((s) => process.stdout.write(s), { firstPrefix: "\n" + mark.answer, indent: " " });
141
410
  printedPrefix = true;
142
411
  }
143
- process.stdout.write(delta.content); // print the token immediately — this IS the streaming UX
412
+ md.push(delta.content); // hand the token to the renderer (it decides when to paint)
144
413
  }
145
- content += delta.content; // always keep it for the history
414
+ content += delta.content; // always keep it for the history (the answer only, never the reasoning)
146
415
  }
147
416
  for (const tc of delta.tool_calls ?? []) {
148
417
  if (spinner?.isSpinning)
@@ -156,6 +425,8 @@ opts) {
156
425
  slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
157
426
  }
158
427
  }
428
+ if (md)
429
+ md.end(); // flush the final (un-terminated) block through the renderer
159
430
  if (printedPrefix)
160
431
  process.stdout.write("\n"); // end the streamed line cleanly
161
432
  return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
@@ -171,25 +442,53 @@ opts) {
171
442
  // calls (allow/deny only, never "ask") are safe to run via this from inside a
172
443
  // Promise.all batch; calls that can prompt must be awaited one at a time.
173
444
  async function runOneCall(call, opts) {
174
- const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
175
- if (!opts.quiet)
176
- console.log(indent + chalk.cyan(`🔧 ${call.name}`) + chalk.dim(` ${call.args.slice(0, 120)}`)); // show what the model wants to do
445
+ const indent = opts.teammate ? chalk.magenta(` ⎿ [${opts.teammate.name}] `) : opts.subAgent ? chalk.blue(" ⎿ ") : ""; // teammate activity is tagged by name; plain sub-agent is nested in blue
446
+ if (!opts.quiet) {
447
+ // Compact one-line summary — long args are hidden behind Tab.
448
+ const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
449
+ const full = `tool: ${call.name}\nargs: ${call.args}`;
450
+ printToolSummary(summary, full);
451
+ }
177
452
  // The permission gate sits between the model's intent and execution.
178
453
  const v = checkPermission(call.name, call.args);
179
454
  emit("agent_tool_call", { tool: call.name }); // every attempt, allowed or not
455
+ // For file-changing tools, show the diff BEFORE the gate decides. A filename
456
+ // ("edit_file: cart.js") is not enough to approve safely; the actual +/- lines
457
+ // are. Skipped for a hard deny (nothing will run) and in quiet sub-agent runs.
458
+ if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
459
+ const preview = previewChange(call.name, call.args);
460
+ if (preview)
461
+ console.log(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
462
+ }
180
463
  let content; // what goes back to the model as the tool result
181
464
  if (v.decision === "deny") {
182
465
  emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
183
466
  if (!opts.quiet)
184
- console.log(chalk.red(` ⛔ denied: ${v.reason}`)); // tell the user we blocked it
467
+ console.log(mark.denied(v.reason)); // tell the user we blocked it
185
468
  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
186
469
  }
187
470
  else if (v.decision === "ask") {
188
- const ok = await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`); // pause and ask the human
471
+ // Optional LLM judge: for a run_bash command the rules couldn't classify,
472
+ // ask the judge first. It can only DOWNGRADE ask→allow for the clearly
473
+ // safe; anything else still goes to the human. The judge never sees a deny.
474
+ let autoAllowed = false;
475
+ if (opts.judge && call.name === "run_bash") {
476
+ let cmd = "";
477
+ try {
478
+ cmd = JSON.parse(call.args).command ?? "";
479
+ }
480
+ catch { /* leave empty → judge will ask */ }
481
+ if (cmd && (await opts.judge.classify(cmd)) === "allow") {
482
+ autoAllowed = true;
483
+ if (!opts.quiet)
484
+ console.log(mark.judge); // visible — the user can see the judge worked
485
+ }
486
+ }
487
+ const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
189
488
  if (!ok)
190
489
  emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
191
490
  if (!ok && !opts.quiet)
192
- console.log(chalk.yellow(" ✋ declined")); // make the refusal visible
491
+ console.log(mark.declined); // make the refusal visible
193
492
  content = ok
194
493
  ? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
195
494
  : `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
@@ -197,6 +496,11 @@ async function runOneCall(call, opts) {
197
496
  else {
198
497
  content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
199
498
  }
499
+ // todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
500
+ // the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
501
+ if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
502
+ console.log(renderTodos(getTodos()));
503
+ }
200
504
  return { id: call.id, content }; // paired by id for the API
201
505
  }
202
506
  // Run an approved tool call, surrounded by the user's lifecycle hooks.
@@ -213,9 +517,23 @@ async function runWithHooks(call, opts) {
213
517
  if (pre.block) {
214
518
  emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
215
519
  if (!opts.quiet)
216
- console.log(chalk.red(` ⛔ blocked by PreToolUse hook`)); // make it visible
520
+ console.log(mark.hookBlock("PreToolUse")); // make it visible
217
521
  return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
218
522
  }
523
+ // A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
524
+ // permission gate already ran on the original args; the rewrite only narrows or
525
+ // annotates, never escalates past a deny. Validate it's parseable, then use it.
526
+ if (pre.rewrite) {
527
+ try {
528
+ JSON.parse(pre.rewrite); // must be valid JSON args
529
+ if (!opts.quiet)
530
+ console.log(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
531
+ call = { ...call, args: pre.rewrite };
532
+ }
533
+ catch {
534
+ /* malformed rewrite — ignore, run the original args */
535
+ }
536
+ }
219
537
  const result = await execute(call, opts); // the actual work
220
538
  const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
221
539
  if (post.block) {
@@ -228,6 +546,33 @@ async function runWithHooks(call, opts) {
228
546
  // Execute one approved tool call. The task tool is special — it is not in the
229
547
  // registry because running it needs the loop itself (it IS a loop).
230
548
  async function execute(call, opts) {
549
+ // ask_user is special like task: running it needs the CLI's prompt, not the
550
+ // tool registry. It presents a form and feeds the selections back to the model.
551
+ if (call.name === "ask_user")
552
+ return runAskUser(call, opts);
553
+ // The team tools (Day 38/39) are loop-level like task: they need the loop and
554
+ // the shared mailbox + protocol state, not the tool registry.
555
+ if (call.name === "send_message")
556
+ return runSendMessage(call, opts);
557
+ if (call.name === "spawn_teammate")
558
+ return runSpawnTeammate(call, opts);
559
+ if (call.name === "request_shutdown")
560
+ return runRequestShutdown(call, opts);
561
+ if (call.name === "request_plan")
562
+ return runRequestPlan(call, opts);
563
+ if (call.name === "review_plan")
564
+ return runReviewPlan(call, opts);
565
+ if (call.name === "submit_plan")
566
+ return runSubmitPlan(call, opts);
567
+ // The task board tools (Day 40) — claim/complete need the caller's identity.
568
+ if (call.name === "create_task")
569
+ return runCreateTask(call, opts);
570
+ if (call.name === "list_tasks")
571
+ return runListTasks(call, opts);
572
+ if (call.name === "claim_task")
573
+ return runClaimTask(call, opts);
574
+ if (call.name === "complete_task")
575
+ return runCompleteTask(call, opts);
231
576
  if (call.name !== "task")
232
577
  return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
233
578
  if (opts.subAgent)
@@ -243,6 +588,305 @@ async function execute(call, opts) {
243
588
  return "[error] task requires a non-empty description argument.";
244
589
  return runSubAgent(description, opts); // spawn the worker
245
590
  }
591
+ // The sender's mailbox name: a teammate sends as itself, everyone else as "lead".
592
+ const senderName = (opts) => opts.teammate?.name ?? LEAD;
593
+ // send_message: deliver one message to another agent's inbox. Available to the
594
+ // Lead and to teammates. Pure coordination — it never blocks on a reply.
595
+ async function runSendMessage(call, opts) {
596
+ let to = "", content = "";
597
+ try {
598
+ const a = JSON.parse(call.args);
599
+ to = (a.to ?? "").trim();
600
+ content = a.content ?? "";
601
+ }
602
+ catch {
603
+ /* fall through */
604
+ }
605
+ if (!to || !content.trim())
606
+ return "[error] send_message needs a non-empty 'to' and 'content'.";
607
+ const from = senderName(opts);
608
+ if (to === from)
609
+ return "[error] You cannot message yourself.";
610
+ // A teammate can only reach the lead or another teammate; the lead can reach
611
+ // any teammate. We don't hard-check the recipient exists (it may be spawning) —
612
+ // an undeliverable message just sits in a file no one reads, which is harmless.
613
+ sendMessage(from, to, content);
614
+ return `Message delivered to ${to}'s inbox.`;
615
+ }
616
+ // request_shutdown (lead → teammate): open a shutdown contract. We stamp the
617
+ // teammate with the request_id (it replies with that id and exits) AND send the
618
+ // request message so it shows up in the teammate's inbox and conversation.
619
+ async function runRequestShutdown(call, opts) {
620
+ if (opts.teammate || opts.subAgent)
621
+ return "[error] Only the lead can request a shutdown.";
622
+ let teammate = "", reason = "";
623
+ try {
624
+ const a = JSON.parse(call.args);
625
+ teammate = (a.teammate ?? "").trim();
626
+ reason = a.reason ?? "";
627
+ }
628
+ catch { /* fall through */ }
629
+ if (!teammate)
630
+ return "[error] request_shutdown needs a 'teammate' name.";
631
+ if (!teammateExists(teammate))
632
+ return `[error] No teammate named "${teammate}".`;
633
+ if (shutdownRequestId(teammate))
634
+ return `Shutdown already requested for "${teammate}" — waiting for it to confirm and exit.`;
635
+ const requestId = createRequest("shutdown", LEAD, teammate, reason || "task complete");
636
+ markShutdown(teammate, requestId);
637
+ sendProtocol(LEAD, teammate, "shutdown_request", requestId, reason || "Your part is done — please wrap up and exit.");
638
+ return `Requested shutdown of "${teammate}" (${requestId}). It will finish up, confirm, and exit; you'll get the confirmation in your inbox.`;
639
+ }
640
+ // request_plan (lead → teammate): ask the teammate to plan-before-acting. This
641
+ // is a directive message; the teammate answers it by calling submit_plan, which
642
+ // opens the actual plan_approval contract.
643
+ async function runRequestPlan(call, opts) {
644
+ if (opts.teammate || opts.subAgent)
645
+ return "[error] Only the lead can request a plan.";
646
+ let teammate = "", task = "";
647
+ try {
648
+ const a = JSON.parse(call.args);
649
+ teammate = (a.teammate ?? "").trim();
650
+ task = a.task ?? "";
651
+ }
652
+ catch { /* fall through */ }
653
+ if (!teammate || !task.trim())
654
+ return "[error] request_plan needs a 'teammate' and a 'task'.";
655
+ if (!teammateExists(teammate))
656
+ return `[error] No teammate named "${teammate}".`;
657
+ sendMessage(LEAD, teammate, `Before doing this, call submit_plan with your plan and WAIT for my approval — do not start until approved:\n${task}`);
658
+ return `Asked "${teammate}" to submit a plan before doing: ${task.slice(0, 80)}. Approve/reject it with review_plan when it arrives.`;
659
+ }
660
+ // review_plan (lead): the answer half of the plan_approval contract. Correlate
661
+ // to the request via match_response (resolveResponse), then send the decision
662
+ // back to the teammate that submitted it.
663
+ async function runReviewPlan(call, opts) {
664
+ if (opts.teammate || opts.subAgent)
665
+ return "[error] Only the lead can review a plan.";
666
+ let requestId = "", decision = "", reason = "";
667
+ try {
668
+ const a = JSON.parse(call.args);
669
+ requestId = (a.request_id ?? "").trim();
670
+ decision = (a.decision ?? "").trim();
671
+ reason = a.reason ?? "";
672
+ }
673
+ catch { /* fall through */ }
674
+ if (!requestId || (decision !== "approve" && decision !== "reject"))
675
+ return '[error] review_plan needs a request_id and decision "approve" or "reject".';
676
+ const approved = decision === "approve";
677
+ const res = resolveResponse(requestId, "plan_approval", approved); // match_response: validates kind + state
678
+ if (!res.ok)
679
+ return `[error] ${res.error}`;
680
+ sendProtocol(LEAD, res.state.from, "plan_approval_response", requestId, reason || (approved ? "approved" : "rejected"), approved ? "approved" : "rejected");
681
+ return `Plan ${approved ? "approved" : "rejected"} (${requestId}); told ${res.state.from}.`;
682
+ }
683
+ // submit_plan (teammate → lead): open a plan_approval contract and STOP. The
684
+ // teammate idles after this until the lead's decision lands in its inbox.
685
+ async function runSubmitPlan(call, opts) {
686
+ if (!opts.teammate)
687
+ return "[error] Only a teammate can submit a plan (the lead reviews them).";
688
+ let plan = "";
689
+ try {
690
+ plan = JSON.parse(call.args).plan ?? "";
691
+ }
692
+ catch { /* fall through */ }
693
+ if (!plan.trim())
694
+ return "[error] submit_plan needs a non-empty 'plan'.";
695
+ const requestId = createRequest("plan_approval", opts.teammate.name, LEAD, plan);
696
+ sendProtocol(opts.teammate.name, LEAD, "plan_approval_request", requestId, plan);
697
+ return `Plan submitted to the lead for approval (${requestId}). STOP here and wait — the lead's decision will arrive in your inbox. Do NOT start the work until it is approved.`;
698
+ }
699
+ // ---- The task board tools (Day 40) ------------------------------------------
700
+ // The claimer's identity is the calling agent's name — the lead or a teammate.
701
+ const boardActor = (opts) => opts.teammate?.name ?? LEAD;
702
+ async function runCreateTask(call, opts) {
703
+ if (opts.subAgent && !opts.teammate)
704
+ return "[error] Only team agents use the task board."; // plain sub-agents have no board
705
+ let subject = "", description = "", blockedBy = [];
706
+ try {
707
+ const a = JSON.parse(call.args);
708
+ subject = (a.subject ?? "").trim();
709
+ description = a.description ?? "";
710
+ if (Array.isArray(a.blockedBy))
711
+ blockedBy = a.blockedBy.filter((x) => typeof x === "string");
712
+ }
713
+ catch { /* fall through */ }
714
+ if (!subject)
715
+ return "[error] create_task needs a 'subject'.";
716
+ const task = createTask(subject, description, blockedBy);
717
+ if (!opts.quiet)
718
+ console.log(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
719
+ return `Created ${task.id}: "${subject}"${blockedBy.length ? ` (blockedBy: ${blockedBy.join(", ")})` : ""}. It's on the board for a teammate to claim.`;
720
+ }
721
+ async function runListTasks(_call, opts) {
722
+ if (opts.subAgent && !opts.teammate)
723
+ return "[error] Only team agents use the task board.";
724
+ return `Task board:\n${boardSummary()}`;
725
+ }
726
+ async function runClaimTask(call, opts) {
727
+ if (opts.subAgent && !opts.teammate)
728
+ return "[error] Only team agents use the task board.";
729
+ let taskId = "";
730
+ try {
731
+ taskId = JSON.parse(call.args).task_id?.trim() ?? "";
732
+ }
733
+ catch { /* fall through */ }
734
+ if (!taskId)
735
+ return "[error] claim_task needs a 'task_id'.";
736
+ const res = claimTask(taskId, boardActor(opts));
737
+ if (!res.ok)
738
+ return `[error] ${res.error} Pick another task or check list_tasks.`;
739
+ return `Claimed ${res.task.id}: "${res.task.subject}". It's yours and in_progress. Do it, then call complete_task("${res.task.id}").\n${res.task.description}`;
740
+ }
741
+ async function runCompleteTask(call, opts) {
742
+ if (opts.subAgent && !opts.teammate)
743
+ return "[error] Only team agents use the task board.";
744
+ let taskId = "";
745
+ try {
746
+ taskId = JSON.parse(call.args).task_id?.trim() ?? "";
747
+ }
748
+ catch { /* fall through */ }
749
+ if (!taskId)
750
+ return "[error] complete_task needs a 'task_id'.";
751
+ const res = completeTask(taskId, boardActor(opts));
752
+ if (!res.ok)
753
+ return `[error] ${res.error}`;
754
+ if (!opts.quiet)
755
+ console.log(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
756
+ const unblocked = res.unblocked?.length ? ` Unblocked: ${res.unblocked.join(", ")}.` : "";
757
+ return `Completed ${taskId}.${unblocked} Claim the next ready task, or idle until the lead shuts you down.`;
758
+ }
759
+ // spawn_teammate: start a persistent worker that runs concurrently. Top-level
760
+ // only — a teammate calling this is the nested-spawn case (its manual omits the
761
+ // tool, but we guard anyway). Returns IMMEDIATELY; the teammate's loop runs in
762
+ // the background and reports back through the lead's inbox.
763
+ async function runSpawnTeammate(call, opts) {
764
+ if (opts.subAgent || opts.teammate)
765
+ return "[error] Only the lead can spawn teammates — teammates cannot spawn teammates. Do the work yourself or message the lead.";
766
+ let name = "", role = "", task = "";
767
+ try {
768
+ const a = JSON.parse(call.args);
769
+ name = (a.name ?? "").trim();
770
+ role = (a.role ?? "").trim();
771
+ task = a.task ?? "";
772
+ }
773
+ catch {
774
+ /* fall through */
775
+ }
776
+ if (!name || !role || !task.trim())
777
+ return "[error] spawn_teammate needs a non-empty name, role, and task.";
778
+ if (name === LEAD)
779
+ return `[error] "${LEAD}" is reserved for the coordinator — pick another name.`;
780
+ if (teammateExists(name))
781
+ return `[error] A teammate named "${name}" already exists — pick a unique name or message the existing one.`;
782
+ if (teammateCount() >= MAX_TEAMMATES)
783
+ return `[error] The team is full (${MAX_TEAMMATES} teammates max). Wait for one to finish, or do the work yourself.`;
784
+ // Kick off the teammate's loop WITHOUT awaiting it — that is what makes it
785
+ // concurrent. It progresses whenever the event loop is free (e.g. while the
786
+ // lead awaits its own API stream). We keep the promise so the lead and
787
+ // shutdown know when it has ended.
788
+ const done = runTeammate(name, role, task, opts);
789
+ registerTeammate(name, role, done);
790
+ if (!opts.quiet)
791
+ console.log(mark.teammateStart(name, role));
792
+ 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.`;
793
+ }
794
+ // Run one teammate to completion: a fresh conversation seeded with its task, a
795
+ // focused toolset, its own mailbox, and a hard round cap. On any ending it sends
796
+ // a `result` message back to the lead — so the lead always learns the outcome,
797
+ // success or failure. The teammate's file read-state is isolated by snapshot,
798
+ // exactly like a sub-agent (it reads things the lead's conversation never saw).
799
+ async function runTeammate(name, role, task, opts) {
800
+ emit("agent_teammate_spawn");
801
+ await runHooks("SubagentStart", { description: `teammate ${name}: ${role}`.slice(0, 200), model: opts.model });
802
+ const snapshot = snapshotFileState();
803
+ const system = TEAMMATE_PROMPT.replace("{name}", name).replace("{role}", role);
804
+ try {
805
+ const messages = [
806
+ { role: "system", content: system },
807
+ { role: "user", content: task }, // its first instruction; more arrive via the inbox
808
+ ];
809
+ // A teammate is a sub-agent (quiet streaming, no hooks, isolated) PLUS team
810
+ // wiring: a name, its inbox, a non-interactive permission policy (it cannot
811
+ // prompt the human, so writes auto-proceed but risky bash is declined — see
812
+ // teammateConfirm; deny ALWAYS still wins), and a runaway backstop. Day 39:
813
+ // it is no longer bounded by a low round cap — it idle-waits for work and
814
+ // exits on the shutdown handshake (continueForTeam); maxRounds is only a
815
+ // generous guard against an active loop that never stops calling tools.
816
+ const result = await runLoop(messages, {
817
+ ...opts,
818
+ subAgent: true,
819
+ teammate: { name },
820
+ maxRounds: TEAMMATE_MAX_ROUNDS,
821
+ confirm: teammateConfirm(name),
822
+ askUser: undefined, // teammates have no line to the human
823
+ });
824
+ // How it ended decides what the lead hears. A shutdown handshake gets a
825
+ // shutdown_response(approved) carrying the request_id; anything else is a
826
+ // plain result. Either way the lead always learns the outcome.
827
+ const summary = result.reason === TerminateReason.Done && result.finalText?.trim() ? result.finalText.trim() : `[ended: ${result.reason}]`;
828
+ const sd = shutdownRequestId(name);
829
+ if (sd)
830
+ sendProtocol(name, LEAD, "shutdown_response", sd, summary || "shut down", "approved");
831
+ else
832
+ sendMessage(name, LEAD, summary, "result");
833
+ finishTeammate(name, result.reason === TerminateReason.Done);
834
+ if (!opts.quiet)
835
+ console.log(mark.teammateDone(name, result.reason === TerminateReason.Done));
836
+ }
837
+ catch (err) {
838
+ // A teammate must never take the whole process down. Report the failure to
839
+ // the lead and mark it failed.
840
+ sendMessage(name, LEAD, `[teammate crashed: ${err.message}]`, "result");
841
+ finishTeammate(name, false);
842
+ }
843
+ finally {
844
+ restoreFileState(snapshot);
845
+ await runHooks("SubagentStop", { description: `teammate ${name}` });
846
+ }
847
+ }
848
+ const TEAMMATE_MAX_ROUNDS = 30; // runaway backstop for an active teammate (not the lifecycle — idle-wait + shutdown handshake end it normally)
849
+ const TEAM_POLL_MS = 250; // how often an idle agent polls its inbox while waiting
850
+ const TEAMMATE_MAX_IDLE_MS = 60_000; // an abandoned teammate self-exits after this long with nothing to do (the lead normally shuts it down first)
851
+ const SHUTDOWN_GRACE_MS = 30_000; // how long the lead waits for teammates to exit cleanly when it disbands the team
852
+ // A teammate's permission policy. It cannot stop and ask the human, so: file
853
+ // writes/edits auto-proceed (that is the teammate's job, and they are reversible
854
+ // via /undo and reviewable via /diff), but anything else the gate rated "ask"
855
+ // (unrecognized or dangerous bash) is DECLINED rather than run unattended. Hard
856
+ // DENY rules (the no-fly zone) are enforced by the gate before confirm is ever
857
+ // called, so they always hold. Production Claude Code instead "bubbles" the
858
+ // request up to the lead and on to the human — omitted here, like the reference.
859
+ function teammateConfirm(name) {
860
+ return async (_question, toolName) => {
861
+ const ok = toolName === "write_file" || toolName === "edit_file";
862
+ if (!ok)
863
+ console.log(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
864
+ return ok;
865
+ };
866
+ }
867
+ // Present the ask_user form and turn the selections into a tool result. The
868
+ // model called this to get a decision; we give it clean question→answer pairs.
869
+ async function runAskUser(call, opts) {
870
+ if (opts.subAgent)
871
+ return "[error] Sub-agents cannot ask the user. Decide yourself, or report back to the parent."; // only the top-level agent talks to the human
872
+ if (!opts.askUser)
873
+ return "[error] No interactive prompt is available in this session — ask in plain text instead.";
874
+ let questions = [];
875
+ try {
876
+ questions = JSON.parse(call.args).questions ?? [];
877
+ }
878
+ catch {
879
+ /* fall through */
880
+ }
881
+ // Keep only well-formed questions (text + at least two options).
882
+ questions = (questions ?? []).filter((q) => q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length >= 2);
883
+ if (!questions.length)
884
+ return "[error] ask_user needs at least one question with two or more options.";
885
+ const answers = await opts.askUser(questions);
886
+ if (!answers)
887
+ return "The user dismissed the question form without answering. Proceed with your best judgment, or ask again if you truly need their input."; // cancelled / non-interactive
888
+ return `The user answered:\n${answers.map((a) => `- ${a.question} → ${a.answer}`).join("\n")}`; // organized, unambiguous
889
+ }
246
890
  // Run one compaction attempt and keep score. Returns true on success.
247
891
  // Every automatic behavior keeps a failure count — that is what lets the
248
892
  // caller stop a hot failure loop instead of compacting forever.
@@ -250,10 +894,14 @@ async function tryCompact(messages, // history to compact (mutated in place)
250
894
  opts, // for client/model/signal
251
895
  compaction) {
252
896
  try {
897
+ if (!opts.subAgent)
898
+ await runHooks("PreCompact", {}); // lifecycle hook (top-level only; hooks are the human's project policy)
253
899
  await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
254
900
  compaction.count++; // one more successful compaction this query
255
901
  compaction.failures = 0; // success resets the failure streak
256
902
  emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
903
+ if (!opts.subAgent)
904
+ await runHooks("PostCompact", {}); // observational
257
905
  return true;
258
906
  }
259
907
  catch {
@@ -262,6 +910,152 @@ compaction) {
262
910
  return false;
263
911
  }
264
912
  }
913
+ // Surface any FINISHED background tasks (Day 37) as a user turn, so the model
914
+ // sees the result of work it kicked off earlier. Mirrors the tutorial reference,
915
+ // adapted to the OpenAI wire protocol: tool results are their own role:"tool"
916
+ // messages, so a notification can't ride inside them — it follows as a user
917
+ // message instead (exactly how the todo nag and Stop hook already inject turns).
918
+ // Top-level only: a sub-agent has no separate turn loop to inject into, and the
919
+ // notification belongs to the agent that started the job. Returns true if it
920
+ // injected something. Each finished task notifies exactly once (background.ts).
921
+ function injectBackgroundNotifications(messages, opts) {
922
+ if (opts.subAgent)
923
+ return false; // notifications belong to the top-level conversation
924
+ const note = pendingNotifications(); // "" unless a task finished since we last checked
925
+ if (!note)
926
+ return false;
927
+ const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
928
+ messages.push({ role: "user", content: note }); // the model reacts to it on the next round
929
+ if (!opts.quiet)
930
+ console.log(mark.bgNote(count));
931
+ return true;
932
+ }
933
+ // Consume this agent's mailbox into its conversation, ROUTING protocol messages
934
+ // (Day 39) as it goes — the reference's consume_lead_inbox + dispatch_message in
935
+ // one pass. The lead reads "lead"; a teammate reads its own name. The read is
936
+ // consumptive (team.ts), so each message is handled exactly once. Protocol
937
+ // messages update state (correlate responses via match_response, flag a shutdown)
938
+ // AND get a plain-language line so the model can react; plain chat is passed
939
+ // through. Returns whether anything was injected. (Plan-approval is a handshake,
940
+ // not a hard gate: the teammate is TOLD to wait and does — execution gating by
941
+ // permission mode is what production Claude Code adds; omitted here, per s16.)
942
+ function consumeInbox(messages, opts) {
943
+ if (opts.subAgent && !opts.teammate)
944
+ return false; // a plain sub-agent is not on the team
945
+ const me = opts.teammate?.name ?? LEAD;
946
+ const msgs = readInbox(me);
947
+ if (!msgs.length)
948
+ return false;
949
+ const lines = [];
950
+ for (const m of msgs) {
951
+ switch (m.kind) {
952
+ case "shutdown_request": // (teammate side) the lead asked us to shut down
953
+ markShutdown(me, m.requestId ?? "");
954
+ lines.push(`[lead] SHUTDOWN REQUESTED (${m.requestId}): ${m.content}. Finish any in-progress write, then you will exit cleanly.`);
955
+ break;
956
+ case "shutdown_response": // (lead side) a teammate confirmed it shut down
957
+ if (m.requestId)
958
+ resolveResponse(m.requestId, "shutdown", m.status === "approved");
959
+ finishTeammate(m.from, true);
960
+ lines.push(`[${m.from}] confirmed shutdown (${m.requestId}) and has exited.`);
961
+ break;
962
+ case "plan_approval_request": // (lead side) a teammate wants approval before risky work
963
+ lines.push(`[${m.from}] PLAN APPROVAL REQUESTED (${m.requestId}). Review it, then call review_plan with this id. Plan:\n${m.content}`);
964
+ break;
965
+ case "plan_approval_response": { // (teammate side) the lead decided on our plan
966
+ if (m.requestId)
967
+ resolveResponse(m.requestId, "plan_approval", m.status === "approved");
968
+ const ok = m.status === "approved";
969
+ lines.push(`[lead] plan ${ok ? "APPROVED" : "REJECTED"} (${m.requestId})${m.content ? `: ${m.content}` : ""}. ${ok ? "Proceed with the plan now." : "Revise it and submit_plan again."}`);
970
+ break;
971
+ }
972
+ default: // plain chat / a teammate's result
973
+ lines.push(`[message from ${m.from}${m.type === "result" ? " · RESULT" : ""}] ${m.content}`);
974
+ }
975
+ }
976
+ const header = opts.teammate ? "Messages from your team — read and act on them:" : "Team inbox — react to these (RESULT = that teammate finished a part):";
977
+ messages.push({ role: "user", content: `${header}\n\n${lines.join("\n\n")}` });
978
+ if (!opts.quiet)
979
+ console.log(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
980
+ return true;
981
+ }
982
+ // At a stop-point (no tool calls), decide whether the TEAM keeps this agent's
983
+ // conversation alive (Day 38/39).
984
+ // - A teammate now IDLE-LOOPS instead of exiting: it processes its inbox, and
985
+ // if there's nothing to do it waits (yielding the event loop) for new work —
986
+ // bounded, so an abandoned teammate self-exits instead of hanging forever. It
987
+ // leaves only on the shutdown handshake (its request_id is set) or that
988
+ // backstop. While waiting it marks itself "idle" so the lead can tell the
989
+ // team is quiescent.
990
+ // - The lead waits while any teammate is actively working, then drains its
991
+ // inbox. When the team has gone quiet and nothing is waiting, it returns
992
+ // false → the lead finishes (and disbands the team, see runLoop).
993
+ async function continueForTeam(messages, opts) {
994
+ if (opts.teammate) {
995
+ const me = opts.teammate.name;
996
+ let waited = 0;
997
+ while (true) {
998
+ const injected = consumeInbox(messages, opts); // INBOX FIRST (priority) — route protocol + plain; may set the shutdown flag
999
+ if (shutdownRequestId(me))
1000
+ return false; // shutdown handshake → exit (runTeammate sends the response)
1001
+ if (injected) {
1002
+ setTeammateState(me, "active");
1003
+ return true;
1004
+ } // a direct message arrived → run a round
1005
+ // s17 autonomy: nothing in the inbox — pull ready work off the shared board
1006
+ // yourself. claimNextAvailable atomically claims the next pending+unblocked
1007
+ // task; the claim is what makes this safe with several teammates scanning.
1008
+ const claimed = claimNextAvailable(me);
1009
+ if (claimed) {
1010
+ // The claimed task becomes a fresh WORK instruction. The leading identity
1011
+ // line also re-anchors the persona if the history was compacted (the
1012
+ // reference's identity-reinjection, folded into the work hand-off).
1013
+ messages.push({ role: "user", content: `You are ${me}. You claimed task ${claimed.id} from the board: "${claimed.subject}".\n${claimed.description}\nWhen it is done and verified, call complete_task("${claimed.id}") — then you'll be offered the next ready task.` });
1014
+ if (!opts.quiet)
1015
+ console.log(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
1016
+ setTeammateState(me, "active");
1017
+ return true;
1018
+ }
1019
+ setTeammateState(me, "idle"); // inbox empty AND no claimable work — wait
1020
+ if (opts.isInterrupted() || waited >= TEAMMATE_MAX_IDLE_MS)
1021
+ return false; // abandoned → self-exit backstop
1022
+ await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted);
1023
+ waited += TEAM_POLL_MS;
1024
+ }
1025
+ }
1026
+ if (opts.subAgent)
1027
+ return false; // a plain sub-agent has no team
1028
+ // The lead: wait for the team to act rather than spinning or ending early.
1029
+ while (inboxCount(LEAD) === 0 && anyTeammateBusy() && !opts.isInterrupted()) {
1030
+ await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted); // yield to the teammates; they message us when they have something
1031
+ }
1032
+ return consumeInbox(messages, opts); // process whatever is waiting; false if the team is quiescent (→ the lead may finish)
1033
+ }
1034
+ // The lead finished its work but teammates may still be idling. Disband the team
1035
+ // GRACEFULLY (Day 39): open a shutdown contract with each, let them confirm and
1036
+ // exit (bounded by a grace window so a wedged teammate can't hang the prompt),
1037
+ // then drain the lead's inbox of their confirmations and clear the team.
1038
+ async function shutdownTeam(opts) {
1039
+ const running = runningTeammates();
1040
+ if (!running.length)
1041
+ return;
1042
+ if (!opts.quiet)
1043
+ console.log(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
1044
+ for (const name of running) {
1045
+ const id = createRequest("shutdown", LEAD, name, "task complete");
1046
+ markShutdown(name, id);
1047
+ sendProtocol(LEAD, name, "shutdown_request", id, "The task is complete — wrap up and exit.");
1048
+ }
1049
+ // Yield the event loop so the teammates wake, confirm, and exit — but never
1050
+ // block the human forever on a stuck one.
1051
+ const start = Date.now();
1052
+ while (runningTeammates().length && Date.now() - start < SHUTDOWN_GRACE_MS && !opts.isInterrupted()) {
1053
+ await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted);
1054
+ }
1055
+ readInbox(LEAD); // drop the shutdown confirmations the lead won't read
1056
+ resetTeam(); // clear the registry + protocol state; the next task starts with a fresh team
1057
+ resetBoard(); // and a fresh task board — its work is done with this team
1058
+ }
265
1059
  // The agent main loop, as a state machine: every iteration either continues
266
1060
  // for a named reason or terminates for a named reason — nothing implicit.
267
1061
  export async function runLoop(messages, // conversation history (mutated in place)
@@ -269,7 +1063,14 @@ opts) {
269
1063
  // The loop's mutable state: budgets and counters, rewritten every iteration.
270
1064
  const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
271
1065
  const compaction = { count: 0, failures: 0 }; // compaction score card for this query
272
- for (let round = 1; round <= MAX_ROUNDS; round++) {
1066
+ let lastText = null; // most recent assistant text — what we return if a round cap (teammates) stops us mid-flight
1067
+ for (let round = 1;; round++) {
1068
+ // A bounded loop (teammates, Day 38) stops cleanly at its round cap with
1069
+ // whatever it last said, so a team can never run away. Unset maxRounds keeps
1070
+ // the usual unbounded-with-safeguards loop for the lead and the user's agent.
1071
+ if (opts.maxRounds && round > opts.maxRounds) {
1072
+ return { reason: TerminateReason.Done, finalText: lastText ?? `(reached the ${opts.maxRounds}-round limit)` };
1073
+ }
273
1074
  // One round = one successful model call + its tool results.
274
1075
  // The inner loop retries the model call until it succeeds or a budget dies.
275
1076
  while (true) {
@@ -344,6 +1145,8 @@ opts) {
344
1145
  ? { tool_calls: out.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) }
345
1146
  : {}), // omit the field entirely when there were no calls
346
1147
  });
1148
+ if (out.content)
1149
+ lastText = out.content; // remember the latest answer text — the round-cap path returns it
347
1150
  if (!out.toolCalls.length) {
348
1151
  // The model wants to stop. A Stop hook gets the last word: if it exits
349
1152
  // 2, the agent is NOT done — its stderr becomes a new instruction and
@@ -357,9 +1160,25 @@ opts) {
357
1160
  if (!opts.quiet)
358
1161
  console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
359
1162
  messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
360
- break; // exit the inner while → advance the round counter (so a stubborn Stop hook is still bounded by MAX_ROUNDS)
1163
+ break; // exit the inner while → advance the round counter
361
1164
  }
362
1165
  }
1166
+ // A background task may have finished in the very round the model decided
1167
+ // to stop. Don't return with an unread result on the table: surface it and
1168
+ // run one more round so the model can react. Each task notifies once, so
1169
+ // this adds at most one round per finished task — never an infinite stop.
1170
+ if (injectBackgroundNotifications(messages, opts))
1171
+ break; // exit the inner while → advance the round counter
1172
+ // Agent teams (Day 38/39): the lead waits for teammate messages and
1173
+ // reacts; a teammate idle-loops for new work and leaves only on the
1174
+ // shutdown handshake (or its idle backstop). If there's something to
1175
+ // process, run another round instead of stopping.
1176
+ if (await continueForTeam(messages, opts))
1177
+ break; // exit the inner while → advance the round counter
1178
+ // The lead is really finishing: disband any teammates still idling so
1179
+ // they exit cleanly (Day 39) instead of being orphaned.
1180
+ if (!opts.subAgent && !opts.teammate)
1181
+ await shutdownTeam(opts);
363
1182
  return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
364
1183
  }
365
1184
  // Execute the tool calls. Read-only calls (read_file/search) in a
@@ -384,8 +1203,29 @@ opts) {
384
1203
  const r = await runOneCall(out.toolCalls[i++], opts);
385
1204
  messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
386
1205
  }
1206
+ // Background tasks (Day 37): if a job the model started has finished, slip
1207
+ // its <task_notification> in here, right after this round's tool results —
1208
+ // the model sees the outcome on the next round without ever having blocked
1209
+ // on it. Tasks that are still running stay silent until they finish.
1210
+ injectBackgroundNotifications(messages, opts);
1211
+ // Agent teams (Day 38/39): drain any messages that arrived during this
1212
+ // round (results, hand-offs, protocol requests/responses) so the agent
1213
+ // reacts to them next round. A shutdown_request seen here just flags the
1214
+ // teammate; it acts on it at the next stop-point (continueForTeam).
1215
+ consumeInbox(messages, opts);
1216
+ // The nag: if there's an unfinished plan the model has stopped touching for
1217
+ // a few rounds, slip in a reminder so the list doesn't go stale and
1218
+ // meaningless. Top-level only — a sub-agent has no plan of its own.
1219
+ if (!opts.subAgent) {
1220
+ const nag = todoNag();
1221
+ if (nag) {
1222
+ messages.push({ role: "user", content: nag });
1223
+ if (!opts.quiet)
1224
+ console.log(chalk.dim(" ⎿ plan reminder injected"));
1225
+ }
1226
+ }
387
1227
  break; // round complete, move to the next one
388
1228
  }
389
1229
  }
390
- return { reason: TerminateReason.RoundCap }; // ran out of rounds before a final answer
1230
+ // loop is unbounded — other safeguards (circuit breaker, retry budget, compaction guard) still apply
391
1231
  }