src/ 目录还原缺口声明。 本文分析的源码树还原自公开安装包中携带的 sourcemap(对应 Claude Code Research Preview 0.2.8),并非官方发布的完整源码。经逐文件核对,以下被引用的文件在还原树中缺失:
src/Tool.ts(工具接口定义,全仓 43 个文件以/Tool.js结尾的路径引用它)、src/tools/MemoryReadTool/prompt.ts与src/tools/MemoryWriteTool/prompt.ts(两个记忆工具的 prompt 与描述文案,各自被同目录的.tsx以import { DESCRIPTION, PROMPT } from './prompt.js'引用)、src/utils/conversationRecovery.ts(会话恢复模块,被入口cli.tsx和ResumeConversation.tsx引用,导出loadMessagesFromLog与deserializeMessages)。因此,本文中关于Tool接口形状的描述,是依据 15 处satisfies Tool实现与全部调用点反推的重建,而非对原始定义的直读;关于两个记忆工具向模型呈现的具体文案,本文不作转述。这也意味着该还原树不能原样通过 TypeScript 编译,本文所有结论均为静态走读所得,未在本机运行 0.2.8。凡受缺口影响、属于推断而非直读的结论,正文均已就地注明。
(本段原文出自本套装出版前事实校验文件 verification.md 第五节,照录未改。)
想自己写一个终端 Agent 的新手。不需要你训过模型,也不需要你读过某个 Agent 框架的源码,但需要两样前置:
node 跑;最好手边再有一份 0.2.8 的还原源码树——GitHub 上 clone TeFuirnever/claude-code-sourcemap 即可。本卷每章「读什么」表格里的行号全部对回这棵树(仓库信息见书名页),读到哪一行就打开哪个文件,这是这本书的基本读法。配套代码(mini-agent 参考实现)随白皮书套装分发,就在套装目录的 mini-agent/ 下。

