agent-from-zero 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.js +16 -4
- package/dist/context.js +4 -3
- package/dist/editor.js +15 -4
- package/dist/ink/app.js +435 -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 +102 -0
- package/dist/ink/sink.js +50 -0
- package/dist/loop.js +50 -52
- package/dist/output.js +50 -0
- package/dist/tui.js +12 -0
- package/dist/ui.js +14 -0
- package/package.json +6 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG } from "../config.js";
|
|
4
|
+
import { statsReport } from "../telemetry.js";
|
|
5
|
+
import { readMemoryTyped, MEMORY_PATH } from "../memory.js";
|
|
6
|
+
import { getTodos, renderTodos } from "../todos.js";
|
|
7
|
+
import { listBackground } from "../background.js";
|
|
8
|
+
import { listTeam } from "../team.js";
|
|
9
|
+
import { boardSummary } from "../board.js";
|
|
10
|
+
import { undoLast, sessionChanges } from "../undo.js";
|
|
11
|
+
import { renderDiff } from "../diff.js";
|
|
12
|
+
// The non-interactive slash commands: each produces a block of text to commit as
|
|
13
|
+
// a note. Lifted from agent.ts's handleCommand, with console.log → a returned
|
|
14
|
+
// string so the Ink REPL can render them inside its tree. The stateful commands
|
|
15
|
+
// (/clear, /compact, /plan, /model, /resume, /skill) stay in the App — they
|
|
16
|
+
// mutate the conversation or pop an interactive menu.
|
|
17
|
+
// The in-session command reference shown by /help.
|
|
18
|
+
export const SESSION_HELP = `commands:
|
|
19
|
+
/help this text
|
|
20
|
+
/clear wipe the conversation (and the file read-state) — fresh start
|
|
21
|
+
/compact compact the history into a summary right now (happens automatically near the limit)
|
|
22
|
+
/model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
|
|
23
|
+
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
24
|
+
/memory show the durable facts the agent remembers about this project
|
|
25
|
+
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
26
|
+
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
27
|
+
/todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
|
|
28
|
+
/bg list background tasks this session (run_bash_background) and their status
|
|
29
|
+
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
30
|
+
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
31
|
+
/undo revert the most recent file write (write_file / edit_file) this session
|
|
32
|
+
/diff show every file changed this session, as a diff from where it started
|
|
33
|
+
/resume list recent sessions in this project and continue one of them
|
|
34
|
+
/skills list the reusable skills available in this project
|
|
35
|
+
/skill <name> run a skill yourself (works even for user-only skills)
|
|
36
|
+
exit leave (Ctrl+C does the same)
|
|
37
|
+
|
|
38
|
+
keys (at the prompt):
|
|
39
|
+
Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
|
|
40
|
+
Ctrl+T reveal the folded tool-call trace for the last answer`;
|
|
41
|
+
// Run a non-interactive command. Returns the note text to display, or null if
|
|
42
|
+
// `line` isn't one of these (the App handles the rest).
|
|
43
|
+
export function runInfoCommand(line, ctx) {
|
|
44
|
+
switch (line) {
|
|
45
|
+
case "/help":
|
|
46
|
+
return chalk.dim(SESSION_HELP);
|
|
47
|
+
case "/cost":
|
|
48
|
+
return chalk.dim(ctx.costMeter.report()); // tokens, cache hit rate, estimated spend (local)
|
|
49
|
+
case "/stats":
|
|
50
|
+
return chalk.dim(statsReport()); // local counters, busiest first
|
|
51
|
+
case "/memory": {
|
|
52
|
+
const entries = readMemoryTyped();
|
|
53
|
+
return chalk.dim(entries.length
|
|
54
|
+
? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
|
|
55
|
+
: `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`);
|
|
56
|
+
}
|
|
57
|
+
case "/todos": {
|
|
58
|
+
const todos = getTodos();
|
|
59
|
+
return todos.length ? renderTodos(todos) : chalk.dim("(no plan yet — the agent writes one with todo_write on multi-step tasks)");
|
|
60
|
+
}
|
|
61
|
+
case "/bg": {
|
|
62
|
+
const bg = listBackground();
|
|
63
|
+
if (!bg.length)
|
|
64
|
+
return chalk.dim("(no background tasks — the agent starts them with run_bash_background for slow commands)");
|
|
65
|
+
const icon = { running: chalk.yellow("●"), completed: chalk.green("✓"), failed: chalk.red("✗"), killed: chalk.dim("∅") };
|
|
66
|
+
return chalk.dim("background tasks this session:\n") + bg.map((t) => chalk.dim(` ${icon[t.status]} ${t.id} [${t.status}, ${t.elapsed}s] — ${t.command.slice(0, 70)}`)).join("\n");
|
|
67
|
+
}
|
|
68
|
+
case "/team": {
|
|
69
|
+
const team = listTeam();
|
|
70
|
+
if (!team.length)
|
|
71
|
+
return chalk.dim("(no team — the agent forms one with spawn_teammate for large parallel tasks)");
|
|
72
|
+
const icon = { active: chalk.yellow("●"), idle: chalk.cyan("◐"), done: chalk.green("✓"), failed: chalk.red("✗") };
|
|
73
|
+
return (chalk.dim("agent team this session:\n") +
|
|
74
|
+
team.map((t) => chalk.dim(` ${icon[t.status] ?? "?"} ${t.name} [${t.status}, ${t.elapsed}s] — ${t.role.slice(0, 60)}${t.pending ? chalk.yellow(` · ${t.pending} unread`) : ""}`)).join("\n"));
|
|
75
|
+
}
|
|
76
|
+
case "/tasks": {
|
|
77
|
+
const summary = boardSummary();
|
|
78
|
+
return summary === "(the task board is empty)" ? chalk.dim("(no tasks — the lead adds them with create_task; teammates claim them autonomously)") : chalk.dim(`task board:\n${summary}`);
|
|
79
|
+
}
|
|
80
|
+
case "/skills": {
|
|
81
|
+
if (!ctx.skills.length)
|
|
82
|
+
return chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)");
|
|
83
|
+
return (chalk.dim("skills in this project:\n") +
|
|
84
|
+
ctx.skills.map((s) => chalk.dim(` ${s.name} [${s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user")}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`)).join("\n"));
|
|
85
|
+
}
|
|
86
|
+
case "/undo": {
|
|
87
|
+
const undone = undoLast();
|
|
88
|
+
if (!undone)
|
|
89
|
+
return chalk.dim("(nothing to undo — no file writes recorded this session)");
|
|
90
|
+
return chalk.dim(`↩ ${undone.summary}`) + "\n" + renderDiff(undone.after ?? "", undone.before ?? "");
|
|
91
|
+
}
|
|
92
|
+
case "/diff": {
|
|
93
|
+
const changes = sessionChanges();
|
|
94
|
+
if (!changes.length)
|
|
95
|
+
return chalk.dim("(no file changes this session)");
|
|
96
|
+
const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
|
|
97
|
+
const head = chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`);
|
|
98
|
+
return head + "\n" + changes.map((c) => `\n${verb[c.status]} ${path.relative(process.cwd(), c.path) || c.path}\n${renderDiff(c.baseline, c.current)}`).join("");
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
return null; // not an info command — the App handles it (or it's a normal task line)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { buildInkSession } from "./setup.js"; // the front-end-agnostic session bootstrap
|
|
4
|
+
import { makeRunTurn } from "./chat.js"; // drives one turn through the real loop
|
|
5
|
+
import { App } from "./app.js"; // the Ink REPL
|
|
6
|
+
// Launch the Ink REPL and resolve when it exits. buildInkSession does the same
|
|
7
|
+
// wiring agent.ts does (MCP, skills, judge, memory, cost, hooks, telemetry,
|
|
8
|
+
// optional resume); App renders it and runs the loop. Exported so BOTH the
|
|
9
|
+
// `npm run ink` entry (main.tsx) and agent.ts (the default `mini-agent` entry,
|
|
10
|
+
// once it hands interactive sessions to Ink) can start it.
|
|
11
|
+
export async function launchInk(opts = {}) {
|
|
12
|
+
const session = await buildInkSession({ resume: opts.resume });
|
|
13
|
+
const { waitUntilExit } = render(_jsx(App, { session: session, runTurn: makeRunTurn(session.client, session.messages) }));
|
|
14
|
+
await waitUntilExit(); // keep the process alive until the user quits
|
|
15
|
+
}
|
package/dist/ink/main.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { launchInk } from "./launch.js"; // the reusable launcher (shared with agent.ts)
|
|
2
|
+
// Direct entry for the Ink REPL: npm run ink. agent.ts launches the same thing
|
|
3
|
+
// for a normal `mini-agent` interactive session.
|
|
4
|
+
await launchInk({ resume: process.argv.includes("-r") || process.argv.includes("--resume") });
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { CONFIG, requireApiKey } from "../config.js";
|
|
5
|
+
import { buildSystemMessage } from "../prompt.js";
|
|
6
|
+
import { estimateHistoryTokens } from "../context.js";
|
|
7
|
+
import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
|
|
8
|
+
import { gitBranch } from "../tui.js";
|
|
9
|
+
import { newSessionId, latestSession } from "../session.js";
|
|
10
|
+
import { initTelemetry, emit } from "../telemetry.js";
|
|
11
|
+
import { runHooks } from "../hooks.js";
|
|
12
|
+
import { connectMcpServers } from "../mcp.js";
|
|
13
|
+
import { Judge } from "../judge.js";
|
|
14
|
+
import { registerExternalTool } from "../tools.js";
|
|
15
|
+
import { rememberTool, readMemory } from "../memory.js";
|
|
16
|
+
import { loadSkills, buildSkillTool } from "../skills.js";
|
|
17
|
+
import { killAllBackground } from "../background.js";
|
|
18
|
+
import { banner } from "../ui.js";
|
|
19
|
+
// The Ink REPL's session bootstrap — the same wiring agent.ts (the readline REPL)
|
|
20
|
+
// does before the prompt loop, but front-end-agnostic: it touches none of
|
|
21
|
+
// readline/editLine, just the shared modules (MCP, skills, judge, memory, cost,
|
|
22
|
+
// hooks, telemetry, session). It returns everything the Ink App needs to run a
|
|
23
|
+
// real session, so main.tsx stays a thin render() call. (agent.ts is left
|
|
24
|
+
// untouched so `npm start` keeps working until 2d flips the default.)
|
|
25
|
+
const pkg = createRequire(import.meta.url)("../../package.json");
|
|
26
|
+
// Build the session. `resume` mirrors `-r/--resume`: fold the most recent
|
|
27
|
+
// conversation in this directory into the fresh constitution.
|
|
28
|
+
export async function buildInkSession(opts = {}) {
|
|
29
|
+
requireApiKey();
|
|
30
|
+
const client = new OpenAI({ baseURL: CONFIG.baseURL, apiKey: CONFIG.apiKey, maxRetries: 0 });
|
|
31
|
+
const systemMessage = buildSystemMessage(); // stable prefix = cache hits every request
|
|
32
|
+
const messages = [{ role: "system", content: systemMessage }];
|
|
33
|
+
const notices = [];
|
|
34
|
+
let sessionId = newSessionId();
|
|
35
|
+
const startedAt = Date.now();
|
|
36
|
+
if (opts.resume) {
|
|
37
|
+
const prev = latestSession();
|
|
38
|
+
if (prev) {
|
|
39
|
+
messages.push(...prev.messages);
|
|
40
|
+
sessionId = prev.id; // keep appending to the same file
|
|
41
|
+
notices.push(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
notices.push("(no previous session here — starting fresh)");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
initTelemetry(sessionId);
|
|
48
|
+
emit("agent_session_start", { mode: "repl" });
|
|
49
|
+
// MCP servers — must finish before the first model call so their tools appear
|
|
50
|
+
// in the manual. A server that fails to start is skipped, never fatal.
|
|
51
|
+
const disconnectMcp = await connectMcpServers();
|
|
52
|
+
process.on("exit", disconnectMcp);
|
|
53
|
+
process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
|
|
54
|
+
const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
55
|
+
if (judge)
|
|
56
|
+
notices.push(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`);
|
|
57
|
+
// The remember tool flows through the same permission gate as any tool.
|
|
58
|
+
registerExternalTool(rememberTool);
|
|
59
|
+
const memCount = readMemory().length;
|
|
60
|
+
if (memCount)
|
|
61
|
+
notices.push(`(long-term memory: ${memCount} facts loaded)`);
|
|
62
|
+
// Skills: model-invocable ones get the single `skill` tool; user-only ones are
|
|
63
|
+
// reachable via /skill <name>.
|
|
64
|
+
const skills = loadSkills();
|
|
65
|
+
if (skills.some((s) => !s.disableModelInvocation))
|
|
66
|
+
registerExternalTool(buildSkillTool(skills));
|
|
67
|
+
if (skills.length)
|
|
68
|
+
notices.push(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`);
|
|
69
|
+
const costMeter = initCostMeter({
|
|
70
|
+
inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
|
|
71
|
+
cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
|
|
72
|
+
outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
|
|
73
|
+
});
|
|
74
|
+
// SessionStart hooks run once; their stdout becomes context on the first turn.
|
|
75
|
+
const sessionStart = await runHooks("SessionStart", {});
|
|
76
|
+
if (sessionStart.stdout)
|
|
77
|
+
messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
|
|
78
|
+
const dir = path.basename(process.cwd());
|
|
79
|
+
const branch = gitBranch();
|
|
80
|
+
const getStatus = () => ({
|
|
81
|
+
ctxPct: Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100)),
|
|
82
|
+
cost: costMeter.cost(),
|
|
83
|
+
elapsedMs: Date.now() - startedAt,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
client,
|
|
87
|
+
messages,
|
|
88
|
+
systemMessage,
|
|
89
|
+
initialSessionId: sessionId,
|
|
90
|
+
startedAt,
|
|
91
|
+
costMeter,
|
|
92
|
+
skills,
|
|
93
|
+
judge,
|
|
94
|
+
model: CONFIG.model,
|
|
95
|
+
dir,
|
|
96
|
+
branch,
|
|
97
|
+
bannerText: banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host),
|
|
98
|
+
notices,
|
|
99
|
+
getStatus,
|
|
100
|
+
disconnectMcp,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/ink/sink.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Build a LoopOutput backed by Ink state. Pure glue: every method maps a loop
|
|
2
|
+
// event to a state change.
|
|
3
|
+
export function makeInkSink(api) {
|
|
4
|
+
return {
|
|
5
|
+
// A spinner becomes the single "status" line below the conversation. A fresh
|
|
6
|
+
// handle per model call (the loop makes one per round); `active` mirrors
|
|
7
|
+
// ora's isSpinning so the loop's `spinner?.spinning` checks behave the same.
|
|
8
|
+
spinner(text) {
|
|
9
|
+
api.setStatus(text);
|
|
10
|
+
let active = true;
|
|
11
|
+
return {
|
|
12
|
+
set: (t) => {
|
|
13
|
+
if (active)
|
|
14
|
+
api.setStatus(t);
|
|
15
|
+
},
|
|
16
|
+
stop: () => {
|
|
17
|
+
if (active) {
|
|
18
|
+
active = false;
|
|
19
|
+
api.setStatus(null);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
get spinning() {
|
|
23
|
+
return active;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
// The "💭 thought for Ns" indicator is just a committed line.
|
|
28
|
+
reasoning: (line) => api.pushItem({ kind: "note", text: line }),
|
|
29
|
+
// The streamed answer accumulates in the dynamic region as raw text (live,
|
|
30
|
+
// "it's typing"), then commits as ONE markdown-rendered item when it ends —
|
|
31
|
+
// partial markdown mid-stream renders badly, so we format only once it's whole.
|
|
32
|
+
answer() {
|
|
33
|
+
let buf = "";
|
|
34
|
+
api.setLive("");
|
|
35
|
+
return {
|
|
36
|
+
push: (tok) => {
|
|
37
|
+
buf += tok;
|
|
38
|
+
api.setLive(buf);
|
|
39
|
+
},
|
|
40
|
+
end: () => {
|
|
41
|
+
api.setLive(null); // clear the live preview first, then commit (no flicker/duplicate)
|
|
42
|
+
if (buf.trim())
|
|
43
|
+
api.pushItem({ kind: "answer", text: buf });
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
// Generic narration — already chalk-formatted by the loop; just commit it.
|
|
48
|
+
note: (line) => api.pushItem({ kind: "note", text: line }),
|
|
49
|
+
};
|
|
50
|
+
}
|
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.
|
|
@@ -281,7 +285,7 @@ async function runSubAgent(description, opts) {
|
|
|
281
285
|
// Show the delegation, and the model when it differs from the orchestrator's —
|
|
282
286
|
// the tiering should be visible, not a silent surprise on the bill.
|
|
283
287
|
if (!opts.quiet)
|
|
284
|
-
|
|
288
|
+
sink(opts).note(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
|
|
285
289
|
await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
|
|
286
290
|
const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
|
|
287
291
|
try {
|
|
@@ -301,7 +305,7 @@ async function runSubAgent(description, opts) {
|
|
|
301
305
|
restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
|
|
302
306
|
await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
|
|
303
307
|
if (!opts.quiet)
|
|
304
|
-
|
|
308
|
+
sink(opts).note(mark.subAgentDone); // close the bracket
|
|
305
309
|
}
|
|
306
310
|
}
|
|
307
311
|
// Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
|
|
@@ -335,7 +339,7 @@ opts) {
|
|
|
335
339
|
let reasoningShown = false; // have we printed the "thought for Ns" indicator yet?
|
|
336
340
|
const spinner = opts.quiet
|
|
337
341
|
? null // the eval harness wants silence
|
|
338
|
-
:
|
|
342
|
+
: sink(opts).spinner(spinnerText(word, 0, !!opts.subAgent, opts.model)); // a fresh spinner from the sink (stdout → ora; Ink → React state)
|
|
339
343
|
let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
|
|
340
344
|
let stallWarned = false; // only warn once per quiet stretch
|
|
341
345
|
// The two-level watchdog, checked once per second:
|
|
@@ -345,8 +349,8 @@ opts) {
|
|
|
345
349
|
const watchdog = setInterval(() => {
|
|
346
350
|
// While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
|
|
347
351
|
// reads differently from "it's waiting on the first token".
|
|
348
|
-
if (spinner?.
|
|
349
|
-
spinner.
|
|
352
|
+
if (spinner?.spinning)
|
|
353
|
+
spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4)));
|
|
350
354
|
const quietMs = Date.now() - lastEvent; // ms since the last event
|
|
351
355
|
if (quietMs > IDLE_TIMEOUT_MS) {
|
|
352
356
|
emit("agent_watchdog_idle"); // record the cut — these should be rare
|
|
@@ -355,7 +359,7 @@ opts) {
|
|
|
355
359
|
else if (quietMs > STALL_WARN_MS && !stallWarned) {
|
|
356
360
|
stallWarned = true; // don't repeat the warning every second
|
|
357
361
|
emit("agent_watchdog_stall"); // record the slowness — pattern-spotting data
|
|
358
|
-
|
|
362
|
+
sink(opts).note(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
|
|
359
363
|
}
|
|
360
364
|
}, 1000);
|
|
361
365
|
try {
|
|
@@ -370,8 +374,7 @@ opts) {
|
|
|
370
374
|
// teammate's manual simply omits spawn_teammate and task.
|
|
371
375
|
{ model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
|
|
372
376
|
let content = ""; // accumulated answer text
|
|
373
|
-
let
|
|
374
|
-
let md = null; // renders the streamed answer as markdown, block by block
|
|
377
|
+
let ans = null; // renders the streamed answer (stdout: markdown block by block; Ink: React state) — non-null once the answer starts
|
|
375
378
|
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
376
379
|
// The model has finished thinking and is about to say/do something. Close out
|
|
377
380
|
// the spinner, stash the trace, and print a single one-line indicator with the
|
|
@@ -382,7 +385,7 @@ opts) {
|
|
|
382
385
|
return;
|
|
383
386
|
reasoningShown = true;
|
|
384
387
|
reasoningActive = false;
|
|
385
|
-
if (spinner?.
|
|
388
|
+
if (spinner?.spinning)
|
|
386
389
|
spinner.stop();
|
|
387
390
|
if (opts.quiet || opts.subAgent)
|
|
388
391
|
return; // no UI for silent / nested runs
|
|
@@ -390,7 +393,7 @@ opts) {
|
|
|
390
393
|
const secs = Math.round((Date.now() - startedAt) / 1000);
|
|
391
394
|
const toks = Math.round(reasoningBuf.length / 4); // same ~chars/4 estimate as the spinner
|
|
392
395
|
const tokStr = toks >= 1000 ? `${(toks / 1000).toFixed(1)}k` : `${toks}`;
|
|
393
|
-
|
|
396
|
+
sink(opts).reasoning(chalk.dim(`💭 thought for ${secs}s (~${tokStr} tokens) · Ctrl+R to view`));
|
|
394
397
|
};
|
|
395
398
|
for await (const chunk of stream) {
|
|
396
399
|
lastEvent = Date.now(); // feed the watchdog
|
|
@@ -414,27 +417,24 @@ opts) {
|
|
|
414
417
|
if (delta.content) {
|
|
415
418
|
flushReasoning(); // thinking is over — show the one-line indicator first
|
|
416
419
|
streamedChars += delta.content.length; // count the answer toward the live token estimate
|
|
417
|
-
if (spinner?.
|
|
420
|
+
if (spinner?.spinning)
|
|
418
421
|
spinner.stop(); // first token: replace the spinner with real output
|
|
419
422
|
if (!opts.quiet && !opts.subAgent) {
|
|
420
423
|
// 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)
|
|
424
|
+
// inner monologue would be mistaken for the answer. The sink decides
|
|
425
|
+
// how to paint: the stdout sink buffers a markdown block (paragraph /
|
|
426
|
+
// heading / list / table) and flushes it whole — live, but no raw
|
|
427
|
+
// `##`/`|---|`; the Ink sink accumulates into React state.
|
|
428
|
+
if (!ans)
|
|
429
|
+
ans = sink(opts).answer(); // first token: start the answer renderer
|
|
430
|
+
ans.push(delta.content); // hand the token to the renderer (it decides when to paint)
|
|
431
431
|
}
|
|
432
432
|
content += delta.content; // always keep it for the history (the answer only, never the reasoning)
|
|
433
433
|
}
|
|
434
434
|
if (delta.tool_calls?.length)
|
|
435
435
|
flushReasoning(); // thinking is over — indicator before the tool logs (which print after we return)
|
|
436
436
|
for (const tc of delta.tool_calls ?? []) {
|
|
437
|
-
if (spinner?.
|
|
437
|
+
if (spinner?.spinning)
|
|
438
438
|
spinner.stop(); // tool call starting — spinner served its purpose
|
|
439
439
|
const slot = (calls[tc.index] ??= { id: "", name: "", args: "" }); // create the slot on first fragment
|
|
440
440
|
if (tc.id)
|
|
@@ -446,15 +446,13 @@ opts) {
|
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
448
|
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
|
|
449
|
+
if (ans)
|
|
450
|
+
ans.end(); // flush the final (un-terminated) block + end the line cleanly (the sink owns the trailing newline)
|
|
453
451
|
return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
|
|
454
452
|
}
|
|
455
453
|
finally {
|
|
456
454
|
clearInterval(watchdog); // always stop the timer
|
|
457
|
-
if (spinner?.
|
|
455
|
+
if (spinner?.spinning)
|
|
458
456
|
spinner.stop(); // and never leave a zombie spinner
|
|
459
457
|
}
|
|
460
458
|
}
|
|
@@ -487,13 +485,13 @@ async function runOneCall(call, opts) {
|
|
|
487
485
|
if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
|
|
488
486
|
const preview = previewChange(call.name, call.args);
|
|
489
487
|
if (preview)
|
|
490
|
-
|
|
488
|
+
sink(opts).note(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
|
|
491
489
|
}
|
|
492
490
|
let content; // what goes back to the model as the tool result
|
|
493
491
|
if (v.decision === "deny") {
|
|
494
492
|
emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
|
|
495
493
|
if (!opts.quiet)
|
|
496
|
-
|
|
494
|
+
sink(opts).note(mark.denied(v.reason)); // tell the user we blocked it
|
|
497
495
|
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
496
|
}
|
|
499
497
|
else if (v.decision === "ask") {
|
|
@@ -510,14 +508,14 @@ async function runOneCall(call, opts) {
|
|
|
510
508
|
if (cmd && (await opts.judge.classify(cmd)) === "allow") {
|
|
511
509
|
autoAllowed = true;
|
|
512
510
|
if (!opts.quiet)
|
|
513
|
-
|
|
511
|
+
sink(opts).note(mark.judge); // visible — the user can see the judge worked
|
|
514
512
|
}
|
|
515
513
|
}
|
|
516
514
|
const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
|
|
517
515
|
if (!ok)
|
|
518
516
|
emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
|
|
519
517
|
if (!ok && !opts.quiet)
|
|
520
|
-
|
|
518
|
+
sink(opts).note(mark.declined); // make the refusal visible
|
|
521
519
|
content = ok
|
|
522
520
|
? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
|
|
523
521
|
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
@@ -528,7 +526,7 @@ async function runOneCall(call, opts) {
|
|
|
528
526
|
// todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
|
|
529
527
|
// the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
|
|
530
528
|
if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
|
|
531
|
-
|
|
529
|
+
sink(opts).note(renderTodos(getTodos()));
|
|
532
530
|
}
|
|
533
531
|
return { id: call.id, content }; // paired by id for the API
|
|
534
532
|
}
|
|
@@ -546,7 +544,7 @@ async function runWithHooks(call, opts) {
|
|
|
546
544
|
if (pre.block) {
|
|
547
545
|
emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
|
|
548
546
|
if (!opts.quiet)
|
|
549
|
-
|
|
547
|
+
sink(opts).note(mark.hookBlock("PreToolUse")); // make it visible
|
|
550
548
|
return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
|
|
551
549
|
}
|
|
552
550
|
// A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
|
|
@@ -556,7 +554,7 @@ async function runWithHooks(call, opts) {
|
|
|
556
554
|
try {
|
|
557
555
|
JSON.parse(pre.rewrite); // must be valid JSON args
|
|
558
556
|
if (!opts.quiet)
|
|
559
|
-
|
|
557
|
+
sink(opts).note(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
|
|
560
558
|
call = { ...call, args: pre.rewrite };
|
|
561
559
|
}
|
|
562
560
|
catch {
|
|
@@ -744,7 +742,7 @@ async function runCreateTask(call, opts) {
|
|
|
744
742
|
return "[error] create_task needs a 'subject'.";
|
|
745
743
|
const task = createTask(subject, description, blockedBy);
|
|
746
744
|
if (!opts.quiet)
|
|
747
|
-
|
|
745
|
+
sink(opts).note(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
|
|
748
746
|
return `Created ${task.id}: "${subject}"${blockedBy.length ? ` (blockedBy: ${blockedBy.join(", ")})` : ""}. It's on the board for a teammate to claim.`;
|
|
749
747
|
}
|
|
750
748
|
async function runListTasks(_call, opts) {
|
|
@@ -781,7 +779,7 @@ async function runCompleteTask(call, opts) {
|
|
|
781
779
|
if (!res.ok)
|
|
782
780
|
return `[error] ${res.error}`;
|
|
783
781
|
if (!opts.quiet)
|
|
784
|
-
|
|
782
|
+
sink(opts).note(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
|
|
785
783
|
const unblocked = res.unblocked?.length ? ` Unblocked: ${res.unblocked.join(", ")}.` : "";
|
|
786
784
|
return `Completed ${taskId}.${unblocked} Claim the next ready task, or idle until the lead shuts you down.`;
|
|
787
785
|
}
|
|
@@ -817,7 +815,7 @@ async function runSpawnTeammate(call, opts) {
|
|
|
817
815
|
const done = runTeammate(name, role, task, opts);
|
|
818
816
|
registerTeammate(name, role, done);
|
|
819
817
|
if (!opts.quiet)
|
|
820
|
-
|
|
818
|
+
sink(opts).note(mark.teammateStart(name, role));
|
|
821
819
|
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
820
|
}
|
|
823
821
|
// Run one teammate to completion: a fresh conversation seeded with its task, a
|
|
@@ -847,7 +845,7 @@ async function runTeammate(name, role, task, opts) {
|
|
|
847
845
|
subAgent: true,
|
|
848
846
|
teammate: { name },
|
|
849
847
|
maxRounds: TEAMMATE_MAX_ROUNDS,
|
|
850
|
-
confirm: teammateConfirm(name),
|
|
848
|
+
confirm: teammateConfirm(name, sink(opts)),
|
|
851
849
|
askUser: undefined, // teammates have no line to the human
|
|
852
850
|
});
|
|
853
851
|
// How it ended decides what the lead hears. A shutdown handshake gets a
|
|
@@ -861,7 +859,7 @@ async function runTeammate(name, role, task, opts) {
|
|
|
861
859
|
sendMessage(name, LEAD, summary, "result");
|
|
862
860
|
finishTeammate(name, result.reason === TerminateReason.Done);
|
|
863
861
|
if (!opts.quiet)
|
|
864
|
-
|
|
862
|
+
sink(opts).note(mark.teammateDone(name, result.reason === TerminateReason.Done));
|
|
865
863
|
}
|
|
866
864
|
catch (err) {
|
|
867
865
|
// A teammate must never take the whole process down. Report the failure to
|
|
@@ -885,11 +883,11 @@ const SHUTDOWN_GRACE_MS = 30_000; // how long the lead waits for teammates to ex
|
|
|
885
883
|
// DENY rules (the no-fly zone) are enforced by the gate before confirm is ever
|
|
886
884
|
// called, so they always hold. Production Claude Code instead "bubbles" the
|
|
887
885
|
// request up to the lead and on to the human — omitted here, like the reference.
|
|
888
|
-
function teammateConfirm(name) {
|
|
886
|
+
function teammateConfirm(name, output) {
|
|
889
887
|
return async (_question, toolName) => {
|
|
890
888
|
const ok = toolName === "write_file" || toolName === "edit_file";
|
|
891
889
|
if (!ok)
|
|
892
|
-
|
|
890
|
+
output.note(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
|
|
893
891
|
return ok;
|
|
894
892
|
};
|
|
895
893
|
}
|
|
@@ -925,7 +923,7 @@ compaction) {
|
|
|
925
923
|
try {
|
|
926
924
|
if (!opts.subAgent)
|
|
927
925
|
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
|
|
926
|
+
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
927
|
compaction.count++; // one more successful compaction this query
|
|
930
928
|
compaction.failures = 0; // success resets the failure streak
|
|
931
929
|
emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
|
|
@@ -956,7 +954,7 @@ function injectBackgroundNotifications(messages, opts) {
|
|
|
956
954
|
const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
|
|
957
955
|
messages.push({ role: "user", content: note }); // the model reacts to it on the next round
|
|
958
956
|
if (!opts.quiet)
|
|
959
|
-
|
|
957
|
+
sink(opts).note(mark.bgNote(count));
|
|
960
958
|
return true;
|
|
961
959
|
}
|
|
962
960
|
// Consume this agent's mailbox into its conversation, ROUTING protocol messages
|
|
@@ -1005,7 +1003,7 @@ function consumeInbox(messages, opts) {
|
|
|
1005
1003
|
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
1004
|
messages.push({ role: "user", content: `${header}\n\n${lines.join("\n\n")}` });
|
|
1007
1005
|
if (!opts.quiet)
|
|
1008
|
-
|
|
1006
|
+
sink(opts).note(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
|
|
1009
1007
|
return true;
|
|
1010
1008
|
}
|
|
1011
1009
|
// At a stop-point (no tool calls), decide whether the TEAM keeps this agent's
|
|
@@ -1041,7 +1039,7 @@ async function continueForTeam(messages, opts) {
|
|
|
1041
1039
|
// reference's identity-reinjection, folded into the work hand-off).
|
|
1042
1040
|
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
1041
|
if (!opts.quiet)
|
|
1044
|
-
|
|
1042
|
+
sink(opts).note(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
|
|
1045
1043
|
setTeammateState(me, "active");
|
|
1046
1044
|
return true;
|
|
1047
1045
|
}
|
|
@@ -1069,7 +1067,7 @@ async function shutdownTeam(opts) {
|
|
|
1069
1067
|
if (!running.length)
|
|
1070
1068
|
return;
|
|
1071
1069
|
if (!opts.quiet)
|
|
1072
|
-
|
|
1070
|
+
sink(opts).note(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
|
|
1073
1071
|
for (const name of running) {
|
|
1074
1072
|
const id = createRequest("shutdown", LEAD, name, "task complete");
|
|
1075
1073
|
markShutdown(name, id);
|
|
@@ -1152,7 +1150,7 @@ opts) {
|
|
|
1152
1150
|
if (attempts.total >= MAX_RETRIES)
|
|
1153
1151
|
return { reason: TerminateReason.RetryBudgetExhausted, detail: `${e.kind}: ${e.message}` };
|
|
1154
1152
|
const wait = backoffMs(attempts.consecutive); // how long to back off this time
|
|
1155
|
-
|
|
1153
|
+
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
1154
|
await interruptibleSleep(wait, opts.isInterrupted); // wait, but stay responsive to Ctrl+C
|
|
1157
1155
|
continue; // retry the same round
|
|
1158
1156
|
}
|
|
@@ -1187,7 +1185,7 @@ opts) {
|
|
|
1187
1185
|
if (stop.block) {
|
|
1188
1186
|
emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
|
|
1189
1187
|
if (!opts.quiet)
|
|
1190
|
-
|
|
1188
|
+
sink(opts).note(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
|
|
1191
1189
|
messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
|
|
1192
1190
|
break; // exit the inner while → advance the round counter
|
|
1193
1191
|
}
|
|
@@ -1235,7 +1233,7 @@ opts) {
|
|
|
1235
1233
|
// Fold this round's tool calls into ONE tally line instead of one line per
|
|
1236
1234
|
// call (the per-call announcements were recorded for Ctrl+T in runOneCall).
|
|
1237
1235
|
if (!opts.quiet && out.toolCalls.length) {
|
|
1238
|
-
|
|
1236
|
+
sink(opts).note(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
|
|
1239
1237
|
}
|
|
1240
1238
|
// Background tasks (Day 37): if a job the model started has finished, slip
|
|
1241
1239
|
// its <task_notification> in here, right after this round's tool results —
|
|
@@ -1255,7 +1253,7 @@ opts) {
|
|
|
1255
1253
|
if (nag) {
|
|
1256
1254
|
messages.push({ role: "user", content: nag });
|
|
1257
1255
|
if (!opts.quiet)
|
|
1258
|
-
|
|
1256
|
+
sink(opts).note(chalk.dim(" ⎿ plan reminder injected"));
|
|
1259
1257
|
}
|
|
1260
1258
|
}
|
|
1261
1259
|
break; // round complete, move to the next one
|