agent-from-zero 0.1.0 → 0.5.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/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # mini-agent
2
2
 
3
- 用 DeepSeek API 从零手写一个类 Claude Code 的命令行 Agent,TypeScript 实现。
3
+ **跟着 AI,从零搓一个自己的 Claude Code**——一个能读文件、改代码、跑命令的命令行 Agent。TypeScript 实现,默认 DeepSeek 驱动(任何 OpenAI 兼容模型可配)。
4
4
 
5
- 10 天,每天一个 commit。**commit 历史就是课程目录**:`git log --oneline` 看大纲,`git checkout dayN` 拿到第 N 天收工时的完整代码。
5
+ 人定方向、做决策、验收,AI 写大部分代码——这既是本仓库的诞生方式,也是这门课教你的工作方式。
6
6
 
7
+ 课程拆成 10 个 Day,一个 Day 一个 commit,按一晚一章的节奏跟做。**commit 历史就是课程目录**:`git log --oneline` 看大纲,`git checkout dayN` 拿到第 N 章对应的完整代码。
8
+
9
+ > **设计有出处,不是凭感觉搭的**:蓝本是《Harness Engineering:从 Claude Code 看 AI 编码工程》提炼的 22 个工程模式——博主在公众号用 30+ 篇拆完了全书(本仓库就是那次拆解的实践篇)。书的收官章,作者用 800 行 Rust 写了一个代码审查 Agent,验证这些模式能跨语言、跨场景活下来;**这个仓库是同一套模式的又一次迁移**:场景换成 Claude Code 式的 CLI 编码 agent,语言换成 TypeScript——不跟着用 Rust,是因为新手教程的第一原则是别让语言难度挡路。
10
+ >
11
+ > **用 AI 搓出一个类 Claude Code,这件事本身就是这套方法最好的 demo**——教程里每个「踩过的坑」都真实发生在开发过程中,不是编的。
12
+ >
7
13
  > 代码、注释、commit message 用英文——模型的工具调用主要在英文数据上训练,英文指令引导更稳,也方便国际读者;配套教程是中文。
8
14
 
9
15
  ## 快速开始
@@ -50,9 +56,16 @@ MINI_AGENT_MODEL=gpt-4.1-mini
50
56
  | `day7` | 子 agent(task 工具:独立上下文、一层分身不套娃、报告是材料不是结论、文件状态快照隔离)+ 最小 eval(10 道固定题、物证判分、8/10 及格线,`npm run eval`) |
51
57
  | `day8` | 封装发布:`bin` 入口 + tsc 构建,npx 一行启动(包名 `agent-from-zero`);内置 .env 加载;缺 key 人话报错 |
52
58
  | `day9` | 教程本身:11 章中文教程开源进 [docs/](docs/README.md) |
59
+ | `day10` | Bring your own model:三个环境变量换任意 OpenAI 兼容端点,`npm run eval` 兼职模型横评 |
60
+ | `day11` | 工业级 CLI 表面:settings.json 双层配置(用户权限规则,deny 永远赢)、slash 命令(/help /model /compact)、`-p` 单发模式、`--version`、测试套件进仓库(`npm test`,60 例) |
61
+ | `day12` | 会话持久化(`--resume`,快照式 + 原子写)+ 本地遥测(事件 JSONL + `/stats`,绝不出本机,`MINI_AGENT_NO_TELEMETRY=1` 关闭) |
62
+ | `day13` | 异步执行:run_bash 换 spawn(不冻 UI、Ctrl+C 秒杀、30s 超时 SIGKILL)、超长输出落盘、只读工具并行;`npm test` 65 例 |
63
+ | `day14` | Hook 生命周期系统:PreToolUse(拦截工具)/PostToolUse(追加校验)/SessionStart(注入上下文)/Stop(测试驱动 AI),退出码协议、stderr 喂回模型;`npm test` 76 例 |
53
64
 
54
65
  ## 📖 配套教程(免费开源)
55
66
 
56
67
  **[完整教程在 docs/ 目录](docs/README.md)**——11 章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
57
68
 
69
+ 想看更深的「为什么」:公众号的 **Harness Engineering 拆书系列**(30+ 篇)逐章拆了《Harness Engineering:从 Claude Code 看 AI 编码工程》——本仓库每个设计决策背后的原理、数据和事故案例都在那里。公众号 @(待填)。
70
+
58
71
  如果这个项目帮你搞懂了 agent,点个 ⭐ 就是最大的支持。
package/dist/agent.js CHANGED
@@ -2,32 +2,153 @@
2
2
  import OpenAI from "openai"; // the API client
3
3
  import chalk from "chalk"; // terminal colors
4
4
  import readline from "node:readline/promises"; // promise-based terminal input