本卷正文七章,对应「新手造 Agent 专项」学习路径的七关——章即关,「第 N 章」就是「第 N 关」,正文保留课程里「关」的说法。每关固定四件套:
src/;七关的顺序是一条因果链,不是专题并列:
| 关 | 主题 | 锚定源码 | 概念 | 练习产出 |
|---|---|---|---|---|
| 1 | 进程怎么活起来的 | entrypoints/cli.tsx(1043 行) |
入口即安全边界:stdin 分家、Trust Dialog、先授读权再取 context | 30 行 CLI:管道/交互双路输入 |
| 2 | 第一包发给模型什么 | constants/prompts.ts、context.ts |
系统提示三段式、静态快照 + memoize、cache 断点 | 生成自己项目的 0.2.8 风格 context 包 |
| 3 | Agent 就是一个循环 | query.ts、messages.ts |
tool_use → tool_result 递归生成器;循环不规划、不管安全 | 核心:~80 行 tool_use 循环(mini-agent 骨架) |
| 4 | 工具契约与编辑引擎 | tools/(16 个内置工具,35 个文件) |
Zod 校验、Edit 先读后改唯一匹配、Grep 只回文件名 | 给骨架加 Read/Edit/Bash 三工具 + schema 校验 |
| 5 | 权限与安全 | permissions.ts、canUseTool |
白名单只八条、dangerously 三道门、错误收成 assistant 消息 | 给骨架加命令白名单 + 确认弹窗 |
| 6 | TUI 是产品的一半 | components/(7961 行 Ink) |
视口约束反向塑造提示词(4 行规则)、190k 警告、5 美元弹窗 | 只读关:画一个权限弹窗的状态机 |
| 7 | 从 0.2.8 到今天 | 官方文档(仓库外,单独标注) | 什么该进内核、什么该是外层(Skills/Hooks/沙箱/自动压缩是后加的) | 结业:mini-agent 跑通「读文件→改 bug→跑测试」 |
主线一句话:跟着 0.2.8 的还原源码,从「敲下 claude」走到「模型改完文件」,最终动手复刻一个 ~200 行可跑的 mini-agent。第 7 章结业时,它要跑通「读文件 → 改 bug → 跑测试」全流程。
规模预算是硬约束:总行数 ~200 行,最多放宽到 220,不许超出——0.2.8 的内核证明这个规模够用。每关一个里程碑 tag:m1-cli → m2-context → m3-loop → m4-tools → m5-permissions → m7-final;第 6 关是只读关,无代码里程碑。附录 B 收参考实现的文件清单、逐里程碑的本机实测记录,以及它与 0.2.8 原作的对读对应表。
图 1 七关全景(手绘版):同一套 tool_use ↔ tool_result 骨架从第 3 关活到结业,逐关只加一层肉;柱子是各里程碑参考实现的真实行数。
verification.md 已对 28 条行号论断逐一抽检(24 条精确命中、4 条 ±1 偏移、0 条失败),四条偏移进了勘误表:state.ts 实为 25 行(非 26);shims import 在 cli.tsx:9;「stop_reason 不可靠」注释在 query.ts:170;wizard 注释块为 query.ts:111–123。本卷正文与附录 A 均按勘误后的行号书写。src/Tool.ts 等四个被引用文件在还原树中缺失,凡涉及 Tool 接口形状的论述全部带「反推」限定,正文就地标注,阅读时请带着这个限定语。素材来源:各章「读什么 / 关键概念」改写自同套装卷一的深度分析底稿与模块笔记(出处见套装 README「材料出处」一节);缺口与反推结论以出版前事实校验的「还原缺口声明」为准,卷首已照录。
你在项目目录里敲下 claude,期待一个能改代码的 Agent 立刻出现。但在模型看见任何文件之前,进程必须先回答四个问题:这是谁、人有没有同意、这个目录能不能碰、配置能不能读。这一关读的就是这段「门厅」代码——src/entrypoints/cli.tsx,1043 行。它不推理、不调工具,只负责把人放进一个可信会话。读懂它,你的 mini-agent 就有了第一块砖:一个分得清「管道」和「人」的入口。
| 文件 | 行号 | 看什么 |
|---|---|---|
src/entrypoints/cli.tsx |
1–10 | shebang、尽早的 Sentry、防 Bun 树摇的 shims import(第 9 行) |
src/entrypoints/cli.tsx |
271–306 | main():enableConfigs() 开闸(273–281)、stdin 分家(291–306) |
src/entrypoints/cli.tsx |
1015–1023 | stdin() 函数本体:怎么把管道吸成一段字符串 |
src/entrypoints/cli.tsx |
93–167 | showSetupScreens():Onboarding 触发条件(102–105)、Trust Dialog(147–160) |
src/entrypoints/cli.tsx |
177–269 | setup():再授读权(184–185)、跳过权限的三道门(188–213)、预取不 await(219–222) |
src/entrypoints/cli.tsx |
369–428 | 根 action:先设置屏(369)后 setup(379),再按 --print(391–410)或 REPL(414–428)分叉 |
src/entrypoints/cli.tsx |
539–553 | mcp serve 子命令,第三种活法 |
src/utils/config.ts |
159–177 | checkHasTrustDialogAccepted():信任沿父目录往上爬 |
src/components/TrustDialog.tsx |
27–60 | 家目录例外(32–42)、No/Esc 直接退出(46–60) |
src/utils/env.ts |
16–40 | Docker 探测(/.dockerenv)与外网探测(1.1.1.1) |
src/utils/state.ts |
1–25 | 全部全局状态,和第 4 行的 Boris 注释 |
src/entrypoints/mcp.ts |
50–177 | MCP server:在 stdio 上露出同一套工具 |
shebang 与双运行时。 入口第一行是 #!/usr/bin/env -S node --no-warnings=ExperimentalWarning --enable-source-maps(cli.tsx:1),按 Node 启动。但第 6–8 行有一段 XXX: 注释,解释第 9 行那个看似无用的 import * as dontcare from '@anthropic-ai/sdk/shims/node' 和第 10 行的 Object.keys(dontcare):不加这两行,Bun 在 Win32 上会把这个纯副作用的 import 当成死代码删掉——打包器这种「删掉没人用的代码」的优化叫树摇(tree-shaking)。0.2.8 已经按 Node 和 Bun 两套运行时打包在跑,入口必须迁就两边的癖好,不能假设「Node 的模块语义就是运行时语义」。第 4 行 initSentry() 尽可能早,否则启动失败本身不可观测。
配置开闸。 main() 的第一件事不是解析 argv,而是 enableConfigs()(cli.tsx:274)。在此之前任何模块在 import 阶段去读 ~/.claude.json,都会直接抛错——用崩溃强迫调用顺序,比 lint 更狠。JSON 解析失败时也不是 stderr 一行字,而是全屏的 InvalidConfigDialog(276–279 行捕获 ConfigParseError 后弹出):人可以退出手修,或用默认值覆盖,两个选择都结束当前进程。配置坏了就不许进门厅。
stdin 分家。 这是本关最重要的一个概念。管道进来的内容是 prompt,不是键盘。cli.tsx:291–296 的判断是:非 TTY、没有 CI 环境变量、argv 里不含 mcp,才把 stdin 吸干(297 行调 stdin(),这个函数在 1015–1023 行,就是一个 for await 把流读成字符串)。然后在 POSIX 上重开 /dev/tty 塞进 Ink 的渲染上下文(300–301 行)——键盘输入从此走真终端,管道内容当第一句话。mcp 子命令必须排除,294–295 行的注释写得很直白:Input hijacking breaks MCP. MCP 是 Model Context Protocol,一种把程序的工具暴露给别的 Agent 调用的协议,它的 ListTools / CallTool 就走这一对标准输入输出;把 stdin 吸成 prompt 等于把协议掐死。三种启动方式的分歧从「谁拥有标准输入」开始,而不是从「调哪个函数」开始。
Onboarding 与 Trust Dialog 是两条生命周期。 交互路径才会走设置屏(NODE_ENV=test 时在 97–99 行直接短路)。Onboarding 看的是全局标记——没选过主题,或 hasCompletedOnboarding 为 false(102–105 行),内容是「人」的事:配色、登录、安全须知,五个步骤的类型定义在 Onboarding.tsx:24。Trust Dialog 看的是目录——checkHasTrustDialogAccepted()(config.ts:159–177)从当前目录出发,每层楼翻一次 ~/.claude.json 的 projects 字典,没有标记就 resolve(currentPath, '..') 爬父目录(168 行),爬到根为止。子项目开在已信任的 monorepo 里不用再点一次。家目录是例外:TrustDialog.tsx:32–42,点了「Yes, proceed」也不落盘,下次还问——家目录永远不能变成「已信任工作区」。拒绝或 Esc 是硬退出(46–60 行),没有「先看看再说」。--print 和 --dangerously-skip-permissions 跳过这个对话框(cli.tsx:148),因为这两种模式「没有坐在屏幕前的人」。
两次授读权,顺序不能倒。 Trust 被接受后,cli.tsx:153 在对话框的 onDone 里调 grantReadPermissionForOriginalDir();随后根 action 里 setup() 在第 185 行又无条件授一次(--print 路径没弹过对话框,靠这一次让 CI 能读启动目录)。授的都是读权,写权此时不存在。授完,setup 才去预取 getContext()(221 行,故意不 await,首屏不卡在 git status 上)。顺序倒了的话,目录树、git 状态、README 可能已经拼进要发给模型的系统提示,而人还没点过头——0.2.8 没有沙箱,社会层的同意必须发生在任何「为模型看世界」的动作之前。
--print 与三道门。 --print 分支在 391–410 行:prompt 和 stdin 至少要有一个(392–397),跑完 ask() 打印纯文本 exit 0,是一次性问答。--dangerously-skip-permissions 的名字本身就是文档,option 描述里写着 "Will crash otherwise"(354 行)。setup() 里的三道门(188–213 行):Unix 上 getuid() === 0 直接拒绝——sudo 加跳过权限等于把整盘交给模型;必须能看见 /.dockerenv 且系统是 Linux(env.ts:16–23);必须对 1.1.1.1 发一次 1 秒超时的 HEAD 探测不到外网(env.ts:25–40)。任一失败 exit 1。这是给内部无人值守评测留的旁路,设计思路是把旁路写得很难误用,而不是假装没有旁路。
内部用户的闸门挂在入口,不渗进循环。 设置屏里还夹着两个只对 Anthropic 员工(USER_TYPE === 'ant')开放的分支:环境变量里出现新 API Key 时弹 ApproveApiKey 批准框(cli.tsx:123–145,Key 被截成末 20 位做指纹记进批准名单);Trust 之后再过一轮 .mcprc 服务器审批(162–165 行)。外部构建里这些分支被树摇干净。原则值得记住:内外差异全部在入口分叉,Agent 循环永远不知道自己跑在哪种构建里。
默认动作就是产品。 注意根 action 里的调用顺序:369 行先 showSetupScreens,379 行才 setup,383–388 行并行拉工具表和 MCP 客户端,最后按 print 分叉。claude 和 claude "修登录 bug" 都落在这一个 action 上,config / mcp / doctor 才是真正的子命令。产品入口保持三个音节,代价是运维、内部狗食、自动更新全挤在这 1043 行里——脏留在门厅,没有脏到循环里。
25 行的全局状态。 src/utils/state.ts 一共 25 行,第 4 行注释写着 DO NOT ADD MORE STATE HERE OR BORIS WILL CURSE YOU。内存里只冻了一件事:进程从哪个目录启动(originalCwd)。之后真正使用的工作目录住在持久 shell 里,靠 getCwd() 去问它。能不下沉的状态就不下沉,这条纪律后面每一关都会再遇到。
MCP server 是第三种活法。 claude mcp serve(cli.tsx:539–553)走 setup 但不走任何设置屏,然后在 stdio 上露出八件内置工具(Agent、Bash、读写改、Glob、Grep、LS,清单在 mcp.ts:39–48)和一条 /review 命令,传输层在 mcp.ts:172–177 接上 StdioServerTransport。它把 Claude Code 自己变成别人的工具后端,所以入口必须保证这条路不碰 TTY、不弹 Ink。注意一个还原缺口:mcp.ts:24 import { Tool } from '../Tool.js',而 src/Tool.ts 在这棵还原树里缺失——这份 Tool[] 清单的类型形状是从全部调用点反推的,不是对原始定义的直读。凡涉及工具接口的论述,本课程都会带这个限定。
门厅结束时,工具表、斜杠命令表、MCP 客户端已经备好,render(<REPL />)(414 行)才发生。模型到这一步仍然没有收到任何仓库内容——那是第 2 关的事。
里程碑 m1-cli:30 行的双路输入 CLI。
在 mini-agent/ 下新建 cli.mjs(Node 18+,零依赖),实现 Claude Code 入口的 stdin 分家逻辑的最小版:
node cli.mjs "修登录 bug":从 argv 拿 prompt;echo "修登录 bug" | node cli.mjs:检测到 !process.stdin.isTTY,用 for await 把 stdin 吸成字符串当 prompt;[prompt] ...,输入 exit 退出;[got] <prompt> 即可——循环是第 3 关的事。验收标准:
echo "hi" | node cli.mjs 输出 [got] hi 且进程自行退出、退出码为 0;node cli.mjs "hi" 输出 [got] hi 并退出;echo "a" | node cli.mjs "b" 输出含 b 和 a 两行拼接结果;node cli.mjs 进入交互,键盘输入有回显,exit 正常退出;node --check cli.mjs 通过。选做(不计入行数预算):管道模式下尝试 openSync('/dev/tty', 'r'),成功则交互循环改从该 fd 读——你就复刻了 cli.tsx:298–305。
main() 为什么把 enableConfigs() 放在解析 argv 之前?在 import 顶层读配置会有什么后果?Input hijacking breaks MCP.——mcp serve 被 stdin 劫持具体会坏在哪?--print 路径离得开第一次却离不开第二次?为什么两次都必须早于 getContext() 预取?--dangerously-skip-permissions 的三道门各自防的是什么误用场景?为什么 uid 0 那条排在最前、连 Docker 检查都不用等?state.ts 里只有 originalCwd 一个值。真正的工作目录存在哪?这种「薄状态」设计和 cli.tsx 的 1043 行单文件矛盾吗?参考答案见附录 A。
门厅走完了。第 1 章结束时,进程分清了管道和键盘,人点过了 Trust Dialog,读权也授了出去——但模型还没见过这个仓库的一个字。门厅只负责「能不能进」,不负责「进去之后看见什么」。第 2 章接着走那半步:你敲下回车的一瞬间,CLI 往 API 塞的首包里到底装了什么。练习也跟着升级:写出 m2-context,给你自己的项目生成一份 0.2.8 风格的首包,亲眼看看模型将看到的世界。
上一关结束时,进程活了,Trust Dialog 也过了,人把这个目录交给了 Agent。这一关回答紧随其后的那个问题:你敲下回车的一瞬间,CLI 到底往 Anthropic 的 API 塞了一个什么样的包?
拆开看,这个首包只有三样东西:一份系统提示(system)、一张仓库快照(context)、一组工具 schema。工具 schema 是第 4 关的事,这一关只看前两样——它们分别来自 src/constants/prompts.ts 和 src/context.ts,最后在 src/services/claude.ts 里合体、贴标、发出去。
| 文件:行号 | 内容 |
|---|---|
src/constants/prompts.ts:16–127 |
getSystemPrompt() 全文,三段系统提示 |
src/constants/prompts.ts:44、:121 |
「少于 4 行」规则,首尾各一遍 |
src/constants/prompts.ts:45–86 |
极端 few-shot 样例组 |
src/constants/prompts.ts:20–21、:124–125 |
恶意软件禁令,首尾各一遍 |
src/constants/prompts.ts:129–142 |
getEnvInfo(),<env> 块 |
src/context.ts:157–180 |
getContext(),五键快照总装 |
src/context.ts:186–224 |
getDirectoryStructure(),1 秒超时 |
src/context.ts:85–152 |
getGitStatus(),五条 git 命令并行 |
src/context.ts:24–48、:71–83 |
getClaudeFiles()(3 秒超时)与 getReadme() |
src/context.ts:50–58 |
setContext(),计算键不准落盘 |
src/utils/style.ts:9–28 |
getCodeStyle(),CLAUDE.md 祖先链 |
src/services/claude.ts:394–400 |
splitSysPromptPrefix(),数组压成两块 |
src/services/claude.ts:426–441 |
formatSystemPromptWithContext(),拼 <context> |
src/services/claude.ts:472–480、:642–650 |
system block 与消息的 cache 断点 |
src/services/claude.ts:58、:516 |
温度写死 1;beta.messages.stream 发出 |
src/utils/tokens.ts:4–27 |
countTokens(),不跑本地 tokenizer |
src/components/TokenWarning.tsx:9–11 |
190_000 上限与 60%/80% 阈值 |
src/screens/REPL.tsx:327–334 |
每次提交并行取四件套 |
读法建议:prompts.ts 一共 154 行,一半是英文提示词原文,通读;context.ts 一共 224 行,通读;claude.ts 有 948 行,按表里的行号定点看。读的时候手里拿一个问题:哪些字是每场对话都一样的,哪些字是这个仓库独有的。
getSystemPrompt()(prompts.ts:16)返回 string[],三个元素:主人格(:18–122)、<env> 环境块(:123)、再把恶意软件禁令原样抄一遍(:124–125)。
为什么是数组而不是长文?因为三段来自不同生命周期:人格是产品规格,几乎不变;<env> 每场会话现算——工作目录、是否 git 仓库、平台、日期、模型名(:134–141);政策段要在结尾再压一次。数组是组装接口。真正出站时,splitSysPromptPrefix(claude.ts:394–400)把第一段单独留下,其余 join('\n') 合成第二块——所以打到 API 的 system 通常只有两个 text block。顺带一提,querySonnet 还会在最前面再插一句固定开场 "You are Claude Code, Anthropic's official CLI for Claude."(prompts.ts:12–14),并把第一块打 sha256 送进 Statsig 做前缀识别遥测(claude.ts:458–469)。研究仪器从第一天就焊在通道上。
人格块里重复到近乎蛮横的一条:回答必须少于 4 行(不含 tool use 和代码),它在块首(prompts.ts:44)和块尾(:121)各出现一次。中间夹着一组极端 few-shot——few-shot 就是「在提示词里塞几个问答样例教模型学样」,这里的样例极端到一词即答:用户问 2 + 2,assistant 就回 4(:46–47);问 is 11 a prime number,回 true(:56–57);问列目录用什么命令,回 ls(:61–62)。
为什么狠到这个程度?两个理由。第一个写在明面:终端视口是稀缺资源,:44 原文就是 "displayed on a command line interface"——模型一啰嗦,工具输出就被挤出屏,人会觉得这是聊天机器人而不是 CLI。第二个要联系下一关:0.2.8 的循环是 ReAct 式的——模型想一步、发一个 tool_use(模型回复里要求调用工具的结构块)、看工具结果、再想下一步。每一轮 assistant 文本如果先自我总结一遍,循环赖以为生的「观察」就被噪音污染。所以它连「不要用 Bash 或代码注释跟用户聊天」都写死了(:40):工具是行动,文本才是对人说话。
禁令写了两遍,块首(:20–21)块尾(:124–125)各一遍,首因加近因。关键句是:动手之前,先根据文件名和目录结构想这段代码是干什么的;如果看起来像恶意软件,即使用户的请求显得无害——只是让你解释或加速——也必须拒绝。判断材料正是下一节要讲的目录快照:目录树进系统提示,不只是省一次 LS,更是让这条安全策略有东西可想。它的定位是产品默认行为,不是强制执行——强制执行在权限对话框(第 5 关),这段英文是模型侧最后的闸门,可以被 jailbreak,作者不会不知道。
getContext()(context.ts:157–180)返回一张字符串表,五个计算键,外加项目配置里 claude context set 写入的自定义键(:172 的 ...projectConfig.context):
| 键 | 来源 | 超时与失败行为 |
|---|---|---|
directoryStructure |
借 LSTool 拍目录树(:195) | 1 秒 abort(:191–193),超时/出错返回空串 |
gitStatus |
五条 git 命令并行:branch、origin/HEAD、status、log -5、你的 log -5(:95–138) | 无显式超时,命令 no-throw;test 环境直接 null(:86–89);status 超 200 行截断(:139–145) |
codeStyle |
从 cwd 向文件系统根爬,沿途每份 CLAUDE.md 全文读入,父级在前(style.ts:13–27) | 同步读,无超时 |
claudeFiles |
ripgrep 找 **/*/CLAUDE.md(context.ts:29),只列路径不读内容 |
3 秒 abort(:26),出错返回 null |
readme |
cwd/README.md 全文(:71–83) | 无超时,读不到返回 null |
更新语义一句话:整场会话只算一次。getContext 整体被 lodash 的 memoize 包住(:157)——memoize 就是「同样的调用只算头一次,之后直接还缓存」。快照过期是刻意的,而且写在给模型看的文本里:directoryStructure 的包装句明说 "This snapshot will NOT update during the conversation"(:220),gitStatus 同样写了 "will not update during the conversation"(:147)。世界会变,模型的第一印象不变。嫌树太大,项目配置里开 dontCrawlDirectory,两个最重的键直接变空串(:163、:167–168)。手动 /compact 和 /clear 会清掉这些缓存(compact.ts:84–85、clear.ts:17–18),下一轮重新爬。
两个小机关值得记住。其一,getClaudeFiles 的 glob 匹配不到根目录那份 CLAUDE.md——根文件走的是 codeStyle 那条祖先链,两边不重复。其二,setContext 落盘前会把 codeStyle 和 directoryStructure omit 掉(:52–56):算出来的键,不准持久化。
顺带看一眼 :195–213:LSTool.call 返回的是一个异步生成器(async function* 的产物,可以中途 yield 多次结果、调用方逐条拉取的对象),这里用 lastX 只取它最后一条结果。生成器是第 3 关的主角,这里先混个脸熟。
REPL 每次提交并行取四件套(REPL.tsx:327–334):系统提示、context、模型名、thinking 预算。然后 formatSystemPromptWithContext(claude.ts:426–441)把 context 表挂到系统提示后面:一句「你可以用这些上下文」,每个键包成 <context name="键名">值</context>(:438)。
接着是 cache。prompt cache 的意思是:Anthropic API 能记住你发过的前缀,下次相同的前缀少算钱、回得更快。cache_control: { type: 'ephemeral' }——ephemeral 字面义「短暂的」——就是打在文本块上的标记:这块可以进缓存。0.2.8 的标法很素:两个 system block 各打一遍(claude.ts:472–480);消息侧 addCacheBreakpoints(:642–650)只给最后两条打,判断式是 index > messages.length - 3(:647–648)。总闸是环境变量 DISABLE_PROMPT_CACHING(:45)。现在回看「快照过期是刻意的」:系统提示整场不变,前缀才稳,缓存才中——这两件事是同一个设计。后来的 2.x 把 system 按变更频率切成更多段、还加了自动压缩,那些材料来自官方文档,不在本仓库,第 7 关再谈。
发送在 claude.ts:516(anthropic.beta.messages.stream)。两个细节值得新手记住。其一,温度写死 MAIN_QUERY_TEMPERATURE = 1(:58),注释 "to get more variation for binary feedback":采样更散,内部员工做 binary feedback(两个候选回答左右对照挑一个)时,两次采样才容易长得不一样。其二,工具 schema 在这里才展开成 {name, description, input_schema}(:482–493),description 来自 tool.prompt(...)——注意 Tool 接口的定义文件在还原树里缺失,这句话是对调用点的直读,接口形状本身属于反推。
上下文用了多少 token,countTokens(tokens.ts:4–27)不在本地跑 tokenizer。它从消息列表尾部往前找最近一条带 usage 的非合成 assistant 消息,把 input、cache_creation、cache_read、output 四个数加起来(:17–22)。usage 是 API 每次响应白送的,何必自己估。合成的打断消息会被跳过(:11–14)。输入框下方的警告读的就是这个数(PromptInput.tsx:448):TokenWarning.tsx:9 把上限写成 190_000,注释说给 /compact 留余量;用到 60% 开始显示(:10),80% 变红(:11、:24),文案直接让你跑 /compact。
里程碑 m2-context:给 mini-agent 加「首包生成器」,为你自己的项目生成一份 0.2.8 风格的 context 包。
在 mini-agent/(与上一关 m1-cli 同目录)新建 context.mjs,预算 ≤60 行,实现四个函数:
getSystemPrompt():照抄三段式结构——人格段(自定,但必须含一条你自己的「少于 N 行」规则和至少两组一词即答的 few-shot)、<env> 块(cwd、是否 git、日期)、结尾再压一条你自己的政策句。返回 string[]。getContext():抓五键。directoryStructure 用 find . -maxdepth 2 或等价物,1 秒超时则空串;gitStatus 至少 branch + status --short + log -5,status 超 200 行截断;codeStyle 从 cwd 向根爬找 CLAUDE.md(或你的等价物);claudeFiles 用 glob 找子目录同名文件,只列路径;readme 读 README.md 前 100 行。整个函数 memoize(手写一个缓存变量即可,不准引 lodash)。formatSystemPromptWithContext(systemPrompt, context):把每个键拼成 <context name="..."> 追加到数组尾部。addCacheBreakpoints(messages):不用真调 API,给最后两条消息的对象打上 cache: true 标记后返回新数组。最后让 node context.mjs 把完整首包打印到终端,肉眼检查一遍模型将会看到什么。
验收标准:在你自己一个 git 项目根目录跑 node context.mjs,输出里能数出三段系统提示和五个 <context name> 块(gitStatus 必须非空);在同一进程内连续调用两次 getContext,第二次零文件系统读取(注意:memoize 是内存缓存,跑两次进程不算数——用计数器或时间戳证明);文件不超过 60 行。
getSystemPrompt() 返回的三段各是什么?恶意软件禁令出现两次,位置分别在哪,为什么这样安排?addCacheBreakpoints 只标最后两条,共同服务的目标是什么?countTokens 的数从哪来?如果会话里只有开头一条真实 assistant 响应、之后全是合成打断消息,TokenWarning 读到的数会停在哪?getClaudeFiles 的 glob 找不到哪份 CLAUDE.md?那份文件实际经哪条路径进系统提示?MAIN_QUERY_TEMPERATURE 为什么是 1?注释里的 binary feedback 指什么场景?参考答案见附录 A。
首包发出去了,模型也回话了。回话里如果带着 tool_use,事情才刚刚开始:那是模型在请求程序替它跑工具,得有人接住、执行、把结果喂回去,再问它下一步想干什么。第 3 章拆的就是这个「接住」的机构——一个递归异步生成器,全部状态就是一个消息数组。这一关你要交出 mini-agent 的心脏:约 80 行、能自己跑两轮工具调用、然后自己停下来的循环骨架。形状这一关定死,后面几关只加肉、不改形。
主线里程碑 m3-loop:本关交出 mini-agent 的心脏——一个能自己跑两轮工具调用、然后自己停下来的循环骨架,约 80 行。
第 2 关把第一包发给了模型。模型回了话,然后呢?本关回答这个问题。把「模型说话」和「程序干活」接起来的,是 src/query.ts——一个 516 行的文件,核心只有一个递归异步生成器。所谓 Agent,扒到底就是一个循环。
| 文件 | 行号 | 看点 |
|---|---|---|
src/query.ts |
124–242 | query() 主体:一次模型往返 + 一轮工具 + 一次递归 |
src/query.ts |
111–123 | query() 正上方的 thinking「wizard」注释 |
src/query.ts |
68、184–216 | MAX_TOOL_USE_CONCURRENCY = 10;全部只读才并发 |
src/query.ts |
285–345、365–495 | runToolUse / checkPermissionsAndCallTool:校验、权限、执行三段 |
src/utils/messages.tsx |
156–233、236 起 | processUserInput 的 bash 分支与斜杠分支 |
src/utils/messages.tsx |
36–43、605 | 四句写死的合成语;stop_reason 不可靠的第二处注释 |
src/services/claude.ts |
66–162 | MAX_RETRIES、shouldRetry、withRetry |
src/services/claude.ts |
206–224、249、543–558、618–640 | TODO(ben) 与 finalMessage;SDK maxRetries: 0;错误收成 assistant |
src/messages.ts |
全文 24 行 | getter/setter 桥 |
阅读顺序建议:先从头到尾读一遍 query() 本体(:124–242,约 120 行),把「拿回复 → 判 tool_use → 跑工具 → 递归」这条脊柱记住;再回头看 runToolUse 和 checkPermissionsAndCallTool 这两段「关节」;最后跳到 claude.ts 看重试和错误收敛。messages.tsx 只读 processUserInput 附近即可,其余留到第 6 关。
tool_use 是模型回复 content 数组里 type: 'tool_use' 的块,带 name(工具名)和 input(参数)。它不是函数调用,是一段数据——模型在请求程序替它跑这个工具。程序跑完,把结果写成 type: 'tool_result' 的块,带上 tool_use_id 指回那个 tool_use——所以 tool_use 块自己要带一个唯一 id,两者靠这对字段配对。一轮有多个 tool_use 时,每个 tool_result 各包成一条 user 消息、逐个发回,不合并,连同整段对话再发回模型。模型读到结果,决定下一步:再发 tool_use,或者回纯文本收工。循环的全部内容就这一件事:
图 2 一次输入如何进入循环(复刻自分析报告图 1,数据来自 query.ts 与 messages.tsx 的 processUserInput)。
注意收尾的判据不是 API 信封上的 stop_reason:query.ts:170 的注释明说它不可靠,messages.tsx:605 写了同一句。程序看的是 content 里还有没有 tool_use 块——相信块,不相信信封。
人敲下回车,文字先过 processUserInput(messages.tsx:156),此刻还没进 query:
<bash-input> 本地执行。cd 开头直接 setCwd,连 BashTool 都不调;其余先 validateInput,再 lastX(BashTool.call(...))(:216,lastX 只取生成器最后一个值)。返回一对 user+assistant。REPL 看见末条已是 assistant,就不再调 query——模型全程不在场。query。三分叉的含义:不是所有输入都值得惊动模型。
export async function* query( 从 query.ts:124 开始。async function* 是异步生成器:函数可以中途 yield 一条消息交还调用方,调用方(REPL)用 for await 一条条拉。一次 query 调用 = 一次模型往返 + 本轮全部工具 + 一次递归。收尾在 :234–241:
yield* await query(
[...messages, assistantMessage, ...orderedToolResults],
systemPrompt, context, canUseTool, toolUseContext, getBinaryFeedbackResponse,
)
yield* 把「下一轮的产出」接到自己身上。没有 while (true)。每轮的局部量(本轮 assistant、本轮 toolResults)天然是这一层栈帧;Esc 取消时 return 就停。消息数组就是全部状态——0.2.8 没有自动压缩,工具轮数通常是个位数,递归深度够用。文件里也没有递归上限,这更像预览期没补的洞,不是有意设计。
query() 正上方(:111–123)有一段 wizard 口气的注释,讲 thinking 块的三条规则。写在循环旁边而非 claude.ts,是因为违规的惩罚发生在下一轮回放整条消息数组时——这恰好提醒你:递归每轮都在原样回放历史。
读完 516 行会发现这个循环刻意不做两件事。它不规划任务:下一步干什么由模型的 tool_use 决定,不由任何状态机的边决定——引入图,等于把模型已经在做的路由再写一遍。它也不判断安全:每个工具执行前过 canUseTool(:424),但那是注入的函数——REPL 传带弹窗的 useCanUseTool,--print 传不带 UI 的 hasPermissionsToUseTool。循环只认「放行 / 把拒绝写成 is_error 的 tool_result 喂回模型」。安全是第 5 关的事。
循环对工具的全部了解只有三个问题:isReadOnly() 决定并发(本轮全部只读才走并发,:184–216,上限 :68 的 10 路);inputSchema 做 Zod 校验;call 跑活。不认识任何具体工具,41 个工具共用同一扇门。要说明:src/Tool.ts 在这份还原里缺失,这里及以下关于 Tool 接口的描述都是依据 15 处 satisfies Tool 和调用点的反推,不是直读。
为什么混着读写时不按段切开、只读的先并行?文件里留了 TODO 承认「可以更激进」,但没做。从旁边代码能看到三个具体障碍:权限弹窗是单槽的,两个写工具同时问人会互相覆盖;Bash 共享一份持久 shell,并行写是数据竞争;MCP 工具的 isReadOnly() 恒为 false,任何一个 MCP 调用都会把整批打成串行。保守调度让循环保持薄——这是 0.2.8 全篇反复出现的取舍。
SDK 的自动重试被关掉了:claude.ts:249 写死 maxRetries: 0,注释说「换成手写」。手写的 withRetry(:120–162)换来两样东西:终端能打出 Retrying in Ns…,Statsig 能记 tengu_api_retry。重试判据在 shouldRetry(:86–118):408(请求超时)、409(锁超时)、429(限流)、5xx 和连接错误;overloaded_error 只对 SWE_BENCH 重试。上限写在 :66——普通用户 10 次,USER_TYPE=SWE_BENCH 100 次。尊重 retry-after 响应头(:142),否则 500ms 起步指数退避,封顶 32 秒。
重试耗尽之后,getAssistantMessageFromError(:618–640)把失败收成固定的 assistant 短句:prompt 太长、余额不足、Key 无效各有文案,其余套 API Error: 前缀。querySonnet 的 catch(:543–558)return 的是一条普通的 type: 'assistant' 消息。循环不需要处理半打异常类型——失败也走同一条路。
HTTP 层走的是流式:anthropic.beta.messages.stream(:516)。但 handleMessageStream(:206–224)把流消费完、等到 finalMessage 才返回;:212 留着 TODO(ben): Consider showing an incremental progress indicator.。所以 0.2.8 里传输在流,屏幕上文字仍整段突然出现。写 mini-agent 时这是最容易「超越原作」的地方,但本关先别做——增量渲染会把界面和消息规范化搅在一起。
本关锚定的第三个文件最短:src/messages.ts 全文 24 行,只有四个函数,注册和取回消息数组的 getter/setter。它存在的唯一理由是让非 React 代码(比如 /compact)摸得到 REPL 的消息数组。状态仍旧只有一份——那个消息数组。
m3-loop:约 80 行的 tool_use 循环骨架。 这是 mini-agent 的心脏,后续关卡只往里加东西,不改它的形状。
新建 mini-agent/loop.mjs(Node 18+,零依赖,不需要 API key),写四块:
fakeModel(messages)(~15 行):按 messages 里已有几条 assistant 依次回——第一轮回 tool_use read_file,第二轮回 tool_use list_dir,第三轮回纯文本收工。read_file 读指定文件前 200 字符;list_dir 列当前目录。async function* query(messages)(~30 行):yield assistant → content 里没 tool_use 就 return → 逐个跑工具,每个结果各包成一条带 tool_result 的 user 消息、逐个 yield(多个 tool_use 不合并成一条)→ yield* query([...messages, assistant, ...results]) 递归。for await 拉取并打印每条消息。验收标准(逐条可查):
node loop.mjs 退出码 0,能跑通两轮工具调用并终止:日志可见两组 tool_use → tool_result,fakeModel 恰好被调 3 次,最后打印纯文本,进程自己结束。while (true),下一轮调用必须是 yield* query(...)。is_error: true 的 tool_result 再继续(对回 query.ts:297–313)。wc -l 可查。提示:先抄形状再填肉。query.ts 的递归只有 8 行(:234–241),你的版本也不会更长。
这个骨架就是后面所有关卡的底座:第 4 关把 read_file / list_dir 换成真正的 Read/Edit/Bash 三工具并加上 schema 校验(对应 checkPermissionsAndCallTool 的前两段),第 5 关在 query 和工具之间插入 canUseTool 白名单与确认弹窗,第 7 关结业时它要跑通「读文件 → 改 bug → 跑测试」三轮以上的真实循环。形状本关定死,后面只加肉。
stop_reason?它改看什么地方?query.ts 哪几行?!ls -la 和 /clear,这两次输入会进 query 吗?REPL 依据什么决定不再调用 query?maxRetries 被设成了几?既然 SDK 自带重试,为什么还要手写 withRetry?手写版本尊重服务器的哪个响应头?querySonnet 会把异常抛给 query 吗?循环实际「看见」的是什么?参考答案见附录 A。
上一章的循环只认工具名做分发,不认识任何具体工具——read_file 和 list_dir 只是教学替身。真正的问题还悬着:一次 tool_use 怎么变成改文件、跑命令、搜代码?第 4 章把答案落到契约上:循环能保持薄,是因为契约够厚。你会给骨架装上 Read/Edit/Bash 三个真工具,配上校验和「先读后改」的规矩。也是从这一关起,「反推」会反复出现——接口定义文件缺失,记得卷首声明。
上一关我们把 query.ts 读成了一个薄循环:模型吐 tool_use(模型说「我要调用某个工具」的结构化请求),循环按名字分发,把结果包成 tool_result 喂回去,再递归。这一关回答剩下的问题:那一次 tool_use 究竟怎么变成改文件、跑命令、搜代码? 答案不在循环里,在 src/tools/ 的 41 个实现文件里。循环能保持薄,是因为契约够厚。
接口文件缺失,本章关于 Tool 形状的论述全部为反推。
src/Tool.ts在还原树中不存在(同批缺失的还有MemoryReadTool/prompt.ts、MemoryWriteTool/prompt.ts、src/utils/conversationRecovery.ts,还原树因此编译不过)。契约形状是从 15 处satisfies Tool实现、query.ts的调用点交叉反推的,凡涉及接口字段的表述都请带着这个限定语来读。
| 文件 | 行号 | 看什么 |
|---|---|---|
src/query.ts |
365–495 | checkPermissionsAndCallTool:一次 tool_use 落地的完整流水线 |
src/query.ts |
377–394 | Zod safeParse 先拦,失败直接回 is_error 的 tool_result |
src/query.ts |
399–420 | 第二道闸 validateInput,失败文案是给模型的下一步指令 |
src/query.ts |
441–493 | tool.call() 是异步生成器;成功与异常都统一收成 tool_result |
src/tools/FileEditTool/FileEditTool.tsx |
115–217 | validateInput 八连拒:空操作、创建冲突、未读、被改、找不到、不唯一 |
src/tools/FileEditTool/FileEditTool.tsx |
218–257 | call 落盘,写完后回填时间戳(238 行) |
src/tools/FileEditTool/prompt.ts |
19–38 | 写给模型看的说明书:3–5 行上下文、一次只改一处 |
src/tools/FileReadTool/FileReadTool.tsx |
181 | 读成功写入 readFileTimestamps[path] = Date.now()——「先读后改」链的起点 |
src/tools/GrepTool/GrepTool.tsx |
103–143 | call 里拼 rg -li,只回文件名,按 mtime 排序 |
src/tools/GrepTool/prompt.ts |
3–11 | prompt 自称「搜索文件内容」——与实现的分叉点 |
src/tools/ArchitectTool/ArchitectTool.tsx |
51–53 | isEnabled() 写死 return false |
src/tools/BashTool/BashTool.tsx |
82–85 | needsPermissions() 恒为 true |
src/tools.ts |
23、41 | getAllTools() 同步清单;getTools 被 memoize 包住 |
src/Tool.ts |
— | 不存在。接口靠反推,读每个工具文件末尾的 satisfies Tool |
每个工具是一个对象字面量,末尾写着 satisfies Tool<...>——这个写法全仓出现 15 次(每个工具文件恰好一次),是我们反推接口形状的主要证据。satisfies 是 TypeScript 的「检查但不改变类型」标注:对象必须满足 Tool 的形状,否则编译报错。反推出来的契约大致是:name(API 名)、userFacingName(TUI 显示名)、description / prompt(短描述与长说明书,模型真正读到的是后者)、inputSchema(Zod schema)、isEnabled()(门控)、isReadOnly()(调度依据)、needsPermissions()(权限门闩)、validateInput()(业务校验)、call()(异步生成器)、外加几个 render* 渲染函数。
一份对象同时服务五个消费者:模型读 prompt + schema;循环调 call;人看 render*;调度器问 isReadOnly;注册表问 isEnabled。这就是「薄循环」能成立的全部原因——细节全焊在工具上,循环只做分发。
query.ts:365 的 checkPermissionsAndCallTool 是一次 tool_use 落地的流水线,顺序固定:
query.ts:377):tool.inputSchema.safeParse(input)。旁边注释很坦白:「the model is not great at generating valid input」。schema 全部用 z.strictObject 写(如 GrepTool.tsx:17、FileEditTool.tsx:27-31),多一个字段都算非法。失败不打异常、不崩循环,而是回一条 is_error: true 的 tool_result(query.ts:385-392),把 Zod 的错误文案交给模型自我纠正。query.ts:399):形状对了还不够,每个工具有自己的规矩。失败同样回 is_error tool_result(query.ts:411-418),文案是命令式语句,不是日志。两道都在 call()(query.ts:441)之前。工具本体永远假设输入已合法——脏活在边界上做完。
Edit 的输入只有三件事:file_path、old_string、new_string。不是行号补丁,不是整文件覆写,是唯一字串替换。FileEditTool.tsx 的 validateInput(115–217 行)是执行引擎,依次拒绝:old 等于 new(空操作);文件已存在却给了空 old_string(创建冲突);文件不存在;.ipynb 改错工具;然后是最关键的两条——
readFileTimestamps 是一张「本会话里每个绝对路径上次被读的时间」的表,由 View 在读成功时写入(FileReadTool.tsx:181)。表里没有这个路径,直接拒:File has not been read yet. Read it first before writing to it.mtimeMs 比阅读时间新,说明用户或 linter 动过,拒,要求再读一遍。写完后再把最新 mtime 回填进表(238 行),下一次编辑不会被自己刚写的文件误杀。语义校验的最后两刀是「找不到」(195–203 行)和「找到多处」(205–214 行):old_string 在文件里出现超过一次就失败,报错文案是「Found N matches… Add more lines of context」。prompt 侧把同一规则写成说明书(prompt.ts:21-33):前后至少 3–5 行上下文、一次只改一处。失败文案本身就是给下一轮模型的指令,这是整个编辑引擎最重要的产品决策:把模型的不精确变成多一轮对话,而不是一次静默错改。
0.2.8 最容易看走眼的工具。prompt 写着「Searches file contents using regular expressions」(GrepTool/prompt.ts:5),实现却在 call 里写死 const args = ['-li', pattern](GrepTool.tsx:107):-l 只打印文件名,-i 忽略大小写。模型拿不到行号、拿不到匹配行,只拿到一串按 mtime 降序的文件路径,上限 100 条。定位候选文件靠 Grep,读内容靠 View,改靠 Edit——分工是刻意的上下文卫生:grep -r 一次能吐出几万行,只回文件名就灌不爆上下文。(顺带一提:后来版本让 Grep 返回 path:line:text,材料来自官方文档,不在本仓库。)
ArchitectTool.tsx:51-53 的 isEnabled() 写死 return false。就算 CLI flag 或项目配置把它 push 进工具清单,注册表的 isEnabled() 过滤也会把它再摘掉。0.2.8 快照里这个「架构师子代理」实际不可达——是「用 Tool 包装一次规划循环」的实验位,门被焊死了。读它的意义在于:同一套契约连「默认不存在的工具」都表达得了,门控也是契约的一部分。
BashTool.tsx:82-85 的 needsPermissions() 不看输入、恒为 true——注释写明「Always check per-project permissions」。对照着看:文件工具按路径问 hasReadPermission / hasWritePermission,Grep 只在搜的目录没授过读权时才要批(GrepTool.tsx:59-61),而 Bash 每一条命令都必须过权限层。契约把「这个工具有多危险」表达成一个逐输入求值的函数,而不是工具级的开关。权限层本身(白名单、前缀记忆、弹窗)是第 5 关的内容,本关只需记住:工具自己申报危险性,循环照单转交。
无论成功、Zod 拦截、业务拒绝、权限拒绝还是 call() 抛异常,循环最终都 yield 一条 type: 'tool_result' 的用户消息(query.ts:385-493 共五处构造点):成功的带 resultForAssistant 内容(449–455 行),失败的额外带 is_error: true。模型看到的永远是同一形状的回执,区别只在文案。循环不规划、不兜底、不崩溃——「出错了」也只是下一轮对话的输入。
里程碑 m4-tools:给第 3 关的 ~80 行骨架装上 Read / Edit / Bash 三个工具,带 schema 校验。 本关预算约 60 行,mini-agent 总行数控制在 ~140 行。
在上关的 tool_use 循环基础上做四件事:
定义契约:每个工具是 { name, inputSchema, validateInput?, call } 的对象。三个 Zod schema:read_file { path: string }、edit_file { path, old_string, new_string }(都用 z.strictObject)、run_command { command: string }。
Zod 是一个 TypeScript schema 校验库,原作用它把「模型生成参数不可靠」挡在工具调用之前。但本练习的硬约束是零 npm 依赖,没有真 Zod 可装——你需要手写一个 ~10 行的迷你 safeParse:检查必填字段在不在、类型对不对、有没有多出来的键,返回 { success, data | error } 形状即可(参考实现 agent.mjs 示范了一种写法,附录 B 的对读表有对应一行)。
循环先校验再调用:拿到 tool_use 后先 safeParse,失败就把 Zod 错误包成 { type: 'tool_result', is_error: true, content } 喂回模型,continue 循环;过了再走 validateInput,再过才 call。本关不做权限弹窗(那是第 5 关),Bash 先直接跑。
实现先读后改:维护 readFileTimestamps: Record<string, number>。read_file 成功后写入时间戳;edit_file 的 validateInput 依次拒:未读过的文件、mtimeMs > readTimestamp、old_string 找不到、old_string 出现多于一次;通过后用函数式 String.replace(old, () => new) 落盘并回填时间戳。
写一组冒烟脚本(不算入 200 行预算,可直接 node 跑):在临时目录里放一个测试文件,依次模拟模型的 tool_use 输入,打印每轮的 tool_result。
验收标准:
edit_file 时少传 new_string,或多传一个 schema 外的字段 → 循环不抛异常,模型收到一条 is_error: true 的 tool_result(Zod 在调用前拦下);read_file 过的已存在文件直接 edit_file → 被拒,回执含「has not been read yet」类文案;read_file 再 edit_file 可成功改写;把 old_string 换成在文件中出现两次的串 → 被拒,回执提示补充上下文;run_command 跑 echo hello 能把 hello 作为 tool_result 回到对话里。全部通过后打 tag m4-tools。
src/Tool.ts 缺失的情况下,我们仍然能描述 Tool 接口的形状?依据是什么,表述上必须加什么限定?checkPermissionsAndCallTool 里 Zod 校验和 validateInput 各拦什么?为什么两道都要把失败包成 is_error: true 的 tool_result,而不是抛异常?ArchitectTool 的 isEnabled() 写死 false,与 CLI flag / 项目配置的关系是什么?这说明了「门控」在契约里的什么位置?参考答案见附录 A。
能读、能改、能跑 shell——第 4 章结束时,你的 mini-agent 第一次有了真本事,也第一次有了真危险:模型吐出一句 rm -rf ~,谁来拦?第 5 章的回答不在模型里,而是一层「默认拒绝」的代码:每次 tool_use 落地前先过闸门,过不了就问人,人的答案记成下次不用再问的契约。这一关的验收只有一句话:白名单外的命令,必须人工确认才能执行。
第 4 关结束时,你的 mini-agent 已经能读文件、改文件、跑 shell 了。问题立刻来了:模型吐出一句 rm -rf ~,谁来拦?
0.2.8 的答案不在模型里。系统提示里确实写着安全要求,但真正的闸门是 harness 里一层「默认拒绝」的代码:每一次 tool_use(模型在回复里发起的工具调用)真正执行前,都要先过 canUseTool 这一关。prompt 可以改变模型想做什么,不能扩大 harness 允许做什么。这套方案的实质是「社会层」而非「系统层」:没有容器和沙箱把破坏关住,靠的是每一次可能改变世界的动作都可能要问人,再把人的答案小心记成契约。本关把这一层拆开,然后给 mini-agent 装上同款闸门。
| 文件 | 行号 | 读它看什么 |
|---|---|---|
src/permissions.ts |
18–27 | SAFE_COMMANDS:恰八条整句的白名单 |
src/permissions.ts |
154–222 | hasPermissionsToUseTool:默认拒绝的决策主干 |
src/permissions.ts |
224–266 | savePermission 与 getPermissionKey:通行证怎么记 |
src/query.ts |
365–441 | checkPermissionsAndCallTool:校验 → 权限 → 执行的次序 |
src/hooks/useCanUseTool.ts |
22–136 | Promise 桥:把人的按键接回循环 |
src/utils/permissions/filesystem.ts |
6–7, 33–60 | 读、写两个内存目录 Set |
src/utils/config.ts |
332–375 | allowedTools 按项目绝对路径分桶存取 |
src/entrypoints/cli.tsx |
187–213 | --dangerously-skip-permissions 的三道门 |
src/constants/prompts.ts |
20, 124–125 | 恶意软件禁令(模型层的产品默认) |
src/tools/BashTool/BashTool.tsx |
86–117 | validateInput:不给 Yes 机会的硬拒绝 |
系统提示里有一段恶意软件禁令(prompts.ts:20):拒绝写或解释可能被恶意使用的代码,即使用户声称是出于教育用途——教育用途免责声明被明确关掉了。下一句更进一步:开工前先根据文件名和目录结构判断代码是干什么的,看起来像恶意软件,哪怕只是让你「解释一下」「加速一下」,也必须拒绝。同一句话在 prompts.ts:124–125 又原样抄了一遍,可见作者多怕模型忘。
但注意它的位置:这是写给模型看的文本,是产品默认行为,不是强制执行。模型可以被 jailbreak,提示词会被注入、会被忘掉。真正的强制执行发生在权限对话框——那是 TypeScript 代码,模型碰不到它。
弹窗之前还有一道更硬的、不给人 Yes 机会的拒绝:BashTool.validateInput(BashTool.tsx:86–117)。BANNED_COMMANDS(src/tools/BashTool/prompt.ts:10–28,curl、wget、nc、telnet、各浏览器等 17 条联网命令)和「cd 不得离开启动目录」在这里直接打回,错误收成一条 tool_result 回给模型。权限层管「这一下能不能跑」,这一层管「这类命令存在都不存在」。
permissions.ts:18–27,一个 Set,八个精确字符串:
git status / git diff / git log / git branch / pwd / tree / date / which
是整句相等,不是前缀匹配。git status --porcelain 不过,which rm 也不过。为什么这么短?因为下面没有地板——0.2.8 没有容器、没有沙箱,cat、ls、echo 一旦带参数就可以读密钥、覆盖文件、拼管道。宁可再问一次人,也不把「看起来只读」写进自动放行。后来的版本把安全名单做大,是因为下面已经有了 OS 沙箱(材料来自官方文档,不在本仓库);把那套宽名单抄回 0.2.8 是错的。
query.ts:365 的 checkPermissionsAndCallTool 定了次序:先 zod 校验输入结构(:377),再 validateInput 校验取值(:399),然后 :424–426 才轮到 canUseTool,全部通过才 tool.call(:441)。
图 3 canUseTool 的决策树(手绘版):三条直接放行路径、Bash 专用子流程、写文件类与其他工具两个分支;所有拒绝收敛到同一句文案,然后弹窗问人。数据来自 permissions.ts 第 154–222 行。
hasPermissionsToUseTool(permissions.ts:154–222)默认拒绝,放行只有几条极窄的路,按检查顺序:
dangerouslySkipPermissions 为真 → 全部放行(见下文三道门);tool.needsPermissions(input) 为 false → 放行(注意:src/Tool.ts 在还原树中缺失,Tool 接口的形状是从 15 处 satisfies Tool 实现反推的,接口字段描述均属反推);allowedTools: ["Bash"] → 放行全部 bash。这是 blanket 放行(整工具放行),注释明说不在 UI 暴露——知道这条手写配置的人,等于有第二套跳过开关;bashToolHasPermission:先 SAFE 八条整句,再查配置里的整句 key 和前缀 key;getPermissionKey 拼出的 key 命中 allowedTools 即放行。全不过,返回 { result: false, message }。useCanUseTool(hooks/useCanUseTool.ts:22–136)把 false 变成弹窗::86 调 setToolUseConfirm 挂起 Promise,等 Ink 对话框里的人按键。人拒绝时 resolve 一条 REJECT_MESSAGE 并 abortController.abort()(:35–44)——同批还没开跑的 tool_use 一起停,不是只停当前这一下。被拒绝的命令不会执行,拒绝原因收成一条 is_error 的 tool_result 发回模型(query.ts:427–437),模型看到的只是「你还没得到授权」,不是 UI 里发生了什么。
还有一处失败关闭(fail closed:出错时默认关闸,而不是默认放行):Bash 的前缀不是正则抽的,是丢给一个更小的模型(Haiku)按 policy spec 分类的。分类失败、或报了 command_injection_detected 时(permissions.ts:88–107),只接受整句精确命中,宁可再问人。用一个小模型守一个大模型,仍然是社会层;但失败的方向是关。
permission key(权限通行证在配置里的字符串形式)拼法在 permissions.ts:253–266,三种粒度并排:Bash(git commit:*) 是一类命令,Bash(git status) 是一句命令,其他工具直接用工具名(如 mcp__foo__bar)。
落盘位置是 ~/.claude.json(env.ts:11–13),按项目绝对路径分桶:getCurrentProjectConfig 用 resolve(getCwd()) 当 key 去 projects 表里查(config.ts:337, 345),saveCurrentProjectConfig 写回同一个桶(:370)。在 A 项目批准的 Bash(npm test:*) 不会漏到 B 项目;claude approved-tools remove 能从桶里删掉单条。
文件写授权是例外:savePermission 对 Edit/Write/NotebookEdit 短路(permissions.ts:231–238),不落盘,改调 grantWritePermissionForOriginalDir() 把整个启动目录写进内存里的目录 Set(filesystem.ts:6–7),会话结束作废。匹配用 startsWith 前缀包含(filesystem.ts:38, 55):授了 /proj 等于授了整棵子树。磁盘上永远不会出现一张永不过期的 FileWrite 通行证——这是刻意的:写权是 ephemeral(随会话生灭)的,bash 前缀才是可累积的契约。
读和写是不对称的。读权在启动时就被无条件授出:Trust Dialog 点完接受会调一次 grantReadPermissionForOriginalDir()(cli.tsx:153),setup() 里又无条件授一次(cli.tsx:184–185)。所以项目树内的 Read、Grep、Glob、LS 常常根本不弹窗——不是因为「只读所以安全」,而是因为读已经是授出的权利;出了启动目录,读一样要走对话框。写权则默认没有,必须等人在编辑工具的弹窗里点头,且如上段所说只活在这个会话里。
--dangerously-skip-permissions 不藏。它过了 cli.tsx:188–212 的三道门才生效:不是 root(:190–199)、必须在 Docker 容器里(:202–207)、必须无外网(:207–212)。任何一道不过,打印原因并 process.exit(1)——不降级成「那还是问人」,直接退出。
设计哲学值得抄:跳过权限是合法需求(无网容器里的 CI、内部狗食),所以提供旁路;但旁路必须难到不可能误开。三道环境检测把「能用这个 flag 的场景」收窄到「炸了也无所谓」的环境,名字里的 dangerously 是诚实的。过了门之后,Trust Dialog 不出现、所有 needsPermissions 失效、子代理都升格为可写可 bash——社会层被整层拆掉,只剩环境隔离,所以环境必须先足够小。
在第 3 关的循环骨架和第 4 关的 Read/Edit/Bash 三工具之上,给 bash 工具加一层 canUseTool。预算 ~40 行,mini-agent 总量不许超 200 行。
要求:
SAFE_COMMANDS,照抄 0.2.8 那八条整句。canUseBash(command):整句命中白名单 → 直接执行;命中配置里存的前缀或整句 → 直接执行;否则用 readline 弹一行确认:允许执行 "npm test"? [y]仅本次 / [a]记住前缀 / [n]拒绝。y 执行这一次;a 把 命令首词:* 写进项目目录下的 mini-agent.json,此后同前缀命令静默通过;n 不执行,把「用户拒绝了这条命令」作为 tool_result 回给模型,循环继续而不是退出。验收标准(逐条可测):
git status)全程无弹窗,直接出结果;touch /tmp/m5-test)必须人工输入 y 才执行;输入 n 时 /tmp/m5-test 不存在,且模型下一轮能「看到」拒绝原因;a 之后重开进程,同前缀命令(如 npm run build)不再弹窗。提示:mini-agent 不调真模型,「让它发起一条指定命令」靠给假模型接一个 JSON 脚本文件——参考实现用 MINI_AGENT_SCRIPT=/path/steps.json 环境变量驱动(见 mini-agent/README.md);验收「重开进程不再弹窗」时用同一脚本复跑即可稳定复现。
一句话验收:白名单外的命令,必须人工确认才能执行。
SAFE_COMMANDS 为什么只有八条、而且必须整句匹配?git status --porcelain 能过吗,为什么?hasPermissionsToUseTool 默认拒绝。从 permissions.ts:154–222 数出所有能让它返回 { result: true } 的路径,并说明各自的作用范围。useCanUseTool.ts 里哪几行代码保证的?--dangerously-skip-permissions 的环境检查失败时为什么 process.exit(1),而不是退回「继续问人」的模式?allowedTools: ["Bash"] 会发生什么?0.2.8 为什么不在 UI 里提供这个选项?参考答案见附录 A。
至此骨架齐了:入口、首包、循环、三工具、权限闸门,两百行以内就位。第 6 章换个姿势——只读,不给 mini-agent 加一行代码。回头称一称 0.2.8 的体量:界面代码是循环代码的十几倍。这不是浪费,是「产品的厚度在交互面」的决策。这一关的产出是一张图:挑一个权限弹窗画出它的状态机,画到不看代码也能答出「按 Esc 之后循环发生了什么」为止。
前五关读完,你已经有了 mini-agent 的骨架:CLI 入口、context 包、tool_use 循环、三个工具、命令白名单加确认弹窗。回头看 0.2.8 的体量分配,会发现一件反直觉的事:query.ts 那个循环约 480 行,而 src/components/ 合计 7961 行——界面代码是循环代码的十几倍。这不是浪费,是设计决策:产品的厚度在交互面,不在循环。
这一关是只读关,不给 mini-agent 加代码。任务是搞懂 0.2.8 怎么把异步生成器钉在一块可打断的终端屏幕上,顺便回答几个「为什么」:为什么人格里写死「回答少于 4 行」?为什么 190k token 就报警?为什么花到 5 美元要弹窗?
先交代底座:0.2.8 用 Ink,一个把 React 组件渲染到终端的库。你在 src/components/ 里看到的 <Box> <Text> 不是 HTML,是终端里的盒子和字符。会话状态全部放在 React state 里,query() 这个生成器(第 3 关讲过:一个 AsyncGenerator<Message>,每次 yield 吐出一条消息)只管 yield,屏幕上发生什么它一概不知道。
| 文件 | 行号 | 看什么 |
|---|---|---|
src/screens/REPL.tsx |
541–644 | 渲染优先级:谁出现,谁就把输入框挤掉 |
src/screens/REPL.tsx |
162–173 | onCancel:权限弹窗挂起时走 onAbort() |
src/screens/REPL.tsx |
133–136、195–199、592–603 | 5 美元弹窗的触发与「只弹一次」 |
src/hooks/useCanUseTool.ts |
35–44、86–122 | 权限 Promise 挂进 state;三个回调 |
src/hooks/useCancelRequest.ts |
16–38 | Esc 的分流逻辑 |
src/components/permissions/PermissionRequest.tsx |
22–40、59–70、79–84 | 按工具分发弹窗;ToolUseConfirm 类型;Ctrl-C |
src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx |
38–120 | 一个具体弹窗的完整结构 |
src/components/permissions/toolUseOptions.ts |
48–58 | 三个选项的文案与取值 |
src/components/TokenWarning.tsx |
9–11、22–29 | 190_000、60% 黄、80% 红、/compact 引导 |
src/components/CostThresholdDialog.tsx |
11–46 | 5 美元弹窗本体(全文件 46 行) |
src/services/claude.ts |
206–224、516 | 传输在流、屏幕整段出现的证据 |
src/constants/prompts.ts |
44、121 | 「少于 4 行」规则,首尾各一遍 |
第 2 关读系统提示时你见过这句话,现在从 TUI 的角度重新看它。src/constants/prompts.ts:44 写得很死:回答必须少于 4 行(不含工具调用和生成的代码),一词回答最好,禁止「The answer is...」这类开场白。同一规则在文件尾部的 prompts.ts:121 又抄了一遍,中间夹着极端的 few-shot 例子:问 2 + 2,答 4;问 is 11 a prime number,答 true。
为什么是 4 行?终端视口一行大约 80 到 120 个字符,一屏能放的行数有限。模型如果先自我总结一段——「我已经帮你查看了这个文件,发现问题出在第 42 行,接下来我将……」——真正的工具输出和 diff 就被挤出了屏幕。而在 ReAct 循环里,每一轮 assistant 文本还会作为下一轮的消息送回模型,客套话不止占屏幕,还污染下一轮推理。所以「少于 4 行」同时是在保护视口和保护循环。
注意这条约束的方向:是界面约束反向写进了模型人格。不是先有提示词后有 UI,是 80 列的终端决定了提示词该怎么写。你自己造 Agent 时如果换成交互富一点的环境(比如 Web 界面),这条规则就可以松——它是产品决策,不是宇宙真理。
src/components/TokenWarning.tsx 一共 31 行,值得整段读完。第 9 行:
const MAX_TOKENS = 190_000 // leave wiggle room for /compact
上限不写 200k 而写 190k,注释说得很坦白:给 /compact 留余量。/compact 在本仓库真实存在(src/commands/compact.ts),作用是把对话历史压缩成摘要再开新会话——压缩本身要再发一次请求,得留出这次请求的 token 空间,所以警戒线画在 190k 而不是顶到上限。
第 10–11 行两档阈值:WARNING_THRESHOLD = MAX_TOKENS * 0.6(114k,黄色),ERROR_THRESHOLD = MAX_TOKENS * 0.8(152k,红色)。用量低于 60% 时组件直接 return null(第 16–18 行),什么都不画。过了阈值就在输入框右下角显示一句 Context low (N% remaining) · Run /compact to compact & continue(第 26–27 行),红黄的判断在第 20 行。它挂在 PromptInput 页脚(src/components/PromptInput.tsx:448),每帧用 countTokens(messages) 现算。
设计要点:警告不是打断,是一行常驻的小字,还顺手把解决方案(/compact)写在同一行里。用户不看也不碍事,看了就知道下一步干什么。
这个弹窗分两半看。本体在 src/components/CostThresholdDialog.tsx,46 行:一个圆角框,一行粗体 You've spent $5 on the Anthropic API this session.(第 28 行),一条指向费用文档的链接,下面一个只有 Got it, thanks! 一个选项的 Select(第 34–42 行)。它还自己接管了按键:Ctrl-C、Ctrl-D、Esc 都等同确认(第 13–17 行)——这个弹窗不需要你做决定,只需要你「知道了」。
触发逻辑在 REPL 那边。src/screens/REPL.tsx:195:每次 messages 变化后检查 totalCost >= 5 /* $5 */,满足就 setShowCostDialog(true)。「只弹一次」靠两层:点掉弹窗时 onDone 把 hasAcknowledgedCostThreshold: true 写进全局配置(REPL.tsx:592–603,字段定义在 src/utils/config.ts:113);下次启动时 haveShownCostDialog 从这个字段初始化(REPL.tsx:133–136),确认过的人这辈子不会再见到它。还有一个小细节:弹窗只在 !isLoading 时真正显示(REPL.tsx:539),循环还在跑就不抢屏——花钱提醒再重要,也不该插在工具执行中间。
0.2.8 里你看到的 assistant 回复是「整段突然出现」的,没有打字机效果。这不是网络不行,是代码就这么写的,而且证据就在同一个函数里。
src/services/claude.ts:516,HTTP 层用的是 anthropic.beta.messages.stream(...)——传输确实是流式的,token 一个个到达。但接着看 handleMessageStream(claude.ts:206–224):第 213–217 行的 for await (const part of stream) 把流整个消费了一遍,却只在 message_start 时记了一下首 token 时间(ttft),中间到达的文本块全部丢弃;第 219 行 await stream.finalMessage() 拿的是攒完的整条消息。流的上面还留着一行注释,claude.ts:212:
// TODO(ben): Consider showing an incremental progress indicator.
所以「传输在流、屏幕整段出现」不是 bug,是一个标了 TODO 的取舍:0.2.8 的流式是消息级,不是 token 级。等待的空白靠什么填?Spinner 和那一串幽默动词(Clauding、Reticulating……)。token 级流式渲染是后来版本才补上的(材料来自官方文档,不在本仓库)。
第 5 关讲了权限的安全语义(白名单、默认拒绝),这里只看 UI 这一半:一个弹窗从挂起到消失,走了哪些组件、落在哪几个终态。
桥:工具执行前调 canUseTool,它是 useCanUseTool 这个 hook 造出来的函数。配置里已有权限就直接 resolve({ result: true }),UI 无感;否则走到 src/hooks/useCanUseTool.ts:86,把一个对象塞进 React state:
setToolUseConfirm({ assistantMessage, tool, description, input,
commandPrefix, riskScore: null, onAbort() {...}, onAllow(type) {...}, onReject() {...} })
循环就此停在一个 Promise 上。这个对象的类型 ToolUseConfirm 定义在 src/components/permissions/PermissionRequest.tsx:59–70。提醒一句:里面引用的 Tool 类型来自 src/Tool.ts,该文件在本 sourcemap 中确认缺失,所以关于 Tool 接口形状的所有论述都是反推,下同。
抢屏:REPL 的渲染区是一段隐式优先级(REPL.tsx:556–611):toolJSX 最高,然后依次是 BinaryFeedback、PermissionRequest、CostThresholdDialog,全空才轮到 PromptInput。toolUseConfirm 非空时弹窗出现(REPL.tsx:577–586),输入框同时被卸下——你不能在 Agent 等批准时再塞一句话。
分发:PermissionRequest.tsx:22–40 的 permissionComponentForTool 按工具选弹窗:FileEdit 和 FileWrite 各有带 diff 预览的专属弹窗,Bash 用 BashPermissionRequest,Glob/Grep/LS/读文件一族共用 FilesystemPermissionRequest,其余一律落到 FallbackPermissionRequest。
一个具体弹窗:BashPermissionRequest.tsx(121 行)的结构是所有弹窗的模板——圆角边框(主题色 permission)、标题、工具自己渲染的命令预览、一句 Do you want to proceed?、一个 Select。选项由 toolUseOptions.ts:48–58 生成,最多三个:Yes、Yes, and don't ask again for ...(前缀或整条命令,危险复合命令会藏起这项)、No, and tell Claude what to do differently (esc)。
三终态:不管哪个弹窗,故事的结局只有三种——
Yes → onAllow('temporary') → Promise resolve({ result: true }),工具放行,循环继续。选「不再询问」则先 savePermission 把命令前缀写进配置白名单,再 onAllow('permanent')(BashPermissionRequest.tsx:72–105)。No → onReject() → resolve({ result: false, message: REJECT_MESSAGE }) 并 abortController.abort()(useCanUseTool.ts:115–121、35–44)。工具结果变成一条拒绝消息,同轮排队的其它 tool_use 一并取消,循环停下等你「告诉 Claude 换种做法」。useCancelRequest(useCancelRequest.ts:16–38)清掉所有模态 state 再调 onCancel;REPL 的 onCancel 发现 toolUseConfirm 还挂着,改走 toolUseConfirm.onAbort()(REPL.tsx:162–173)——onAbort 和 onReject 最终走同一条路(REJECT_MESSAGE + abort),区别只在埋点和语义。Ctrl-C 则是弹窗组件自己接的:onDone() 加 onReject()(PermissionRequest.tsx:79–84)。另外一个产品细节:弹窗挂起 6 秒没人理,就弹一个桌面通知(PermissionRequest.tsx:89 的 useNotifyAfterTimeout)——人去倒水了,Agent 不能干等。
这一关不改 mini-agent 的代码,产出是一张图:从 src/components/permissions/ 里挑一个权限弹窗组件,画出它的状态机。建议选 BashPermissionRequest.tsx(结构最典型);想多看 diff 预览可以选 FileEditPermissionRequest,想看兜底逻辑选 FallbackPermissionRequest。
要求用 mermaid stateDiagram-v2,画图前先通读三处接线:useCanUseTool.ts:86–122(Promise 怎么挂)、PermissionRequest.tsx:22–40、79–84(分发与 Ctrl-C)、REPL.tsx:162–173、577–586(Esc 路径与渲染时机)。
验收标准:
resolve({ result: true }) 还是 resolve({ result: false, message: REJECT_MESSAGE }),以及是否伴随 abortController.abort();setToolUseConfirm 挂起 Promise,终点必须是 Promise resolve(循环继续或停下)。一句话版:图能让人不看代码就答出「按 Esc 之后循环发生了什么」就算过。
画完之后回头看你的 mini-agent:第 5 关的确认弹窗就是这张图的最小实现——三终态加一个 resolve。0.2.8 用 8k 行 Ink 做的事,骨架里那几行已经够用了,规模预算维持 ~200 行不动。
!isLoading 时显示」这条时序约束。No,最终对 query() 循环的效果有何异同?(提示:对比 onAbort 与 onReject 的函数体。)参考答案见附录 A
零件拆完了,最后一章做两件事:把零件装回去,看清「最小内核」小在哪;再抬头看后来的产品往内核外面堆了什么、为什么堆在外面。这一关要动用来源纪律——0.2.8 之后的材料全部来自官方文档,不在本仓库,读到时注意标注。然后是结业考:让 mini-agent 跑通「读文件 → 改 bug → 跑测试」,七条验收逐项打勾。全过,你手里这两百来行就和 0.2.8 的内核同构了。
前六关把 0.2.8 拆成了零件:入口、context 包、循环、工具、权限、TUI。这一关不拆新零件,做两件事:把零件装回去,看清「最小内核」到底小在哪;再抬头看后来的产品往内核外面堆了什么——以及为什么堆在外面,而不是里面。
先交代两条纪律,它们正好就是本关的主题。
第一,本关写到 0.2.8 之后的东西——Skills、Hooks、操作系统沙箱、自动压缩上下文——材料全部来自官方文档,不在本仓库。本仓库是 0.2.8 的 sourcemap 还原树,211 个文件共 25979 行,里面没有这些特性的文件,连引用都没有。
第二,2026 年 3 月另有一次更出名的泄露,对应 npm 上的 2.1.88,约 1900 个文件。那份材料和本仓库这份 0.2.8 不是同一件事,引用时不要混。本关所有关于「今天的 Claude Code」的句子,都是对官方文档与公开泄露分析(例如 Siddhant Khare 读 2.x 源码的文章)的转述;本仓库能证明的,只有 0.2.8 的骨架长什么样。
本关几乎没有新代码要读——它是回头看的关。重读下面几处,带一个问题:这件东西如果在 2025 年 2 月不存在,产品还能不能给人用?
| 文件 | 位置 | 重读什么 |
|---|---|---|
src/query.ts |
全文 516 行 | 一个文件塞进了多少责任:并发调度、校验、权限、错误截断、左右对照。这就是内核的上限 |
src/query.ts |
111–123、124 | wizard 口气的 thinking 三规则注释,和 query() 生成器签名 |
src/query.ts |
169–177 | 第 170 行写 stop_reason === 'tool_use' 不可靠;没有 tool_use 就 return,循环结束 |
src/commands/compact.ts |
30–45、64–83 | 0.2.8 唯一的压缩:人手动下的命令,独立调一次 querySonnet,清屏,把摘要塞回消息列表 |
src/permissions.ts |
18–27 | SAFE_COMMANDS 八条整句——下面没有地板时的诚实长度 |
src/tools.ts |
23、41 | getAllTools() 注册表和 memoize:MCP 从第一天就进同一张工具表 |
src/utils/state.ts |
全文 25 行 | 第 4 行 Boris 的诅咒:内核不许长新状态 |
src/components/TokenWarning.tsx |
9 | 190_000 和它的注释:余量是留给 /compact 的——压缩在 0.2.8 是人的动作 |
仓库外材料(材料来自官方文档,不在本仓库):现行 Claude Code 的 Skills、Hooks、沙箱、自动压缩相关文档;Siddhant Khare 的 2.x 源码分析。
0.2.8 是产品刚能给人用的那一版。说「能用」,不只是因为循环能转——它已经有了一整套对付真实用户的东西:Trust Dialog 先问信不信这个目录,八条白名单管命令,TokenWarning 在 190k token 时提醒,CostThresholdDialog 在花到 5 美元时弹窗(CostThresholdDialog.tsx:28),自动更新和 claude doctor 管版本,CLAUDE.md 当记忆,/compact 让人手动给上下文续命。剥掉这层产品壳,剩下的内核小得惊人:一个 516 行的递归生成器,一张工具表,一个权限函数。
换句话说,0.2.8 的内核 = 模型每说一句话都要经过的最短路径。tool_use(模型回复里的工具调用块)抽出来,tool_result(工具执行结果包装成的用户消息)拼回去,权限在调用点问一次,人按 Esc 就让生成器 return。循环、工具契约、权限、给人看的界面,四件事焊在一起。模型可以换——后来的 anon-kode 真把这套 harness 换成过别的模型——但这四件换了,产品就不再是它自己。
以下全部材料来自官方文档,不在本仓库:
/compact,到阈值自动摘要续写。据公开泄露分析的转述,后来同一文件从递归改成了 while 加多处 continue,循环体内插进多层压缩逻辑。本仓库证明不了 2.x 长什么样,这里只是转述。注意这四件的共同点:没有一个需要改 0.2.8 的循环签名才能存在。这不是巧合,是下一节的框架。
给你三问,以后看到任何 Agent 新功能都可以套:
/compact 在 0.2.8 就是这么活的:它不动 query.ts,独立调一次 querySonnet(compact.ts:34–45),换一句系统提示,再把摘要连同一条说明塞回消息列表(compact.ts:78–83)。后来的自动压缩(材料来自官方文档,不在本仓库)本质上是同一个动作,只是触发者从人变成了阈值。Skills 是 context 的新来源,Hooks 是消息流上的监听器,同样够不着循环签名。query.ts。反过来看,0.2.8 的白名单只能有八条,恰恰因为当时下面没有地板——名单短是诚实的,把后来的宽名单抄回 0.2.8 反而是错的。三问合成一句口诀:换模型不换的是内核;能后装、能拆掉、失效时有别人兜底的,是外层。
0.2.8 自己也在防内核膨胀。query.ts 516 行已经塞了五件事,state.ts 用一句诅咒(第 4 行,全文 25 行)禁止新全局状态。据公开分析转述,等压缩、钩子都进去之后,同一个文件长成了另一个名字——内核的成本从来不是写出来,是守住边界。
一处诚实声明:「统一工具对象」的接口定义文件 src/Tool.ts 在还原树中缺失,其形状是从 15 处 satisfies Tool 与全部调用点反推的。本关所有关于工具契约的说法都带着这个限定。
| 层 | 0.2.8(本仓库可见) | 今天(材料来自官方文档,不在本仓库) |
|---|---|---|
| 循环 | 递归 query() 生成器,516 行,无递归上限 |
queryLoop:while 加多处 continue,多层自动压缩(公开分析转述) |
| 工具 | 统一工具对象,16 个内置工具;MCP 第一天进同一张表 | WebFetch、LSP、Todo、内容级 Grep 等,四十多个工具;企业托管 MCP、工具定义按需加载 |
| 权限 | Trust Dialog、八条白名单、前缀记忆 | deny/ask/allow 规则、bash AST 解析、操作系统沙箱、classifier |
| 记忆与扩展 | CLAUDE.md 祖先链 | Skills、Hooks、PreToolUse、auto memory |
| 上下文 | 手动 /compact,独立 querySonnet 摘要 |
到阈值自动压缩 |
| 界面 | Ink REPL,消息只追加,重说就 fork | token 打字机、虚拟滚动;桌面与 IDE 复用同一循环 |
| 仪器 | Statsig、binary feedback、USER_TYPE=ant |
构建期 DCE、client attestation、cache break 检测 |
读这张表的方式:左栏每一行你在前六关都亲手摸过;右栏每一行都能用三问归到「外层」。公开文档里的现行产品仍然跑工具调用循环、仍在运行时做权限、仍读 CLAUDE.md——但这只是产品文档层面的连续性,不能反证 2.x 的 query.ts 还是这份 516 行递归生成器。能下的结论是:0.2.8 押的骨架形状,今天看仍然成立。
结业练习(里程碑 m7-final):让你的 mini-agent 跑通「读文件 → 改 bug → 跑测试」全流程。规模预算不变:mini-agent 本体总行数不超 ~200 行,最多放宽到 220。
准备 fixture(不算进 200 行预算):一个含 bug 的 calc.py(例如 def add(a, b): return a - b),配一个针对它的测试文件,初始状态测试必红。
执行任务:一条命令启动,例如 node mini-agent.mjs "修复 calc.py 让测试通过"——管道模式,第 1 关的双路输入在这里一并验收。
逐项验收清单(全部打勾才算结业):
wc -l 统计 mini-agent 本体 ≤ 220 行。calc.py 的 Read 发生在 Edit 之前;若模型试图直接 Edit 未读文件,工具拒绝并把原因写回 tool_result,循环继续。query.ts:176–177 的 return)。七条全过,你就拥有了一个和 0.2.8 同构的内核:薄循环、三工具、权限在调用点、失败写成消息。剩下的——压缩、沙箱、Skills、Hooks——你现在已经知道它们该装在哪一层,以及为什么。
SAFE_COMMANDS 只有八条整句,后来的版本敢把只读名单放宽。用「地板」的概念解释:变的是名单本身,还是名单下面的东西?query.ts 仍然是 516 行递归生成器?能证明的和不能证明的各是什么?参考答案见附录 A。
对应七关正文末尾的全部 35 道自检题(每关 5 道)。答案结论与关卡正文一致;每题附「答案要点 + 源码依据」,源码依据中的行号均在撰写时对回过 0.2.8 还原树
src/的当前文件状态(仓库见书名页)。行号口径。 本附录应用 P0 校验(本套装校验文件
verification.md)的四条勘误:state.ts为 25 行(非 26);shims import 在cli.tsx:9(第 6–8 行是XXX:注释);「stop_reason 不可靠」注释在query.ts:170(:169 是@see链接行);wizard 注释块为query.ts:111–123(/**起于 :111)。反推限定。
src/Tool.ts在还原树中缺失,凡涉及 Tool 接口字段(isReadOnly/needsPermissions/validateInput/call等)形状的论述均为反推——依据是全仓 15 处satisfies Tool(本附录复核:15 个文件各一次)与全部调用点。同批缺失的还有src/tools/MemoryReadTool/prompt.ts、src/tools/MemoryWriteTool/prompt.ts(两个记忆工具的文案,不作转述)与src/utils/conversationRecovery.ts(被cli.tsx:52、screens/ResumeConversation.tsx:4引用)。凡受影响的题目,答案中就地标注。题面勘误。 逐题比对后,35 道题的题面与关卡正文、源码三者一致,本附录 0 条题面勘误。一处口径说明:
verification.md按宽口径记「53 个文件 importTool.js」;本附录复核时按「import 路径恰好以/Tool.js结尾」的严口径数得 43 个文件——宽口径多出的约 10 个命中的是BashTool.js、MCPTool.js这类工具实现文件的路径,并非缺失的src/Tool.ts本体。下文引用时用严口径数字。
main() 为什么把 enableConfigs() 放在解析 argv 之前?在 import 顶层读配置会有什么后果?答案要点
enableConfigs() 是配置系统的「开闸」动作:它执行之前,任何模块在 import 阶段读 ~/.claude.json 都会直接抛错。把它放在 main() 第一件事(解析 argv 之前),是用崩溃强迫全仓遵守调用顺序——先开闸,后读配置。ConfigParseError)时不是 stderr 一行字,而是弹全屏 InvalidConfigDialog——人可以退出手修或用默认值覆盖,两个选择都结束当前进程。配置坏了就不许进门厅。源码依据
src/entrypoints/cli.tsx:271–281:main() 开头 try { enableConfigs() } catch,:274 开闸,:276–279 捕获 ConfigParseError 后 showInvalidConfigDialog({ error }) 并 return。src/entrypoints/cli.tsx:307:开闸之后才 parseArgs(inputPrompt, renderContext) 进入 argv 解析。Input hijacking breaks MCP.——mcp serve 被 stdin 劫持具体会坏在哪?答案要点
cli.tsx:297 调 stdin(),函数本体 :1015–1023 是一个 for await 把流读干)。claude mcp serve 是第三种活法:它在 stdio 上跑 MCP 协议,ListTools / CallTool 的请求和响应就走这一对标准输入输出(传输层在 mcp.ts:172–177 接 StdioServerTransport)。stdin 此时是协议线,不是 prompt 来源。mcp,进程一启动就把 stdin 吸干当 prompt:MCP 客户端发来的协议帧被当成「用户第一句话」吃掉,server 侧永远收不到请求,协议被掐死。所以 cli.tsx:291–296 的判断条件里明确排除 argv 含 mcp 的情形,注释 :294 写明 Input hijacking breaks MCP.源码依据
src/entrypoints/cli.tsx:291–306:stdin 分家判断(非 TTY、无 CI、argv 不含 mcp),:297 吸 stdin,:300–301 POSIX 上重开 /dev/tty 给 Ink。src/entrypoints/cli.tsx:1015–1023:stdin() 函数本体。src/entrypoints/mcp.ts:172–177:StdioServerTransport 接 stdio。--print 路径离得开第一次却离不开第二次?为什么两次都必须早于 getContext() 预取?答案要点
cli.tsx:153)发生在 Trust Dialog 的 onDone 回调里——只有对话框真的弹出来且人点了接受才会执行。--print(以及 --dangerously-skip-permissions)模式在 cli.tsx:148 就跳过了 Trust Dialog(「没有坐在屏幕前的人」),所以这条路第一次授权根本不发生。cli.tsx:185,setup() 内)是无条件的:所有路径——交互、--print、mcp serve——都走 setup()(--print 分支见 cli.tsx:391–410,其中 :379 已先调 setup)。CI 里跑 --print 没弹过对话框,全靠这一次让进程能读启动目录。getContext() 预取(cli.tsx:221,故意不 await):getContext() 会爬目录树、读 git 状态、读 README 拼进系统提示。0.2.8 没有沙箱,社会层的同意(Trust / 授读权)必须先于任何「为模型看世界」的动作,顺序倒了等于在没得到同意前就把仓库内容送进提示词。源码依据
src/entrypoints/cli.tsx:147–160:Trust Dialog 触发条件与 :153 grantReadPermissionForOriginalDir()。src/entrypoints/cli.tsx:177–185:setup() 起手,:184 注释 // Always grant read permissions for original working dir,:185 无条件再授一次。src/entrypoints/cli.tsx:219–222:getContext() 等预取不 await。src/entrypoints/cli.tsx:391–410:--print 分支。src/utils/permissions/filesystem.ts:84–87:grantReadPermissionForOriginalDir() 本体(写进内存读目录 Set)。--dangerously-skip-permissions 的三道门各自防的是什么误用场景?为什么 uid 0 那条排在最前、连 Docker 检查都不用等?答案要点
cli.tsx:188–213,任一失败打印原因并 process.exit(1)):getuid() === 0 直接拒绝。防的场景:sudo 加跳过权限,等于把整台机器的文件系统不加过问地交给模型,爆炸半径是整盘。/.dockerenv 且系统是 Linux,env.ts:16–23):防在宿主机裸奔——容器文件系统是随弃的,炸了也无所谓。1.1.1.1 发 1 秒超时 HEAD 探测,env.ts:25–40):防提示词注入后数据外泄——模型拿不到权限闸门,至少也拿不到网络出口。await(外网探测最坏要等 1 秒超时)。fail-fast 原则:最便宜、爆炸半径最大的闸门先落。root 身份本身是另两道门无法挽回的——在容器里以 root 跑和 sudo 跑在语义上同样需要拒绝,所以它不依赖、也不等待环境探测结果。源码依据
src/entrypoints/cli.tsx:190–199:uid 0 检查(同步,先执行)。src/entrypoints/cli.tsx:202–212:Promise.all([getIsDocker(), hasInternetAccess()]) 后,!isDocker || hasInternet 即 exit(1)。src/utils/env.ts:16–23:getIsDocker(/.dockerenv + linux)。src/utils/env.ts:25–40:hasInternetAccess(:28 一秒 AbortController 超时,:30 http://1.1.1.1 HEAD)。src/entrypoints/cli.tsx:354:option 描述自述 Will crash otherwise.——失败是退出,不降级。state.ts 里只有 originalCwd 一个值。真正的工作目录存在哪?这种「薄状态」设计和 cli.tsx 的 1043 行单文件矛盾吗?答案要点
PersistentShell 单例)里:getCwd() 去问它(state.ts:23–25 返回 PersistentShell.getInstance().pwd()),setCwd() 也是改它(:11–13)。内存全局状态里只冻了一件事:进程从哪个目录启动(originalCwd,:5–9,第 4 行是 Boris 的诅咒注释)。源码依据
src/utils/state.ts 全文 25 行::4 Boris 注释,:5–9 STATE 仅 originalCwd 一键,:11–13 setCwd 转调持久 shell,:23–25 getCwd 返回 PersistentShell.getInstance().pwd()。src/entrypoints/cli.tsx 全文 1043 行(第 1 行 shebang)。getSystemPrompt() 返回的三段各是什么?恶意软件禁令出现两次,位置分别在哪,为什么这样安排?答案要点
getSystemPrompt() 返回 string[],prompts.ts:16–127):<env> 环境块(:123):现算的工作目录、是否 git 仓库、平台、日期、模型名(getEnvInfo(),:129–142)。源码依据
src/constants/prompts.ts:16–127:三段结构(:18–122 / :123 / :124–125)。src/constants/prompts.ts:20–21、:124–125:禁令两处原文逐字相同。src/constants/prompts.ts:129–142:getEnvInfo()。src/services/claude.ts:394–400:splitSysPromptPrefix 把第一段单独留下、其余 join('\n')——出站时通常只剩两个 system block。addCacheBreakpoints 只标最后两条,共同服务的目标是什么?答案要点
getContext 被 lodash memoize 包住(context.ts:157),整场会话只算一次;包装句还把这件事写给模型看——directoryStructure 明说 "This snapshot will NOT update during the conversation"(:220),gitStatus 同样写了 "will not update during the conversation"(:147)。addCacheBreakpoints(claude.ts:642–650)只给消息列表最后两条打 cache_control 断点(判断式 index > messages.length - 3,:647–648)——缓存边界跟着对话尾部往前滚,历史部分作为稳定前缀复用。两件事是同一个设计:「静态的系统提示与快照」保住前缀,「只标最后两条」保住增量。/compact 和 /clear 会清掉 memoize 缓存(compact.ts:84–85、clear.ts:17–18),下一轮重新爬。源码依据
src/context.ts:157(memoize)、:220、:147(不更新声明)。src/services/claude.ts:642–650:addCacheBreakpoints;:472–480 两个 system block 各打一遍 cache_control: { type: 'ephemeral' };:45 DISABLE_PROMPT_CACHING 总闸。src/commands/compact.ts:84–85、src/commands/clear.ts:17–18:清 getContext / getCodeStyle 缓存。countTokens 的数从哪来?如果会话里只有开头一条真实 assistant 响应、之后全是合成打断消息,TokenWarning 读到的数会停在哪?答案要点
countTokens(tokens.ts:4–27)不跑本地 tokenizer:从消息列表尾部往前找最近一条带 usage 的非合成 assistant 消息,把 input_tokens、cache_creation_input_tokens、cache_read_input_tokens、output_tokens 四项相加(:17–22)。usage 是 API 每次响应白送的。SYNTHETIC_ASSISTANT_MESSAGES 集合(INTERRUPT / CANCEL / REJECT 等写死文案,messages.tsx:36–43、45 起)的消息不算。compact.ts:68–73 的小机关:/compact 把摘要消息的 usage 改写成接近 0 的数,警告立刻消下去。源码依据
src/utils/tokens.ts:4–27:倒序扫描与四项相加(:17–22)、合成消息跳过(:11–14)。src/utils/messages.tsx:36–43:四句写死的合成语;:45 起 SYNTHETIC_ASSISTANT_MESSAGES 集合。src/components/PromptInput.tsx:448:<TokenWarning tokenUsage={countTokens(messages)} />。src/commands/compact.ts:64–73:压缩后改写 usage 让警告归零。getClaudeFiles 的 glob 找不到哪份 CLAUDE.md?那份文件实际经哪条路径进系统提示?答案要点
**/*/CLAUDE.md(context.ts:29),模式里至少要求一层子目录,cwd 顶层的文件匹配不到。getClaudeFiles 只负责收集子目录里的 CLAUDE.md,且只列路径不读内容(:38–41 拼成 NOTE 文本)。getCodeStyle()(style.ts:9–28)从 cwd 向文件系统根逐层爬,沿途每份 CLAUDE.md 全文读入,父级在前(:27 reverse())拼进 codeStyle 键。两边不重复:根文件不会被 glob 漏进系统提示两次。formatSystemPromptWithContext(claude.ts:426–441)把 codeStyle 等键包成 <context name="键名">值</context>(:438)挂在系统提示尾部。源码依据
src/context.ts:24–48:getClaudeFiles(:26 三秒 abort,:29 glob,:38–41 只列路径)。src/utils/style.ts:9–28:getCodeStyle(:13–21 向根爬并全文读入,:27 父级在前)。src/services/claude.ts:426–441:<context name="..."> 拼装。MAIN_QUERY_TEMPERATURE 为什么是 1?注释里的 binary feedback 指什么场景?答案要点
MAIN_QUERY_TEMPERATURE = 1(claude.ts:58),行内注释 "to get more variation for binary feedback"。温度拉满让两次采样更容易长得不一样。USER_TYPE === 'ant')的左右对照评审:同一个请求采样两个候选回复 m1 / m2,并排展示给人挑一个(query.ts:90–93 并行取两条,getBinaryFeedbackResponse 把选择挂成 UI)。温度 1 保证两个候选有足够差异,评审才有区分度——这不是产品质量设置,是研究仪器设置:采样更散,偏好数据才更有效。query.ts:79–89 非 ant 直接单发);thinking 预算同样只有 ant 才发给 API(claude.ts:529)。源码依据
src/services/claude.ts:58:常量与注释。src/query.ts:71–109:queryWithBinaryFeedback(:80 ant 门,:90–93 双采样)。src/services/claude.ts:529:USER_TYPE === 'ant' 才带 thinking。stop_reason?它改看什么地方?答案要点
query.ts:170 注释明说 stop_reason === 'tool_use' is unreliable -- it's not always set correctly(:169 是该注释块的 @see 链接行),messages.tsx:605 把同一句又写了一遍。query.ts:171–173 过滤 assistantMessage.message.content 里 type === 'tool_use' 的块;一个都没有(:176–178)就 return,这一轮(以及整条递归)结束;有就执行工具、拼 tool_result、递归下一轮。判据是块,不是信封。stop_reason 只是传输层的元信息,两处注释说明作者被它坑过。源码依据
src/query.ts:169–178:注释 + filter + 无 tool_use 即 return(:176–177)。src/utils/messages.tsx:599–608:isToolUseRequestMessage 同样看 content(:605 同句注释、:606 some(_ => _.type === 'tool_use'))。query.ts 哪几行?答案要点
tool_use 全部 isReadOnly() 为真(query.ts:184–188 的 every);Bash 的 isReadOnly() 返回 false(BashTool.tsx:72–74),一个写工具就把整批打成串行,走 runToolsSerially(:202–215)。四个工具按顺序逐个过校验、权限、执行。query.ts:184–216(:184 起 if (... every(isReadOnly())),:189 runToolsConcurrently,:202 else,块收于 :216);并发上限写在 query.ts:68 的 MAX_TOOL_USE_CONCURRENCY = 10。旁边 :182–183 的 TODO 承认「可以更激进」但没做。isReadOnly() 恒 false(任何一个 MCP 调用都会拖住整批)。保守调度让循环保持薄。(isReadOnly 是 Tool 接口字段,src/Tool.ts 缺失,接口形状为反推。)源码依据
src/query.ts:68、:182–216。src/tools/BashTool/BashTool.tsx:72–74:Bash 非只读。src/tools/GrepTool/GrepTool.tsx:53–55:对照,读类工具 isReadOnly() 返回 true。!ls -la 和 /clear,这两次输入会进 query 吗?REPL 依据什么决定不再调用 query?答案要点
!ls -la 走 processUserInput 的 bash 分支(messages.tsx:168–233):先 BashTool.validateInput,再 lastX(BashTool.call(...)) 本地执行(:210–216),返回的是一对 user + assistant 消息(stdout/stderr 包成 <bash-stdout> 文本);模型全程不在场。/clear 走斜杠分支(:236 起),它是 type: 'local' 的本地命令(clear.ts:22–35),就地清屏清消息清缓存,同样不碰模型。processUserInput 返回消息列表的末条是不是 assistant。REPL.tsx:321–325:末条 type === 'assistant' 就 setIsLoading(false) 直接返回,不调 query——本地路径已经产出了「回答」,循环无事可做。只有普通句子包成一条 user 消息(末条是 user)时才进 query。源码依据
src/utils/messages.tsx:156–233(bash 分支,:174–194 cd 特例,:210–216 validate + 调用,:217–222 返回 user+assistant 对)、:236 起(斜杠分支)。src/screens/REPL.tsx:321–325:末条 assistant → 不调 query。src/commands/clear.ts:22–35:/clear 是本地命令。maxRetries 被设成了几?既然 SDK 自带重试,为什么还要手写 withRetry?手写版本尊重服务器的哪个响应头?答案要点
maxRetries: 0(claude.ts:249),行内注释 "Disabled auto-retry in favor of manual implementation"——SDK 自动重试被整体关掉。API <错误> · Retrying in Ns… (attempt n/m)(:145–147),Statsig 能记 tengu_api_retry 事件带上 attempt、延迟、状态码、provider(:149–155)。重试判据也收归己有:shouldRetry(:86–118)只对 408 / 409 / 429 / 5xx / 连接错误重试,overloaded_error 只对 SWE_BENCH 重试(:88–90)。上限 MAX_RETRIES(:66)普通用户 10 次、SWE_BENCH 100 次。retry-after(:142 取出后按秒换算成延迟);没有该头则 500ms 起步指数退避、封顶 32 秒(:83)。另一个非标准头 x-should-retry 也会被服从(:93–97,显式 true/false 直接决定重不重试)。源码依据
src/services/claude.ts:249、:66、:86–118、:120–162(:142 retry-after,:83 退避公式,:145–155 终端输出与埋点)。querySonnet 会把异常抛给 query 吗?循环实际「看见」的是什么?答案要点
querySonnet 的外层 catch(claude.ts:543–558)记日志、记 tengu_api_error 埋点之后,return 一条普通的 assistant 消息——由 getAssistantMessageFromError(:618–640)把异常翻译成写死文案:prompt 太长、余额不足、Key 无效各有专句,其余套 API Error: 前缀。type: 'assistant'、content 里没 tool_use 的普通文本消息。按 3.1 的判据,没有 tool_use 块 → query.ts:176–177 直接 return,循环平摊地收尾。失败也走同一条路:循环不需要处理半打异常类型,错误被收敛成「模型说了一句人话」。isApiErrorMessage 标记,UI 按错误样式渲染;messages.tsx 的四句合成语(:36–43)同理——取消、拒绝、打断也都是收成消息喂回对话,而不是抛栈。源码依据
src/services/claude.ts:543–558(catch 返回消息,:558)、:618–640(错误文案映射)。src/query.ts:176–177:无 tool_use 即结束。src/utils/messages.tsx:36–43:四句写死的合成语。src/Tool.ts 缺失的情况下,我们仍然能描述 Tool 接口的形状?依据是什么,表述上必须加什么限定?答案要点
satisfies Tool<...>(本附录复核:15 处,每文件恰好一次),加上 40 余个文件对 Tool 的 import 与调用点(严口径复核 43 个文件 import 路径以 /Tool.js 结尾;verification.md 宽口径记 53 个,多出的命中的是 BashTool.js 等工具文件路径)——字段名、可选性、调用方式从两侧交叉印证,形状可以重建。satisfies 是「检查但不改变类型」的标注:对象必须满足 Tool 的形状否则编译报错,所以每个工具文件本身就是一份带校验的接口证据。query.ts 的调用点(safeParse / validateInput? / call / isReadOnly / needsPermissions)给出另一面。satisfies Tool 实现与全部调用点反推的重建,不是对原始定义的直读。同批缺失文件(MemoryReadTool/prompt.ts、MemoryWriteTool/prompt.ts、utils/conversationRecovery.ts)同理:两个记忆工具的文案不作转述,conversationRecovery 的导出名(loadMessagesFromLog、deserializeMessages)只能从调用点反推。源码依据
src/tools/GrepTool/GrepTool.tsx:144:satisfies Tool<Input, Output> 示例(15 处之一)。src/entrypoints/mcp.ts:24、src/tools.ts:1:import { Tool } from '.../Tool.js'——指向缺失文件的引用。src/entrypoints/cli.tsx:52、src/screens/ResumeConversation.tsx:4:conversationRecovery 的两个反推导出名。verification.md 第四节:缺失文件清单与反推依据。checkPermissionsAndCallTool 里 Zod 校验和 validateInput 各拦什么?为什么两道都要把失败包成 is_error: true 的 tool_result,而不是抛异常?答案要点
query.ts:377 的 tool.inputSchema.safeParse(input)):类型错、缺字段、多字段(schema 全是 z.strictObject,如 GrepTool.tsx:17、FileEditTool.tsx:27–31)都算非法。旁边注释坦白:「the model is not great at generating valid input」(:375–376)。validateInput 拦业务语义(query.ts:399):形状对了还不够,每个工具有自己的规矩——Edit 的未读不许改、Bash 的 BANNED_COMMANDS,都在这一层。is_error: true 的 tool_result 把 Zod 错误文案或校验文案作为下一轮对话的输入喂回去(:385–392、:411–418),循环不崩、对话继续——「失败文案本身就是给下一轮模型的指令」。抛异常则把模型的可纠正错误升格成程序的崩溃路径,循环还得额外长出一套异常分类逻辑。0.2.8 全篇同此设计:权限拒绝(:427–437)、call() 异常(:477–493)也都收进同一形状的 tool_result。源码依据
src/query.ts:365 起函数本体;:375–392(Zod 拦截与回执);:398–420(validateInput 拦截与回执);:424–426(权限);:441(call)。src/tools/GrepTool/GrepTool.tsx:17、src/tools/FileEditTool/FileEditTool.tsx:27–31:z.strictObject。答案要点
readFileTimestamps——「本会话每个绝对路径上次被读的时间」的映射,挂在 toolUseContext 上随会话生灭。写入端是 View:FileReadTool.tsx:181 读成功时 readFileTimestamps[fullFilePath] = Date.now()。FileEditTool.tsx 的 validateInput):statSync 的 mtimeMs > readTimestamp 说明读后被人或 linter 动过,拒,要求再读一遍。源码依据
src/tools/FileReadTool/FileReadTool.tsx:181。src/tools/FileEditTool/FileEditTool.tsx:170–180、:183–191、:237–238(:237 注释 "Update read timestamp, to invalidate stale writes")。答案要点
GrepTool/prompt.ts:5),实现却在 call 里写死 const args = ['-li', pattern](GrepTool.tsx:107)——-l 只打印文件名,-i 忽略大小写。模型拿不到行号和匹配行,只拿到按 mtime 降序的文件路径,上限 100 条(:35 MAX_RESULTS)。grep -r 一次能吐几万行,只回文件名就灌不爆上下文;mtime 排序把「最近改过的文件」排前,命中概率更高。prompt 与实现的分叉也提醒读源码的人:写给模型看的说明书不等于实现。(后来版本让 Grep 返回 path:line:text,材料来自官方文档,不在本仓库。)源码依据
src/tools/GrepTool/prompt.ts:5(自称搜内容)、:8(自述按 mtime 返回文件路径)。src/tools/GrepTool/GrepTool.tsx:103–143(call)、:107(-li)、:35(100 条上限)、:114–130(mtime 降序)。ArchitectTool 的 isEnabled() 写死 false,与 CLI flag / 项目配置的关系是什么?这说明了「门控」在契约里的什么位置?答案要点
--enable-architect 或项目配置 enableArchitectTool 能把 ArchitectTool push 进候选工具清单(tools.ts:46–48,getTools 的唯一入口参数 enableArchitect 来自 cli.tsx:383–386 的 flag ?? 配置);但随后 tools.ts:50–51 对每个工具调 isEnabled() 过滤——ArchitectTool.tsx:51–53 写死 return false,于是它又被摘掉。flag 和配置能把它送进清单,却送不进注册表。isEnabled 是 Tool 接口字段,接口文件缺失,此处为反推。)源码依据
src/tools/ArchitectTool/ArchitectTool.tsx:51–53:isEnabled() 写死 false。src/tools.ts:41–53:getTools(:46–48 flag/config push,:50–51 isEnabled 过滤)。src/entrypoints/cli.tsx:346、:383–386:flag 定义与取值(flag 优先,缺省读项目配置)。SAFE_COMMANDS 为什么只有八条、而且必须整句匹配?git status --porcelain 能过吗,为什么?答案要点
permissions.ts:18–27 的 Set):git status / git diff / git log / git branch / pwd / tree / date / which。是 Set.has(command) 的整句相等(:34),不是前缀匹配。git status --porcelain 不能过:整句比较下它不等于 git status;which rm 同理不过。cat、ls、echo 一旦允许带参数就能读密钥、覆盖文件、拼管道;「看起来只读」的命令带参数后几乎全部变成任意读写。宁可再问一次人,也不把猜测写进自动放行。短名单是诚实的长度:它精确表达了「无沙箱时敢免问的只有这八句」。(后来的版本名单放宽是因为有了 OS 沙箱,材料来自官方文档,不在本仓库。)源码依据
src/permissions.ts:18–27:八条原文。src/permissions.ts:34:SAFE_COMMANDS.has(command) 整句命中。hasPermissionsToUseTool 默认拒绝。从 permissions.ts:154–222 数出所有能让它返回 { result: true } 的路径,并说明各自的作用范围。答案要点
按检查顺序,放行路径共五条(随后才是 Bash 与默认分支的细分):
:161–163 dangerouslySkipPermissions 为真 → 全放行。作用范围:整个进程、所有工具(过了入口三道门的无人值守模式)。:171–173 tool.needsPermissions(input) 为 false → 放行。作用范围:这一次输入。读类工具在已授读权的目录内、文件写工具在已授写权的目录内,都从这里过(文件写授权在内存里,filesystem.ts:6–7、:38、:55 的 startsWith 前缀匹配)。注意 :174–177:这一步抛异常时失败关闭(返回 false),不是放行。:182–184 项目配置 allowedTools 含 "Bash" → Bash 整工具 blanket 放行。作用范围:该项目所有 bash 命令;:181 注释明说不在 UI 暴露。:189–193 Bash 细分(bashToolHasPermission):SAFE 八条整句(:34)→ 本句放行;配置里的整句 key Bash(git status)(:38、:42 的精确匹配)→ 本句放行;配置里的前缀 key Bash(git commit:*)(:58)→ 该前缀的一类命令放行。两个失败关闭分支:前缀分类查询失败(:88–95)直接拒;commandInjectionDetected 时(:97–107)只接受整句精确命中。:210–220 其余工具:getPermissionKey 拼出的 key 命中项目配置 allowedTools(:211–213)→ 放行。作用范围:该项目下该工具(key 即工具名,如 mcp__foo__bar,:265)。全部不过,返回 { result: false, message } 交给弹窗。(needsPermissions 是 Tool 接口字段,接口文件缺失,此处为反推。)
源码依据
src/permissions.ts:154–222(各分支行号如上)、:29–46、:48–59、:61–120(Bash 细分与失败关闭)。src/permissions.ts:253–266:key 的三种粒度。useCanUseTool.ts 里哪几行代码保证的?答案要点
{ result: false, message: REJECT_MESSAGE },同时 abortController.abort() 触发全局中止信号——串行队列里后续每个 tool_use 在执行前都会查到 aborted 而改道(query.ts:318–328 返回中止回执),本轮结束后 query.ts:218–221 再补一条 INTERRUPT 合成消息并 return,循环停下等人「告诉 Claude 换种做法」。被拒绝的命令不会执行,拒绝原因收成 is_error 的 tool_result 发回模型(query.ts:427–437)——模型只看到「未获授权」,看不到 UI。useCanUseTool.ts:35–44 的 resolveWithCancelledAndAbortAllToolCalls(:36–39 resolve REJECT_MESSAGE,:43 toolUseContext.abortController.abort()),以及 onReject 回调 :115–121 对它的调用。源码依据
src/hooks/useCanUseTool.ts:35–44、:115–121。src/query.ts:318–328(执行前查 aborted)、:218–221(本轮收尾)、:427–437(拒绝回执)。src/utils/messages.tsx:41–43:REJECT_MESSAGE 原文。--dangerously-skip-permissions 的环境检查失败时为什么 process.exit(1),而不是退回「继续问人」的模式?答案要点
cli.tsx:354)把崩溃写成了承诺。源码依据
src/entrypoints/cli.tsx:187–213:三道门,:198 与 :211 两处 process.exit(1)。src/entrypoints/cli.tsx:352–356:option 定义与 "Will crash otherwise." 描述。allowedTools: ["Bash"] 会发生什么?0.2.8 为什么不在 UI 里提供这个选项?答案要点
hasPermissionsToUseTool 在 permissions.ts:182–184 特判——tool === BashTool && allowedTools.includes(BashTool.name) 即 { result: true }。这个项目里所有 bash 命令不再有任何弹窗(SAFE 八条、前缀记忆、Bash 细分全部被这条 blanket 放行短路);配置按项目绝对路径分桶(config.ts:337、:345、:370),所以作用范围限于手写它的那个项目。BashTool.needsPermissions() 恒 true(BashTool.tsx:82–85)在这层被跳过。源码依据
src/permissions.ts:179–184(blanket 分支与注释)。src/utils/config.ts:332–375(:337、:345 按 resolve(getCwd()) 分桶,:370 写回同桶)。src/tools/BashTool/BashTool.tsx:82–85。答案要点
prompts.ts:44 原文自带理由 "displayed on a command line interface"。prompts.ts:44(块首)和 :121(块尾)各压一遍,中间夹一词即答的极端 few-shot(:46–47 2 + 2 → 4、:56–57、:61–62)。源码依据
src/constants/prompts.ts:44、:121、:40、:45–86(few-shot 组)。答案要点
MAX_TOKENS = 190_000,TokenWarning.tsx:9):WARNING_THRESHOLD = MAX_TOKENS * 0.6,:10;:16–18 低于阈值 return null);ERROR_THRESHOLD = MAX_TOKENS * 0.8,:11;红黄判断在 :20);Context low (N% remaining) · Run /compact to compact & continue(:24–27),挂在输入框页脚每帧现算(PromptInput.tsx:448)。/compact 压缩本身要再发一次请求,得给这次请求留出 token 空间;警戒线画在 190k,压缩才有地方落脚。警告是常驻小字不是打断,还把解决方案写在同一行。源码依据
src/components/TokenWarning.tsx:9–11、:16–18、:20、:24–27(全文 31 行)。src/components/PromptInput.tsx:448。答案要点
claude.ts:516,anthropic.beta.messages.stream(...)——HTTP 层确实是流式,token 逐个到达。claude.ts:206–224 的 handleMessageStream——:213–217 的 for await (const part of stream) 把流整个消费了一遍,却只在 message_start 时记了一下首 token 时间(ttft),中间到达的文本块全部丢弃;:219 await stream.finalMessage() 拿攒完的整条消息返回。所以 0.2.8 的流式是消息级,不是 token 级。claude.ts:212,// TODO(ben): Consider showing an incremental progress indicator.)承认:增量渲染是被权衡掉的已知缺口,不是 bug——作者知道该做进度指示,选择先不做,等待的空白用 Spinner 和幽默动词填。(token 级打字机是后来版本补的,材料来自官方文档,不在本仓库。)源码依据
src/services/claude.ts:516、:206–224(:212 TODO、:213–217 丢弃中间块、:219 finalMessage)。!isLoading 时显示」这条时序约束。答案要点
REPL.tsx:133–136):showCostDialog(本次会话内是否该弹)与 haveShownCostDialog(这辈子是否已确认过,初值从全局配置读)。一个配置字段:hasAcknowledgedCostThreshold(定义在 config.ts:113,存在 ~/.claude.json)。messages 变化后 REPL.tsx:193–199 检查 totalCost >= 5 /* $5 */ && !showCostDialog && !haveShownCostDialog,满足才 setShowCostDialog(true);点掉弹窗时 onDone(:592–603)把两个 state 都翻过去,并把 hasAcknowledgedCostThreshold: true 写进全局配置——下次启动 haveShownCostDialog 直接从 true 初始化,确认过的人这辈子不再见。弹窗本体(CostThresholdDialog.tsx,46 行)连 Ctrl-C / Ctrl-D / Esc 都等同确认(:13–17)——它不需要决定,只需要「知道了」。REPL.tsx:538–539 的 showingCostDialog = !isLoading && showCostDialog——循环还在跑就不抢屏。花钱提醒再重要,也不插在工具执行中间;等循环停下来再弹。源码依据
src/screens/REPL.tsx:133–136、:193–199、:538–539、:587–604。src/utils/config.ts:113。src/components/CostThresholdDialog.tsx:11–46(:28 文案)。No,最终对 query() 循环的效果有何异同?(提示:对比 onAbort 与 onReject 的函数体。)答案要点
useCanUseTool.ts:35–44 的 resolveWithCancelledAndAbortAllToolCalls——Promise resolve({ result: false, message: REJECT_MESSAGE })(:36–39)+ abortController.abort()(:43)。当前工具拿到拒绝回执(query.ts:427–437 收成 is_error tool_result),同批排队工具一起中止,循环停下等人换说法。No 是弹窗自己的 Select 触发的(如 BashPermissionRequest.tsx:106–114 调 toolUseConfirm.onReject()),onReject(useCanUseTool.ts:115–121)只记 tengu_tool_use_rejected_in_prompt;按 Esc 先过 useCancelRequest(useCancelRequest.ts:16–38,:33–37 记 tengu_cancel 并清掉所有模态 state),再调 REPL 的 onCancel,后者发现 toolUseConfirm 还挂着而改走 toolUseConfirm.onAbort()(REPL.tsx:162–173),onAbort(useCanUseTool.ts:93–100)多记一个 tengu_tool_use_cancelled(:94,经 :28–33 的 logCancelledEvent)再汇入同一函数。另外 Ctrl-C 是弹窗组件自己接的:PermissionRequest.tsx:79–84 先 onDone() 再 onReject()——等同选 No。ToolUseConfirm 类型引用了缺失的 src/Tool.ts,其工具字段形状为反推。)源码依据
src/hooks/useCanUseTool.ts:35–44、:93–100、:115–121、:86。src/hooks/useCancelRequest.ts:16–38;src/screens/REPL.tsx:162–173。src/components/permissions/PermissionRequest.tsx:79–84;src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx:106–114。SAFE_COMMANDS 只有八条整句,后来的版本敢把只读名单放宽。用「地板」的概念解释:变的是名单本身,还是名单下面的东西?答案要点
源码依据
src/permissions.ts:18–27:0.2.8 的八条整句(本仓库可见的事实)。答案要点
三问过一遍:
/compact 就是这么活的:不动 query.ts,独立调一次 querySonnet(compact.ts:34–45),换一句「你是摘要助手」的系统提示(:36),拿到摘要后清屏清消息(:75–77),把摘要连同一条说明塞回消息列表(:78–83),再清掉 context 缓存(:84–85)。自动压缩只是把触发者从人换成阈值,动作本身不变——循环签名原样。/clear 重开——不需要操作系统级兜底,不是地板。/compact(src/commands/compact.ts,全文 94 行);配套仪器 TokenWarning.tsx:9 的 190k 警戒线注释 "leave wiggle room for /compact" 证明压缩在 0.2.8 是人的动作——警戒线为人留出动手余量。(自动压缩的实现细节:材料来自官方文档,不在本仓库。)源码依据
src/commands/compact.ts:30–45、:64–83、:84–85。src/components/TokenWarning.tsx:9。答案要点
src/Tool.ts、两个 Memory prompt.ts、utils/conversationRecovery.ts),不能原样编译;2.1.88 的材料是另一来源的泄露。引用时混在一起,两边的事实会互相污染。源码依据
verification.md 第五节):缺失文件清单与「未在本机运行 0.2.8」的声明。query.ts 仍然是 516 行递归生成器?能证明的和不能证明的各是什么?答案要点
while 加多处 continue」这类说法在本课程里一律标注为公开泄露分析的转述——本仓库既不能证实也不能证伪。query.ts 是一个 516 行文件,export async function* query( 从 :124 开始,收尾是 :234–241 的 yield* await query(...) 递归;没有 while (true),没有递归上限;「这一版押的骨架形状」长什么样。以及产品文档层面的连续性:现行产品仍跑工具调用循环、仍做运行时权限、仍读 CLAUDE.md(材料来自官方文档)。query.ts 还是这份 516 行递归生成器。能下的结论只有:0.2.8 押的骨架形状,今天看仍然成立。源码依据
src/query.ts 全文 516 行;:124(生成器签名)、:234–241(递归收尾)、:176–177(无 tool_use 即 return)。答案要点
抛异常把「模型能纠正的错误」升格成「程序的控制流」,循环要付出四类代价:
call() 异常,五处全部收成同一形状的 tool_result(query.ts:385–493,成功的 :449–455 带内容,失败的带 is_error: true)。runToolsConcurrently)里一个工具抛异常,要么拖垮整批,要么要为每个工具包一层隔离——现在这层隔离天然存在,因为每个工具的结果本来就是一条消息。Esc 取消、弹窗拒绝也复用同一通道(REJECT_MESSAGE),异常路径会让「取消」和「失败」分叉成两套机制。claude.ts:543–558 → getAssistantMessageFromError :618–640),循环全程只处理一种数据。改成抛异常,这条「失败即消息」的统一性就破了——循环要同时懂消息流和异常流两种语言。源码依据
src/query.ts:385–493:五处 tool_result 构造点(:385 Zod、:411 validateInput、:428 权限、:449 成功、:486 异常)。src/services/claude.ts:543–558、:618–640:API 错误收成 assistant 消息。src/utils/messages.tsx:36–43:取消/拒绝/打断四句合成语。src/,仓库见书名页)。state.ts 25 行(Boris 注释 :4)、shims import 在 cli.tsx:9(Object.keys 在 :10,:6–8 为 XXX: 注释)、stop_reason 注释在 query.ts:170、wizard 注释块 query.ts:111–123。satisfies Tool 复核为 15 处(15 个文件各一次);import 缺失 Tool.js 的文件复核为 43 个(严口径),与 verification.md 宽口径 53 个的关系见本附录卷首的口径说明。src/Tool.ts、MemoryReadTool/prompt.ts、MemoryWriteTool/prompt.ts、utils/conversationRecovery.ts 的论述均已带「反推」限定;两个记忆工具的文案不作转述。第 1–7 章的练习最终要搭出一个约 200 行的小 Agent。本附录收它的参考实现:纯 Node.js(>=18)ESM,零 npm 依赖,不调真实 LLM——假模型把多轮 tool_use 响应脚本化(第 3 章验收原话如此)。代码不进本书,完整文件随白皮书套装分发:在套装目录的 mini-agent/ 下,cd 进去直接 node 跑(六个里程碑文件与 fixtures 齐全,本附录的实测记录就是在那里产生的)。实测环境:macOS,node v24.15.0,python3 3.14.6(m7 fixture 是 Python 文件)。
结构说明:章节练习里 m3→m4→m5→m7 是在同一份骨架上逐关演化(课程用里程碑 tag 标记);参考实现把每个里程碑冻结成独立可跑的文件。diff agent.mjs mini-agent.mjs 即可看到第 5、7 章各加了什么肉。第 6 章是只读关,无代码里程碑。
| 文件 | 行数 | 里程碑(关卡) | 说明 |
|---|---|---|---|
cli.mjs |
29(非空行 25,预算 ≤30) | m1-cli(第 1 关) | 双路输入 CLI:argv / 管道拼接 / 裸跑交互 |
context.mjs |
59(预算 ≤60) | m2-context(第 2 关) | 0.2.8 风格首包生成器:三段式系统提示 + 五键 context + cache 断点 |
loop.mjs |
77(预算 ≤90) | m3-loop(第 3 关) | tool_use 递归生成器骨架 + 假模型 + 两个教学工具 |
agent.mjs |
141(预算 ~140) | m4-tools(第 4 关) | 骨架 + Read/Edit/Bash 三工具 + 手写迷你 Zod 校验 |
smoke-m4.mjs |
54(不计预算) | m4 冒烟脚本 | 逐条模拟模型输入并断言第 4 关四条验收标准 |
mini-agent.mjs |
210(预算 ≤220) | m5-permissions(第 5 关)+ m7-final(第 7 关) | 结业版:m4 全部 + canUseBash 权限闸门 + 结业流程 |
fixtures/calc.py |
6 | m7 fixture | 含 bug 的计算器(add 写成了减法),初始测试必红 |
fixtures/test_calc.py |
24 | m7 fixture | unittest,自带 sys.path 修正,与调用目录无关 |
cd mini-agent/
# m1:双路输入
echo "hi" | node cli.mjs # [got] hi
node cli.mjs "hi" # [got] hi
node cli.mjs # 交互:回显 [prompt] ...,exit 退出
# m2:首包生成器(请在一个 git 项目根目录里跑,gitStatus 才非空)
cd /path/to/any-git-repo && node /path/to/mini-agent/context.mjs
# m3:循环骨架
node loop.mjs # 两轮工具调用后纯文本收工
LOOP_DEMO_BAD_TOOL=1 node loop.mjs # 演示未知工具 → is_error tool_result,不崩
# m4:三工具 + schema 校验
node agent.mjs # 临时文件上演示 跑命令→读→改→看
node smoke-m4.mjs # 冒烟:5 passed, 0 failed 为全过
# m5/m7:结业版(在 mini-agent/ 目录下跑,fixture 是相对路径)
echo "修复 calc.py 让测试通过" | MINI_AGENT_ANSWERS=n,y,y node mini-agent.mjs
# 或 argv 形式:MINI_AGENT_ANSWERS=n,y,y node mini-agent.mjs "修复 calc.py 让测试通过"
三个环境变量(都是假模型/管道模式的替身,非原作概念):
MINI_AGENT_ANSWERS=n,y,y:管道模式(无 TTY)下人工确认的预置答案磁带,依序消费;磁带耗尽且非交互 → 默认拒绝(安全侧);交互模式(stdin 是 TTY)弹真实一行确认。MINI_AGENT_SCRIPT=/path/steps.json:把结业假模型换成自定义脚本([{"name":"run_command","input":{"command":"git status"}},{"text":"完。"}]),m5 验收就是靠它驱动的。LOOP_DEMO_BAD_TOOL=1:m3 的「假模型请求不存在工具名」开关。注意:结业跑会真实改写 fixtures/calc.py(这正是练习目的)。重跑前先恢复初始 bug 状态:
printf 'def add(a, b):\n return a - b\n\n\ndef sub(a, b):\n return a - b\n' > fixtures/calc.py
rm -rf fixtures/__pycache__
以下每条都在本机真实跑过(六个 .mjs 的 node --check 全部通过,从略)。m1–m5 只留关键输出,m7 结业跑保留全量日志。
$ echo "hi" | node cli.mjs
[got] hi # exit=0
$ node cli.mjs "hi"
[got] hi # exit=0
$ echo "a" | node cli.mjs "b"
[got] b
a # argv 在前、stdin 在后,换行拼接;exit=0
$ node cli.mjs # pty 实测:输入 hello world
[prompt] hello world # 回显正常;输入 exit → 退出码 0
输出里能数出三段系统提示(人格段含两组一词即答 few-shot;<env> 块含 cwd/isGit/date;政策句收尾)和五个 <context name> 块,gitStatus 非空(main + 未跟踪文件 + log -5)。memoize 生效有硬证据:
[memoize] 第一次 getContext 文件系统读取 6 次;第二次新增 0 次
[cache] [{"role":"user"},{"role":"assistant","cache":true},{"role":"user","cache":true}]
$ node loop.mjs # exit=0
assistant| tool_use read_file({"path":"loop.mjs"})
user | tool_result: #!/usr/bin/env node
assistant| tool_use list_dir({})
user | tool_result: cli.mjs
assistant| 两轮工具调用完成,收工。
[done] fakeModel 被调用 3 次 # 恰好 3 次,纯文本收尾
$ LOOP_DEMO_BAD_TOOL=1 node loop.mjs # exit=0
user | tool_result [is_error]: Error: unknown tool "no_such_tool"
assistant| tool_use list_dir({}) # 不崩,回 is_error 后继续
递归生成器形态:query 内无 while (true),下一轮是 yield* query([...messages, asst, ...results])。
$ node agent.mjs # echo hello → 读 /tmp/m4-demo.txt → 1-1 改 1+1 → cat 验证,exit=0
$ node smoke-m4.mjs # exit=0
PASS 循环不抛,模型收到 is_error 的 tool_result(Zod 迷你版拦下) # 少传 new_string + 多传 bogus
PASS 被拒且回执含 "has not been read yet" # 未读先改
PASS 多处匹配被拒并提示补充上下文 # old_string 出现 2 次
PASS 唯一匹配落盘成功 # 先读后改
PASS hello 作为 tool_result 回到对话 # run_command echo
5 passed, 0 failed
canUseBash,用 MINI_AGENT_SCRIPT 驱动实测)git status 全程无 [confirm],直接出结果。n → 未执行,/tmp/m5-test 不存在,拒绝原因以 is_error tool_result 回到对话;同命令预置 y → 执行,文件已创建。a → 项目目录 mini-agent.json 写入 {"allowedCommands":["npm:*"]};重开进程跑 npm run build(同首词前缀)无 [confirm] 静默通过。结业命令全量日志(fixture 恢复初始 bug 后):
$ echo "修复 calc.py 让测试通过" | MINI_AGENT_ANSWERS=n,y,y node mini-agent.mjs # exit=0
assistant| tool_use edit_file({"path":"fixtures/calc.py","old_string":"return a - b",...})
user | tool_result [is_error]: File has not been read yet. ... # ② 未读先改被拒,循环继续
assistant| tool_use read_file({"path":"fixtures/calc.py"})
user | tool_result: def add(a, b): ⏎ return a - b ⏎ ... # Read 在成功的 Edit 之前
assistant| tool_use edit_file({... "old_string":"return a - b" ...})
user | tool_result [is_error]: Found 2 matches of the string ... # ③ 多处匹配被拒
assistant| tool_use edit_file({... "def add(a, b):\n return a - b" → "a * b"})
user | tool_result: File updated. # 补上下文后唯一匹配落盘(但改错方向)
assistant| tool_use run_command({"command":"python3 -B fixtures/test_calc.py"})
[confirm] "python3 -B fixtures/test_calc.py" -> n(预置答案) # ④ 权限在场:故意拒绝一次
user | tool_result [is_error]: 用户拒绝了这条命令,没有执行:...
assistant| tool_use run_command({"command":"python3 -B fixtures/test_calc.py"})
[confirm] "python3 -B fixtures/test_calc.py" -> y(预置答案)
user | tool_result: Exit code 1 ⏎ F. ⏎ === ⏎ FAIL: test_add (...) # ⑤ 失败输出原样进 tool_result
assistant| tool_use edit_file({... "a * b" → "a + b"}) # 模型据失败输出发起第二轮 Edit
user | tool_result: File updated.
assistant| tool_use run_command({"command":"python3 -B fixtures/test_calc.py"})
[confirm] "python3 -B fixtures/test_calc.py" -> y(预置答案)
user | tool_result: .. ⏎ --- ⏎ Ran 2 tests in 0.000s # 全绿
assistant| 已修复 calc.py:add(a, b) 从 return a - b 改为 return a + b,测试全部通过。 # ⑥ 无 tool_use 收尾
逐项对照第 7 章结业清单:
wc -l mini-agent.mjs → 210 ≤ 220。return a - b 两处匹配被拒并提示补上下文;带函数签名的 old_string 唯一匹配才落盘。n 拒绝一次,agent 收到拒绝消息继续。全拒变体 MINI_AGENT_ANSWERS=n,n,n 实测:三次拒绝后以「测试命令被连续拒绝,我停止尝试。修改已落盘,请人工运行 python3 -B fixtures/test_calc.py 核对。」说人话收尾,exit=0,无栈。Exit code 1 / FAIL: test_add 原样进了 tool_result,下一轮就是针对性 Edit——日志里因果链可见。python3 -B fixtures/test_calc.py 输出 OK;收尾消息不含 tool_use(对应 query.ts:176–177 的 return)。[Request interrupted by user],退出码 130;此刻 calc.py 处于 a * b 中间态但语法完整(python3 -m py_compile 通过)——写入是单次 writeFileSync,不存在写了一半的文件。| mini-agent | 原作(src/) |
对应点 |
|---|---|---|
cli.mjs 双路输入 |
entrypoints/cli.tsx:294–305、:390 |
!stdin.isTTY 吸 stdin 当 prompt;argv + stdin 过滤空值换行拼接 |
cli.mjs / mini-agent.mjs 交互循环 |
cli.tsx → render(<REPL/>)(:414) |
两路皆空才进 readline |
context.mjs getSystemPrompt() |
constants/prompts.ts |
三段式:人格段 / <env> 块 / 政策句;few-shot 一词即答 |
context.mjs getContext() |
context.ts |
五键快照、手写 memoize(原作 lodash-es memoize)、抓不到就空串 |
context.mjs addCacheBreakpoints() |
context.ts / cache 断点策略 |
只给最后两条消息打 cache: true |
loop.mjs / 两处 query() |
query.ts:176–177、:234–241 |
无 tool_use 即 return 收尾;yield* query(...) 递归 |
未知工具/异常 → is_error tool_result |
query.ts:297–313、:385–493 |
五种失败共用一个出口:包成消息喂回模型,循环不崩 |
工具契约 {name, inputSchema, validateInput?, call} |
src/Tool.ts(还原树缺失,形状从 41 个调用点反推)+ tools/* |
schema 校验 → validateInput → call 的调用顺序对回 checkPermissionsAndCallTool 前两段 |
迷你 Zod(z.strictObject(...).safeParse) |
原作依赖 Zod(z.strictObject + safeParse) |
零依赖约束下手写 ~10 行迷你版,返回形状 `{success, data |
edit_file 先读后改 |
tools/FileEditTool/FileEditTool.tsx:117、:170–216;同款文案见 FileWriteTool.tsx:178 |
readFileTimestamps 表;未读拒改、读后变更拒改、找不到拒改、多处匹配拒改(文案照抄原作风味) |
edit_file 函数式 replace(old, () => new) |
FileEditTool 的 applyEdit |
防 new_string 里 $ 特殊串被当替换模式 |
run_command 失败即消息 |
tools/BashTool/BashTool.tsx(needsPermissions 恒 true)+ query.ts tool_result 构造点 |
非零退出不抛出,Exit code N + 输出 原样回灌 |
canUseBash / SAFE_COMMANDS |
permissions.ts:18–27、:29–46 |
八条整句白名单照抄;整句或 首词:* 前缀命中配置即放行 |
mini-agent.json(选 a 写入) |
utils/config.js 项目级 allowedTools |
前缀记忆持久化到项目目录,跨进程生效 |
| 确认弹窗只在代码里 | hooks/useCanUseTool.ts |
闸门不进系统提示;拒绝也是 tool_result,循环继续 |
INTERRUPT_MESSAGE |
utils/messages.tsx:36 |
写死的 [Request interrupted by user],Ctrl-C 打印后退出 |
OK(否则改口「没看到 OK,请人工核对」)。query.ts:182–216 的只读工具并发批调度、PersistentShell(cd 持久化)、Trust Dialog、token 计数与 190k 警告,全部省略——它们不改变内核形状。hasReadPermission / hasWritePermission)未实现。run_command 每次起新进程(spawnSync shell 模式),不保留 shell 状态。-B 是不写 .pyc:同秒时间戳会吃到陈旧字节码(实测踩过),这与原作无关,是 fixture 自身的坑。z.strictObject),把「模型生成参数不可靠」挡在工具调用之前。本书练习在零依赖约束下用 ~10 行手写迷你版替代。