agent-from-zero 0.32.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/agent.js CHANGED
@@ -4,7 +4,7 @@ import chalk from "chalk"; // terminal colors
4
4
  import readline from "node:readline/promises"; // promise-based terminal input
5
5
  import { createRequire } from "node:module"; // to read package.json for --version
6
6
  import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
7
- import { runLoop, TerminateReason, MAX_RETRIES } from "./loop.js"; // the state machine
7
+ import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.js"; // the state machine
8
8
  import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
9
9
  import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
10
10
  import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
@@ -155,6 +155,7 @@ async function main() {
155
155
  const disconnectMcp = await connectMcpServers();
156
156
  process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
157
157
  process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
158
+ process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed so they don't leave pending promises
158
159
  // The optional LLM permission judge, built once if a settings file enabled it.
159
160
  const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
160
161
  if (judge)
package/dist/ink/app.js CHANGED
@@ -6,7 +6,7 @@ import { formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.
6
6
  import { renderMarkdown } from "../markdown.js"; // model speaks markdown → ANSI, same as the non-Ink REPL
7
7
  import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.js"; // the ask_user form: pure state machine + renderer
8
8
  import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
9
- import { TerminateReason } from "../loop.js"; // how a turn can end
9
+ import { TerminateReason, listSubAgents } from "../loop.js"; // how a turn can end
10
10
  import { compactHistory, COMPACT_AT } from "../context.js"; // /compact
11
11
  import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
12
12
  import { clearUndo } from "../undo.js";
@@ -27,6 +27,25 @@ import { runInfoCommand } from "./commands.js"; // the non-interactive slash com
27
27
  // Injected when plan mode turns on, so the model knows the rules it now lives in.
28
28
  const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
29
29
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // the braille spinner ora uses, hand-rolled for Ink
30
+ // Cap the live streaming preview to a terminal-aware tail. THIS IS LOAD-BEARING:
31
+ // Ink redraws the whole dynamic region (everything below <Static>) on every
32
+ // token, and when that region grows TALLER than the terminal, Ink can no longer
33
+ // erase its previous frame — committed lines get reprinted (duplicate messages)
34
+ // and the cursor math drifts (sudden blank gaps). So we never let the in-flight
35
+ // answer render more than the rows we can spare; the FULL reply still commits to
36
+ // <Static> (formatted as markdown) the moment it finishes.
37
+ function liveTail(s) {
38
+ const rows = process.stdout.rows || 24;
39
+ const maxLines = Math.max(3, rows - 14); // reserve rows for the input box + status bar + agent bar + margins
40
+ const cols = process.stdout.columns || 80;
41
+ const maxChars = maxLines * cols; // rough bound so one very long line can't blow past either
42
+ let t = s.length > maxChars ? s.slice(-maxChars) : s;
43
+ const lines = t.split("\n");
44
+ const clipped = s.length > maxChars || lines.length > maxLines;
45
+ if (lines.length > maxLines)
46
+ t = lines.slice(-maxLines).join("\n");
47
+ return clipped ? "… " + t : t; // a leading ellipsis signals there's more above (it's all in the scrollback on commit)
48
+ }
30
49
  function Spinner() {
31
50
  const [f, setF] = useState(0);
32
51
  useEffect(() => {
@@ -83,6 +102,8 @@ export function App({ session, runTurn }) {
83
102
  const [sessionId, setSessionId] = useState(session.initialSessionId); // changes on /clear, /resume
84
103
  const [planMode, setPlan] = useState(isPlanMode()); // mirrored into the prompt frame
85
104
  const [histIdx, setHistIdx] = useState(null); // ↑/↓ recall position (null = editing a fresh line)
105
+ const [subAgentFocus, setSubAgentFocus] = useState(null); // which sub-agent is highlighted (null = none)
106
+ const [subAgentDetail, setSubAgentDetail] = useState(null); // the id of the sub-agent whose output is shown in detail
86
107
  const [, setTick] = useState(0); // forces a re-render once a second so the clock / cost tick
87
108
  const { exit } = useApp();
88
109
  const pushItem = useRef((it) => setItems((xs) => [...xs, it])).current;
@@ -338,6 +359,12 @@ export function App({ session, runTurn }) {
338
359
  return note(getReasoning() ?? chalk.dim("(no thinking recorded for the last answer)"));
339
360
  if (key.ctrl && char === "t")
340
361
  return note(getToolCalls() ?? chalk.dim("(no tool calls recorded for the last answer)"));
362
+ // Esc while a sub-agent detail is open: close it, don't interrupt.
363
+ if (key.escape && subAgentDetail && !pending) {
364
+ setSubAgentDetail(null);
365
+ setSubAgentFocus(null);
366
+ return;
367
+ }
341
368
  // Esc while a turn runs (and no menu is up): interrupt it — like the first
342
369
  // Ctrl+C of the readline REPL, without the force-quit escalation.
343
370
  if (key.escape && busy && !pending) {
@@ -386,9 +413,53 @@ export function App({ session, runTurn }) {
386
413
  }
387
414
  return;
388
415
  }
416
+ // Tab / Shift+Tab: cycle through the sub-agent list below the input box.
417
+ // Enter on a highlighted sub-agent toggles its detail view.
418
+ const agents = listSubAgents();
419
+ if (agents.length > 0 && !key.return && key.tab) {
420
+ const back = key.shift; // Shift+Tab = go backwards
421
+ if (subAgentDetail) {
422
+ // Detail open: Tab moves to next + closes detail.
423
+ setSubAgentDetail(null);
424
+ setSubAgentFocus((prev) => {
425
+ const n = agents.length;
426
+ if (back) {
427
+ const next = prev === null ? n - 1 : (prev - 1 + n) % n;
428
+ return next;
429
+ }
430
+ const next = prev === null ? 0 : (prev + 1) % n;
431
+ return next;
432
+ });
433
+ }
434
+ else {
435
+ setSubAgentDetail(null);
436
+ setSubAgentFocus((prev) => {
437
+ const n = agents.length;
438
+ if (back) {
439
+ if (prev === null)
440
+ return n - 1;
441
+ const next = (prev - 1 + n) % n;
442
+ return next === n - 1 ? null : next;
443
+ }
444
+ if (prev === null)
445
+ return 0;
446
+ const next = (prev + 1) % n;
447
+ return next === 0 ? null : next;
448
+ });
449
+ }
450
+ return;
451
+ }
452
+ // Enter with a sub-agent highlighted: show its detail.
453
+ if (agents.length > 0 && subAgentFocus !== null && key.return) {
454
+ setSubAgentDetail(agents[subAgentFocus].id === subAgentDetail ? null : agents[subAgentFocus].id);
455
+ return;
456
+ }
389
457
  if (busy)
390
458
  return; // a turn in flight with no prompt — ignore typing for now (type-ahead is later)
391
459
  if (key.return) {
460
+ // Dismiss the sub-agent detail/focus on a normal submit.
461
+ setSubAgentDetail(null);
462
+ setSubAgentFocus(null);
392
463
  const text = input.trim();
393
464
  setInput("");
394
465
  setHistIdx(null);
@@ -431,5 +502,17 @@ export function App({ session, runTurn }) {
431
502
  setInput((s) => s + add);
432
503
  }
433
504
  });
434
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : live }) })] })), status !== null && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsxs(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: planMode ? "magenta" : "cyan", children: planMode ? "plan ❯ " : "❯ " }), _jsx(Text, { children: input }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(StatusBar, { model: model, dir: dir, branch: branch, status: getStatus() })] }));
505
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsxs(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: planMode ? "magenta" : "cyan", children: planMode ? "plan ❯ " : "❯ " }), _jsx(Text, { children: input }), _jsx(Text, { inverse: true, children: " " })] }), (() => {
506
+ const agents = listSubAgents();
507
+ if (!agents.length)
508
+ return null;
509
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "agents \u00B7 " }), agents.map((sa, i) => (_jsxs(React.Fragment, { children: [_jsx(Text, { color: i === subAgentFocus ? "cyan" : "blue", bold: i === subAgentFocus, children: sa.id }), _jsx(Text, { dimColor: true, children: sa.status === "running" ? " ●" : sa.status === "done" ? " ✓" : " ✗" }), i < agents.length - 1 && _jsx(Text, { dimColor: true, children: " \u2502 " })] }, sa.id))), _jsx(Text, { dimColor: true, children: " \u00B7 Tab" })] }), subAgentDetail && (() => {
510
+ const detail = agents.find((sa) => sa.id === subAgentDetail);
511
+ if (!detail)
512
+ return null;
513
+ const head = `${detail.id} · ${detail.status === "running" ? "running" : detail.status === "done" ? "done" : "failed"}`;
514
+ const body = detail.result ?? "(still working…)";
515
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 0, borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: head }), _jsx(Text, { dimColor: true, children: detail.description }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: body.length > 500 ? body.slice(0, 500) + "\n…" : body }) })] }));
516
+ })()] }));
517
+ })(), _jsx(StatusBar, { model: model, dir: dir, branch: branch, status: getStatus() })] }));
435
518
  }
