agent-from-zero 0.31.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/loop.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import chalk from "chalk"; // terminal colors for status lines
2
- import ora from "ora"; // the "thinking..." spinner while waiting for the first token
3
2
  import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
4
3
  import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
5
4
  import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
@@ -13,7 +12,7 @@ import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / Pos
13
12
  import { recordUsage } from "./cost.js"; // meter token usage from the stream
14
13
  import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
15
14
  import { recordToolCall, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
16
- import { MarkdownStream } from "./markdown.js"; // render the streamed answer as terminal markdown
15
+ import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
17
16
  import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
18
17
  import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
19
18
  export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
@@ -36,6 +35,11 @@ export var TerminateReason;
36
35
  TerminateReason["FatalApiError"] = "fatal_api_error";
37
36
  TerminateReason["UserInterrupt"] = "user_interrupt";
38
37
  })(TerminateReason || (TerminateReason = {}));
38
+ // Resolve the output sink for a loop: the caller's sink, or stdout (the old
39
+ // behaviour) when none was passed. Centralised so every site reads the same way.
40
+ function sink(opts) {
41
+ return opts.output ?? STDOUT_OUTPUT;
42
+ }
39
43
  // Which model should a delegated sub-agent run on? The configured sub-agent
40
44
  // model if set and non-blank, otherwise the same model as the orchestrator.
41
45
  // Pulled out as a pure function so the tiering rule is testable on its own.
