agent-from-zero 0.5.0 → 0.27.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
@@ -4,7 +4,7 @@
4
4
 
5
5
  人定方向、做决策、验收,AI 写大部分代码——这既是本仓库的诞生方式,也是这门课教你的工作方式。
6
6
 
7
- 课程拆成 10 个 Day,一个 Day 一个 commit,按一晚一章的节奏跟做。**commit 历史就是课程目录**:`git log --oneline` 看大纲,`git checkout dayN` 拿到第 N 章对应的完整代码。
7
+ 课程共 35 天——主线 10 天搓出一个能用的 agent,进阶 25 天打磨成工业级 CLI(MCP / 权限裁判 / 长期记忆 / 成本核算 / 调用校验 / 计划模式 / diff 预览 / 撤销 / 模型分级 / 远程 MCP / 会话改动总览 / @file 引用 / 会话选择器 / 终端 UI / 方向键审批 / 多问题表单 / 模型切换 / 推理流 / Skill 系统 / 记忆深化 / Hook 深化);一天一个 commit、一晚一章的节奏跟做。**commit 历史就是课程目录**:`git log --oneline` 看大纲,`git checkout dayN` 拿到第 N 天对应的完整代码。
8
8
 
9
9
  > **设计有出处,不是凭感觉搭的**:蓝本是《Harness Engineering:从 Claude Code 看 AI 编码工程》提炼的 22 个工程模式——博主在公众号用 30+ 篇拆完了全书(本仓库就是那次拆解的实践篇)。书的收官章,作者用 800 行 Rust 写了一个代码审查 Agent,验证这些模式能跨语言、跨场景活下来;**这个仓库是同一套模式的又一次迁移**:场景换成 Claude Code 式的 CLI 编码 agent,语言换成 TypeScript——不跟着用 Rust,是因为新手教程的第一原则是别让语言难度挡路。
10
10
  >
@@ -61,10 +61,31 @@ MINI_AGENT_MODEL=gpt-4.1-mini
61
61
  | `day12` | 会话持久化(`--resume`,快照式 + 原子写)+ 本地遥测(事件 JSONL + `/stats`,绝不出本机,`MINI_AGENT_NO_TELEMETRY=1` 关闭) |
62
62
  | `day13` | 异步执行:run_bash 换 spawn(不冻 UI、Ctrl+C 秒杀、30s 超时 SIGKILL)、超长输出落盘、只读工具并行;`npm test` 65 例 |
63
63
  | `day14` | Hook 生命周期系统:PreToolUse(拦截工具)/PostToolUse(追加校验)/SessionStart(注入上下文)/Stop(测试驱动 AI),退出码协议、stderr 喂回模型;`npm test` 76 例 |
