agent-from-zero 0.27.0 → 0.29.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
@@ -5,12 +5,17 @@ import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
5
5
  import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
6
6
  import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
7
7
  import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
8
- 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
9
11
  import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
10
12
  import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
11
13
  import { recordUsage } from "./cost.js"; // meter token usage from the stream
12
14
  import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
13
- import { printToolSummary } from "./tui.js"; // collapsible tool output
15
+ import { printToolSummary, recordReasoning } from "./tui.js"; // collapsible tool output + the model's hidden thinking (Ctrl+R)
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
14
19
  export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
15
20
  export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
16
21
  export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
@@ -87,6 +92,187 @@ If the user cancels, you'll be told — then proceed with your best judgment or
87
92
  },
88
93
  },
89
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
+ }
90
276
  // Run one sub-agent: a fresh conversation, same tools minus task, same
91
277
  // permission gate, and the parent's file read-state protected by a snapshot.
92
278
  async function runSubAgent(description, opts) {
@@ -142,6 +328,11 @@ opts) {
142
328
  const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
143
329
  const startedAt = Date.now(); // for the spinner's live elapsed counter
144
330
  let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
331
+ // Reasoning is COLLAPSED by default: while the model thinks, the spinner keeps
332
+ // spinning (with a 💭 hint) instead of dumping the trace; we stash it for Ctrl+R.
333
+ let reasoningBuf = ""; // the accumulated thinking, hidden behind the spinner
334
+ let reasoningActive = false; // true while reasoning tokens are arriving → spinner shows 💭
335
+ let reasoningShown = false; // have we printed the "thought for Ns" indicator yet?
145
336
  const spinner = opts.quiet
146
337
  ? null // the eval harness wants silence
147
338
  : ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
@@ -152,8 +343,10 @@ opts) {
152
343
  // - stall (slow but alive for 30s) → log it and keep waiting
153
344
  // It also ticks the spinner: elapsed seconds + a live token estimate (~chars/4).
154
345
  const watchdog = setInterval(() => {
346
+ // While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
347
+ // reads differently from "it's waiting on the first token".
155
348
  if (spinner?.isSpinning)
156
- spinner.text = spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
349
+ spinner.text = (reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
157
350
  const quietMs = Date.now() - lastEvent; // ms since the last event
158
351
  if (quietMs > IDLE_TIMEOUT_MS) {
159
352
  emit("agent_watchdog_idle"); // record the cut — these should be rare
@@ -172,11 +365,33 @@ opts) {
172
365
  // Nested spawning means orphan processes and debugging hell.
173
366
  // include_usage adds a final chunk carrying token counts — that is how we
174
367
  // meter cost without a second (counting) request.
175
- { model: opts.model, messages, tools: opts.subAgent ? toolDefinitions() : [...toolDefinitions(), taskTool, askUserTool], stream: true, stream_options: { include_usage: true } }, { signal });
368
+ // The tool manual depends on the agent kind (Lead / teammate / sub-agent)
369
+ // — see toolsFor(). This is also where nested spawning is prevented: a
370
+ // teammate's manual simply omits spawn_teammate and task.
371
+ { model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
176
372
  let content = ""; // accumulated answer text
177
373
  let printedPrefix = false; // have we printed the answer prefix yet?
178
- let printedThinking = false; // have we started printing the reasoning trace yet?
374
+ let md = null; // renders the streamed answer as markdown, block by block
179
375
  const calls = []; // tool calls under assembly, indexed by delta.index
376
+ // The model has finished thinking and is about to say/do something. Close out
377
+ // the spinner, stash the trace, and print a single one-line indicator with the
378
+ // shortcut to reveal it. Idempotent + collapses to nothing for sub-agents and
379
+ // the eval harness (they never show thinking).
380
+ const flushReasoning = () => {
381
+ if (!reasoningBuf || reasoningShown)
382
+ return;
383
+ reasoningShown = true;
384
+ reasoningActive = false;
385
+ if (spinner?.isSpinning)
386
+ spinner.stop();
387
+ if (opts.quiet || opts.subAgent)
388
+ return; // no UI for silent / nested runs
389
+ recordReasoning(reasoningBuf); // keep it for Ctrl+R (never goes into history)
390
+ const secs = Math.round((Date.now() - startedAt) / 1000);
391
+ const toks = Math.round(reasoningBuf.length / 4); // same ~chars/4 estimate as the spinner
392
+ const tokStr = toks >= 1000 ? `${(toks / 1000).toFixed(1)}k` : `${toks}`;
393
+ process.stdout.write(chalk.dim(`💭 thought for ${secs}s (~${tokStr} tokens) · Ctrl+R to view`) + "\n");
394
+ };
180
395
  for await (const chunk of stream) {
181
396
  lastEvent = Date.now(); // feed the watchdog
182
397
  stallWarned = false; // the stream spoke — reset the stall warning
@@ -186,39 +401,38 @@ opts) {
186
401
  if (!delta)
187
402
  continue; // keep-alive or usage chunk — nothing to do
188
403
  // Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
189
- // separate `reasoning_content` field BEFORE the answer. Show it dimly so a
190
- // switch to a reasoning model is visibly different — but do NOT keep it:
191
- // the reasoning is not the answer and must not be replayed in later turns.
404
+ // separate `reasoning_content` field BEFORE the answer. We do NOT print it
405
+ // (it buries the actual reply) and do NOT keep it (it must not be replayed
406
+ // in later turns): we let the spinner keep spinning, stash the trace, and
407
+ // surface it only behind Ctrl+R.
192
408
  const reasoning = delta.reasoning_content;
193
409
  if (reasoning) {
194
410
  streamedChars += reasoning.length; // count reasoning toward the live token estimate
195
- if (spinner?.isSpinning)
196
- spinner.stop();
197
- if (!opts.quiet && !opts.subAgent) {
198
- if (!printedThinking) {
199
- process.stdout.write("\n" + chalk.dim("💭 thinking: ")); // label the trace once
200
- printedThinking = true;
201
- }
202
- process.stdout.write(chalk.dim(reasoning)); // stream the thinking, dimmed
203
- }
411
+ reasoningBuf += reasoning; // stash it for Ctrl+R instead of streaming it
412
+ reasoningActive = true; // the spinner now shows 💭 (set by the watchdog)
204
413
  }
205
414
  if (delta.content) {
415
+ flushReasoning(); // thinking is over — show the one-line indicator first
206
416
  streamedChars += delta.content.length; // count the answer toward the live token estimate
207
417
  if (spinner?.isSpinning)
208
418
  spinner.stop(); // first token: replace the spinner with real output
209
419
  if (!opts.quiet && !opts.subAgent) {
210
420
  // Only the top-level agent streams to the screen — a sub-agent's
211
421
  // inner monologue would be mistaken for the answer.
212
- if (printedThinking && !printedPrefix)
213
- process.stdout.write("\n"); // end the thinking block before the answer
214
422
  if (!printedPrefix) {
215
- process.stdout.write("\n" + mark.answer); // prefix once (⏺), then stream raw
423
+ // Stream the answer through the markdown renderer: it buffers a block
424
+ // (paragraph / heading / list / table) and prints it formatted the
425
+ // moment that block completes — still live, but no raw `##`/`|---|`.
426
+ // The ⏺ marker leads the first line; wrapped lines align under it.
427
+ md = new MarkdownStream((s) => process.stdout.write(s), { firstPrefix: "\n" + mark.answer, indent: " " });
216
428
  printedPrefix = true;
217
429
  }
218
- process.stdout.write(delta.content); // print the token immediately — this IS the streaming UX
430
+ md.push(delta.content); // hand the token to the renderer (it decides when to paint)
219
431
  }
220
432
  content += delta.content; // always keep it for the history (the answer only, never the reasoning)
221
433
  }
434
+ if (delta.tool_calls?.length)
435
+ flushReasoning(); // thinking is over — indicator before the tool logs (which print after we return)
222
436
  for (const tc of delta.tool_calls ?? []) {
223
437
  if (spinner?.isSpinning)
224
438
  spinner.stop(); // tool call starting — spinner served its purpose
@@ -231,6 +445,9 @@ opts) {
231
445
  slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
232
446
  }
233
447
  }
448
+ flushReasoning(); // a reasoning-only turn (no content, no tools) still records + indicates
449
+ if (md)
450
+ md.end(); // flush the final (un-terminated) block through the renderer
234
451
  if (printedPrefix)
235
452
  process.stdout.write("\n"); // end the streamed line cleanly
236
453
  return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
@@ -246,7 +463,7 @@ opts) {
246
463
  // calls (allow/deny only, never "ask") are safe to run via this from inside a
247
464
  // Promise.all batch; calls that can prompt must be awaited one at a time.
248
465
  async function runOneCall(call, opts) {
249
- const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
466
+ 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
250
467
  if (!opts.quiet) {
251
468
  // Compact one-line summary — long args are hidden behind Tab.
252
469
  const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
@@ -300,6 +517,11 @@ async function runOneCall(call, opts) {
300
517
  else {
301
518
  content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
302
519
  }
520
+ // todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
521
+ // the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
522
+ if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
523
+ console.log(renderTodos(getTodos()));
524
+ }
303
525
  return { id: call.id, content }; // paired by id for the API
304
526
  }
305
527
  // Run an approved tool call, surrounded by the user's lifecycle hooks.
@@ -349,6 +571,29 @@ async function execute(call, opts) {
349
571
  // tool registry. It presents a form and feeds the selections back to the model.
350
572
  if (call.name === "ask_user")
351
573
  return runAskUser(call, opts);
574
+ // The team tools (Day 38/39) are loop-level like task: they need the loop and
575
+ // the shared mailbox + protocol state, not the tool registry.
576
+ if (call.name === "send_message")
577
+ return runSendMessage(call, opts);
578
+ if (call.name === "spawn_teammate")
579
+ return runSpawnTeammate(call, opts);
580
+ if (call.name === "request_shutdown")
581
+ return runRequestShutdown(call, opts);
582
+ if (call.name === "request_plan")
583
+ return runRequestPlan(call, opts);
584
+ if (call.name === "review_plan")
585
+ return runReviewPlan(call, opts);
586
+ if (call.name === "submit_plan")
587
+ return runSubmitPlan(call, opts);
588
+ // The task board tools (Day 40) — claim/complete need the caller's identity.
589
+ if (call.name === "create_task")
590
+ return runCreateTask(call, opts);
591
+ if (call.name === "list_tasks")
592
+ return runListTasks(call, opts);
593
+ if (call.name === "claim_task")
594
+ return runClaimTask(call, opts);
595
+ if (call.name === "complete_task")
596
+ return runCompleteTask(call, opts);
352
597
  if (call.name !== "task")
353
598
  return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
354
599
  if (opts.subAgent)
@@ -364,6 +609,282 @@ async function execute(call, opts) {
364
609
  return "[error] task requires a non-empty description argument.";
365
610
  return runSubAgent(description, opts); // spawn the worker
366
611
  }
612
+ // The sender's mailbox name: a teammate sends as itself, everyone else as "lead".
613
+ const senderName = (opts) => opts.teammate?.name ?? LEAD;
614
+ // send_message: deliver one message to another agent's inbox. Available to the
615
+ // Lead and to teammates. Pure coordination — it never blocks on a reply.
616
+ async function runSendMessage(call, opts) {
617
+ let to = "", content = "";
618
+ try {
619
+ const a = JSON.parse(call.args);
620
+ to = (a.to ?? "").trim();
621
+ content = a.content ?? "";
622
+ }
623
+ catch {
624
+ /* fall through */
625
+ }
626
+ if (!to || !content.trim())
627
+ return "[error] send_message needs a non-empty 'to' and 'content'.";
628
+ const from = senderName(opts);
629
+ if (to === from)
630
+ return "[error] You cannot message yourself.";
631
+ // A teammate can only reach the lead or another teammate; the lead can reach
632
+ // any teammate. We don't hard-check the recipient exists (it may be spawning) —
633
+ // an undeliverable message just sits in a file no one reads, which is harmless.
634
+ sendMessage(from, to, content);
635
+ return `Message delivered to ${to}'s inbox.`;
636
+ }
637
+ // request_shutdown (lead → teammate): open a shutdown contract. We stamp the
638
+ // teammate with the request_id (it replies with that id and exits) AND send the
639
+ // request message so it shows up in the teammate's inbox and conversation.
640
+ async function runRequestShutdown(call, opts) {
641
+ if (opts.teammate || opts.subAgent)
642
+ return "[error] Only the lead can request a shutdown.";
643
+ let teammate = "", reason = "";
644
+ try {
645
+ const a = JSON.parse(call.args);
646
+ teammate = (a.teammate ?? "").trim();
647
+ reason = a.reason ?? "";
648
+ }
649
+ catch { /* fall through */ }
650
+ if (!teammate)
651
+ return "[error] request_shutdown needs a 'teammate' name.";
652
+ if (!teammateExists(teammate))
653
+ return `[error] No teammate named "${teammate}".`;
654
+ if (shutdownRequestId(teammate))
655
+ return `Shutdown already requested for "${teammate}" — waiting for it to confirm and exit.`;
656
+ const requestId = createRequest("shutdown", LEAD, teammate, reason || "task complete");
657
+ markShutdown(teammate, requestId);
658
+ sendProtocol(LEAD, teammate, "shutdown_request", requestId, reason || "Your part is done — please wrap up and exit.");
659
+ return `Requested shutdown of "${teammate}" (${requestId}). It will finish up, confirm, and exit; you'll get the confirmation in your inbox.`;
660
+ }
661
+ // request_plan (lead → teammate): ask the teammate to plan-before-acting. This
662
+ // is a directive message; the teammate answers it by calling submit_plan, which
663
+ // opens the actual plan_approval contract.
664
+ async function runRequestPlan(call, opts) {
665
+ if (opts.teammate || opts.subAgent)
666
+ return "[error] Only the lead can request a plan.";
667
+ let teammate = "", task = "";
668
+ try {
669
+ const a = JSON.parse(call.args);
670
+ teammate = (a.teammate ?? "").trim();
671
+ task = a.task ?? "";
672
+ }
673
+ catch { /* fall through */ }
674
+ if (!teammate || !task.trim())
675
+ return "[error] request_plan needs a 'teammate' and a 'task'.";
676
+ if (!teammateExists(teammate))
677
+ return `[error] No teammate named "${teammate}".`;
678
+ sendMessage(LEAD, teammate, `Before doing this, call submit_plan with your plan and WAIT for my approval — do not start until approved:\n${task}`);
679
+ return `Asked "${teammate}" to submit a plan before doing: ${task.slice(0, 80)}. Approve/reject it with review_plan when it arrives.`;
680
+ }
681
+ // review_plan (lead): the answer half of the plan_approval contract. Correlate
682
+ // to the request via match_response (resolveResponse), then send the decision
683
+ // back to the teammate that submitted it.
684
+ async function runReviewPlan(call, opts) {
685
+ if (opts.teammate || opts.subAgent)
686
+ return "[error] Only the lead can review a plan.";
687
+ let requestId = "", decision = "", reason = "";
688
+ try {
689
+ const a = JSON.parse(call.args);
690
+ requestId = (a.request_id ?? "").trim();
691
+ decision = (a.decision ?? "").trim();
692
+ reason = a.reason ?? "";
693
+ }
694
+ catch { /* fall through */ }
695
+ if (!requestId || (decision !== "approve" && decision !== "reject"))
696
+ return '[error] review_plan needs a request_id and decision "approve" or "reject".';
697
+ const approved = decision === "approve";
698
+ const res = resolveResponse(requestId, "plan_approval", approved); // match_response: validates kind + state
699
+ if (!res.ok)
700
+ return `[error] ${res.error}`;
701
+ sendProtocol(LEAD, res.state.from, "plan_approval_response", requestId, reason || (approved ? "approved" : "rejected"), approved ? "approved" : "rejected");
702
+ return `Plan ${approved ? "approved" : "rejected"} (${requestId}); told ${res.state.from}.`;
703
+ }
704
+ // submit_plan (teammate → lead): open a plan_approval contract and STOP. The
705
+ // teammate idles after this until the lead's decision lands in its inbox.
706
+ async function runSubmitPlan(call, opts) {
707
+ if (!opts.teammate)
708
+ return "[error] Only a teammate can submit a plan (the lead reviews them).";
709
+ let plan = "";
710
+ try {
711
+ plan = JSON.parse(call.args).plan ?? "";
712
+ }
713
+ catch { /* fall through */ }
714
+ if (!plan.trim())
715
+ return "[error] submit_plan needs a non-empty 'plan'.";
716
+ const requestId = createRequest("plan_approval", opts.teammate.name, LEAD, plan);
717
+ sendProtocol(opts.teammate.name, LEAD, "plan_approval_request", requestId, plan);
718
+ 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.`;
719
+ }
720
+ // ---- The task board tools (Day 40) ------------------------------------------
721
+ // The claimer's identity is the calling agent's name — the lead or a teammate.
722
+ const boardActor = (opts) => opts.teammate?.name ?? LEAD;
723
+ async function runCreateTask(call, opts) {
724
+ if (opts.subAgent && !opts.teammate)
725
+ return "[error] Only team agents use the task board."; // plain sub-agents have no board
726
+ let subject = "", description = "", blockedBy = [];
727
+ try {
728
+ const a = JSON.parse(call.args);
729
+ subject = (a.subject ?? "").trim();
730
+ description = a.description ?? "";
731
+ if (Array.isArray(a.blockedBy))
732
+ blockedBy = a.blockedBy.filter((x) => typeof x === "string");
733
+ }
734
+ catch { /* fall through */ }
735
+ if (!subject)
736
+ return "[error] create_task needs a 'subject'.";
737
+ const task = createTask(subject, description, blockedBy);
738
+ if (!opts.quiet)
739
+ console.log(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
740
+ return `Created ${task.id}: "${subject}"${blockedBy.length ? ` (blockedBy: ${blockedBy.join(", ")})` : ""}. It's on the board for a teammate to claim.`;
741
+ }
742
+ async function runListTasks(_call, opts) {
743
+ if (opts.subAgent && !opts.teammate)
744
+ return "[error] Only team agents use the task board.";
745
+ return `Task board:\n${boardSummary()}`;
746
+ }
747
+ async function runClaimTask(call, opts) {
748
+ if (opts.subAgent && !opts.teammate)
749
+ return "[error] Only team agents use the task board.";
750
+ let taskId = "";
751
+ try {
752
+ taskId = JSON.parse(call.args).task_id?.trim() ?? "";
753
+ }
754
+ catch { /* fall through */ }
755
+ if (!taskId)
756
+ return "[error] claim_task needs a 'task_id'.";
757
+ const res = claimTask(taskId, boardActor(opts));
758
+ if (!res.ok)
759
+ return `[error] ${res.error} Pick another task or check list_tasks.`;
760
+ 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}`;
761
+ }
762
+ async function runCompleteTask(call, opts) {
763
+ if (opts.subAgent && !opts.teammate)
764
+ return "[error] Only team agents use the task board.";
765
+ let taskId = "";
766
+ try {
767
+ taskId = JSON.parse(call.args).task_id?.trim() ?? "";
768
+ }
769
+ catch { /* fall through */ }
770
+ if (!taskId)
771
+ return "[error] complete_task needs a 'task_id'.";
772
+ const res = completeTask(taskId, boardActor(opts));
773
+ if (!res.ok)
774
+ return `[error] ${res.error}`;
775
+ if (!opts.quiet)
776
+ console.log(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
777
+ const unblocked = res.unblocked?.length ? ` Unblocked: ${res.unblocked.join(", ")}.` : "";
778
+ return `Completed ${taskId}.${unblocked} Claim the next ready task, or idle until the lead shuts you down.`;
779
+ }
780
+ // spawn_teammate: start a persistent worker that runs concurrently. Top-level
781
+ // only — a teammate calling this is the nested-spawn case (its manual omits the
782
+ // tool, but we guard anyway). Returns IMMEDIATELY; the teammate's loop runs in
783
+ // the background and reports back through the lead's inbox.
784
+ async function runSpawnTeammate(call, opts) {
785
+ if (opts.subAgent || opts.teammate)
786
+ return "[error] Only the lead can spawn teammates — teammates cannot spawn teammates. Do the work yourself or message the lead.";
787
+ let name = "", role = "", task = "";
788
+ try {
789
+ const a = JSON.parse(call.args);
790
+ name = (a.name ?? "").trim();
791
+ role = (a.role ?? "").trim();
792
+ task = a.task ?? "";
793
+ }
794
+ catch {
795
+ /* fall through */
796
+ }
797
+ if (!name || !role || !task.trim())
798
+ return "[error] spawn_teammate needs a non-empty name, role, and task.";
799
+ if (name === LEAD)
800
+ return `[error] "${LEAD}" is reserved for the coordinator — pick another name.`;
801
+ if (teammateExists(name))
802
+ return `[error] A teammate named "${name}" already exists — pick a unique name or message the existing one.`;
803
+ if (teammateCount() >= MAX_TEAMMATES)
804
+ return `[error] The team is full (${MAX_TEAMMATES} teammates max). Wait for one to finish, or do the work yourself.`;
805
+ // Kick off the teammate's loop WITHOUT awaiting it — that is what makes it
806
+ // concurrent. It progresses whenever the event loop is free (e.g. while the
807
+ // lead awaits its own API stream). We keep the promise so the lead and
808
+ // shutdown know when it has ended.
809
+ const done = runTeammate(name, role, task, opts);
810
+ registerTeammate(name, role, done);
811
+ if (!opts.quiet)
812
+ console.log(mark.teammateStart(name, role));
813
+ 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.`;
814
+ }
815
+ // Run one teammate to completion: a fresh conversation seeded with its task, a
816
+ // focused toolset, its own mailbox, and a hard round cap. On any ending it sends
817
+ // a `result` message back to the lead — so the lead always learns the outcome,
818
+ // success or failure. The teammate's file read-state is isolated by snapshot,
819
+ // exactly like a sub-agent (it reads things the lead's conversation never saw).
820
+ async function runTeammate(name, role, task, opts) {
821
+ emit("agent_teammate_spawn");
822
+ await runHooks("SubagentStart", { description: `teammate ${name}: ${role}`.slice(0, 200), model: opts.model });
823
+ const snapshot = snapshotFileState();
824
+ const system = TEAMMATE_PROMPT.replace("{name}", name).replace("{role}", role);
825
+ try {
826
+ const messages = [
827
+ { role: "system", content: system },
828
+ { role: "user", content: task }, // its first instruction; more arrive via the inbox
829
+ ];
830
+ // A teammate is a sub-agent (quiet streaming, no hooks, isolated) PLUS team
831
+ // wiring: a name, its inbox, a non-interactive permission policy (it cannot
832
+ // prompt the human, so writes auto-proceed but risky bash is declined — see
833
+ // teammateConfirm; deny ALWAYS still wins), and a runaway backstop. Day 39:
834
+ // it is no longer bounded by a low round cap — it idle-waits for work and
835
+ // exits on the shutdown handshake (continueForTeam); maxRounds is only a
836
+ // generous guard against an active loop that never stops calling tools.
837
+ const result = await runLoop(messages, {
838
+ ...opts,
839
+ subAgent: true,
840
+ teammate: { name },
841
+ maxRounds: TEAMMATE_MAX_ROUNDS,
842
+ confirm: teammateConfirm(name),
843
+ askUser: undefined, // teammates have no line to the human
844
+ });
845
+ // How it ended decides what the lead hears. A shutdown handshake gets a
846
+ // shutdown_response(approved) carrying the request_id; anything else is a
847
+ // plain result. Either way the lead always learns the outcome.
848
+ const summary = result.reason === TerminateReason.Done && result.finalText?.trim() ? result.finalText.trim() : `[ended: ${result.reason}]`;
849
+ const sd = shutdownRequestId(name);
850
+ if (sd)
851
+ sendProtocol(name, LEAD, "shutdown_response", sd, summary || "shut down", "approved");
852
+ else
853
+ sendMessage(name, LEAD, summary, "result");
854
+ finishTeammate(name, result.reason === TerminateReason.Done);
855
+ if (!opts.quiet)
856
+ console.log(mark.teammateDone(name, result.reason === TerminateReason.Done));
857
+ }
858
+ catch (err) {
859
+ // A teammate must never take the whole process down. Report the failure to
860
+ // the lead and mark it failed.
861
+ sendMessage(name, LEAD, `[teammate crashed: ${err.message}]`, "result");
862
+ finishTeammate(name, false);
863
+ }
864
+ finally {
865
+ restoreFileState(snapshot);
866
+ await runHooks("SubagentStop", { description: `teammate ${name}` });
867
+ }
868
+ }
869
+ const TEAMMATE_MAX_ROUNDS = 30; // runaway backstop for an active teammate (not the lifecycle — idle-wait + shutdown handshake end it normally)
870
+ const TEAM_POLL_MS = 250; // how often an idle agent polls its inbox while waiting
871
+ 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)
872
+ const SHUTDOWN_GRACE_MS = 30_000; // how long the lead waits for teammates to exit cleanly when it disbands the team
873
+ // A teammate's permission policy. It cannot stop and ask the human, so: file
874
+ // writes/edits auto-proceed (that is the teammate's job, and they are reversible
875
+ // via /undo and reviewable via /diff), but anything else the gate rated "ask"
876
+ // (unrecognized or dangerous bash) is DECLINED rather than run unattended. Hard
877
+ // DENY rules (the no-fly zone) are enforced by the gate before confirm is ever
878
+ // called, so they always hold. Production Claude Code instead "bubbles" the
879
+ // request up to the lead and on to the human — omitted here, like the reference.
880
+ function teammateConfirm(name) {
881
+ return async (_question, toolName) => {
882
+ const ok = toolName === "write_file" || toolName === "edit_file";
883
+ if (!ok)
884
+ console.log(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
885
+ return ok;
886
+ };
887
+ }
367
888
  // Present the ask_user form and turn the selections into a tool result. The
368
889
  // model called this to get a decision; we give it clean question→answer pairs.
369
890
  async function runAskUser(call, opts) {
@@ -410,6 +931,152 @@ compaction) {
410
931
  return false;
411
932
  }
412
933
  }
934
+ // Surface any FINISHED background tasks (Day 37) as a user turn, so the model
935
+ // sees the result of work it kicked off earlier. Mirrors the tutorial reference,
936
+ // adapted to the OpenAI wire protocol: tool results are their own role:"tool"
937
+ // messages, so a notification can't ride inside them — it follows as a user
938
+ // message instead (exactly how the todo nag and Stop hook already inject turns).
939
+ // Top-level only: a sub-agent has no separate turn loop to inject into, and the
940
+ // notification belongs to the agent that started the job. Returns true if it
941
+ // injected something. Each finished task notifies exactly once (background.ts).
942
+ function injectBackgroundNotifications(messages, opts) {
943
+ if (opts.subAgent)
944
+ return false; // notifications belong to the top-level conversation
945
+ const note = pendingNotifications(); // "" unless a task finished since we last checked
946
+ if (!note)
947
+ return false;
948
+ const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
949
+ messages.push({ role: "user", content: note }); // the model reacts to it on the next round
950
+ if (!opts.quiet)
951
+ console.log(mark.bgNote(count));
952
+ return true;
953
+ }
954
+ // Consume this agent's mailbox into its conversation, ROUTING protocol messages
955
+ // (Day 39) as it goes — the reference's consume_lead_inbox + dispatch_message in
956
+ // one pass. The lead reads "lead"; a teammate reads its own name. The read is
957
+ // consumptive (team.ts), so each message is handled exactly once. Protocol
958
+ // messages update state (correlate responses via match_response, flag a shutdown)
959
+ // AND get a plain-language line so the model can react; plain chat is passed
960
+ // through. Returns whether anything was injected. (Plan-approval is a handshake,
961
+ // not a hard gate: the teammate is TOLD to wait and does — execution gating by
962
+ // permission mode is what production Claude Code adds; omitted here, per s16.)
963
+ function consumeInbox(messages, opts) {
964
+ if (opts.subAgent && !opts.teammate)
965
+ return false; // a plain sub-agent is not on the team
966
+ const me = opts.teammate?.name ?? LEAD;
967
+ const msgs = readInbox(me);
968
+ if (!msgs.length)
969
+ return false;
970
+ const lines = [];
971
+ for (const m of msgs) {
972
+ switch (m.kind) {
973
+ case "shutdown_request": // (teammate side) the lead asked us to shut down
974
+ markShutdown(me, m.requestId ?? "");
975
+ lines.push(`[lead] SHUTDOWN REQUESTED (${m.requestId}): ${m.content}. Finish any in-progress write, then you will exit cleanly.`);
976
+ break;
977
+ case "shutdown_response": // (lead side) a teammate confirmed it shut down
978
+ if (m.requestId)
979
+ resolveResponse(m.requestId, "shutdown", m.status === "approved");
980
+ finishTeammate(m.from, true);
981
+ lines.push(`[${m.from}] confirmed shutdown (${m.requestId}) and has exited.`);
982
+ break;
983
+ case "plan_approval_request": // (lead side) a teammate wants approval before risky work
984
+ lines.push(`[${m.from}] PLAN APPROVAL REQUESTED (${m.requestId}). Review it, then call review_plan with this id. Plan:\n${m.content}`);
985
+ break;
986
+ case "plan_approval_response": { // (teammate side) the lead decided on our plan
987
+ if (m.requestId)
988
+ resolveResponse(m.requestId, "plan_approval", m.status === "approved");
989
+ const ok = m.status === "approved";
990
+ 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."}`);
991
+ break;
992
+ }
993
+ default: // plain chat / a teammate's result
994
+ lines.push(`[message from ${m.from}${m.type === "result" ? " · RESULT" : ""}] ${m.content}`);
995
+ }
996
+ }
997
+ const header = opts.teammate ? "Messages from your team — read and act on them:" : "Team inbox — react to these (RESULT = that teammate finished a part):";
998
+ messages.push({ role: "user", content: `${header}\n\n${lines.join("\n\n")}` });
999
+ if (!opts.quiet)
1000
+ console.log(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
1001
+ return true;
1002
+ }
1003
+ // At a stop-point (no tool calls), decide whether the TEAM keeps this agent's
1004
+ // conversation alive (Day 38/39).
1005
+ // - A teammate now IDLE-LOOPS instead of exiting: it processes its inbox, and
1006
+ // if there's nothing to do it waits (yielding the event loop) for new work —
1007
+ // bounded, so an abandoned teammate self-exits instead of hanging forever. It
1008
+ // leaves only on the shutdown handshake (its request_id is set) or that
1009
+ // backstop. While waiting it marks itself "idle" so the lead can tell the
1010
+ // team is quiescent.
1011
+ // - The lead waits while any teammate is actively working, then drains its
1012
+ // inbox. When the team has gone quiet and nothing is waiting, it returns
1013
+ // false → the lead finishes (and disbands the team, see runLoop).
1014
+ async function continueForTeam(messages, opts) {
1015
+ if (opts.teammate) {
1016
+ const me = opts.teammate.name;
1017
+ let waited = 0;
1018
+ while (true) {
1019
+ const injected = consumeInbox(messages, opts); // INBOX FIRST (priority) — route protocol + plain; may set the shutdown flag
1020
+ if (shutdownRequestId(me))
1021
+ return false; // shutdown handshake → exit (runTeammate sends the response)
1022
+ if (injected) {
1023
+ setTeammateState(me, "active");
1024
+ return true;
1025
+ } // a direct message arrived → run a round
1026
+ // s17 autonomy: nothing in the inbox — pull ready work off the shared board
1027
+ // yourself. claimNextAvailable atomically claims the next pending+unblocked
1028
+ // task; the claim is what makes this safe with several teammates scanning.
1029
+ const claimed = claimNextAvailable(me);
1030
+ if (claimed) {
1031
+ // The claimed task becomes a fresh WORK instruction. The leading identity
1032
+ // line also re-anchors the persona if the history was compacted (the
1033
+ // reference's identity-reinjection, folded into the work hand-off).
1034
+ 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.` });
1035
+ if (!opts.quiet)
1036
+ console.log(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
1037
+ setTeammateState(me, "active");
1038
+ return true;
1039
+ }
1040
+ setTeammateState(me, "idle"); // inbox empty AND no claimable work — wait
1041
+ if (opts.isInterrupted() || waited >= TEAMMATE_MAX_IDLE_MS)
1042
+ return false; // abandoned → self-exit backstop
1043
+ await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted);
1044
+ waited += TEAM_POLL_MS;
1045
+ }
1046
+ }
1047
+ if (opts.subAgent)
1048
+ return false; // a plain sub-agent has no team
1049
+ // The lead: wait for the team to act rather than spinning or ending early.
1050
+ while (inboxCount(LEAD) === 0 && anyTeammateBusy() && !opts.isInterrupted()) {
1051
+ await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted); // yield to the teammates; they message us when they have something
1052
+ }
1053
+ return consumeInbox(messages, opts); // process whatever is waiting; false if the team is quiescent (→ the lead may finish)
1054
+ }
1055
+ // The lead finished its work but teammates may still be idling. Disband the team
1056
+ // GRACEFULLY (Day 39): open a shutdown contract with each, let them confirm and
1057
+ // exit (bounded by a grace window so a wedged teammate can't hang the prompt),
1058
+ // then drain the lead's inbox of their confirmations and clear the team.
1059
+ async function shutdownTeam(opts) {
1060
+ const running = runningTeammates();
1061
+ if (!running.length)
1062
+ return;
1063
+ if (!opts.quiet)
1064
+ console.log(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
1065
+ for (const name of running) {
1066
+ const id = createRequest("shutdown", LEAD, name, "task complete");
1067
+ markShutdown(name, id);
1068
+ sendProtocol(LEAD, name, "shutdown_request", id, "The task is complete — wrap up and exit.");
1069
+ }
1070
+ // Yield the event loop so the teammates wake, confirm, and exit — but never
1071
+ // block the human forever on a stuck one.
1072
+ const start = Date.now();
1073
+ while (runningTeammates().length && Date.now() - start < SHUTDOWN_GRACE_MS && !opts.isInterrupted()) {
1074
+ await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted);
1075
+ }
1076
+ readInbox(LEAD); // drop the shutdown confirmations the lead won't read
1077
+ resetTeam(); // clear the registry + protocol state; the next task starts with a fresh team
1078
+ resetBoard(); // and a fresh task board — its work is done with this team
1079
+ }
413
1080
  // The agent main loop, as a state machine: every iteration either continues
414
1081
  // for a named reason or terminates for a named reason — nothing implicit.
415
1082
  export async function runLoop(messages, // conversation history (mutated in place)
@@ -417,7 +1084,14 @@ opts) {
417
1084
  // The loop's mutable state: budgets and counters, rewritten every iteration.
418
1085
  const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
419
1086
  const compaction = { count: 0, failures: 0 }; // compaction score card for this query
1087
+ let lastText = null; // most recent assistant text — what we return if a round cap (teammates) stops us mid-flight
420
1088
  for (let round = 1;; round++) {
1089
+ // A bounded loop (teammates, Day 38) stops cleanly at its round cap with
1090
+ // whatever it last said, so a team can never run away. Unset maxRounds keeps
1091
+ // the usual unbounded-with-safeguards loop for the lead and the user's agent.
1092
+ if (opts.maxRounds && round > opts.maxRounds) {
1093
+ return { reason: TerminateReason.Done, finalText: lastText ?? `(reached the ${opts.maxRounds}-round limit)` };
1094
+ }
421
1095
  // One round = one successful model call + its tool results.
422
1096
  // The inner loop retries the model call until it succeeds or a budget dies.
423
1097
  while (true) {
@@ -492,6 +1166,8 @@ opts) {
492
1166
  ? { tool_calls: out.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) }
493
1167
  : {}), // omit the field entirely when there were no calls
494
1168
  });
1169
+ if (out.content)
1170
+ lastText = out.content; // remember the latest answer text — the round-cap path returns it
495
1171
  if (!out.toolCalls.length) {
496
1172
  // The model wants to stop. A Stop hook gets the last word: if it exits
497
1173
  // 2, the agent is NOT done — its stderr becomes a new instruction and
@@ -508,6 +1184,22 @@ opts) {
508
1184
  break; // exit the inner while → advance the round counter
509
1185
  }
510
1186
  }
1187
+ // A background task may have finished in the very round the model decided
1188
+ // to stop. Don't return with an unread result on the table: surface it and
1189
+ // run one more round so the model can react. Each task notifies once, so
1190
+ // this adds at most one round per finished task — never an infinite stop.
1191
+ if (injectBackgroundNotifications(messages, opts))
1192
+ break; // exit the inner while → advance the round counter
1193
+ // Agent teams (Day 38/39): the lead waits for teammate messages and
1194
+ // reacts; a teammate idle-loops for new work and leaves only on the
1195
+ // shutdown handshake (or its idle backstop). If there's something to
1196
+ // process, run another round instead of stopping.
1197
+ if (await continueForTeam(messages, opts))
1198
+ break; // exit the inner while → advance the round counter
1199
+ // The lead is really finishing: disband any teammates still idling so
1200
+ // they exit cleanly (Day 39) instead of being orphaned.
1201
+ if (!opts.subAgent && !opts.teammate)
1202
+ await shutdownTeam(opts);
511
1203
  return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
512
1204
  }
513
1205
  // Execute the tool calls. Read-only calls (read_file/search) in a
@@ -532,6 +1224,27 @@ opts) {
532
1224
  const r = await runOneCall(out.toolCalls[i++], opts);
533
1225
  messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
534
1226
  }
1227
+ // Background tasks (Day 37): if a job the model started has finished, slip
1228
+ // its <task_notification> in here, right after this round's tool results —
1229
+ // the model sees the outcome on the next round without ever having blocked
1230
+ // on it. Tasks that are still running stay silent until they finish.
1231
+ injectBackgroundNotifications(messages, opts);
1232
+ // Agent teams (Day 38/39): drain any messages that arrived during this
1233
+ // round (results, hand-offs, protocol requests/responses) so the agent
1234
+ // reacts to them next round. A shutdown_request seen here just flags the
1235
+ // teammate; it acts on it at the next stop-point (continueForTeam).
1236
+ consumeInbox(messages, opts);
1237
+ // The nag: if there's an unfinished plan the model has stopped touching for
1238
+ // a few rounds, slip in a reminder so the list doesn't go stale and
1239
+ // meaningless. Top-level only — a sub-agent has no plan of its own.
1240
+ if (!opts.subAgent) {
1241
+ const nag = todoNag();
1242
+ if (nag) {
1243
+ messages.push({ role: "user", content: nag });
1244
+ if (!opts.quiet)
1245
+ console.log(chalk.dim(" ⎿ plan reminder injected"));
1246
+ }
1247
+ }
535
1248
  break; // round complete, move to the next one
536
1249
  }
537
1250
  }