package/dist/ink/setup.js CHANGED
@@ -15,6 +15,7 @@ import { registerExternalTool } from "../tools.js";
15
15
  import { rememberTool, readMemory } from "../memory.js";
16
16
  import { loadSkills, buildSkillTool } from "../skills.js";
17
17
  import { killAllBackground } from "../background.js";
18
+ import { killAllSubAgents } from "../loop.js";
18
19
  import { banner } from "../ui.js";
19
20
  // The Ink REPL's session bootstrap — the same wiring agent.ts (the readline REPL)
20
21
  // does before the prompt loop, but front-end-agnostic: it touches none of
@@ -51,6 +52,7 @@ export async function buildInkSession(opts = {}) {
51
52
  const disconnectMcp = await connectMcpServers();
52
53
  process.on("exit", disconnectMcp);
53
54
  process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
55
+ process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed
54
56
  const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
55
57
  if (judge)
56
58
  notices.push(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`);
package/dist/loop.js CHANGED
@@ -55,6 +55,7 @@ const taskTool = {
55
55
  description: `Delegate one self-contained subtask to a sub-agent that works in a FRESH context.
56
56
  The sub-agent has the same tools (except task) but knows NOTHING about this conversation — put every detail it needs into the description.
57
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.
58
59
  The sub-agent's report is INPUT MATERIAL, not verified truth — re-check key claims yourself before acting on them.
59
60
  On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a clearer description, or do the work yourself.`,
60
61
  parameters: {
@@ -277,37 +278,94 @@ function toolsFor(opts) {
277
278
  // watching the board (it lays out tasks; teammates pull from it).
278
279
  return [...builtins, taskTool, askUserTool, spawnTeammateTool, sendMessageTool, requestShutdownTool, requestPlanTool, reviewPlanTool, createTaskTool, listTasksTool];
279
280
  }
280
- // Run one sub-agent: a fresh conversation, same tools minus task, same
281
- // permission gate, and the parent's file read-state protected by a snapshot.
282
- async function runSubAgent(description, opts) {
283
- emit("agent_subagent_spawn"); // delegation is worth counting
284
- const subModel = subAgentModelFor(opts); // the tier this delegated work runs on
285
- // Show the delegation, and the model when it differs from the orchestrator's —
286
- // 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 });
287
306
  if (!opts.quiet)
288
- sink(opts).note(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
289
- await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
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}`;
316
+ if (!opts.quiet)
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)
290
319
  const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
291
- try {
292
- const subMessages = [
293
- { role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
294
- { role: "user", content: description }, // the task is its entire world
295
- ];
296
- const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel }); // recurse as a sub-agent, on its own tier
297
- if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
298
- // The framing matters: the parent must treat this as material to verify,
299
- // not as conclusions to copy — the most common multi-agent failure mode.
300
- 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
+ }
301
334
  }