64
+ | `day15` | MCP 接入:stdio JSON-RPC 客户端(握手/行缓冲/id 配对),外部 server 工具注册成内置同款(同一 dispatch + 权限闸门,默认 ask);`npm test` 84 例 |
65
+ | `day16` | 可选 LLM 权限裁判:只看规则判 ask 的灰色地带、只能 ask→allow、prompt 铁死格式、解析 fail closed、自带熔断、默认关闭;`npm test` 98 例 |
66
+ | `day17` | 长期记忆:跨会话的 MEMORY.md(人类可读)+ remember 工具,作为「背景」注入(区别于 AGENT.md 的「命令」),有界 30 条/8KB 且自修复;`npm test` 114 例 |
67
+ | `day18` | 成本核算:从流里捕获 usage(include_usage)、抹平 DeepSeek/OpenAI 格式差异、纯函数算成本与缓存命中率、`/cost` 看花销(命中率即缓存击穿预警);`npm test` 130 例 |
68
+ | `day19` | 工具调用校验:够用就好的 schema 校验(必填/类型/形状)、钉在 dispatch 唯一入口、批量返回精确修复指引、自动覆盖内置+MCP 工具(送出前拦下坏参数);`npm test` 146 例 |
69
+ | `day20` | 计划模式:只读研究、想清楚再交计划由你批准;权限层一个外层过滤器(只收紧不放松、盖过预批准、保留禁飞区理由)+ `exit_plan_mode` 工具借用 ask 流程闭合审批 + `/plan` 开关;`npm test` 167 例 |
70
+ | `day21` | diff 预览:写文件前把真实改动(绿 +/红 -)摊给你看再批准;十几行 LCS 行级 diff、折叠未变动行、复现工具语义无副作用推演、永不抛异常 fail 到 null;在闸门后执行前打印;`npm test` 189 例 |
71
+ | `day22` | `/undo` 撤销上一次写入:写入前埋一行存「之前」快照(有界栈),`/undo` 还原内容或删掉新建文件、复用 Day 21 diff 画出撤销结果;`/clear` 同时清栈;`npm test` 204 例 |
72
+ | `day23` | 子 agent 模型分级:`subAgentModel` 配置让委托跑在不同档位(便宜模型干杂活 / 强模型当顾问审查),纯函数 `subAgentModelFor` 定回落规则,首次用假 client 端到端验证「编排→委托→回归」真换了模型;`npm test` 212 例 |
73
+ | `day24` | 远程 MCP(HTTP/SSE):把 Day 15 的传输从协议里剥成 Transport 接口,新增 HTTP 传输(POST→按 Content-Type 分 JSON/SSE 解析、回带 Mcp-Session-Id、AbortSignal 超时),配置 `url` 即走远程;协议层/发现/注册零改动;`npm test` 218 例 |
74
+ | `day25` | `/diff` 会话改动总览:撤销账本旁加一张「会话起点基线」表(每个文件首次被碰的样子,只记一次),`/diff` 与磁盘现状逐个比、复用 Day 21 渲染净改动、净零跳过,三态 created/modified/deleted;`npm test` 227 例 |
75
+ | `day26` | `@file` 引用:提问里 `@路径` 即把文件内容附进消息省一次 read_file 往返;认行首/空白后的 `@`、剥句尾标点;每个提及过 read_file 权限闸门——密钥文件(.env/*.pem)拒绝不读、被拒出声、没命中静默;`npm test` 244 例 |
76
+ | `day27` | `/resume` 会话选择器:`session.ts` 加 listSessions(摘要)/loadSession(按 id)/sessionTitle(首句当标题、剥 @file 附件),REPL 列表+选号+整体切换(重建宪法、换 id、清读态与撤销栈),会话目录改惰性求值以可测;`npm test` 256 例 |
77
+ | `day28` | 终端 UI:新增 `ui.ts` 收拢外观——圆角欢迎框、`❯` 提示符、`⏺`/`⎿` 活动标记(替 emoji)、走秒 spinner(搭车看门狗);讲透「颜色码污染宽度计算」要先按纯文本对齐再上色;`npm test` 277 例 |
78
+ | `day29` | 方向键审批菜单:审批从「打 y/N」换成 ↑↓ 选(`menu.ts` 借 stdin 进 raw 模式、原地重绘、选完归还),带编号+底部操作提示;中间项「本会话别再问」复用 allow 规则(run_bash 只升 ask 不碰硬 deny);非 TTY 回退打字;`npm test` 289 例 |
79
+ | `day30` | 多问题选择表单:给模型一个 `ask_user` 工具,需用户决策时弹表单(`form.ts` 纯状态机:扁平光标+reduce+提交校验+缺口跳转,`promptForm` 薄键盘层),收集成 question→answer 喂回;输入区改成 Claude Code 式「分隔线+❯」;`npm test` 309 例 |
80
+ | `day31` | 运行时切换模型:`/model` 从只读升级成可切——`/model <name>` 直接设,裸 `/model` 拉端点 `/models` 列表用 Day 29 菜单选;切换只改 `CONFIG.model`、靠主循环每轮重读立即生效;`npm test` 314 例 |
81
+ | `day32` | 让换模型看得见:流式循环多读 `reasoning_content`,把推理模型(R1)的思考灰着流出来(只渲染不进历史,绝不回传);spinner 显示当前模型作为切换生效的证明;`npm test` 324 例 |
82
+ | `day33` | Skill 系统(Markdown 即插件):`SKILL.md`(frontmatter+正文)=一个插件、执行者是模型;渐进披露(列表进上下文/正文按需加载)、`skill` 工具说明书即列表、`disableModelInvocation` 分模型可调/仅用户、`/skills` `/skill <name>`;`npm test` 360 例 |
83
+ | `day34` | 记忆深化:四类型(user/feedback/project/reference,feedback 最值钱)、每轮自动提取(便宜模型读对话存「用户纠正过的」,默认关、永不抛异常)、按类型分组注入、向后兼容旧格式;`npm test` 375 例 |
84
+ | `day35` | Hook 深化:4→10 个时机(加 UserPromptSubmit/SessionEnd/Pre·PostCompact/Subagent*),配置改开放 record、exit 2 语义按事件由调用方解读、SessionEnd 极短超时;**`PreToolUse` 可改写工具参数**(权限三态做不到的「岔道」,严格在安全检查之后);`npm test` 381 例 |
64
85
 
65
86
  ## 📖 配套教程(免费开源)
66
87
 
67
- **[完整教程在 docs/ 目录](docs/README.md)**——11 章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
88
+ **[完整教程在 docs/ 目录](docs/README.md)**——主线 + 进阶共十几章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
68
89
 
69
90
  想看更深的「为什么」:公众号的 **Harness Engineering 拆书系列**(30+ 篇)逐章拆了《Harness Engineering:从 Claude Code 看 AI 编码工程》——本仓库每个设计决策背后的原理、数据和事故案例都在那里。公众号 @(待填)。
70
91
 
package/dist/agent.js CHANGED
@@ -3,14 +3,27 @@ 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
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)
7
- import { runLoop, TerminateReason, MAX_ROUNDS, MAX_RETRIES } from "./loop.js"; // the state machine
6
+ import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
7
+ import { runLoop, TerminateReason, MAX_RETRIES } from "./loop.js"; // the state machine
8
8
  import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
9
- import { forgetFilesExcept } from "./tools.js"; // to reset file state on /clear
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)
9
+ import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
10
+ import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
11
+ import { newSessionId, saveSession, latestSession, listSessions, loadSession } from "./session.js"; // conversation persistence (project-local) + the /resume picker
12
12
  import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
13
13
  import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
14
+ import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
15
+ import { Judge } from "./judge.js"; // optional LLM permission classifier
16
+ import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
17
+ import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
18
+ import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
19
+ import path from "node:path"; // shorten paths for the /diff summary
20
+ import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
21
+ import { banner, framedPrompt, formatModelChoices, statusLine, inputRule } from "./ui.js"; // welcome box, prompt, model labels, status line
22
+ import { gitBranch, toggleLastCollapsed, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output
23
+ import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
24
+ import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
25
+ import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
26
+ import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
14
27
  // package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
15
28
  const pkg = createRequire(import.meta.url)("../package.json");
16
29
  // ---- CLI arguments --------------------------------------------------------------
@@ -33,7 +46,6 @@ Configuration (optional):
33
46
  In a session, type /help for the in-session commands.`;
34
47
  // What we tell the user for every way a query can end. No raw stack traces.
35
48
  const EXIT_NOTES = {
36
- [TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
37
49
  [TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
38
50
  [TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
39
51
  [TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
@@ -47,9 +59,20 @@ const SESSION_HELP = `commands:
47
59
  /help this text
48
60
  /clear wipe the conversation (and the file read-state) — fresh start
49
61
  /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
62
+ /model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
51
63
  /stats event counts for this session (local telemetry — nothing leaves this machine)
64
+ /memory show the durable facts the agent remembers about this project
65
+ /cost tokens, cache hit rate and estimated spend this session (local)
66
+ /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
67
+ /undo revert the most recent file write (write_file / edit_file) this session
68
+ /diff show every file changed this session, as a diff from where it started
69
+ /resume list recent sessions in this project and continue one of them
70
+ /skills list the reusable skills available in this project
71
+ /skill <name> run a skill yourself (works even for user-only skills)
52
72
  exit leave (Ctrl+C at the prompt does the same)`;
73
+ // Injected when the user turns plan mode on, so the model knows the rules of the
74
+ // mode it now lives in (the permission gate enforces them regardless).
75
+ const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
53
76
  async function main() {
54
77
  // ---- Flag handling, before anything touches the network -----------------------
55
78
  const argv = process.argv.slice(2); // everything after "mini-agent"
@@ -85,6 +108,7 @@ async function main() {
85
108
  // -r / --resume picks up the most recent one. The system message is NOT
86
109
  // restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
87
110
  let sessionId = newSessionId(); // this session's identity (and file name)
111
+ const sessionStartedAt = Date.now(); // for the status line's elapsed clock
88
112
  if (argv.includes("-r") || argv.includes("--resume")) {
89
113
  const prev = latestSession(); // newest snapshot in this directory, if any
90
114
  if (prev) {
@@ -98,6 +122,36 @@ async function main() {
98
122
  }
99
123
  initTelemetry(sessionId); // arm the local event log (no-op under MINI_AGENT_NO_TELEMETRY=1)
100
124
  emit("agent_session_start", { mode: printTask !== null ? "print" : "repl" }); // how this session was started
125
+ // Connect to any configured MCP servers and register their tools. This must
126
+ // finish before the first model call so the tools appear in the manual. A
127
+ // server that fails to start is skipped, never fatal.
128
+ const disconnectMcp = await connectMcpServers();
129
+ process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
130
+ // The optional LLM permission judge, built once if a settings file enabled it.
131
+ const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
132
+ if (judge)
133
+ console.log(chalk.dim(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`));
134
+ // The remember tool: let the model save durable facts to long-term memory.
135
+ // Registered like any tool, so it flows through the same permission gate.
136
+ registerExternalTool(rememberTool);
137
+ const memCount = readMemory().length;
138
+ if (memCount)
139
+ console.log(chalk.dim(`(long-term memory: ${memCount} facts loaded)`));
140
+ // Skills: reusable Markdown procedures. The model-invocable ones are exposed
141
+ // through a single `skill` tool whose description lists them (progressive
142
+ // disclosure); user-only skills are reachable via /skill <name>.
143
+ const skills = loadSkills();
144
+ if (skills.some((s) => !s.disableModelInvocation))
145
+ registerExternalTool(buildSkillTool(skills));
146
+ if (skills.length)
147
+ console.log(chalk.dim(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`));
148
+ // Token/cost meter for this session. Prices come from settings, falling back
149
+ // to the defaults; the loop records usage into it from every stream.
150
+ const costMeter = initCostMeter({
151
+ inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
152
+ cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
153
+ outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
154
+ });
101
155
  // SessionStart hooks run once, here. Their stdout is injected as context the
102
156
  // model sees on its first turn — e.g. inject the current git branch, an
103
157
  // on-call rota, today's deploy freeze status. (exit 2 here is ignored: there
@@ -125,6 +179,8 @@ async function main() {
125
179
  model: CONFIG.model,
126
180
  signal: controller.signal,
127
181
  isInterrupted: () => interrupted,
182
+ subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
183
+ judge, // auto-allow clearly-safe commands even unattended (safer than blanket AUTO_APPROVE)
128
184
  // Print mode never prompts: unattended means nobody can say yes.
129
185
  // MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
130
186
  // were enforced in permissions.ts long before this runs.
@@ -146,9 +202,8 @@ async function main() {
146
202
  return; // the answer itself already streamed to stdout
147
203
  }
148
204
  // ---- 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`));
205
+ // The welcome box: who am I, which model and host am I on, and the basics.
206
+ console.log(banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host));
152
207
  // ONE readline interface for the whole session — the task prompt and the
153
208
  // permission prompts share it. Two interfaces on one stdin fight each other.
154
209
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -202,8 +257,8 @@ async function main() {
202
257
  // Fail closed: in a non-interactive session nobody can say yes, so the
203
258
  // answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
204
259
  // denies were already enforced in permissions.ts before we get here.
205
- const confirm = async (question) => {
206
- console.log(chalk.yellow(`\n⚠️ approval needed — ${question}`)); // always show what is being asked
260
+ const confirm = async (question, toolName) => {
261
+ console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
207
262
  if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
208
263
  console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
209
264
  return true; // approve without asking
@@ -212,26 +267,234 @@ async function main() {
212
267
  console.log(chalk.dim(" non-interactive session — denied by default (fail closed)")); // nobody can answer — refuse
213
268
  return false; // fail closed
214
269
  }
215
- const answer = await readUserLine(" Allow? [y/N] "); // wait for the human (shares the line queue)
216
- return /^y(es)?$/i.test(answer.trim()); // anything but y/yes means no
270
+ // Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
271
+ // option lets the user stop being asked about this tool for the session.
272
+ const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
273
+ const choice = await promptSelect(rl, ["Yes", allowLabel, "No — let me tell the agent what to do instead"]);
274
+ const approved = choice === 0 || choice === 1;
275
+ if (choice === 1 && toolName) {
276
+ CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
277
+ console.log(chalk.dim(` won't ask again for ${toolName} this session`));
278
+ }
279
+ console.log(approved ? chalk.green(" ✓ approved") : chalk.yellow(" ✗ declined"));
280
+ return approved;
281
+ };
282
+ // The model's handle on the user: present a multi-question form and return the
283
+ // selections. Null in a non-interactive session — the model is told to ask in
284
+ // plain text instead.
285
+ const askUser = async (questions) => {
286
+ if (!process.stdin.isTTY)
287
+ return null;
288
+ return promptForm(rl, questions);
289
+ };
290
+ // Ask the endpoint what models it serves (the OpenAI-compatible /models API).
291
+ // Empty list on any failure — not every endpoint implements it, and a failed
292
+ // listing should never break the command.
293
+ const listModels = async () => {
294
+ try {
295
+ const page = await client.models.list();
296
+ const ids = (page.data ?? []).map((m) => m.id).filter(Boolean);
297
+ return [...new Set(ids)].sort();
298
+ }
299
+ catch {
300
+ return [];
301
+ }
302
+ };
303
+ // /model — show the current model, or switch. `/model <name>` sets it directly;
304
+ // bare `/model` lists the endpoint's models and lets you pick one (Day 29 menu).
305
+ // The switch takes effect next turn: the loop reads CONFIG.model each turn.
306
+ const handleModelCommand = async (arg) => {
307
+ // Switching is SESSION-ONLY by default — a quick "/model <name>" to try a
308
+ // model must never permanently change your default. "/model save <name>"
309
+ // (or save after picking) writes it to the global settings file.
310
+ const save = arg === "save" || arg.startsWith("save ");
311
+ const name = save ? arg.slice(4).trim() : arg;
312
+ const apply = (next) => {
313
+ const prev = CONFIG.model;
314
+ CONFIG.model = next; // any string — the API errors on an unknown one, and the error layer reports it
315
+ if (save) {
316
+ saveGlobalSetting("model", next); // persist as the new default
317
+ console.log(chalk.dim(`(model: ${prev} → ${next} · saved as your default)`));
318
+ }
319
+ else {
320
+ console.log(chalk.dim(`(model: ${prev} → ${next} · this session only — use "/model save ${next}" to keep it)`));
321
+ }
322
+ };
323
+ if (name) {
324
+ apply(name);
325
+ return;
326
+ }
327
+ // No name: show the current model and a picker.
328
+ console.log(chalk.dim(`model: ${CONFIG.model}\n` +
329
+ (CONFIG.subAgentModel ? `sub-agent model: ${CONFIG.subAgentModel} (delegated task work)\n` : "") +
330
+ `endpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
331
+ if (!process.stdin.isTTY)
332
+ return; // no picker without a TTY — switch with: /model <name>
333
+ const models = await listModels();
334
+ if (!models.length) {
335
+ console.log(chalk.dim("(couldn't list models from this endpoint — switch with: /model <name>)"));
336
+ return;
337
+ }
338
+ console.log(chalk.dim("switch model (this session):"));
339
+ const choice = await promptSelect(rl, formatModelChoices(models, CONFIG.model));
340
+ if (choice < 0 || models[choice] === CONFIG.model) {
341
+ console.log(chalk.dim("(model unchanged)"));
342
+ return;
343
+ }
344
+ apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
217
345
  };
218
346
  // Handle a /slash command. Returns true if the line was a command.
219
347
  const handleCommand = async (line) => {
348
+ // /model takes an optional argument (/model <name>), so it can't be a plain
349
+ // switch case — handle it before the exact-match switch.
350
+ if (line === "/model" || line.startsWith("/model ")) {
351
+ await handleModelCommand(line.slice("/model".length).trim());
352
+ return true;
353
+ }
354
+ // /skill <name> runs a skill on the user's behalf — inject its instructions
355
+ // as a turn so the model follows them. Works for user-only skills too.
356
+ if (line === "/skill" || line.startsWith("/skill ")) {
357
+ const name = line.slice("/skill".length).trim();
358
+ if (!name) {
359
+ console.log(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
360
+ return true;
361
+ }
362
+ const s = findSkill(skills, name);
363
+ if (!s) {
364
+ console.log(chalk.yellow(`(no skill named "${name}")`));
365
+ return true;
366
+ }
367
+ messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
368
+ console.log(chalk.dim(`(running skill: ${s.name})`));
369
+ running = true;
370
+ interrupted = false;
371
+ controller = new AbortController();
372
+ const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, subAgentModel: CONFIG.subAgentModel, judge });
373
+ running = false;
374
+ saveSession(sessionId, CONFIG.model, messages);
375
+ if (r.reason !== TerminateReason.Done)
376
+ console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
377
+ return true;
378
+ }
220
379
  switch (line) {
380
+ case "/skills": {
381
+ if (!skills.length) {
382
+ console.log(chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)"));
383
+ return true;
384
+ }
385
+ console.log(chalk.dim(`skills in this project:`));
386
+ for (const s of skills) {
387
+ const who = s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user");
388
+ console.log(chalk.dim(` ${s.name} [${who}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`));
389
+ }
390
+ return true;
391
+ }
221
392
  case "/help":
222
393
  console.log(chalk.dim(SESSION_HELP)); // the command reference
223
394
  return true;
224
395
  case "/clear":
225
396
  messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
226
397
  forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
398
+ clearUndo(); // a fresh conversation should not undo the previous one's writes
227
399
  sessionId = newSessionId(); // a fresh conversation is a fresh session file
228
400
  console.log(chalk.dim("(history cleared)")); // confirm the reset
229
401
  return true;
230
402
  case "/stats":
231
403
  console.log(chalk.dim(statsReport())); // local counters, busiest first
232
404
  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
405
+ case "/memory": {
406
+ const entries = readMemoryTyped(); // the durable, typed facts carried across sessions
407
+ console.log(chalk.dim(entries.length
408
+ ? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
409
+ : `(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"})`));
410
+ return true;
411
+ }
412
+ case "/cost":
413
+ console.log(chalk.dim(costMeter.report())); // tokens, cache hit rate, estimated spend (local)
414
+ return true;
415
+ case "/undo": {
416
+ // Revert the most recent write. This is a USER action on the filesystem,
417
+ // not a turn for the model — the conversation is untouched. If the model
418
+ // later edits a file it thinks it changed, edit_file's read-before-edit +
419
+ // exact-match guards will catch the staleness and ask it to re-read.
420
+ const undone = undoLast();
421
+ if (!undone) {
422
+ console.log(chalk.dim("(nothing to undo — no file writes recorded this session)"));
423
+ return true;
424
+ }
425
+ console.log(chalk.dim(`↩ ${undone.summary}`));
426
+ // Show the diff of what was put back (from the current state to the
427
+ // restored one), reusing the Day 21 renderer.
428
+ console.log(renderDiff(undone.after ?? "", undone.before ?? ""));
429
+ return true;
430
+ }
431
+ case "/resume": {
432
+ // List recent sessions in this project and switch to the chosen one.
433
+ // Swapping mid-conversation is fine: we rebuild from the constitution +
434
+ // the saved messages, and reset the per-conversation state (read-state,
435
+ // undo history) so the resumed session behaves like a fresh start of it.
436
+ const sessions = listSessions(10);
437
+ if (!sessions.length) {
438
+ console.log(chalk.dim("(no saved sessions in this project yet)"));
439
+ return true;
440
+ }
441
+ console.log(chalk.dim("recent sessions in this project:"));
442
+ sessions.forEach((s, i) => {
443
+ const when = s.savedAt.slice(0, 16).replace("T", " "); // 2026-06-15 10:30
444
+ console.log(chalk.dim(` ${i + 1}. ${when} · ${s.messageCount} msg · ${s.title.slice(0, 60)}`));
445
+ });
446
+ const answer = (await readUserLine(" resume which? [number, or Enter to cancel] ")).trim();
447
+ if (!answer) {
448
+ console.log(chalk.dim("(cancelled)"));
449
+ return true;
450
+ }
451
+ const idx = Number(answer) - 1;
452
+ if (!Number.isInteger(idx) || idx < 0 || idx >= sessions.length) {
453
+ console.log(chalk.dim("(not a valid choice — cancelled)"));
454
+ return true;
455
+ }
456
+ const chosen = loadSession(sessions[idx].id);
457
+ if (!chosen) {
458
+ console.log(chalk.yellow("(could not load that session — it may be corrupt)"));
459
+ return true;
460
+ }
461
+ messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
462
+ sessionId = chosen.id; // keep appending to the resumed session's file
463
+ forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
464
+ clearUndo(); // the previous session's writes are not ours to undo
465
+ console.log(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
466
+ return true;
467
+ }
468
+ case "/diff": {
469
+ // A read-only review of the net effect of this session on the
470
+ // filesystem — every file whose content differs from where it started,
471
+ // shown as a diff from baseline → now. Reuses the Day 21 renderer.
472
+ const changes = sessionChanges();
473
+ if (!changes.length) {
474
+ console.log(chalk.dim("(no file changes this session)"));
475
+ return true;
476
+ }
477
+ const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
478
+ console.log(chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`));
479
+ for (const c of changes) {
480
+ const short = path.relative(process.cwd(), c.path) || c.path;
481
+ console.log(`\n${verb[c.status]} ${short}`);
482
+ console.log(renderDiff(c.baseline, c.current));
483
+ }
484
+ return true;
485
+ }
486
+ case "/plan":
487
+ // A toggle. Turning it on injects the rules of the mode as a user turn so
488
+ // the model adopts them; the gate (permissions.ts) enforces them either way.
489
+ if (isPlanMode()) {
490
+ setPlanMode(false);
491
+ console.log(chalk.dim("(plan mode OFF — writing and executing tools enabled again)"));
492
+ }
493
+ else {
494
+ setPlanMode(true);
495
+ messages.push({ role: "user", content: PLAN_MODE_NOTICE });
496
+ console.log(chalk.dim("(plan mode ON — read-only research; the agent will present a plan for you to approve)"));
497
+ }
235
498
  return true;
236
499
  case "/compact": {
237
500
  if (messages.length <= 1) {
@@ -256,15 +519,50 @@ async function main() {
256
519
  };
257
520
  // The session loop: ask → run → render → ask again. This is what makes it
258
521
  // a conversation instead of a one-shot command.
522
+ // Listen for Tab key on raw stdin — toggles expand/collapse of hidden tool output.
523
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
524
+ process.stdin.on("data", (buf) => {
525
+ if (buf[0] === 0x09) { // Tab
526
+ if (!running)
527
+ toggleLastCollapsed();
528
+ }
529
+ });
530
+ }
259
531
  while (true) {
260
- const line = (await readUserLine(chalk.green("\n> "))).trim(); // next input, or "exit" on EOF
532
+ // Before each prompt, a status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
533
+ // then a separator rule, then the "❯". Only in a TTY; piped input is clean.
534
+ if (process.stdin.isTTY) {
535
+ const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
536
+ console.log("\n" + statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt));
537
+ console.log(inputRule());
538
+ }
539
+ const line = (await readUserLine(framedPrompt(isPlanMode()))).trim(); // next input, or "exit" on EOF
261
540
  if (!line)
262
541
  continue; // empty line — just re-prompt
263
542
  if (line === "exit" || line === "quit")
264
543
  break; // explicit goodbye
265
544
  if (await handleCommand(line))
266
545
  continue; // slash commands never reach the model
267
- messages.push({ role: "user", content: line }); // the new turn joins the shared history
546
+ // UserPromptSubmit hook: a chance to validate/inject before the model sees
547
+ // the prompt. exit 2 DROPS the prompt entirely (e.g. block a forbidden ask);
548
+ // stdout becomes extra context for this turn (e.g. inject the current ticket).
549
+ const submit = await runHooks("UserPromptSubmit", { prompt: line });
550
+ if (submit.block) {
551
+ console.log(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${submit.feedback.slice(0, 150)})`));
552
+ continue; // the prompt is erased — never reaches the model
553
+ }
554
+ const injected = submit.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${submit.stdout}` : "";
555
+ // @file mentions: if the line references files (@src/loop.ts), attach their
556
+ // content to the message so the model sees them directly. Secret files are
557
+ // refused here, exactly as read_file would refuse them.
558
+ const { augmented, mentions } = expandMentions(line);
559
+ const attached = mentions.filter((m) => m.status === "ok").map((m) => m.raw);
560
+ const refused = mentions.filter((m) => m.status === "denied").map((m) => m.raw);
561
+ if (attached.length)
562
+ console.log(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
563
+ if (refused.length)
564
+ console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
565
+ messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
268
566
  running = true; // Ctrl+C now means "interrupt the task"
269
567
  interrupted = false; // fresh interrupt state for this task
270
568
  controller = new AbortController(); // fresh abort signal for this task
@@ -274,9 +572,25 @@ async function main() {
274
572
  signal: controller.signal, // for aborting requests
275
573
  isInterrupted: () => interrupted, // for stopping between steps
276
574
  confirm, // for permission prompts
575
+ askUser, // the multi-question form the model can pop
576
+ subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
577
+ judge, // optional LLM classifier for the "ask" middle ground
277
578
  });
278
579
  running = false; // back at the prompt — Ctrl+C means "exit" again
279
580
  saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
581
+ // Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
582
+ // that saves durable facts — especially user corrections — so memory
583
+ // captures itself instead of relying on the model to call `remember`.
584
+ if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
585
+ try {
586
+ const got = await extractMemories(client, CONFIG.subAgentModel || CONFIG.model, messages);
587
+ if (got.length)
588
+ console.log(chalk.dim(`(remembered ${got.length}: ${got.map((g) => g.fact.slice(0, 50)).join("; ")})`));
589
+ }
590
+ catch {
591
+ /* memory extraction must never break the session */
592
+ }
593
+ }
280
594
  // Done already streamed its answer to the screen; everything else gets a