@@ -51,6 +55,7 @@ const taskTool = {
51
55
  description: `Delegate one self-contained subtask to a sub-agent that works in a FRESH context.
52
56
  The sub-agent has the same tools (except task) but knows NOTHING about this conversation — put every detail it needs into the description.
53
57
  Use it for exploration that would flood your context: reading many files, broad searches, summarizing a directory.
58
+ The sub-agent runs ASYNC — it returns a handle immediately and you are notified when it finishes. Do NOT wait idly: continue working or wait for its result.
54
59
  The sub-agent's report is INPUT MATERIAL, not verified truth — re-check key claims yourself before acting on them.
55
60
  On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a clearer description, or do the work yourself.`,
56
61
  parameters: {
@@ -273,37 +278,94 @@ function toolsFor(opts) {
273
278
  // watching the board (it lays out tasks; teammates pull from it).
274
279
  return [...builtins, taskTool, askUserTool, spawnTeammateTool, sendMessageTool, requestShutdownTool, requestPlanTool, reviewPlanTool, createTaskTool, listTasksTool];
275
280
  }
276
- // Run one sub-agent: a fresh conversation, same tools minus task, same
277
- // permission gate, and the parent's file read-state protected by a snapshot.
278
- async function runSubAgent(description, opts) {
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.
281
+ const pendingSubAgents = new Map();
282
+ let subAgentSeq = 0;
283
+ // Collect finished sub-agent results (like pendingNotifications for background
284
+ // tasks). Each result surfaces exactly once (the `notified` flag).
285
+ function pendingSubAgentResults() {
286
+ const done = [];
287
+ for (const sa of pendingSubAgents.values()) {
288
+ if (sa.result === null || sa.notified)
289
+ continue;
290
+ sa.notified = true;
291
+ done.push(`<subagent_notification>\n <id>${sa.id}</id>\n <task>${sa.description.slice(0, 200)}</task>\n <result>\n${sa.result}\n </result>\n</subagent_notification>`);
292
+ }
293
+ if (!done.length)
294
+ return "";
295
+ return `A sub-agent you delegated work to has finished. Read its report carefully — it is INPUT MATERIAL, not verified truth. Verify key claims yourself, then continue.\n\n${done.join("\n\n")}`;
296
+ }
297
+ // Inject finished sub-agent results into the conversation, like
298
+ // injectBackgroundNotifications. Returns true if something was injected.
299
+ function injectSubAgentResults(messages, opts) {
300
+ if (opts.subAgent)
301
+ return false; // notifications belong to the top-level conversation
302
+ const note = pendingSubAgentResults();
303
+ if (!note)
304
+ return false;
305
+ messages.push({ role: "user", content: note });
306
+ if (!opts.quiet)
307
+ sink(opts).note(mark.subAgentDone);
308
+ return true;
309
+ }
310
+ // Start a sub-agent WITHOUT awaiting — returns a handle immediately. The
311
+ // sub-agent's result will be injected as a notification when it finishes.
312
+ function spawnSubAgent(description, opts) {
313
+ emit("agent_subagent_spawn");
314
+ const subModel = subAgentModelFor(opts);
315
+ const id = `sa_${++subAgentSeq}`;
283
316
  if (!opts.quiet)
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)
317
+ sink(opts).note(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : ""));
318
+ runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // fire-and-forget (observational)
286
319
  const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
287
- try {
288
- const subMessages = [
289
- { role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
290
- { role: "user", content: description }, // the task is its entire world
291
- ];
292
- const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel }); // recurse as a sub-agent, on its own tier
293
- if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
294
- // The framing matters: the parent must treat this as material to verify,
295
- // not as conclusions to copy — the most common multi-agent failure mode.
296
- return `Sub-agent report (INPUT MATERIAL — verify key claims before acting on them):\n${result.finalText}`;
320
+ const sa = { id, description, done: Promise.resolve(), result: null, notified: false };
321
+ sa.done = (async () => {
322
+ try {
323
+ const subMessages = [
324
+ { role: "system", content: SUB_AGENT_PROMPT },
325
+ { role: "user", content: description },
326
+ ];
327
+ const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel });
328
+ if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
329
+ sa.result = `Sub-agent report (INPUT MATERIAL — verify key claims before acting on them):\n${result.finalText}`;
330
+ }
331
+ else {
332
+ sa.result = `[sub-agent failed: ${result.reason}]`;
333
+ }
297
334
  }
298
- return `[sub-agent failed: ${result.reason}]`; // any non-Done ending, compressed to one line
299
- }
300
- finally {
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)
303
- if (!opts.quiet)
304
- console.log(mark.subAgentDone); // close the bracket
335
+ catch (err) {
336
+ sa.result = `[sub-agent crashed: ${err.message}]`;
337
+ }
338
+ finally {
339
+ restoreFileState(snapshot);
340
+ await runHooks("SubagentStop", { description: description.slice(0, 200) });
341
+ }
342
+ })();
343
+ pendingSubAgents.set(id, sa);
344
+ return `Sub-agent started (${id}): "${description.slice(0, 100)}". Continue working — you'll be notified when it finishes. Do NOT wait idly; keep working on other parts of the task, or wait for the notification.`;
345
+ }
346
+ // True if any sub-agent is still running.
347
+ function hasRunningSubAgents() {
348
+ return [...pendingSubAgents.values()].some((sa) => sa.result === null);
349
+ }
350
+ // Kill every still-running sub-agent. Called on session exit.
351
+ export function killAllSubAgents() {
352
+ for (const sa of pendingSubAgents.values()) {
353
+ if (sa.result === null)
354
+ sa.result = "[sub-agent killed: session exited]";
305
355
  }
306
356
  }
357
+ // Snapshot of current sub-agents for the UI (Ink app shows a list below the
358
+ // input box, like Claude Code's agent list). Newest first.
359
+ export function listSubAgents() {
360
+ return [...pendingSubAgents.values()]
361
+ .reverse()
362
+ .map((sa) => ({
363
+ id: sa.id,
364
+ description: sa.description.slice(0, 80),
365
+ status: sa.result === null ? "running" : sa.result.startsWith("[sub-agent failed") || sa.result.startsWith("[sub-agent crashed") || sa.result.startsWith("[sub-agent killed") ? "failed" : "done",
366
+ result: sa.result ?? undefined,
367
+ }));
368
+ }
307
369
  // Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
308
370
  // Without jitter, every client that failed at the same second retries at the
309
371
  // same second — a thundering herd against an already-struggling server.