5
- import { CONFIG, requireApiKey } from "./config.js"; // provider-agnostic settings (.env loaded there)
5
+ import { createRequire } from "node:module"; // to read package.json for --version
6
+ import { CONFIG, requireApiKey, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
6
7
  import { runLoop, TerminateReason, MAX_ROUNDS, MAX_RETRIES } from "./loop.js"; // the state machine
7
8
  import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
8
9
  import { forgetFilesExcept } from "./tools.js"; // to reset file state on /clear
9
- requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
10
- // Build the API client once, for the whole session.
11
- const client = new OpenAI({
12
- baseURL: CONFIG.baseURL, // DeepSeek by default; any OpenAI-compatible endpoint via MINI_AGENT_BASE_URL
13
- apiKey: CONFIG.apiKey, // from .env or the shell
14
- maxRetries: 0, // we own the retry policy — never let the SDK retry underneath us
15
- });
10
+ import { compactHistory, COMPACT_AT } from "./context.js"; // for the manual /compact command
11
+ import { newSessionId, saveSession, latestSession } from "./session.js"; // conversation persistence (project-local)
12
+ import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
13
+ import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
14
+ // package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
15
+ const pkg = createRequire(import.meta.url)("../package.json");
16
+ // ---- CLI arguments --------------------------------------------------------------
17
+ // Tiny by design: a help text, a version, and a one-shot print mode. Anything
18
+ // fancier belongs in settings files, not flags.
19
+ const USAGE = `mini-agent ${pkg.version} — a Claude Code-style CLI agent (any OpenAI-compatible model)
20
+
21
+ Usage:
22
+ mini-agent interactive session (REPL)
23
+ mini-agent -r | --resume continue the most recent session in this directory
24
+ mini-agent -p "<task>" one-shot: run a single task, print the result, exit
25
+ mini-agent -v | --version print the version
26
+ mini-agent -h | --help this text
27
+
28
+ Configuration (optional):
29
+ ${GLOBAL_SETTINGS_PATH} your defaults (model, baseURL, permissions, hooks)
30
+ ${PROJECT_SETTINGS_PATH} per-project rules — both layers apply, deny always wins
31
+ MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL override everything.
32
+
33
+ In a session, type /help for the in-session commands.`;
16
34
  // What we tell the user for every way a query can end. No raw stack traces.
17
35
  const EXIT_NOTES = {
18
36
  [TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
19
37
  [TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
20
38
  [TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
21
- [TerminateReason.RateLimitBudgetExhausted]: "DeepSeek keeps rate-limiting us. Wait a minute, then try again.",
39
+ [TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
22
40
  [TerminateReason.ContextTooLong]: "The conversation no longer fits the model's context window, and compaction could not shrink it enough. Use /clear to start fresh.",
23
41
  [TerminateReason.CompactionFailed]: "Automatic compaction kept failing — stopping instead of looping. Use /clear to start fresh.",
24
42
  [TerminateReason.FatalApiError]: "Unrecoverable API error — retrying would not help. Check your API key and request.",
25
43
  [TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
26
44
  };
45
+ // The in-session command reference, shown by /help.
46
+ const SESSION_HELP = `commands:
47
+ /help this text
48
+ /clear wipe the conversation (and the file read-state) — fresh start
49
+ /compact compact the history into a summary right now (happens automatically near the limit)
50
+ /model show which model and endpoint this session is talking to
51
+ /stats event counts for this session (local telemetry — nothing leaves this machine)
52
+ exit leave (Ctrl+C at the prompt does the same)`;
27
53
  async function main() {
28
- // The banner: who am I, which model and host am I on, and the three things worth knowing.
29
- console.log(chalk.bold("mini-agent") +
30
- chalk.dim(` — ${CONFIG.model} @ ${new URL(CONFIG.baseURL).host} | "exit" to quit · "/clear" to reset · Ctrl+C interrupts a running task`));
54
+ // ---- Flag handling, before anything touches the network -----------------------
55
+ const argv = process.argv.slice(2); // everything after "mini-agent"
56
+ if (argv.includes("-v") || argv.includes("--version")) {
57
+ console.log(pkg.version); // just the number — script-friendly
58
+ return;
59
+ }
60
+ if (argv.includes("-h") || argv.includes("--help")) {
61
+ console.log(USAGE); // the full help text
62
+ return;
63
+ }
64
+ // Print mode: -p / --print takes the task from the remaining arguments.
65
+ const pIdx = argv.findIndex((a) => a === "-p" || a === "--print"); // where the flag sits
66
+ const printTask = pIdx >= 0 ? argv.slice(pIdx + 1).join(" ").trim() : null; // everything after it is the task
67
+ if (pIdx >= 0 && !printTask) {
68
+ console.error('Usage: mini-agent -p "<task>"'); // -p without a task is a usage error
69
+ process.exitCode = 2;
70
+ return;
71
+ }
72
+ requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
73
+ // Build the API client once, for the whole session.
74
+ const client = new OpenAI({
75
+ baseURL: CONFIG.baseURL, // DeepSeek by default; any OpenAI-compatible endpoint via config
76
+ apiKey: CONFIG.apiKey, // from .env or the shell
77
+ maxRetries: 0, // we own the retry policy — never let the SDK retry underneath us
78
+ });
79
+ // The system message is built ONCE per session: stable prefix = cache hits
80
+ // on every request. Nothing session-specific may sneak into it later.
81
+ const systemMessage = buildSystemMessage();
82
+ let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
83
+ // ---- Session persistence ---------------------------------------------------------
84
+ // Every conversation is snapshotted to .mini-agent/sessions/ after each turn;
85
+ // -r / --resume picks up the most recent one. The system message is NOT
86
+ // restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
87
+ let sessionId = newSessionId(); // this session's identity (and file name)
88
+ if (argv.includes("-r") || argv.includes("--resume")) {
89
+ const prev = latestSession(); // newest snapshot in this directory, if any
90
+ if (prev) {
91
+ messages.push(...prev.messages); // the old conversation joins the fresh constitution
92
+ sessionId = prev.id; // keep appending to the same session file
93
+ console.log(chalk.dim(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`));
94
+ }
95
+ else {
96
+ console.log(chalk.dim("(no previous session here — starting fresh)")); // resume with nothing to resume is not an error
97
+ }
98
+ }
99
+ initTelemetry(sessionId); // arm the local event log (no-op under MINI_AGENT_NO_TELEMETRY=1)
100
+ emit("agent_session_start", { mode: printTask !== null ? "print" : "repl" }); // how this session was started
101
+ // SessionStart hooks run once, here. Their stdout is injected as context the
102
+ // model sees on its first turn — e.g. inject the current git branch, an
103
+ // on-call rota, today's deploy freeze status. (exit 2 here is ignored: there
104
+ // is no tool to block, only context to add.)
105
+ const sessionStart = await runHooks("SessionStart", {});
106
+ if (sessionStart.stdout)
107
+ messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
108
+ // Per-task interrupt state. `running` decides what Ctrl+C means right now.
109
+ let running = false; // is a task currently executing?
110
+ let interrupted = false; // has the current task been interrupted?
111
+ let controller = new AbortController(); // aborts the in-flight API request
112
+ // ---- One-shot print mode -------------------------------------------------------
113
+ // The same loop, the same permission gate (fail closed without a TTY), no REPL.
114
+ // Designed for scripts: the exit code distinguishes success from every failure.
115
+ if (printTask !== null) {
116
+ process.on("SIGINT", () => {
117
+ if (interrupted)
118
+ process.exit(130); // second press: force quit
119
+ interrupted = true; // first press: wind down
120
+ controller.abort(); // cancel the in-flight request
121
+ });
122
+ messages.push({ role: "user", content: printTask }); // the single task
123
+ const result = await runLoop(messages, {
124
+ client,
125
+ model: CONFIG.model,
126
+ signal: controller.signal,
127
+ isInterrupted: () => interrupted,
128
+ // Print mode never prompts: unattended means nobody can say yes.
129
+ // MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
130
+ // were enforced in permissions.ts long before this runs.
131
+ confirm: async (q) => {
132
+ if (process.env.MINI_AGENT_AUTO_APPROVE === "1")
133
+ return true; // explicit bypass
134
+ console.error(chalk.dim(`[print mode] declined (no prompt available): ${q.split("\n")[0]}`)); // visible in stderr, not stdout
135
+ return false; // fail closed
136
+ },
137
+ });
138
+ saveSession(sessionId, CONFIG.model, messages); // print-mode runs are resumable too
139
+ emit("agent_session_end"); // close the books
140
+ if (result.reason !== TerminateReason.Done) {
141
+ console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
142
+ if (result.detail)
143
+ console.error(chalk.dim(result.detail.slice(0, 300))); // raw detail on stderr
144
+ process.exitCode = result.reason === TerminateReason.UserInterrupt ? 130 : 1; // scripts can branch on this
145
+ }
146
+ return; // the answer itself already streamed to stdout
147
+ }
148
+ // ---- Interactive session (REPL) -------------------------------------------------
149
+ // The banner: who am I, which model and host am I on, and how to get help.
150
+ console.log(chalk.bold(`mini-agent ${pkg.version}`) +
151
+ chalk.dim(` — ${CONFIG.model} @ ${new URL(CONFIG.baseURL).host} | /help for commands · Ctrl+C interrupts a running task`));
31
152
  // ONE readline interface for the whole session — the task prompt and the
32
153
  // permission prompts share it. Two interfaces on one stdin fight each other.
33
154
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -61,14 +182,6 @@ async function main() {
61
182
  rl.prompt();
62
183
  return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
63
184
  };
64
- // The system message is built ONCE per session: stable prefix = cache hits
65
- // on every request. Nothing session-specific may sneak into it later.
66
- const systemMessage = buildSystemMessage();
67
- let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
68
- // Per-task interrupt state. `running` decides what Ctrl+C means right now.
69
- let running = false; // is a task currently executing?
70
- let interrupted = false; // has the current task been interrupted?
71
- let controller = new AbortController(); // aborts the in-flight API request
72
185
  // Ctrl+C semantics, by state:
73
186
  // - at the prompt: exit the session (standard REPL behavior)
74
187
  // - while running: first press interrupts the task, second press force-quits
@@ -102,6 +215,45 @@ async function main() {
102
215
  const answer = await readUserLine(" Allow? [y/N] "); // wait for the human (shares the line queue)
103
216
  return /^y(es)?$/i.test(answer.trim()); // anything but y/yes means no
104
217
  };
218
+ // Handle a /slash command. Returns true if the line was a command.
219
+ const handleCommand = async (line) => {
220
+ switch (line) {
221
+ case "/help":
222
+ console.log(chalk.dim(SESSION_HELP)); // the command reference
223
+ return true;
224
+ case "/clear":
225
+ messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
226
+ forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
227
+ sessionId = newSessionId(); // a fresh conversation is a fresh session file
228
+ console.log(chalk.dim("(history cleared)")); // confirm the reset
229
+ return true;
230
+ case "/stats":
231
+ console.log(chalk.dim(statsReport())); // local counters, busiest first
232
+ return true;
233
+ case "/model":
234
+ console.log(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`)); // where this session points
235
+ return true;
236
+ case "/compact": {
237
+ if (messages.length <= 1) {
238
+ console.log(chalk.dim("(nothing to compact yet)")); // only the system message so far
239
+ return true;
240
+ }
241
+ try {
242
+ await compactHistory(messages, client, CONFIG.model, new AbortController().signal); // same machinery as automatic compaction
243
+ }
244
+ catch (err) {
245
+ console.log(chalk.yellow(`compaction failed: ${err.message}`)); // report, don't crash
246
+ }
247
+ return true;
248
+ }
249
+ default:
250
+ if (line.startsWith("/")) {
251
+ console.log(chalk.dim(`unknown command: ${line} — try /help`)); // typo guard
252
+ return true; // still consumed — don't send typos to the model
253
+ }
254
+ return false; // a normal task line
255
+ }
256
+ };
105
257
  // The session loop: ask → run → render → ask again. This is what makes it
106
258
  // a conversation instead of a one-shot command.
107
259
  while (true) {
@@ -110,12 +262,8 @@ async function main() {
110
262
  continue; // empty line — just re-prompt
111
263
  if (line === "exit" || line === "quit")
112
264
  break; // explicit goodbye
113
- if (line === "/clear") {
114
- messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
115
- forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
116
- console.log(chalk.dim("(history cleared)")); // confirm the reset
117
- continue;
118
- }
265
+ if (await handleCommand(line))
266
+ continue; // slash commands never reach the model
119
267
  messages.push({ role: "user", content: line }); // the new turn joins the shared history
120
268
  running = true; // Ctrl+C now means "interrupt the task"
121
269
  interrupted = false; // fresh interrupt state for this task
@@ -128,6 +276,7 @@ async function main() {
128
276
  confirm, // for permission prompts
129
277
  });
130
278
  running = false; // back at the prompt — Ctrl+C means "exit" again
279
+ saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
131
280
  // Done already streamed its answer to the screen; everything else gets a
132
281
  // one-line human explanation. The session continues either way — except a
133
282
  // fatal API error, which no amount of chatting will fix.
@@ -139,6 +288,7 @@ async function main() {
139
288
  break; // a bad key won't fix itself mid-session
140
289
  }
141
290
  }
291
+ emit("agent_session_end"); // close the books
142
292
  rl.close(); // release stdin
143
293
  console.log(chalk.dim("bye.")); // a clean goodbye
144
294
  }
package/dist/config.js CHANGED
@@ -1,8 +1,9 @@
1
- import fs from "node:fs"; // for the minimal .env loader
1
+ import fs from "node:fs"; // reading .env and settings files
2
+ import os from "node:os"; // home directory for the global settings path
3
+ import path from "node:path"; // path joining
2
4
  import chalk from "chalk"; // colored error output
3
5
  // Minimal .env loader: lets `npx mini-agent` work in any directory that has a
4
6
  // .env file — no dotenv dependency, no --env-file flag to remember.
5
- // Lives here (not in agent.ts) so the eval harness gets it for free too.
6
7
  try {
7
8
  for (const line of fs.readFileSync(".env", "utf8").split("\n")) {
8
9
  const m = line.match(/^([A-Za-z0-9_]+)=(.*)$/); // KEY=value, nothing fancier
@@ -13,20 +14,61 @@ try {
13
14
  catch {
14
15
  /* no .env here — the variables may come from the shell instead */
15
16
  }
16
- // Provider-agnostic configuration. mini-agent talks the OpenAI wire protocol,
17
- // so ANY compatible endpoint works — DeepSeek, OpenAI, Kimi, Qwen, GLM,
18
- // OpenRouter, a local Ollama... three variables and you're on a new model.
19
- //
20
- // Resolution order, most specific wins:
21
- // MINI_AGENT_* (generic) > DEEPSEEK_* (back-compat) > DeepSeek defaults
17
+ // ---- Settings files ------------------------------------------------------------
18
+ // Two layers, both optional:
19
+ // global: ~/.config/mini-agent/settings.json (your defaults, every project)
20
+ // project: ./.mini-agent/settings.json (this repo's rules, commit it if you like)
21
+ // Scalars (model, baseURL, contextWindow): project wins over global.
22
+ // Permission lists: BOTH layers apply — a deny from either layer is a deny.
23
+ // API keys never live in settings files — secrets stay in the environment.
24
+ export const GLOBAL_SETTINGS_PATH = path.join(os.homedir(), ".config", "mini-agent", "settings.json");
25
+ export const PROJECT_SETTINGS_PATH = path.resolve(".mini-agent", "settings.json");
26
+ // Read one settings file. A broken settings file is a HARD error, not a warning:
27
+ // silently ignoring it would silently drop the user's deny rules — fail closed.
28
+ function readSettings(p) {
29
+ if (!fs.existsSync(p))
30
+ return {}; // absent is fine
31
+ try {
32
+ return JSON.parse(fs.readFileSync(p, "utf8")); // parse or throw
33
+ }
34
+ catch (err) {
35
+ console.error(chalk.red(`Could not parse settings file: ${p}`));
36
+ console.error(chalk.dim(` ${err.message}`));
37
+ console.error("Fix or delete the file, then run again. (Ignoring it could drop your deny rules.)");
38
+ process.exit(1); // fail closed — never run with half-loaded security config
39
+ }
40
+ }
41
+ const globalSettings = readSettings(GLOBAL_SETTINGS_PATH); // the user's defaults
42
+ const projectSettings = readSettings(PROJECT_SETTINGS_PATH); // this project's rules
43
+ // Provider-agnostic configuration. Resolution order, most specific wins:
44
+ // env MINI_AGENT_* > project settings > global settings > legacy DEEPSEEK_* env > defaults
22
45
  export const CONFIG = {
23
- baseURL: process.env.MINI_AGENT_BASE_URL || // bring your own provider...
24
- process.env.DEEPSEEK_BASE_URL || // ...or the original DeepSeek override (kept for back-compat and tests)
25
- "https://api.deepseek.com", // the default provider
46
+ baseURL: process.env.MINI_AGENT_BASE_URL || // explicit env wins...
47
+ projectSettings.baseURL || // ...then the project file...
48
+ globalSettings.baseURL || // ...then the user's global file...
49
+ process.env.DEEPSEEK_BASE_URL || // ...then the legacy override (kept for back-compat and tests)
50
+ "https://api.deepseek.com", // ...and finally the default provider
26
51
  apiKey: process.env.MINI_AGENT_API_KEY || // generic key wins...
27
52
  process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
28
53
  "", // empty = missing; requireApiKey() turns that into a helpful error
29
- model: process.env.MINI_AGENT_MODEL || "deepseek-chat", // must support function calling
54
+ model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-chat", // must support function calling
55
+ contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
56
+ projectSettings.contextWindow || // then files
57
+ globalSettings.contextWindow ||
58
+ 1_048_565, // DeepSeek's real window, learned from an actual API error
59
+ permissions: {
60
+ // Both layers apply: global rules + project rules. Mutable on purpose —
61
+ // the test suite injects rules here instead of writing temp files.
62
+ allow: [...(globalSettings.permissions?.allow ?? []), ...(projectSettings.permissions?.allow ?? [])],
63
+ deny: [...(globalSettings.permissions?.deny ?? []), ...(projectSettings.permissions?.deny ?? [])],
64
+ },
65
+ hooks: {
66
+ // Hook lists also concatenate: global hooks run first, then project hooks.
67
+ PreToolUse: [...(globalSettings.hooks?.PreToolUse ?? []), ...(projectSettings.hooks?.PreToolUse ?? [])],
68
+ PostToolUse: [...(globalSettings.hooks?.PostToolUse ?? []), ...(projectSettings.hooks?.PostToolUse ?? [])],
69
+ SessionStart: [...(globalSettings.hooks?.SessionStart ?? []), ...(projectSettings.hooks?.SessionStart ?? [])],
70
+ Stop: [...(globalSettings.hooks?.Stop ?? []), ...(projectSettings.hooks?.Stop ?? [])],
71
+ },
30
72
  };
31
73
  // No key, no point: fail with instructions instead of a stack trace later.
32
74
  export function requireApiKey() {
package/dist/context.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import fs from "node:fs"; // re-reading files from disk during recovery
2
2
  import chalk from "chalk"; // status lines
3
+ import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
3
4
  import { recentFiles, forgetFilesExcept } from "./tools.js"; // session file state (what was read/edited, and when)
4
5
  // ---- The context window ------------------------------------------------------
5
- // 1,048,565 came from a real API error message, not from documentation.
6
+ // The default (1,048,565) came from a real API error message, not from docs.
6
7
  // Trust what the API tells you over what you remember reading.
7
- export const CONTEXT_WINDOW = Number(process.env.MINI_AGENT_CONTEXT_WINDOW || 1_048_565); // overridable for other models
8
+ export const CONTEXT_WINDOW = CONFIG.contextWindow; // resolved in config.ts: env > project file > global file > default
8
9
  // Compact well before the hard limit: estimation is approximate and the summary
9
10
  // itself needs room. 80% is deliberately conservative.
10
- export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); // overridable so tests can trigger compaction cheaply
11
+ export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); // env override kept so tests can trigger compaction cheaply
11
12
  // How many times one query may compact / fail to compact before we give up.
12
13
  export const MAX_COMPACTIONS_PER_QUERY = 4; // a query that needs more is too big — stop, don't loop
13
14
  export const MAX_COMPACT_FAILURES = 3; // the compaction circuit breaker
package/dist/hooks.js ADDED
@@ -0,0 +1,67 @@
1
+ import { spawn } from "node:child_process"; // hooks are external programs
2
+ import chalk from "chalk"; // status lines
3
+ import { CONFIG } from "./config.js"; // user-configured hooks
4
+ import { emit } from "./telemetry.js"; // count hook activity
5
+ // Hooks let users extend the agent without touching its source: an external
6
+ // program runs at a lifecycle moment and can observe — or block — what happens.
7
+ // The contract is deliberately language-agnostic (a shell command, JSON on
8
+ // stdin, the exit code as the verdict) so a hook can be a bash one-liner, a
9
+ // Python script, anything. This is the "keep the core small, bolt business
10
+ // logic on via hooks" architecture.
11
+ //
12
+ // Communication protocol (per event):
13
+ // exit 0 → allow. stdout is shown to the model only for SessionStart.
14
+ // exit 2 → block. stderr is fed back to the model as the reason.
15
+ // other → the hook itself errored; we log and treat it as allow (a broken
16
+ // hook must not wedge the agent — but it is loud, not silent).
17
+ const DEFAULT_HOOK_TIMEOUT_MS = 10_000; // a hook gets 10s, then we kill it
18
+ // Run one hook process, feeding it the event payload on stdin.
19
+ function runOne(hook, payload) {
20
+ return new Promise((resolve) => {
21
+ const child = spawn(hook.command, { shell: true, stdio: ["pipe", "pipe", "pipe"] }); // shell so users can write one-liners
22
+ let stdout = "", stderr = "", settled = false; // accumulate; resolve once
23
+ const done = (code) => {
24
+ if (settled)
25
+ return;
26
+ settled = true;
27
+ clearTimeout(timer);
28
+ resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
29
+ };
30
+ const timer = setTimeout(() => {
31
+ child.kill("SIGKILL"); // a hung hook is killed, never hangs the agent
32
+ done(124); // 124 = timed out (treated as "other" → allow, logged)
33
+ }, hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS);
34
+ child.stdout.on("data", (d) => (stdout += d));
35
+ child.stderr.on("data", (d) => (stderr += d));
36
+ child.on("error", () => done(127)); // could not start (e.g. command not found)
37
+ child.on("close", (code) => done(code ?? 0));
38
+ child.stdin.end(JSON.stringify(payload)); // the event, as JSON, on stdin
39
+ });
40
+ }
41
+ // Run every hook registered for an event, in order, and combine their verdicts.
42
+ // `match` (when present) filters by tool name so a hook can target one tool.
43
+ export async function runHooks(event, payload) {
44
+ const hooks = (CONFIG.hooks[event] ?? []).filter((h) => !h.match || h.match === payload.tool); // applicable hooks only
45
+ if (!hooks.length)
46
+ return { block: false, feedback: "", stdout: "" }; // the common case: nothing registered
47
+ const feedback = []; // stderr from blocking hooks
48
+ const stdout = []; // stdout from passing hooks
49
+ let block = false; // did any hook exit 2?
50
+ for (const hook of hooks) {
51
+ const { code, stdout: out, stderr: err } = await runOne(hook, { event, ...payload }); // run it
52
+ emit("agent_hook_run", { event, code }); // observability for hooks themselves
53
+ if (code === 0) {
54
+ if (out)
55
+ stdout.push(out); // passing-hook output (used by SessionStart)
56
+ }
57
+ else if (code === 2) {
58
+ block = true; // a blocking verdict
59
+ feedback.push(err || `(${event} hook blocked with no message)`); // the reason, for the model
60
+ }
61
+ else {
62
+ // Any other code: the hook is broken. Be loud, but do not wedge the agent.
63
+ console.error(chalk.yellow(` [hook] ${event} command exited ${code} (ignored): ${(err || out).slice(0, 120)}`));
64
+ }
65
+ }
66
+ return { block, feedback: feedback.join("\n"), stdout: stdout.join("\n") };
67
+ }
package/dist/loop.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import chalk from "chalk"; // terminal colors for status lines
2
2
  import ora from "ora"; // the "thinking..." spinner while waiting for the first token
3
- import { toolDefinitions, dispatch, snapshotFileState, restoreFileState } from "./tools.js"; // the tool manuals + the executor + file-state isolation
3
+ import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
4
4
  import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
5
5
  import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
6
6
  import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
7
7
  import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent's own constitution
8
+ import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
9
+ import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
8
10
  export const MAX_ROUNDS = 15; // model→tool round cap per query
9
11
  export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
10
12
  export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
@@ -50,6 +52,7 @@ On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a
50
52
  // Run one sub-agent: a fresh conversation, same tools minus task, same
51
53
  // permission gate, and the parent's file read-state protected by a snapshot.
52
54
  async function runSubAgent(description, opts) {
55
+ emit("agent_subagent_spawn"); // delegation is worth counting
53
56
  if (!opts.quiet)
54
57
  console.log(chalk.blue(` ⎿ sub-agent started: ${description.slice(0, 100)}`)); // show the delegation
55
58
  const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
@@ -102,14 +105,18 @@ opts) {
102
105
  // - stall (slow but alive for 30s) → log it and keep waiting
103
106
  const watchdog = setInterval(() => {
104
107
  const quietMs = Date.now() - lastEvent; // ms since the last event
105
- if (quietMs > IDLE_TIMEOUT_MS)
108
+ if (quietMs > IDLE_TIMEOUT_MS) {
109
+ emit("agent_watchdog_idle"); // record the cut — these should be rare
106
110
  idleAbort.abort(); // dead — cut it
111
+ }
107
112
  else if (quietMs > STALL_WARN_MS && !stallWarned) {
108
113
  stallWarned = true; // don't repeat the warning every second
114
+ emit("agent_watchdog_stall"); // record the slowness — pattern-spotting data
109
115
  console.log(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
110
116
  }
111
117
  }, 1000);
112
118
  try {
119
+ emit("agent_api_call"); // one event per attempt — retries show up as extra calls
113
120
  const stream = await opts.client.chat.completions.create(
114
121
  // Sub-agents do NOT get the task tool: one level of delegation only.
115
122
  // Nested spawning means orphan processes and debugging hell.
@@ -159,11 +166,70 @@ opts) {
159
166
  spinner.stop(); // and never leave a zombie spinner
160
167
  }
161
168
  }
169
+ // Run one tool call end to end: log it, run it through the permission gate,
170
+ // ask the human if needed, execute, and return the paired result. Read-only
171
+ // calls (allow/deny only, never "ask") are safe to run via this from inside a
172
+ // Promise.all batch; calls that can prompt must be awaited one at a time.
173
+ async function runOneCall(call, opts) {
174
+ const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
175
+ if (!opts.quiet)
176
+ console.log(indent + chalk.cyan(`🔧 ${call.name}`) + chalk.dim(` ${call.args.slice(0, 120)}`)); // show what the model wants to do
177
+ // The permission gate sits between the model's intent and execution.
178
+ const v = checkPermission(call.name, call.args);
179
+ emit("agent_tool_call", { tool: call.name }); // every attempt, allowed or not
180
+ let content; // what goes back to the model as the tool result
181
+ if (v.decision === "deny") {
182
+ emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
183
+ if (!opts.quiet)
184
+ console.log(chalk.red(` ⛔ denied: ${v.reason}`)); // tell the user we blocked it
185
+ 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
186
+ }
187
+ else if (v.decision === "ask") {
188
+ const ok = await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`); // pause and ask the human
189
+ if (!ok)
190
+ emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
191
+ if (!ok && !opts.quiet)
192
+ console.log(chalk.yellow(" ✋ declined")); // make the refusal visible
193
+ content = ok
194
+ ? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
195
+ : `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
196
+ }
197
+ else {
198
+ content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
199
+ }
200
+ return { id: call.id, content }; // paired by id for the API
201
+ }
202
+ // Run an approved tool call, surrounded by the user's lifecycle hooks.
203
+ // PreToolUse can block (exit 2) — its stderr is fed back to the model as the
204
+ // reason, exactly like a permission denial, so the model adapts instead of
205
+ // retrying. PostToolUse is observational; if it blocks, its message is appended
206
+ // to the tool result as extra context (e.g. "lint failed on the file you just
207
+ // wrote"). Sub-agents skip hooks: hooks are about the human's project policy,
208
+ // not internal delegation.
209
+ async function runWithHooks(call, opts) {
210
+ if (opts.subAgent)
211
+ return execute(call, opts); // sub-agents run hook-free
212
+ const pre = await runHooks("PreToolUse", { tool: call.name, args: call.args }); // before
213
+ if (pre.block) {
214
+ emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
215
+ if (!opts.quiet)
216
+ console.log(chalk.red(` ⛔ blocked by PreToolUse hook`)); // make it visible
217
+ return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
218
+ }
219
+ const result = await execute(call, opts); // the actual work
220
+ const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
221
+ if (post.block) {
222
+ // PostToolUse cannot undo the action, but it can tell the model something
223
+ // is wrong with the result — surface that as appended context.
224
+ return `${result}\n\n[hook] PostToolUse: ${post.feedback}`;
225
+ }
226
+ return result;
227
+ }
162
228
  // Execute one approved tool call. The task tool is special — it is not in the
163
229
  // registry because running it needs the loop itself (it IS a loop).
164
230
  async function execute(call, opts) {
165
231
  if (call.name !== "task")
166
- return dispatch(call.name, call.args); // ordinary tools go through the registry
232
+ return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
167
233
  if (opts.subAgent)
168
234
  return "[error] Sub-agents cannot spawn sub-agents. Do the work yourself."; // one level of delegation only
169
235
  let description = ""; // the sub-task text
@@ -187,10 +253,12 @@ compaction) {
187
253
  await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
188
254
  compaction.count++; // one more successful compaction this query
189
255
  compaction.failures = 0; // success resets the failure streak
256
+ emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
190
257
  return true;
191
258
  }
192
259
  catch {
193
260
  compaction.failures++; // failed — count it against the breaker
261
+ emit("agent_compaction_failed"); // a failure streak here trips the breaker
194
262
  return false;
195
263
  }
196
264
  }
@@ -240,6 +308,7 @@ opts) {
240
308
  }
241
309
  if (!e.retryable)
242
310
  return { reason: TerminateReason.FatalApiError, detail: `${e.kind}: ${e.message}` }; // bad key etc. — stop now
311
+ emit("agent_api_error", { kind: e.kind, attempt: attempts.total + 1 }); // classified, greppable
243
312
  attempts.total++; // every failure consumes the overall budget
244
313
  attempts.consecutive++; // ...and the breaker counter
245
314
  if (e.kind === ApiErrorKind.RateLimited)
@@ -276,33 +345,44 @@ opts) {
276
345
  : {}), // omit the field entirely when there were no calls
277
346
  });
278
347
  if (!out.toolCalls.length) {
348
+ // The model wants to stop. A Stop hook gets the last word: if it exits
349
+ // 2, the agent is NOT done — its stderr becomes a new instruction and
350
+ // the loop continues. This is how you build test-driven AI: a Stop hook
351
+ // runs the tests, blocks while they fail, and the agent keeps fixing.
352
+ // Sub-agents are exempt — Stop hooks are the human's project policy.
353
+ if (!opts.subAgent) {
354
+ const stop = await runHooks("Stop", { finalText: out.content }); // give hooks the last word
355
+ if (stop.block) {
356
+ emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
357
+ if (!opts.quiet)
358
+ console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
359
+ messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
360
+ break; // exit the inner while → advance the round counter (so a stubborn Stop hook is still bounded by MAX_ROUNDS)
361
+ }
362
+ }
279
363
  return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
280
364
  }
281
- // Execute every tool call, each behind the permission gate.
282
- for (const call of out.toolCalls) {
283
- const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
284
- if (!opts.quiet)
285
- console.log(indent + chalk.cyan(`🔧 ${call.name}`) + chalk.dim(` ${call.args.slice(0, 120)}`)); // show what the model wants to do
286
- // The permission gate sits between the model's intent and execution.
287
- const v = checkPermission(call.name, call.args);
288
- let content; // what goes back to the model as the tool result
289
- if (v.decision === "deny") {
290
- if (!opts.quiet)
291
- console.log(chalk.red(` ⛔ denied: ${v.reason}`)); // tell the user we blocked it
292
- 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
293
- }
294
- else if (v.decision === "ask") {
295
- const ok = await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`); // pause and ask the human
296
- if (!ok && !opts.quiet)
297
- console.log(chalk.yellow(" ✋ declined")); // make the refusal visible
298
- content = ok
299
- ? await execute(call, opts) // approved — actually run it
300
- : `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
301
- }
302
- else {
303
- content = await execute(call, opts); // allow — run without ceremony
365
+ // Execute the tool calls. Read-only calls (read_file/search) in a
366
+ // contiguous run are executed CONCURRENTLY — they cannot conflict and
367
+ // never need an interactive prompt. Anything that writes or executes runs
368
+ // ALONE and in order: writes can depend on each other, and we can only ask
369
+ // the human one question at a time. This greedy batching mirrors how
370
+ // Claude Code parallelizes safe reads while serializing risky work.
371
+ let i = 0; // index into out.toolCalls
372
+ while (i < out.toolCalls.length) {
373
+ const batch = []; // a run of safe, parallelizable calls
374
+ while (i < out.toolCalls.length && isReadOnlyTool(out.toolCalls[i].name))
375
+ batch.push(out.toolCalls[i++]);
376
+ if (batch.length) {
377
+ // Run the whole read-only batch at once, preserving result order.
378
+ const results = await Promise.all(batch.map((c) => runOneCall(c, opts)));
379
+ for (const r of results)
380
+ messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
381
+ continue; // back to the top — the next call is non-read-only
304
382
  }
305
- messages.push({ role: "tool", tool_call_id: call.id, content }); // feed the result back, paired by id
383
+ // A single non-read-only call: gate, maybe ask, execute — all serial.
384
+ const r = await runOneCall(out.toolCalls[i++], opts);
385
+ messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
306
386
  }
307
387
  break; // round complete, move to the next one
308
388
  }
@@ -1,4 +1,5 @@
1
1
  import path from "node:path"; // used to resolve and split file paths
2
+ import { CONFIG } from "./config.js"; // user-configured allow/deny rules from settings files
2
3
  // ---- The no-fly zone --------------------------------------------------------
3
4
  // Writes here are never OK, in any mode. .git can destroy history, shell rc
4
5
  // files inject code into every future terminal, .ssh/.env leak credentials.
@@ -43,13 +44,26 @@ const BASH_ASK = [
43
44
  // Single, read-only-ish commands that run without asking.
44
45
  const BASH_ALLOW = new Set(["ls", "pwd", "echo", "wc", "which", "date", "node", "npm", "npx", "cat", "head", "tail"]);
45
46
  const GIT_READONLY = new Set(["status", "log", "diff", "show", "branch", "remote", "tag"]); // git subcommands that only read
47
+ // User rules from settings files, split by kind. Computed per call (not cached)
48
+ // so the test suite can mutate CONFIG.permissions and see the effect.
49
+ function userBashRules() {
50
+ return {
51
+ allow: CONFIG.permissions.allow.filter((r) => !r.startsWith("tool:")), // bash first-words to trust
52
+ deny: CONFIG.permissions.deny.filter((r) => !r.startsWith("tool:")), // command substrings to block
53
+ };
54
+ }
46
55
  // Decide what a bash command deserves: deny, ask, or allow.
47
56
  function checkBash(command) {
48
57
  const summary = command.trim(); // what the user will see in the prompt
49
- // Order is the security model: deny first, ask second, allow last.
58
+ const user = userBashRules(); // the user's configured additions
59
+ // Order is the security model: built-in deny, then user deny, then ask,
60
+ // then allow. A user allow can never jump this queue — allow is checked last.
50
61
  for (const [re, why] of BASH_DENY)
51
62
  if (re.test(command))
52
63
  return { decision: "deny", reason: why, summary };
64
+ for (const d of user.deny)
65
+ if (command.toLowerCase().includes(d.toLowerCase()))
66
+ return { decision: "deny", reason: `denied by your settings ("${d}")`, summary };
53
67
  for (const [re, why] of BASH_ASK)
54
68
  if (re.test(command))
55
69
  return { decision: "ask", reason: why, summary };
@@ -61,12 +75,18 @@ function checkBash(command) {
61
75
  return { decision: "allow", reason: "read-only git", summary }; // safe git subcommands
62
76
  if (BASH_ALLOW.has(words[0]))
63
77
  return { decision: "allow", reason: "safe command", summary }; // known-harmless single command
78
+ if (user.allow.includes(words[0]))
79
+ return { decision: "allow", reason: "allowed by your settings", summary }; // the user vouched for this command
64
80
  // Fail closed: a command we don't recognize is a question for the human.
65
81
  return { decision: "ask", reason: "unrecognized command", summary };
66
82
  }
67
83
  // ---- The single entry point ---------------------------------------------------
68
84
  // Called by the loop for EVERY tool call, before anything executes.
69
85
  export function checkPermission(toolName, argsJson) {
86
+ // A user-configured tool block beats everything, including built-in allows.
87
+ if (CONFIG.permissions.deny.includes(`tool:${toolName}`)) {
88
+ return { decision: "deny", reason: `tool blocked by your settings ("tool:${toolName}")`, summary: toolName };
89
+ }
70
90
  let args; // the parsed tool arguments
71
91
  try {
72
92
  args = JSON.parse(argsJson); // arguments arrive as a raw JSON string from the model
@@ -95,6 +115,12 @@ export function checkPermission(toolName, argsJson) {
95
115
  const hit = noFlyHit(p); // is it in the no-fly zone?
96
116
  if (hit)
97
117
  return { decision: "deny", reason: hit, summary: p }; // hard stop — deny always wins
118
+ // The user may pre-approve write tools ("tool:edit_file") to skip the
119
+ // prompt — note this runs AFTER the no-fly check, so it widens "ask",
120
+ // never "deny".
121
+ if (CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
122
+ return { decision: "allow", reason: "pre-approved by your settings", summary: p };
123
+ }
98
124
  return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
99
125
  }
100
126
  case "run_bash":
@@ -0,0 +1,61 @@
1
+ import fs from "node:fs"; // session files live on disk
2
+ import path from "node:path"; // path joining
3
+ // Sessions are project-local: .mini-agent/sessions/<id>.json under the cwd.
4
+ // Project-local (not global) because a conversation is about a codebase —
5
+ // resuming a refactor of project A from inside project B makes no sense.
6
+ const SESSIONS_DIR = path.resolve(".mini-agent", "sessions");
7
+ const KEEP_SESSIONS = 20; // how many session files survive pruning
8
+ // A fresh, filename-safe session id derived from the wall clock.
9
+ export function newSessionId() {
10
+ return new Date().toISOString().replace(/[:.]/g, "-"); // 2026-06-12T10-30-00-000Z
11
+ }
12
+ // Persist the conversation. Full snapshot, atomically (tmp + rename): the
13
+ // history is not append-only — compaction rewrites it — so a snapshot is the
14
+ // only representation that is always correct. Crash mid-write leaves the
15
+ // previous snapshot intact.
16
+ export function saveSession(id, model, messages) {
17
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true }); // first save creates the directory
18
+ const file = path.join(SESSIONS_DIR, `${id}.json`); // the real destination
19
+ const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt
20
+ const data = {
21
+ id,
22
+ startedAt: existing?.startedAt ?? new Date().toISOString(), // first save stamps it, later saves keep it
23
+ savedAt: new Date().toISOString(), // always refreshed
24
+ model, // informational — resume does not force the same model
25
+ messages: messages.filter((m) => m.role !== "system"), // the constitution is rebuilt fresh on resume (AGENT.md may have changed)
26
+ };
27
+ const tmp = `${file}.tmp`; // write next to the destination (same filesystem → rename is atomic)
28
+ fs.writeFileSync(tmp, JSON.stringify(data)); // the new snapshot
29
+ fs.renameSync(tmp, file); // atomic swap
30
+ prune(); // keep the directory bounded
31
+ }
32
+ // Load the most recent session, or null if there is none (or it is unreadable —
33
+ // a corrupt session file should not kill the CLI, just start fresh).
34
+ export function latestSession() {
35
+ if (!fs.existsSync(SESSIONS_DIR))
36
+ return null; // nothing ever saved here
37
+ const files = fs
38
+ .readdirSync(SESSIONS_DIR)
39
+ .filter((f) => f.endsWith(".json")) // ignore tmp files and strangers
40
+ .map((f) => ({ f, mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtimeMs })) // newest by modification time
41
+ .sort((a, b) => b.mtime - a.mtime);
42
+ if (!files.length)
43
+ return null; // empty directory
44
+ try {
45
+ return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, files[0].f), "utf8")); // the newest snapshot
46
+ }
47
+ catch {
48
+ return null; // unreadable — treat as absent, never crash on resume
49
+ }
50
+ }
51
+ // Delete everything beyond the newest KEEP_SESSIONS files. A CLI that quietly
52
+ // accumulates unbounded state in every project directory is not industrial.
53
+ function prune() {
54
+ const files = fs
55
+ .readdirSync(SESSIONS_DIR)
56
+ .filter((f) => f.endsWith(".json"))
57
+ .map((f) => ({ f, mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtimeMs }))
58
+ .sort((a, b) => b.mtime - a.mtime); // newest first
59
+ for (const { f } of files.slice(KEEP_SESSIONS))
60
+ fs.rmSync(path.join(SESSIONS_DIR, f)); // drop the tail
61
+ }
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs"; // the event log lives on disk
2
+ import path from "node:path"; // path joining
3
+ // LOCAL-ONLY telemetry. Events go to .mini-agent/telemetry.jsonl in the
4
+ // project directory and NOWHERE else — nothing leaves the machine, ever.
5
+ // It exists because "no data" means every optimization is a guess: which
6
+ // errors actually happen, how often compaction fires, which tools get denied.
7
+ // Opt out entirely with MINI_AGENT_NO_TELEMETRY=1.
8
+ //
9
+ // Naming follows the unified-prefix convention (agent_<domain>_<action>) so
10
+ // the log stays greppable: `grep agent_api_error telemetry.jsonl | wc -l`.
11
+ const TELEMETRY_FILE = path.resolve(".mini-agent", "telemetry.jsonl");
12
+ let sessionId = null; // set by initTelemetry; emit() is a no-op before that
13
+ const tallies = new Map(); // per-session event counts, for /stats
14
+ // Arm telemetry for this session. Tests and the eval harness never call this,
15
+ // so library use stays silent — only the real CLI writes events.
16
+ export function initTelemetry(id) {
17
+ if (process.env.MINI_AGENT_NO_TELEMETRY === "1")
18
+ return; // the user opted out — stay disarmed
19
+ sessionId = id; // armed
20
+ }
21
+ // Record one event. Never throws: a telemetry failure must never break work.
22
+ export function emit(event, fields = {}) {
23
+ const key = event + (fields.tool ? `:${fields.tool}` : ""); // tool events tally per tool name
24
+ tallies.set(key, (tallies.get(key) ?? 0) + 1); // /stats counts even when disarmed
25
+ if (!sessionId)
26
+ return; // disarmed — counters only, no file
27
+ try {
28
+ fs.mkdirSync(path.dirname(TELEMETRY_FILE), { recursive: true }); // ensure the directory
29
+ fs.appendFileSync(TELEMETRY_FILE, JSON.stringify({ ts: new Date().toISOString(), session: sessionId, event, ...fields }) + "\n"); // one JSON object per line
30
+ }
31
+ catch {
32
+ /* a full disk or a read-only checkout is not our problem to escalate */
33
+ }
34
+ }
35
+ // The per-session tallies, sorted by count — the data behind /stats.
36
+ export function statsReport() {
37
+ if (!tallies.size)
38
+ return "(no events this session yet)"; // nothing happened
39
+ const lines = [...tallies.entries()]
40
+ .sort((a, b) => b[1] - a[1]) // busiest first
41
+ .map(([k, n]) => ` ${String(n).padStart(5)} ${k}`); // aligned counts
42
+ return ["events this session:", ...lines, `\nfull log: ${TELEMETRY_FILE} (local only — nothing leaves this machine)`].join("\n");
43
+ }
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs"; // filesystem reads and writes
2
+ import os from "node:os"; // temp dir for spilled tool output
2
3
  import path from "node:path"; // path resolution and joining
3
- import { execSync } from "node:child_process"; // runs a shell command and waits for it
4
+ import { spawn } from "node:child_process"; // async process execution (does NOT block the event loop)
4
5
  // ============ Session state ============
5
6
  // The foundation of "read before edit": which files has this session actually read?
6
7
  // Files that were never read must not be edited — otherwise the model edits from
@@ -15,6 +16,9 @@ const TOOL_RESULT_LIMIT = 4000; // cap (chars) for a normal tool result entering
15
16
  const READ_LIMIT = 16000; // read_file gets a larger cap of its own
16
17
  const SEARCH_MAX_MATCHES = 50; // max matches returned by search
17
18
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); // directories search never enters
19
+ const BASH_TIMEOUT_MS = 30_000; // a single command may run this long before we kill it
20
+ const BASH_OUTPUT_LIMIT = 8_000; // chars of command output kept inline; beyond this we spill to a file
21
+ const SPILL_NOTE_SLACK = 400; // room for the "[...saved to FILE]" note so dispatch's cap never eats it
18
22
  // Errors are not exceptions — they are text that tells the model what to do next.
19
23
  const fail = (msg) => `[error] ${msg}`;
20
24
  // Small helper to build a tool definition without repeating boilerplate.
@@ -165,24 +169,66 @@ ALWAYS use this tool to search. NEVER run grep/rg/find via run_bash.`, {
165
169
  },
166
170
  };
167
171
  // ============ run_bash ============
172
+ // Spill oversized output to a file and hand the model a preview + the path, so
173
+ // it can read_file deeper if it wants. Returning megabytes inline would blow up
174
+ // the context; truncating silently would hide the part that matters.
175
+ function spillIfHuge(out, what) {
176
+ if (out.length <= BASH_OUTPUT_LIMIT)
177
+ return out; // fits inline — return as-is
178
+ const file = path.join(os.tmpdir(), `mini-agent-output-${Date.now()}.txt`); // a stable place to find it
179
+ fs.writeFileSync(file, out, "utf8"); // the full output lives on disk
180
+ const head = out.slice(0, BASH_OUTPUT_LIMIT); // a generous preview
181
+ return `${head}\n\n[...${what} truncated: ${out.length} chars total. Full output saved to ${file} — use read_file to inspect more.]`;
182
+ }
168
183
  const runBash = {
169
184
  definition: def("run_bash", `Run a bash command on the user's machine (30s timeout) and return its output.
170
185
  Boundaries: only for things that truly need execution — installing dependencies, running tests/scripts, git operations.
171
186
  To read files use read_file (not cat), to search use search (not grep/find), to edit files use edit_file (not sed/redirection).
172
187
  Dangerous commands require user approval; some are denied outright. A [permission] result is a hard boundary — never try to work around it.
173
188
  On error: failures return the error output — analyze it and try a different approach; do not resend the same command unchanged.`, { command: { type: "string", description: "The bash command to run" } }, ["command"]),
174
- run: (args) => {
175
- try {
176
- // stderr is piped (not inherited): it belongs in the tool result for the
177
- // model to read, not splattered onto the user's terminal.
178
- const out = execSync(args.command, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
179
- return out || "(command succeeded, no output)"; // never return an empty string
180
- }
181
- catch (err) {
182
- const e = err; // execSync attaches the captured stderr on failure
183
- return fail(`Command failed: ${e.message}${e.stderr ? `\nstderr: ${e.stderr.slice(0, 1000)}` : ""}`); // failure becomes guidance, not a crash
184
- }
185
- },
189
+ // Async + spawn: the event loop stays responsive while the command runs, so
190
+ // the spinner keeps spinning and Ctrl+C is heard. execSync would freeze
191
+ // everything — the whole process, the UI, the signal handler — until it
192
+ // returned. The AbortSignal lets the loop kill the child on interruption
193
+ // instead of waiting out the 30s timeout.
194
+ run: (args, signal) => new Promise((resolve) => {
195
+ const child = spawn(args.command, { shell: true, stdio: ["ignore", "pipe", "pipe"] }); // shell:true → bash semantics
196
+ let stdout = ""; // captured stdout
197
+ let stderr = ""; // captured stderr (for the model, not the user's terminal)
198
+ let settled = false; // resolve exactly once
199
+ const done = (text) => {
200
+ if (settled)
201
+ return; // guard against timeout/exit/abort racing
202
+ settled = true;
203
+ clearTimeout(timer); // stop the watchdog
204
+ signal?.removeEventListener("abort", onAbort); // unhook
205
+ resolve(text); // hand the result back
206
+ };
207
+ // Hard timeout: SIGKILL the whole process group, never hang the agent.
208
+ const timer = setTimeout(() => {
209
+ child.kill("SIGKILL");
210
+ done(fail(`Command timed out after ${BASH_TIMEOUT_MS / 1000}s and was killed.${stdout ? `\npartial stdout:\n${stdout.slice(0, 2000)}` : ""}`));
211
+ }, BASH_TIMEOUT_MS);
212
+ // Ctrl+C while a command runs: kill the child immediately.
213
+ const onAbort = () => {
214
+ child.kill("SIGKILL");
215
+ done(fail("Command was interrupted by the user."));
216
+ };
217
+ signal?.addEventListener("abort", onAbort);
218
+ child.stdout.on("data", (d) => (stdout += d)); // accumulate
219
+ child.stderr.on("data", (d) => (stderr += d));
220
+ child.on("error", (err) => done(fail(`Could not start command: ${err.message}`))); // e.g. command not found at the spawn level
221
+ child.on("close", (code) => {
222
+ if (code === 0) {
223
+ done(spillIfHuge(stdout, "output") || "(command succeeded, no output)"); // success
224
+ }
225
+ else {
226
+ // Non-zero exit: combine streams so the model sees the actual error.
227
+ const combined = [stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`].filter(Boolean).join("\n");
228
+ done(fail(`Command exited with code ${code}.\n${spillIfHuge(combined, "error output")}`));
229
+ }
230
+ });
231
+ }),
186
232
  };