281
595
  // one-line human explanation. The session continues either way — except a
282
596
  // fatal API error, which no amount of chatting will fix.
@@ -288,8 +602,10 @@ async function main() {
288
602
  break; // a bad key won't fix itself mid-session
289
603
  }
290
604
  }
605
+ await runHooks("SessionEnd", {}); // cleanup hooks (tiny 1.5s budget — the user may be leaving via Ctrl+C)
291
606
  emit("agent_session_end"); // close the books
292
607
  rl.close(); // release stdin
608
+ tuiCleanup(); // restore terminal
293
609
  console.log(chalk.dim("bye.")); // a clean goodbye
294
610
  }
295
611
  main(); // kick everything off
package/dist/config.js CHANGED
@@ -52,6 +52,10 @@ export const CONFIG = {
52
52
  process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
53
53
  "", // empty = missing; requireApiKey() turns that into a helpful error
54
54
  model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-chat", // must support function calling
55
+ // Sub-agents (the task tool) can run on a DIFFERENT model than the orchestrator:
56
+ // a cheap/fast one for grunt work (reading many files, broad search), or a
57
+ // strong one as an "advisor" to double-check. undefined → use the main model.
58
+ subAgentModel: process.env.MINI_AGENT_SUBAGENT_MODEL || projectSettings.subAgentModel || globalSettings.subAgentModel || undefined,
55
59
  contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
56
60
  projectSettings.contextWindow || // then files
57
61
  globalSettings.contextWindow ||
@@ -62,14 +66,44 @@ export const CONFIG = {
62
66
  allow: [...(globalSettings.permissions?.allow ?? []), ...(projectSettings.permissions?.allow ?? [])],
63
67
  deny: [...(globalSettings.permissions?.deny ?? []), ...(projectSettings.permissions?.deny ?? [])],
64
68
  },
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 ?? [])],
69
+ // Hooks for every event, merged across layers (global first, then project) by
70
+ // a generic union so adding a new event needs no edit here.
71
+ hooks: (() => {
72
+ const merged = {};
73
+ for (const src of [globalSettings.hooks, projectSettings.hooks]) {
74
+ for (const [event, defs] of Object.entries(src ?? {}))
75
+ merged[event] = [...(merged[event] ?? []), ...(defs ?? [])];
76
+ }
77
+ return merged;
78
+ })(),
79
+ // MCP servers: project entries override global ones with the same name.
80
+ mcpServers: { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}) },
81
+ // LLM permission judge: off unless a settings file turns it on. Project wins.
82
+ judge: {
83
+ enabled: projectSettings.judge?.enabled ?? globalSettings.judge?.enabled ?? false,
84
+ model: projectSettings.judge?.model || globalSettings.judge?.model || undefined, // undefined → use the main model
85
+ },
86
+ // Token prices for the /cost estimate (defaults applied in cost.ts). Project
87
+ // overrides global; either may set just the fields it cares about.
88
+ pricing: { ...(globalSettings.pricing ?? {}), ...(projectSettings.pricing ?? {}) },
89
+ // Long-term memory: auto-extract durable facts after each turn. Off by default
90
+ // — it costs an extra (cheap) model call per turn. Project overrides global.
91
+ memory: {
92
+ autoExtract: projectSettings.memory?.autoExtract ?? globalSettings.memory?.autoExtract ?? false,
71
93
  },
72
94
  };
95
+ // Persist a single key to the global settings file, preserving all other keys.
96
+ // Used by /model to remember the user's last choice across sessions.
97
+ export function saveGlobalSetting(key, value) {
98
+ const dir = path.dirname(GLOBAL_SETTINGS_PATH);
99
+ if (!fs.existsSync(dir))
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ const current = fs.existsSync(GLOBAL_SETTINGS_PATH)
102
+ ? JSON.parse(fs.readFileSync(GLOBAL_SETTINGS_PATH, "utf8"))
103
+ : {};
104
+ current[key] = value;
105
+ fs.writeFileSync(GLOBAL_SETTINGS_PATH, JSON.stringify(current, null, 2) + "\n");
106
+ }
73
107
  // No key, no point: fail with instructions instead of a stack trace later.
74
108
  export function requireApiKey() {
75
109
  if (CONFIG.apiKey)