@@ -335,7 +397,7 @@ opts) {
335
397
  let reasoningShown = false; // have we printed the "thought for Ns" indicator yet?
336
398
  const spinner = opts.quiet
337
399
  ? null // the eval harness wants silence
338
- : ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
400
+ : sink(opts).spinner(spinnerText(word, 0, !!opts.subAgent, opts.model)); // a fresh spinner from the sink (stdout → ora; Ink → React state)
339
401
  let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
340
402
  let stallWarned = false; // only warn once per quiet stretch
341
403
  // The two-level watchdog, checked once per second:
@@ -345,8 +407,8 @@ opts) {
345
407
  const watchdog = setInterval(() => {
346
408
  // While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
347
409
  // reads differently from "it's waiting on the first token".
348
- if (spinner?.isSpinning)
349
- spinner.text = (reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
410
+ if (spinner?.spinning)
411
+ spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4)));
350
412
  const quietMs = Date.now() - lastEvent; // ms since the last event
351
413
  if (quietMs > IDLE_TIMEOUT_MS) {
352
414
  emit("agent_watchdog_idle"); // record the cut — these should be rare
@@ -355,7 +417,7 @@ opts) {
355
417
  else if (quietMs > STALL_WARN_MS && !stallWarned) {
356
418
  stallWarned = true; // don't repeat the warning every second
357
419
  emit("agent_watchdog_stall"); // record the slowness — pattern-spotting data
358
- console.log(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
420
+ sink(opts).note(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
359
421
  }
360
422
  }, 1000);
361
423
  try {
@@ -370,8 +432,7 @@ opts) {
370
432
  // teammate's manual simply omits spawn_teammate and task.
371
433
  { model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
372
434
  let content = ""; // accumulated answer text
373
- let printedPrefix = false; // have we printed the answer prefix yet?
374
- let md = null; // renders the streamed answer as markdown, block by block
435
+ let ans = null; // renders the streamed answer (stdout: markdown block by block; Ink: React state) — non-null once the answer starts
375
436
  const calls = []; // tool calls under assembly, indexed by delta.index
376
437
  // The model has finished thinking and is about to say/do something. Close out
377
438
  // the spinner, stash the trace, and print a single one-line indicator with the
@@ -382,7 +443,7 @@ opts) {
382
443
  return;
383
444
  reasoningShown = true;
384
445
  reasoningActive = false;
385
- if (spinner?.isSpinning)
446
+ if (spinner?.spinning)
386
447
  spinner.stop();
387
448
  if (opts.quiet || opts.subAgent)
388
449
  return; // no UI for silent / nested runs
@@ -390,7 +451,7 @@ opts) {
390
451
  const secs = Math.round((Date.now() - startedAt) / 1000);
391
452
  const toks = Math.round(reasoningBuf.length / 4); // same ~chars/4 estimate as the spinner
392
453
  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");
454
+ sink(opts).reasoning(chalk.dim(`💭 thought for ${secs}s (~${tokStr} tokens) · Ctrl+R to view`));
394
455
  };
395
456
  for await (const chunk of stream) {
396
457
  lastEvent = Date.now(); // feed the watchdog
@@ -414,27 +475,24 @@ opts) {
414
475
  if (delta.content) {
415
476
  flushReasoning(); // thinking is over — show the one-line indicator first
416
477
  streamedChars += delta.content.length; // count the answer toward the live token estimate
417
- if (spinner?.isSpinning)
478
+ if (spinner?.spinning)
418
479
  spinner.stop(); // first token: replace the spinner with real output
419
480
  if (!opts.quiet && !opts.subAgent) {
420
481
  // Only the top-level agent streams to the screen — a sub-agent's
421
- // inner monologue would be mistaken for the answer.
422
- if (!printedPrefix) {
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: " " });
428
- printedPrefix = true;
429
- }
430
- md.push(delta.content); // hand the token to the renderer (it decides when to paint)
482
+ // inner monologue would be mistaken for the answer. The sink decides
483
+ // how to paint: the stdout sink buffers a markdown block (paragraph /
484
+ // heading / list / table) and flushes it whole — live, but no raw
485
+ // `##`/`|---|`; the Ink sink accumulates into React state.
486
+ if (!ans)
487
+ ans = sink(opts).answer(); // first token: start the answer renderer
488
+ ans.push(delta.content); // hand the token to the renderer (it decides when to paint)
431
489
  }
432
490
  content += delta.content; // always keep it for the history (the answer only, never the reasoning)
433
491
  }
434
492
  if (delta.tool_calls?.length)
435
493
  flushReasoning(); // thinking is over — indicator before the tool logs (which print after we return)
436
494
  for (const tc of delta.tool_calls ?? []) {
437
- if (spinner?.isSpinning)
495
+ if (spinner?.spinning)
438
496
  spinner.stop(); // tool call starting — spinner served its purpose
439
497
  const slot = (calls[tc.index] ??= { id: "", name: "", args: "" }); // create the slot on first fragment
440
498
  if (tc.id)
@@ -446,15 +504,13 @@ opts) {
446
504
  }
447
505
  }
448
506
  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
451
- if (printedPrefix)
452
- process.stdout.write("\n"); // end the streamed line cleanly
507
+ if (ans)
508
+ ans.end(); // flush the final (un-terminated) block + end the line cleanly (the sink owns the trailing newline)
453
509
  return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
454
510
  }