302
- return `[sub-agent failed: ${result.reason}]`; // any non-Done ending, compressed to one line
303
- }
304
- finally {
305
- restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
306
- await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
307
- if (!opts.quiet)
308
- sink(opts).note(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]";
309
355
  }
310
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
+ }
311
369
  // Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
312
370
  // Without jitter, every client that failed at the same second retries at the
313
371
  // same second — a thundering herd against an already-struggling server.
@@ -613,7 +671,7 @@ async function execute(call, opts) {
613
671
  }
614
672
  if (!description)
615
673
  return "[error] task requires a non-empty description argument.";
616
- return runSubAgent(description, opts); // spawn the worker
674
+ return spawnSubAgent(description, opts); // spawn the worker (async — returns immediately; result injected later)
617
675
  }
618
676
  // The sender's mailbox name: a teammate sends as itself, everyone else as "lead".
619
677
  const senderName = (opts) => opts.teammate?.name ?? LEAD;
@@ -1190,12 +1248,14 @@ opts) {
1190
1248
  break; // exit the inner while → advance the round counter
1191
1249
  }
1192
1250
  }
1193
- // A background task may have finished in the very round the model decided
1194
- // to stop. Don't return with an unread result on the table: surface it and
1195
- // run one more round so the model can react. Each task notifies once, so
1196
- // 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.
1197
1255
  if (injectBackgroundNotifications(messages, opts))
1198
1256
  break; // exit the inner while → advance the round counter
1257
+ if (injectSubAgentResults(messages, opts))
1258
+ break;
1199
1259
  // Agent teams (Day 38/39): the lead waits for teammate messages and
1200
1260
  // reacts; a teammate idle-loops for new work and leaves only on the
1201
1261
  // shutdown handshake (or its idle backstop). If there's something to
@@ -1240,6 +1300,9 @@ opts) {
1240
1300
  // the model sees the outcome on the next round without ever having blocked
1241
1301
  // on it. Tasks that are still running stay silent until they finish.
1242
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);
1243
1306
  // Agent teams (Day 38/39): drain any messages that arrived during this
1244
1307
  // round (results, hand-offs, protocol requests/responses) so the agent
1245
1308
  // reacts to them next round. A shutdown_request seen here just flags the
package/dist/ui.js CHANGED
@@ -23,14 +23,27 @@ function box(lines) {
23
23
  });
24
24
  return [top, ...body, bottom].join("\n");
25
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");
26
37
  // The welcome banner, shown once at startup.
27
38
  export function banner(version, model, host) {
28
- return box([
39
+ const art = chalk.ansi256(215)(LLAMA);
40
+ const info = box([
29
41
  { text: `mini-agent v${version}`, style: chalk.bold.cyan },
30
42
  { text: `${model} · ${host}`, style: chalk.dim },
31
43
  { text: "" },
32
44
  { text: "/help commands @file attach Esc interrupt Ctrl+C quit", style: chalk.dim },
33
45
  ]);
46
+ return art + "\n" + info;
34
47
  }
35
48
  // The input prompt. A styled chevron, not a bare ">". Plan mode (Day 20) gets a
36
49
  // distinct marker so you always know writes are blocked.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
5
5
  "type": "module",
6
6
  "bin": {