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/agent.js +14 -1
- package/dist/context.js +4 -3
- package/dist/ink/app.js +518 -0
- package/dist/ink/chat.js +22 -0
- package/dist/ink/commands.js +103 -0
- package/dist/ink/launch.js +15 -0
- package/dist/ink/main.js +4 -0
- package/dist/ink/setup.js +104 -0
- package/dist/ink/sink.js +50 -0
- package/dist/loop.js +142 -81
- package/dist/output.js +50 -0
- package/dist/tui.js +12 -0
- package/dist/ui.js +26 -5
- package/package.json +5 -1
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 {
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
:
|
|
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?.
|
|
349
|
-
spinner.
|
|
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
|
-
|
|
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
|
|
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?.
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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?.
|
|
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 (
|
|
450
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1196
|
-
// to stop. Don't return with an unread result on the table:
|
|
1197
|
-
// run one more round so the model can react. Each
|
|
1198
|
-
// this adds at most one round per finished
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
128
|
-
//
|
|
129
|
-
// sent
|
|
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
|
-
|
|
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
|