455
511
  finally {
456
512
  clearInterval(watchdog); // always stop the timer
457
- if (spinner?.isSpinning)
513
+ if (spinner?.spinning)
458
514
  spinner.stop(); // and never leave a zombie spinner
459
515
  }
460
516
  }
@@ -487,13 +543,13 @@ async function runOneCall(call, opts) {
487
543
  if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
488
544
  const preview = previewChange(call.name, call.args);
489
545
  if (preview)
490
- console.log(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
546
+ sink(opts).note(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
491
547
  }
492
548
  let content; // what goes back to the model as the tool result
493
549
  if (v.decision === "deny") {
494
550
  emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
495
551
  if (!opts.quiet)
496
- console.log(mark.denied(v.reason)); // tell the user we blocked it
552
+ sink(opts).note(mark.denied(v.reason)); // tell the user we blocked it
497
553
  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
498
554
  }
499
555
  else if (v.decision === "ask") {
@@ -510,14 +566,14 @@ async function runOneCall(call, opts) {
510
566
  if (cmd && (await opts.judge.classify(cmd)) === "allow") {
511
567
  autoAllowed = true;
512
568
  if (!opts.quiet)
513
- console.log(mark.judge); // visible — the user can see the judge worked
569
+ sink(opts).note(mark.judge); // visible — the user can see the judge worked
514
570
  }
515
571
  }
516
572
  const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
517
573
  if (!ok)
518
574
  emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
519
575
  if (!ok && !opts.quiet)
520
- console.log(mark.declined); // make the refusal visible
576
+ sink(opts).note(mark.declined); // make the refusal visible
521
577
  content = ok
522
578
  ? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
523
579
  : `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
@@ -528,7 +584,7 @@ async function runOneCall(call, opts) {
528
584
  // todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
529
585
  // the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
530
586
  if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
531
- console.log(renderTodos(getTodos()));
587
+ sink(opts).note(renderTodos(getTodos()));
532
588
  }
533
589
  return { id: call.id, content }; // paired by id for the API
534
590
  }
@@ -546,7 +602,7 @@ async function runWithHooks(call, opts) {
546
602
  if (pre.block) {
547
603
  emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
548
604
  if (!opts.quiet)
549
- console.log(mark.hookBlock("PreToolUse")); // make it visible
605
+ sink(opts).note(mark.hookBlock("PreToolUse")); // make it visible
550
606
  return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
551
607
  }
552
608
  // A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
@@ -556,7 +612,7 @@ async function runWithHooks(call, opts) {
556
612
  try {
557
613
  JSON.parse(pre.rewrite); // must be valid JSON args
558
614
  if (!opts.quiet)
559
- console.log(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
615
+ sink(opts).note(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
560
616
  call = { ...call, args: pre.rewrite };
561
617
  }
562
618
  catch {
@@ -615,7 +671,7 @@ async function execute(call, opts) {
615
671
  }
616
672
  if (!description)
617
673
  return "[error] task requires a non-empty description argument.";
618
- return runSubAgent(description, opts); // spawn the worker
674
+ return spawnSubAgent(description, opts); // spawn the worker (async — returns immediately; result injected later)
619
675
  }
620
676
  // The sender's mailbox name: a teammate sends as itself, everyone else as "lead".
621
677
  const senderName = (opts) => opts.teammate?.name ?? LEAD;
@@ -744,7 +800,7 @@ async function runCreateTask(call, opts) {
744
800
  return "[error] create_task needs a 'subject'.";
745
801
  const task = createTask(subject, description, blockedBy);
746
802
  if (!opts.quiet)
747
- console.log(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
803
+ sink(opts).note(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
748
804
  return `Created ${task.id}: "${subject}"${blockedBy.length ? ` (blockedBy: ${blockedBy.join(", ")})` : ""}. It's on the board for a teammate to claim.`;
749
805
  }
750
806
  async function runListTasks(_call, opts) {
@@ -781,7 +837,7 @@ async function runCompleteTask(call, opts) {
781
837
  if (!res.ok)
782
838
  return `[error] ${res.error}`;
783
839
  if (!opts.quiet)
784
- console.log(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
840
+ sink(opts).note(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
785
841
  const unblocked = res.unblocked?.length ? ` Unblocked: ${res.unblocked.join(", ")}.` : "";
786
842
  return `Completed ${taskId}.${unblocked} Claim the next ready task, or idle until the lead shuts you down.`;
787
843
  }
@@ -817,7 +873,7 @@ async function runSpawnTeammate(call, opts) {
817
873
  const done = runTeammate(name, role, task, opts);
818
874
  registerTeammate(name, role, done);
819
875
  if (!opts.quiet)
820
- console.log(mark.teammateStart(name, role));
876
+ sink(opts).note(mark.teammateStart(name, role));
821
877
  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.`;
822
878
  }
823
879
  // Run one teammate to completion: a fresh conversation seeded with its task, a
@@ -847,7 +903,7 @@ async function runTeammate(name, role, task, opts) {
847
903
  subAgent: true,
848
904
  teammate: { name },
849
905
  maxRounds: TEAMMATE_MAX_ROUNDS,
850
- confirm: teammateConfirm(name),
906
+ confirm: teammateConfirm(name, sink(opts)),
851
907
  askUser: undefined, // teammates have no line to the human
852
908
  });
853
909
  // How it ended decides what the lead hears. A shutdown handshake gets a
@@ -861,7 +917,7 @@ async function runTeammate(name, role, task, opts) {
861
917
  sendMessage(name, LEAD, summary, "result");
862
918
  finishTeammate(name, result.reason === TerminateReason.Done);
863
919
  if (!opts.quiet)
864
- console.log(mark.teammateDone(name, result.reason === TerminateReason.Done));
920
+ sink(opts).note(mark.teammateDone(name, result.reason === TerminateReason.Done));
865
921
  }
866
922
  catch (err) {
867
923
  // A teammate must never take the whole process down. Report the failure to
@@ -885,11 +941,11 @@ const SHUTDOWN_GRACE_MS = 30_000; // how long the lead waits for teammates to ex
885
941
  // DENY rules (the no-fly zone) are enforced by the gate before confirm is ever
886
942
  // called, so they always hold. Production Claude Code instead "bubbles" the
887
943
  // request up to the lead and on to the human — omitted here, like the reference.
888
- function teammateConfirm(name) {
944
+ function teammateConfirm(name, output) {
889
945
  return async (_question, toolName) => {
890
946
  const ok = toolName === "write_file" || toolName === "edit_file";
891
947
  if (!ok)
892
- console.log(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
948
+ output.note(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
893
949
  return ok;
894
950
  };
895
951
  }
@@ -925,7 +981,7 @@ compaction) {
925
981
  try {
926
982
  if (!opts.subAgent)
927
983
  await runHooks("PreCompact", {}); // lifecycle hook (top-level only; hooks are the human's project policy)
928
- await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
984
+ await compactHistory(messages, opts.client, opts.model, opts.signal, (s) => sink(opts).note(s)); // do the actual work — narration through the sink (so it doesn't corrupt an Ink live region)
929
985
  compaction.count++; // one more successful compaction this query
930
986
  compaction.failures = 0; // success resets the failure streak
931
987
  emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
@@ -956,7 +1012,7 @@ function injectBackgroundNotifications(messages, opts) {
956
1012
  const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
957
1013
  messages.push({ role: "user", content: note }); // the model reacts to it on the next round
958
1014
  if (!opts.quiet)
959
- console.log(mark.bgNote(count));
1015
+ sink(opts).note(mark.bgNote(count));
960
1016
  return true;
961
1017
  }
962
1018
  // Consume this agent's mailbox into its conversation, ROUTING protocol messages
@@ -1005,7 +1061,7 @@ function consumeInbox(messages, opts) {
1005
1061
  const header = opts.teammate ? "Messages from your team — read and act on them:" : "Team inbox — react to these (RESULT = that teammate finished a part):";
1006
1062
  messages.push({ role: "user", content: `${header}\n\n${lines.join("\n\n")}` });
1007
1063
  if (!opts.quiet)
1008
- console.log(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
1064
+ sink(opts).note(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
1009
1065
  return true;
1010
1066
  }
1011
1067
  // At a stop-point (no tool calls), decide whether the TEAM keeps this agent's
@@ -1041,7 +1097,7 @@ async function continueForTeam(messages, opts) {
1041
1097
  // reference's identity-reinjection, folded into the work hand-off).
1042
1098
  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.` });
1043
1099
  if (!opts.quiet)
1044
- console.log(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
1100
+ sink(opts).note(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
1045
1101
  setTeammateState(me, "active");
1046
1102
  return true;
1047
1103
  }
@@ -1069,7 +1125,7 @@ async function shutdownTeam(opts) {
1069
1125
  if (!running.length)
1070
1126
  return;
1071
1127
  if (!opts.quiet)
1072
- console.log(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
1128
+ sink(opts).note(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
1073
1129
  for (const name of running) {
1074
1130
  const id = createRequest("shutdown", LEAD, name, "task complete");
1075
1131
  markShutdown(name, id);
@@ -1152,7 +1208,7 @@ opts) {
1152
1208
  if (attempts.total >= MAX_RETRIES)
1153
1209
  return { reason: TerminateReason.RetryBudgetExhausted, detail: `${e.kind}: ${e.message}` };
1154
1210
  const wait = backoffMs(attempts.consecutive); // how long to back off this time
1155
- console.log(chalk.dim(` [retry] ${e.kind} — attempt ${attempts.total}/${MAX_RETRIES}, waiting ${wait}ms`)); // retries must be visible, or the app just looks frozen
1211
+ sink(opts).note(chalk.dim(` [retry] ${e.kind} — attempt ${attempts.total}/${MAX_RETRIES}, waiting ${wait}ms`)); // retries must be visible, or the app just looks frozen
1156
1212
  await interruptibleSleep(wait, opts.isInterrupted); // wait, but stay responsive to Ctrl+C
1157
1213
  continue; // retry the same round
1158
1214
  }
@@ -1187,17 +1243,19 @@ opts) {
1187
1243
  if (stop.block) {
1188
1244
  emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
1189
1245
  if (!opts.quiet)
1190
- console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
1246
+ sink(opts).note(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
1191
1247
  messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
1192
1248
  break; // exit the inner while → advance the round counter
1193
1249
  }
1194
1250
  }
1195
- // A background task may have finished in the very round the model decided
1196
- // to stop. Don't return with an unread result on the table: surface it and
1197
- // run one more round so the model can react. Each task notifies once, so
1198
- // this adds at most one round per finished task — never an infinite stop.
1251
+ // A background task (or sub-agent) may have finished in the very round the
1252
+ // model decided to stop. Don't return with an unread result on the table:
1253
+ // surface it and run one more round so the model can react. Each notifies
1254
+ // once, so this adds at most one round per finished job — never infinite.
1199
1255
  if (injectBackgroundNotifications(messages, opts))
1200
1256
  break; // exit the inner while → advance the round counter
1257
+ if (injectSubAgentResults(messages, opts))
1258
+ break;
1201
1259
  // Agent teams (Day 38/39): the lead waits for teammate messages and
1202
1260
  // reacts; a teammate idle-loops for new work and leaves only on the
1203
1261
  // shutdown handshake (or its idle backstop). If there's something to
@@ -1235,13 +1293,16 @@ opts) {
1235
1293
  // Fold this round's tool calls into ONE tally line instead of one line per
1236
1294
  // call (the per-call announcements were recorded for Ctrl+T in runOneCall).
1237
1295
  if (!opts.quiet && out.toolCalls.length) {
1238
- console.log(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
1296
+ sink(opts).note(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
1239
1297
  }
1240
1298
  // Background tasks (Day 37): if a job the model started has finished, slip
1241
1299
  // its <task_notification> in here, right after this round's tool results —
1242
1300
  // the model sees the outcome on the next round without ever having blocked
1243
1301
  // on it. Tasks that are still running stay silent until they finish.
1244
1302
  injectBackgroundNotifications(messages, opts);
1303
+ // Sub-agent results (async task delegation): same pattern — a finished
1304
+ // sub-agent's report is injected here so the model can react next round.
1305
+ injectSubAgentResults(messages, opts);
1245
1306
  // Agent teams (Day 38/39): drain any messages that arrived during this
1246
1307
  // round (results, hand-offs, protocol requests/responses) so the agent
1247
1308
  // reacts to them next round. A shutdown_request seen here just flags the
@@ -1255,7 +1316,7 @@ opts) {
1255
1316
  if (nag) {
1256
1317
  messages.push({ role: "user", content: nag });
1257
1318
  if (!opts.quiet)
1258
- console.log(chalk.dim(" ⎿ plan reminder injected"));
1319
+ sink(opts).note(chalk.dim(" ⎿ plan reminder injected"));
1259
1320
  }
1260
1321
  }
1261
1322
  break; // round complete, move to the next one
package/dist/output.js ADDED
@@ -0,0 +1,50 @@
1
+ import ora from "ora"; // the "thinking…" spinner the default sink drives
2
+ import { MarkdownStream } from "./markdown.js"; // the default sink renders the streamed answer as terminal markdown
3
+ import { mark } from "./ui.js"; // the ⏺ answer marker, kept consistent with the rest of the UI
4
+ // ---- the default sink: stdout, identical to the pre-sink behaviour -----------
5
+ class StdoutSpinner {
6
+ s;
7
+ constructor(text) {
8
+ // discardStdin:false — ora would otherwise swallow Ctrl+C while spinning.
9
+ this.s = ora({ text, discardStdin: false }).start();
10
+ }
11
+ set(text) {
12
+ if (this.s.isSpinning)
13
+ this.s.text = text;
14
+ }
15
+ stop() {
16
+ if (this.s.isSpinning)
17
+ this.s.stop();
18
+ }
19
+ get spinning() {
20
+ return this.s.isSpinning;
21
+ }
22
+ }
23
+ // The sink the readline REPL (and every non-Ink caller) gets when it passes no
24
+ // `output`. Each method is the exact line the loop used to run inline.
25
+ export const STDOUT_OUTPUT = {
26
+ spinner: (text) => new StdoutSpinner(text),
27
+ reasoning: (line) => process.stdout.write(line + "\n"),
28
+ answer: () => {
29
+ // Lazily build the MarkdownStream on the first token (so a tool-only or
30
+ // empty turn prints nothing), then end with a single trailing newline —
31
+ // exactly the old `if (printedPrefix) process.stdout.write("\n")`.
32
+ let md = null;
33
+ let printed = false;
34
+ return {
35
+ push(token) {
36
+ if (!md)
37
+ md = new MarkdownStream((s) => process.stdout.write(s), { firstPrefix: "\n" + mark.answer, indent: " " });
38
+ printed = true;
39
+ md.push(token);
40
+ },
41
+ end() {
42
+ if (md)
43
+ md.end();
44
+ if (printed)
45
+ process.stdout.write("\n");
46
+ },
47
+ };
48
+ },
49
+ note: (line) => console.log(line),
50
+ };
package/dist/tui.js CHANGED
@@ -107,6 +107,18 @@ export function revealToolCalls() {
107
107
  process.stdout.write(toolCallLog.join("\n") + "\n");
108
108
  return true;
109
109
  }
110
+ // Ink variants of the two reveals: RETURN the formatted block (or null) instead
111
+ // of writing to stdout, so the Ink REPL can commit it as a note in its tree.
112
+ export function getReasoning() {
113
+ if (!reasoningTrace.length)
114
+ return null;
115
+ return chalk.dim("💭 model's thinking:\n") + chalk.dim(reasoningTrace.join("\n\n— — —\n\n").replace(/^/gm, " "));
116
+ }
117
+ export function getToolCalls() {
118
+ if (!toolCallLog.length)
119
+ return null;
120
+ return chalk.dim(`tool calls this turn (${toolCallLog.length}):\n`) + toolCallLog.join("\n");
121
+ }
110
122
  // ---- cleanup -----------------------------------------------------------------
111
123
  // Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
112
124
  // of the screen), but keep the hook so callers don't have to change if that
package/dist/ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk"; // the one place color lives
2
+ import { displayWidth } from "./editor.js"; // CJK-/ANSI-aware width, to pad the sent-message bar to the full row
2
3
  // The app's visual theme, in one place. Before this, emoji and colors were
3
4
  // sprinkled across agent.ts and loop.ts — no coherent look, and impossible to
4
5
  // restyle without hunting. This module owns "what the tool looks like": the
@@ -22,14 +23,27 @@ function box(lines) {
22
23
  });
23
24
  return [top, ...body, bottom].join("\n");
24
25
  }
26
+ // The llama — our mascot. Drawn in ASCII so it renders in any terminal.
27
+ // Each line is styled with a warm alpaca-wool tone (ansi256 #215, a sandy gold).
28
+ const LLAMA = [
29
+ " ╱▔▔╲ ╱▔▔╲",
30
+ " ╱ ╲_╱ ╲",
31
+ " ╭──────────────╮",
32
+ " │ ◉ ◉ │",
33
+ " │ ⏝ │",
34
+ " ╰──────┬──────╯",
35
+ " │",
36
+ ].join("\n");
25
37
  // The welcome banner, shown once at startup.
26
38
  export function banner(version, model, host) {
27
- return box([
39
+ const art = chalk.ansi256(215)(LLAMA);
40
+ const info = box([
28
41
  { text: `mini-agent v${version}`, style: chalk.bold.cyan },
29
42
  { text: `${model} · ${host}`, style: chalk.dim },
30
43
  { text: "" },
31
44
  { text: "/help commands @file attach Esc interrupt Ctrl+C quit", style: chalk.dim },
32
45
  ]);
46
+ return art + "\n" + info;
33
47
  }
34
48
  // The input prompt. A styled chevron, not a bare ">". Plan mode (Day 20) gets a
35
49
  // distinct marker so you always know writes are blocked.
@@ -124,11 +138,18 @@ export function inputRule() {
124
138
  export function framedPrompt(planMode) {
125
139
  return planMode ? chalk.yellow.bold("⏸ plan ❯ ") : chalk.cyan.bold("❯ ");
126
140
  }
127
- // How a SUBMITTED message is echoed into the scrollback once you hit Enter: a
128
- // distinct, muted "> …" line (not the bright ❯ prompt) so each turn reads like a
129
- // sent chat message scrolling up, leaving the input box empty below.
141
+ // How a SUBMITTED message is echoed into the scrollback once you hit Enter:
142
+ // a "> …" line on a full-width highlight BAR (like Claude Code), so each turn
143
+ // reads as a distinct sent message scrolling up — not just faint text. The line
144
+ // is padded to the terminal width so the background spans the whole row; if the
145
+ // message is longer than the row it just wraps (the tail line won't be padded,
146
+ // a minor cosmetic). Colors fall back gracefully when the terminal lacks 256.
130
147
  export function sentMessage(text) {
131
- return chalk.dim("> " + text);
148
+ const cols = Math.max(20, process.stdout.columns || 80);
149
+ const content = "> " + text;
150
+ const w = displayWidth(content);
151
+ const padded = w < cols ? content + " ".repeat(cols - w) : content; // fill the row when it fits
152
+ return chalk.bgAnsi256(237).ansi256(252)(padded); // light-grey text on a subtle dark-grey bar
132
153
  }
133
154
  // ---- the selection menu -----------------------------------------------------
134
155
  // Render a numbered list of options with one highlighted. The interactive part