agent-from-zero 0.34.2 → 0.35.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/status.js ADDED
@@ -0,0 +1,80 @@
1
+ import { CONFIG } from "./config.js";
2
+ import chalk from "chalk";
3
+ import wrapAnsi from "wrap-ansi";
4
+ import { displayWidth } from "./editor.js";
5
+ import { cacheHitRate } from "./cost.js";
6
+ import { getAccountStatus } from "./account-status.js";
7
+ function show(metric, format) {
8
+ return metric.state === "ok" ? format(metric.value) : chalk.dim(`unavailable — ${metric.reason}`);
9
+ }
10
+ const money = (values) => values.map((m) => `${m.currency} ${m.amount.toFixed(4)}`).join(" · ");
11
+ function accountLines(status) {
12
+ return [
13
+ { text: chalk.bold("Vendor account") },
14
+ { label: "Provider", text: chalk.cyan.bold(status.provider) },
15
+ { label: "Balance", text: show(status.balance, (values) => chalk.green.bold(money(values))) },
16
+ { label: "Reported cost", text: show(status.cost, (values) => chalk.yellow.bold(money(values))) },
17
+ { label: "Account usage", text: show(status.usage, (u) => chalk.cyan(`input ${u.input} (${u.cached} cached) · output ${u.output}`)) },
18
+ { label: "Period (UTC)", text: status.period },
19
+ { label: "Checked at", text: chalk.dim(status.checkedAt) },
20
+ ...(status.note ? [{ text: chalk.dim(status.note) }] : []),
21
+ ];
22
+ }
23
+ // Both frontends use the same ANSI/CJK-aware layout. Narrow terminals stack
24
+ // labels above values; wider ones align values in a second column.
25
+ function renderLines(lines, width) {
26
+ return lines.flatMap(({ label, text }) => {
27
+ const indent = label && width >= 44 ? 18 : 0;
28
+ const value = wrapAnsi(text, Math.max(1, width - indent), { hard: true, trim: false }).split("\n");
29
+ if (!label)
30
+ return value;
31
+ if (!indent)
32
+ return [chalk.dim(`${label}:`), ...value];
33
+ return value.map((line, i) => (i === 0 ? chalk.dim(`${label}:`.padEnd(indent)) : " ".repeat(indent)) + line);
34
+ });
35
+ }
36
+ export function formatAccountStatus(status) {
37
+ return renderLines(accountLines(status), 92).join("\n");
38
+ }
39
+ function statusCard(lines, columns) {
40
+ const width = Math.max(1, Math.min(96, Math.floor(columns)));
41
+ if (width < 6)
42
+ return renderLines(lines, width).join("\n");
43
+ const inner = width - 4; // two borders and one space of padding per side
44
+ const body = renderLines(lines, inner).map((line) => chalk.dim("│ ") + line + " ".repeat(Math.max(0, inner - displayWidth(line))) + chalk.dim(" │"));
45
+ return [chalk.dim(`╭${"─".repeat(width - 2)}╮`), ...body, chalk.dim(`╰${"─".repeat(width - 2)}╯`)].join("\n");
46
+ }
47
+ // Shared by Ink and readline. No model call, conversation insertion or logging.
48
+ export async function statusCommand(line, meter, options = {}) {
49
+ const arg = line.trim().split(/\s+/).slice(1).join(" ");
50
+ if (arg && !["local", "deepseek", "openai", "anthropic"].includes(arg))
51
+ return "Usage: /status [local|deepseek|openai|anthropic]";
52
+ // A URL may itself contain credentials/query tokens; show only its origin.
53
+ let endpoint = "custom endpoint";
54
+ try {
55
+ endpoint = new URL(CONFIG.baseURL).origin;
56
+ }
57
+ catch { /* leave generic */ }
58
+ const usage = meter.snapshot();
59
+ const local = [
60
+ { text: chalk.bold("mini-agent · Status") },
61
+ { text: "" },
62
+ { label: "Model", text: chalk.cyan.bold(CONFIG.model) },
63
+ { label: "Endpoint", text: endpoint },
64
+ { text: "" },
65
+ { text: chalk.bold("Current session") },
66
+ { label: "Model calls", text: chalk.cyan(String(usage.calls)) },
67
+ { label: "Input tokens", text: chalk.cyan(String(usage.inputUncached + usage.inputCached)) },
68
+ { label: "Cached input", text: `${usage.inputCached} (${(cacheHitRate(usage) * 100).toFixed(0)}% hit rate)` },
69
+ { label: "Output tokens", text: chalk.cyan(String(usage.output)) },
70
+ { label: "Estimated cost", text: chalk.yellow.bold(`$${usage.cost.toFixed(4)}`) },
71
+ { text: "" },
72
+ { text: chalk.dim("Main/subagent streams only; excludes permission review/Jev and other auxiliary calls.") },
73
+ { text: chalk.dim("Estimates use settings.pricing, not account billing.") },
74
+ ];
75
+ const columns = options.columns ?? (process.stdout.columns || 80);
76
+ if (arg === "local")
77
+ return statusCard(local, columns);
78
+ const account = await getAccountStatus({ baseURL: CONFIG.baseURL, apiKey: CONFIG.apiKey, env: options.env ?? process.env, provider: arg ? arg : undefined, request: options.request, signal: options.signal });
79
+ return statusCard([...local, { text: "" }, ...accountLines(account)], columns);
80
+ }
package/dist/team.js CHANGED
@@ -151,14 +151,20 @@ export function teammateCount() {
151
151
  return teammates.size;
152
152
  }