187
233
  // ============ Registry & dispatch ============
188
234
  // One place that knows every tool. Adding a tool = adding one entry here.
@@ -195,9 +241,16 @@ export const tools = {
195
241
  };
196
242
  // The list of manuals sent to the model on every API call.
197
243
  export const toolDefinitions = Object.values(tools).map((t) => t.definition);
244
+ // Read-only tools never touch the filesystem in a conflicting way, so the loop
245
+ // is free to run a batch of them concurrently. Anything that writes or executes
246
+ // runs alone, in order. (See loop.ts for the batching.)
247
+ const READ_ONLY_TOOLS = new Set(["read_file", "search"]);
248
+ export const isReadOnlyTool = (name) => READ_ONLY_TOOLS.has(name);
198
249
  // Single entry point: every failure becomes text fed back to the model.
199
- // It never throws — the main loop must never die because of a tool.
200
- export function dispatch(name, argsJson) {
250
+ // It never throws — the main loop must never die because of a tool. Async since
251
+ // Day 13 so run_bash can spawn without blocking; the signal lets Ctrl+C kill a
252
+ // running command.
253
+ export async function dispatch(name, argsJson, signal) {
201
254
  const tool = tools[name]; // look the tool up by name
202
255
  if (!tool)
203
256
  return fail(`Unknown tool: ${name}. Available tools: ${Object.keys(tools).join(", ")}`); // model hallucinated a tool → list the real ones
@@ -208,8 +261,14 @@ export function dispatch(name, argsJson) {
208
261
  catch {
209
262
  return fail(`Arguments are not valid JSON: ${argsJson.slice(0, 200)}`); // malformed JSON → show what we got
210
263
  }
264
+ // Each tool gets a result cap sized to its job. run_bash already manages its
265
+ // own output (preview + spill-to-disk via spillIfHuge); its cap must leave
266
+ // room for the spill note, or dispatch would chop off the very pointer the
267
+ // model needs to read the rest.
268
+ const cap = name === "read_file" ? READ_LIMIT + 100 : name === "run_bash" ? BASH_OUTPUT_LIMIT + SPILL_NOTE_SLACK : TOOL_RESULT_LIMIT;
211
269
  try {
212
- return tool.run(args).slice(0, name === "read_file" ? READ_LIMIT + 100 : TOOL_RESULT_LIMIT); // run it, cap the result size
270
+ const result = await tool.run(args, signal); // await covers both sync and async tools
271
+ return result.slice(0, cap); // cap the result size
213
272
  }
214
273
  catch (err) {
215
274
  return fail(`Tool crashed: ${err.message}`); // even a crashing tool becomes a readable result
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.1.0",
4
- "description": "A Claude Code-style CLI agent built from scratch with the DeepSeek API — companion code for a 10-day tutorial",
3
+ "version": "0.5.0",
4
+ "description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mini-agent": "dist/agent.js"
@@ -15,7 +15,8 @@
15
15
  "typecheck": "tsc --noEmit",
16
16
  "eval": "tsx --env-file=.env eval/run.ts",
17
17
  "build": "tsc -p tsconfig.build.json",
18
- "prepare": "npm run build"
18
+ "prepare": "npm run build",
19
+ "test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts"
19
20
  },
20
21
  "engines": {
21
22
  "node": ">=20.6"