153
153
  // Record a newly spawned teammate. The caller supplies the promise for its loop.
154
- export function registerTeammate(name, role, done) {
155
- teammates.set(name, { name, role, status: "running", state: "active", startedAt: Date.now(), done });
154
+ export function registerTeammate(name, role, done, progress) {
155
+ teammates.set(name, { name, role, status: "running", state: "active", startedAt: Date.now(), done, progress });
156
156
  }
157
157
  // Mark a teammate finished. `ok=false` means its loop ended on a non-Done reason.
158
158
  export function finishTeammate(name, ok) {
159
159
  const t = teammates.get(name);
160
- if (t)
160
+ if (t) {
161
161
  t.status = ok ? "done" : "failed";
162
+ t.progress?.finish();
163
+ }
164
+ }
165
+ // Unlike /team, this snapshot does not read mailboxes on each UI repaint.
166
+ export function listTeammateViews() {
167
+ return [...teammates.values()].flatMap((t) => t.progress ? [t.progress.view(`team:${t.name}`, t.name, t.role, t.status === "running" ? t.state === "idle" ? "idle" : "running" : t.status)] : []);
162
168
  }
163
169
  // How many teammates are still running — at least one running teammate means the
164
170
  // team isn't disbanded yet.
package/dist/tools.js CHANGED
@@ -8,6 +8,7 @@ import { recordMutation } from "./undo.js"; // capture the before-state so /undo
8
8
  import { parseTodos, setTodos, summarizeTodos } from "./todos.js"; // the agent's own checklist (Day 36)
9
9
  import { startBackground, readBackground } from "./background.js"; // long-running commands that outlive the turn (Day 37)
10
10
  import { scheduleJob, cancelJob, listJobs } from "./cron.js"; // cron scheduler (Day s14): scheduled, recurring work
11
+ import { childEnv } from "./auto-debug.js"; // shells never inherit review tracing
11
12
  // ============ Session state ============
12
13
  // The foundation of "read before edit": which files has this session actually read?
13
14
  // Files that were never read must not be edited — otherwise the model edits from
@@ -200,7 +201,7 @@ On error: failures return the error output — analyze it and try a different ap
200
201
  // returned. The AbortSignal lets the loop kill the child on interruption
201
202
  // instead of waiting out the 30s timeout.
202
203
  run: (args, signal) => new Promise((resolve) => {
203
- const child = spawn(args.command, { shell: true, stdio: ["ignore", "pipe", "pipe"] }); // shell:true → bash semantics
204
+ const child = spawn(args.command, { shell: true, stdio: ["ignore", "pipe", "pipe"], env: childEnv() }); // shell:true → bash semantics
204
205
  let stdout = ""; // captured stdout
205
206
  let stderr = ""; // captured stderr (for the model, not the user's terminal)
206
207
  let settled = false; // resolve exactly once
@@ -428,6 +429,17 @@ export function toolDefinitions() {
428
429
  // order. (See loop.ts for the batching.)
429
430
  const READ_ONLY_TOOLS = new Set(["read_file", "search"]);
430
431
  export const isReadOnlyTool = (name) => READ_ONLY_TOOLS.has(name);
432
+ // A server's own claim that a tool loses nothing: it only reads (readOnlyHint),
433
+ // or only adds — a message, an issue — without changing or deleting what exists
434
+ // (destructiveHint: false), so a mistake can be deleted afterwards. Any server,
435
+ // no per-service list. Trusted for auto-mode review only, NOT for the concurrent
436
+ // batching above: that needs our own guarantee. The MCP default is destructive,
437
+ // so an unannotated tool still goes to review.
438
+ export function declaresRecoverable(name) {
439
+ const hints = tools[name]?.annotations;
440
+ return hints?.readOnlyHint === true ? "read-only tool (server annotation)"
441
+ : hints?.destructiveHint === false ? "additive tool (server annotation: not destructive)" : null;
442
+ }
431
443
  // Single entry point: every failure becomes text fed back to the model.
432
444
  // It never throws — the main loop must never die because of a tool. Async since
433
445
  // Day 13 so run_bash can spawn without blocking; the signal lets Ctrl+C kill a
package/dist/tui.js CHANGED
@@ -78,37 +78,52 @@ export function revealReasoning() {
78
78
  process.stdout.write(chalk.dim(reasoningTrace.join("\n\n— — —\n\n").replace(/^/gm, " ")) + "\n");
79
79
  return true;
80
80
  }
81
- // ---- collapsed tool calls (the activity trace) -------------------------------
82
- // A long task fires dozens of read_file/search calls; printing one line each
83
- // floods the screen. Instead the loop prints a compact per-round tally and we
84
- // keep the full call lines here for Ctrl+T to reveal. Cleared per turn, so
85
- // Ctrl+T shows the calls behind the LAST answer. (Diffs, prompts, and results
86
- // still print live — only the one-line announcements are folded.)
87
81
  const toolCallLog = [];
88
- const MAX_TOOL_LOG = 500; // bound the memory
82
+ const MAX_TOOL_LOG = 100;
83
+ const MAX_DETAIL_CHARS = 32_000;
84
+ let toolCallCount = 0;
85
+ function boundedDetail(text) {
86
+ return text.length > MAX_DETAIL_CHARS ? text.slice(0, MAX_DETAIL_CHARS) + "\n… (display truncated)" : text;
87
+ }
89
88
  // Record a tool call's full line for Ctrl+T, and stash its args for Tab.
90
- export function recordToolCall(line, full) {
91
- toolCallLog.push(line);
89
+ export function recordToolCall(line, full, action) {
90
+ const record = { line, full: boundedDetail(full), ...action, args: action ? boundedDetail(action.args) : undefined, startedAt: Date.now() };
91
+ toolCallCount++;
92
+ toolCallLog.push(record);
92
93
  if (toolCallLog.length > MAX_TOOL_LOG)
93
94
  toolCallLog.shift();
94
- collapsed.push({ full, expanded: false }); // Tab still reveals the most recent call's full args
95
+ collapsed.push({ full: record.full, expanded: false });
95
96
  if (collapsed.length > MAX_COLLAPSED)
96
97
  collapsed.shift();
98
+ return record;
99
+ }
100
+ export function recordToolResult(record, result) {
101
+ record.endedAt = Date.now();
102
+ record.result = boundedDetail(result);
97
103
  }
104
+ export function recordToolDetail(id, text) {
105
+ const record = [...toolCallLog].reverse().find((r) => r.id === id);
106
+ if (record)
107
+ record.full = boundedDetail(record.full + "\n" + text);
108
+ }
109
+ export function getToolActivity() { return toolCallLog; }
110
+ export function getToolCallCount() { return toolCallCount; }
98
111
  export function clearToolCalls() {
99
112
  toolCallLog.length = 0;
113
+ collapsed.length = 0;
114
+ toolCallCount = 0;
100
115
  }
101
116
  // Ctrl+T handler: print every tool call behind the last answer. Returns false
102
117
  // (nothing to show) so the editor can ignore the key.
103
118
  export function revealToolCalls() {
104
- if (!toolCallLog.length)
119
+ const text = getToolCalls();
120
+ if (!text)
105
121
  return false;
106
- process.stdout.write(chalk.dim(`tool calls this turn (${toolCallLog.length}):\n`));
107
- process.stdout.write(toolCallLog.join("\n") + "\n");
122
+ process.stdout.write(text + "\n");
108
123
  return true;
109
124
  }
110
125
  // 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.
126
+ // of writing to stdout, so Ink can show it in a transient, scrollable panel.
112
127
  export function getReasoning() {
113
128
  if (!reasoningTrace.length)
114
129
  return null;
@@ -117,7 +132,8 @@ export function getReasoning() {
117
132
  export function getToolCalls() {
118
133
  if (!toolCallLog.length)
119
134
  return null;
120
- return chalk.dim(`tool calls this turn (${toolCallLog.length}):\n`) + toolCallLog.join("\n");
135
+ const omitted = toolCallCount > toolCallLog.length ? ` — showing latest ${toolCallLog.length}` : "";
136
+ return chalk.dim(`tool calls this turn (${toolCallCount})${omitted}:\n`) + toolCallLog.map((r) => `${r.line}\n${r.full}\nresult: ${r.result ?? "(in progress)"}`).join("\n\n");
121
137
  }
122
138
  // ---- cleanup -----------------------------------------------------------------
123
139
  // Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
package/dist/ui.js CHANGED
@@ -100,17 +100,15 @@ export function formatElapsed(ms) {
100
100
  const h = Math.floor(m / 60);
101
101
  return `${h}h${String(m % 60).padStart(2, "0")}m`;
102
102
  }
103
- // The live spinner text: the model (so you can SEE which one is answering — proof
104
- // a /model switch took effect) + a thinking word + elapsed + tokens streamed so
105
- // far + how to bail. Mirrors Claude Code's "Mulling… (1m32s · ↓ 4.0k tokens)".
106
- export function spinnerText(word, elapsedSec, subAgent, model, tokens) {
103
+ // Live activity, elapsed time and streamed tokens. The persistent status line
104
+ // owns the model name, so each animation need not repeat it.
105
+ export function spinnerText(word, elapsedSec, subAgent, tokens) {
107
106
  const head = subAgent ? "sub-agent" : word;
108
- const tag = model && !subAgent ? chalk.dim(`${model} · `) : ""; // show the model on top-level calls
109
107
  const bits = [`${elapsedSec}s`];
110
108
  if (tokens && tokens > 0)
111
109
  bits.push(`↓ ${formatTokens(tokens)} tokens`);
112
110
  bits.push("Ctrl+C to interrupt");
113
- return `${tag}${head}… ${chalk.dim(`(${bits.join(" · ")})`)}`;
111
+ return `${head}… ${chalk.dim(`(${bits.join(" · ")})`)}`;
114
112
  }
115
113
  // The persistent status line — model, project, branch, context use, spend, time.
116
114
  // Pure (dir + branch passed in) so it's testable without a filesystem or git.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.34.2",
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)",
3
+ "version": "0.35.0",
4
+ "description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer \u2014 companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mini-agent": "dist/agent.js"
@@ -15,9 +15,10 @@
15
15
  "ink": "tsx --env-file=.env src/ink/main.tsx",
16
16
  "typecheck": "tsc --noEmit",
17
17
  "eval": "tsx --env-file=.env eval/run.ts",
18
+ "auto:report": "tsx eval/auto-report.ts",
18
19
  "build": "tsc -p tsconfig.build.json",
19
20
  "prepare": "npm run build",
20
- "test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/title.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
21
+ "test": "export MINI_AGENT_REVIEW_DEBUG=0 && tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/auto.test.ts && tsx tests/auto-context.test.ts && tsx tests/auto-providers.test.ts && tsx tests/auto-cascade.test.ts && tsx tests/auto-rules.test.ts && tsx tests/auto-rule-cascade.test.ts && tsx tests/auto-debug.test.ts && tsx tests/auto-report.test.ts && tsx tests/auto-clarification.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/cli.test.ts && tsx tests/print.test.ts && tsx tests/status.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/title.test.ts && tsx tests/ui.test.ts && tsx tests/activity.test.ts && tsx tests/follow-up.test.ts && tsx tests/follow-up-ui.test.tsx && tsx tests/agents.test.ts && tsx tests/images.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/effort.test.ts && tsx tests/completion.test.ts && tsx tests/pasted-text.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
21
22
  },
22
23
  "engines": {
23
24
  "node": ">=20.6"